diff --git a/.github/ISSUE_SPECS/131-move-the-web-adapter-to-stable-effect-4-0-x.md b/.github/ISSUE_SPECS/131-move-the-web-adapter-to-stable-effect-4-0-x.md new file mode 100644 index 0000000..1225f13 --- /dev/null +++ b/.github/ISSUE_SPECS/131-move-the-web-adapter-to-stable-effect-4-0-x.md @@ -0,0 +1,30 @@ +# Move the web adapter to stable Effect 4.0.x (0.6.0) + +GitHub: https://github.com/misofm/engine-web-adapter/issues/131 + +## Product outcome + +Release `@misofm/engine-web-adapter@0.6.0` on stable Effect `4.0.x` with `@misofm/codec@0.2.0`, so an application on the Effect 4.0.4 train decodes FLAC in the mixer. `0.5.13` depends on `effect@4.0.0-rc.112`; when the codec resolves the application's Effect 4.x copy while the adapter keeps its nested rc.112 copy (codec 0.2.0, or codec 0.1.1 under bun's loose peers or `npm --legacy-peer-deps`), the adapter's `Layer.build(makeFlacDecoderLayer(bytes))` fails with `TypeError: Cannot read properties of undefined (reading 'scheduler')` and no stem decodes. Unit tests resolve one Effect copy and did not catch it. + +Base: origin/main `602b7c7` (adapter 0.5.13). Branch `effect-4.0.4`, draft PR https://github.com/misofm/engine-web-adapter/pull/130. Codec prerequisite: https://github.com/misofm/codec/issues/10 (PR https://github.com/misofm/codec/pull/9). Effect guidance: [skills revision 0507930](https://github.com/unconfirmedlabs/skills/tree/0507930b6dd13f39739aba40b86a6afb63d32e09/effect-ts) (`references/migration.md`, "Effect v4 release candidates to 4.0.x"). + +## Smallest bounded implementation + +- `package.json`: version 0.6.0; `effect` moves from a dependency (exact rc.112) to a peer `>=4.0.3 <4.1` with exact dev dependency 4.0.4, so the adapter, codec, and application share one copy; `@misofm/codec` exact `0.2.0`; `CHANGELOG.md` in `files`. Engine stays `0.4.3`. +- Codec 0.2.0 was published (https://github.com/misofm/codec/releases/tag/v0.2.0, registry integrity `sha512-nbhiEKPB…ksUmqGA==`), so the dependency is the registry package and the lockfile resolves its registry tarball with that integrity; during development it was a git ref to the throwaway branch `misofm/codec:effect-4.0.4-ci-dist`. `npm-publish.yml` refuses to run unless `dependencies['@misofm/codec'] == "0.2.0"` and the peer range matches, and repeats both checks against the registry artifact. +- `effect/unstable/http` becomes `effect/http` (three modules). No other compile change. Built `.d.ts` differ only in that import in `stems/sparse-response.d.ts`. +- Preserve rc.112 behaviour: `preflightExpected` and `preflightSpan` check `Reflect.ownKeys` because Effect 4.0.x (#8423) strips non-enumerable extras under `onExcessProperty: "error"` (tests added). +- Deliberate behaviour change: the sparse full GET disables `HttpClient.TracerPropagationEnabled`, as the FLAC range lane does, so `b3`/`traceparent` headers no longer force a CORS preflight (test added). +- Browser gate scripts no longer copy `fast-check`/`pure-rand`/`msgpackr`; `NOTICE` names the codec decoder asset hash instead of codec 0.1.0; workflow job name and `PACKAGE_VERSION` are 0.6.0; `CHANGELOG.md` records the audit and migration note. +- Do not change exports, workers, AudioWorklet code, delivery policy, or the Wasm assets. + +## Objective gates + +1. `npm ci --ignore-scripts` and `npm run check` (383 tests, SHA-256/BLAKE3 Wasm reproducibility, package policy). Also the full suite with `effect@4.0.3` installed. +2. `npm run test:browser`, `node scripts/browser-packed.mjs --indexed-sparse`, and the Chromium part of `npm run test:browser:opfs` with the installed Chromium; PR workflow Packed OPFS qualification (macOS Chromium and WebKit) green. +3. Crash repro in a consumer with `effect@4.0.4`: codec 0.1.1 with adapter 0.5.13 fails with the `scheduler` TypeError; codec 0.2.0 with the packed adapter 0.6.0 compiles the decoder module. `npm install --strict-peer-deps` and `npm ls` valid on 4.0.4 and 4.0.3; 4.0.0-rc.115 rejected. +4. Release only from `main` with the published codec 0.2.0 dependency. + +## Release contract and blockers + +Run `.github/workflows/npm-publish.yml` on `main` (`mode=publish`, the exact 40-character merge SHA); the workflow publishes through OIDC only, refuses an existing version, and verifies the registry artifact. Record the run URL, registry integrity, and archive hashes here. Blockers: PR #130 awaits review and merge. Publication has not been performed. diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 1059e1a..59d6815 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -23,12 +23,12 @@ concurrency: env: PACKAGE_NAME: "@misofm/engine-web-adapter" - PACKAGE_VERSION: "0.5.13" + PACKAGE_VERSION: "0.6.0" NPM_CONFIG_REGISTRY: "https://registry.npmjs.org" jobs: publish: - name: ${{ inputs.mode }} @misofm/engine-web-adapter 0.5.13 + name: ${{ inputs.mode }} @misofm/engine-web-adapter 0.6.0 runs-on: ubuntu-24.04 steps: - name: Refuse a non-main or mismatched dispatch @@ -64,6 +64,8 @@ jobs: [[ "$(node -p "require('./package-lock.json').name")" == "$PACKAGE_NAME" ]] [[ "$(node -p "require('./package-lock.json').version")" == "$PACKAGE_VERSION" ]] [[ "$(node -p "require('./package.json').dependencies['@misofm/engine']")" == "0.4.3" ]] + [[ "$(node -p "require('./package.json').dependencies['@misofm/codec']")" == "0.2.0" ]] + [[ "$(node -p "require('./package.json').peerDependencies.effect")" == ">=4.0.3 <4.1" ]] [[ "$(node -p "require('./package.json').repository.url")" == "git+https://github.com/misofm/engine-web-adapter.git" ]] - name: Install dependencies @@ -139,6 +141,8 @@ jobs: sleep 5 done [[ "$(npm view "$PACKAGE_NAME@$PACKAGE_VERSION" dependencies.@misofm/engine)" == "0.4.3" ]] + [[ "$(npm view "$PACKAGE_NAME@$PACKAGE_VERSION" dependencies.@misofm/codec)" == "0.2.0" ]] + [[ "$(npm view "$PACKAGE_NAME@$PACKAGE_VERSION" peerDependencies.effect)" == ">=4.0.3 <4.1" ]] consumer=$(mktemp -d) trap 'rm -rf -- "$consumer"' EXIT cd "$consumer" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f1ad5be --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,108 @@ +# Changelog + +All notable changes to `@misofm/engine-web-adapter`, newest first. + +## 0.6.0 + +Moves the package from Effect `4.0.0-rc.112` to stable Effect `4.0.x` (developed and +tested on `4.0.4`, peer floor `4.0.3`) and from `@misofm/codec@0.1.1` to `0.2.0`. +The runtime API is unchanged: no export was added, removed or re-typed, the session, +resolver, delivery, store and error contracts are untouched, and the only difference +in the built `.d.ts` files is that `stems/sparse-response.d.ts` imports `HttpClient` +from `effect/http` instead of `effect/unstable/http` (Effect removed the `unstable` +path in rc.118). + +### Why this release exists + +`0.5.13` depended on `effect@4.0.0-rc.112` directly, so it always ran its own rc.112 +Effect copy. The failure appears whenever the codec resolves the application's Effect +4.x copy while the adapter keeps that nested rc.112 copy: with `@misofm/codec@0.2.0` +(whose peer is satisfied by the application's 4.0.x), and also with `@misofm/codec@0.1.1` +under package managers that accept an out-of-range peer, such as bun's loose peer +resolution or `npm --legacy-peer-deps` (npm's strict peer check refuses that tree). +The adapter's rc.112 runtime then runs a `Layer` built by the codec's 4.0.x copy +(`Effect.runPromise(Effect.scoped(Layer.build(makeFlacDecoderLayer(bytes))))` while +loading the decoder module) and fails with `TypeError: Cannot read properties of +undefined (reading 'scheduler')`, so FLAC decoding never starts. The adapter's unit tests +did not catch this because they resolve a single Effect copy. + +### Changed (breaking) + +- **`effect` is now a peer dependency, `>=4.0.3 <4.1`** (was a hard dependency on exactly + `4.0.0-rc.112`). The adapter, `@misofm/codec` and your application now share the one + Effect copy you install. Release candidates are no longer supported; stay on 0.5.x for + `effect@4.0.0-rc.112`. +- **`@misofm/codec` is pinned to exactly `0.2.0`** (was `0.1.1`), whose own peer is + `effect >=4.0.3 <4.1`. +- The `fast-check`, `pure-rand` and `msgpackr` packages that Effect rc.112 pulled in are + no longer installed: Effect 4.0.x has no runtime dependencies. + +### Behaviour changes + +- **Sparse full GET no longer sends trace-propagation headers.** `openSparseResponse` + now provides `HttpClient.TracerPropagationEnabled = false`, as the FLAC range lane + already did. The request always runs under the `SparseResponse.open` span, and with + propagation enabled Effect adds `b3` and `traceparent` headers, which turn a + cross-origin GET into a CORS preflight. A regression test captures the headers (they + were sent before this change). If you relied on those headers reaching your stem host, + that no longer happens. +- **Unknown properties on a sparse expectation or span are still rejected, including + non-enumerable ones.** Effect 4.0.x (#8423) silently strips a non-enumerable extra key + under `onExcessProperty: "error"`, where rc.112 rejected it. `preflightExpected` and + `preflightSpan` now check `Reflect.ownKeys`, so `installSource`/`openSource` + expectations and resolver spans behave as in 0.5.13 (tests added). +- **Cause shape on cancelled FLAC range reads.** The download-admission acquire in + `flac-delivery.ts` (`Effect.acquireRelease`, uninterruptible) is the one region where an + interruption can meet a failure. From Effect 4.0.1 (#8652) an interrupt that arrives + while it is failing is kept in the cause: the cause is `Fail, Interrupt` where rc.112 + produced `Fail` only (checked on both versions). The public error is unchanged + (`stem.cancelled` for an aborted operation, otherwise the same typed delivery error); + only the nested `cause` aggregate may now list one extra `AbortError` entry. + +### Audited without a behaviour change + +The package uses `Effect.timeout`/`timeoutOrElse`/`raceFirst`, `Effect.acquireUseRelease`, +`Effect.uninterruptible`, `Scope.make`/`close`, `Effect.callback`, +`Stream.fromPull`/`transformPull`/`paginate`/`timeoutOrElse`, `Channel.toPullScoped`, +`Queue.bounded`, `Layer.build`, `HttpClient.withScope`, `FetchHttpClient`, `Cause.*`, +`Exit.*`, `Schema.TaggedError` and Schema decoding, and no `partition`, `Schedule`, +`Effect.retry` or Effect `Worker` module. + +- `Scope.close` finalizers run uninterruptibly (Effect rc.118). None of the adapter's + scope closes (sparse acquisition scope, async-iterator bridge scope, response scopes) + is reached by interrupting the closer, so nothing changes here. +- Finalizer order (reverse of registration) and the one concurrent `Effect.forEach` + (sparse source preparation, #8585 keeps sibling cleanup failures alongside an + interruption) were checked; the cancellation, cleanup-failure and ordering tests pass + unchanged. +- `Effect.acquireUseRelease` releases when the use callback throws synchronously + (rc.113) and `Effect.race*` interrupts losers that are still starting (4.0.0). In the + abort helper (`abortEffect`), a loser's `Effect.callback` cleanup removes the `abort` + listener from the operation signal, and a winner's listener fires once (`{ once: true }`). +- The `store.openSession` call inside `Effect.uninterruptible` (sparse acquisition) + already awaited the store's own cancellation and is unchanged. +- `Effect.timeout` still fails with a `TimeoutError` whose `_tag` the adapter matches with + `catchTag`; no adapter code reads the message text. +- A `SchemaError` is serialized as a compact `{ _tag, message }` object in JSON and Node + inspection (4.0.3). The adapter never serializes one; its errors are + `EngineWebAdapterError` values. +- The `internal/*.js` Worker bundles embed Effect `4.0.4` and `@misofm/codec@0.2.0` at + build time, whatever Effect version your application installs inside the peer range. + The main-thread modules import your Effect copy. + +Checks run for this release are listed in the pull request: the full `npm run check` +(383 tests), the packed Chromium consumer with real OPFS and a Worker FLAC decode, the +indexed sparse packed lane, and the macOS OPFS qualification workflow. + +### Migrating from 0.5.x + +1. Install Effect yourself and move the whole Effect release train to stable together, + pinned to one `4.0.x` version, for example `"effect": "4.0.4"` plus every `@effect/*` + package you use at `4.0.4`. A mixed release-candidate and stable install fails to + resolve `effect/*` module paths, and two copies of `effect` fail at runtime with the + `reading 'scheduler'` error above. Do not rely on the peer range alone: it admits + `>=4.0.3 <4.1`, but each `@effect/*` package peers on its own `effect` floor. +2. Upgrade `@misofm/codec` to `0.2.0` if you import it directly, and move every other + `@misofm/*` package that depends on `effect` to a stable-Effect release in the same + change. +3. Nothing to change in code that imports from `@misofm/engine-web-adapter`. diff --git a/NOTICE b/NOTICE index 155199e..9178f96 100644 --- a/NOTICE +++ b/NOTICE @@ -33,7 +33,9 @@ misofm/app commit 7485693e9bbcf2f65a91a4e5950e22d678d99062; their retained attribution is shipped with the SDK that now owns these implementations. This product redistributes the pinned libFLAC 1.5.0 WebAssembly decoder owned -and published by `@misofm/codec@0.1.0`. The packed adapter also bundles the +and published by `@misofm/codec` (decoder asset SHA-256 +`70caf38185675dff89498e89f98171d49ec6f143a56c6895088d93c35e2018cd`, first +released in 0.1.1 and shipped unchanged in 0.2.0). The packed adapter also bundles the public codec's Effect-powered worker implementation. The complete codec, Effect, and hash-wasm license texts, together with the codec's third-party notices and licenses for the bundled Wasm asset, are included in `dist/codec-licenses/`. diff --git a/README.md b/README.md index 77eadd4..ef174ae 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # @misofm/engine-web-adapter Headless, framework-neutral browser session hosting for -`@misofm/engine@0.4.3`. Version 0.5 identifies canonical PCM with BLAKE3-256 and streams standards-compliant native FLAC -through bounded HTTP ranges and a one-stem universal `@misofm/codec@0.1.1` +`@misofm/engine@0.4.3`. Canonical PCM is identified with BLAKE3-256, and standards-compliant native FLAC streams +through bounded HTTP ranges and a one-stem universal `@misofm/codec@0.2.0` Wasm Worker, verifies canonical PCM into OPFS, then feeds the Engine through bounded shared-memory rings. URL, authentication, and request mapping remain caller-owned. @@ -10,11 +10,16 @@ rings. URL, authentication, and request mapping remain caller-owned. ## Install ```sh -npm install @misofm/engine-web-adapter@0.5.13 @misofm/engine@0.4.3 +npm install @misofm/engine-web-adapter@0.6.0 @misofm/engine@0.4.3 effect@4.0.4 ``` +Effect is a peer dependency (`effect >=4.0.3 <4.1`): the adapter, the codec, and +your application must share one Effect copy, so pin the whole Effect release train +you use to one exact `4.0.x` version. See the [changelog](CHANGELOG.md) for the +migration from 0.5.x (Effect `4.0.0-rc.112`). + The package is ESM-only and remains pinned to exactly Engine `0.4.3` and -`@misofm/codec` `0.1.1`. The codec currently supports Node `>=22.23.2 <23` +`@misofm/codec` `0.2.0`. The codec currently supports Node `>=22.23.2 <23` and Bun `>=1.4.2 <1.5`; browser consumers use the bundled public codec asset. The integration uses the published Engine 0.4.3 archive from commit `a3d71148e4144a396aa1ceb7fb83b6e671a0eced`, SHA256 diff --git a/package-lock.json b/package-lock.json index 9ecceac..3eccfd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,21 +1,21 @@ { "name": "@misofm/engine-web-adapter", - "version": "0.5.13", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@misofm/engine-web-adapter", - "version": "0.5.13", + "version": "0.6.0", "license": "Apache-2.0", "dependencies": { - "@misofm/codec": "0.1.1", + "@misofm/codec": "0.2.0", "@misofm/engine": "0.4.3", - "effect": "4.0.0-rc.112", "hash-wasm": "4.12.0" }, "devDependencies": { "@types/node": "22.20.1", + "effect": "4.0.4", "playwright-core": "1.62.1", "typescript": "5.9.3", "vite": "8.2.2" @@ -23,19 +23,22 @@ "engines": { "bun": ">=1.4.2 <1.5", "node": ">=22.23.2 <23" + }, + "peerDependencies": { + "effect": ">=4.0.3 <4.1" } }, "node_modules/@misofm/codec": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/@misofm/codec/-/codec-0.1.1.tgz", - "integrity": "sha512-FKpciqHaUuIOlXcyB+N1xGKcvZC57P0vEka+w+QL84WhwaxcqZA6pqATids+zQSwm7ldp9Dv4yFdT4fTQ8JoZQ==", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@misofm/codec/-/codec-0.2.0.tgz", + "integrity": "sha512-nbhiEKPBElcwB4sc57McQLWh/1hdXLgPH2W2Dz2qU3luxiiNXtCwbCU/BunGuTrRNVudnrJod8rITv5ksUmqGA==", "license": "Apache-2.0", "engines": { "bun": ">=1.4.2 <1.5", "node": ">=22.23.2 <23" }, "peerDependencies": { - "effect": "4.0.0-rc.112 || 4.0.0-rc.115" + "effect": ">=4.0.3 <4.1" } }, "node_modules/@misofm/engine": { @@ -50,84 +53,6 @@ "node": ">=20" } }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", - "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", - "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", - "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", - "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", - "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", - "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@oxc-project/types": { "version": "0.148.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", @@ -414,43 +339,17 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" } }, "node_modules/effect": { - "version": "4.0.0-rc.112", - "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-rc.112.tgz", - "integrity": "sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==", - "license": "MIT", - "dependencies": { - "fast-check": "^4.9.0", - "msgpackr": "^2.0.5" - } - }, - "node_modules/fast-check": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz", - "integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT", - "dependencies": { - "pure-rand": "^8.0.0" - }, - "engines": { - "node": ">=12.17.0" - } + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.4.tgz", + "integrity": "sha512-Sr50OeVSC3M5UkoUYUhFf3EULryhGaZZUf6OzDHTvaznOw7HRlxOKdF3/4nvQXHL22StOyMG3Cvo8Bt7xUp+Ng==", + "license": "MIT" }, "node_modules/fdir": { "version": "6.5.0", @@ -752,37 +651,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/msgpackr": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.1.0.tgz", - "integrity": "sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==", - "license": "MIT", - "optionalDependencies": { - "msgpackr-extract": "^3.0.4" - } - }, - "node_modules/msgpackr-extract": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", - "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-gyp-build-optional-packages": "5.2.2" - }, - "bin": { - "download-msgpackr-prebuilds": "bin/download-prebuilds.js" - }, - "optionalDependencies": { - "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", - "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" - } - }, "node_modules/nanoid": { "version": "3.3.18", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", @@ -802,21 +670,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/node-gyp-build-optional-packages": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", - "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.1" - }, - "bin": { - "node-gyp-build-optional-packages": "bin.js", - "node-gyp-build-optional-packages-optional": "optional.js", - "node-gyp-build-optional-packages-test": "build-test.js" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -879,22 +732,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/pure-rand": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", - "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT" - }, "node_modules/rolldown": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", diff --git a/package.json b/package.json index 4771b3c..4625d7c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@misofm/engine-web-adapter", - "version": "0.5.13", + "version": "0.6.0", "description": "Bounded FLAC browser session adapter for @misofm/engine using @misofm/codec", "license": "Apache-2.0", "repository": { @@ -15,7 +15,8 @@ "dist", "LICENSE", "NOTICE", - "README.md" + "README.md", + "CHANGELOG.md" ], "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -61,13 +62,16 @@ "check": "npm run format:check && npm run build:sha256:wasm && npm run build:blake3:wasm && npm run lint && npm run check:decoder && npm test && npm run check:package" }, "dependencies": { - "@misofm/codec": "0.1.1", + "@misofm/codec": "0.2.0", "@misofm/engine": "0.4.3", - "effect": "4.0.0-rc.112", "hash-wasm": "4.12.0" }, + "peerDependencies": { + "effect": ">=4.0.3 <4.1" + }, "devDependencies": { "@types/node": "22.20.1", + "effect": "4.0.4", "playwright-core": "1.62.1", "typescript": "5.9.3", "vite": "8.2.2" diff --git a/scripts/browser-opfs.mjs b/scripts/browser-opfs.mjs index e62f7b2..8609ca9 100644 --- a/scripts/browser-opfs.mjs +++ b/scripts/browser-opfs.mjs @@ -24,9 +24,6 @@ await cp(join(process.cwd(), "node_modules", "@misofm", "engine"), join(consumer await cp(join(process.cwd(), "node_modules", "@misofm", "codec"), join(consumer, "node_modules", "@misofm", "codec"), { recursive: true }); await cp(join(process.cwd(), "node_modules", "effect"), join(consumer, "node_modules", "effect"), { recursive: true }); await cp(join(process.cwd(), "node_modules", "hash-wasm"), join(consumer, "node_modules", "hash-wasm"), { recursive: true }); -for (const dependency of ["fast-check", "pure-rand", "msgpackr", "msgpackr-extract"]) { - await cp(join(process.cwd(), "node_modules", dependency), join(consumer, "node_modules", dependency), { recursive: true }); -} await writeFile(join(consumer, "package.json"), JSON.stringify({ type: "module" })); await writeFile(join(consumer, "index.html"), '
loading
\n'); await mkdir(join(consumer, "src")); diff --git a/scripts/browser-packed.mjs b/scripts/browser-packed.mjs index bd93f7b..08544f4 100644 --- a/scripts/browser-packed.mjs +++ b/scripts/browser-packed.mjs @@ -51,9 +51,6 @@ await cp(join(process.cwd(), "node_modules", "@misofm", "engine"), join(consumer await cp(join(process.cwd(), "node_modules", "@misofm", "codec"), join(consumer, "node_modules", "@misofm", "codec"), { recursive: true }); await cp(join(process.cwd(), "node_modules", "effect"), join(consumer, "node_modules", "effect"), { recursive: true }); await cp(join(process.cwd(), "node_modules", "hash-wasm"), join(consumer, "node_modules", "hash-wasm"), { recursive: true }); -for (const dependency of ["fast-check", "pure-rand", "msgpackr", "msgpackr-extract"]) { - await cp(join(process.cwd(), "node_modules", dependency), join(consumer, "node_modules", dependency), { recursive: true }); -} await mkdir(join(consumer, "public")); await cp(join(process.cwd(), "tests", "fixtures", "native-silence.flac"), join(consumer, "public", "native-silence.flac")); // Existing SDK457 first-output recorder, scoped to this packed consumer fixture. diff --git a/scripts/check-package.mjs b/scripts/check-package.mjs index f4df6bb..7a8696a 100644 --- a/scripts/check-package.mjs +++ b/scripts/check-package.mjs @@ -6,13 +6,15 @@ import { tmpdir } from "node:os"; import { spawnSync } from "node:child_process"; const packageJson = JSON.parse(await readFile("package.json", "utf8")); -assert.equal(packageJson.version, "0.5.13"); +assert.equal(packageJson.version, "0.6.0"); assert.deepEqual(packageJson.dependencies, { - "@misofm/codec": "0.1.1", + "@misofm/codec": "0.2.0", "@misofm/engine": "0.4.3", - effect: "4.0.0-rc.112", "hash-wasm": "4.12.0", }); +// Effect is a peer so an app's single Effect copy runs every Layer the adapter and codec build. +assert.deepEqual(packageJson.peerDependencies, { effect: ">=4.0.3 <4.1" }); +assert.equal(packageJson.devDependencies.effect, "4.0.4"); assert.equal(packageJson.type, "module"); const blake3Manifest = JSON.parse(await readFile("src/stems/blake3-wasm.manifest.json", "utf8")); const blake3Wasm = await import("../dist/stems/blake3-wasm.js"); @@ -55,7 +57,7 @@ for (const required of [ "dist/codec-licenses/vendor/licenses/emscripten.txt", "dist/codec-licenses/vendor/licenses/libFLAC.txt", "dist/codec-licenses/vendor/licenses/musl.txt", - "README.md", "NOTICE", "LICENSE", + "README.md", "CHANGELOG.md", "NOTICE", "LICENSE", ]) assert.ok(names.has(required), `packed artifact missing ${required}`); for (const [packedPath, sourcePath] of [ ["dist/codec-licenses/codec-LICENSE", "node_modules/@misofm/codec/LICENSE"], diff --git a/src/stems/flac-delivery.ts b/src/stems/flac-delivery.ts index 25a6787..19fa5be 100644 --- a/src/stems/flac-delivery.ts +++ b/src/stems/flac-delivery.ts @@ -1,5 +1,5 @@ import { Cause, Effect, Exit, Stream } from "effect"; -import { FetchHttpClient, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http"; +import { FetchHttpClient, HttpClient, HttpClientError, HttpClientRequest } from "effect/http"; import { EngineWebAdapterError } from "../errors.js"; import type { BoundedStemAdmission } from "./flac-admission.js"; diff --git a/src/stems/sparse-resolver.ts b/src/stems/sparse-resolver.ts index 93494c4..6cf65ab 100644 --- a/src/stems/sparse-resolver.ts +++ b/src/stems/sparse-resolver.ts @@ -1,5 +1,5 @@ import { Cause, Channel, Context, Effect, Exit, Fiber, Pull, Schema, Scope, Stream } from "effect"; -import { FetchHttpClient } from "effect/unstable/http"; +import { FetchHttpClient } from "effect/http"; import { EngineWebAdapterError } from "../errors.js"; import { BoundedStemAdmission, defaultFlacMemoryBudgetBytes, flacPipelineWidths, FLAC_WORKER_RESERVATION_BYTES } from "./flac-admission.js"; diff --git a/src/stems/sparse-response.ts b/src/stems/sparse-response.ts index b9b1984..203d7a1 100644 --- a/src/stems/sparse-response.ts +++ b/src/stems/sparse-response.ts @@ -1,5 +1,5 @@ import { Cause, Effect, Exit } from "effect"; -import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http"; import { EngineWebAdapterError } from "../errors.js"; import type { StemIdentity } from "./types.js"; @@ -182,6 +182,8 @@ export const openSparseResponse = Effect.fn("SparseResponse.open")(function*(opt const response = yield* HttpClient.withScope(http).execute(normalized.request).pipe( Effect.provideService(FetchHttpClient.RequestInit, normalized.fetchInit), Effect.provideService(FetchHttpClient.Fetch, physicalFetch), + // Like the FLAC range lane: never add b3/traceparent headers, which would force a CORS preflight. + Effect.provideService(HttpClient.TracerPropagationEnabled, false), Effect.timeoutOrElse({ duration: options.readDeadlineMs, orElse: () => Effect.fail(failure("stem.delivery.stall", "Sparse full response headers exceeded their deadline", { identity: options.identity })) }), Effect.raceFirst(abortEffect(options.signal, options.identity)), Effect.mapError((cause) => preserveEffectFailure(options.identity, "response", cause)), diff --git a/src/stems/sparse-store.ts b/src/stems/sparse-store.ts index 9feaa8a..366272d 100644 --- a/src/stems/sparse-store.ts +++ b/src/stems/sparse-store.ts @@ -679,8 +679,12 @@ function decodeExpected(input: unknown): Effect.Effect { return Effect.try({ try: () => { - if (!isRecord(input) || Object.getOwnPropertySymbols(input).length !== 0) throw new Error("expectation must be a plain object"); - const keys = Object.keys(input).sort(); + if (!isRecord(input)) throw new Error("expectation must be a plain object"); + // Effect 4.0.x strips non-enumerable extras instead of rejecting them under + // `onExcessProperty: "error"` (rc.112 rejected them), so check every own key here. + const ownKeys = Reflect.ownKeys(input); + if (ownKeys.some((key) => typeof key !== "string")) throw new Error("expectation must be a plain object"); + const keys = (ownKeys as string[]).sort(); const expected = ["bitDepth", "canonicalBytes", "channels", "frames", "identity", "sampleRateHz"]; if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) throw new Error("expectation keys are not exact"); const candidate = input as Record; @@ -1124,6 +1128,9 @@ function ingestAndCommit( function preflightSpan(value: unknown): Effect.Effect { if (!isRecord(value) || !(value.bytes instanceof Uint8Array)) return Effect.fail(new SparseBoundaryError({ message: "Sparse span must contain a Uint8Array" })); if (value.bytes.byteLength > MAX_SPAN_BYTES || value.bytes.byteLength === 0) return Effect.fail(new SparseBoundaryError({ message: "Sparse span byte length is outside its bound" })); + // Effect 4.0.x strips non-enumerable extras instead of rejecting them under + // `onExcessProperty: "error"` (rc.112 rejected them), so check every own key here. + if (Reflect.ownKeys(value).some((key) => key !== "startFrame" && key !== "bytes")) return Effect.fail(new SparseBoundaryError({ message: "Sparse span has unknown properties" })); return Effect.void; } function decodeSpan(value: unknown): Effect.Effect<{ readonly startFrame: number; readonly bytes: Uint8Array }, SparseBoundaryError> { diff --git a/tests/sparse-resolver.test.ts b/tests/sparse-resolver.test.ts index c09ad32..c75cac7 100644 --- a/tests/sparse-resolver.test.ts +++ b/tests/sparse-resolver.test.ts @@ -480,6 +480,28 @@ test("full-response admission rejects malformed sections, headers, status and tr for (const item of cases) await expectFullResponseFailure(packed, item.response, item.code); }); +test("the sparse full GET never carries trace-propagation headers that would force a CORS preflight", async () => { + const packed = emptySilentPackage(); + const sent: Array = []; + let fetches = 0; + const resolver = createSparseStemResolver({ + locate: () => "https://fixture.invalid/indexed", + fetch: async (input, init) => { + fetches += 1; + const headers = new Headers(input instanceof Request ? input.headers : undefined); + new Headers(init?.headers).forEach((value, name) => headers.set(name, value)); + headers.forEach((value, name) => sent.push([name, value])); + return new Response(responseBytes(new Uint8Array([...packed.body, 0])), { status: 200 }); + }, + createWorker: () => { throw new Error("malformed all-silent source must not create a decoder worker"); }, + }); + const resolved = await resolver(packed.expected, new AbortController().signal); + await resolved.spans[Symbol.asyncIterator]().next().catch(() => undefined); + assert.equal(fetches, 1); + const propagation = sent.filter(([name]) => /^(traceparent|tracestate|b3|x-b3-.*)$/iu.test(name)); + assert.deepEqual(propagation, []); +}); + test("factory validates locator and decoder deadlines before the first request", () => { const options = { locate: () => "https://fixture.invalid/indexed" }; assert.throws(() => createSparseStemResolver({ ...options, locate: null as never }), TypeError); diff --git a/tests/sparse-store.test.ts b/tests/sparse-store.test.ts index e215eff..a90745d 100644 --- a/tests/sparse-store.test.ts +++ b/tests/sparse-store.test.ts @@ -817,6 +817,53 @@ describe("VerifiedSparsePcmStore", () => { await store.close(); }); + it("rejects non-enumerable and symbol extras on expectations, as Effect rc.112 did", async () => { + for (const kind of ["enumerable", "non-enumerable", "symbol", "non-enumerable symbol"] as const) { + const backend = new MemoryStemStorageBackend(); + const store = new VerifiedSparsePcmStore({ backend, instanceId: `excess-${kind}` }); + const bytes = new Uint8Array([1, 2]); + const expected: Record = { ...expectation(bytes, 1) }; + if (kind === "enumerable") expected.extra = 1; + else if (kind === "non-enumerable") Object.defineProperty(expected, "extra", { value: 1, enumerable: false }); + else if (kind === "symbol") expected[Symbol("extra")] = 1; + else Object.defineProperty(expected, Symbol("extra"), { value: 1, enumerable: false }); + let resolves = 0; + await assert.rejects( + store.installSource(expected as never, { resolve: async () => { resolves += 1; return { spans: spans({ startFrame: 0, bytes }) }; } }), + (error: unknown) => error instanceof EngineWebAdapterError && error.code === "stem.invalid_declaration", + kind, + ); + assert.equal(resolves, 0, kind); + assert.deepEqual(await backend.list(), [], kind); + await store.close(); + } + }); + + it("rejects non-enumerable and symbol extras on resolver spans, as Effect rc.112 did", async () => { + const outcomes = new Map(); + for (const kind of ["none", "enumerable", "non-enumerable", "symbol", "non-enumerable symbol"] as const) { + const backend = new MemoryStemStorageBackend(); + const store = new VerifiedSparsePcmStore({ backend, instanceId: `span-excess-${kind.replace(" ", "-")}` }); + const bytes = new Uint8Array([1, 2]); + const span: Record = { startFrame: 0, bytes }; + if (kind === "enumerable") span.extra = 1; + else if (kind === "non-enumerable") Object.defineProperty(span, "extra", { value: 1, enumerable: false }); + else if (kind === "symbol") span[Symbol("extra")] = 1; + else if (kind === "non-enumerable symbol") Object.defineProperty(span, Symbol("extra"), { value: 1, enumerable: false }); + let outcome = "installed"; + await store.installSource(expectation(bytes, 1), { resolve: async () => ({ spans: spans(span as never) }) }).catch((error: unknown) => { + outcome = error instanceof EngineWebAdapterError ? error.code : "unexpected"; + }); + outcomes.set(kind, outcome); + await store.close(); + } + assert.equal(outcomes.get("none"), "installed"); + const rejected = outcomes.get("enumerable"); + assert.notEqual(rejected, "installed"); + assert.notEqual(rejected, "unexpected"); + for (const kind of ["non-enumerable", "symbol", "non-enumerable symbol"]) assert.equal(outcomes.get(kind), rejected, kind); + }); + it("preserves an existing capability error from the backend boundary", async () => { class CapabilityBackend extends MemoryStemStorageBackend { override async open(): Promise { throw new EngineWebAdapterError("capability.opfs", "OPFS unavailable"); }