@unicity-astrid/build 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # @unicity-astrid/build
2
+
3
+ [![License: MIT OR Apache-2.0](https://img.shields.io/badge/License-MIT%20OR%20Apache--2.0-blue.svg)](../../LICENSE-MIT)
4
+ [![Node: >=20](https://img.shields.io/badge/Node-%3E%3D20-blue)](https://nodejs.org)
5
+
6
+ **The compiler that turns a TypeScript class into a WASM capsule.**
7
+
8
+ JavaScript counterpart of [`astrid-sdk-macros`](https://github.com/unicity-astrid/sdk-rust/tree/main/astrid-sdk-macros). Rust uses a proc macro that runs at `cargo build` time; JavaScript needs the same work done by external tooling that runs at `astrid build` time. This package is that tooling.
9
+
10
+ Do not depend on this package directly from capsule code. It is invoked by the Rust kernel's `astrid-build` binary when it detects a `package.json + Capsule.toml` project, and emits the `wasm32-wasip2` component that the Rust side packs into the `.capsule` archive.
11
+
12
+ ## Pipeline
13
+
14
+ ```
15
+ sdk-js/packages/astrid-build/src/index.mjs <project-dir> --out <wasm-path>
16
+
17
+ 1. Read package.json (name, version)
18
+ 2. wit-events codegen: walk project/wit/, emit project/gen/*.d.ts mirroring `wit_events!`
19
+ 3. tsc: compile src/*.ts → dist/*.js, using project's tsconfig.json
20
+ 4. Emit gen/_entry.src.mjs that imports the user's compiled entry,
21
+ constructs the SDK bridge, re-exports the four WIT export names
22
+ 5. esbuild bundle: gen/_entry.src.mjs + @unicity-astrid/sdk → gen/_entry.mjs
23
+ (one self-contained ESM file, "astrid:*" specifiers marked external)
24
+ 6. ComponentizeJS programmatic API: gen/_entry.mjs + wit/ → target/<name>.wasm
25
+ (with disableFeatures: all five, so the output has zero WASI imports)
26
+ 7. Print {wasmPath, bytes} as the final stdout line for the caller to parse
27
+ ```
28
+
29
+ The Rust `astrid-build` then calls `pack_capsule_archive` on the resulting `.wasm` to produce a `dist/<name>.capsule` archive structurally identical to a Rust-built capsule.
30
+
31
+ ## What the bridge generates
32
+
33
+ The `gen/_entry.mjs` is a thin wrapper that:
34
+
35
+ - **Imports the user code** (decorators fire and populate the SDK registry)
36
+ - **Builds the bridge** via `createBridge()` from `@unicity-astrid/sdk/runtime`
37
+ - **Re-exports the four WIT-required guest functions**: `astridHookTrigger`, `run`, `astridInstall`, `astridUpgrade`
38
+
39
+ The bridge runtime in `@unicity-astrid/sdk` dispatches:
40
+
41
+ - `tool_describe` → lazy-built aggregated schema list (`{ tools: [...], description: "..." }`)
42
+ - `tool_execute_<name>` → optional KV `__state` load → user handler → optional state save → IPC publish to `tool.v1.execute.<name>.result` → return `{ action: "continue", data: undefined }`
43
+ - interceptor topic name → user handler → return result via `capsule-result.data`
44
+ - command name → same as interceptor
45
+
46
+ This mirrors `astrid-sdk-macros::capsule_impl` exactly, including the runnable-vs-hook-driven duality from `#[astrid::run]`.
47
+
48
+ ## Compile-time enforcement
49
+
50
+ Decorator violations are caught at **registration time** (when the class first evaluates), not deferred to runtime:
51
+
52
+ - Two `@install` methods on one class → throws at module load
53
+ - Two `@upgrade` methods → throws
54
+ - Two `@run` methods → throws
55
+ - Two `@tool("name")` with the same name → throws
56
+ - Two `@interceptor("topic")` with the same topic → throws
57
+ - `@install` / `@upgrade` / `@run` on a private or static method → throws
58
+
59
+ Schema input types are typechecked by `tsc` at build time. WIT event types are checked against generated `gen/<file>.d.ts`.
60
+
61
+ ## Configuration
62
+
63
+ The orchestrator currently accepts:
64
+
65
+ | Flag | Description |
66
+ |---|---|
67
+ | `<project-dir>` | Path to the capsule project (required) |
68
+ | `--out <wasm-path>` | Where to write the componentized wasm (defaults to `<project>/target/<name>.wasm`) |
69
+
70
+ The Rust kernel locates this script via:
71
+
72
+ 1. `$ASTRID_JS_BUILD` environment variable (absolute path override, dev-only)
73
+ 2. `<project>/node_modules/@unicity-astrid/build/src/index.mjs` (walked up like Node's resolver, so npm-workspaces hoisting works)
74
+
75
+ ## ComponentizeJS configuration
76
+
77
+ The orchestrator pins:
78
+
79
+ - `worldName: "capsule"` (the world declared in `astrid-capsule.wit`)
80
+ - `disableFeatures: ["stdio", "random", "clocks", "http", "fetch-event"]` — strips all WASI imports so the kernel doesn't need to provide WASI. Without this, the component would fail to instantiate against the kernel's linker.
81
+
82
+ These are not configurable — they are correctness requirements, not options.
83
+
84
+ ## Development
85
+
86
+ ```bash
87
+ npm install
88
+ node src/index.mjs ../../examples/test-capsule
89
+ ```
90
+
91
+ ## License
92
+
93
+ Dual MIT/Apache-2.0. See [LICENSE-MIT](../../LICENSE-MIT) and [LICENSE-APACHE](../../LICENSE-APACHE).
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@unicity-astrid/build",
3
+ "version": "0.1.0",
4
+ "description": "Build orchestrator that turns a TS/JS Astrid capsule project into a wasm32-wasip2 component.",
5
+ "type": "module",
6
+ "main": "./src/index.mjs",
7
+ "bin": {
8
+ "astrid-js-build": "./src/index.mjs"
9
+ },
10
+ "files": [
11
+ "src"
12
+ ],
13
+ "scripts": {
14
+ "build": "echo 'no build step — pure ESM'"
15
+ },
16
+ "dependencies": {
17
+ "@bytecodealliance/componentize-js": "^0.18.0",
18
+ "esbuild": "^0.24.0",
19
+ "typescript": "^5.6.0"
20
+ },
21
+ "engines": {
22
+ "node": ">=20"
23
+ }
24
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,481 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Astrid JS/TS capsule build orchestrator.
4
+ *
5
+ * Invoked by `core/crates/astrid-build/src/js.rs`. Reads a project
6
+ * directory, produces a wasm32-wasip2 component.
7
+ *
8
+ * Pipeline:
9
+ * 1. Read package.json metadata (name, version, main / source entry).
10
+ * 2. tsc compile the project (if tsconfig.json present).
11
+ * 3. Resolve the SDK's `wit/` directory so componentize sees astrid-capsule.wit.
12
+ * 4. Emit gen/_entry.mjs: imports the user's compiled entry (decorators
13
+ * fire), constructs the bridge, re-exports the four WIT export names.
14
+ * 5. Bundle gen/_entry.mjs (resolves @unicity-astrid/sdk into the bundle so
15
+ * componentize sees one self-contained ESM file).
16
+ * 6. Invoke ComponentizeJS programmatic API.
17
+ * 7. Write the .wasm to the requested output path.
18
+ *
19
+ * CLI: `astrid-js-build <project-dir> --out <wasm-path>`
20
+ */
21
+
22
+ import { componentize } from "@bytecodealliance/componentize-js";
23
+ import * as esbuild from "esbuild";
24
+ import { codegenWitEvents } from "./wit-codegen.mjs";
25
+ import { existsSync } from "node:fs";
26
+ import { mkdir, readFile, writeFile, stat, rm, copyFile, readdir } from "node:fs/promises";
27
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
28
+ import { fileURLToPath } from "node:url";
29
+ import { spawnSync } from "node:child_process";
30
+ import { createRequire } from "node:module";
31
+ import ts from "typescript";
32
+
33
+ const HERE = dirname(fileURLToPath(import.meta.url));
34
+ const SDK_PKG_DIR = resolve(HERE, "..", "..", "astrid-sdk");
35
+ // Canonical host ABI lives in the unicity-astrid/wit submodule (mounted at
36
+ // repo-root/contracts/). Post per-domain WIT split (PR #752), each domain
37
+ // is a separate `astrid:<domain>/host@1.0.0` package plus the foundation
38
+ // `astrid:io/*@1.0.0` interfaces; componentize-js resolves the world the
39
+ // capsule declares by reading every file in this directory.
40
+ const REPO_ROOT = resolve(HERE, "..", "..", "..");
41
+ const CANONICAL_WIT_DIR = resolve(REPO_ROOT, "contracts", "host");
42
+
43
+ function die(msg) {
44
+ console.error(`astrid-js-build: ${msg}`);
45
+ process.exit(1);
46
+ }
47
+
48
+ function parseArgs(argv) {
49
+ if (argv.length < 1) die("usage: astrid-js-build <project-dir> [--out <wasm-path>]");
50
+ const projectDir = resolve(argv[0]);
51
+ let out;
52
+ for (let i = 1; i < argv.length; i++) {
53
+ if (argv[i] === "--out" && i + 1 < argv.length) {
54
+ out = resolve(argv[++i]);
55
+ } else {
56
+ die(`unknown argument: ${argv[i]}`);
57
+ }
58
+ }
59
+ return { projectDir, out };
60
+ }
61
+
62
+ async function readJson(path) {
63
+ try {
64
+ return JSON.parse(await readFile(path, "utf8"));
65
+ } catch (err) {
66
+ die(`failed to read ${path}: ${err.message}`);
67
+ }
68
+ }
69
+
70
+ async function resolveProjectMetadata(projectDir) {
71
+ const pkgPath = join(projectDir, "package.json");
72
+ if (!existsSync(pkgPath)) die(`no package.json at ${pkgPath}`);
73
+ const pkg = await readJson(pkgPath);
74
+ if (typeof pkg.name !== "string" || pkg.name.length === 0) {
75
+ die("package.json must have a non-empty 'name'");
76
+ }
77
+ return { name: pkg.name, version: pkg.version ?? "0.0.0", pkg };
78
+ }
79
+
80
+ function compileTypeScript(projectDir) {
81
+ const tsconfigPath = join(projectDir, "tsconfig.json");
82
+ if (!existsSync(tsconfigPath)) {
83
+ console.log("astrid-js-build: no tsconfig.json — skipping tsc compile");
84
+ return null;
85
+ }
86
+ // Use tsc CLI for full project-references support. The programmatic API
87
+ // would require us to re-implement project references; the CLI is the
88
+ // contract-tested path.
89
+ const tscBin = createRequire(import.meta.url).resolve("typescript/bin/tsc");
90
+ const result = spawnSync(process.execPath, [tscBin, "-p", projectDir], { stdio: "inherit" });
91
+ if (result.status !== 0) die("tsc failed");
92
+ // Read tsconfig to find outDir.
93
+ const raw = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
94
+ if (raw.error) die(`tsconfig parse error: ${raw.error.messageText}`);
95
+ const parsed = ts.parseJsonConfigFileContent(raw.config, ts.sys, projectDir);
96
+ return parsed.options.outDir ?? join(projectDir, "dist");
97
+ }
98
+
99
+ function resolveEntry(projectDir, outDir) {
100
+ // Prefer compiled JS in outDir; fall back to package.json main.
101
+ const candidates = [
102
+ outDir ? join(outDir, "index.js") : undefined,
103
+ join(projectDir, "dist", "index.js"),
104
+ join(projectDir, "index.js"),
105
+ join(projectDir, "index.mjs"),
106
+ ].filter(Boolean);
107
+ for (const c of candidates) {
108
+ if (existsSync(c)) return c;
109
+ }
110
+ die("could not locate compiled entry. Expected dist/index.js after tsc or index.js/.mjs at project root.");
111
+ }
112
+
113
+ function resolveSdkRuntime() {
114
+ const candidate = join(SDK_PKG_DIR, "dist", "runtime", "index.js");
115
+ if (!existsSync(candidate)) {
116
+ die(
117
+ `SDK runtime not found at ${candidate}. ` +
118
+ `Build @unicity-astrid/sdk first (npx tsc -b packages/astrid-sdk in the workspace root).`,
119
+ );
120
+ }
121
+ return candidate;
122
+ }
123
+
124
+ async function emitEntry(projectDir, userEntry) {
125
+ const genDir = join(projectDir, "gen");
126
+ await mkdir(genDir, { recursive: true });
127
+ const entryPath = join(genDir, "_entry.src.mjs");
128
+
129
+ // ComponentizeJS's splicer rejects file:// URL imports — it treats the
130
+ // entire URL as a relative path. Use POSIX-style relative paths instead.
131
+ const userEntryRel = posixRelative(genDir, userEntry);
132
+ const sdkRuntimeRel = posixRelative(genDir, resolveSdkRuntime());
133
+
134
+ const entrySource = `// Auto-generated by @unicity-astrid/build. Do not edit by hand.
135
+ //
136
+ // This file gets bundled with esbuild before componentize. Importing the
137
+ // user module fires the @capsule / @tool / @install / @upgrade decorators,
138
+ // which populate the SDK registry. We then build the bridge and re-export
139
+ // the four WIT-required guest export names.
140
+
141
+ import "${userEntryRel}";
142
+ import { createBridge } from "${sdkRuntimeRel}";
143
+
144
+ const bridge = createBridge();
145
+
146
+ export function astridHookTrigger(action, payload) {
147
+ return bridge.astridHookTrigger(action, payload);
148
+ }
149
+
150
+ export function run() {
151
+ bridge.run();
152
+ }
153
+
154
+ export function astridInstall() {
155
+ bridge.astridInstall();
156
+ }
157
+
158
+ export function astridUpgrade() {
159
+ bridge.astridUpgrade();
160
+ }
161
+ `;
162
+ await writeFile(entryPath, entrySource);
163
+ return entryPath;
164
+ }
165
+
166
+ /**
167
+ * Bundle the generated entry into a single ESM file. ComponentizeJS can't
168
+ * resolve bare-specifier imports (`@unicity-astrid/sdk`); esbuild inlines
169
+ * everything except the WIT-resolved `astrid:*` imports.
170
+ */
171
+ /**
172
+ * esbuild plugin that resolves Node builtins (`fs`, `path`, `crypto`,
173
+ * `zlib`, …) and a small allowlist of Node-flavoured packages (`ws`)
174
+ * to a synthetic module that returns throw-on-call Proxies for every
175
+ * access. The capsule runtime (StarlingMonkey) doesn't provide
176
+ * `node:*` builtins; libraries written for both Node and the browser
177
+ * commonly carry a `node:crypto` import for paths that never run on
178
+ * non-Node platforms. Without this plugin, those imports fail the
179
+ * bundle. With it, the bundle succeeds and any unreached path stays
180
+ * unreached at runtime — the proxy throws if a capsule actually
181
+ * touches them.
182
+ *
183
+ * Throwing lazily (on first call, not on import) matters: many
184
+ * sphere-sdk classes bind `crypto.createHmac` at top level via
185
+ * destructuring, but only call it down a code path that's never
186
+ * exercised in the capsule's actual usage (admin tooling, etc).
187
+ */
188
+ function nodeBuiltinStubPlugin() {
189
+ // Per-module named-export lists. esbuild does static analysis of
190
+ // named imports against the stub module's actual exports, so the
191
+ // stub file must explicitly declare every name that could be
192
+ // destructure-imported by an upstream library. Growing this list
193
+ // is benign — unused entries cost nothing at bundle or runtime.
194
+ // Add new entries when a build surfaces "No matching export in
195
+ // 'astrid-node-stub:<X>' for import 'Y'".
196
+ const namedExports = {
197
+ assert: ["ok", "strict", "strictEqual", "deepEqual", "deepStrictEqual", "notEqual", "fail"],
198
+ buffer: ["Buffer", "Blob", "File", "constants", "kMaxLength", "kStringMaxLength", "atob", "btoa", "isAscii", "isUtf8"],
199
+ child_process: ["spawn", "spawnSync", "exec", "execSync", "execFile", "execFileSync", "fork"],
200
+ constants: [],
201
+ crypto: ["createHmac", "createHash", "createCipher", "createCipheriv", "createDecipher", "createDecipheriv", "createSign", "createVerify", "createDiffieHellman", "createECDH", "randomBytes", "randomUUID", "randomInt", "randomFillSync", "scrypt", "scryptSync", "pbkdf2", "pbkdf2Sync", "timingSafeEqual", "constants", "webcrypto", "subtle", "X509Certificate", "KeyObject", "generateKeySync", "generateKey", "generateKeyPair", "generateKeyPairSync", "createPublicKey", "createPrivateKey", "createSecretKey", "sign", "verify", "publicEncrypt", "privateDecrypt", "privateEncrypt", "publicDecrypt", "hkdf", "hkdfSync", "getCiphers", "getHashes", "getCurves", "getRandomValues"],
202
+ dgram: ["createSocket", "Socket"],
203
+ dns: ["lookup", "resolve", "resolve4", "resolve6", "resolveTxt", "promises"],
204
+ events: ["EventEmitter", "once", "on", "captureRejectionSymbol", "defaultMaxListeners", "errorMonitor", "setMaxListeners"],
205
+ fs: ["readFileSync", "writeFileSync", "existsSync", "statSync", "lstatSync", "readdirSync", "mkdirSync", "rmSync", "rmdirSync", "unlinkSync", "renameSync", "promises", "constants", "createReadStream", "createWriteStream", "watch", "watchFile", "openSync", "closeSync", "readSync", "writeSync", "fstatSync", "ftruncateSync", "appendFileSync", "accessSync", "copyFileSync", "linkSync", "symlinkSync", "readlinkSync", "realpathSync", "utimesSync", "chmodSync", "chownSync"],
206
+ "fs/promises": ["readFile", "writeFile", "stat", "lstat", "readdir", "mkdir", "rm", "rmdir", "unlink", "rename", "open", "access", "copyFile", "appendFile", "link", "symlink", "readlink", "realpath", "utimes", "chmod", "chown"],
207
+ http: ["createServer", "request", "get", "Agent", "globalAgent", "STATUS_CODES", "METHODS"],
208
+ https: ["createServer", "request", "get", "Agent", "globalAgent"],
209
+ module: ["createRequire", "builtinModules", "Module", "isBuiltin"],
210
+ net: ["createServer", "createConnection", "connect", "Socket", "Server", "isIP", "isIPv4", "isIPv6"],
211
+ os: ["arch", "platform", "type", "release", "hostname", "homedir", "tmpdir", "cpus", "endianness", "freemem", "totalmem", "uptime", "userInfo", "EOL", "constants"],
212
+ path: ["join", "resolve", "basename", "dirname", "extname", "sep", "delimiter", "isAbsolute", "normalize", "relative", "parse", "format", "posix", "win32", "toNamespacedPath"],
213
+ perf_hooks: ["performance", "PerformanceObserver"],
214
+ process: ["env", "argv", "platform", "arch", "version", "versions", "stdout", "stderr", "stdin", "cwd", "chdir", "exit", "nextTick", "pid", "ppid"],
215
+ querystring: ["parse", "stringify", "escape", "unescape"],
216
+ readline: ["createInterface", "Interface"],
217
+ stream: ["Readable", "Writable", "Transform", "PassThrough", "Duplex", "pipeline", "finished", "promises"],
218
+ "stream/web": ["ReadableStream", "WritableStream", "TransformStream", "ByteLengthQueuingStrategy", "CountQueuingStrategy"],
219
+ tls: ["createServer", "connect", "createSecureContext", "TLSSocket", "Server", "checkServerIdentity", "rootCertificates", "DEFAULT_CIPHERS", "DEFAULT_MIN_VERSION", "DEFAULT_MAX_VERSION"],
220
+ tty: ["isatty", "ReadStream", "WriteStream"],
221
+ url: ["URL", "URLSearchParams", "fileURLToPath", "pathToFileURL", "format", "parse", "resolve", "domainToASCII", "domainToUnicode"],
222
+ util: ["promisify", "inspect", "format", "deprecate", "callbackify", "TextDecoder", "TextEncoder", "types", "isDeepStrictEqual"],
223
+ vm: ["createContext", "runInContext", "runInNewContext", "runInThisContext", "Script"],
224
+ worker_threads: ["Worker", "isMainThread", "parentPort", "workerData", "threadId"],
225
+ zlib: ["gzip", "gunzip", "gzipSync", "gunzipSync", "deflate", "inflate", "deflateSync", "inflateSync", "createGzip", "createGunzip", "createDeflate", "createInflate", "constants", "brotliCompress", "brotliDecompress", "brotliCompressSync", "brotliDecompressSync"],
226
+ ws: ["WebSocket", "WebSocketServer", "Server", "createWebSocketStream"],
227
+ };
228
+ return {
229
+ name: "astrid-node-builtin-stub",
230
+ setup(build) {
231
+ build.onResolve({ filter: /^(node:)?[^./].*$/ }, (args) => {
232
+ const stripped = args.path.startsWith("node:")
233
+ ? args.path.slice(5)
234
+ : args.path;
235
+ if (Object.prototype.hasOwnProperty.call(namedExports, stripped)) {
236
+ return { path: stripped, namespace: "astrid-node-stub" };
237
+ }
238
+ return null;
239
+ });
240
+ build.onLoad(
241
+ { filter: /.*/, namespace: "astrid-node-stub" },
242
+ (args) => {
243
+ const names = namedExports[args.path] ?? [];
244
+ const exportLines = names
245
+ .map((n) => `export const ${n} = makeStub(${JSON.stringify(n)});`)
246
+ .join("\n");
247
+ return {
248
+ loader: "js",
249
+ contents: `
250
+ const importPath = ${JSON.stringify(args.path)};
251
+ const throwUnavailable = (member) => {
252
+ throw new Error(
253
+ "Capsule reached node-builtin stub: " + importPath +
254
+ (member ? "." + member : "") +
255
+ " is not available on wasm32. The capsule must avoid this code path " +
256
+ "or replace the import with a web-equivalent (e.g. globalThis.crypto " +
257
+ "for WebCrypto, astrid_sdk net for sockets)."
258
+ );
259
+ };
260
+ const makeStub = (member) => new Proxy(function () {}, {
261
+ get(_target, prop) {
262
+ if (typeof prop === "symbol") return undefined;
263
+ return makeStub((member ? member + "." : "") + String(prop));
264
+ },
265
+ apply() { throwUnavailable(member); },
266
+ construct() { throwUnavailable(member); },
267
+ });
268
+ ${exportLines}
269
+ export default makeStub("");
270
+ `,
271
+ };
272
+ },
273
+ );
274
+ },
275
+ };
276
+ }
277
+
278
+ async function bundle(entryPath, projectDir) {
279
+ const genDir = dirname(entryPath);
280
+ const bundlePath = join(genDir, "_entry.mjs");
281
+ await esbuild.build({
282
+ entryPoints: [entryPath],
283
+ bundle: true,
284
+ format: "esm",
285
+ // Browser platform so esbuild honors the `browser` field in package.json
286
+ // (node-forge maps `crypto`/`buffer`/`process` to empty modules; libp2p
287
+ // maps its node:crypto-using files to .browser.js variants that use
288
+ // WebCrypto). StarlingMonkey provides WebCrypto via globalThis.crypto
289
+ // but does NOT provide node:crypto/node:events/etc., so the Node
290
+ // variants of these packages would fail at bundle time. The capsule
291
+ // runtime surface is web-like, not Node-like, on every axis that
292
+ // matters for bundling.
293
+ platform: "browser",
294
+ target: "es2022",
295
+ outfile: bundlePath,
296
+ // Mark WIT module specifiers as external so esbuild doesn't try to
297
+ // resolve them — ComponentizeJS owns those imports. Post per-domain WIT
298
+ // split, the specifiers are `astrid:<domain>/host@1.0.0` and
299
+ // `astrid:io/<iface>@1.0.0`; the bare `astrid:*` glob covers both.
300
+ external: ["astrid:*"],
301
+ // Working dir resolution: esbuild needs to look in the project's
302
+ // node_modules to find @unicity-astrid/sdk via the workspace symlink.
303
+ absWorkingDir: projectDir,
304
+ // Conditional exports: prefer ESM, then browser-conditional entries
305
+ // (Sphere SDK's `./impl/browser` etc.). `node` is intentionally
306
+ // omitted — the capsule runtime can't satisfy node:* builtins.
307
+ conditions: ["import", "module", "browser"],
308
+ // Stub Node builtins + Node-adjacent packages that may leak in from
309
+ // dual-target dependencies (libp2p, sphere-sdk, etc.). See plugin doc.
310
+ plugins: [nodeBuiltinStubPlugin()],
311
+ // Don't minify — we want readable error messages during development.
312
+ // Componentize will be the one stripping for size.
313
+ minify: false,
314
+ sourcemap: false,
315
+ });
316
+ return bundlePath;
317
+ }
318
+
319
+ function resolveWitPath() {
320
+ // Read straight from the canonical `unicity-astrid/wit` submodule. The
321
+ // kernel side (cargo-published `astrid-sys` crate) keeps an in-tree copy
322
+ // because `cargo package` only bundles files inside the crate dir; the
323
+ // JS SDK has no such constraint, so we consume the submodule directly
324
+ // and skip the drift surface entirely. Sanity-check that the per-domain
325
+ // split landed — `ipc@1.0.0.wit` is the canary file that appears on the
326
+ // new layout but never the old.
327
+ if (!existsSync(join(CANONICAL_WIT_DIR, "ipc@1.0.0.wit"))) {
328
+ die(
329
+ `canonical host WIT missing or pre-split at ${CANONICAL_WIT_DIR}. ` +
330
+ `Expected per-domain layout from unicity-astrid/wit; run 'git submodule update --init --recursive' from the sdk-js repo root.`,
331
+ );
332
+ }
333
+ return CANONICAL_WIT_DIR;
334
+ }
335
+
336
+ /**
337
+ * Stage the canonical host WIT files into a synthesized deps tree that
338
+ * componentize-js can resolve, then emit a single `astrid-sdk:capsule` world
339
+ * that imports every per-domain host package and includes every guest export
340
+ * world. Mirrors the Rust SDK's `astrid-sys` synthetic world (see
341
+ * `sdk-rust/astrid-sys/src/lib.rs`) so the two SDKs target the same world.
342
+ *
343
+ * Layout produced under `<projectDir>/gen/wit/`:
344
+ *
345
+ * capsule.wit (the synthetic world)
346
+ * deps/astrid-io/io@1.0.0.wit
347
+ * deps/astrid-fs/fs@1.0.0.wit
348
+ * deps/astrid-ipc/ipc@1.0.0.wit
349
+ * ... (one dir per per-domain package)
350
+ * deps/astrid-guest/guest@1.0.0.wit
351
+ *
352
+ * The deps/ dir is the WIT convention componentize-js / wit-bindgen uses to
353
+ * find external packages.
354
+ */
355
+ async function stageCapsuleWit(projectDir) {
356
+ const witDir = join(projectDir, "gen", "wit");
357
+ const depsDir = join(witDir, "deps");
358
+ await rm(witDir, { recursive: true, force: true });
359
+ await mkdir(depsDir, { recursive: true });
360
+
361
+ // Map of <wit-file-name> → <deps subdir name> (the bare package name
362
+ // without the version is the conventional subdir).
363
+ const hostFiles = await readdir(CANONICAL_WIT_DIR);
364
+ const stagedPkgs = [];
365
+ for (const fname of hostFiles) {
366
+ if (!fname.endsWith(".wit")) continue;
367
+ // `ipc@1.0.0.wit` → bare package "ipc"
368
+ const bare = fname.replace(/@.*$/, "");
369
+ const subdir = join(depsDir, `astrid-${bare}`);
370
+ await mkdir(subdir, { recursive: true });
371
+ await copyFile(join(CANONICAL_WIT_DIR, fname), join(subdir, fname));
372
+ stagedPkgs.push(bare);
373
+ }
374
+
375
+ const world = `// Auto-generated by @unicity-astrid/build. Do not edit by hand.
376
+ //
377
+ // Synthetic capsule world combining every frozen per-domain host import
378
+ // plus every guest export world. Mirrors the Rust SDK's astrid-sys
379
+ // synthetic world so capsules built with either SDK target the same world.
380
+
381
+ package astrid-sdk:capsule;
382
+
383
+ world capsule {
384
+ import astrid:io/error@1.0.0;
385
+ import astrid:io/poll@1.0.0;
386
+ import astrid:io/streams@1.0.0;
387
+
388
+ import astrid:fs/host@1.0.0;
389
+ import astrid:ipc/host@1.0.0;
390
+ import astrid:kv/host@1.0.0;
391
+ import astrid:net/host@1.0.0;
392
+ import astrid:http/host@1.0.0;
393
+ import astrid:sys/host@1.0.0;
394
+ import astrid:process/host@1.0.0;
395
+ import astrid:uplink/host@1.0.0;
396
+ import astrid:elicit/host@1.0.0;
397
+ import astrid:approval/host@1.0.0;
398
+ import astrid:identity/host@1.0.0;
399
+
400
+ include astrid:guest/interceptor@1.0.0;
401
+ include astrid:guest/background@1.0.0;
402
+ include astrid:guest/installable@1.0.0;
403
+ include astrid:guest/upgradable@1.0.0;
404
+ }
405
+ `;
406
+ await writeFile(join(witDir, "capsule.wit"), world);
407
+ return witDir;
408
+ }
409
+
410
+ async function runComponentize(entryPath, outPath, projectDir) {
411
+ // Sanity-check the canonical host WIT exists before staging.
412
+ resolveWitPath();
413
+ const witPath = await stageCapsuleWit(projectDir);
414
+ const t0 = performance.now();
415
+ const { component, imports } = await componentize({
416
+ sourcePath: entryPath,
417
+ witPath,
418
+ worldName: "capsule",
419
+ // Phase 0 finding: ALL features must be disabled to avoid pulling in
420
+ // WASI 0.2 imports the Astrid kernel doesn't satisfy.
421
+ disableFeatures: ["stdio", "random", "clocks", "http", "fetch-event"],
422
+ });
423
+ const elapsedMs = performance.now() - t0;
424
+ await mkdir(dirname(outPath), { recursive: true });
425
+ await writeFile(outPath, component);
426
+ const info = await stat(outPath);
427
+ return {
428
+ bytes: info.size,
429
+ componentizeSec: (elapsedMs / 1000).toFixed(2),
430
+ importCount: imports.length,
431
+ };
432
+ }
433
+
434
+ async function main() {
435
+ const { projectDir, out } = parseArgs(process.argv.slice(2));
436
+ if (!existsSync(projectDir)) die(`project directory does not exist: ${projectDir}`);
437
+
438
+ const { name } = await resolveProjectMetadata(projectDir);
439
+ const outPath = out ?? join(projectDir, "target", `${name}.wasm`);
440
+
441
+ // WIT events codegen runs BEFORE tsc so the generated .d.ts files are
442
+ // visible during type-checking. Idempotent: if there's no wit/ dir,
443
+ // returns { files: 0, types: 0 } and skips the work.
444
+ const witResult = await codegenWitEvents(projectDir);
445
+ if (witResult.files > 0) {
446
+ console.log(
447
+ `astrid-js-build: wit-events emitted ${witResult.types} type(s) from ${witResult.files} file(s)`,
448
+ );
449
+ }
450
+
451
+ const outDir = compileTypeScript(projectDir);
452
+ const userEntry = resolveEntry(projectDir, outDir);
453
+ console.log(`astrid-js-build: user entry = ${userEntry}`);
454
+ const entrySrcPath = await emitEntry(projectDir, userEntry);
455
+ console.log(`astrid-js-build: generated entry = ${entrySrcPath}`);
456
+ const bundledPath = await bundle(entrySrcPath, projectDir);
457
+ console.log(`astrid-js-build: bundled = ${bundledPath}`);
458
+ console.log("astrid-js-build: running componentize-js...");
459
+ const result = await runComponentize(bundledPath, outPath, projectDir);
460
+ console.log(
461
+ `astrid-js-build: wrote ${outPath} (${(result.bytes / 1024 / 1024).toFixed(2)} MB, ${result.componentizeSec}s, ${result.importCount} host imports)`,
462
+ );
463
+ // The kernel-side Rust caller can parse this JSON for the binary path
464
+ // without scraping logs.
465
+ console.log(
466
+ `astrid-js-build-result: ${JSON.stringify({ wasmPath: outPath, bytes: result.bytes })}`,
467
+ );
468
+ }
469
+
470
+ function posixRelative(fromDir, toFile) {
471
+ let rel = relative(fromDir, toFile);
472
+ if (sep !== "/") rel = rel.split(sep).join("/");
473
+ if (!rel.startsWith(".")) rel = "./" + rel;
474
+ return rel;
475
+ }
476
+
477
+ main().catch((err) => {
478
+ console.error("astrid-js-build: FAILED");
479
+ console.error(err?.stack ?? err);
480
+ process.exit(1);
481
+ });
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Walks a project's wit/ directory, parses each .wit file, and emits
3
+ * `gen/<file>.d.ts` files with TypeScript types for every named record,
4
+ * enum, variant, and flags.
5
+ *
6
+ * Mirror of `astrid_sdk_macros::wit_events::generate` (Rust). Naming:
7
+ * - kebab-case names → PascalCase in TS (matching the Rust output's
8
+ * PascalCase struct names).
9
+ * - kebab-case field names → snake_case (matching Rust's
10
+ * `#[serde(rename_all = "snake_case")]` on generated structs, so the
11
+ * wire format is identical regardless of which SDK produced it).
12
+ * - option<T> → T | undefined (with optional `?:` field syntax).
13
+ * - list<T> → T[].
14
+ * - tuple<T,U,...> → [T, U, ...].
15
+ * - variants → discriminated unions with `tag` + optional `value`.
16
+ * - flags → string-array of variant names.
17
+ */
18
+
19
+ import { parseWit } from "./wit-parser.mjs";
20
+ import { readdir, readFile, writeFile, mkdir } from "node:fs/promises";
21
+ import { join, basename, extname } from "node:path";
22
+
23
+ export async function codegenWitEvents(projectDir) {
24
+ const witDir = join(projectDir, "wit");
25
+ let entries;
26
+ try {
27
+ entries = await readdir(witDir, { withFileTypes: true });
28
+ } catch (err) {
29
+ if (err.code === "ENOENT") return { files: 0, types: 0 };
30
+ throw err;
31
+ }
32
+
33
+ const witFiles = entries
34
+ .filter((e) => e.isFile() && extname(e.name) === ".wit")
35
+ .map((e) => join(witDir, e.name));
36
+
37
+ if (witFiles.length === 0) return { files: 0, types: 0 };
38
+
39
+ const genDir = join(projectDir, "gen");
40
+ await mkdir(genDir, { recursive: true });
41
+
42
+ let totalTypes = 0;
43
+ for (const witPath of witFiles) {
44
+ const source = await readFile(witPath, "utf8");
45
+ const ast = parseWit(source, witPath);
46
+ const ts = emitTs(ast);
47
+ const baseName = basename(witPath, ".wit");
48
+ const outPath = join(genDir, `${baseName}.d.ts`);
49
+ await writeFile(outPath, ts);
50
+ totalTypes += ast.interfaces.reduce((sum, iface) => sum + iface.types.length, 0);
51
+ }
52
+
53
+ return { files: witFiles.length, types: totalTypes };
54
+ }
55
+
56
+ function emitTs(ast) {
57
+ const lines = [
58
+ "// Auto-generated by @unicity-astrid/build from a WIT file. Do not edit by hand.",
59
+ "// Mirror of `astrid_sdk_macros::wit_events!` output.",
60
+ "",
61
+ ];
62
+
63
+ // Per-interface namespacing prevents collisions across the canonical
64
+ // interface set — agent / approval / elicit all define `record
65
+ // response`, and all three must be addressable. Build an owner index
66
+ // so cross-interface references can fully qualify
67
+ // (e.g. `types.Message` inside the `llm` namespace).
68
+ const typeOwner = new Map();
69
+ for (const iface of ast.interfaces) {
70
+ for (const t of iface.types) {
71
+ typeOwner.set(t.name, kebabToSnake(iface.name));
72
+ }
73
+ }
74
+
75
+ for (const iface of ast.interfaces) {
76
+ const ns = kebabToSnake(iface.name);
77
+ lines.push(`/** Types generated from the \`${iface.name}\` WIT interface. */`);
78
+ lines.push(`export namespace ${ns} {`);
79
+ for (const t of iface.types) {
80
+ if (t.doc) emitDoc(lines, t.doc, " ");
81
+ if (t.kind === "record") emitRecord(lines, t, typeOwner, ns);
82
+ else if (t.kind === "enum") emitEnum(lines, t);
83
+ else if (t.kind === "variant") emitVariant(lines, t, typeOwner, ns);
84
+ else if (t.kind === "flags") emitFlags(lines, t);
85
+ lines.push("");
86
+ }
87
+ lines.push(`}`);
88
+ lines.push("");
89
+ }
90
+
91
+ return lines.join("\n");
92
+ }
93
+
94
+ function emitRecord(lines, t, typeOwner, currentNs) {
95
+ lines.push(` export interface ${kebabToPascal(t.name)} {`);
96
+ for (const f of t.fields) {
97
+ if (f.doc) emitDoc(lines, f.doc, " ");
98
+ const fieldName = kebabToSnake(f.name);
99
+ const isOption = f.type.kind === "option";
100
+ const renderedType = renderType(isOption ? f.type.inner : f.type, typeOwner, currentNs);
101
+ if (isOption) {
102
+ lines.push(` ${fieldName}?: ${renderedType};`);
103
+ } else {
104
+ lines.push(` ${fieldName}: ${renderedType};`);
105
+ }
106
+ }
107
+ lines.push(` }`);
108
+ }
109
+
110
+ function emitEnum(lines, t) {
111
+ const variants = t.cases.map((c) => JSON.stringify(kebabToSnake(c.name))).join(" | ");
112
+ lines.push(` export type ${kebabToPascal(t.name)} = ${variants};`);
113
+ }
114
+
115
+ function emitVariant(lines, t, typeOwner, currentNs) {
116
+ // Mirror serde(tag = "tag", content = "value") — see wit_events.rs
117
+ // line 113. Wire format: `{ tag: "case-name", value?: T }`.
118
+ const cases = t.cases.map((c) => {
119
+ const tag = kebabToSnake(c.name);
120
+ if (c.type === undefined) return `{ tag: ${JSON.stringify(tag)} }`;
121
+ return `{ tag: ${JSON.stringify(tag)}; value: ${renderType(c.type, typeOwner, currentNs)} }`;
122
+ });
123
+ lines.push(` export type ${kebabToPascal(t.name)} =`);
124
+ for (let i = 0; i < cases.length; i++) {
125
+ lines.push(` | ${cases[i]}${i === cases.length - 1 ? ";" : ""}`);
126
+ }
127
+ }
128
+
129
+ function emitFlags(lines, t) {
130
+ // Mirror the Rust output: `type X = Vec<XFlag>;` becomes
131
+ // `type X = XFlag[]; type XFlag = "case" | ...`.
132
+ const flagName = kebabToPascal(t.name) + "Flag";
133
+ const flagUnion = t.cases.map((c) => JSON.stringify(kebabToSnake(c.name))).join(" | ");
134
+ lines.push(` export type ${flagName} = ${flagUnion};`);
135
+ lines.push(` export type ${kebabToPascal(t.name)} = ${flagName}[];`);
136
+ }
137
+
138
+ function renderType(ty, typeOwner, currentNs) {
139
+ switch (ty.kind) {
140
+ case "builtin":
141
+ return renderBuiltin(ty.name);
142
+ case "option":
143
+ return `${renderType(ty.inner, typeOwner, currentNs)} | undefined`;
144
+ case "list":
145
+ return `${renderType(ty.inner, typeOwner, currentNs)}[]`;
146
+ case "tuple":
147
+ return `[${ty.elems.map((e) => renderType(e, typeOwner, currentNs)).join(", ")}]`;
148
+ case "named": {
149
+ const pascal = kebabToPascal(ty.name);
150
+ // If the type lives in another interface, fully qualify so the
151
+ // reference resolves under the parent module instead of looking
152
+ // for it inside the current namespace.
153
+ const owner = typeOwner && typeOwner.get(ty.name);
154
+ if (owner && owner !== currentNs) return `${owner}.${pascal}`;
155
+ return pascal;
156
+ }
157
+ case "unknown":
158
+ return "unknown";
159
+ default:
160
+ return "unknown";
161
+ }
162
+ }
163
+
164
+ function renderBuiltin(name) {
165
+ switch (name) {
166
+ case "bool":
167
+ return "boolean";
168
+ case "string":
169
+ case "char":
170
+ return "string";
171
+ case "u8":
172
+ case "u16":
173
+ case "u32":
174
+ case "s8":
175
+ case "s16":
176
+ case "s32":
177
+ case "f32":
178
+ case "f64":
179
+ return "number";
180
+ case "u64":
181
+ case "s64":
182
+ // 64-bit integers cross the WIT boundary as bigint in jco bindings.
183
+ return "bigint";
184
+ default:
185
+ return "unknown";
186
+ }
187
+ }
188
+
189
+ function kebabToPascal(s) {
190
+ return s
191
+ .split("-")
192
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
193
+ .join("");
194
+ }
195
+
196
+ function kebabToSnake(s) {
197
+ return s.replace(/-/g, "_");
198
+ }
199
+
200
+ function emitDoc(lines, doc, indent = "") {
201
+ const docLines = doc.split("\n");
202
+ if (docLines.length === 1) {
203
+ lines.push(`${indent}/** ${docLines[0]} */`);
204
+ return;
205
+ }
206
+ lines.push(`${indent}/**`);
207
+ for (const dl of docLines) lines.push(`${indent} * ${dl}`);
208
+ lines.push(`${indent} */`);
209
+ }
@@ -0,0 +1,382 @@
1
+ /**
2
+ * Minimal WIT parser sufficient for the wit_events codegen step.
3
+ *
4
+ * Mirrors the subset the Rust `astrid_sdk_macros::wit_events!` macro
5
+ * handles: records, enums, variants, flags. Skips resource types,
6
+ * functions, and use/import statements (codegen doesn't emit code for
7
+ * those — they're for the WIT-to-binding layer ComponentizeJS owns).
8
+ *
9
+ * Input: WIT file content (string).
10
+ * Output: an AST suitable for the codegen step to walk.
11
+ */
12
+
13
+ const BUILTIN_TYPES = new Set([
14
+ "bool",
15
+ "u8", "u16", "u32", "u64",
16
+ "s8", "s16", "s32", "s64",
17
+ "f32", "f64",
18
+ "char",
19
+ "string",
20
+ ]);
21
+
22
+ /** Parse WIT source. Returns `{ pkg, interfaces }`. */
23
+ export function parseWit(source, filename = "<input>") {
24
+ const tokens = tokenize(source);
25
+ const parser = new Parser(tokens, filename);
26
+ return parser.parseFile();
27
+ }
28
+
29
+ function tokenize(source) {
30
+ const tokens = [];
31
+ let i = 0;
32
+ let line = 1;
33
+ let pendingDoc = "";
34
+
35
+ while (i < source.length) {
36
+ const ch = source[i];
37
+
38
+ if (ch === "\n") {
39
+ line++;
40
+ i++;
41
+ continue;
42
+ }
43
+ if (ch === " " || ch === "\t" || ch === "\r") {
44
+ i++;
45
+ continue;
46
+ }
47
+
48
+ // Doc comment: /// ...
49
+ if (ch === "/" && source[i + 1] === "/" && source[i + 2] === "/") {
50
+ let end = source.indexOf("\n", i);
51
+ if (end === -1) end = source.length;
52
+ const line_doc = source.slice(i + 3, end).replace(/^ /, "");
53
+ pendingDoc = pendingDoc === "" ? line_doc : pendingDoc + "\n" + line_doc;
54
+ i = end;
55
+ continue;
56
+ }
57
+ // Block comment: /* ... */
58
+ if (ch === "/" && source[i + 1] === "*") {
59
+ const end = source.indexOf("*/", i + 2);
60
+ if (end === -1) throw new Error(`Unterminated block comment at line ${line}`);
61
+ const block = source.slice(i + 2, end);
62
+ line += (block.match(/\n/g) ?? []).length;
63
+ i = end + 2;
64
+ continue;
65
+ }
66
+ // Line comment: // ... (non-doc)
67
+ if (ch === "/" && source[i + 1] === "/") {
68
+ let end = source.indexOf("\n", i);
69
+ if (end === -1) end = source.length;
70
+ i = end;
71
+ continue;
72
+ }
73
+
74
+ // Punctuation
75
+ const single = "{}<>(),;:.@/=%";
76
+ if (single.indexOf(ch) >= 0) {
77
+ tokens.push({ kind: "punct", value: ch, line, doc: "" });
78
+ i++;
79
+ continue;
80
+ }
81
+
82
+ // Quoted identifier (escape with %): %type, %if, etc.
83
+ if (ch === "%") {
84
+ let j = i + 1;
85
+ while (j < source.length && /[A-Za-z0-9_-]/.test(source[j])) j++;
86
+ const id = source.slice(i + 1, j);
87
+ if (id.length === 0) throw new Error(`Empty %-escaped identifier at line ${line}`);
88
+ tokens.push({ kind: "ident", value: id, line, doc: pendingDoc });
89
+ pendingDoc = "";
90
+ i = j;
91
+ continue;
92
+ }
93
+
94
+ // Identifier / keyword
95
+ if (/[A-Za-z_]/.test(ch)) {
96
+ let j = i;
97
+ while (j < source.length && /[A-Za-z0-9_-]/.test(source[j])) j++;
98
+ const id = source.slice(i, j);
99
+ tokens.push({ kind: "ident", value: id, line, doc: pendingDoc });
100
+ pendingDoc = "";
101
+ i = j;
102
+ continue;
103
+ }
104
+
105
+ // Number (version segments)
106
+ if (/[0-9]/.test(ch)) {
107
+ let j = i;
108
+ while (j < source.length && /[0-9]/.test(source[j])) j++;
109
+ tokens.push({ kind: "num", value: source.slice(i, j), line, doc: "" });
110
+ i = j;
111
+ continue;
112
+ }
113
+
114
+ throw new Error(`Unexpected character '${ch}' at line ${line}`);
115
+ }
116
+
117
+ tokens.push({ kind: "eof", value: "", line, doc: "" });
118
+ return tokens;
119
+ }
120
+
121
+ class Parser {
122
+ constructor(tokens, filename) {
123
+ this.tokens = tokens;
124
+ this.filename = filename;
125
+ this.pos = 0;
126
+ }
127
+
128
+ peek(offset = 0) {
129
+ return this.tokens[this.pos + offset];
130
+ }
131
+
132
+ eat() {
133
+ return this.tokens[this.pos++];
134
+ }
135
+
136
+ expect(kind, value) {
137
+ const t = this.eat();
138
+ if (t.kind !== kind || (value !== undefined && t.value !== value)) {
139
+ throw new Error(
140
+ `${this.filename}:${t.line}: expected ${kind}${value !== undefined ? ` '${value}'` : ""} got ${t.kind} '${t.value}'`,
141
+ );
142
+ }
143
+ return t;
144
+ }
145
+
146
+ match(kind, value) {
147
+ const t = this.peek();
148
+ return t.kind === kind && (value === undefined || t.value === value);
149
+ }
150
+
151
+ parseFile() {
152
+ let pkg;
153
+ const interfaces = [];
154
+
155
+ while (!this.match("eof")) {
156
+ if (this.match("ident", "package")) {
157
+ pkg = this.parsePackage();
158
+ } else if (this.match("ident", "interface")) {
159
+ interfaces.push(this.parseInterface());
160
+ } else if (this.match("ident", "world")) {
161
+ // Skip world declarations — we only care about types.
162
+ this.skipBraced();
163
+ } else {
164
+ const t = this.peek();
165
+ throw new Error(`${this.filename}:${t.line}: unexpected token '${t.value}' at file scope`);
166
+ }
167
+ }
168
+
169
+ return { pkg, interfaces };
170
+ }
171
+
172
+ parsePackage() {
173
+ this.expect("ident", "package");
174
+ const ns = this.expect("ident").value;
175
+ this.expect("punct", ":");
176
+ const name = this.expect("ident").value;
177
+ let version;
178
+ if (this.match("punct", "@")) {
179
+ this.eat();
180
+ version = this.parseVersion();
181
+ }
182
+ this.expect("punct", ";");
183
+ return { ns, name, version };
184
+ }
185
+
186
+ parseVersion() {
187
+ const parts = [];
188
+ parts.push(this.expect("num").value);
189
+ while (this.match("punct", ".")) {
190
+ this.eat();
191
+ parts.push(this.expect("num").value);
192
+ }
193
+ return parts.join(".");
194
+ }
195
+
196
+ parseInterface() {
197
+ this.expect("ident", "interface");
198
+ const name = this.expect("ident").value;
199
+ this.expect("punct", "{");
200
+
201
+ const types = [];
202
+ while (!this.match("punct", "}")) {
203
+ const doc = this.peek().doc;
204
+ if (this.match("ident", "use")) {
205
+ this.skipStatement();
206
+ continue;
207
+ }
208
+ if (this.match("ident", "record")) {
209
+ types.push(this.parseRecord(doc));
210
+ continue;
211
+ }
212
+ if (this.match("ident", "enum")) {
213
+ types.push(this.parseEnum(doc));
214
+ continue;
215
+ }
216
+ if (this.match("ident", "variant")) {
217
+ types.push(this.parseVariant(doc));
218
+ continue;
219
+ }
220
+ if (this.match("ident", "flags")) {
221
+ types.push(this.parseFlags(doc));
222
+ continue;
223
+ }
224
+ if (this.match("ident", "type")) {
225
+ // type alias — record but skip codegen
226
+ this.skipStatement();
227
+ continue;
228
+ }
229
+ // Function declarations: name: func(...) -> ...
230
+ // We only care about types here; skip the rest of the line.
231
+ this.skipStatement();
232
+ }
233
+ this.expect("punct", "}");
234
+ return { name, types };
235
+ }
236
+
237
+ parseRecord(doc) {
238
+ this.expect("ident", "record");
239
+ const name = this.expect("ident").value;
240
+ this.expect("punct", "{");
241
+ const fields = [];
242
+ while (!this.match("punct", "}")) {
243
+ const fieldDoc = this.peek().doc;
244
+ const fieldName = this.expect("ident").value;
245
+ this.expect("punct", ":");
246
+ const ty = this.parseType();
247
+ fields.push({ name: fieldName, type: ty, doc: fieldDoc });
248
+ if (this.match("punct", ",")) this.eat();
249
+ }
250
+ this.expect("punct", "}");
251
+ return { kind: "record", name, fields, doc };
252
+ }
253
+
254
+ parseEnum(doc) {
255
+ this.expect("ident", "enum");
256
+ const name = this.expect("ident").value;
257
+ this.expect("punct", "{");
258
+ const cases = [];
259
+ while (!this.match("punct", "}")) {
260
+ const caseDoc = this.peek().doc;
261
+ const caseName = this.expect("ident").value;
262
+ cases.push({ name: caseName, doc: caseDoc });
263
+ if (this.match("punct", ",")) this.eat();
264
+ }
265
+ this.expect("punct", "}");
266
+ return { kind: "enum", name, cases, doc };
267
+ }
268
+
269
+ parseVariant(doc) {
270
+ this.expect("ident", "variant");
271
+ const name = this.expect("ident").value;
272
+ this.expect("punct", "{");
273
+ const cases = [];
274
+ while (!this.match("punct", "}")) {
275
+ const caseDoc = this.peek().doc;
276
+ const caseName = this.expect("ident").value;
277
+ let ty;
278
+ if (this.match("punct", "(")) {
279
+ this.eat();
280
+ ty = this.parseType();
281
+ this.expect("punct", ")");
282
+ }
283
+ cases.push({ name: caseName, type: ty, doc: caseDoc });
284
+ if (this.match("punct", ",")) this.eat();
285
+ }
286
+ this.expect("punct", "}");
287
+ return { kind: "variant", name, cases, doc };
288
+ }
289
+
290
+ parseFlags(doc) {
291
+ this.expect("ident", "flags");
292
+ const name = this.expect("ident").value;
293
+ this.expect("punct", "{");
294
+ const cases = [];
295
+ while (!this.match("punct", "}")) {
296
+ const caseDoc = this.peek().doc;
297
+ const caseName = this.expect("ident").value;
298
+ cases.push({ name: caseName, doc: caseDoc });
299
+ if (this.match("punct", ",")) this.eat();
300
+ }
301
+ this.expect("punct", "}");
302
+ return { kind: "flags", name, cases, doc };
303
+ }
304
+
305
+ parseType() {
306
+ const t = this.eat();
307
+ if (t.kind !== "ident") {
308
+ throw new Error(`${this.filename}:${t.line}: expected type, got ${t.kind} '${t.value}'`);
309
+ }
310
+ const name = t.value;
311
+ if (BUILTIN_TYPES.has(name)) {
312
+ return { kind: "builtin", name };
313
+ }
314
+ if (name === "option") {
315
+ this.expect("punct", "<");
316
+ const inner = this.parseType();
317
+ this.expect("punct", ">");
318
+ return { kind: "option", inner };
319
+ }
320
+ if (name === "list") {
321
+ this.expect("punct", "<");
322
+ const inner = this.parseType();
323
+ this.expect("punct", ">");
324
+ return { kind: "list", inner };
325
+ }
326
+ if (name === "tuple") {
327
+ this.expect("punct", "<");
328
+ const elems = [];
329
+ elems.push(this.parseType());
330
+ while (this.match("punct", ",")) {
331
+ this.eat();
332
+ elems.push(this.parseType());
333
+ }
334
+ this.expect("punct", ">");
335
+ return { kind: "tuple", elems };
336
+ }
337
+ if (name === "result") {
338
+ // result<T, E> or result<_, E> or result. Skip for now — events
339
+ // shouldn't have result fields. If we encounter one, decay to `any`.
340
+ if (this.match("punct", "<")) {
341
+ this.skipBraced("<", ">");
342
+ }
343
+ return { kind: "unknown" };
344
+ }
345
+ // Named reference to another record/enum/variant.
346
+ return { kind: "named", name };
347
+ }
348
+
349
+ skipStatement() {
350
+ let depth = 0;
351
+ while (!this.match("eof")) {
352
+ const t = this.eat();
353
+ if (t.kind === "punct") {
354
+ if (t.value === "(" || t.value === "<" || t.value === "{") depth++;
355
+ else if (t.value === ")" || t.value === ">" || t.value === "}") {
356
+ if (depth === 0) {
357
+ // A bare closer ends our scan, but we shouldn't consume it
358
+ // unless we opened it. Put it back.
359
+ this.pos--;
360
+ return;
361
+ }
362
+ depth--;
363
+ } else if (t.value === ";" && depth === 0) {
364
+ return;
365
+ }
366
+ }
367
+ }
368
+ }
369
+
370
+ skipBraced(open = "{", close = "}") {
371
+ // If the next token is the opener, consume it.
372
+ if (this.match("punct", open)) this.eat();
373
+ let depth = 1;
374
+ while (depth > 0 && !this.match("eof")) {
375
+ const t = this.eat();
376
+ if (t.kind === "punct") {
377
+ if (t.value === open) depth++;
378
+ else if (t.value === close) depth--;
379
+ }
380
+ }
381
+ }
382
+ }