feat: bring JavaScript SDK to Rust parity with native APIs - #26
Conversation
There was a problem hiding this comment.
Pull request overview
This PR advances the JavaScript/TypeScript Astrid SDK toward parity with the Rust SDK while reshaping APIs into idiomatic JS/TS (WHATWG fetch, Node-style process/fs, lifecycle hooks, and schema-versioned KV helpers), and updates the pinned WIT/contracts artifacts and build staging to support frozen multi-version host imports (1.0 + 1.1).
Changes:
- Introduces semantic lifecycle hooks via
@hook,HookEvent, scoped replies, and fail-open bridge dispatch. - Upgrades HTTP and process integrations to the additive
astrid:http/host@1.1.0andastrid:process/host@1.1.0surfaces (WHATWGfetch(),RequestBuilder, Node-likespawn()/spawnSync(), injected files). - Adds schema-versioned KV helpers + migrations and expands parity tests + CI coverage.
Reviewed changes
Copilot reviewed 29 out of 30 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| scripts/sync-contracts-wit.sh | Allows overriding contracts source directory via ASTRID_CONTRACTS_DIR. |
| README.md | Updates top-level documentation and manifest examples to new publish/subscribe and hook surfaces. |
| packages/astrid-sdk/wit-contracts/astrid-contracts.wit | Bumps bundled WIT to include additive session management records (session@1.1.0). |
| packages/astrid-sdk/test/parity.test.mjs | Adds host-mocked Node parity tests for HTTP/process/fs/env/kv/hooks/uplink. |
| packages/astrid-sdk/src/wit-imports.d.ts | Adds ambient bindings for HTTP/process 1.1 while retaining 1.0 modules. |
| packages/astrid-sdk/src/uplink.ts | Makes UplinkId an opaque branded string and inlines UplinkProfile union. |
| packages/astrid-sdk/src/tool.ts | Adds @hook decorator and hook registration plumbing. |
| packages/astrid-sdk/src/time.ts | Adds promise-shaped sleep(ms) helper. |
| packages/astrid-sdk/src/runtime/registry.ts | Stores hook registrations alongside tools/interceptors/commands. |
| packages/astrid-sdk/src/runtime/bridge.ts | Dispatches lifecycle hooks fail-open and publishes scoped replies via HookEvent. |
| packages/astrid-sdk/src/runtime.ts | Tightens socket path config validation/error message. |
| packages/astrid-sdk/src/process.ts | Moves to process@1.1.0; introduces Node-like spawn() + spawnSync() + file injection support. |
| packages/astrid-sdk/src/net.ts | Makes network resources non-constructible and defines JS-native exported types. |
| packages/astrid-sdk/src/kv.ts | Adds schema-versioned KV envelopes and migration helpers; adds delete alias. |
| packages/astrid-sdk/src/ipc.ts | Makes IPC resources non-constructible and defines JS-native exported types. |
| packages/astrid-sdk/src/index.ts | Re-exports new hooks/http/process/kv types and entrypoints. |
| packages/astrid-sdk/src/http.ts | Implements WHATWG-native fetch() routed via host 1.1; introduces RequestBuilder/BufferedResponse. |
| packages/astrid-sdk/src/hooks.ts | Adds HookEvent type with JSON parsing and scoped reply helpers. |
| packages/astrid-sdk/src/fs.ts | Introduces Node-style open flags, recursive mkdir/rm options, and non-constructible resource classes. |
| packages/astrid-sdk/src/env.ts | Changes env reads to return undefined when missing; adds getOrThrow. |
| packages/astrid-sdk/src/contracts.ts | Regenerates TS types to match updated WIT (session@1.1 additions). |
| packages/astrid-sdk/src/approval.ts | Defines JS-native ApprovalDecision string union. |
| packages/astrid-sdk/README.md | Updates package README for new JS-native APIs, versioned KV, hooks, and HTTP/process changes. |
| packages/astrid-sdk/package.json | Adds a real test script and builds before testing. |
| packages/astrid-build/src/index.mjs | Stages each WIT package version separately; imports http/process 1.1; adds ASTRID_HOST_WIT_DIR override. |
| examples/test-capsule/src/index.ts | Demonstrates new @hook usage in the example capsule. |
| examples/test-capsule/Capsule.toml | Migrates from legacy capability blocks to publish/subscribe entries incl. hook topics. |
| CHANGELOG.md | Documents parity features, breaking changes, and new tests. |
| .github/workflows/ci.yml | Enables running tests (removes “no-op” note). |
Suppressed comments (1)
packages/astrid-sdk/src/http.ts:505
optionalMshardcodesmilliseconds("timeout", value), which causes misleading error messages for other timeout fields. MakeoptionalMsaccept the field name and pass it through tomilliseconds(...).
function optionalMs(value: number | undefined): bigint | undefined {
return value === undefined ? undefined : BigInt(milliseconds("timeout", value));
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 29 out of 30 changed files in this pull request and generated no new comments.
Suppressed comments (4)
packages/astrid-sdk/src/http.ts:372
setDefaultContentTypealso usesinstanceof URLSearchParams/Blobwithout guarding for runtimes where those globals may be undefined, which can causeReferenceErrorduring header normalization. Addtypeof ... !== "undefined"checks around these branches.
function setDefaultContentType(headers: Headers, body: BodyInit | Uint8Array): void {
if (headers.has("content-type")) return;
if (typeof body === "string") {
headers.set("content-type", "text/plain;charset=UTF-8");
} else if (body instanceof URLSearchParams) {
headers.set("content-type", "application/x-www-form-urlencoded;charset=UTF-8");
} else if (body instanceof Blob && body.type !== "") {
headers.set("content-type", body.type);
}
packages/astrid-sdk/src/http.ts:360
bodyBytesusesinstanceof URLSearchParams/Blob/FormDatawithout guarding for runtimes where those globals may be undefined. In such environments this will throw aReferenceErrorbefore you can produce the intended “unsupported body”TypeError. Prefertypeof ... !== "undefined" && body instanceof ...checks (similar to the existingtypeof Request !== "undefined"guard).
This issue also appears on line 364 of the same file.
if (body instanceof URLSearchParams) return encoder.encode(body.toString());
if (body instanceof Blob) return new Uint8Array(await body.arrayBuffer());
if (body instanceof FormData) {
throw new TypeError("FormData request bodies are not supported by the buffered Astrid HTTP host");
}
packages/astrid-sdk/src/fs.ts:456
hostOpenModerelies on theOpenModeTypeScript union for exhaustiveness, but at runtime an invalidmode(e.g. from plain JS oras any) will fall through and returnundefined, leading to a confusing host-binding type error. Throw a clearSysError.api(...)for unknown values.
function hostOpenMode(mode: OpenMode): "read" | "read-write" | "write" | "append" {
switch (mode) {
case "r": return "read";
case "r+": return "read-write";
case "w": return "write";
packages/astrid-sdk/src/process.ts:594
hostSignalrelies on theProcessSignalTypeScript union for exhaustiveness, but at runtime an invalidsignalvalue will fall through and returnundefined, which then gets passed to the host binding. Add adefaultbranch that throws a clearSysError.api(...)instead of failing later with a type error.
function hostSignal(signal: ProcessSignal): "term" | "hup" | "usr1" | "usr2" | "int" | "stop" | "cont" {
switch (signal) {
case "SIGTERM": return "term";
case "SIGHUP": return "hup";
case "SIGUSR1": return "usr1";
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 29 out of 30 changed files in this pull request and generated no new comments.
Suppressed comments (2)
packages/astrid-sdk/src/kv.ts:144
getVersionedtreats a stored zero-length value asnotFound. KV values are arbitrary bytes, so an empty value is still “present”; silently mapping it to missing can hide corruption or deliberate empty writes (e.g. viasetBytes). It’s better to treat onlyundefinedas not found and let empty content surface as a JSON parse error.
if (bytes === undefined || bytes.length === 0) return { kind: "notFound" };
packages/astrid-sdk/src/process.ts:173
- This host-call label says
process.spawn(...)but the function invoked isspawnBackground. SincecallHostuses the label in thrownSysErrormessages, this will mislead users when a background spawn fails.
const inner = callHost(`process.spawn(${JSON.stringify(cmd)})`, () =>
hostSpawnBackground(request),
);
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 29 out of 30 changed files in this pull request and generated no new comments.
Suppressed comments (4)
packages/astrid-sdk/src/uplink.ts:40
- The
callHost(...)context string foruplink.send(...)interpolates the opaque uplink id without quoting. If the id ever contains characters like spaces/quotes, the resulting context string becomes ambiguous and makes host error messages harder to interpret. UseJSON.stringify(uplink)(as used inregister) for consistent, unambiguous context.
export function send(uplink: UplinkId, platformUserId: string, content: string): boolean {
return callHost(`uplink.send(${uplink})`, () =>
uplinkSend(uplink, platformUserId, content),
);
packages/astrid-sdk/src/process.ts:219
ChildProcess.kill()always returnstrue, so the boolean return value is not meaningful. Since this method is documented as “Node-compatible”, it should returnfalsewhen the handle is already closed (analogous to Node’schild.kill()returning false when it can’t signal).
/** Node-compatible signal helper. Defaults to SIGTERM. */
kill(signal: ProcessSignal = "SIGTERM"): boolean {
this.signal(signal);
return true;
}
packages/astrid-sdk/src/process.ts:129
toWitFileInjectionallocates a newTextEncoderfor every injected file. Reusing a single encoder avoids repeated allocations wheninjectedFilesis used in batches.
function toWitFileInjection(file: InjectedFile): WitFileInjection {
const content = typeof file.content === "string"
? new TextEncoder().encode(file.content)
: file.content;
packages/astrid-sdk/src/runtime/bridge.ts:240
executeLifecycleHooktreats any non-undefinedreturn value as aHookResultand publishes it. If a hook handler accidentally returnsnull(common sentinel) or a non-object (e.g. a string), the bridge will publish an invalid payload onhook.v1.response.*, which can break downstream consumers. Since lifecycle hooks are meant to be fail-open, treatnulllikeundefinedand reject non-object results before replying.
try {
const result = invoke(instance, entry.methodName, event) as HookResult | undefined;
if (result !== undefined) event.reply(result);
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 29 out of 30 changed files in this pull request and generated no new comments.
Suppressed comments (2)
packages/astrid-sdk/src/tool.ts:131
@hookrecordsmethodNameviaString(context.name). If a hook is applied to a symbol-named method,String(symbol)won’t be a usable property key, and dispatch will later fail with “method … not found”. Reject non-string method names at decoration time (consistent with the registry’smethodName: stringcontract).
context.addInitializer(function () {
const ctor = (this as object).constructor as CapsuleConstructor;
recordHook(ctor, {
name,
methodName: String(context.name),
packages/astrid-sdk/package.json:31
- The new test suite (
node --test) importsesbuild, butesbuildis not declared in this package’s dependencies. This can break installs under stricter workspace/node_modules layouts (and makes the test script non-self-contained). Addesbuildas a devDependency of@unicity-astrid/sdk.
"build": "tsc -b",
"pretest": "npm run build",
"test": "node --test test/*.test.mjs",
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 30 out of 32 changed files in this pull request and generated no new comments.
Suppressed comments (1)
packages/astrid-sdk/src/fs.ts:413
readdir(..., { withFileTypes: true })wraps synchronouscallHost(...)calls inasyncclosures andPromise.all(...), which adds unnecessary microtask/Promise overhead (and makes stack traces noisier) without providing concurrency (host calls are synchronous at the WASM boundary). This can also become noticeable for large directories because you create N promises.
return Promise.all(names.map(async (name) => {
const entryPath = path.endsWith("/") ? path + name : `${path}/${name}`;
const entry = callHost(`fs.lstat(${quote(entryPath)})`, () => hostStatSymlink(entryPath));
return createDirent(path, name, entry.kind);
}));
## Summary Prepare `@unicity-astrid/build` and `@unicity-astrid/sdk` 0.2.0 following the JavaScript-native Rust SDK parity work merged in #26. The minor bump reflects intentional pre-1.0 API changes, including WHATWG-native HTTP, Node-style process and filesystem surfaces, semantic lifecycle hooks, and schema-versioned KV helpers. ## Changes - `package.json` (workspace): `0.1.0` → `0.2.0` - `packages/astrid-build/package.json`: `0.1.0` → `0.2.0` - `packages/astrid-sdk/package.json`: `0.1.0` → `0.2.0` - `package-lock.json`: synchronize all workspace package versions - `CHANGELOG.md`: roll `[Unreleased]` into `[0.2.0] - 2026-08-05` ## Validation - [x] `npm run build` against committed contracts `a268eb3` — example componentized successfully (13.49 MB, 178 host imports) - [x] `npm test` — 9/9 tests passed - [x] `npm pack --dry-run --workspace @unicity-astrid/build` — `@unicity-astrid/build@0.2.0` - [x] `npm pack --dry-run --workspace @unicity-astrid/sdk` — `@unicity-astrid/sdk@0.2.0` - [x] `git diff --check` ## After merge - [ ] Tag merged `main` as `v0.2.0` to create the GitHub Release - [ ] Publish npm packages manually in dependency order: `@unicity-astrid/build`, then `@unicity-astrid/sdk` The existing release workflow creates a GitHub Release from the tag; npm publication remains deliberately manual.
Summary
Bring the JavaScript SDK up to current Rust SDK capability parity while translating the surface into native JavaScript and TypeScript conventions.
@hook,HookEvent, scoped replies, and fail-open bridge dispatchastrid:http/host@1.1.0request controls and response metadata through a genuine WHATWGfetch()surfaceastrid:process/host@1.1.0read-only file injection and expose Node-stylespawn()/spawnSync()WIT and contracts
This PR pins the canonical contracts repository from
9742f80toa268eb3and implements those existing contracts in the JavaScript SDK.ASTRID_HOST_WIT_DIRandASTRID_CONTRACTS_DIRverification overridesThe canonical WIT definitions themselves were not authored in this repository; this PR advances the gitlink to their already-merged revision and supplies the JS implementation and generated mirrors.
API impact
This intentionally makes pre-stabilization API breaks where the old shape was misleading:
process.spawn()now returnsChildProcess; captured execution isspawnSync()SIGTERMfs.open()usesr/r+/w/akindvaluesRequestBuilder/BufferedResponse; misleadingRequest/Responsealiases are removedenv.get()returnsundefinedfor a missing keyUplinkIdis an opaque stringReview hardening
notFoundkill()behavior accurateesbuilddependencyValidation
npm test --workspace @unicity-astrid/sdk— 9/9 tests passeda268eb3scripts/sync-contracts-wit.sh --check— in syncgit diff --check— cleanca081cc