From f53381d3e1b8bb5c5f208a0be276892d05695f3a Mon Sep 17 00:00:00 2001 From: Gordon Farquharson Date: Fri, 5 Jun 2026 08:26:30 +0100 Subject: [PATCH 01/11] some context added --- context/CONTEXT_INDEX.md | 12 +++-- context/KNOWN_LIMITATIONS.md | 86 ++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 context/KNOWN_LIMITATIONS.md diff --git a/context/CONTEXT_INDEX.md b/context/CONTEXT_INDEX.md index 1d71188..caf2316 100644 --- a/context/CONTEXT_INDEX.md +++ b/context/CONTEXT_INDEX.md @@ -37,6 +37,7 @@ | `PROJECT_OVERVIEW.md` | ~150 | Lightweight project overview — architecture, key modules, dev setup, common commands. Read when new to the codebase. | | `CHANGELOG.md` | ~25+ | Change history. Use grep, don't read linearly as this file grows. | | `ENHANCEMENTS.md` | ~50 | Known inconsistencies and planned improvements. Read before refactoring related areas. | +| `KNOWN_LIMITATIONS.md` | ~90 | Confirmed runtime gaps — standard Web APIs that look available but don't work on FastEdge (e.g. `Response.clone()`). Read when a user reports "X is in the types/spec but throws at runtime". | ### Plugin Integration (read when modifying manifest or examples) @@ -108,6 +109,11 @@ 2. Follow co-located pattern: `__tests__/` next to source 3. Integration tests go in `integration-tests/` +### "Standard Web API X throws / isn't available at runtime" +1. Read `KNOWN_LIMITATIONS.md` — confirmed runtime gaps + workarounds + upstream tracking +2. Confirm against the builtin: grep `runtime/StarlingMonkey/builtins/web/fetch/request-response.cpp` (or relevant builtin) for the method/symbol +3. Cross-check `types/globals.d.ts` for a commented-out "not implemented by the StarlingMonkey runtime" block + ### Understanding the System (new to codebase) 1. Read `PROJECT_OVERVIEW.md` (~150 lines) 2. Skim `architecture/COMPONENTIZE_PIPELINE.md` (pipeline diagram) @@ -165,7 +171,7 @@ Items that need attention. Surface these when asked "what's next" or "what needs These are runtime/Web-API behaviors that have been *requested* in patterns docs but cannot yet be verified against an existing example or runtime test. Build a minimal example app proving each works on FastEdge before adding it to a `docs/` file. If a behavior is **not** supported, capture that here too — negative findings are also documentation. -- **`Response.clone()`** — Standard Web Fetch API. Used by patterns where the upstream body needs to be read twice (e.g. log full response while transforming a copy). No example currently exercises this. Build: a small handler that clones a `fetch()` response and reads both copies. Verify both bodies decode to the same bytes. If it works, the `docs/PROXY_PATTERNS.md` "JSON Transform" section can be expanded to document `clone()` for dual-read patterns. +- **`Response.clone()`** — RESOLVED as a negative finding: **not implemented by the StarlingMonkey runtime.** See `KNOWN_LIMITATIONS.md` for the full rationale, upstream tracking (issue #125 / PR #178), and workaround. Do not document `clone()` in `docs/` until the runtime exposes it. - **`fetch(url, { redirect: "manual" })`** — Standard Web Fetch option, returns the upstream redirect response without following it. Used by patterns where the app needs to inspect or rewrite the `Location` header. Runtime test harness includes WPT `redirect-mode.any.js` but that does not confirm FastEdge's outbound `fetch` honors the option in production. Build: a handler that issues a `fetch()` to a known 302 endpoint with `redirect: "manual"` and asserts the response is the 302 itself, not the followed target. If it works, the `docs/PROXY_PATTERNS.md` operational notes can call out manual redirect handling. When adding either to docs, also update the manifest source description so reviewers know the content is now grounded in an example. @@ -188,7 +194,7 @@ When adding either to docs, also update the manifest source description so revie |----------|-----------|-------------| | Architecture | 2 docs | ~330 | | Development | 2 docs | ~200 | -| Reference | 2 docs | ~175 | -| **Total** | **6 docs** | **~700** | +| Reference | 3 docs | ~265 | +| **Total** | **7 docs** | **~790** | All documents are designed for single-sitting reads. No doc exceeds 170 lines. diff --git a/context/KNOWN_LIMITATIONS.md b/context/KNOWN_LIMITATIONS.md new file mode 100644 index 0000000..7113c27 --- /dev/null +++ b/context/KNOWN_LIMITATIONS.md @@ -0,0 +1,86 @@ +# Known Runtime Limitations + +Confirmed gaps in the StarlingMonkey runtime as it ships in this SDK. These are +**negative findings** — behaviors that look like they should work (standard Web +APIs, types that nearly exist) but do **not** on FastEdge today. + +Use this file when a user reports "X is in the types / spec but throws at runtime" +or asks "does FastEdge support X?". For *planned* work and unverified items, see +`ENHANCEMENTS.md` and the "Known Issues / Future Work" section of `CONTEXT_INDEX.md`. + +> StarlingMonkey is a git submodule: `runtime/StarlingMonkey` → +> `github.com/bytecodealliance/StarlingMonkey`. The pinned revision determines +> which builtins exist. When the pin moves, re-verify the items below. + +--- + +## `Response.clone()` — not implemented + +**Status:** Not supported by the runtime. Declared but **commented out** in +`types/globals.d.ts` (see the "Spec methods not implemented" block on the +`Response` interface). + +**Symptom:** Calling `.clone()` on a `Response` (e.g. to read an upstream +`fetch()` body twice — log one copy while transforming another) is a runtime +error. There is no `clone` method on the `Response` prototype. + +**Why:** It simply hasn't been finished and merged upstream — not a deep +technical impossibility. + +- `Request.clone()` **is** implemented (`Request::clone`, registered in + `Request::methods`). It works by calling `ReadableStreamTee()` to split the + body into two independent streams. +- `Response` registers only `arrayBuffer / blob / formData / json / text` — there + is **no** `Response::clone` symbol in + `runtime/StarlingMonkey/builtins/web/fetch/request-response.cpp`. + +**The Response-specific wrinkle:** An *incoming* `Response` (the result of a +`fetch()`) is backed by a host `HttpIncomingBody` handle. The runtime optimizes +this in `RequestOrResponse::maybe_stream_body` by **direct-forwarding** the host +body straight to the outgoing body via async host tasks — without ever +materializing a JS `ReadableStream`. That host body is a *single-consumer* +resource. Cloning requires reifying it into a tee-able JS stream and giving up +that fast path, which is what makes a fully spec-conformant implementation +non-trivial. + +**Upstream tracking:** + +| Ref | What | State | +|-----|------|-------| +| [Issue #125](https://github.com/bytecodealliance/StarlingMonkey/issues/125) | "Response.clone support" | Open — *"We support `Request.clone` but not `Response.clone`."* | +| [PR #178](https://github.com/bytecodealliance/StarlingMonkey/pull/178) | "Implement Response.clone" | **Open draft.** Adapts `Request.clone`; stalled on WPT conformance (only 6/21 `response-clone.any.js` cases passing). | +| [Issue #84](https://github.com/bytecodealliance/StarlingMonkey/issues/84) | "Request.clone() regression" | Closed — context that even the Request side has been fragile. | + +**When fixed upstream:** Uncomment `prototype.clone(): Response;` in +`types/globals.d.ts`, bump the `runtime/StarlingMonkey` submodule pin, and verify +with a handler that clones a `fetch()` response and reads both copies to the same +bytes. + +**Workaround today:** Read the body once (e.g. `await res.arrayBuffer()`) and +construct fresh `Response` objects from the buffered bytes when you need to use +the payload more than once. + +--- + +## `Response.error()` — not implemented + +**Status:** Not supported. Also commented out in the same `types/globals.d.ts` +block (`error(): Response; // static`). + +**Symptom:** `Response.error()` (the static factory returning a network-error +response) is unavailable. + +**Why:** Same root cause — the static method is not registered on `Response` in +the StarlingMonkey fetch builtin. No dedicated upstream issue; track alongside the +general fetch-builtin completeness work. + +--- + +## Adding a new limitation here + +1. Confirm it is a *runtime* gap (grep the relevant builtin in + `runtime/StarlingMonkey/builtins/` — absence of the method/symbol). +2. Capture the **why** and any **upstream issue/PR** links. +3. Note the **workaround** and the **uncomment/verify** steps for when it lands. +4. Keep this file a single-sitting read — link out to source rather than + inlining large excerpts. From 54c309593622153ad75f49d103b2c4bd64e532d9 Mon Sep 17 00:00:00 2001 From: Gordon Farquharson Date: Fri, 5 Jun 2026 09:58:04 +0100 Subject: [PATCH 02/11] updated CI/CD tests for outbound fetch --- .github/scripts/prod-invocation/invoke-test-app.js | 4 ++-- .github/workflows/prod-invocation.yaml | 2 +- integration-tests/test-application/test-app.js | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/scripts/prod-invocation/invoke-test-app.js b/.github/scripts/prod-invocation/invoke-test-app.js index 38c1f96..579aca8 100644 --- a/.github/scripts/prod-invocation/invoke-test-app.js +++ b/.github/scripts/prod-invocation/invoke-test-app.js @@ -20,8 +20,8 @@ async function checkOutboundFetch(appUrl) { if (!data.ok) { throw new Error(`/fetch: outbound request failed (ok=${data.ok}, status=${data.status})`); } - if (data.title !== 'Sample Slide Show') { - throw new Error(`/fetch: unexpected slideshow title: "${data.title}"`); + if (!data.cdnDebugEndpoint || typeof data.cdnDebugEndpoint !== 'string' || !data.cdnDebugEndpoint.includes('.well-known')) { + throw new Error(`/fetch: missing or invalid cdnDebugEndpoint: "${data.cdnDebugEndpoint}"`); } } diff --git a/.github/workflows/prod-invocation.yaml b/.github/workflows/prod-invocation.yaml index af18a33..93b166d 100644 --- a/.github/workflows/prod-invocation.yaml +++ b/.github/workflows/prod-invocation.yaml @@ -108,7 +108,7 @@ jobs: comment: 'Deployed by prod-invocation workflow' env: | BUILD_SHA=${{ github.sha }} - TEST_FETCH_URL=https://httpbin.org/json + TEST_FETCH_URL=https://auth.gcore.com/login/assets/config.json secrets: | test-secret=${{ steps.create-test-secret.outputs.secret_id }} diff --git a/integration-tests/test-application/test-app.js b/integration-tests/test-application/test-app.js index 21df549..140c2b0 100644 --- a/integration-tests/test-application/test-app.js +++ b/integration-tests/test-application/test-app.js @@ -10,13 +10,13 @@ async function handleEnvCheck(event) { } async function handleOutboundFetch(event) { - const targetUrl = getEnv('TEST_FETCH_URL') || 'https://httpbin.org/json'; + const targetUrl = getEnv('TEST_FETCH_URL') || 'https://auth.gcore.com/login/assets/config.json'; const res = await fetch(targetUrl); const data = await res.json(); return Response.json({ status: res.status, ok: res.ok, - title: data?.slideshow?.title, + cdnDebugEndpoint: data?.cdnDebugEndpoint, }); } From e61c848612b59ab20742f3b9c67995037d5fdb5e Mon Sep 17 00:00:00 2001 From: Gordon Farquharson Date: Mon, 8 Jun 2026 15:31:57 +0100 Subject: [PATCH 03/11] chore(runtime): pin StarlingMonkey to gcore/integration branch Bump the StarlingMonkey submodule from 0.3.0 (9dda8ba) to the tip of godronus/gcore/integration (84f5d52), which carries two pending upstream patches on top of 0.3.0: - feat(fetch): implement Response.clone() (PR #312) - fix(fetch): body.blob() sets Blob.type from Content-Type header (issue #311) Add context/PATCHES.md documenting the applied patches, their upstream PR/issue links, and the rebase procedure for future StarlingMonkey bumps. Update CONTEXT_INDEX.md and CLAUDE.md to make the file discoverable. --- CLAUDE.md | 1 + context/CONTEXT_INDEX.md | 9 +++-- context/PATCHES.md | 72 ++++++++++++++++++++++++++++++++++++++++ runtime/StarlingMonkey | 2 +- 4 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 context/PATCHES.md diff --git a/CLAUDE.md b/CLAUDE.md index 5204f38..949b1ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,6 +74,7 @@ Use the decision tree in CONTEXT_INDEX.md to determine what to read. **Only read | **Adding an example** | Browse `examples/` for similar existing example | | **Changing build system** | BUILD_SYSTEM + `esbuild/` scripts | | **Working with WIT** | RUNTIME_ARCHITECTURE (WIT section) + `runtime/FastEdge-wit/` | +| **Bumping StarlingMonkey submodule** | `context/PATCHES.md` (rebase procedure + patch list) | | **Writing tests** | TESTING_GUIDE | | **Understanding the system** | PROJECT_OVERVIEW (~150 lines) | | **Updating docs site** | `github-pages/` (Astro, separate workspace) | diff --git a/context/CONTEXT_INDEX.md b/context/CONTEXT_INDEX.md index caf2316..0099e42 100644 --- a/context/CONTEXT_INDEX.md +++ b/context/CONTEXT_INDEX.md @@ -38,6 +38,7 @@ | `CHANGELOG.md` | ~25+ | Change history. Use grep, don't read linearly as this file grows. | | `ENHANCEMENTS.md` | ~50 | Known inconsistencies and planned improvements. Read before refactoring related areas. | | `KNOWN_LIMITATIONS.md` | ~90 | Confirmed runtime gaps — standard Web APIs that look available but don't work on FastEdge (e.g. `Response.clone()`). Read when a user reports "X is in the types/spec but throws at runtime". | +| `PATCHES.md` | ~60 | Applied patches on the `gcore/integration` StarlingMonkey branch — upstream PR links, rebase procedure, and retirement steps. Read before touching `runtime/StarlingMonkey`. | ### Plugin Integration (read when modifying manifest or examples) @@ -104,6 +105,10 @@ 3. Read `runtime/fastedge/host-api/wit/` (local bindings) 4. Run `pnpm run generate:wit-world` after changes +### Bumping the StarlingMonkey Submodule +1. Read `PATCHES.md` — lists applied patches, upstream PR status, and the full rebase procedure +2. Follow the rebase steps there when a new upstream tag lands + ### Writing Tests 1. Read `development/TESTING_GUIDE.md` 2. Follow co-located pattern: `__tests__/` next to source @@ -194,7 +199,7 @@ When adding either to docs, also update the manifest source description so revie |----------|-----------|-------------| | Architecture | 2 docs | ~330 | | Development | 2 docs | ~200 | -| Reference | 3 docs | ~265 | -| **Total** | **7 docs** | **~790** | +| Reference | 4 docs | ~325 | +| **Total** | **8 docs** | **~850** | All documents are designed for single-sitting reads. No doc exceeds 170 lines. diff --git a/context/PATCHES.md b/context/PATCHES.md new file mode 100644 index 0000000..f5057a3 --- /dev/null +++ b/context/PATCHES.md @@ -0,0 +1,72 @@ +# StarlingMonkey Patches + +The `runtime/StarlingMonkey` submodule is pinned to the tip of +`godronus/gcore/integration` rather than a vanilla upstream tag. + +That branch is upstream `0.3.0` with the patches below cherry-picked on top. +When StarlingMonkey releases a new version, follow the **Rebase procedure** +below to carry the patches forward. + +--- + +## Applied patches + +### 1. `feat(fetch): implement Response.clone()` + +| Field | Value | +|-------|-------| +| Commit on `gcore/integration` | `65f6a6c` | +| Source branch | `godronus/feature/response-clone` | +| Upstream PR | https://github.com/bytecodealliance/StarlingMonkey/pull/312 | +| Status | Open — awaiting upstream review | + +### 2. `test(wpt): update WPT expectations` + +| Field | Value | +|-------|-------| +| Commit on `gcore/integration` | `8fc9b89` | +| Source branch | `godronus/feature/response-clone` | +| Upstream PR | https://github.com/bytecodealliance/StarlingMonkey/pull/312 | +| Status | Open — part of the Response.clone() PR | + +### 3. `fix(fetch): body.blob() sets Blob.type from Content-Type header` + +| Field | Value | +|-------|-------| +| Commit on `gcore/integration` | `84f5d52` | +| Source branch | `godronus/fix/blob-type` | +| Upstream issue | https://github.com/bytecodealliance/StarlingMonkey/issues/311 | +| Upstream PR | (PR opened from fork — link when available) | +| Status | Open — awaiting upstream review | + +--- + +## Rebase procedure (when upstream releases a new version) + +```bash +cd runtime/StarlingMonkey + +# 1. Fetch the new upstream tag +git fetch origin + +# 2. Rebase our integration branch onto the new release tag +git checkout gcore/integration +git rebase vX.Y.Z # replace with new tag, e.g. v0.4.0 + +# 3. Resolve any conflicts, then: +git push godronus gcore/integration --force-with-lease + +# 4. Back in FastEdge-sdk-js, stage the new submodule SHA +cd ../.. +git add runtime/StarlingMonkey +git commit -m "chore(runtime): bump StarlingMonkey to vX.Y.Z + gcore patches" +``` + +## Retiring a patch + +When an upstream PR merges, remove the corresponding entry from this file and +drop the commit from `gcore/integration` during the next rebase: + +```bash +git rebase -i vX.Y.Z # drop the line for the merged commit +``` diff --git a/runtime/StarlingMonkey b/runtime/StarlingMonkey index 9dda8ba..84f5d52 160000 --- a/runtime/StarlingMonkey +++ b/runtime/StarlingMonkey @@ -1 +1 @@ -Subproject commit 9dda8ba7fcda2e17c6795d402f0478cf4c1f7f37 +Subproject commit 84f5d526aa86f744cc433c2451a74599205f882d From be5ef10057e48652040434e7aeb78879436ad6e2 Mon Sep 17 00:00:00 2001 From: Gordon Farquharson Date: Mon, 8 Jun 2026 15:33:44 +0100 Subject: [PATCH 04/11] build script now uses "--parallel nproc" for faster builds --- runtime/fastedge/build.sh | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/runtime/fastedge/build.sh b/runtime/fastedge/build.sh index f8a1f74..65538cf 100755 --- a/runtime/fastedge/build.sh +++ b/runtime/fastedge/build.sh @@ -23,8 +23,7 @@ HOST_API=$(realpath host-api) cmake -B $BUILD_PATH -DCMAKE_BUILD_TYPE=$BUILD_TYP # cmake -B $BUILD_PATH -DCMAKE_BUILD_TYPE=$BUILD_TYPE -DENABLE_BUILTIN_WEB_FETCH=0 -DENABLE_BUILTIN_WEB_FETCH_FETCH_EVENT=0 # cmake -B $BUILD_PATH -DCMAKE_BUILD_TYPE=$BUILD_TYPE -DENABLE_BUILTIN_WEB_FETCH=0 # Build the StarlingMonkey runtime -# cmake --build $BUILD_PATH --parallel 16 -cmake --build $BUILD_PATH --parallel 8 +cmake --build $BUILD_PATH --parallel $(nproc) # Copy the built WebAssembly module to the parent directory mv $BUILD_PATH/starling-raw.wasm/starling-raw.wasm ../../lib/fastedge-runtime.wasm mv $BUILD_PATH/starling-raw.wasm/preview1-adapter.wasm ../../lib/preview1-adapter.wasm From f341557ffd3b91363112b09da628374aa0569303 Mon Sep 17 00:00:00 2001 From: Gordon Farquharson Date: Mon, 8 Jun 2026 17:07:31 +0100 Subject: [PATCH 05/11] feat(fetch): implement Response.clone() and restructure prod-invocation tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement Response.clone() in the StarlingMonkey runtime and add blob.type propagation from Content-Type headers. Both fixes are applied via the godronus/gcore/integration fork branch (SHA 84f5d52) pending upstream review of PR #312 and issue #311. Expose Response.clone() in types/globals.d.ts (instance method, previously commented out). Update KNOWN_LIMITATIONS.md to reflect the implementation. Restructure integration-tests/test-application from a single flat JS file into a typed, extensible architecture: - Convert to TypeScript with Hono routing - Split into handlers/ (WASM context) and checks/ (Node.js, auto-discovered) - routes.ts as single source of truth for route paths and test names - Build artefacts consolidated under dist/ (one .gitignore entry) - Add temporary Response.clone() prod-invocation guard — remove when PR #312 merges upstream and submodule is rebased Add pnpm test:app:build and test:app:check scripts for local development. Add context/PATCHES.md documenting the integration branch, rebase procedure, and test guard removal checklist. --- .../prod-invocation/create-test-app.js | 40 ++++++-- .../prod-invocation/invoke-test-app.js | 79 ++++----------- .github/workflows/prod-invocation.yaml | 6 +- .gitignore | 2 +- context/CHANGELOG.md | 26 +++++ context/CONTEXT_INDEX.md | 7 +- context/KNOWN_LIMITATIONS.md | 61 ++++-------- context/PATCHES.md | 13 +++ context/development/TESTING_GUIDE.md | 89 +++++++++-------- integration-tests/test-application/.gitkeep | 0 integration-tests/test-application/README.md | 95 +++++++++++++++++++ .../test-application/checks/echo.ts | 23 +++++ .../test-application/checks/env.ts | 13 +++ .../test-application/checks/outbound-fetch.ts | 16 ++++ .../test-application/checks/response-clone.ts | 36 +++++++ .../test-application/checks/secret.ts | 13 +++ .../test-application/handlers/echo.ts | 12 +++ .../test-application/handlers/env.ts | 9 ++ .../handlers/outbound-fetch.ts | 11 +++ .../handlers/response-clone.ts | 32 +++++++ .../test-application/handlers/secret.ts | 9 ++ integration-tests/test-application/routes.ts | 5 + .../test-application/test-app.js | 51 ---------- .../test-application/test-app.ts | 13 +++ integration-tests/test-application/types.ts | 13 +++ package.json | 3 + pnpm-lock.yaml | 5 + scripts/build-test-app.js | 13 +++ scripts/run-test-app-checks.js | 69 ++++++++++++++ types/globals.d.ts | 5 +- 30 files changed, 556 insertions(+), 213 deletions(-) delete mode 100644 integration-tests/test-application/.gitkeep create mode 100644 integration-tests/test-application/README.md create mode 100644 integration-tests/test-application/checks/echo.ts create mode 100644 integration-tests/test-application/checks/env.ts create mode 100644 integration-tests/test-application/checks/outbound-fetch.ts create mode 100644 integration-tests/test-application/checks/response-clone.ts create mode 100644 integration-tests/test-application/checks/secret.ts create mode 100644 integration-tests/test-application/handlers/echo.ts create mode 100644 integration-tests/test-application/handlers/env.ts create mode 100644 integration-tests/test-application/handlers/outbound-fetch.ts create mode 100644 integration-tests/test-application/handlers/response-clone.ts create mode 100644 integration-tests/test-application/handlers/secret.ts create mode 100644 integration-tests/test-application/routes.ts delete mode 100644 integration-tests/test-application/test-app.js create mode 100644 integration-tests/test-application/test-app.ts create mode 100644 integration-tests/test-application/types.ts create mode 100644 scripts/build-test-app.js create mode 100644 scripts/run-test-app-checks.js diff --git a/.github/scripts/prod-invocation/create-test-app.js b/.github/scripts/prod-invocation/create-test-app.js index aba17c3..ff079f6 100644 --- a/.github/scripts/prod-invocation/create-test-app.js +++ b/.github/scripts/prod-invocation/create-test-app.js @@ -1,10 +1,13 @@ import { execSync } from 'child_process'; +import { readdirSync } from 'fs'; +import { join } from 'path'; -const TEST_APP_SOURCE_FILE_PATH = './integration-tests/test-application/test-app.js'; -const TEST_APP_WASM_FILE_PATH = './integration-tests/test-application/test-app.wasm'; +const TEST_APP_SOURCE_FILE_PATH = './integration-tests/test-application/test-app.ts'; +const TEST_APP_WASM_FILE_PATH = './integration-tests/test-application/dist/test-app.wasm'; +const CHECKS_SOURCE_DIR = './integration-tests/test-application/checks'; +const CHECKS_DIST_DIR = './integration-tests/test-application/dist/checks'; -export default async ({ github, context, core }) => { - // Ensure this is running in GitHub Actions +export default async ({ core }) => { if (!process.env.GITHUB_ENV) { throw new Error( 'GITHUB_ENV is not defined. This script must be run in a GitHub Actions environment.', @@ -13,12 +16,9 @@ export default async ({ github, context, core }) => { const workspaceDir = process.env.GITHUB_WORKSPACE || process.cwd(); - // Build the test app code into a wasm binary + // Build the test app into a WASM binary const buildResponse = execSync( - './bin/fastedge-build.js --input ' + - TEST_APP_SOURCE_FILE_PATH + - ' --output ' + - TEST_APP_WASM_FILE_PATH, + `./bin/fastedge-build.js --input ${TEST_APP_SOURCE_FILE_PATH} --output ${TEST_APP_WASM_FILE_PATH}`, { encoding: 'utf8', cwd: workspaceDir }, ); @@ -29,4 +29,26 @@ export default async ({ github, context, core }) => { } core.info(`Test application built into wasm binary at ${TEST_APP_WASM_FILE_PATH}`); + + // Compile TypeScript check modules to JS so invoke-test-app.js can import them in Node.js. + // fastedge:: modules are only available in the WASM runtime — mark them external so esbuild + // leaves the imports as-is. The check functions never call handler code so they never execute. + const checkFiles = readdirSync(join(workspaceDir, CHECKS_SOURCE_DIR)) + .filter((f) => f.endsWith('.ts')) + .map((f) => join(CHECKS_SOURCE_DIR, f)); + + execSync( + [ + 'node ./node_modules/.bin/esbuild', + '--bundle=false', + '--format=esm', + '--platform=node', + '--external:fastedge::*', + `--outdir=${CHECKS_DIST_DIR}`, + checkFiles.join(' '), + ].join(' '), + { encoding: 'utf8', cwd: workspaceDir }, + ); + + core.info(`Check modules compiled to ${CHECKS_DIST_DIR}`); }; diff --git a/.github/scripts/prod-invocation/invoke-test-app.js b/.github/scripts/prod-invocation/invoke-test-app.js index 579aca8..63873c8 100644 --- a/.github/scripts/prod-invocation/invoke-test-app.js +++ b/.github/scripts/prod-invocation/invoke-test-app.js @@ -1,55 +1,14 @@ +import { readdirSync } from 'fs'; +import { join, resolve } from 'path'; +import { pathToFileURL } from 'url'; + const MAX_RETRIES = 3; const INITIAL_WAIT_FOR_DEPLOYMENT_SECONDS = 15; const RETRY_DELAY_SECONDS = 5; -const sleep = (secs) => new Promise((resolve) => setTimeout(resolve, secs * 1000)); - -async function checkEnv(appUrl, buildSha) { - const res = await fetch(appUrl); - if (res.status !== 200) throw new Error(`/: bad status ${res.status}`); - const data = await res.json(); - if (data.build_sha !== buildSha) { - throw new Error(`/: build_sha mismatch: expected ${buildSha}, got ${data.build_sha}`); - } -} - -async function checkOutboundFetch(appUrl) { - const res = await fetch(`${appUrl}/fetch`); - if (res.status !== 200) throw new Error(`/fetch: bad status ${res.status}`); - const data = await res.json(); - if (!data.ok) { - throw new Error(`/fetch: outbound request failed (ok=${data.ok}, status=${data.status})`); - } - if (!data.cdnDebugEndpoint || typeof data.cdnDebugEndpoint !== 'string' || !data.cdnDebugEndpoint.includes('.well-known')) { - throw new Error(`/fetch: missing or invalid cdnDebugEndpoint: "${data.cdnDebugEndpoint}"`); - } -} - -async function checkSecret(appUrl) { - const res = await fetch(`${appUrl}/secret`); - if (res.status !== 200) throw new Error(`/secret: bad status ${res.status}`); - const data = await res.json(); - if (data.value !== 'hello-from-fastedge-secret') { - throw new Error(`/secret: wrong value "${data.value}"`); - } -} - -async function checkRequestEcho(appUrl) { - const res = await fetch(`${appUrl}/echo`, { - method: 'POST', - headers: { 'x-test-header': 'hello-fastedge', 'content-type': 'text/plain' }, - body: 'ping', - }); - if (res.status !== 200) throw new Error(`/echo: bad status ${res.status}`); - const data = await res.json(); - if (data.method !== 'POST') throw new Error(`/echo: wrong method "${data.method}"`); - if (data.body !== 'ping') throw new Error(`/echo: wrong body "${data.body}"`); - if (data.headers?.['x-test-header'] !== 'hello-fastedge') { - throw new Error(`/echo: wrong x-test-header "${data.headers?.['x-test-header']}"`); - } -} +const sleep = (secs) => new Promise((r) => setTimeout(r, secs * 1000)); -export default async ({ github, context, core }) => { +export default async ({ context, core }) => { if (!process.env.GITHUB_ENV) { throw new Error( 'GITHUB_ENV is not defined. This script must be run in a GitHub Actions environment.', @@ -57,25 +16,25 @@ export default async ({ github, context, core }) => { } const appUrl = process.env.APP_URL.replace(/\/$/, ''); - const buildSha = context.sha; + const ctx = { buildSha: context.sha }; + + // Auto-discover compiled check modules from checks/dist/ + const checksDir = resolve('./integration-tests/test-application/dist/checks'); + const checkModules = await Promise.all( + readdirSync(checksDir) + .filter((f) => f.endsWith('.js')) + .map((f) => import(pathToFileURL(join(checksDir, f)).href)), + ); await sleep(INITIAL_WAIT_FOR_DEPLOYMENT_SECONDS); for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { await sleep(RETRY_DELAY_SECONDS); try { - await checkEnv(appUrl, buildSha); - core.info('✓ env check passed'); - - await checkOutboundFetch(appUrl); - core.info('✓ outbound fetch check passed'); - - await checkSecret(appUrl); - core.info('✓ secret check passed'); - - await checkRequestEcho(appUrl); - core.info('✓ request echo check passed'); - + for (const mod of checkModules) { + await mod.check(appUrl, ctx); + core.info(`✓ ${mod.name} check passed`); + } break; } catch (error) { core.warning(`Attempt ${attempt} failed: ${error.message}`); diff --git a/.github/workflows/prod-invocation.yaml b/.github/workflows/prod-invocation.yaml index 93b166d..8b481be 100644 --- a/.github/workflows/prod-invocation.yaml +++ b/.github/workflows/prod-invocation.yaml @@ -17,10 +17,10 @@ jobs: - name: Setup Node Environment uses: ./.github/setup-node - - name: Clean up test-app.wasm + - name: Clean up dist/ shell: bash run: | - rm -f ./integration-tests/test-application/test-app.wasm + rm -rf ./integration-tests/test-application/dist/ - name: Restore build cache id: restore-build-cache @@ -103,7 +103,7 @@ jobs: ${{ steps.env-check.outputs.is_prod == 'true' && steps.secrets.outputs.PROD_GCORE_API_HOSTNAME || steps.secrets.outputs.PREPROD_GCORE_API_HOSTNAME }} - wasm_file: integration-tests/test-application/test-app.wasm + wasm_file: integration-tests/test-application/dist/test-app.wasm app_name: 'fastedge-sdk-js-test-app' comment: 'Deployed by prod-invocation workflow' env: | diff --git a/.gitignore b/.gitignore index ab5238b..62b2c77 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,7 @@ bin/ dist/ lib/ integration-tests/test-files/output.wasm -integration-tests/test-application/test-app.wasm +integration-tests/test-application/dist/ # *.o # *.d /rusturl/ diff --git a/context/CHANGELOG.md b/context/CHANGELOG.md index 01ed8c9..5025bef 100644 --- a/context/CHANGELOG.md +++ b/context/CHANGELOG.md @@ -5,6 +5,32 @@ When this file grows large, use grep to search — don't read linearly. --- +## [2026-06-08] — Response.clone(), blob.type fix, prod-invocation test infrastructure + +### Overview +Implemented `Response.clone()` and `blob.type` propagation in the StarlingMonkey runtime via a `gcore/integration` fork branch. Restructured the prod-invocation test application to be extensible and TypeScript-first. + +### Changes + +**Runtime (StarlingMonkey submodule):** +- `feat(fetch): implement Response.clone()` — upstream PR #312 (open) +- `fix(fetch): body.blob() sets Blob.type from Content-Type header` — upstream issue #311 (open) +- Submodule now pinned to `godronus/gcore/integration` (SHA `84f5d52`) rather than bare `0.3.0` tag +- `context/PATCHES.md` added — documents applied patches, upstream PR links, rebase procedure, and test guard removal checklist + +**Prod-invocation test application (`integration-tests/test-application/`):** +- Converted from a single flat JS file to TypeScript with Hono routing +- Split into `handlers/` (WASM context, `fastedge::` imports) and `checks/` (Node.js, auto-discovered) +- `routes.ts` — single source of truth for route paths and test names +- `types.ts` — shared `CheckContext`, `HandlerModule`, `CheckModule` interfaces +- Build artefacts consolidated under `dist/` (one gitignore entry covers both wasm and compiled checks) +- Temporary `response-clone` handler + check added as regression guard for the StarlingMonkey patches +- `scripts/build-test-app.js` + `scripts/run-test-app-checks.js` — local equivalents of CI scripts +- `pnpm test:app:build` and `pnpm test:app:check` scripts added +- `integration-tests/test-application/README.md` added + +--- + ## [2026-05-05] — Pin host-api bindings to wit-bindgen 0.30.0 (fix wasmtime 36 trap) ### Overview diff --git a/context/CONTEXT_INDEX.md b/context/CONTEXT_INDEX.md index 0099e42..55b34a9 100644 --- a/context/CONTEXT_INDEX.md +++ b/context/CONTEXT_INDEX.md @@ -109,6 +109,11 @@ 1. Read `PATCHES.md` — lists applied patches, upstream PR status, and the full rebase procedure 2. Follow the rebase steps there when a new upstream tag lands +### Adding or Modifying a Prod-Invocation Test +1. Read `integration-tests/test-application/README.md` — structure, handler/check split, routes.ts convention +2. Add route constant to `routes.ts`, create `handlers/.ts` + `checks/.ts`, register in `test-app.ts` +3. Test locally: `pnpm test:app:build` then `APP_URL= pnpm test:app:check` + ### Writing Tests 1. Read `development/TESTING_GUIDE.md` 2. Follow co-located pattern: `__tests__/` next to source @@ -176,7 +181,7 @@ Items that need attention. Surface these when asked "what's next" or "what needs These are runtime/Web-API behaviors that have been *requested* in patterns docs but cannot yet be verified against an existing example or runtime test. Build a minimal example app proving each works on FastEdge before adding it to a `docs/` file. If a behavior is **not** supported, capture that here too — negative findings are also documentation. -- **`Response.clone()`** — RESOLVED as a negative finding: **not implemented by the StarlingMonkey runtime.** See `KNOWN_LIMITATIONS.md` for the full rationale, upstream tracking (issue #125 / PR #178), and workaround. Do not document `clone()` in `docs/` until the runtime exposes it. +- **`Response.clone()`** — **Implemented** via `gcore/integration` StarlingMonkey branch (2026-06-08). `clone()` is now in `types/globals.d.ts`. Upstream PR #312 open. See `context/PATCHES.md` for rebase procedure and test guard removal checklist. - **`fetch(url, { redirect: "manual" })`** — Standard Web Fetch option, returns the upstream redirect response without following it. Used by patterns where the app needs to inspect or rewrite the `Location` header. Runtime test harness includes WPT `redirect-mode.any.js` but that does not confirm FastEdge's outbound `fetch` honors the option in production. Build: a handler that issues a `fetch()` to a known 302 endpoint with `redirect: "manual"` and asserts the response is the 302 itself, not the followed target. If it works, the `docs/PROXY_PATTERNS.md` operational notes can call out manual redirect handling. When adding either to docs, also update the manifest source description so reviewers know the content is now grounded in an example. diff --git a/context/KNOWN_LIMITATIONS.md b/context/KNOWN_LIMITATIONS.md index 7113c27..faffbdf 100644 --- a/context/KNOWN_LIMITATIONS.md +++ b/context/KNOWN_LIMITATIONS.md @@ -14,51 +14,22 @@ or asks "does FastEdge support X?". For *planned* work and unverified items, see --- -## `Response.clone()` — not implemented - -**Status:** Not supported by the runtime. Declared but **commented out** in -`types/globals.d.ts` (see the "Spec methods not implemented" block on the -`Response` interface). - -**Symptom:** Calling `.clone()` on a `Response` (e.g. to read an upstream -`fetch()` body twice — log one copy while transforming another) is a runtime -error. There is no `clone` method on the `Response` prototype. - -**Why:** It simply hasn't been finished and merged upstream — not a deep -technical impossibility. - -- `Request.clone()` **is** implemented (`Request::clone`, registered in - `Request::methods`). It works by calling `ReadableStreamTee()` to split the - body into two independent streams. -- `Response` registers only `arrayBuffer / blob / formData / json / text` — there - is **no** `Response::clone` symbol in - `runtime/StarlingMonkey/builtins/web/fetch/request-response.cpp`. - -**The Response-specific wrinkle:** An *incoming* `Response` (the result of a -`fetch()`) is backed by a host `HttpIncomingBody` handle. The runtime optimizes -this in `RequestOrResponse::maybe_stream_body` by **direct-forwarding** the host -body straight to the outgoing body via async host tasks — without ever -materializing a JS `ReadableStream`. That host body is a *single-consumer* -resource. Cloning requires reifying it into a tee-able JS stream and giving up -that fast path, which is what makes a fully spec-conformant implementation -non-trivial. - -**Upstream tracking:** - -| Ref | What | State | -|-----|------|-------| -| [Issue #125](https://github.com/bytecodealliance/StarlingMonkey/issues/125) | "Response.clone support" | Open — *"We support `Request.clone` but not `Response.clone`."* | -| [PR #178](https://github.com/bytecodealliance/StarlingMonkey/pull/178) | "Implement Response.clone" | **Open draft.** Adapts `Request.clone`; stalled on WPT conformance (only 6/21 `response-clone.any.js` cases passing). | -| [Issue #84](https://github.com/bytecodealliance/StarlingMonkey/issues/84) | "Request.clone() regression" | Closed — context that even the Request side has been fragile. | - -**When fixed upstream:** Uncomment `prototype.clone(): Response;` in -`types/globals.d.ts`, bump the `runtime/StarlingMonkey` submodule pin, and verify -with a handler that clones a `fetch()` response and reads both copies to the same -bytes. - -**Workaround today:** Read the body once (e.g. `await res.arrayBuffer()`) and -construct fresh `Response` objects from the buffered bytes when you need to use -the payload more than once. +## `Response.clone()` — ~~not implemented~~ implemented via gcore patch + +**Status:** Implemented in the `gcore/integration` StarlingMonkey branch (as of +2026-06-08). `clone(): Response` is now declared on the `Response` interface in +`types/globals.d.ts`. See `context/PATCHES.md` for the upstream PR status. + +**When the upstream PR merges** ([PR #312](https://github.com/bytecodealliance/StarlingMonkey/pull/312)) +and the submodule is rebased onto a release containing it, remove the +prod-invocation test guard per the checklist in `context/PATCHES.md`. + +**Historical note:** The implementation was non-trivial because an incoming +`Response` (result of `fetch()`) is backed by a host `HttpIncomingBody` handle +that the runtime fast-forwards directly to the outgoing body without materialising +a JS `ReadableStream`. Cloning requires reifying it into a tee-able stream and +giving up that fast path. PR #312 solves this by materialising the stream on +clone. --- diff --git a/context/PATCHES.md b/context/PATCHES.md index f5057a3..163a554 100644 --- a/context/PATCHES.md +++ b/context/PATCHES.md @@ -70,3 +70,16 @@ drop the commit from `gcore/integration` during the next rebase: ```bash git rebase -i vX.Y.Z # drop the line for the merged commit ``` + +Also delete any prod-invocation test guard that was added for the patch: + +- `integration-tests/test-application/handlers/.ts` +- `integration-tests/test-application/checks/.ts` +- The route constant in `integration-tests/test-application/routes.ts` +- The import + array entry in `integration-tests/test-application/test-app.ts` + +## Current test guards + +| Patch | Guard files | Remove when | +|-------|-------------|-------------| +| Response.clone() (patches 1–2) | `handlers/response-clone.ts`, `checks/response-clone.ts` | PR #312 merges and submodule is rebased | diff --git a/context/development/TESTING_GUIDE.md b/context/development/TESTING_GUIDE.md index 8c99fba..d1e421b 100644 --- a/context/development/TESTING_GUIDE.md +++ b/context/development/TESTING_GUIDE.md @@ -9,63 +9,66 @@ ## Running Tests -| Command | Purpose | -|---------|---------| -| `pnpm run test:unit:dev` | Unit tests (fast — excludes slow tests) | -| `pnpm run test:unit` | Unit tests + slow tests (`RUN_SLOW_TESTS=true`) | -| `pnpm run test:integration` | Integration tests only | -| `pnpm run test:solo -- ` | Run a specific test file | +| Command | Purpose | When it runs | +|---------|---------|-------------| +| `pnpm run test:unit:dev` | Unit tests (fast — excludes slow tests) | Every PR | +| `pnpm run test:unit` | Unit tests + slow tests (`RUN_SLOW_TESTS=true`) | Every PR (CI) | +| `pnpm run test:integration` | CLI integration tests | Release builds | +| `pnpm run test:solo -- ` | Run a specific test file | Local | +| `pnpm test:app:build` | Build prod-invocation WASM app | Local / release builds | +| `pnpm test:app:check` | Run prod-invocation checks against a live URL | Local | -All test commands use `NODE_ENV=test` and the shared Jest config. +## Testing Layers -## Test Organization +There are three distinct testing layers. Understanding which layer to use for a given task matters: -### Unit Tests +### 1. Unit Tests — toolchain correctness -Co-located with source in `src/**/__tests__/*.test.ts`: +Co-located with source in `src/**/__tests__/*.test.ts`. Test the TypeScript/JS build pipeline in Node.js with heavy mocking. **Do not test WASM runtime behaviour.** ``` src/ -├── componentize/ -│ └── __tests__/ -│ ├── add-wasm-metadata.test.ts -│ ├── componentize.test.ts -│ └── get-js-input.test.ts -├── utils/ -│ └── __tests__/ -│ ├── color-log.test.ts -│ ├── config-helpers.test.ts -│ ├── content-types.test.ts -│ ├── deep-copy.test.ts -│ ├── file-info.test.ts -│ ├── file-system.test.ts -│ └── input-path-verification.test.ts -└── server/static-assets/ +├── componentize/__tests__/ — build pipeline stages (mocked) +├── utils/__tests__/ — shared utilities +└── server/static-assets/ — static asset server logic └── (multiple __tests__/ dirs) - ├── asset-cache.test.ts - ├── create-manifest.test.ts - ├── create-static-server.test.ts - ├── headers.test.ts - ├── static-server.test.ts - └── ... ``` -### Integration Tests +### 2. CLI Integration Tests — build tool correctness -Located in `integration-tests/`: +Located in `integration-tests/` (3 test files). Test that `fastedge-build` and `fastedge-assets` CLIs accept/reject the right inputs and produce output. `generates-output.test.js` verifies the build emits a `.wasm` file but **does not execute it**. ``` integration-tests/ -├── fastedge-build.test.js — CLI argument parsing + build modes -├── fastedge-assets.test.js — Asset manifest CLI -├── generates-output.test.js — Full build produces valid WASM -├── test-application/ — Fixture app for build tests -└── test-files/ — Test fixture files +├── fastedge-build.test.js — argument parsing, file validation, error handling +├── fastedge-assets.test.js — asset manifest CLI +└── generates-output.test.js — full build produces a .wasm file ``` -Integration tests exercise the full CLI tools end-to-end using `@gmrchk/cli-testing-library`. +### 3. Production Invocation Tests — runtime correctness -### Mocks +A real FastEdge WASM app deployed to live infrastructure during each release. The only layer that validates actual WASM runtime behaviour. See `integration-tests/test-application/README.md` for full details. + +``` +integration-tests/test-application/ +├── test-app.ts ← Hono entry point +├── routes.ts ← single source of truth for route paths + test names +├── types.ts ← shared TypeScript interfaces +├── handlers/ ← WASM handlers (fastedge:: imports, run in runtime) +├── checks/ ← Node.js check functions (run in CI / locally) +└── dist/ ← build artefacts (gitignored) +``` + +**Running locally:** +```bash +# Requires pnpm run build:js first +pnpm test:app:build +APP_URL=https://your-deployed-app.example.com pnpm test:app:check +``` + +**Adding a prod-invocation test:** see `integration-tests/test-application/README.md`. + +## Mocks Co-located in `src/**/__mocks__/` directories. Jest auto-discovers these for module mocking. @@ -84,8 +87,12 @@ moduleNameMapper: { ## Writing Tests +### Unit / CLI integration tests 1. **Follow the co-located pattern** — put `__tests__/` next to the source file 2. **Import from `@jest/globals`** for test functions 3. **Use `__mocks__/`** for module-level mocks 4. **Guard slow tests** with `process.env.RUN_SLOW_TESTS` check if appropriate -5. **Integration tests** go in `integration-tests/` and test full CLI output +5. **CLI integration tests** go in `integration-tests/` and test full CLI output + +### Production invocation tests +See `integration-tests/test-application/README.md` — adding/removing a test, the handler/check split, `routes.ts` as single source of truth, and the temporary test pattern. diff --git a/integration-tests/test-application/.gitkeep b/integration-tests/test-application/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/integration-tests/test-application/README.md b/integration-tests/test-application/README.md new file mode 100644 index 0000000..9b70e09 --- /dev/null +++ b/integration-tests/test-application/README.md @@ -0,0 +1,95 @@ +# FastEdge SDK Test Application + +## What this is + +The production invocation test app — a real FastEdge WASM app deployed to live infrastructure during each SDK release to verify the runtime works end-to-end. + +This is not a unit test suite. It validates that FastEdge host APIs (env vars, secrets, outbound fetch, request handling) and any patched runtime capabilities work correctly after a real deployment. + +## Structure + +``` +test-application/ + test-app.ts ← Hono entry point; registers all handlers + routes.ts ← Single source of truth for route paths and test names + types.ts ← Shared TypeScript interfaces (CheckContext, HandlerModule, CheckModule) + handlers/ ← WASM handlers — run inside the FastEdge runtime + checks/ ← Node.js check functions — run locally or in CI against a live URL + dist/ ← build artefacts (gitignored) + test-app.wasm ← compiled WASM binary + checks/*.js ← compiled check modules for Node.js +``` + +Each test is a pair of files with matching names: + +| Handler | Check | Route | What it tests | +|---------|-------|-------|---------------| +| `handlers/env.ts` | `checks/env.ts` | `GET /` | Env variable access | +| `handlers/outbound-fetch.ts` | `checks/outbound-fetch.ts` | `GET /fetch` | Outbound HTTP fetch | +| `handlers/secret.ts` | `checks/secret.ts` | `GET /secret` | Secret injection | +| `handlers/echo.ts` | `checks/echo.ts` | `POST /echo` | Request method/headers/body echo | +| `handlers/response-clone.ts` | `checks/response-clone.ts` | `GET /response-clone` | **[temporary]** `Response.clone()` | + +## How CI uses this + +1. `create-test-app.js` builds `test-app.ts` → `dist/test-app.wasm` via `fastedge-build`, then compiles `checks/*.ts` → `dist/checks/*.js` for Node.js +2. The WASM binary is deployed to live FastEdge infrastructure +3. `invoke-test-app.js` auto-discovers every module in `dist/checks/` and runs its `check()` function against the deployed URL +4. All checks must pass for the release to proceed + +## Running locally + +Requires a built SDK (`pnpm run build:js`) and the URL of an already-deployed FastEdge app. + +```bash +# Build the WASM binary +pnpm test:app:build + +# Run all checks against a deployed app +APP_URL=https://your-app.example.com pnpm test:app:check + +# Override the expected BUILD_SHA (default: current git HEAD) +BUILD_SHA=abc123 APP_URL=https://your-app.example.com pnpm test:app:check +``` + +Local runs report all failures rather than stopping on the first, so you see the full picture in one pass. + +## Adding a test + +1. Add a constant to `routes.ts`: + ```typescript + export const MY_FEATURE = { name: 'my feature', route: '/my-feature' }; + ``` + +2. Create `handlers/my-feature.ts`: + ```typescript + import { MY_FEATURE } from '../routes.js'; + export const route = MY_FEATURE.route; + export async function handler(req: Request): Promise { ... } + ``` + +3. Create `checks/my-feature.ts`: + ```typescript + import { MY_FEATURE } from '../routes.js'; + import type { CheckContext } from '../types.js'; + export const name = MY_FEATURE.name; + export async function check(appUrl: string, _ctx: CheckContext): Promise { ... } + ``` + +4. Register the handler in `test-app.ts` — import the module and add it to the `handlers` array. + +The check is auto-discovered at runtime; no changes to the CI scripts are needed. + +## Removing a test + +1. Delete `handlers/feature.ts` and `checks/feature.ts` +2. Remove the import and array entry from `test-app.ts` +3. Remove the constant from `routes.ts` + +## Temporary tests + +Some tests exist only to guard against a specific regression until an upstream fix lands. Remove them once the upstream PR merges and the StarlingMonkey submodule is rebased onto a release that includes it. + +| Test | Upstream fix | Remove when | +|------|-------------|-------------| +| `response-clone` | [PR #312](https://github.com/bytecodealliance/StarlingMonkey/pull/312) | PR merges and submodule is rebased | diff --git a/integration-tests/test-application/checks/echo.ts b/integration-tests/test-application/checks/echo.ts new file mode 100644 index 0000000..fe4de0d --- /dev/null +++ b/integration-tests/test-application/checks/echo.ts @@ -0,0 +1,23 @@ +import type { CheckContext } from '../types.js'; +import { ECHO } from '../routes.js'; + +export const name = ECHO.name; + +export async function check(appUrl: string, _ctx: CheckContext): Promise { + const res = await fetch(`${appUrl}${ECHO.route}`, { + method: 'POST', + headers: { 'x-test-header': 'hello-fastedge', 'content-type': 'text/plain' }, + body: 'ping', + }); + if (res.status !== 200) throw new Error(`${ECHO.route}: bad status ${res.status}`); + const data = (await res.json()) as { + method: string; + body: string; + headers: Record; + }; + if (data.method !== 'POST') throw new Error(`${ECHO.route}: wrong method "${data.method}"`); + if (data.body !== 'ping') throw new Error(`${ECHO.route}: wrong body "${data.body}"`); + if (data.headers?.['x-test-header'] !== 'hello-fastedge') { + throw new Error(`${ECHO.route}: wrong x-test-header "${data.headers?.['x-test-header']}"`); + } +} diff --git a/integration-tests/test-application/checks/env.ts b/integration-tests/test-application/checks/env.ts new file mode 100644 index 0000000..cec6b24 --- /dev/null +++ b/integration-tests/test-application/checks/env.ts @@ -0,0 +1,13 @@ +import type { CheckContext } from '../types.js'; +import { ENV } from '../routes.js'; + +export const name = ENV.name; + +export async function check(appUrl: string, ctx: CheckContext): Promise { + const res = await fetch(`${appUrl}${ENV.route}`); + if (res.status !== 200) throw new Error(`${ENV.route}: bad status ${res.status}`); + const data = (await res.json()) as { build_sha: string }; + if (data.build_sha !== ctx.buildSha) { + throw new Error(`${ENV.route}: build_sha mismatch: expected ${ctx.buildSha}, got ${data.build_sha}`); + } +} diff --git a/integration-tests/test-application/checks/outbound-fetch.ts b/integration-tests/test-application/checks/outbound-fetch.ts new file mode 100644 index 0000000..64cd46e --- /dev/null +++ b/integration-tests/test-application/checks/outbound-fetch.ts @@ -0,0 +1,16 @@ +import type { CheckContext } from '../types.js'; +import { OUTBOUND_FETCH } from '../routes.js'; + +export const name = OUTBOUND_FETCH.name; + +export async function check(appUrl: string, _ctx: CheckContext): Promise { + const res = await fetch(`${appUrl}${OUTBOUND_FETCH.route}`); + if (res.status !== 200) throw new Error(`${OUTBOUND_FETCH.route}: bad status ${res.status}`); + const data = (await res.json()) as { ok: boolean; status: number; cdnDebugEndpoint?: string }; + if (!data.ok) { + throw new Error(`${OUTBOUND_FETCH.route}: outbound request failed (ok=${data.ok}, status=${data.status})`); + } + if (!data.cdnDebugEndpoint?.includes('.well-known')) { + throw new Error(`${OUTBOUND_FETCH.route}: missing or invalid cdnDebugEndpoint: "${data.cdnDebugEndpoint}"`); + } +} diff --git a/integration-tests/test-application/checks/response-clone.ts b/integration-tests/test-application/checks/response-clone.ts new file mode 100644 index 0000000..bec7723 --- /dev/null +++ b/integration-tests/test-application/checks/response-clone.ts @@ -0,0 +1,36 @@ +// Temporary guard: remove once https://github.com/bytecodealliance/StarlingMonkey/pull/312 +// merges upstream and the StarlingMonkey submodule is rebased onto a release containing it. +import type { CheckContext } from '../types.js'; +import { RESPONSE_CLONE } from '../routes.js'; + +export const name = RESPONSE_CLONE.name; + +export async function check(appUrl: string, _ctx: CheckContext): Promise { + const res = await fetch(`${appUrl}${RESPONSE_CLONE.route}`); + if (res.status !== 200) throw new Error(`${RESPONSE_CLONE.route}: bad status ${res.status}`); + const data = (await res.json()) as { + originalText: string; + clonedText: string; + originalStatus: number; + clonedStatus: number; + originalHeader: string | null; + clonedHeader: string | null; + consumedCloneError: string | null; + }; + if (data.originalText !== 'clone-body') + throw new Error(`${RESPONSE_CLONE.route}: originalText wrong "${data.originalText}"`); + if (data.clonedText !== 'clone-body') + throw new Error(`${RESPONSE_CLONE.route}: clonedText wrong "${data.clonedText}"`); + if (data.originalStatus !== 201) + throw new Error(`${RESPONSE_CLONE.route}: originalStatus wrong ${data.originalStatus}`); + if (data.clonedStatus !== 201) + throw new Error(`${RESPONSE_CLONE.route}: clonedStatus wrong ${data.clonedStatus}`); + if (data.originalHeader !== 'value') + throw new Error(`${RESPONSE_CLONE.route}: originalHeader wrong "${data.originalHeader}"`); + if (data.clonedHeader !== 'value') + throw new Error(`${RESPONSE_CLONE.route}: clonedHeader wrong "${data.clonedHeader}"`); + if (data.consumedCloneError !== 'TypeError') + throw new Error( + `${RESPONSE_CLONE.route}: expected TypeError on consumed clone, got "${data.consumedCloneError}"`, + ); +} diff --git a/integration-tests/test-application/checks/secret.ts b/integration-tests/test-application/checks/secret.ts new file mode 100644 index 0000000..22e77f4 --- /dev/null +++ b/integration-tests/test-application/checks/secret.ts @@ -0,0 +1,13 @@ +import type { CheckContext } from '../types.js'; +import { SECRET } from '../routes.js'; + +export const name = SECRET.name; + +export async function check(appUrl: string, _ctx: CheckContext): Promise { + const res = await fetch(`${appUrl}${SECRET.route}`); + if (res.status !== 200) throw new Error(`${SECRET.route}: bad status ${res.status}`); + const data = (await res.json()) as { value: string }; + if (data.value !== 'hello-from-fastedge-secret') { + throw new Error(`${SECRET.route}: wrong value "${data.value}"`); + } +} diff --git a/integration-tests/test-application/handlers/echo.ts b/integration-tests/test-application/handlers/echo.ts new file mode 100644 index 0000000..0c806e2 --- /dev/null +++ b/integration-tests/test-application/handlers/echo.ts @@ -0,0 +1,12 @@ +import { ECHO } from '../routes.js'; + +export const route = ECHO.route; + +export async function handler(req: Request): Promise { + const body = await req.text(); + return Response.json({ + method: req.method, + headers: Object.fromEntries(req.headers), + body, + }); +} diff --git a/integration-tests/test-application/handlers/env.ts b/integration-tests/test-application/handlers/env.ts new file mode 100644 index 0000000..77b6b37 --- /dev/null +++ b/integration-tests/test-application/handlers/env.ts @@ -0,0 +1,9 @@ +import { getEnv } from 'fastedge::env'; +import { ENV } from '../routes.js'; + +export const route = ENV.route; + +export async function handler(_req: Request): Promise { + const build_sha = getEnv('BUILD_SHA'); + return Response.json({ message: 'app running on production', build_sha: `${build_sha}` }); +} diff --git a/integration-tests/test-application/handlers/outbound-fetch.ts b/integration-tests/test-application/handlers/outbound-fetch.ts new file mode 100644 index 0000000..9d1f1b5 --- /dev/null +++ b/integration-tests/test-application/handlers/outbound-fetch.ts @@ -0,0 +1,11 @@ +import { getEnv } from 'fastedge::env'; +import { OUTBOUND_FETCH } from '../routes.js'; + +export const route = OUTBOUND_FETCH.route; + +export async function handler(_req: Request): Promise { + const targetUrl = getEnv('TEST_FETCH_URL') || 'https://auth.gcore.com/login/assets/config.json'; + const res = await fetch(targetUrl); + const data = (await res.json()) as { cdnDebugEndpoint?: string }; + return Response.json({ status: res.status, ok: res.ok, cdnDebugEndpoint: data?.cdnDebugEndpoint }); +} diff --git a/integration-tests/test-application/handlers/response-clone.ts b/integration-tests/test-application/handlers/response-clone.ts new file mode 100644 index 0000000..20aa7ad --- /dev/null +++ b/integration-tests/test-application/handlers/response-clone.ts @@ -0,0 +1,32 @@ +// Temporary guard: remove once https://github.com/bytecodealliance/StarlingMonkey/pull/312 +// merges upstream and the StarlingMonkey submodule is rebased onto a release containing it. +import { RESPONSE_CLONE } from '../routes.js'; + +export const route = RESPONSE_CLONE.route; + +export async function handler(_req: Request): Promise { + const original = new Response('clone-body', { + status: 201, + headers: { 'x-test': 'value' }, + }); + const cloned = original.clone(); + + const [originalText, clonedText] = await Promise.all([original.text(), cloned.text()]); + + let consumedCloneError: string | null = null; + try { + original.clone(); + } catch (e) { + consumedCloneError = e instanceof TypeError ? 'TypeError' : (e as Error).constructor.name; + } + + return Response.json({ + originalText, + clonedText, + originalStatus: original.status, + clonedStatus: cloned.status, + originalHeader: original.headers.get('x-test'), + clonedHeader: cloned.headers.get('x-test'), + consumedCloneError, + }); +} diff --git a/integration-tests/test-application/handlers/secret.ts b/integration-tests/test-application/handlers/secret.ts new file mode 100644 index 0000000..c9cca32 --- /dev/null +++ b/integration-tests/test-application/handlers/secret.ts @@ -0,0 +1,9 @@ +import { getSecret } from 'fastedge::secret'; +import { SECRET } from '../routes.js'; + +export const route = SECRET.route; + +export async function handler(_req: Request): Promise { + const value = getSecret('test-secret'); + return Response.json({ value }); +} diff --git a/integration-tests/test-application/routes.ts b/integration-tests/test-application/routes.ts new file mode 100644 index 0000000..3d8971a --- /dev/null +++ b/integration-tests/test-application/routes.ts @@ -0,0 +1,5 @@ +export const ENV = { name: 'env', route: '/' }; +export const OUTBOUND_FETCH = { name: 'outbound fetch', route: '/fetch' }; +export const SECRET = { name: 'secret', route: '/secret' }; +export const ECHO = { name: 'request echo', route: '/echo' }; +export const RESPONSE_CLONE = { name: 'Response.clone', route: '/response-clone' }; diff --git a/integration-tests/test-application/test-app.js b/integration-tests/test-application/test-app.js deleted file mode 100644 index 140c2b0..0000000 --- a/integration-tests/test-application/test-app.js +++ /dev/null @@ -1,51 +0,0 @@ -import { getEnv } from 'fastedge::env'; -import { getSecret } from 'fastedge::secret'; - -async function handleEnvCheck(event) { - const build_sha = getEnv('BUILD_SHA'); - return Response.json({ - message: 'app running on production', - build_sha: `${build_sha}`, - }); -} - -async function handleOutboundFetch(event) { - const targetUrl = getEnv('TEST_FETCH_URL') || 'https://auth.gcore.com/login/assets/config.json'; - const res = await fetch(targetUrl); - const data = await res.json(); - return Response.json({ - status: res.status, - ok: res.ok, - cdnDebugEndpoint: data?.cdnDebugEndpoint, - }); -} - -async function handleSecretCheck(event) { - const value = getSecret('test-secret'); - return Response.json({ value }); -} - -async function handleRequestEcho(event) { - const req = event.request; - const body = await req.text(); - return Response.json({ - method: req.method, - headers: Object.fromEntries(req.headers), - body, - }); -} - -addEventListener('fetch', (event) => { - const url = new URL(event.request.url); - let handler; - if (url.pathname === '/fetch') { - handler = handleOutboundFetch; - } else if (url.pathname === '/secret') { - handler = handleSecretCheck; - } else if (url.pathname === '/echo') { - handler = handleRequestEcho; - } else { - handler = handleEnvCheck; - } - event.respondWith(handler(event)); -}); diff --git a/integration-tests/test-application/test-app.ts b/integration-tests/test-application/test-app.ts new file mode 100644 index 0000000..be03baa --- /dev/null +++ b/integration-tests/test-application/test-app.ts @@ -0,0 +1,13 @@ +import { Hono } from 'hono'; +import * as echo from './handlers/echo.js'; +import * as env from './handlers/env.js'; +import * as outboundFetch from './handlers/outbound-fetch.js'; +import * as responseClone from './handlers/response-clone.js'; +import * as secret from './handlers/secret.js'; + +const app = new Hono(); + +const handlers = [env, outboundFetch, secret, echo, responseClone]; +handlers.forEach((m) => app.all(m.route, (c) => m.handler(c.req.raw))); + +addEventListener('fetch', (event) => event.respondWith(app.fetch(event.request))); diff --git a/integration-tests/test-application/types.ts b/integration-tests/test-application/types.ts new file mode 100644 index 0000000..0466ccc --- /dev/null +++ b/integration-tests/test-application/types.ts @@ -0,0 +1,13 @@ +export interface CheckContext { + buildSha: string; +} + +export interface HandlerModule { + route: string; + handler: (req: Request) => Promise; +} + +export interface CheckModule { + name: string; + check: (appUrl: string, ctx: CheckContext) => Promise; +} diff --git a/package.json b/package.json index 9eefe13..925130b 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,8 @@ "test:unit:dev": "NODE_ENV=test jest -c ./config/jest/jest.config.js -- src/", "test:unit": "NODE_ENV=test RUN_SLOW_TESTS=true jest -c ./config/jest/jest.config.js -- src/", "test:integration": "NODE_ENV=test jest -c ./config/jest/jest.config.js -- integration-tests/", + "test:app:build": "node scripts/build-test-app.js", + "test:app:check": "node scripts/run-test-app-checks.js", "typecheck": "tsc -p ./tsconfig.typecheck.json ", "wit:merge": "./runtime/fastedge/scripts/merge-wit-bindings.js", "wit:bindings": "./runtime/fastedge/scripts/create-wit-bindings.sh" @@ -76,6 +78,7 @@ "eslint-plugin-testing-library": "^7.16.2", "eslint-plugin-unicorn": "^64.0.0", "globals": "^17.5.0", + "hono": "^4.12.12", "husky": "^9.1.7", "jest": "^30.3.0", "semantic-release": "^23.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25f58a8..3a8f425 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,6 +108,9 @@ importers: globals: specifier: ^17.5.0 version: 17.5.0 + hono: + specifier: ^4.12.12 + version: 4.12.12 husky: specifier: ^9.1.7 version: 9.1.7 @@ -2575,6 +2578,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -3410,6 +3414,7 @@ packages: esbuild-plugin-polyfill-node@0.3.0: resolution: {integrity: sha512-SHG6CKUfWfYyYXGpW143NEZtcVVn8S/WHcEOxk62LuDXnY4Zpmc+WmxJKN6GMTgTClXJXhEM5KQlxKY6YjbucQ==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: esbuild: '*' diff --git a/scripts/build-test-app.js b/scripts/build-test-app.js new file mode 100644 index 0000000..988917e --- /dev/null +++ b/scripts/build-test-app.js @@ -0,0 +1,13 @@ +#!/usr/bin/env node +// Builds integration-tests/test-application/test-app.ts into a WASM binary. +// Requires the SDK to be built first: pnpm run build:js +import { execSync } from 'child_process'; + +const INPUT = './integration-tests/test-application/test-app.ts'; +const OUTPUT = './integration-tests/test-application/dist/test-app.wasm'; + +const output = execSync(`./bin/fastedge-build.js --input ${INPUT} --output ${OUTPUT}`, { + encoding: 'utf8', +}); + +process.stdout.write(output); diff --git a/scripts/run-test-app-checks.js b/scripts/run-test-app-checks.js new file mode 100644 index 0000000..a5da09a --- /dev/null +++ b/scripts/run-test-app-checks.js @@ -0,0 +1,69 @@ +#!/usr/bin/env node +// Compiles checks/*.ts and runs all check functions against a deployed app. +// Usage: APP_URL=https://your-app.example.com pnpm test:app:check +// BUILD_SHA= APP_URL=... pnpm test:app:check (optional: override sha) +import { execSync } from 'child_process'; +import { mkdirSync, readdirSync } from 'fs'; +import { join, resolve } from 'path'; +import { pathToFileURL } from 'url'; + +const appUrl = process.env.APP_URL?.replace(/\/$/, ''); +if (!appUrl) { + console.error('Error: APP_URL environment variable is required'); + console.error('Usage: APP_URL=https://your-app.example.com pnpm test:app:check'); + process.exit(1); +} + +const buildSha = + process.env.BUILD_SHA ?? execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim(); + +const CHECKS_SOURCE_DIR = './integration-tests/test-application/checks'; +const CHECKS_DIST_DIR = './integration-tests/test-application/dist/checks'; + +// Compile TypeScript check modules for Node.js. fastedge:: modules are WASM-only +// so they are marked external — check functions never call handler code, so they +// never execute and the missing modules are never imported at runtime. +mkdirSync(CHECKS_DIST_DIR, { recursive: true }); +const checkFiles = readdirSync(CHECKS_SOURCE_DIR) + .filter((f) => f.endsWith('.ts')) + .map((f) => join(CHECKS_SOURCE_DIR, f)); + +execSync( + [ + 'node ./node_modules/.bin/esbuild', + '--bundle=false', + '--format=esm', + '--platform=node', + '--external:fastedge::*', + `--outdir=${CHECKS_DIST_DIR}`, + checkFiles.join(' '), + ].join(' '), + { encoding: 'utf8' }, +); + +// Auto-discover and run all check modules. Unlike CI, runs all checks and +// reports all failures rather than stopping on the first. +const checksDir = resolve(CHECKS_DIST_DIR); +const checkModules = await Promise.all( + readdirSync(checksDir) + .filter((f) => f.endsWith('.js')) + .map((f) => import(pathToFileURL(join(checksDir, f)).href)), +); + +const ctx = { buildSha }; +let passed = 0; +let failed = 0; + +for (const mod of checkModules) { + try { + await mod.check(appUrl, ctx); + console.log(`✓ ${mod.name} check passed`); + passed++; + } catch (e) { + console.error(`✗ ${mod.name} check failed: ${e.message}`); + failed++; + } +} + +console.log(`\n${passed} passed, ${failed} failed`); +if (failed > 0) process.exit(1); diff --git a/types/globals.d.ts b/types/globals.d.ts index 017b14c..9338329 100644 --- a/types/globals.d.ts +++ b/types/globals.d.ts @@ -499,6 +499,8 @@ interface Response extends Body { readonly statusText: string; readonly type: ResponseType; readonly url: string; + /** Creates a copy of the current Response object. */ + clone(): Response; } /** @@ -516,8 +518,7 @@ declare var Response: { // Will be uncommented when the runtime exposes them. See: // runtime/StarlingMonkey/builtins/web/fetch/request-response.cpp // - // error(): Response; // static - // prototype.clone(): Response; // instance + // error(): Response; // static }; /** From 6b51133b39de443952af2b1b4ad85b278c952e55 Mon Sep 17 00:00:00 2001 From: Gordon Farquharson Date: Mon, 8 Jun 2026 17:32:19 +0100 Subject: [PATCH 06/11] fix(test-app): resolve esbuild invocation and tsconfig issues in check runner Add tsconfig.json for the test application so fastedge-build resolves SDK types directly from ../../types rather than the missing installed package. Pass --tsconfig in build-test-app.js and create-test-app.js. Fix run-test-app-checks.js: remove --bundle=false (incompatible with --external), switch to --bundle so relative imports (routes.ts) are inlined into each check file, and fix ESLint errors (regex u flag, await-in-loop, no-plusplus, catch param naming, dynamic import comment). --- .../prod-invocation/create-test-app.js | 8 +-- .../test-application/tsconfig.json | 15 ++++++ scripts/build-test-app.js | 10 ++-- scripts/run-test-app-checks.js | 50 +++++++++++-------- 4 files changed, 53 insertions(+), 30 deletions(-) create mode 100644 integration-tests/test-application/tsconfig.json diff --git a/.github/scripts/prod-invocation/create-test-app.js b/.github/scripts/prod-invocation/create-test-app.js index ff079f6..6805c57 100644 --- a/.github/scripts/prod-invocation/create-test-app.js +++ b/.github/scripts/prod-invocation/create-test-app.js @@ -4,6 +4,7 @@ import { join } from 'path'; const TEST_APP_SOURCE_FILE_PATH = './integration-tests/test-application/test-app.ts'; const TEST_APP_WASM_FILE_PATH = './integration-tests/test-application/dist/test-app.wasm'; +const TEST_APP_TSCONFIG_PATH = './integration-tests/test-application/tsconfig.json'; const CHECKS_SOURCE_DIR = './integration-tests/test-application/checks'; const CHECKS_DIST_DIR = './integration-tests/test-application/dist/checks'; @@ -18,7 +19,7 @@ export default async ({ core }) => { // Build the test app into a WASM binary const buildResponse = execSync( - `./bin/fastedge-build.js --input ${TEST_APP_SOURCE_FILE_PATH} --output ${TEST_APP_WASM_FILE_PATH}`, + `./bin/fastedge-build.js --input ${TEST_APP_SOURCE_FILE_PATH} --output ${TEST_APP_WASM_FILE_PATH} --tsconfig ${TEST_APP_TSCONFIG_PATH}`, { encoding: 'utf8', cwd: workspaceDir }, ); @@ -39,11 +40,10 @@ export default async ({ core }) => { execSync( [ - 'node ./node_modules/.bin/esbuild', - '--bundle=false', + './node_modules/.bin/esbuild', + '--bundle', '--format=esm', '--platform=node', - '--external:fastedge::*', `--outdir=${CHECKS_DIST_DIR}`, checkFiles.join(' '), ].join(' '), diff --git a/integration-tests/test-application/tsconfig.json b/integration-tests/test-application/tsconfig.json new file mode 100644 index 0000000..16dc31c --- /dev/null +++ b/integration-tests/test-application/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "esnext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true + }, + "include": [ + "**/*.ts", + "../../types/**/*.d.ts" + ] +} diff --git a/scripts/build-test-app.js b/scripts/build-test-app.js index 988917e..76f1c5c 100644 --- a/scripts/build-test-app.js +++ b/scripts/build-test-app.js @@ -1,13 +1,15 @@ #!/usr/bin/env node // Builds integration-tests/test-application/test-app.ts into a WASM binary. // Requires the SDK to be built first: pnpm run build:js -import { execSync } from 'child_process'; +import { execSync } from 'node:child_process'; const INPUT = './integration-tests/test-application/test-app.ts'; const OUTPUT = './integration-tests/test-application/dist/test-app.wasm'; +const TSCONFIG = './integration-tests/test-application/tsconfig.json'; -const output = execSync(`./bin/fastedge-build.js --input ${INPUT} --output ${OUTPUT}`, { - encoding: 'utf8', -}); +const output = execSync( + `./bin/fastedge-build.js --input ${INPUT} --output ${OUTPUT} --tsconfig ${TSCONFIG}`, + { encoding: 'utf8' }, +); process.stdout.write(output); diff --git a/scripts/run-test-app-checks.js b/scripts/run-test-app-checks.js index a5da09a..2c5d52d 100644 --- a/scripts/run-test-app-checks.js +++ b/scripts/run-test-app-checks.js @@ -1,13 +1,14 @@ #!/usr/bin/env node +/* eslint-disable no-console */ // Compiles checks/*.ts and runs all check functions against a deployed app. // Usage: APP_URL=https://your-app.example.com pnpm test:app:check // BUILD_SHA= APP_URL=... pnpm test:app:check (optional: override sha) -import { execSync } from 'child_process'; -import { mkdirSync, readdirSync } from 'fs'; -import { join, resolve } from 'path'; -import { pathToFileURL } from 'url'; +import { execSync } from 'node:child_process'; +import { mkdirSync, readdirSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; -const appUrl = process.env.APP_URL?.replace(/\/$/, ''); +const appUrl = process.env.APP_URL?.replace(/\/$/u, ''); if (!appUrl) { console.error('Error: APP_URL environment variable is required'); console.error('Usage: APP_URL=https://your-app.example.com pnpm test:app:check'); @@ -20,9 +21,8 @@ const buildSha = const CHECKS_SOURCE_DIR = './integration-tests/test-application/checks'; const CHECKS_DIST_DIR = './integration-tests/test-application/dist/checks'; -// Compile TypeScript check modules for Node.js. fastedge:: modules are WASM-only -// so they are marked external — check functions never call handler code, so they -// never execute and the missing modules are never imported at runtime. +// Compile TypeScript check modules for Node.js. Bundle each file so relative +// imports (routes.ts, types.ts) are inlined and resolve correctly from dist/checks/. mkdirSync(CHECKS_DIST_DIR, { recursive: true }); const checkFiles = readdirSync(CHECKS_SOURCE_DIR) .filter((f) => f.endsWith('.ts')) @@ -30,38 +30,44 @@ const checkFiles = readdirSync(CHECKS_SOURCE_DIR) execSync( [ - 'node ./node_modules/.bin/esbuild', - '--bundle=false', + './node_modules/.bin/esbuild', + '--bundle', '--format=esm', '--platform=node', - '--external:fastedge::*', `--outdir=${CHECKS_DIST_DIR}`, checkFiles.join(' '), ].join(' '), { encoding: 'utf8' }, ); -// Auto-discover and run all check modules. Unlike CI, runs all checks and -// reports all failures rather than stopping on the first. +// Auto-discover all compiled check modules. const checksDir = resolve(CHECKS_DIST_DIR); const checkModules = await Promise.all( readdirSync(checksDir) .filter((f) => f.endsWith('.js')) - .map((f) => import(pathToFileURL(join(checksDir, f)).href)), + .map((f) => import(/* webpackChunkName: "Check" */ pathToFileURL(join(checksDir, f)).href)), ); +// Run all checks and report all failures (not fail-fast). const ctx = { buildSha }; +const checkResults = await Promise.allSettled( + checkModules.map(async (mod) => { + await mod.check(appUrl, ctx); + return mod.name; + }), +); + let passed = 0; let failed = 0; -for (const mod of checkModules) { - try { - await mod.check(appUrl, ctx); - console.log(`✓ ${mod.name} check passed`); - passed++; - } catch (e) { - console.error(`✗ ${mod.name} check failed: ${e.message}`); - failed++; +for (const [index, result] of checkResults.entries()) { + const modName = checkModules[index].name; + if (result.status === 'fulfilled') { + console.log(`✓ ${modName} check passed`); + passed += 1; + } else { + console.error(`✗ ${modName} check failed: ${result.reason.message}`); + failed += 1; } } From 18bbd5532e576e754fa14495152ad7c85a725aa7 Mon Sep 17 00:00:00 2001 From: Gordon Farquharson Date: Mon, 8 Jun 2026 18:00:40 +0100 Subject: [PATCH 07/11] test(response-clone): extend prod-invocation guard with getReader mutation tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add four test scenarios covering the full clone surface: - Constructed response text() on both branches (basic correctness) - Constructed response getReader() + mutation guard: drain original, fill(0) all chunks, verify clone reads independently - Incoming fetch() text() on both branches (HttpIncomingBody materialisation) - Incoming fetch() getReader() + mutation guard: the precise original bug scenario — host-backed body, both branches via getReader() Tests 2 and 4 currently fail: the tee implementation enqueues the same Uint8Array reference to both branches rather than cloning each chunk, so fill(0) on the original's chunks corrupts the clone's queued data. This is a separate bug from the materialisation fix in PR #312 and blocks release. Update KNOWN_LIMITATIONS.md and CONTEXT_INDEX.md to document the tee chunk independence bug and mark Response.clone() as partially implemented. --- context/CONTEXT_INDEX.md | 2 +- context/KNOWN_LIMITATIONS.md | 44 ++++--- .../test-application/checks/response-clone.ts | 117 ++++++++++++++---- .../handlers/response-clone.ts | 111 ++++++++++++++--- 4 files changed, 216 insertions(+), 58 deletions(-) diff --git a/context/CONTEXT_INDEX.md b/context/CONTEXT_INDEX.md index 55b34a9..41745a2 100644 --- a/context/CONTEXT_INDEX.md +++ b/context/CONTEXT_INDEX.md @@ -181,7 +181,7 @@ Items that need attention. Surface these when asked "what's next" or "what needs These are runtime/Web-API behaviors that have been *requested* in patterns docs but cannot yet be verified against an existing example or runtime test. Build a minimal example app proving each works on FastEdge before adding it to a `docs/` file. If a behavior is **not** supported, capture that here too — negative findings are also documentation. -- **`Response.clone()`** — **Implemented** via `gcore/integration` StarlingMonkey branch (2026-06-08). `clone()` is now in `types/globals.d.ts`. Upstream PR #312 open. See `context/PATCHES.md` for rebase procedure and test guard removal checklist. +- **`Response.clone()`** — **Partially implemented** via `gcore/integration` StarlingMonkey branch (2026-06-08). Calling `.clone()` and reading both branches via `.text()` works. However, the tee implementation shares chunk `ArrayBuffer`s between branches — mutating a `Uint8Array` chunk from one reader corrupts the other. Blocks release. See `context/KNOWN_LIMITATIONS.md` for the full bug description and `context/PATCHES.md` for the removal checklist. - **`fetch(url, { redirect: "manual" })`** — Standard Web Fetch option, returns the upstream redirect response without following it. Used by patterns where the app needs to inspect or rewrite the `Location` header. Runtime test harness includes WPT `redirect-mode.any.js` but that does not confirm FastEdge's outbound `fetch` honors the option in production. Build: a handler that issues a `fetch()` to a known 302 endpoint with `redirect: "manual"` and asserts the response is the 302 itself, not the followed target. If it works, the `docs/PROXY_PATTERNS.md` operational notes can call out manual redirect handling. When adding either to docs, also update the manifest source description so reviewers know the content is now grounded in an example. diff --git a/context/KNOWN_LIMITATIONS.md b/context/KNOWN_LIMITATIONS.md index faffbdf..736dd2b 100644 --- a/context/KNOWN_LIMITATIONS.md +++ b/context/KNOWN_LIMITATIONS.md @@ -14,22 +14,34 @@ or asks "does FastEdge support X?". For *planned* work and unverified items, see --- -## `Response.clone()` — ~~not implemented~~ implemented via gcore patch - -**Status:** Implemented in the `gcore/integration` StarlingMonkey branch (as of -2026-06-08). `clone(): Response` is now declared on the `Response` interface in -`types/globals.d.ts`. See `context/PATCHES.md` for the upstream PR status. - -**When the upstream PR merges** ([PR #312](https://github.com/bytecodealliance/StarlingMonkey/pull/312)) -and the submodule is rebased onto a release containing it, remove the -prod-invocation test guard per the checklist in `context/PATCHES.md`. - -**Historical note:** The implementation was non-trivial because an incoming -`Response` (result of `fetch()`) is backed by a host `HttpIncomingBody` handle -that the runtime fast-forwards directly to the outgoing body without materialising -a JS `ReadableStream`. Cloning requires reifying it into a tee-able stream and -giving up that fast path. PR #312 solves this by materialising the stream on -clone. +## `Response.clone()` — partial: materialisation fixed, tee chunk independence broken + +**Status:** Partially implemented in the `gcore/integration` StarlingMonkey branch +(as of 2026-06-08). `clone(): Response` is declared in `types/globals.d.ts` and +calling `.clone()` no longer throws. Reading both branches via `.text()` works +correctly. See `context/PATCHES.md` for the upstream PR status. + +**Known remaining bug — tee shares chunk buffers:** Reading either clone branch +via `getReader()` and mutating the returned `Uint8Array` chunks corrupts the +other branch's data. The tee implementation enqueues the same `Uint8Array` +reference (or a view into the same `ArrayBuffer`) to both branches rather than +cloning each chunk. This affects both constructed responses and incoming `fetch()` +responses. Confirmed by the prod-invocation mutation guard tests (tests 2 and 4 +in `integration-tests/test-application/handlers/response-clone.ts`). + +**Readable byte stream tee** ([spec §8.2](https://streams.spec.whatwg.org/#abstract-opdef-readablebytestreamtee)) +requires cloning each chunk before enqueueing to the second branch. This is a +separate issue from PR #312 and needs its own upstream fix. + +**Do not release until** the prod-invocation `Response.clone` check passes +(currently fails on `constructedReader` and `incomingReader` mutation assertions). +See the removal checklist in `context/PATCHES.md`. + +**Historical note:** PR #312 fixed the materialisation: an incoming `Response` +(result of `fetch()`) is backed by a host `HttpIncomingBody` handle that the +runtime fast-forwards directly to the outgoing body without materialising a JS +`ReadableStream`. Cloning requires reifying it into a tee-able stream. That part +works. The remaining bug is in the tee algorithm itself. --- diff --git a/integration-tests/test-application/checks/response-clone.ts b/integration-tests/test-application/checks/response-clone.ts index bec7723..77653f5 100644 --- a/integration-tests/test-application/checks/response-clone.ts +++ b/integration-tests/test-application/checks/response-clone.ts @@ -5,32 +5,97 @@ import { RESPONSE_CLONE } from '../routes.js'; export const name = RESPONSE_CLONE.name; +interface ResponseCloneResult { + constructed: { + origText: string; + cloneText: string; + cloneStatus: number; + cloneHeader: string | null; + }; + constructedReader: { + origText: string; + cloneText: string; + }; + incomingText: { + origText: string; + cloneText: string; + }; + incomingReader: { + origText: string; + cloneText: string; + }; + consumedCloneError: string | null; + lockedCloneError: string | null; +} + +function assert(condition: boolean, message: string): void { + if (!condition) throw new Error(`${RESPONSE_CLONE.route}: ${message}`); +} + export async function check(appUrl: string, _ctx: CheckContext): Promise { const res = await fetch(`${appUrl}${RESPONSE_CLONE.route}`); - if (res.status !== 200) throw new Error(`${RESPONSE_CLONE.route}: bad status ${res.status}`); - const data = (await res.json()) as { - originalText: string; - clonedText: string; - originalStatus: number; - clonedStatus: number; - originalHeader: string | null; - clonedHeader: string | null; - consumedCloneError: string | null; - }; - if (data.originalText !== 'clone-body') - throw new Error(`${RESPONSE_CLONE.route}: originalText wrong "${data.originalText}"`); - if (data.clonedText !== 'clone-body') - throw new Error(`${RESPONSE_CLONE.route}: clonedText wrong "${data.clonedText}"`); - if (data.originalStatus !== 201) - throw new Error(`${RESPONSE_CLONE.route}: originalStatus wrong ${data.originalStatus}`); - if (data.clonedStatus !== 201) - throw new Error(`${RESPONSE_CLONE.route}: clonedStatus wrong ${data.clonedStatus}`); - if (data.originalHeader !== 'value') - throw new Error(`${RESPONSE_CLONE.route}: originalHeader wrong "${data.originalHeader}"`); - if (data.clonedHeader !== 'value') - throw new Error(`${RESPONSE_CLONE.route}: clonedHeader wrong "${data.clonedHeader}"`); - if (data.consumedCloneError !== 'TypeError') - throw new Error( - `${RESPONSE_CLONE.route}: expected TypeError on consumed clone, got "${data.consumedCloneError}"`, - ); + assert(res.status === 200, `bad status ${res.status}`); + const data = (await res.json()) as ResponseCloneResult; + + // Test 1: constructed text() — basic clone correctness + metadata preservation + assert( + data.constructed.origText === 'clone-body', + `constructed origText wrong: "${data.constructed.origText}"`, + ); + assert( + data.constructed.cloneText === 'clone-body', + `constructed cloneText wrong: "${data.constructed.cloneText}"`, + ); + assert( + data.constructed.cloneStatus === 201, + `constructed cloneStatus wrong: ${data.constructed.cloneStatus}`, + ); + assert( + data.constructed.cloneHeader === 'value', + `constructed cloneHeader wrong: "${data.constructed.cloneHeader}"`, + ); + + // Test 2: constructed getReader() + mutation guard + // origText was decoded before fill(0); cloneText must match it, not return null bytes. + assert( + data.constructedReader.origText === 'reader-body', + `constructedReader origText wrong: "${data.constructedReader.origText}"`, + ); + assert( + data.constructedReader.cloneText === 'reader-body', + `constructedReader cloneText wrong after mutation: "${data.constructedReader.cloneText}" — shared buffer detected`, + ); + + // Test 3: incoming fetch() text() — HttpIncomingBody materialisation + tee + assert(data.incomingText.origText.length > 0, 'incomingText origText is empty'); + assert(data.incomingText.cloneText.length > 0, 'incomingText cloneText is empty'); + assert( + data.incomingText.origText === data.incomingText.cloneText, + 'incomingText orig/clone mismatch', + ); + + // Test 4: incoming fetch() getReader() + mutation guard + // The precise original bug scenario: host-backed body, both branches via getReader(). + // origText decoded before fill(0); if buffers are shared, cloneText would be null bytes. + assert(data.incomingReader.origText.length > 0, 'incomingReader origText is empty'); + assert( + data.incomingReader.cloneText.length > 0, + 'incomingReader cloneText is empty after mutation — shared buffer detected', + ); + assert( + data.incomingReader.origText === data.incomingReader.cloneText, + `incomingReader orig/clone mismatch after mutation — shared buffer detected`, + ); + + // Test 5: consuming a body then cloning throws TypeError + assert( + data.consumedCloneError === 'TypeError', + `expected TypeError cloning consumed body, got "${data.consumedCloneError}"`, + ); + + // Test 6: locking a body (via getReader) then cloning throws TypeError + assert( + data.lockedCloneError === 'TypeError', + `expected TypeError cloning locked body, got "${data.lockedCloneError}"`, + ); } diff --git a/integration-tests/test-application/handlers/response-clone.ts b/integration-tests/test-application/handlers/response-clone.ts index 20aa7ad..1a42122 100644 --- a/integration-tests/test-application/handlers/response-clone.ts +++ b/integration-tests/test-application/handlers/response-clone.ts @@ -1,32 +1,113 @@ // Temporary guard: remove once https://github.com/bytecodealliance/StarlingMonkey/pull/312 // merges upstream and the StarlingMonkey submodule is rebased onto a release containing it. +import { getEnv } from 'fastedge::env'; import { RESPONSE_CLONE } from '../routes.js'; export const route = RESPONSE_CLONE.route; +async function readStream(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (value) chunks.push(value); + } + return chunks; +} + +function decodeChunks(chunks: Uint8Array[]): string { + const total = new Uint8Array(chunks.reduce((n, c) => n + c.byteLength, 0)); + let offset = 0; + for (const chunk of chunks) { + total.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder().decode(total); +} + export async function handler(_req: Request): Promise { - const original = new Response('clone-body', { - status: 201, - headers: { 'x-test': 'value' }, - }); - const cloned = original.clone(); + const fetchUrl = getEnv('TEST_FETCH_URL') || 'https://auth.gcore.com/login/assets/config.json'; - const [originalText, clonedText] = await Promise.all([original.text(), cloned.text()]); + // Test 1: constructed response — text() on both branches (basic correctness + metadata) + const constructed = new Response('clone-body', { status: 201, headers: { 'x-test': 'value' } }); + const constructedClone = constructed.clone(); + const [constructedOrigText, constructedCloneText] = await Promise.all([ + constructed.text(), + constructedClone.text(), + ]); + // Test 2: constructed response — getReader() + mutation guard. + // Drain original, poison its chunk buffers with fill(0), then drain clone. + // If the tee shares the underlying ArrayBuffer, clone reads zeroed data. + const forReader = new Response('reader-body'); + const forReaderClone = forReader.clone(); + const constructedOrigChunks = await readStream(forReader.body!); + const constructedReaderOrigText = decodeChunks(constructedOrigChunks); + for (const chunk of constructedOrigChunks) chunk.fill(0); + const constructedReaderCloneText = decodeChunks(await readStream(forReaderClone.body!)); + + // Test 3: incoming fetch() — text() on both branches. + // Exercises the HttpIncomingBody materialisation + tee path. + const incoming = await fetch(fetchUrl); + const incomingClone = incoming.clone(); + const [incomingTextOrigText, incomingTextCloneText] = await Promise.all([ + incoming.text(), + incomingClone.text(), + ]); + + // Test 4: incoming fetch() — getReader() + mutation guard. + // The precise original bug scenario: host-backed body + getReader() on both branches. + // Drain original via getReader(), poison its buffers, then drain clone via getReader(). + const incomingForReader = await fetch(fetchUrl); + const incomingForReaderClone = incomingForReader.clone(); + const incomingOrigChunks = await readStream(incomingForReader.body!); + const incomingReaderOrigText = decodeChunks(incomingOrigChunks); + for (const chunk of incomingOrigChunks) chunk.fill(0); + const incomingReaderCloneText = decodeChunks(await readStream(incomingForReaderClone.body!)); + + // Test 5: cloning a consumed body throws TypeError. + const consumed = new Response('consumed'); + await consumed.text(); let consumedCloneError: string | null = null; try { - original.clone(); - } catch (e) { - consumedCloneError = e instanceof TypeError ? 'TypeError' : (e as Error).constructor.name; + consumed.clone(); + } catch (error) { + consumedCloneError = + error instanceof TypeError ? 'TypeError' : (error as Error).constructor.name; + } + + // Test 6: cloning a locked body (getReader() called) throws TypeError. + const locked = new Response('locked'); + locked.body!.getReader(); + let lockedCloneError: string | null = null; + try { + locked.clone(); + } catch (error) { + lockedCloneError = + error instanceof TypeError ? 'TypeError' : (error as Error).constructor.name; } return Response.json({ - originalText, - clonedText, - originalStatus: original.status, - clonedStatus: cloned.status, - originalHeader: original.headers.get('x-test'), - clonedHeader: cloned.headers.get('x-test'), + constructed: { + origText: constructedOrigText, + cloneText: constructedCloneText, + cloneStatus: constructedClone.status, + cloneHeader: constructedClone.headers.get('x-test'), + }, + constructedReader: { + origText: constructedReaderOrigText, + cloneText: constructedReaderCloneText, + }, + incomingText: { + origText: incomingTextOrigText, + cloneText: incomingTextCloneText, + }, + incomingReader: { + origText: incomingReaderOrigText, + cloneText: incomingReaderCloneText, + }, consumedCloneError, + lockedCloneError, }); } From b4b13dcd62927f05dd50255cce8b56019f2ae97b Mon Sep 17 00:00:00 2001 From: Gordon Farquharson Date: Tue, 9 Jun 2026 17:48:13 +0100 Subject: [PATCH 08/11] chore(runtime): bump StarlingMonkey to Response.clone + blob fixes (gcore/integration) --- context/PATCHES.md | 29 ++++++++++++++--------------- runtime/StarlingMonkey | 2 +- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/context/PATCHES.md b/context/PATCHES.md index 163a554..7105c04 100644 --- a/context/PATCHES.md +++ b/context/PATCHES.md @@ -11,29 +11,25 @@ below to carry the patches forward. ## Applied patches -### 1. `feat(fetch): implement Response.clone()` +### 1. `fix(fetch): Response.clone - full clone isolation via cloneForBranch2 tee` | Field | Value | |-------|-------| -| Commit on `gcore/integration` | `65f6a6c` | +| Commit on `gcore/integration` | `db26865` | | Source branch | `godronus/feature/response-clone` | | Upstream PR | https://github.com/bytecodealliance/StarlingMonkey/pull/312 | | Status | Open — awaiting upstream review | -### 2. `test(wpt): update WPT expectations` +Single squashed commit implementing `Response.clone()`: eager `cloneForBranch2` +tee for body isolation, handle-based header cloning (preserves `Set-Cookie`, +avoids the immutable-headers throw), the `bodyUsed`/`ReadableStreamIsDisturbed` +fallback, and the accompanying WPT expectations. -| Field | Value | -|-------|-------| -| Commit on `gcore/integration` | `8fc9b89` | -| Source branch | `godronus/feature/response-clone` | -| Upstream PR | https://github.com/bytecodealliance/StarlingMonkey/pull/312 | -| Status | Open — part of the Response.clone() PR | - -### 3. `fix(fetch): body.blob() sets Blob.type from Content-Type header` +### 2. `fix(fetch): body.blob() sets Blob.type from Content-Type header` | Field | Value | |-------|-------| -| Commit on `gcore/integration` | `84f5d52` | +| Commit on `gcore/integration` | `702d7a4` | | Source branch | `godronus/fix/blob-type` | | Upstream issue | https://github.com/bytecodealliance/StarlingMonkey/issues/311 | | Upstream PR | (PR opened from fork — link when available) | @@ -75,11 +71,14 @@ Also delete any prod-invocation test guard that was added for the patch: - `integration-tests/test-application/handlers/.ts` - `integration-tests/test-application/checks/.ts` -- The route constant in `integration-tests/test-application/routes.ts` -- The import + array entry in `integration-tests/test-application/test-app.ts` +- Any helper handler the guard relies on (e.g. a source route it fetches) +- The route constant(s) in `integration-tests/test-application/routes.ts` +- The import(s) + array entries in `integration-tests/test-application/test-app.ts` ## Current test guards | Patch | Guard files | Remove when | |-------|-------------|-------------| -| Response.clone() (patches 1–2) | `handlers/response-clone.ts`, `checks/response-clone.ts` | PR #312 merges and submodule is rebased | +| Response.clone() | `handlers/response-clone.ts` (tests 1–9), `checks/response-clone.ts`, `handlers/multi-chunk-source.ts` (multi-chunk source self-fetched by tests 7–9), `RESPONSE_CLONE` + `MULTI_CHUNK_SOURCE` in `routes.ts`, and both `test-app.ts` registrations | PR #312 merges and submodule is rebased | + +The `response-clone` guard covers: basic clone + metadata (1), constructed getReader mutation guard (2), incoming `text()` (3) and getReader mutation guard (4), consumed/locked `TypeError` (5–6), host-backed multi-chunk mutation guard (7), cancel-one-branch (8), and read-header-then-clone (9, guards the Headers-immutable clone path). diff --git a/runtime/StarlingMonkey b/runtime/StarlingMonkey index 84f5d52..702d7a4 160000 --- a/runtime/StarlingMonkey +++ b/runtime/StarlingMonkey @@ -1 +1 @@ -Subproject commit 84f5d526aa86f744cc433c2451a74599205f882d +Subproject commit 702d7a44ad46a6ef32bbf4420b5d42c7847ade56 From 646f6e340bcbbd7fb4ccfedc28c95624c4ad3727 Mon Sep 17 00:00:00 2001 From: Gordon Farquharson Date: Tue, 9 Jun 2026 17:48:13 +0100 Subject: [PATCH 09/11] test(response-clone): add host-backed multi-chunk, cancel, and header-then-clone guards --- integration-tests/test-application/README.md | 5 +- .../test-application/checks/response-clone.ts | 47 ++++++++++++++ .../handlers/multi-chunk-source.ts | 26 ++++++++ .../handlers/response-clone.ts | 61 +++++++++++++++++-- integration-tests/test-application/routes.ts | 3 + .../test-application/test-app.ts | 3 +- 6 files changed, 138 insertions(+), 7 deletions(-) create mode 100644 integration-tests/test-application/handlers/multi-chunk-source.ts diff --git a/integration-tests/test-application/README.md b/integration-tests/test-application/README.md index 9b70e09..561045b 100644 --- a/integration-tests/test-application/README.md +++ b/integration-tests/test-application/README.md @@ -28,7 +28,8 @@ Each test is a pair of files with matching names: | `handlers/outbound-fetch.ts` | `checks/outbound-fetch.ts` | `GET /fetch` | Outbound HTTP fetch | | `handlers/secret.ts` | `checks/secret.ts` | `GET /secret` | Secret injection | | `handlers/echo.ts` | `checks/echo.ts` | `POST /echo` | Request method/headers/body echo | -| `handlers/response-clone.ts` | `checks/response-clone.ts` | `GET /response-clone` | **[temporary]** `Response.clone()` | +| `handlers/response-clone.ts` | `checks/response-clone.ts` | `GET /response-clone` | **[temporary]** `Response.clone()` (9 sub-tests) | +| `handlers/multi-chunk-source.ts` | _(none — helper)_ | `GET /multi-chunk-source` | **[temporary]** serves a multi-chunk body the `response-clone` test self-fetches (tests 7–9) | ## How CI uses this @@ -92,4 +93,4 @@ Some tests exist only to guard against a specific regression until an upstream f | Test | Upstream fix | Remove when | |------|-------------|-------------| -| `response-clone` | [PR #312](https://github.com/bytecodealliance/StarlingMonkey/pull/312) | PR merges and submodule is rebased | +| `response-clone` (+ its `multi-chunk-source` helper route) | [PR #312](https://github.com/bytecodealliance/StarlingMonkey/pull/312) | PR merges and submodule is rebased | diff --git a/integration-tests/test-application/checks/response-clone.ts b/integration-tests/test-application/checks/response-clone.ts index 77653f5..1ed9c62 100644 --- a/integration-tests/test-application/checks/response-clone.ts +++ b/integration-tests/test-application/checks/response-clone.ts @@ -24,10 +24,25 @@ interface ResponseCloneResult { origText: string; cloneText: string; }; + multiChunk: { + origLength: number; + matches: boolean; + }; + canceledOriginal: { + cloneLength: number; + matchesSource: boolean; + }; + headerThenClone: { + error: string | null; + bodiesMatch: boolean; + }; consumedCloneError: string | null; lockedCloneError: string | null; } +// MULTI_CHUNK_SOURCE serves CHUNK_COUNT (128) chunks of CHUNK_SIZE (1024) bytes. +const MULTI_CHUNK_BODY_LENGTH = 128 * 1024; + function assert(condition: boolean, message: string): void { if (!condition) throw new Error(`${RESPONSE_CLONE.route}: ${message}`); } @@ -98,4 +113,36 @@ export async function check(appUrl: string, _ctx: CheckContext): Promise { data.lockedCloneError === 'TypeError', `expected TypeError cloning locked body, got "${data.lockedCloneError}"`, ); + + // Test 7: host-backed, multi-chunk body + mutation guard. + // A genuine HttpIncomingBody re-segmented across multiple host reads; if any chunk's buffer + // is shared across the tee, fill(0) on the original would corrupt the clone. + assert( + data.multiChunk.origLength === MULTI_CHUNK_BODY_LENGTH, + `multiChunk origLength wrong: ${data.multiChunk.origLength} (expected ${MULTI_CHUNK_BODY_LENGTH})`, + ); + assert( + data.multiChunk.matches, + `multiChunk clone != original after mutation — shared buffer across multi-chunk host body`, + ); + + // Test 8: cancelling the original branch must not starve or corrupt the clone. + assert( + data.canceledOriginal.cloneLength === MULTI_CHUNK_BODY_LENGTH, + `canceledOriginal cloneLength wrong: ${data.canceledOriginal.cloneLength} (clone starved by original cancel)`, + ); + assert( + data.canceledOriginal.matchesSource, + `canceledOriginal clone content mismatch after cancelling the original branch`, + ); + + // Test 9: reading a header on an incoming response then cloning must not throw. + assert( + data.headerThenClone.error === null, + `cloning after reading a header threw: ${data.headerThenClone.error}`, + ); + assert( + data.headerThenClone.bodiesMatch, + `headerThenClone bodies empty or mismatched`, + ); } diff --git a/integration-tests/test-application/handlers/multi-chunk-source.ts b/integration-tests/test-application/handlers/multi-chunk-source.ts new file mode 100644 index 0000000..4b9242d --- /dev/null +++ b/integration-tests/test-application/handlers/multi-chunk-source.ts @@ -0,0 +1,26 @@ +// Temporary guard helper for the Response.clone() test: serves a multi-chunk streaming +// body so the clone test can fetch a genuine host-backed (HttpIncomingBody) body that the +// host re-segments across multiple reads. Remove once +// https://github.com/bytecodealliance/StarlingMonkey/pull/312 merges and the StarlingMonkey +// submodule is rebased onto a release containing it. +import { MULTI_CHUNK_SOURCE } from '../routes.js'; + +export const route = MULTI_CHUNK_SOURCE.route; + +const CHUNK_SIZE = 1024; +const CHUNK_COUNT = 128; // ~128 KiB total — large enough to span multiple host reads + +export async function handler(_req: Request): Promise { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + start(controller) { + for (let i = 0; i < CHUNK_COUNT; i += 1) { + // Distinct per-chunk prefix padded to a fixed width, so the reassembled body is + // deterministic (CHUNK_SIZE * CHUNK_COUNT bytes) and order-sensitive. + controller.enqueue(encoder.encode(`${i}:`.padEnd(CHUNK_SIZE, '.'))); + } + controller.close(); + }, + }); + return new Response(stream, { headers: { 'content-type': 'application/octet-stream' } }); +} diff --git a/integration-tests/test-application/handlers/response-clone.ts b/integration-tests/test-application/handlers/response-clone.ts index 1a42122..a672124 100644 --- a/integration-tests/test-application/handlers/response-clone.ts +++ b/integration-tests/test-application/handlers/response-clone.ts @@ -1,7 +1,7 @@ // Temporary guard: remove once https://github.com/bytecodealliance/StarlingMonkey/pull/312 // merges upstream and the StarlingMonkey submodule is rebased onto a release containing it. import { getEnv } from 'fastedge::env'; -import { RESPONSE_CLONE } from '../routes.js'; +import { MULTI_CHUNK_SOURCE, RESPONSE_CLONE } from '../routes.js'; export const route = RESPONSE_CLONE.route; @@ -26,7 +26,7 @@ function decodeChunks(chunks: Uint8Array[]): string { return new TextDecoder().decode(total); } -export async function handler(_req: Request): Promise { +export async function handler(req: Request): Promise { const fetchUrl = getEnv('TEST_FETCH_URL') || 'https://auth.gcore.com/login/assets/config.json'; // Test 1: constructed response — text() on both branches (basic correctness + metadata) @@ -84,8 +84,48 @@ export async function handler(_req: Request): Promise { try { locked.clone(); } catch (error) { - lockedCloneError = - error instanceof TypeError ? 'TypeError' : (error as Error).constructor.name; + lockedCloneError = error instanceof TypeError ? 'TypeError' : (error as Error).constructor.name; + } + + // Tests 7 & 8 fetch a multi-chunk body served by this same app, so the body arrives as a + // genuine host-backed HttpIncomingBody re-segmented across multiple host reads. The handler + // sees an http:// request URL (TLS terminates at the edge), so force https to avoid a redirect. + const origin = new URL(req.url).origin.replace(/^http:/u, 'https:'); + const multiChunkUrl = `${origin}${MULTI_CHUNK_SOURCE.route}`; + + // Test 7: host-backed, multi-chunk — getReader() + mutation guard across many chunks. + // Drain the original via getReader(), poison every chunk with fill(0), then drain the clone. + const multiChunkRes = await fetch(multiChunkUrl); + const multiChunkClone = multiChunkRes.clone(); + const multiChunkOrigChunks = await readStream(multiChunkRes.body!); + const multiChunkOrigText = decodeChunks(multiChunkOrigChunks); + for (const chunk of multiChunkOrigChunks) chunk.fill(0); + const multiChunkCloneText = decodeChunks(await readStream(multiChunkClone.body!)); + + // Test 8: cancel the original branch, then drain the clone. + // Cancelling one branch must neither starve nor corrupt the other. + const forCancel = await fetch(multiChunkUrl); + const forCancelClone = forCancel.clone(); + await forCancel.body!.cancel(); + const canceledOriginalCloneText = decodeChunks(await readStream(forCancelClone.body!)); + + // Test 9: reading a header on an incoming (immutable-headers) response and THEN cloning must + // not throw. Materialising the headers makes clone take the maybe_headers branch, which + // currently rebuilds them through a guarded append and throws "Headers are immutable". + const headerThenClone = await fetch(fetchUrl); + headerThenClone.headers.get('content-type'); // materialise the headers object + let headerThenCloneError: string | null = null; + let headerThenCloneBodiesMatch = false; + try { + const headerThenCloneClone = headerThenClone.clone(); + const [origText, cloneText] = await Promise.all([ + headerThenClone.text(), + headerThenCloneClone.text(), + ]); + headerThenCloneBodiesMatch = origText.length > 0 && origText === cloneText; + } catch (error) { + headerThenCloneError = + error instanceof Error ? `${error.constructor.name}: ${error.message}` : String(error); } return Response.json({ @@ -107,6 +147,19 @@ export async function handler(_req: Request): Promise { origText: incomingReaderOrigText, cloneText: incomingReaderCloneText, }, + // Lengths + comparisons (not the ~128 KiB bodies) keep the response small. + multiChunk: { + origLength: multiChunkOrigText.length, + matches: multiChunkOrigText === multiChunkCloneText, + }, + canceledOriginal: { + cloneLength: canceledOriginalCloneText.length, + matchesSource: canceledOriginalCloneText === multiChunkOrigText, + }, + headerThenClone: { + error: headerThenCloneError, + bodiesMatch: headerThenCloneBodiesMatch, + }, consumedCloneError, lockedCloneError, }); diff --git a/integration-tests/test-application/routes.ts b/integration-tests/test-application/routes.ts index 3d8971a..84f3a3c 100644 --- a/integration-tests/test-application/routes.ts +++ b/integration-tests/test-application/routes.ts @@ -3,3 +3,6 @@ export const OUTBOUND_FETCH = { name: 'outbound fetch', route: '/fetch' }; export const SECRET = { name: 'secret', route: '/secret' }; export const ECHO = { name: 'request echo', route: '/echo' }; export const RESPONSE_CLONE = { name: 'Response.clone', route: '/response-clone' }; +// Source for the Response.clone guard: serves a multi-chunk streaming body so the clone +// test can exercise a host-backed (HttpIncomingBody), multi-read body. Remove with the guard. +export const MULTI_CHUNK_SOURCE = { name: 'multi-chunk source', route: '/multi-chunk-source' }; diff --git a/integration-tests/test-application/test-app.ts b/integration-tests/test-application/test-app.ts index be03baa..75dbb28 100644 --- a/integration-tests/test-application/test-app.ts +++ b/integration-tests/test-application/test-app.ts @@ -1,13 +1,14 @@ import { Hono } from 'hono'; import * as echo from './handlers/echo.js'; import * as env from './handlers/env.js'; +import * as multiChunkSource from './handlers/multi-chunk-source.js'; import * as outboundFetch from './handlers/outbound-fetch.js'; import * as responseClone from './handlers/response-clone.js'; import * as secret from './handlers/secret.js'; const app = new Hono(); -const handlers = [env, outboundFetch, secret, echo, responseClone]; +const handlers = [env, outboundFetch, secret, echo, responseClone, multiChunkSource]; handlers.forEach((m) => app.all(m.route, (c) => m.handler(c.req.raw))); addEventListener('fetch', (event) => event.respondWith(app.fetch(event.request))); From 2ddd156d4a9ea8d03584e106d9f54ba298944d0e Mon Sep 17 00:00:00 2001 From: Gordon Farquharson Date: Tue, 9 Jun 2026 17:50:58 +0100 Subject: [PATCH 10/11] scipts are executable --- scripts/build-test-app.js | 0 scripts/run-test-app-checks.js | 0 2 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 scripts/build-test-app.js mode change 100644 => 100755 scripts/run-test-app-checks.js diff --git a/scripts/build-test-app.js b/scripts/build-test-app.js old mode 100644 new mode 100755 diff --git a/scripts/run-test-app-checks.js b/scripts/run-test-app-checks.js old mode 100644 new mode 100755 From 53ca8afdaebdf35c66eec9bc68282c01c83f95d7 Mon Sep 17 00:00:00 2001 From: Gordon Farquharson Date: Tue, 9 Jun 2026 18:22:52 +0100 Subject: [PATCH 11/11] copilot --- .../prod-invocation/create-test-app.js | 16 +++++----- .../prod-invocation/invoke-test-app.js | 6 ++-- context/CHANGELOG.md | 20 ++++++++++++ context/CONTEXT_INDEX.md | 3 +- context/KNOWN_LIMITATIONS.md | 31 ------------------- scripts/run-test-app-checks.js | 18 +++++++---- 6 files changed, 46 insertions(+), 48 deletions(-) diff --git a/.github/scripts/prod-invocation/create-test-app.js b/.github/scripts/prod-invocation/create-test-app.js index 6805c57..bc930c8 100644 --- a/.github/scripts/prod-invocation/create-test-app.js +++ b/.github/scripts/prod-invocation/create-test-app.js @@ -1,4 +1,4 @@ -import { execSync } from 'child_process'; +import { execFileSync, execSync } from 'child_process'; import { readdirSync } from 'fs'; import { join } from 'path'; @@ -32,21 +32,23 @@ export default async ({ core }) => { core.info(`Test application built into wasm binary at ${TEST_APP_WASM_FILE_PATH}`); // Compile TypeScript check modules to JS so invoke-test-app.js can import them in Node.js. - // fastedge:: modules are only available in the WASM runtime — mark them external so esbuild - // leaves the imports as-is. The check functions never call handler code so they never execute. + // Checks should import only routes.ts/types.ts, never handlers or fastedge:: modules. fastedge:: + // is marked external as a guard: a stray handler/fastedge:: import in a check then surfaces when + // Node loads that check, rather than aborting the whole bundle at build time. const checkFiles = readdirSync(join(workspaceDir, CHECKS_SOURCE_DIR)) .filter((f) => f.endsWith('.ts')) .map((f) => join(CHECKS_SOURCE_DIR, f)); - execSync( + execFileSync( + './node_modules/.bin/esbuild', [ - './node_modules/.bin/esbuild', '--bundle', '--format=esm', '--platform=node', + '--external:fastedge::*', `--outdir=${CHECKS_DIST_DIR}`, - checkFiles.join(' '), - ].join(' '), + ...checkFiles, + ], { encoding: 'utf8', cwd: workspaceDir }, ); diff --git a/.github/scripts/prod-invocation/invoke-test-app.js b/.github/scripts/prod-invocation/invoke-test-app.js index 63873c8..bedc218 100644 --- a/.github/scripts/prod-invocation/invoke-test-app.js +++ b/.github/scripts/prod-invocation/invoke-test-app.js @@ -37,10 +37,12 @@ export default async ({ context, core }) => { } break; } catch (error) { - core.warning(`Attempt ${attempt} failed: ${error.message}`); + const detail = error instanceof Error ? (error.stack ?? error.message) : String(error); + core.warning(`Attempt ${attempt} failed: ${detail}`); if (attempt === MAX_RETRIES) { + const summary = error instanceof Error ? error.message : String(error); throw new Error( - `Test application invocation failed after ${MAX_RETRIES} attempts: ${error.message}`, + `Test application invocation failed after ${MAX_RETRIES} attempts: ${summary}`, ); } } diff --git a/context/CHANGELOG.md b/context/CHANGELOG.md index 5025bef..775967f 100644 --- a/context/CHANGELOG.md +++ b/context/CHANGELOG.md @@ -5,6 +5,26 @@ When this file grows large, use grep to search — don't read linearly. --- +## [2026-06-09] — Response.clone() full isolation + headers-clone fix; prod guard expanded + +### Overview +Completed `Response.clone()`: the body tee now hands each branch independent chunks, and cloning a response after its headers have been read no longer throws. Squashed the Response.clone work into a single commit on `gcore/integration` and re-pinned the submodule. Expanded the prod-invocation guard. + +### Changes + +**Runtime (StarlingMonkey submodule, `gcore/integration`):** +- `fix(fetch): Response.clone - full clone isolation via cloneForBranch2 tee` — replaces the initial tee (which shared one chunk object between branches) with a hand-rolled `ReadableStreamDefaultTee` (`cloneForBranch2 = true`): branch1 keeps the original chunk, branch2 receives an eager structured clone taken at source-read time. Per-branch cancellation tracked (`canceled1`/`canceled2`). +- Header cloning rewritten to clone via the header handle (`headers_handle_clone`) instead of replaying entries through a guarded append. Fixes `TypeError: Headers are immutable` when cloning an incoming response after its headers were materialised, and preserves multiple `Set-Cookie`. +- The two earlier Response.clone commits (`feat` + wpt expectations) squashed into one; submodule re-pinned `84f5d52` → `702d7a4` (`gcore/integration` = `0.3.0` → Response.clone → blob #311 fix). +- WPT `fetch/api/response/response-clone.any.js` — 21/21. + +**Prod-invocation test application:** +- `response-clone` guard extended to 9 sub-tests: host-backed multi-chunk mutation guard (7), cancel-one-branch (8), read-header-then-clone (9). +- `handlers/multi-chunk-source.ts` added — serves a multi-chunk body the guard self-fetches (over https) to exercise a host-backed `HttpIncomingBody` re-segmented across multiple host reads. +- `KNOWN_LIMITATIONS.md` — removed the now-resolved `Response.clone()` tee-bug entry. + +--- + ## [2026-06-08] — Response.clone(), blob.type fix, prod-invocation test infrastructure ### Overview diff --git a/context/CONTEXT_INDEX.md b/context/CONTEXT_INDEX.md index 41745a2..a6322a9 100644 --- a/context/CONTEXT_INDEX.md +++ b/context/CONTEXT_INDEX.md @@ -37,7 +37,7 @@ | `PROJECT_OVERVIEW.md` | ~150 | Lightweight project overview — architecture, key modules, dev setup, common commands. Read when new to the codebase. | | `CHANGELOG.md` | ~25+ | Change history. Use grep, don't read linearly as this file grows. | | `ENHANCEMENTS.md` | ~50 | Known inconsistencies and planned improvements. Read before refactoring related areas. | -| `KNOWN_LIMITATIONS.md` | ~90 | Confirmed runtime gaps — standard Web APIs that look available but don't work on FastEdge (e.g. `Response.clone()`). Read when a user reports "X is in the types/spec but throws at runtime". | +| `KNOWN_LIMITATIONS.md` | ~90 | Confirmed runtime gaps — standard Web APIs that look available but don't work on FastEdge (e.g. `Response.error()`). Read when a user reports "X is in the types/spec but throws at runtime". | | `PATCHES.md` | ~60 | Applied patches on the `gcore/integration` StarlingMonkey branch — upstream PR links, rebase procedure, and retirement steps. Read before touching `runtime/StarlingMonkey`. | ### Plugin Integration (read when modifying manifest or examples) @@ -181,7 +181,6 @@ Items that need attention. Surface these when asked "what's next" or "what needs These are runtime/Web-API behaviors that have been *requested* in patterns docs but cannot yet be verified against an existing example or runtime test. Build a minimal example app proving each works on FastEdge before adding it to a `docs/` file. If a behavior is **not** supported, capture that here too — negative findings are also documentation. -- **`Response.clone()`** — **Partially implemented** via `gcore/integration` StarlingMonkey branch (2026-06-08). Calling `.clone()` and reading both branches via `.text()` works. However, the tee implementation shares chunk `ArrayBuffer`s between branches — mutating a `Uint8Array` chunk from one reader corrupts the other. Blocks release. See `context/KNOWN_LIMITATIONS.md` for the full bug description and `context/PATCHES.md` for the removal checklist. - **`fetch(url, { redirect: "manual" })`** — Standard Web Fetch option, returns the upstream redirect response without following it. Used by patterns where the app needs to inspect or rewrite the `Location` header. Runtime test harness includes WPT `redirect-mode.any.js` but that does not confirm FastEdge's outbound `fetch` honors the option in production. Build: a handler that issues a `fetch()` to a known 302 endpoint with `redirect: "manual"` and asserts the response is the 302 itself, not the followed target. If it works, the `docs/PROXY_PATTERNS.md` operational notes can call out manual redirect handling. When adding either to docs, also update the manifest source description so reviewers know the content is now grounded in an example. diff --git a/context/KNOWN_LIMITATIONS.md b/context/KNOWN_LIMITATIONS.md index 736dd2b..ee7ea70 100644 --- a/context/KNOWN_LIMITATIONS.md +++ b/context/KNOWN_LIMITATIONS.md @@ -14,37 +14,6 @@ or asks "does FastEdge support X?". For *planned* work and unverified items, see --- -## `Response.clone()` — partial: materialisation fixed, tee chunk independence broken - -**Status:** Partially implemented in the `gcore/integration` StarlingMonkey branch -(as of 2026-06-08). `clone(): Response` is declared in `types/globals.d.ts` and -calling `.clone()` no longer throws. Reading both branches via `.text()` works -correctly. See `context/PATCHES.md` for the upstream PR status. - -**Known remaining bug — tee shares chunk buffers:** Reading either clone branch -via `getReader()` and mutating the returned `Uint8Array` chunks corrupts the -other branch's data. The tee implementation enqueues the same `Uint8Array` -reference (or a view into the same `ArrayBuffer`) to both branches rather than -cloning each chunk. This affects both constructed responses and incoming `fetch()` -responses. Confirmed by the prod-invocation mutation guard tests (tests 2 and 4 -in `integration-tests/test-application/handlers/response-clone.ts`). - -**Readable byte stream tee** ([spec §8.2](https://streams.spec.whatwg.org/#abstract-opdef-readablebytestreamtee)) -requires cloning each chunk before enqueueing to the second branch. This is a -separate issue from PR #312 and needs its own upstream fix. - -**Do not release until** the prod-invocation `Response.clone` check passes -(currently fails on `constructedReader` and `incomingReader` mutation assertions). -See the removal checklist in `context/PATCHES.md`. - -**Historical note:** PR #312 fixed the materialisation: an incoming `Response` -(result of `fetch()`) is backed by a host `HttpIncomingBody` handle that the -runtime fast-forwards directly to the outgoing body without materialising a JS -`ReadableStream`. Cloning requires reifying it into a tee-able stream. That part -works. The remaining bug is in the tee algorithm itself. - ---- - ## `Response.error()` — not implemented **Status:** Not supported. Also commented out in the same `types/globals.d.ts` diff --git a/scripts/run-test-app-checks.js b/scripts/run-test-app-checks.js index 2c5d52d..27122a1 100755 --- a/scripts/run-test-app-checks.js +++ b/scripts/run-test-app-checks.js @@ -3,7 +3,7 @@ // Compiles checks/*.ts and runs all check functions against a deployed app. // Usage: APP_URL=https://your-app.example.com pnpm test:app:check // BUILD_SHA= APP_URL=... pnpm test:app:check (optional: override sha) -import { execSync } from 'node:child_process'; +import { execFileSync, execSync } from 'node:child_process'; import { mkdirSync, readdirSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; @@ -23,20 +23,24 @@ const CHECKS_DIST_DIR = './integration-tests/test-application/dist/checks'; // Compile TypeScript check modules for Node.js. Bundle each file so relative // imports (routes.ts, types.ts) are inlined and resolve correctly from dist/checks/. +// fastedge:: is marked external as a guard: checks should only import routes/types, but if one +// ever pulls in a handler, this keeps that stray import from aborting the whole bundle (it would +// instead surface when Node loads that check). mkdirSync(CHECKS_DIST_DIR, { recursive: true }); const checkFiles = readdirSync(CHECKS_SOURCE_DIR) .filter((f) => f.endsWith('.ts')) .map((f) => join(CHECKS_SOURCE_DIR, f)); -execSync( +execFileSync( + './node_modules/.bin/esbuild', [ - './node_modules/.bin/esbuild', '--bundle', '--format=esm', '--platform=node', + '--external:fastedge::*', `--outdir=${CHECKS_DIST_DIR}`, - checkFiles.join(' '), - ].join(' '), + ...checkFiles, + ], { encoding: 'utf8' }, ); @@ -66,7 +70,9 @@ for (const [index, result] of checkResults.entries()) { console.log(`✓ ${modName} check passed`); passed += 1; } else { - console.error(`✗ ${modName} check failed: ${result.reason.message}`); + const { reason } = result; + const detail = reason instanceof Error ? (reason.stack ?? reason.message) : String(reason); + console.error(`✗ ${modName} check failed: ${detail}`); failed += 1; } }