diff --git a/.github/ISSUE_SPECS/428-make-browser-sdk-boot-defaults-package-owned.md b/.github/ISSUE_SPECS/428-make-browser-sdk-boot-defaults-package-owned.md new file mode 100644 index 000000000..9df1e9155 --- /dev/null +++ b/.github/ISSUE_SPECS/428-make-browser-sdk-boot-defaults-package-owned.md @@ -0,0 +1,178 @@ +# Make browser SDK boot defaults package-owned + +**Status:** Approved bounded SDK brief under the user-confirmed end-to-end plan. Astra medium implements and a separate Astra medium agent reviews. Adapter #22 and SDK #405 are now independently approved; root will synchronize this issue before implementation. + +## Baseline and dependency truth + +Start from reviewed integrated SDK checkpoint `a0493021` on `codex/dx-browser-control`, containing approved #393 host ownership, #409 fixture correction, and renewed approved #405 PCM ingress. Reuse existing asset/NOTICE staging and preserve all PCM bytes; this issue changes only default boot ownership. Registry publication and progressive playback are not prerequisites. + +Current SDK `createEngine` already owns source-format preflight, scratch-derived shape, construct/verify/close/retry, pre-worklet quantum refusal, worklet boot, console binding, and host-then-context close. Its three injected functions are required. The SDK compiles with `ES2022` + `WebWorker`, while a normal packed browser consumer compiles with DOM types. The solution must preserve both environments. + +## Smallest closable product slice + +Make `createEngine({ document })` boot the existing `BrowserEngine` through packaged defaults. Make `createContext`, `scratchBoot`, and `createHost` optional while preserving their current signatures and precedence when supplied. Move the adapter's existing engine-specific scratch Worker client/entry and shipped-host import factory into the SDK, and add a guarded default `AudioContext` constructor. This changes construction ownership only; it creates no second engine/session abstraction and no delivery behavior. + +The future adapter consumer remains a separate issue. This issue only leaves public, narrow SDK helpers it can call later. + +## Frozen SDK contract + +- Existing `simd128ModuleUrl` and `workletModuleUrl` overrides keep their behavior. Add only `hostModuleUrl`, `scratchWorkerModuleUrl`, and the structural Worker factory/deadline/signal inputs needed by the defaults. Each supplied injection or URL wins independently; an all-injected call touches no global constructor, Worker, or dynamic import. +- The default context factory performs a guarded runtime lookup of `globalThis.AudioContext` and constructs it with the existing exact `{ sampleRate, renderSizeHint }` request. Absence is a typed boot failure. Do not add `DOM` to SDK `tsconfig`. +- Parameterize `CreateEngineOptions` and `BrowserEngine` over the context returned by `createContext`. In a packed DOM consumer, the default call must infer the structural instance type of that consumer's `globalThis.AudioContext`, permitting `resume()`, `suspend()`, and `engine.host.node.connect(engine.context.destination)` without casts. An injected factory retains its exact return type and must not acquire capabilities it did not return. Use a structural conditional/overload; do not cast the result to a richer public type. +- Context ownership stays exact: every wrong-rate candidate closes before retry; quantum or later boot failure closes the accepted context; `BrowserEngine.close()` remains idempotent and disposes host before context, including when host disposal rejects. There is no caller-supplied context ownership mode. +- Preserve the current `sources` refusal and ordering. SDK-authored `32f` declarations refuse before scratch/context. Raw documents remain opaque bytes/string/builder output; add no parser or introspection. +- Keep `scratchBootInWorker` unchanged. Export one narrow `scratchBootWithWorker` one-shot helper; any reusable client underneath is private. It launches the packaged module Worker, waits for the existing ready handshake, sends one correlated document/options/Wasm-URL request, returns `SessionShape`, and terminates exactly once after success or failure. +- Preserve the adapter-derived bounded handshake/request deadline and optional abort. Timeout, abort, Worker `error`/`messageerror`, synchronous post failure, or a rejected Worker result must settle once, remove timers/listeners, terminate before rejection, and make late events inert. Abort racing with ready/result cannot return a live client. Repeated cleanup is safe. +- The packaged Worker entry fetches the requested Wasm URL, requires an OK response, reads its bytes, calls `scratchBootInWorker`, and posts the existing correlated serializable success/error shape. It owns no caller resource and exposes no session inspection API. +- Export one narrow default-host helper. It dynamically imports the selected packaged host module once, converts current SDK worklet boot options with `toWebBootOptions`, and calls `createMisoAudioWorkletHost` with the exact context, document, Wasm URL, and worklet URL. It loads no feed prelude and changes no host/wire request. +- Add a narrow browser-boot error discriminant only if exact operation identity cannot otherwise be tested or translated. Limit it to default context unavailable, scratch Worker start/load/deadline, and host import/creation. Existing engine refusals, `MisoUsageError`, and caller abort reasons pass through unchanged. +- Emit the Worker entry as ordinary TypeScript package output and expose its package-relative URL through `BUNDLED_ENGINE_ASSETS`. It is outside the six Rust-generated artifacts and their generated manifest/provenance closure. Copy repository `NOTICE` to `dist/NOTICE`, add the moved-code attribution from adapter baseline `63b4ee6212287000ff85e1cfa969d385f6246d2d`, and keep the generated artifact bytes and manifest byte-identical. + +## Allowed paths + +- `.github/ISSUE_SPECS/428-make-browser-sdk-boot-defaults-package-owned.md` +- `sdk/src/browser/engine.ts` +- `sdk/src/browser/scratch.ts` (new) +- `sdk/src/browser/scratch-worker.ts` (new; one equivalent emitted entry is allowed) +- `sdk/src/browser/default-host.ts` (new; a bounded section of `engine.ts` is allowed) +- `sdk/src/browser/index.ts` +- `sdk/src/assets.ts` +- `sdk/codegen/stage-package.mjs` +- `sdk/test/browser-evals.mjs` +- one focused SDK scratch/default-host eval file +- one focused structural-context type probe +- `sdk/test/package-tarball-smoke.mjs` +- `sdk/README.md` +- `NOTICE` + +No adapter path, Rust/Cargo path, generated host/worklet JS or declaration, Wasm, ABI/parameter JSON, generated provenance, policy algorithm, session/console implementation, runtime dependency, codec, storage, PCM behavior, backend-selection, transport, or release workflow may change. If making the public Worker URL import-complete after packaging demonstrably requires a small build-only bundling step, propose the exact build paths/dependency before implementation; do not hide a broken worker graph behind the default-factory test. + +## Proportional objective gates + +1. Existing injected browser evals remain green. Add default-path and per-injection precedence coverage proving one existing `BrowserEngine`, exact URLs/options/identity, source refusal before work, construct/verify/close/retry, quantum refusal before host creation, shared scratch/worklet policy words, and idempotent host-then-context close. +2. Focused scratch tests cover success plus representative fail-close races: never-ready timeout, abort during request, Worker error/messageerror, synchronous post failure, late reply, and repeated cleanup. Assert one settlement, one termination, and no surviving timer/listener; do not build a combinatorial fixture matrix. +3. Default-host coverage proves one import, exact `toWebBootOptions` result and URL forwarding, returned host identity, and accepted-context cleanup on import/factory failure. No feed prelude or wire change appears. +4. `scripts/check-sdk-types.sh` passes under the unchanged ES/WebWorker libs. A packed strict DOM consumer calls `createEngine({ document })`, resumes/suspends its inferred context, and connects the host node to `destination` without casts. Two distinct injected structural factories retain only their actual capabilities. +5. `scripts/sdk-package.sh check` and packed tarball smoke pass. The extraction contains the Worker graph, `NOTICE`, and resolvable package-relative URLs; the generated six-artifact manifest/closure and pinned bytes remain unchanged. Also run `scripts/check-sdk-generated.sh`, `scripts/check-sdk-headless.sh`, and the focused browser evals. No full Rust rebuild, browser matrix, benchmark, or listening gate is required. + +## Release and review truth + +This SDK issue is independently closable when Astra's bounded implementation, proportional local gates, and dedicated Astra adversarial review pass, its evidence commit is upstream, and the matching GitHub issue is synchronized and closed. A local packed tarball proves the capability; it does not claim npm publication or adapter compatibility. Adapter migration, exact published dependency integration, no-double-boot orchestration, storage/readiness/playback, and progressive delivery remain future consumer/release work. + +## App delivery requirement + +The existing app intentionally vendors exact reviewed package archives with hash/source provenance. Use this workflow for downstream integration; npm publication is not a prerequisite. This issue must prove packaged Worker loading through default and ordinary forwarding custom factory paths, including dependency graph resolution in a real packed browser consumer, so it does not repeat the adapter worker URL defect. App UI/offline/spectrum compatibility remains in the downstream adapter/app preservation work. + +## Execution record + +Matching issue misofm/engine#428. Isolated branch codex/dx-sdk-boot starts from approved PCM boundary a0493021. Astra medium implements; separate Astra medium review follows. This SDK ownership slice is independent of the adapter OPFS correction, with no shared checkout, files or build outputs. Root checkpoints coherent exact-path tranches before further implementation. + +### Narrow worker packaging amendment + +Root approves adding a pinned build-only esbuild development dependency in sdk/package.json and sdk/package-lock.json and using sdk/codegen/stage-package.mjs to bundle emitted dist/browser/scratch-worker.js in place as an import-complete browser ES module. Existing TypeScript alone cannot preserve the worker dependency graph when a public URL is relocated by an app bundler. This preserves the public entry name, has no runtime package dependency, does not touch the six generated artifact bytes, and must make both default and forwarding custom Worker factories work in the packed browser. Preserve provenance/NOTICE. + +### Attempt 1 implementation evidence (Astra medium, 2026-09-05) + +Package-owned defaults are implemented on the existing `BrowserEngine`; independent injected +context/scratch/host functions and asset overrides remain available. The default context type is +resolved structurally from the consuming environment, and injected factories retain their actual +return type. `scratchBootInWorker`'s executable body is unchanged. A one-shot Worker helper owns +handshake/request deadlines, abort/listener/timer cleanup and termination. The narrow host helper +imports the selected shipped module and applies the existing `toWebBootOptions` adapter. + +The approved build-only esbuild 0.28.1 dependency bundles the emitted Worker to an import-complete +43,914-byte entry. Vite's literal default Worker build and its raw copied exported Worker URL both +load successfully. No runtime dependency, generated host declaration, generated artifact byte, +PCM implementation or policy algorithm changed. NOTICE retains the moved adapter attribution. +Source/package milestones were checkpointed at `fca2f3a3`, `cba19b96`, and `b6747844`. + +The first actual browser probe found that a context constructed after user activation may already +be running. The shipped host correctly refuses that state before worklet creation. The existing +adapter factory's suspension step was therefore preserved in the SDK default-host helper; focused +regressions prove suspension before construction and accepted-context closure if suspension +rejects. This is a construction precondition, with no host or wire relaxation. + +Validation: + +- `bash scripts/check-sdk-types.sh`: PASS under unchanged ES2022/WebWorker libs. +- `node --test sdk/test/browser-defaults-evals.mjs`: PASS, 17 focused tests, including fail-close + handshake/request faults, late events, abort inside construction, exact default host forwarding, + injected precedence, host failure cleanup and the native suspension precondition. +- Existing `sdk/test/browser-evals.mjs` against `/private/tmp/dx-393-current-artifacts`: PASS, 16 + tests. `bash scripts/check-sdk-headless.sh /private/tmp/dx-393-current-artifacts`: PASS, 152 + passed / one existing skip before the two additional suspension regressions above. +- `bash scripts/check-sdk-generated.sh`: PASS after final implementation; no Wasm rebuild/repin. +- `MISO_ENGINE_SDK_BROWSER_TOOLS=/private/tmp/miso-dx-app/node_modules bash + scripts/sdk-package.sh check /private/tmp/dx-393-current-artifacts`: PASS, including the generated + gate, CLI tests, packed strict DOM consumer, structural thin context negative checks, standalone + Worker AST check, tarball smoke and actual Vite 8.2.2 / Playwright Chromium boot. +- The retained optional browser mode uses an existing Vite/Playwright `node_modules` selected by + `MISO_ENGINE_SDK_BROWSER_TOOLS`; it adds no browser framework dependency. It serves the production + bundle on loopback, clicks both boot paths, resumes/connects/status-checks/suspends/closes each + engine and asserts no failed requests or HTTP errors. Both report 48000 Hz, 128 frames, status + result 0 and context state `closed`; the forwarding factory receives the exact exported URL and + `{ type: "module" }`. All observed requests returned HTTP 200. This probe required normal + loopback/browser permissions beyond the sandbox's network restriction. +- Direct byte comparison: all six staged generated artifacts equal the approved input directory; + manifest lengths/digests match those exact bytes; the staged PCM feed equals its unchanged + source. The generated manifest retains exactly six artifact entries. + +Reproducible browser assertions are in `sdk/test/package-tarball-smoke.mjs`, not solely in temporary +logs. Local logs: `/private/tmp/dx428-final-package-browser.log`, +`/private/tmp/dx428-final-generated.log`, `/private/tmp/dx428-focused.log`, and +`/private/tmp/dx428-headless.log`. Dedicated independent review and root's final upstream/issue +synchronization remain pending. These gates prove a packed SDK capability, not npm publication or +completion of the downstream adapter/app integration. + +### Attempt 2 bounded revision evidence (Astra medium, 2026-09-05) + +Attempt 1 received independent **FAIL at `56bbd518`**, preserved in +`/private/tmp/dx-428-astra-medium-review.md`: its Worker transport erased typed engine refusal +fields, and its resource fixture did not detect removal of settlement `clearTimeout`. + +Checkpoint `2da95d23` addresses those two findings in the existing scratch client, entry and +focused test file only. The reply carries an explicit engine/usage discriminator; an actual +`MisoEngineError` carries phase, code, result and diagnostics, while `MisoUsageError` retains its +existing usage identity. The client constructs the corresponding SDK error class and preserves +the original formatted message verbatim without parsing it or decorating it twice. The generic +name/message fallback remains available for other Worker failures. No public engine authority, +context type, policy, generated artifact or PCM behavior changed. + +The focused test runs the actual scratch primitive against the approved real Wasm and document +`{}`, then routes that same refusal through the actual Worker entry, structured-cloned messages +and actual one-shot client. It verifies `instanceof MisoEngineError`, name/message, phase/code/ +result/diagnostics and diagnostic getters against the direct refusal; usage reconstruction is +also exercised through the entry/client. Compact local timer and AbortSignal listener accounting +requires zero active resources after success/abort/error. Captured removed Worker, abort and timer +callbacks are actually invoked after settlement; they remain inert with one termination/request +and one observed settlement. + +Validation after the correction: + +- `bash scripts/check-sdk-types.sh`: PASS. +- `MISO_ENGINE_SDK_ARTIFACTS_HEX=2f707269766174652f746d702f64782d3339332d63757272656e742d617274696661637473 + node --test sdk/test/browser-defaults-evals.mjs`: PASS, **19/19**. The artifact input is now + required for the real-Wasm refusal regression, following the existing SDK fixture convention. +- Exact requested red mutation: remove only `clearTimeout(timer)` from `finish` in an isolated + source copy. The focused run exits **1**, failing `no scratch deadline survives settlement` + with actual 1 / expected 0. Source copy: + `/private/tmp/dx428-attempt2-timer-9uffw4ye`; log: + `/private/tmp/dx428-attempt2-timer-mutant.log`. The unmodified baseline exits 0. +- `bash scripts/check-sdk-headless.sh /private/tmp/dx-393-current-artifacts`: PASS, + **156 passed / one existing skip** (157 total). +- `MISO_ENGINE_SDK_BROWSER_TOOLS=/private/tmp/miso-dx-app/node_modules bash + scripts/sdk-package.sh check /private/tmp/dx-393-current-artifacts`: PASS, including its + generated-surface gate, strict packed consumer types, standalone Worker/package checks and + retained real Vite/Chromium default and forwarding-factory boot. Both paths report 48000 Hz, + 128 frames, status result 0 and closed contexts; all observed network responses are 200. +- Direct comparison again confirms all six generated artifacts equal the approved input bytes, + their six-entry manifest lengths/digests match, and the staged PCM feed is unchanged. + +Logs: `/private/tmp/dx428-attempt2-focused.log`, +`/private/tmp/dx428-attempt2-headless.log`, and +`/private/tmp/dx428-attempt2-package-browser.log`. Independent attempt-two review and final remote +issue synchronization remain pending; the first FAIL is retained rather than rewritten. + +## Dedicated Astra medium attempt2 PASS + +Independent review at9ab79d13 verifies typed scratch refusal classes/fields and meaningful timer cleanup regressions, plus headless and actual packed default/forwarding browser startup. Six generated artifacts and PCM bytes are unchanged. Report attached to PR432. Both first-review findings are resolved; historical FAIL retained. This completes package-owned startup, not downstream app integration or registry publication. diff --git a/NOTICE b/NOTICE index 2c7ddbf9b..b815aca01 100644 --- a/NOTICE +++ b/NOTICE @@ -11,3 +11,7 @@ mechanism were adapted from misofm/app at commit 7485693e9bbcf2f65a91a4e5950e22d678d99062. The TypeScript ring/feed move and packaged asset are retained under the engine-web-adapter lineage above; this notice applies to both sources. + +The browser scratch Worker client/entry and default host construction are +adapted from misofm/engine-web-adapter at commit +63b4ee6212287000ff85e1cfa969d385f6246d2d, under Apache-2.0. diff --git a/sdk/README.md b/sdk/README.md index c11f7c690..a6ca01788 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -180,7 +180,11 @@ consumer that reads its rate from it cannot be told 48000 by a fallback that nev ## Browser ```ts -import { createEngine, scratchBootInWorker } from "@misofm/engine/browser"; +import { createEngine } from "@misofm/engine/browser"; + +const engine = await createEngine({ document }); +await engine.context.resume(); +engine.host.node.connect(engine.context.destination); ``` The order matters, and each step exists because the next is expensive to undo: refuse what web @@ -190,9 +194,21 @@ verify and if necessary close-and-retry the `AudioContext` → refuse a quantum Both boots read the same policy object, so the ring, the memory budget and all four console words are identical by construction; the two `require_*` words are role-defined (zero in the scratch boot, -physical in the worklet). Source plumbing is bring-your-own: SDK core has no opinions about audio -plumbing — no OPFS, no fetch, no Workers — so the Worker boot and the context constructor are -injected rather than reached for. +physical in the worklet). The browser entry supplies a packaged scratch module Worker, a guarded +`AudioContext` constructor, and the shipped host factory. Source delivery remains caller-owned. + +`createContext`, `scratchBoot`, and `createHost` are optional independent overrides. An injected +context factory preserves its exact return type; a default DOM consumer receives its native +`AudioContext` type. The `simd128ModuleUrl`, `workletModuleUrl`, `hostModuleUrl`, and +`scratchWorkerModuleUrl` overrides select individual assets. `createWorker(url, { type })` can +forward the exported package Worker URL directly to `new Worker`; the packaged entry contains its +imports, including after Vite copies it. `requestDeadlineMs` (default 5000) bounds each scratch +handshake/request phase, and `signal` cancels scratch work. Every outcome terminates the Worker. + +For callers that compose boot explicitly, `scratchBootWithWorker` performs one scratch request +and `createDefaultHost` imports and invokes the shipped host with `toWebBootOptions` mapping. +`scratchBootInWorker` remains the low-level primitive for custom Worker entries. The browser +helpers install no PCM feed or storage service. `await engine.console()` binds the same semantic console shown above to the shipped browser host. It resolves the browser session map once, then submits the same whole-batch edits over MessagePort. diff --git a/sdk/codegen/stage-package.mjs b/sdk/codegen/stage-package.mjs index 071565582..a88ed6c88 100755 --- a/sdk/codegen/stage-package.mjs +++ b/sdk/codegen/stage-package.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node /** Stage the exact gated browser artifact closure under the emitted package tree. */ +import { build } from "esbuild"; import { createHash } from "node:crypto"; import { copyFile, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; @@ -67,3 +68,16 @@ await copyFile( ); await copyFile(resolve(repoRoot, "NOTICE"), resolve(sdkRoot, "dist", "NOTICE")); console.log(`staged ${expected.length} Engine V1 artifacts and package manifest`); + +// A consumer bundler may copy an exported new-URL asset without following its imports. Emit an +// import-complete Worker so both that URL and the default literal Worker constructor are valid. +await build({ + entryPoints: [resolve(sdkRoot, "dist", "browser", "scratch-worker.js")], + outfile: resolve(sdkRoot, "dist", "browser", "scratch-worker.js"), + allowOverwrite: true, + bundle: true, + format: "esm", + platform: "browser", + target: "es2022", + legalComments: "inline", +}); diff --git a/sdk/package-lock.json b/sdk/package-lock.json index eef7beb9e..f72534a02 100644 --- a/sdk/package-lock.json +++ b/sdk/package-lock.json @@ -13,12 +13,455 @@ }, "devDependencies": { "@types/node": "22.20.1", + "esbuild": "0.28.1", "typescript": "5.9.3" }, "engines": { "node": ">=20" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@types/node": { "version": "22.20.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", @@ -29,6 +472,48 @@ "undici-types": "~6.21.0" } }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/sdk/package.json b/sdk/package.json index fc9aad900..3250710fe 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -46,6 +46,7 @@ }, "devDependencies": { "@types/node": "22.20.1", + "esbuild": "0.28.1", "typescript": "5.9.3" }, "scripts": { diff --git a/sdk/src/assets.ts b/sdk/src/assets.ts index 427b688f9..4c002b711 100644 --- a/sdk/src/assets.ts +++ b/sdk/src/assets.ts @@ -28,6 +28,7 @@ export const BUNDLED_ENGINE_ASSETS = Object.freeze({ parameterMetadata: new URL("./assets/miso-engine-v1-parameter-metadata.json", import.meta.url), abiLayout: new URL("./assets/miso-engine-v1-abi-layout.json", import.meta.url), manifest: new URL("./assets/miso-engine-v1-sdk-manifest.json", import.meta.url), + scratchWorkerModule: new URL("./browser/scratch-worker.js", import.meta.url), pcmFeedWorklet: new URL("./assets/miso-engine-v1-pcm-feed-worklet.js", import.meta.url), }); diff --git a/sdk/src/browser/default-host.ts b/sdk/src/browser/default-host.ts new file mode 100644 index 000000000..12a50d730 --- /dev/null +++ b/sdk/src/browser/default-host.ts @@ -0,0 +1,43 @@ +import type { AudioContextLike } from "./engine.ts"; +import type { BootOptions } from "../core/abi.ts"; +import type { MisoAudioWorkletHost } from "./shipped-host.d.ts"; +import { BUNDLED_ENGINE_ASSETS } from "../assets.ts"; +import { toWebBootOptions } from "./host-mirror.ts"; + +/** Failures in package-owned browser construction, before an engine refusal is available. */ +export class BrowserBootError extends Error { + readonly operation: "context-unavailable" | "scratch-start" | "scratch-load" | "scratch-deadline" | "host-import"; + constructor(operation: BrowserBootError["operation"], message: string, cause?: unknown) { + super(message, { cause }); + this.name = "BrowserBootError"; + this.operation = operation; + } +} + +/** Import the shipped host and forward the existing boot request without installing a feed. */ +export async function createDefaultHost(request: { + readonly context: AudioContextLike & { suspend?(): Promise }; + readonly document: Uint8Array; + readonly options: BootOptions; + readonly simd128ModuleUrl: string; + readonly workletModuleUrl: string; + readonly hostModuleUrl?: string; +}): Promise { + // Native contexts constructed after user activation may start running. The shipped host + // requires suspension before it installs its worklet; this is the adapter's existing prelude. + if (request.context.state === "running") await request.context.suspend?.(); + const options = toWebBootOptions(request.options); + const url = request.hostModuleUrl ?? BUNDLED_ENGINE_ASSETS.hostModule.href; + let module: typeof import("./shipped-host.d.ts"); + try { module = await import(/* @vite-ignore */ url); } + catch (error) { throw new BrowserBootError("host-import", "Engine host module could not load", error); } + // The host's declaration uses the ambient BaseAudioContext; the runtime consumes this exact + // structural context. No public return type is asserted or enriched here. + return module.createMisoAudioWorkletHost({ + context: request.context, + document: request.document, + options, + simd128ModuleUrl: request.simd128ModuleUrl, + workletModuleUrl: request.workletModuleUrl, + }); +} diff --git a/sdk/src/browser/engine.ts b/sdk/src/browser/engine.ts index 3fa67b649..ea2e0923b 100644 --- a/sdk/src/browser/engine.ts +++ b/sdk/src/browser/engine.ts @@ -14,6 +14,9 @@ import type { BrowserBootPolicy } from "./policy.ts"; import { BUNDLED_ENGINE_ASSETS } from "../assets.ts"; import type { MisoAudioWorkletHost } from "./shipped-host.d.ts"; import type { EngineConsole } from "../core/console.ts"; +import { scratchBootWithWorker } from "./scratch.ts"; +import type { ScratchWorkerFactory } from "./scratch.ts"; +import { createDefaultHost, BrowserBootError } from "./default-host.ts"; import { createBrowserConsole } from "./console.ts"; /** @@ -56,7 +59,12 @@ export interface AudioContextLike { readonly audioWorklet: { addModule(url: string): Promise }; } -export interface CreateEngineOptions { +/** Resolves against the consumer's ambient browser constructor without requiring DOM libs here. */ +export type DefaultAudioContext = typeof globalThis extends { + AudioContext: abstract new (...args: never[]) => infer Context extends AudioContextLike; +} ? Context : AudioContextLike; + +export interface CreateEngineOptions { /** The Session V1 document, or the SDK builder session that produced it. */ readonly document: Uint8Array | string | { toJson(): string }; /** @@ -70,19 +78,24 @@ export interface CreateEngineOptions { /** Release URLs, from the same release as the module bytes. */ readonly simd128ModuleUrl?: string; readonly workletModuleUrl?: string; + readonly hostModuleUrl?: string; + readonly scratchWorkerModuleUrl?: string; + readonly createWorker?: ScratchWorkerFactory; + readonly requestDeadlineMs?: number; + readonly signal?: AbortSignal; /** Constructs an `AudioContext` at the requested rate. Injected so the entry stays testable. */ - readonly createContext: (options: { + readonly createContext?: (options: { readonly sampleRate: number; readonly renderSizeHint: number; - }) => AudioContextLike; + }) => Context; /** Boots a scratch instance in a Worker and returns the shape it read back. */ - readonly scratchBoot: (request: { + readonly scratchBoot?: (request: { readonly document: Uint8Array; readonly options: ReturnType; }) => Promise; /** Creates the worklet host once the context is verified. Normally the shipped factory. */ - readonly createHost: (request: { - readonly context: AudioContextLike; + readonly createHost?: (request: { + readonly context: Context; readonly document: Uint8Array; readonly options: ReturnType; readonly simd128ModuleUrl: string; @@ -93,9 +106,9 @@ export interface CreateEngineOptions { readonly contextAttempts?: number; } -export interface BrowserEngine { +export interface BrowserEngine { readonly shape: SessionShape; - readonly context: AudioContextLike; + readonly context: Context; readonly host: MisoAudioWorkletHost; /** Resolve the compiled session map once and bind the shared semantic console. */ console(): Promise; @@ -133,7 +146,14 @@ function documentBytes(document: CreateEngineOptions["document"]): Uint8Array { +export function createEngine( + options: CreateEngineOptions & { readonly createContext: NonNullable["createContext"]> }, +): Promise>; +export function createEngine( + options: CreateEngineOptions & { readonly createContext?: undefined }, +): Promise>; +export function createEngine(options: CreateEngineOptions): Promise>; +export async function createEngine(options: CreateEngineOptions): Promise> { const document = documentBytes(options.document); const policy = options.policy ?? {}; const simd128ModuleUrl = options.simd128ModuleUrl ?? BUNDLED_ENGINE_ASSETS.wasm.href; @@ -143,7 +163,15 @@ export async function createEngine(options: CreateEngineOptions): Promise scratchBootWithWorker({ + ...request, + moduleUrl: simd128ModuleUrl, + ...(options.scratchWorkerModuleUrl === undefined ? {} : { scratchWorkerModuleUrl: options.scratchWorkerModuleUrl }), + ...(options.createWorker === undefined ? {} : { createWorker: options.createWorker }), + ...(options.requestDeadlineMs === undefined ? {} : { requestDeadlineMs: options.requestDeadlineMs }), + ...(options.signal === undefined ? {} : { signal: options.signal }), + })); + const shape = await scratchBoot({ document, options: scratchBootOptions(policy), }); @@ -153,9 +181,10 @@ export async function createEngine(options: CreateEngineOptions): Promise createDefaultHost({ + ...request, + ...(options.hostModuleUrl === undefined ? {} : { hostModuleUrl: options.hostModuleUrl }), + })); + const host = await createHost({ context, document, options: workletBootOptions(policy, { @@ -228,9 +261,8 @@ export async function createEngine(options: CreateEngineOptions): Promise; @@ -248,3 +280,13 @@ export async function scratchBootInWorker(request: { boundary.dispose(); } } + +function defaultCreateContext(options: { sampleRate: number; renderSizeHint: number }): AudioContextLike { + const constructor = (globalThis as { AudioContext?: new (options: { + sampleRate: number; renderSizeHint: number; + }) => AudioContextLike }).AudioContext; + if (typeof constructor !== "function") { + throw new BrowserBootError("context-unavailable", "AudioContext is unavailable"); + } + return new constructor(options); +} diff --git a/sdk/src/browser/index.ts b/sdk/src/browser/index.ts index fc51757d2..a5b4a9616 100644 --- a/sdk/src/browser/index.ts +++ b/sdk/src/browser/index.ts @@ -1,11 +1,4 @@ -/** - * The browser entry: a scratch boot in a Worker, then an AudioContext, then the worklet. - * - * Source plumbing stays bring-your-own here. The store/resolver seam is #244's, and SDK core has - * no opinions about audio plumbing by ruling #207/5448359546 -- no OPFS, no fetch, no Workers in - * core -- so this entry takes the Worker boot and the context construction as injected functions - * rather than reaching for them itself. - */ +/** Package-owned browser boot, with independent context, scratch and host overrides. */ export * from "./engine.ts"; export * from "./console.ts"; export * from "./policy.ts"; @@ -15,3 +8,7 @@ export * from "./policy.ts"; export * from "./host-mirror.ts"; export * from "./pcm-ring.ts"; export * from "./pcm-feed.ts"; + +export { scratchBootWithWorker } from "./scratch.ts"; +export type { ScratchWorker, ScratchWorkerFactory } from "./scratch.ts"; +export { createDefaultHost, BrowserBootError } from "./default-host.ts"; diff --git a/sdk/src/browser/scratch-worker.ts b/sdk/src/browser/scratch-worker.ts new file mode 100644 index 000000000..a5be0a2f6 --- /dev/null +++ b/sdk/src/browser/scratch-worker.ts @@ -0,0 +1,41 @@ +import { MisoEngineError, MisoUsageError } from "../core/errors.ts"; +import { scratchBootInWorker } from "./engine.ts"; + +import type { ScratchBootReply, ScratchBootRequest } from "./scratch.ts"; + +interface Scope { + onmessage: ((event: MessageEvent) => void) | null; + postMessage(message: ScratchBootReply): void; +} +const scope = ((globalThis as unknown as { readonly self?: Scope }).self ?? globalThis) as unknown as Scope; + +scope.onmessage = (event) => { + const request = event.data; + void run(request).then((reply) => scope.postMessage(reply)); +}; +scope.postMessage({ type: "worker-ready" }); + +async function run(request: ScratchBootRequest): Promise { + try { + const response = await fetch(request.moduleUrl); + if (!response.ok) throw new Error(`Engine Wasm fetch failed with HTTP ${response.status}`); + const shape = await scratchBootInWorker({ + moduleBytes: new Uint8Array(await response.arrayBuffer()), + document: request.document, + options: request.options, + }); + return { type: "scratch-result", requestId: request.requestId, ok: true, shape }; + } catch (error) { + return { + type: "scratch-result", requestId: request.requestId, ok: false, + error: { + name: error instanceof Error ? error.name : "Error", + message: error instanceof Error ? error.message : String(error), + ...(error instanceof MisoEngineError ? { + kind: "engine" as const, + detail: { phase: error.phase, code: error.code, result: error.result, diagnostics: error.diagnostics }, + } : error instanceof MisoUsageError ? { kind: "usage" as const } : {}), + }, + }; + } +} diff --git a/sdk/src/browser/scratch.ts b/sdk/src/browser/scratch.ts new file mode 100644 index 000000000..74bb52a6c --- /dev/null +++ b/sdk/src/browser/scratch.ts @@ -0,0 +1,112 @@ +import type { BootOptions } from "../core/abi.ts"; +import type { SessionShape } from "../core/boundary.ts"; +import type { MisoEngineErrorInit } from "../core/errors.ts"; +import { MisoEngineError, MisoUsageError } from "../core/errors.ts"; +import { BUNDLED_ENGINE_ASSETS } from "../assets.ts"; +import { BrowserBootError } from "./default-host.ts"; + +export interface ScratchBootRequest { + readonly type: "scratch"; readonly requestId: number; readonly moduleUrl: string; + readonly document: Uint8Array; readonly options: BootOptions; +} +type ScratchFailure = { readonly name: string; readonly message: string } & ( + | { readonly kind: "engine"; readonly detail: MisoEngineErrorInit } + | { readonly kind: "usage" } + | { readonly kind?: undefined } +); +export type ScratchBootReply = + | { readonly type: "worker-ready" } + | { readonly type: "scratch-result"; readonly requestId: number; readonly ok: true; readonly shape: SessionShape } + | { readonly type: "scratch-result"; readonly requestId: number; readonly ok: false; readonly error: ScratchFailure }; + +/** Only the Worker operations used by a one-shot scratch boot. */ +export interface ScratchWorker { + postMessage(message: ScratchBootRequest): void; + terminate(): void; + addEventListener(type: "message" | "error" | "messageerror", listener: (event: any) => void): void; + removeEventListener(type: "message" | "error" | "messageerror", listener: (event: any) => void): void; +} +export type ScratchWorkerFactory = (url: URL, options: { readonly type: "module" }) => ScratchWorker; + +/** Boot once on a bounded module Worker, terminating before either outcome becomes observable. */ +export async function scratchBootWithWorker(options: { + readonly document: Uint8Array; + readonly options: BootOptions; + readonly moduleUrl: string | URL; + readonly scratchWorkerModuleUrl?: string; + readonly createWorker?: ScratchWorkerFactory; + readonly requestDeadlineMs?: number; + readonly signal?: AbortSignal; +}): Promise { + options.signal?.throwIfAborted(); + const deadline = options.requestDeadlineMs ?? 5_000; + if (!Number.isFinite(deadline) || deadline <= 0 || deadline > 2_147_483_647) { + throw new MisoUsageError("requestDeadlineMs must be positive and at most 2147483647"); + } + let worker: ScratchWorker; + try { + if (options.createWorker !== undefined) { + worker = options.createWorker(options.scratchWorkerModuleUrl === undefined + ? BUNDLED_ENGINE_ASSETS.scratchWorkerModule : new URL(options.scratchWorkerModuleUrl, import.meta.url), { type: "module" }); + } else if (options.scratchWorkerModuleUrl !== undefined) { + worker = new Worker(options.scratchWorkerModuleUrl, { type: "module" }); + } else { + worker = new Worker(new URL("./scratch-worker.js", import.meta.url), { type: "module" }); + } + } catch (error) { throw new BrowserBootError("scratch-start", "Scratch module Worker could not start", error); } + return new Promise((resolve, reject) => { + let settled = false; + let requested = false; + let timer: ReturnType; + const finish = (error: unknown, shape?: SessionShape) => { + if (settled) return; + settled = true; + clearTimeout(timer); + options.signal?.removeEventListener("abort", abort); + worker.removeEventListener("message", message); + worker.removeEventListener("error", failure); + worker.removeEventListener("messageerror", decodeFailure); + worker.terminate(); + if (shape !== undefined) resolve(shape); else reject(error); + }; + const arm = () => { + clearTimeout(timer); + timer = setTimeout(() => finish(new BrowserBootError("scratch-deadline", + requested ? "Scratch request exceeded its deadline" : "Scratch Worker handshake exceeded its deadline")), deadline); + }; + const abort = () => finish(options.signal?.reason); + const failure = (event: ErrorEvent) => finish(new BrowserBootError("scratch-load", "Scratch Worker failed", event.error ?? new Error(event.message))); + const decodeFailure = () => finish(new BrowserBootError("scratch-load", "Scratch Worker reply could not be decoded")); + const message = (event: MessageEvent) => { + if (settled) return; + const reply = event.data; + if (reply.type === "worker-ready" && !requested) { + if (options.signal?.aborted) { abort(); return; } + requested = true; + arm(); + try { worker.postMessage({ type: "scratch", requestId: 1, moduleUrl: String(options.moduleUrl), document: options.document, options: options.options }); } + catch (error) { finish(error); } + } else if (reply.type === "scratch-result" && requested && reply.requestId === 1) { + if (options.signal?.aborted) { abort(); return; } + if (reply.ok) finish(undefined, reply.shape); + else { + const failure = reply.error; + const error = failure.kind === "engine" + ? new MisoEngineError("", failure.detail) + : failure.kind === "usage" ? new MisoUsageError(failure.message) : new Error(failure.message); + // The engine message is already formatted in the Worker. Preserve it verbatim rather + // than applying the constructor's phase/diagnostic decoration twice or parsing it. + error.message = failure.message; + error.name = failure.name; + finish(error); + } + } + }; + worker.addEventListener("message", message); + worker.addEventListener("error", failure); + worker.addEventListener("messageerror", decodeFailure); + options.signal?.addEventListener("abort", abort, { once: true }); + arm(); + if (options.signal?.aborted) abort(); + }); +} diff --git a/sdk/test/browser-context-types.ts b/sdk/test/browser-context-types.ts new file mode 100644 index 000000000..06cfbcc99 --- /dev/null +++ b/sdk/test/browser-context-types.ts @@ -0,0 +1,17 @@ +import { createEngine } from "../src/browser/index.ts"; +const common = { + sampleRate: 48000, state: "suspended", close: async () => {}, + audioWorklet: { addModule: async (_url: string) => {} }, +}; +async function injectedContexts() { + const thin = await createEngine({ document: "", createContext: () => ({ ...common, marker: "thin" as const }) }); + const marker: "thin" = thin.context.marker; + // @ts-expect-error A thin factory cannot promise Web Audio methods. + thin.context.resume(); + const other = await createEngine({ document: "", createContext: () => ({ ...common, custom: () => 42 }) }); + const value: number = other.context.custom(); + // @ts-expect-error Independent factories do not acquire each other's properties. + other.context.marker; + return [marker, value]; +} +void injectedContexts; diff --git a/sdk/test/browser-defaults-evals.mjs b/sdk/test/browser-defaults-evals.mjs new file mode 100644 index 000000000..3ab7c877d --- /dev/null +++ b/sdk/test/browser-defaults-evals.mjs @@ -0,0 +1,183 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createEngine, scratchBootInWorker, scratchBootWithWorker, BrowserBootError, scratchBootOptions, workletBootOptions, toWebBootOptions } from "../src/browser/index.ts"; +import { MisoEngineError, MisoUsageError } from "../src/core/errors.ts"; +import { moduleBytes } from "./support.mjs"; +import { BUNDLED_ENGINE_ASSETS } from "../src/assets.ts"; + +const shape = { sampleRateHz: 48000, quantumFrames: 128 }; +class FakeWorker { + listeners = new Map(); history = new Map(); terminated = 0; requests = []; + addEventListener(type, listener) { const set = this.listeners.get(type) ?? new Set(); set.add(listener); this.listeners.set(type, set); const historical = this.history.get(type) ?? []; historical.push(listener); this.history.set(type, historical); } + removeEventListener(type, listener) { this.listeners.get(type)?.delete(listener); } + emit(type, data) { for (const listener of [...(this.listeners.get(type) ?? [])]) listener(type === "message" ? { data } : data); } + emitHistorical(type, data) { for (const listener of this.history.get(type) ?? []) listener(type === "message" ? { data } : data); } + postMessage(request) { this.requests.push(request); this.onPost?.(request); } + terminate() { this.terminated++; } + assertClosed() { assert.equal(this.terminated, 1); assert.equal([...this.listeners.values()].reduce((n,s) => n+s.size,0), 0); } +} +function boot(worker, extra = {}) { + return scratchBootWithWorker({ document: new Uint8Array([1]), options: {}, moduleUrl: "wasm", createWorker(url, options) { + assert.equal(url.href, BUNDLED_ENGINE_ASSETS.scratchWorkerModule.href); assert.deepEqual(options, { type: "module" }); return worker; + }, requestDeadlineMs: 20, ...extra }); +} +const result = { type: "scratch-result", requestId: 1, ok: true, shape }; +test("scratch succeeds once after ready, correlates and ignores late events", async () => { + const worker = new FakeWorker(); const pending = boot(worker); + worker.emit("message", result); assert.equal(worker.requests.length, 0); + worker.emit("message", { type: "worker-ready" }); worker.emit("message", { type: "worker-ready" }); + assert.equal(worker.requests.length, 1); assert.equal(worker.requests[0].moduleUrl, "wasm"); + worker.emit("message", { ...result, requestId: 2 }); worker.emit("message", result); + assert.equal(await pending, shape); worker.emitHistorical("message", result); worker.assertClosed(); +}); +for (const phase of ["handshake", "request"]) { + for (const fault of ["timeout", "abort", "error", "messageerror", "post", "reject"]) { + if (phase === "handshake" && ["post", "reject"].includes(fault)) continue; + test(`scratch closes on ${phase} ${fault}`, async () => { + const worker = new FakeWorker(); const controller = new AbortController(); const reason = new Error("stop"); + const pending = boot(worker, { signal: controller.signal }); + if (fault === "post") worker.onPost = () => { throw reason; }; + if (phase === "request") worker.emit("message", { type: "worker-ready" }); + if (fault === "abort") controller.abort(reason); + if (fault === "error") worker.emit("error", { error: reason }); + if (fault === "messageerror") worker.emit("messageerror", {}); + if (fault === "reject") worker.emit("message", { ...result, ok: false, error: { name: "Failure", message: "refused" } }); + await assert.rejects(pending, error => fault === "abort" || fault === "post" ? error === reason : error instanceof Error); + worker.emitHistorical("message", result); worker.assertClosed(); + }); + } +} +test("abort inside worker factory is observed before ready or request", async () => { + const worker = new FakeWorker(); const controller = new AbortController(); const reason = new Error("cancel"); + await assert.rejects(boot(worker, { signal: controller.signal, createWorker() { controller.abort(reason); return worker; } }), error => error === reason); + worker.assertClosed(); assert.equal(worker.requests.length, 0); +}); + +function context() { return { sampleRate: 48000, renderQuantumSize: 128, state: "suspended", audioWorklet: { async addModule() {} }, closed: 0, async close() { this.closed++; } }; } +function hostUrl(body) { return `data:text/javascript,${encodeURIComponent(body)}`; } +test("default context + host preserve exact context, URLs and mapped policy", async () => { + const previous = globalThis.AudioContext; + const candidate = context(); let constructed; + globalThis.AudioContext = class { constructor(options) { constructed = options; return candidate; } }; + globalThis.__sdkBootRequests = []; + const url = hostUrl('export async function createMisoAudioWorkletHost(request) { globalThis.__sdkBootRequests.push(request); return { async dispose() { globalThis.__sdkBootRequests.push("dispose"); } }; }'); + try { + const engine = await createEngine({ document: "raw", scratchBoot: async () => shape, hostModuleUrl: url, simd128ModuleUrl: "chosen-wasm", workletModuleUrl: "chosen-worklet" }); + assert.equal(engine.context, candidate); assert.deepEqual(constructed, { sampleRate: 48000, renderSizeHint: 128 }); + const request = globalThis.__sdkBootRequests[0]; assert.equal(request.context, candidate); + assert.deepEqual(request.options, toWebBootOptions(workletBootOptions({}, shape))); + assert.equal(request.simd128ModuleUrl, "chosen-wasm"); assert.equal(request.workletModuleUrl, "chosen-worklet"); + const close = engine.close(); assert.equal(engine.close(), close); await close; + assert.deepEqual(globalThis.__sdkBootRequests.slice(1), ["dispose"]); assert.equal(candidate.closed, 1); + } finally { if (previous === undefined) delete globalThis.AudioContext; else globalThis.AudioContext = previous; delete globalThis.__sdkBootRequests; } +}); +test("injected context and host independently retain default scratch worker", async () => { + const worker = new FakeWorker(); const candidate = context(); let request; + worker.onPost = value => { request = value; worker.emit("message", result); }; + const pending = createEngine({ document: "opaque", createContext: () => candidate, createHost: async () => ({ async dispose() {} }), simd128ModuleUrl: "override", createWorker: () => worker }); + worker.emit("message", { type: "worker-ready" }); const engine = await pending; + assert.equal(request.moduleUrl, "override"); assert.deepEqual(request.options, scratchBootOptions({})); worker.assertClosed(); await engine.close(); +}); +test("default host failures close accepted context and preserve factory refusal", async () => { + const refusalUrl = hostUrl('export async function createMisoAudioWorkletHost() { throw { tag: "miso.error.v1", result: 1 }; }'); + for (const url of ["data:text/javascript,throw new Error('load')", refusalUrl]) { + const candidate = context(); + await assert.rejects(createEngine({ document: "opaque", scratchBoot: async () => shape, createContext: () => candidate, hostModuleUrl: url }), error => url === refusalUrl ? error.tag === "miso.error.v1" : error instanceof BrowserBootError && error.operation === "host-import"); + assert.equal(candidate.closed, 1); + } +}); + +test("default host suspends a running native context before construction", async () => { + const candidate = context(); candidate.state = "running"; + const calls = []; + candidate.suspend = async () => { calls.push("suspend"); candidate.state = "suspended"; }; + const url = hostUrl('export async function createMisoAudioWorkletHost(request) { if (request.context.state !== "suspended") throw new Error("running"); return { async dispose() {} }; }'); + const engine = await createEngine({ document: "opaque", scratchBoot: async () => shape, createContext: () => candidate, hostModuleUrl: url }); + assert.deepEqual(calls, ["suspend"]); await engine.close(); assert.equal(candidate.closed, 1); +}); +test("suspension failure closes the accepted context and retains the error", async () => { + const candidate = context(); candidate.state = "running"; + const error = new Error("suspend failed"); + candidate.suspend = async () => { throw error; }; + await assert.rejects(createEngine({ document: "opaque", scratchBoot: async () => shape, createContext: () => candidate, hostModuleUrl: "not-an-importable-url" }), failure => failure === error); + assert.equal(candidate.closed, 1); +}); + + +test("scratch settlement clears timers and abort listeners; removed callbacks remain inert", async (t) => { + const timers = new Map(); const historicalTimers = []; + t.mock.method(globalThis, "setTimeout", (callback) => { const handle = {}; timers.set(handle, callback); historicalTimers.push(callback); return handle; }); + t.mock.method(globalThis, "clearTimeout", handle => timers.delete(handle)); + for (const outcome of ["success", "abort", "error"]) { + const worker = new FakeWorker(); const controller = new AbortController(); + const abortListeners = new Set(); const historicalAbort = []; + const add = controller.signal.addEventListener.bind(controller.signal); + const remove = controller.signal.removeEventListener.bind(controller.signal); + t.mock.method(controller.signal, "addEventListener", (type, listener, options) => { + if (type === "abort") { abortListeners.add(listener); historicalAbort.push(listener); } add(type, listener, options); + }); + t.mock.method(controller.signal, "removeEventListener", (type, listener) => { + if (type === "abort") abortListeners.delete(listener); remove(type, listener); + }); + let settlements = 0; + const pending = boot(worker, { signal: controller.signal }).then(() => { settlements++; }, () => { settlements++; }); + assert.equal(timers.size, 1); assert.equal(abortListeners.size, 1); + worker.emit("message", { type: "worker-ready" }); assert.equal(timers.size, 1); + if (outcome === "success") worker.emit("message", result); + else if (outcome === "abort") controller.abort(new Error("stop")); + else worker.emit("error", { error: new Error("failed") }); + await pending; + assert.equal(timers.size, 0, "no scratch deadline survives settlement"); + assert.equal(abortListeners.size, 0, "no abort listener survives settlement"); + worker.emitHistorical("message", { type: "worker-ready" }); + worker.emitHistorical("message", result); + worker.emitHistorical("error", { error: new Error("late") }); + worker.emitHistorical("messageerror", {}); + for (const callback of historicalAbort) callback(); + for (const callback of historicalTimers) callback(); + await Promise.resolve(); + assert.equal(settlements, 1); assert.equal(worker.requests.length, 1); + assert.equal(timers.size, 0); assert.equal(abortListeners.size, 0); worker.assertClosed(); + } +}); + +test("actual scratch entry and client retain real Wasm refusal and usage error types", async () => { + const bytes = await moduleBytes(); + const document = new TextEncoder().encode("{}"); + const options = scratchBootOptions({}); + let direct; + await assert.rejects(scratchBootInWorker({ moduleBytes: bytes, document, options }), error => { + assert.ok(error instanceof MisoEngineError); direct = error; return true; + }); + const oldSelf = globalThis.self; const oldFetch = globalThis.fetch; + let worker; + const scope = { onmessage: null, postMessage(reply) { worker?.emit("message", structuredClone(reply)); } }; + globalThis.self = scope; + globalThis.fetch = async () => new Response(bytes); + try { + await import("../src/browser/scratch-worker.ts"); + async function refused() { + worker = new FakeWorker(); + worker.onPost = request => scope.onmessage({ data: structuredClone(request) }); + const pending = scratchBootWithWorker({ document, options, moduleUrl: "wasm", createWorker: () => worker }); + worker.emit("message", { type: "worker-ready" }); + let refusal; + await assert.rejects(pending, error => { refusal = error; return true; }); + worker.assertClosed(); return refusal; + } + const transported = await refused(); + assert.ok(transported instanceof MisoEngineError); + for (const key of ["name", "message", "phase", "code", "result", "diagnostics", "diagnosticCode", "diagnosticPath"]) { + assert.deepEqual(transported[key], direct[key], key); + } + assert.ok(Object.isFrozen(transported.diagnostics)); + const usage = new MisoUsageError("Worker-side SDK usage refusal"); + globalThis.fetch = async () => { throw usage; }; + const transportedUsage = await refused(); + assert.ok(transportedUsage instanceof MisoUsageError); + assert.equal(transportedUsage.name, usage.name); assert.equal(transportedUsage.message, usage.message); + } finally { + if (oldSelf === undefined) delete globalThis.self; else globalThis.self = oldSelf; + globalThis.fetch = oldFetch; + } +}); diff --git a/sdk/test/package-tarball-smoke.mjs b/sdk/test/package-tarball-smoke.mjs index 8ae5bbad9..11e58912a 100644 --- a/sdk/test/package-tarball-smoke.mjs +++ b/sdk/test/package-tarball-smoke.mjs @@ -32,6 +32,13 @@ for (const subpath of [".", "./headless", "./browser", "./assets"]) { assert.equal(typeof imported["."].session, "function"); assert.equal(typeof imported["./headless"].createOfflineEngine, "function"); assert.equal(typeof imported["./browser"].createEngine, "function"); +assert.equal(typeof imported["./browser"].scratchBootWithWorker, "function"); +assert.equal(typeof imported["./browser"].createDefaultHost, "function"); +const workerText = await readFile(imported["./assets"].BUNDLED_ENGINE_ASSETS.scratchWorkerModule, "utf8"); +const workerAst = ts.createSourceFile("scratch-worker.js", workerText, ts.ScriptTarget.ES2022, true, ts.ScriptKind.JS); +assert.equal(workerAst.statements.some(statement => ts.isImportDeclaration(statement) + || (ts.isExportDeclaration(statement) && statement.moduleSpecifier !== undefined)), false, +"the exported Worker URL is import-complete when a consumer bundler copies it"); assert.equal(typeof imported["./browser"].attachEngineFeed, "function"); assert.equal(typeof imported["./browser"].prepareEngineFeed, "function"); assert.ok(imported["./assets"].BUNDLED_ENGINE_ASSETS.wasm instanceof URL); @@ -122,6 +129,24 @@ const packedWriter = new Msb1RingWriter(packedFeed.rings[0]); packedWriter.engage(1n); void packedWriter; void prepareEngineFeed(domContext, BUNDLED_ENGINE_ASSETS.pcmFeedWorklet); +async function defaultBrowserContext() { + const engine = await createEngine({ document: "opaque" }); + await engine.context.resume(); + await engine.context.suspend(); + engine.host.node.connect(engine.context.destination); + const nativeContext: AudioContext = engine.context; + const forwarded = await createEngine({ document: "opaque", createWorker: (url, options) => new Worker(url, options) }); + await forwarded.context.resume(); + const thin = await createEngine({ document: "opaque", createContext: () => ({ + sampleRate: 48000, state: "suspended", close: async () => {}, + audioWorklet: { addModule: async (_url: string) => {} }, marker: "thin" as const, + }) }); + const marker: "thin" = thin.context.marker; + // @ts-expect-error Thin injected contexts never acquire DOM capabilities. + thin.context.resume(); + void [nativeContext, marker]; +} +void defaultBrowserContext; declare const browser: BrowserEngine; const host = browser.host; void host.command({ commands: [] }); @@ -301,3 +326,85 @@ try { } finally { await writeFile(wasmUrl, original); } + +// Opt-in real-browser qualification reuses an existing Vite/Playwright installation; neither is +// a package dependency. Set MISO_ENGINE_SDK_BROWSER_TOOLS to that installation's node_modules. +// This deliberately tests a production bundle: a raw new-URL Worker asset must survive copying, +// not just Vite's special handling of the default literal Worker constructor. +if (process.env.MISO_ENGINE_SDK_BROWSER_TOOLS) { + const toolsRoot = resolve(process.env.MISO_ENGINE_SDK_BROWSER_TOOLS); + const [{ build }, { chromium }, { createServer }] = await Promise.all([ + import(pathToFileURL(resolve(toolsRoot, "vite/dist/node/index.js")).href), + import(pathToFileURL(resolve(toolsRoot, "playwright/index.mjs")).href), + import("node:http"), + ]); + const browserRoot = resolve(consumerRoot, "browser"); + await mkdir(browserRoot); + await writeFile(resolve(browserRoot, "index.html"), ''); + await writeFile(resolve(browserRoot, "main.js"), ` +import { createEngine } from '@misofm/engine/browser'; +import { BUNDLED_ENGINE_ASSETS } from '@misofm/engine/assets'; +const sessionDocument = ${JSON.stringify(builtDocument)}; +window.proof = []; +for (const id of ['default', 'forward']) document.querySelector('#' + id).onclick = async () => { + try { + const calls = []; + const engine = await createEngine({ document: sessionDocument, ...(id === 'forward' ? { + createWorker(url, options) { + calls.push({ url: String(url), expected: String(BUNDLED_ENGINE_ASSETS.scratchWorkerModule), type: options.type }); + return new Worker(url, options); + }, + } : {}) }); + engine.host.node.connect(engine.context.destination); + await engine.context.resume(); + const status = await engine.host.status(); + await engine.context.suspend(); + await engine.close(); + await engine.close(); + window.proof.push({ id, calls, rate: engine.shape.sampleRateHz, quantum: engine.shape.quantumFrames, result: status.result, state: engine.context.state }); + } catch (error) { window.bootError = String(error?.stack ?? JSON.stringify(error)); } +}; +`); + await build({ root: browserRoot, configFile: false, logLevel: "warn" }); + const network = []; + const faults = []; + const server = createServer(async (request, response) => { + const pathname = new URL(request.url, "http://localhost").pathname; + try { + const bytes = await readFile(resolve(browserRoot, "dist", `.${pathname === "/" ? "/index.html" : pathname}`)); + const mime = pathname.endsWith(".js") ? "text/javascript" : pathname.endsWith(".wasm") ? "application/wasm" : "text/html"; + response.writeHead(200, { "content-type": mime }); response.end(bytes); + } catch { response.writeHead(404); response.end(); } + }); + await new Promise((accept, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", accept); }); + let browser; + try { + browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + page.on("pageerror", error => faults.push(error.message)); + page.on("requestfailed", request => faults.push(`${request.url()}: ${request.failure()?.errorText}`)); + page.on("response", response => network.push({ url: response.url(), status: response.status() })); + await page.goto(`http://127.0.0.1:${server.address().port}`); + await page.waitForLoadState("networkidle"); + for (const [id, count] of [["default", 1], ["forward", 2]]) { + await page.locator(`#${id}`).click(); + await page.waitForFunction(count => window.proof.length === count || window.bootError, count, { timeout: 20000 }); + assert.equal(await page.evaluate(() => window.bootError), undefined); + } + const results = await page.evaluate(() => window.proof); + for (const result of results) { + assert.equal(result.rate, 48000); assert.equal(result.quantum, 128); + assert.equal(result.result, 0); assert.equal(result.state, "closed"); + } + assert.deepEqual(results[0].calls, []); + assert.equal(results[1].calls.length, 1); + assert.equal(results[1].calls[0].url, results[1].calls[0].expected); + assert.equal(results[1].calls[0].type, "module"); + assert.deepEqual(faults, []); + assert.equal(network.some(response => response.status >= 400), false); + console.log(`packed Vite/Chromium browser boot passed: ${JSON.stringify({ results, network })}`); + } finally { + await browser?.close(); + await new Promise(accept => server.close(accept)); + } +}