diff --git a/.github/scripts/prod-invocation/create-test-app.js b/.github/scripts/prod-invocation/create-test-app.js index aba17c3..bc930c8 100644 --- a/.github/scripts/prod-invocation/create-test-app.js +++ b/.github/scripts/prod-invocation/create-test-app.js @@ -1,10 +1,14 @@ -import { execSync } from 'child_process'; +import { execFileSync, 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 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'; -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 +17,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} --tsconfig ${TEST_APP_TSCONFIG_PATH}`, { encoding: 'utf8', cwd: workspaceDir }, ); @@ -29,4 +30,27 @@ 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. + // 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)); + + execFileSync( + './node_modules/.bin/esbuild', + [ + '--bundle', + '--format=esm', + '--platform=node', + '--external:fastedge::*', + `--outdir=${CHECKS_DIST_DIR}`, + ...checkFiles, + ], + { 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..bedc218 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,31 +16,33 @@ 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}`); + 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/.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/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/CHANGELOG.md b/context/CHANGELOG.md index 01ed8c9..775967f 100644 --- a/context/CHANGELOG.md +++ b/context/CHANGELOG.md @@ -5,6 +5,52 @@ 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 +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 caf2316..a6322a9 100644 --- a/context/CONTEXT_INDEX.md +++ b/context/CONTEXT_INDEX.md @@ -37,7 +37,8 @@ | `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) @@ -104,6 +105,15 @@ 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 + +### 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 @@ -171,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()`** — 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. @@ -194,7 +203,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/KNOWN_LIMITATIONS.md b/context/KNOWN_LIMITATIONS.md index 7113c27..ee7ea70 100644 --- a/context/KNOWN_LIMITATIONS.md +++ b/context/KNOWN_LIMITATIONS.md @@ -14,54 +14,6 @@ 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.error()` — not implemented **Status:** Not supported. Also commented out in the same `types/globals.d.ts` diff --git a/context/PATCHES.md b/context/PATCHES.md new file mode 100644 index 0000000..7105c04 --- /dev/null +++ b/context/PATCHES.md @@ -0,0 +1,84 @@ +# 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. `fix(fetch): Response.clone - full clone isolation via cloneForBranch2 tee` + +| Field | Value | +|-------|-------| +| 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 | + +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. + +### 2. `fix(fetch): body.blob() sets Blob.type from Content-Type header` + +| Field | Value | +|-------|-------| +| 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) | +| 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 +``` + +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` +- 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() | `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/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..561045b --- /dev/null +++ b/integration-tests/test-application/README.md @@ -0,0 +1,96 @@ +# 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()` (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 + +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` (+ 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/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..1ed9c62 --- /dev/null +++ b/integration-tests/test-application/checks/response-clone.ts @@ -0,0 +1,148 @@ +// 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; + +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; + }; + 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}`); +} + +export async function check(appUrl: string, _ctx: CheckContext): Promise { + const res = await fetch(`${appUrl}${RESPONSE_CLONE.route}`); + 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}"`, + ); + + // 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/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/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/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..a672124 --- /dev/null +++ b/integration-tests/test-application/handlers/response-clone.ts @@ -0,0 +1,166 @@ +// 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 { MULTI_CHUNK_SOURCE, 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 fetchUrl = getEnv('TEST_FETCH_URL') || 'https://auth.gcore.com/login/assets/config.json'; + + // 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 { + 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; + } + + // 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({ + 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, + }, + // 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/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..84f3a3c --- /dev/null +++ b/integration-tests/test-application/routes.ts @@ -0,0 +1,8 @@ +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' }; +// 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.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..75dbb28 --- /dev/null +++ b/integration-tests/test-application/test-app.ts @@ -0,0 +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, multiChunkSource]; +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/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/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/runtime/StarlingMonkey b/runtime/StarlingMonkey index 9dda8ba..702d7a4 160000 --- a/runtime/StarlingMonkey +++ b/runtime/StarlingMonkey @@ -1 +1 @@ -Subproject commit 9dda8ba7fcda2e17c6795d402f0478cf4c1f7f37 +Subproject commit 702d7a44ad46a6ef32bbf4420b5d42c7847ade56 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 diff --git a/scripts/build-test-app.js b/scripts/build-test-app.js new file mode 100755 index 0000000..76f1c5c --- /dev/null +++ b/scripts/build-test-app.js @@ -0,0 +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 '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} --tsconfig ${TSCONFIG}`, + { 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 100755 index 0000000..27122a1 --- /dev/null +++ b/scripts/run-test-app-checks.js @@ -0,0 +1,81 @@ +#!/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 { execFileSync, 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(/\/$/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'); + 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. 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)); + +execFileSync( + './node_modules/.bin/esbuild', + [ + '--bundle', + '--format=esm', + '--platform=node', + '--external:fastedge::*', + `--outdir=${CHECKS_DIST_DIR}`, + ...checkFiles, + ], + { encoding: 'utf8' }, +); + +// 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(/* 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 [index, result] of checkResults.entries()) { + const modName = checkModules[index].name; + if (result.status === 'fulfilled') { + console.log(`✓ ${modName} check passed`); + passed += 1; + } else { + const { reason } = result; + const detail = reason instanceof Error ? (reason.stack ?? reason.message) : String(reason); + console.error(`✗ ${modName} check failed: ${detail}`); + failed += 1; + } +} + +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 }; /**