Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to scriptc will be documented in this file.

## Unreleased

### Features

- **Native MIDI messaging.** `node:midi` (API-compatible with node-midi/@julusian/midi) enumerates ports, opens inputs and outputs including virtual ports, sends raw messages, and receives time-stamped messages through the `"message"` event on the dependency-free event loop. Backends bind each platform's MIDI stack — ALSA on Linux, CoreMIDI on macOS, WinMM on Windows — and are linked only into binaries that use the surface. An open input holds the loop alive like a bound `dgram` socket; the runtime is byte-transparent and does not parse MIDI semantics. `openVirtualPort` is POSIX-only (WinMM has no user-space virtual ports), and any MIDI surface on `wasm32-wasi` refuses before linking with `SC3002`.

<!-- release:start -->

## 0.0.30
Expand Down
2 changes: 1 addition & 1 deletion docs/src/app/how-it-works/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ fib.ir.json

- **Memory** — values are reference-counted; an acyclic value is freed the moment its last reference drops. Reference cycles are collected at deterministic points by a cycle collector, not a concurrent GC. There are no GC pauses and no tracing heap.
- **Concurrency** — `async`/`await` runs on stackful fibers with JS-exact scheduling: microtasks drain in the same order Node's do, timers fire in the same order, and the event loop (kqueue on macOS, epoll on Linux) has no external dependencies.
- **The server stack** — `net`, `http`, `https`, `tls` (vendored mbedTLS), `dgram`, `dns` are native implementations on that same loop.
- **The server stack** — `net`, `http`, `https`, `tls` (vendored mbedTLS), `dgram`, `dns` are native implementations on that same loop, as is `midi` (ALSA/CoreMIDI/WinMM, linked only into binaries that use it).
- **Numbers** — JS-exact f64 semantics, including shortest-roundtrip number-to-string formatting fuzz-verified against Node's output.
- **Regular expressions** — the same ECMAScript-exact bytecode interpreter QuickJS uses, linked only into regex-using binaries.

Expand Down
2 changes: 1 addition & 1 deletion docs/src/app/introduction/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ The static surface covers the language and the standard library real programs us

- **The language** — classes with single inheritance and dynamic dispatch, closures with JS capture semantics, generic function declarations (monomorphized), discriminated unions driven by TypeScript's own narrowing, `async`/`await` with JS-exact scheduling, exceptions with `finally`, destructuring, spread, optional/default/rest parameters, getters and setters, iterators, template literals, bitwise operators with JS-exact ToInt32 semantics, and the static slice of regular expressions.
- **The standard library** — strings with UTF-16-exact surface semantics, arrays, `Map` and `Set` with JS-exact ordering, read-only `Date` values and calendar getters, `JSON` with runtime-validated casts, `Math`, typed arrays and `Buffer`, `Error` hierarchies with typed `catch`.
- **Node's API surface** — `fs` (sync and promises), `path`, `process`, `child_process`, `os`, `crypto`, `url`/`URL`, `zlib`, timers and signal handlers on a dependency-free event loop, and the server stack: `net`, `http`, `https`, `tls`, `dgram`, `dns`, `readline`. Real servers compile:
- **Node's API surface** — `fs` (sync and promises), `path`, `process`, `child_process`, `os`, `crypto`, `url`/`URL`, `zlib`, timers and signal handlers on a dependency-free event loop, the server stack: `net`, `http`, `https`, `tls`, `dgram`, `dns`, `readline`, and native `midi` (raw MIDI messaging over the same loop). Real servers compile:

