Skip to content

feat: bring JavaScript SDK to Rust parity with native APIs - #26

Merged
joshuajbouw merged 7 commits into
mainfrom
feat/js-rust-sdk-parity
Aug 5, 2026
Merged

feat: bring JavaScript SDK to Rust parity with native APIs#26
joshuajbouw merged 7 commits into
mainfrom
feat/js-rust-sdk-parity

Conversation

@joshuajbouw

@joshuajbouw joshuajbouw commented Aug 5, 2026

Copy link
Copy Markdown
Member

Summary

Bring the JavaScript SDK up to current Rust SDK capability parity while translating the surface into native JavaScript and TypeScript conventions.

  • add semantic lifecycle hooks through @hook, HookEvent, scoped replies, and fail-open bridge dispatch
  • consume astrid:http/host@1.1.0 request controls and response metadata through a genuine WHATWG fetch() surface
  • consume astrid:process/host@1.1.0 read-only file injection and expose Node-style spawn() / spawnSync()
  • add cross-SDK schema-versioned KV helpers and migrations
  • make filesystem, environment, time, process signals, hooks, uplink IDs, and resource handles feel native to JavaScript
  • keep generated WIT/host binding details out of the published TypeScript declarations

WIT and contracts

This PR pins the canonical contracts repository from 9742f80 to a268eb3 and implements those existing contracts in the JavaScript SDK.

  • adds ambient bindings for HTTP and process 1.1 while retaining the frozen 1.0 declarations
  • imports HTTP/process 1.1 in the synthesized component world
  • stages each full WIT package version separately so 1.0 and 1.1 resolve together
  • regenerates the canonical IPC mirror from the pinned contracts revision, including additive session-management records
  • adds ASTRID_HOST_WIT_DIR and ASTRID_CONTRACTS_DIR verification overrides

The 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 returns ChildProcess; captured execution is spawnSync()
  • process signals use names such as SIGTERM
  • fs.open() uses r / r+ / w / a
  • versioned KV narrows on camelCase kind values
  • HTTP's Rust-style path is explicitly RequestBuilder / BufferedResponse; misleading Request / Response aliases are removed
  • env.get() returns undefined for a missing key
  • UplinkId is an opaque string

Review hardening

  • validates hook routing and returned hook-result shapes before publishing scoped replies
  • preserves field-specific timeout errors and rejects invalid runtime open modes or process signals
  • feature-detects optional web globals used by HTTP body normalization
  • treats empty KV bytes as present malformed data instead of notFound
  • makes background-process errors and closed-handle kill() behavior accurate
  • rejects symbol-named registry decorators early and declares the test suite's direct esbuild dependency

Validation

  • npm test --workspace @unicity-astrid/sdk — 9/9 tests passed
  • full workspace build and real ComponentizeJS build passed against contracts a268eb3
  • example component: 13.48 MB, 178 host imports
  • scripts/sync-contracts-wit.sh --check — in sync
  • git diff --check — clean
  • GitHub Actions workspace build and WIT mirror checks — passed on ca081cc

@joshuajbouw
joshuajbouw marked this pull request as ready for review August 5, 2026 16:47
Copilot AI lite review requested due to automatic review settings August 5, 2026 16:47

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.0 and astrid:process/host@1.1.0 surfaces (WHATWG fetch(), RequestBuilder, Node-like spawn()/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

  • optionalMs hardcodes milliseconds("timeout", value), which causes misleading error messages for other timeout fields. Make optionalMs accept the field name and pass it through to milliseconds(...).
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.

Comment thread packages/astrid-sdk/src/runtime/bridge.ts
Comment thread packages/astrid-sdk/src/http.ts

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • setDefaultContentType also uses instanceof URLSearchParams / Blob without guarding for runtimes where those globals may be undefined, which can cause ReferenceError during header normalization. Add typeof ... !== "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

  • bodyBytes uses instanceof URLSearchParams / Blob / FormData without guarding for runtimes where those globals may be undefined. In such environments this will throw a ReferenceError before you can produce the intended “unsupported body” TypeError. Prefer typeof ... !== "undefined" && body instanceof ... checks (similar to the existing typeof 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

  • hostOpenMode relies on the OpenMode TypeScript union for exhaustiveness, but at runtime an invalid mode (e.g. from plain JS or as any) will fall through and return undefined, leading to a confusing host-binding type error. Throw a clear SysError.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

  • hostSignal relies on the ProcessSignal TypeScript union for exhaustiveness, but at runtime an invalid signal value will fall through and return undefined, which then gets passed to the host binding. Add a default branch that throws a clear SysError.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";

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • getVersioned treats a stored zero-length value as notFound. 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. via setBytes). It’s better to treat only undefined as 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 is spawnBackground. Since callHost uses the label in thrown SysError messages, this will mislead users when a background spawn fails.
  const inner = callHost(`process.spawn(${JSON.stringify(cmd)})`, () =>
    hostSpawnBackground(request),
  );

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 for uplink.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. Use JSON.stringify(uplink) (as used in register) 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 returns true, so the boolean return value is not meaningful. Since this method is documented as “Node-compatible”, it should return false when the handle is already closed (analogous to Node’s child.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

  • toWitFileInjection allocates a new TextEncoder for every injected file. Reusing a single encoder avoids repeated allocations when injectedFiles is 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

  • executeLifecycleHook treats any non-undefined return value as a HookResult and publishes it. If a hook handler accidentally returns null (common sentinel) or a non-object (e.g. a string), the bridge will publish an invalid payload on hook.v1.response.*, which can break downstream consumers. Since lifecycle hooks are meant to be fail-open, treat null like undefined and reject non-object results before replying.
    try {
      const result = invoke(instance, entry.methodName, event) as HookResult | undefined;
      if (result !== undefined) event.reply(result);

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • @hook records methodName via String(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’s methodName: string contract).
    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) imports esbuild, but esbuild is 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). Add esbuild as a devDependency of @unicity-astrid/sdk.
    "build": "tsc -b",
    "pretest": "npm run build",
    "test": "node --test test/*.test.mjs",

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 synchronous callHost(...) calls in async closures and Promise.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);
    }));

@joshuajbouw
joshuajbouw merged commit 5c48823 into main Aug 5, 2026
3 checks passed
@joshuajbouw joshuajbouw mentioned this pull request Aug 5, 2026
7 tasks
joshuajbouw added a commit that referenced this pull request Aug 5, 2026
## 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants