@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 +93 -0
- package/package.json +24 -0
- package/src/index.mjs +481 -0
- package/src/wit-codegen.mjs +209 -0
- package/src/wit-parser.mjs +382 -0
package/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# @unicity-astrid/build
|
|
2
|
+
|
|
3
|
+
[](../../LICENSE-MIT)
|
|
4
|
+
[](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
|
+
}
|