```ts:server.ts
import { createServer } from "node:http";
Expand Down
10 changes: 10 additions & 0 deletions docs/src/app/limitations/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,16 @@ const who = process.argv.length > 2 ? process.argv[2] : "world";

The production <code>wasm32-wasi</code> target supports the complete executable language tier through LLVM: async/await, promises, generators, timers and other portable event-loop work, stdin/readline, filesystem callbacks and promises, and the <code>--dynamic</code> island. Portable WASI Preview 1 has no socket, process-spawn, OS-signal, network-interface, or filesystem-notification capabilities, so networking/fetch, child processes, signal APIs, <code>os.networkInterfaces()</code>, and <code>fs.watch</code> are rejected before linking with <code>SC3002</code>. <code>--sanitize</code>, native FFI, and library-mode archive builds are also unavailable. Filesystem access is bounded by the host's preopens; <code>scriptc run</code> exposes the current working directory and <code>/tmp</code>. See [Platform Support](/platforms) for build and run details.

## MIDI limits

`node:midi` is raw MIDI messaging, modeled on node-midi/@julusian/midi — enumerate ports, open input/output (including virtual ports), send raw messages, and receive time-stamped messages via the `"message"` event.

- **The runtime is byte-transparent.** It carries raw message bytes (Note On/Off, CC, Program Change, Pitch Bend, SysEx as a byte run) and neither parses nor validates MIDI semantics. Higher-level semantic events (`noteon`, `cc`, …), MIDI file parsing, sequencing/clock scheduling, and MIDI 2.0 / UMP are out of scope.
- **Virtual ports are POSIX-only.** `openVirtualPort` works on Linux (ALSA) and macOS (CoreMIDI); on Windows WinMM it fails at runtime with a clear error, because WinMM has no user-space virtual ports. See [Platform Support](/platforms).
- **No MIDI on WASI.** WASI Preview 1 has no MIDI capability, so any `node:midi` surface is rejected before linking with `SC3002`. Browser Web MIDI is a separate runtime the WASI target does not cover.
- **`on`/`once` accept only the `"message"` event**, with a `(deltaTime, message)` listener. `deltaTime` is seconds since the previous message on that input (`0` for the first) and is inherently nondeterministic — a differential test must never print it.
- **A Linux host without ALSA** (many CI containers) has no MIDI backend: the runtime enumerates zero ports and throws on open. The hardware-free loopback tests use a virtual-port pair on a capable host.

## Tooling gaps

- `scriptc run` does not forward extra CLI arguments to the program — `build` and invoke the binary directly.
Expand Down
38 changes: 38 additions & 0 deletions docs/src/app/platforms/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,44 @@ WASI is a production LLVM target with the same language tiers as the native targ

The remaining executable boundary is host capability, not language coverage. WASI Preview 1 has no portable socket, process-spawn, OS-signal, network-interface, or filesystem-notification APIs. Networking/fetch, child processes, signal APIs, <code>os.networkInterfaces()</code>, and <code>fs.watch</code> therefore fail before linking with diagnostic <code>SC3002</code>. <code>--sanitize</code>, native FFI, and library-mode archive builds are unavailable too. Filesystem behavior is bounded by the host's preopens, and process/OS introspection follows WASI's reduced model.

## MIDI (`node:midi`)

Raw MIDI messaging (`node:midi`, API-compatible with node-midi/@julusian/midi) is a native runtime unit linked only into binaries that use it. Each platform binds its own MIDI stack, so the availability is per target:

<table>
<thead>
<tr>
<th>Platform</th>
<th>Backend</th>
<th>Virtual ports</th>
</tr>
</thead>
<tbody>
<tr>
<td>Linux</td>
<td>ALSA sequencer (<code>libasound</code>, linked as <code>-lasound</code>)</td>
<td>Yes — a native ALSA port other clients connect to</td>
</tr>
<tr>
<td>macOS</td>
<td>CoreMIDI (<code>-framework CoreMIDI</code>)</td>
<td>Yes — <code>MIDISourceCreate</code>/<code>MIDIDestinationCreate</code></td>
</tr>
<tr>
<td>Windows</td>
<td>WinMM (<code>winmm.lib</code>)</td>
<td>No — WinMM has no user-space virtual ports; <code>openVirtualPort</code> fails at runtime with a clear error</td>
</tr>
<tr>
<td>WASI</td>
<td>None</td>
<td>No — any midi surface fences before linking with <code>SC3002</code></td>
</tr>
</tbody>
</table>

An open Input is a live pollable source that holds the event loop alive (like a bound `dgram` socket); an Output is fire-and-forget. Port enumeration (`getPortCount`/`getPortName`) works on a fresh handle before `openPort`. The runtime is byte-transparent — it neither parses nor validates MIDI message semantics. Note that a Linux host without an ALSA sound stack (many CI containers) enumerates zero ports and throws on open.

## Cross-target limits

- `--sanitize` is a host-build lane.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"devDependencies": {
"@types/node": "^24.0.0",
"eslint": "^9.20.0",
"midi": "npm:@julusian/midi@^3.8.1",
"tsx": "^4.19.0",
"typescript": "5.9.3",
"typescript-eslint": "^8.24.0",
Expand Down
45 changes: 44 additions & 1 deletion packages/compiler/ambient/scriptc-node-fallback.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3032,6 +3032,47 @@ declare module "node:dns" {
export * from "dns";
}

/* node:midi — raw MIDI messaging over the event loop (scr_midi.c, linked
* only into using binaries — the moduleUsesMidi switch). API-compatible
* with node-midi/@julusian/midi so the Node differential baseline is a
* real, installable package. Input is a live pollable source (an open
* port holds the loop alive, like a bound dgram socket); Output is
* fire-and-forget (send never holds the loop). Port enumeration
* (getPortCount/getPortName) works on a fresh handle before openPort —
* enumerate then open, like node-midi. openVirtualPort is POSIX-only and
* fences at runtime on Windows (WinMM has no user-space virtual ports).
* on/once accept ONLY the "message" event with a (deltaTime, message)
* handler; message bytes arrive as a number[] with deltaTime (seconds
* since the previous message, 0 for the first) as the leading argument —
* the node-midi callback shape. sendMessage takes an array literal or a
* Uint8Array; the runtime is byte-transparent (it neither parses nor
* validates MIDI semantics). */
declare module "midi" {
export class Input {
getPortCount(): number;
getPortName(port: number): string;
openPort(port: number): void;
openVirtualPort(name: string): void;
closePort(): void;
isPortOpen(): boolean;
ignoreTypes(sysex: boolean, timing: boolean, activeSensing: boolean): void;
on(event: "message", listener: (deltaTime: number, message: number[]) => void): void;
once(event: "message", listener: (deltaTime: number, message: number[]) => void): void;
}
export class Output {
getPortCount(): number;
getPortName(port: number): string;
openPort(port: number): void;
openVirtualPort(name: string): void;
closePort(): void;
isPortOpen(): boolean;
sendMessage(message: number[] | Uint8Array): void;
}
}
declare module "node:midi" {
export * from "midi";
}

/* node:worker_threads — the MAIN-THREAD slice only. A compiled binary is
* always the main thread (no JS-engine thread machinery exists), so
* isMainThread lowers to `true` and threadId to 0 — Node's main-thread
Expand Down Expand Up @@ -3133,7 +3174,9 @@ declare module "node:async_hooks" {
* checks annotated listener parameters against it (unannotated non-empty
* parameter lists have no static types and fence at the registration). */
declare module "events" {
class EventEmitter {
// Node 24 makes EventEmitter generic; the native surface remains
// intentionally event-name agnostic, but accepts that type argument.
class EventEmitter<T = any> {
constructor();
static defaultMaxListeners: number;
on(eventName: string, listener: (...args: any[]) => void): this;
Expand Down
28 changes: 27 additions & 1 deletion packages/compiler/src/backend/cc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,12 @@ export interface CcOptions {
* on the IR): compiles scr_dgram.c into the binary — the net gating
* precedent, so dgram-free binaries keep their exact link line. */
dgram?: boolean;
/** The program uses the node:midi surface (moduleUsesMidi on the IR):
* compiles scr_midi.c into the binary and links the platform MIDI stack
* (ALSA seq on Linux where libasound is present, CoreMIDI on macOS, WinMM
* on Windows) — the dgram gating precedent, so midi-free binaries keep
* their exact link line. */
midi?: boolean;
/** The program uses fs.watch (moduleUsesFsWatch on the IR): compiles
* scr_watch.c into the binary — the net gating precedent, so watch-free
* binaries keep their exact link line. */
Expand Down Expand Up @@ -3595,7 +3601,7 @@ export async function compileC(opts: CcOptions): Promise<void> {
// platform, so all three link whenever a poller-using unit does and
// the others cost nothing (ws2_32 rides the unconditional win32 libs
// above).
...(net || opts.dgram
...(net || opts.dgram || opts.midi
? [
rt(join(rtDir, "scr_loop_kqueue.c")),
rt(join(rtDir, "scr_loop_epoll.c")),
Expand All @@ -3606,6 +3612,26 @@ export async function compileC(opts: CcOptions): Promise<void> {
...(http ? [rt(join(rtDir, "scr_http.c"))] : []),
...(opts.http2 ?? false ? [rt(join(rtDir, "scr_http2.c"))] : []),
...(opts.dgram ? [rt(join(rtDir, "scr_dgram.c"))] : []),
// node:midi (scr_midi.c) + the platform MIDI stack. The runtime's ALSA
// backend is guarded by __has_include(<alsa/asoundlib.h>): on a Linux
// host with libasound-dev it compiles the ALSA seq path and needs
// -lasound; without the header it compiles a stub that references no
// snd_* symbols, so -lasound must be withheld or the link fails. The
// host header probe below matches that compile-time guard (the default
// host-target path; a cross-compile to Linux keys off the target sysroot
// header at compile time and may need the flag threaded explicitly).
...(opts.midi
? [
rt(join(rtDir, "scr_midi.c")),
...(targetPlatform(driver) === "darwin"
? ["-framework", "CoreMIDI", "-framework", "CoreFoundation"]
: targetPlatform(driver) === "win32"
? ["-lwinmm"]
: targetPlatform(driver) === "linux" && existsSync("/usr/include/alsa/asoundlib.h")
? ["-lasound"]
: []),
]
: []),
...(opts.watch ? [rt(join(rtDir, "scr_watch.c"))] : []),
...(opts.nodeTest ? [rt(join(rtDir, "scr_test.c"))] : []),
// The CA-store unit rides its own gate OR the tls one: scr_tls.c
Expand Down
48 changes: 48 additions & 0 deletions packages/compiler/src/backend/emission/emit-exprs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4296,6 +4296,54 @@ export function emitExpr(E: CEmitter, e: IrExpr): Temp {
E.line(`scr_dns_lookup(${arg(0)}, ${arg(1)}, ${cb.name}, &${adapter});${E.srcComment(e.loc)}`);
return { name: "", type: e.type };
}
// node:midi (scr_midi.c + the loop's midi hook — linked only when
// these appear on the IR; moduleUsesMidi is the switch). Handles
// and byte payloads are BORROWED; the onMessage CALLBACK MOVES into
// the input's registry. An open input port holds the loop live
// (usesTimers) — a source of pending messages, like a bound socket.
case "midi.newInput":
return finish(`scr_midi_input_new()`);
case "midi.newOutput":
return finish(`scr_midi_output_new()`);
case "midi.portCount":
return finish(`scr_midi_port_count(${arg(0)}, ${arg(1)})`);
case "midi.portName":
return finish(`scr_midi_port_name(${arg(0)}, ${arg(1)})`);
case "midi.openPort":
// Opening an INPUT makes the loop live; an OUTPUT does not.
if (e.args[0]!.type.kind === "midiInput") E.usesTimers = true;
return finish(`scr_midi_open_port(${arg(0)}, ${arg(1)})`);
case "midi.openVirtual":
if (e.args[0]!.type.kind === "midiInput") E.usesTimers = true;
return finish(`scr_midi_open_virtual(${arg(0)}, ${arg(1)})`);
case "midi.closePort":
E.line(`scr_midi_close_port(${arg(0)});${E.srcComment(e.loc)}`);
return { name: "", type: e.type };
case "midi.isOpen":
return finish(`scr_midi_is_open(${arg(0)})`);
case "midi.ignoreTypes":
E.line(`scr_midi_ignore_types(${arg(0)}, ${arg(1)}, ${arg(2)}, ${arg(3)});${E.srcComment(e.loc)}`);
return { name: "", type: e.type };
case "midi.sendArray":
return finish(`scr_midi_send_array(${arg(0)}, ${arg(1)})`);
case "midi.sendBytes":
return finish(`scr_midi_send_bytes(${arg(0)}, ${arg(1)})`);
case "midi.onMessage": {
// The message listener receives (deltaTime: f64, message:
// number[]); the runtime invokes the moved-in closure through
// the per-arity adapter picked by the declared param count.
E.usesTimers = true; // a listening input holds the loop open
const cbT = e.args[1]!.type;
if (cbT.kind !== "func") throw new Error("emitter bug: midi.onMessage callback not a func");
const cb = args[1]!;
E.moveTemp(cb);
const adapter =
cbT.params.length === 0 ? "scr_midi_msg_thunk0"
: cbT.params.length === 1 ? "scr_midi_msg_thunk1"
: "scr_midi_msg_thunk2";
E.line(`scr_midi_on_message(${arg(0)}, ${cb.name}, &${adapter}, ${arg(2)});${E.srcComment(e.loc)}`);
return { name: "", type: e.type };
}
// node:test (scr_test.c — linked only when these appear on the
// IR; moduleUsesNodeTest is the switch). Strings borrowed,
// callbacks MOVE. Registrations keep the loop-run emitted
Expand Down
20 changes: 20 additions & 0 deletions packages/compiler/src/backend/emission/emit-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ export function cType(t: IrType): string {
return "ScrH2Stream *";
case "dgramSocket":
return "ScrDgramSocket *";
case "midiInput":
return "ScrMidiInput *";
case "midiOutput":
return "ScrMidiOutput *";
case "testCtx":
return "ScrTestCtx *";
case "httpReq":
Expand Down Expand Up @@ -164,6 +168,10 @@ export function retainCallC(type: IrType, expr: string): string {
return `scr_http2_stream_retain(${expr})`;
case "dgramSocket":
return `scr_dgram_retain(${expr})`;
case "midiInput":
return `scr_midi_input_retain(${expr})`;
case "midiOutput":
return `scr_midi_output_retain(${expr})`;
case "testCtx":
return `scr_testctx_retain(${expr})`;
case "httpReq":
Expand Down Expand Up @@ -245,6 +253,10 @@ export function releaseCallC(type: IrType, expr: string): string {
return `scr_http2_stream_release(${expr})`;
case "dgramSocket":
return `scr_dgram_release(${expr})`;
case "midiInput":
return `scr_midi_input_release(${expr})`;
case "midiOutput":
return `scr_midi_output_release(${expr})`;
case "testCtx":
return `scr_testctx_release(${expr})`;
case "httpReq":
Expand Down Expand Up @@ -320,6 +332,8 @@ export function boxKindC(t: IrType): string {
case "http2Session":
case "http2Stream":
case "dgramSocket":
case "midiInput":
case "midiOutput":
case "testCtx":
case "httpReq":
case "httpRes":
Expand Down Expand Up @@ -403,6 +417,10 @@ export function vAdapters(t: IrType): { retain: string; release: string } {
return { retain: "scr_http2_stream_retain_v", release: "scr_http2_stream_release_v" };
case "dgramSocket":
return { retain: "scr_dgram_retain_v", release: "scr_dgram_release_v" };
case "midiInput":
return { retain: "scr_midi_input_retain_v", release: "scr_midi_input_release_v" };
case "midiOutput":
return { retain: "scr_midi_output_retain_v", release: "scr_midi_output_release_v" };
case "testCtx":
return { retain: "scr_testctx_retain_v", release: "scr_testctx_release_v" };
case "httpReq":
Expand Down Expand Up @@ -526,6 +544,8 @@ export function elemKindC(elem: IrType): string {
case "http2Session":
case "http2Stream":
case "dgramSocket":
case "midiInput":
case "midiOutput":
case "testCtx":
case "httpReq":
case "httpRes":
Expand Down
Loading
Loading