From 894fb1e320da486324a737423f599e289b350852 Mon Sep 17 00:00:00 2001 From: Gordon Farquharson Date: Sun, 26 Apr 2026 11:38:56 +0100 Subject: [PATCH 1/9] wip - http apps --- .github/copilot-instructions.md | 28 +++++-- .github/workflows/copilot-sync.yml | 2 + .gitignore | 4 + docs/quickstart.md | 2 +- examples/README.md | 10 ++- examples/ab-testing/README.md | 8 +- examples/ab-testing/fixtures/.env | 1 + .../fixtures/existing-visitor.test.json | 15 ++++ .../fixtures/missing-config.test.json | 9 +++ .../ab-testing/fixtures/new-visitor.test.json | 13 ++++ examples/ab-testing/package.json | 1 + examples/ab-testing/src/index.js | 10 +-- examples/bloom-filter-denylist/README.md | 40 ++++++++++ examples/bloom-filter-denylist/fixtures/.env | 1 + .../fixtures/happy-path.test.json | 13 ++++ .../fixtures/missing-config.test.json | 9 +++ examples/bloom-filter-denylist/package.json | 12 +++ examples/bloom-filter-denylist/src/index.js | 40 ++++++++++ examples/crypto-hmac-jwt/README.md | 45 ++++++++++++ examples/crypto-hmac-jwt/fixtures/.env | 1 + .../fixtures/malformed-token.test.json | 15 ++++ .../fixtures/missing-auth.test.json | 13 ++++ .../fixtures/valid-token.test.json | 15 ++++ examples/crypto-hmac-jwt/package.json | 12 +++ examples/crypto-hmac-jwt/src/index.js | 73 +++++++++++++++++++ examples/downstream-fetch/README.md | 5 -- examples/downstream-fetch/package.json | 12 --- examples/downstream-modify-response/README.md | 5 -- .../downstream-modify-response/package.json | 12 --- examples/geo-redirect/fixtures/.env | 3 + .../geo-redirect/fixtures/fallback.test.json | 15 ++++ .../geo-redirect/fixtures/germany.test.json | 15 ++++ .../fixtures/missing-config.test.json | 11 +++ examples/geo-redirect/fixtures/us.test.json | 15 ++++ examples/headers/fixtures/.env | 1 + .../headers/fixtures/happy-path.test.json | 15 ++++ .../hello-world/fixtures/happy-path.test.json | 9 +++ .../fixtures/happy-path.test.json | 9 +++ .../kv-store/fixtures/bf-exists.test.json | 9 +++ examples/kv-store/fixtures/get.test.json | 9 +++ .../fixtures/missing-params.test.json | 9 +++ examples/kv-store/fixtures/scan.test.json | 9 +++ examples/kv-store/fixtures/zrange.test.json | 9 +++ examples/kv-store/fixtures/zscan.test.json | 9 +++ .../mcp-server/fixtures/initialize.test.json | 13 ++++ .../mcp-server/fixtures/tools-list.test.json | 13 ++++ examples/outbound-fetch/README.md | 5 ++ .../fixtures/happy-path.test.json | 9 +++ examples/outbound-fetch/package.json | 12 +++ .../src/index.js | 0 examples/outbound-modify-response/README.md | 5 ++ .../fixtures/happy-path.test.json | 9 +++ .../outbound-modify-response/package.json | 12 +++ .../src/index.js | 4 +- .../fixtures/api-hello.test.json | 11 +++ .../fixtures/api-users-post.test.json | 13 ++++ .../fixtures/home.test.json | 11 +++ examples/request-inspection/README.md | 9 +++ .../fixtures/happy-path.test.json | 13 ++++ examples/request-inspection/package.json | 12 +++ examples/request-inspection/src/index.js | 21 ++++++ examples/secret-rotation/README.md | 45 ++++++++++++ examples/secret-rotation/fixtures/.env | 2 + .../fixtures/custom-secret.test.json | 16 ++++ .../fixtures/default.test.json | 13 ++++ .../fixtures/specific-slot.test.json | 15 ++++ examples/secret-rotation/package.json | 12 +++ examples/secret-rotation/src/index.js | 38 ++++++++++ examples/static-assets/fixtures/css.test.json | 9 +++ .../static-assets/fixtures/home.test.json | 11 +++ .../static-assets/fixtures/image.test.json | 9 +++ examples/static-assets/fixtures/jsx.test.json | 11 +++ .../static-assets/fixtures/template.test.json | 9 +++ examples/streaming/README.md | 35 +++++++++ .../streaming/fixtures/happy-path.test.json | 9 +++ examples/streaming/package.json | 12 +++ examples/streaming/src/index.js | 22 ++++++ .../fixtures/bottle-gloria.test.json | 12 +++ .../fixtures/defaults.test.json | 9 +++ .../fixtures/hops-exo2.test.json | 12 +++ .../fixtures/happy-path.test.json | 9 +++ examples/tls-client-info/README.md | 39 ++++++++++ .../fixtures/happy-path.test.json | 9 +++ examples/tls-client-info/package.json | 12 +++ examples/tls-client-info/src/index.js | 37 ++++++++++ examples/variables-and-secrets/fixtures/.env | 2 + .../fixtures/happy-path.test.json | 13 ++++ fastedge-plugin-source/check-copilot-sync.sh | 53 +++++++++++++- fastedge-plugin-source/generate-docs.sh | 5 +- fastedge-plugin-source/manifest.json | 12 +-- .../docs/examples/downstream-fetch.mdx | 12 --- .../examples/downstream-modify-response.mdx | 12 --- .../content/docs/examples/main-examples.mdx | 6 +- .../content/docs/examples/outbound-fetch.mdx | 12 +++ .../examples/outbound-modify-response.mdx | 12 +++ .../src/content/docs/reference/headers.md | 2 +- pnpm-lock.yaml | 40 +++++++++- 97 files changed, 1240 insertions(+), 93 deletions(-) create mode 100644 examples/ab-testing/fixtures/.env create mode 100644 examples/ab-testing/fixtures/existing-visitor.test.json create mode 100644 examples/ab-testing/fixtures/missing-config.test.json create mode 100644 examples/ab-testing/fixtures/new-visitor.test.json create mode 100644 examples/bloom-filter-denylist/README.md create mode 100644 examples/bloom-filter-denylist/fixtures/.env create mode 100644 examples/bloom-filter-denylist/fixtures/happy-path.test.json create mode 100644 examples/bloom-filter-denylist/fixtures/missing-config.test.json create mode 100644 examples/bloom-filter-denylist/package.json create mode 100644 examples/bloom-filter-denylist/src/index.js create mode 100644 examples/crypto-hmac-jwt/README.md create mode 100644 examples/crypto-hmac-jwt/fixtures/.env create mode 100644 examples/crypto-hmac-jwt/fixtures/malformed-token.test.json create mode 100644 examples/crypto-hmac-jwt/fixtures/missing-auth.test.json create mode 100644 examples/crypto-hmac-jwt/fixtures/valid-token.test.json create mode 100644 examples/crypto-hmac-jwt/package.json create mode 100644 examples/crypto-hmac-jwt/src/index.js delete mode 100644 examples/downstream-fetch/README.md delete mode 100644 examples/downstream-fetch/package.json delete mode 100644 examples/downstream-modify-response/README.md delete mode 100644 examples/downstream-modify-response/package.json create mode 100644 examples/geo-redirect/fixtures/.env create mode 100644 examples/geo-redirect/fixtures/fallback.test.json create mode 100644 examples/geo-redirect/fixtures/germany.test.json create mode 100644 examples/geo-redirect/fixtures/missing-config.test.json create mode 100644 examples/geo-redirect/fixtures/us.test.json create mode 100644 examples/headers/fixtures/.env create mode 100644 examples/headers/fixtures/happy-path.test.json create mode 100644 examples/hello-world/fixtures/happy-path.test.json create mode 100644 examples/kv-store-basic/fixtures/happy-path.test.json create mode 100644 examples/kv-store/fixtures/bf-exists.test.json create mode 100644 examples/kv-store/fixtures/get.test.json create mode 100644 examples/kv-store/fixtures/missing-params.test.json create mode 100644 examples/kv-store/fixtures/scan.test.json create mode 100644 examples/kv-store/fixtures/zrange.test.json create mode 100644 examples/kv-store/fixtures/zscan.test.json create mode 100644 examples/mcp-server/fixtures/initialize.test.json create mode 100644 examples/mcp-server/fixtures/tools-list.test.json create mode 100644 examples/outbound-fetch/README.md create mode 100644 examples/outbound-fetch/fixtures/happy-path.test.json create mode 100644 examples/outbound-fetch/package.json rename examples/{downstream-fetch => outbound-fetch}/src/index.js (100%) create mode 100644 examples/outbound-modify-response/README.md create mode 100644 examples/outbound-modify-response/fixtures/happy-path.test.json create mode 100644 examples/outbound-modify-response/package.json rename examples/{downstream-modify-response => outbound-modify-response}/src/index.js (71%) create mode 100644 examples/react-with-hono-server/fixtures/api-hello.test.json create mode 100644 examples/react-with-hono-server/fixtures/api-users-post.test.json create mode 100644 examples/react-with-hono-server/fixtures/home.test.json create mode 100644 examples/request-inspection/README.md create mode 100644 examples/request-inspection/fixtures/happy-path.test.json create mode 100644 examples/request-inspection/package.json create mode 100644 examples/request-inspection/src/index.js create mode 100644 examples/secret-rotation/README.md create mode 100644 examples/secret-rotation/fixtures/.env create mode 100644 examples/secret-rotation/fixtures/custom-secret.test.json create mode 100644 examples/secret-rotation/fixtures/default.test.json create mode 100644 examples/secret-rotation/fixtures/specific-slot.test.json create mode 100644 examples/secret-rotation/package.json create mode 100644 examples/secret-rotation/src/index.js create mode 100644 examples/static-assets/fixtures/css.test.json create mode 100644 examples/static-assets/fixtures/home.test.json create mode 100644 examples/static-assets/fixtures/image.test.json create mode 100644 examples/static-assets/fixtures/jsx.test.json create mode 100644 examples/static-assets/fixtures/template.test.json create mode 100644 examples/streaming/README.md create mode 100644 examples/streaming/fixtures/happy-path.test.json create mode 100644 examples/streaming/package.json create mode 100644 examples/streaming/src/index.js create mode 100644 examples/template-invoice-ab-testing/fixtures/bottle-gloria.test.json create mode 100644 examples/template-invoice-ab-testing/fixtures/defaults.test.json create mode 100644 examples/template-invoice-ab-testing/fixtures/hops-exo2.test.json create mode 100644 examples/template-invoice/fixtures/happy-path.test.json create mode 100644 examples/tls-client-info/README.md create mode 100644 examples/tls-client-info/fixtures/happy-path.test.json create mode 100644 examples/tls-client-info/package.json create mode 100644 examples/tls-client-info/src/index.js create mode 100644 examples/variables-and-secrets/fixtures/.env create mode 100644 examples/variables-and-secrets/fixtures/happy-path.test.json delete mode 100644 github-pages/src/content/docs/examples/downstream-fetch.mdx delete mode 100644 github-pages/src/content/docs/examples/downstream-modify-response.mdx create mode 100644 github-pages/src/content/docs/examples/outbound-fetch.mdx create mode 100644 github-pages/src/content/docs/examples/outbound-modify-response.mdx diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 492153f..a57ec8e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -23,11 +23,27 @@ The public API surface is defined by: Changes to these surfaces require updated `docs/`, updated tests, and a semver-appropriate version bump. -## Documentation Freshness +## Generated Content — `docs/` + +Files in `docs/` are **machine-generated** from source code by `./fastedge-plugin-source/generate-docs.sh`. They must not be edited by hand — manual changes will be silently overwritten on the next generation run. + +### When reviewing PRs that touch `docs/`: + +- **Never** suggest manual edits to any file in `docs/` +- If docs are stale or incorrect, suggest: **Run `./fastedge-plugin-source/generate-docs.sh`** +- If the generated output itself is wrong (e.g., wrong structure, missing section), the fix belongs in `fastedge-plugin-source/.generation-config.md`, not in `docs/` directly +- If a PR modifies `docs/` files without a corresponding source code change, flag it — the change should come from the generation script, not a hand-edit -`docs/` is the single source of truth for public API documentation. When code changes affect the public API or user-facing behavior, **request changes** if the corresponding doc file was not updated in the same PR. +### When reviewing PRs that change source code covered by `docs/`: + +- Check whether the change affects the public API or user-facing behavior +- If yes, and `docs/` was not regenerated in the same PR, **request changes** with: + > Source code affecting public API was changed but docs/ was not regenerated. + > Run: `./fastedge-plugin-source/generate-docs.sh` + +## Documentation Freshness -### Public API changes (must update docs/) +### Public API changes (must regenerate docs/) - New, modified, or removed CLI flags in `src/cli/fastedge-build/build.ts` - Changes to `BuildConfig` or `AssetCacheConfig` interfaces in `src/cli/fastedge-build/types.ts` - Changes to scaffold wizard behavior in `src/cli/fastedge-init/` @@ -57,15 +73,15 @@ Changes to these surfaces require updated `docs/`, updated tests, and a semver-a ### Violation example -> PR changes `BuildConfig` interface in `types.ts` but `docs/BUILD_CLI.md` still shows the old field names → **request changes**. The config interface must be documented before merge. +> PR changes `BuildConfig` interface in `types.ts` but `docs/BUILD_CLI.md` still shows the old field names → **request changes**. Run `./fastedge-plugin-source/generate-docs.sh` before merge. ### Quickstart protection -If any public API signature or CLI behavior changes, check whether `docs/quickstart.md` examples are still accurate. Request changes if examples would no longer work against the updated code. +If any public API signature or CLI behavior changes, check whether `docs/quickstart.md` examples are still accurate. Request regeneration if examples would no longer work against the updated code. ### Pipeline source contract -If `fastedge-plugin-source/manifest.json` lists source files that overlap with files changed in this PR, request that `docs/` is updated to keep the plugin pipeline's source material current. +If `fastedge-plugin-source/manifest.json` lists source files that overlap with files changed in this PR, request that `docs/` is regenerated (run `./fastedge-plugin-source/generate-docs.sh`) to keep the plugin pipeline's source material current. ## Quality Rules diff --git a/.github/workflows/copilot-sync.yml b/.github/workflows/copilot-sync.yml index c4ca5e4..521f5ea 100644 --- a/.github/workflows/copilot-sync.yml +++ b/.github/workflows/copilot-sync.yml @@ -7,6 +7,8 @@ on: - fastedge-plugin-source/check-copilot-sync.sh - .github/copilot-instructions.md - .github/workflows/copilot-sync.yml + - examples/** + - docs/** jobs: check-sync: diff --git a/.gitignore b/.gitignore index 091e5af..95d7b4c 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,7 @@ package-lock.json # FastEdge debugger artifacts **/.fastedge-debug/ + +# example project lock files +examples/**/pnpm-lock.yaml +examples/**/package-lock.json diff --git a/docs/quickstart.md b/docs/quickstart.md index 90a5983..bbfe3b2 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -123,7 +123,7 @@ addEventListener('fetch', (event) => { event.respondWith( (async () => { const token = getSecret('SECRET_TOKEN'); - // Use token to authenticate downstream requests + // Use token to authenticate outbound requests return new Response('OK'); })(), ); diff --git a/examples/README.md b/examples/README.md index 312cf75..2aa8173 100644 --- a/examples/README.md +++ b/examples/README.md @@ -9,22 +9,28 @@ network using | Example | Description | | ----------------------------------------------------------- | -------------------------------------------------- | | [hello-world](./hello-world/) | Simplest request handler — returns the request URL | -| [downstream-fetch](./downstream-fetch/) | Fetch from a downstream HTTP origin | -| [downstream-modify-response](./downstream-modify-response/) | Fetch downstream and transform the response | +| [request-inspection](./request-inspection/) | Echo request method, URL, headers, and client info | +| [outbound-fetch](./outbound-fetch/) | Fetch from an outbound HTTP origin | +| [outbound-modify-response](./outbound-modify-response/) | Fetch outbound and transform the response | | [headers](./headers/) | Header manipulation using environment variables | | [kv-store-basic](./kv-store-basic/) | Simple KV Store get operation | | [variables-and-secrets](./variables-and-secrets/) | Read environment variables and secrets | +| [secret-rotation](./secret-rotation/) | Slot-based secret retrieval for rotation | ## Full Examples | Example | Description | | ------------------------------------------------------------- | ------------------------------------------------------------------------------------- | | [ab-testing](./ab-testing/) | Cookie-based A/B testing — assigns weighted variants to returning users | +| [bloom-filter-denylist](./bloom-filter-denylist/) | Reject requests from IPs present in a KV Store bloom filter (`bfExists`) | +| [crypto-hmac-jwt](./crypto-hmac-jwt/) | Verify HS256 JWTs with the Web Crypto API (importKey + verify) | | [geo-redirect](./geo-redirect/) | Redirect requests by country code using env vars | | [kv-store](./kv-store/) | Query a KV Store via URL params — get/scan/zrange/zscan/bfExists | | [template-invoice](./template-invoice/) | HTML invoice rendered server-side using Handlebars templates | | [template-invoice-ab-testing](./template-invoice-ab-testing/) | Template invoice with logo and font variants driven by A/B test headers | +| [tls-client-info](./tls-client-info/) | Inspect TLS metadata from `event.client` — JA3 fingerprint, protocol, cert bytes | | [static-assets](./static-assets/) | Serve static assets (images, styles, templates) embedded in the wasm binary with Hono | +| [streaming](./streaming/) | Generate a streaming response body with `ReadableStream` and timed chunks | | [mcp-server](./mcp-server/) | MCP server running on FastEdge — weather alerts and forecast via NWS API | ## Usage diff --git a/examples/ab-testing/README.md b/examples/ab-testing/README.md index 8e1ad4d..d98b6e6 100644 --- a/examples/ab-testing/README.md +++ b/examples/ab-testing/README.md @@ -2,6 +2,10 @@ # AB Testing +> **Note:** For pure header/cookie manipulation like this, the `abTesting` example in +> [proxy-wasm-sdk-as](https://github.com/G-Core/proxy-wasm-sdk-as) is the better fit — it runs as an +> HTTP filter instead of a full compute workload. + Simple AB testing application that uses cookies to provide each client with a trackable ID. Before making the fetch to the backend, it checks for an existing usable ID, or creates a new one. @@ -22,8 +26,8 @@ const testConfig = { } ``` -Using the unique ID and the weights from the testConfig it adds test specific cookies for consumption by the downstream url (backend). +Using the unique ID and the weights from the testConfig it adds test specific cookies for consumption by the outbound url (backend). These test cases and there associated `weights` ensures that returning users will always receive the same test conditions on each visit. -[Template Invoice AB Testing](../template-invoice-ab-testing/README.md) is an example of how a downstream backend could use these cookies to provide different AB Test cases. +[Template Invoice AB Testing](../template-invoice-ab-testing/README.md) is an example of how an outbound backend could use these cookies to provide different AB Test cases. diff --git a/examples/ab-testing/fixtures/.env b/examples/ab-testing/fixtures/.env new file mode 100644 index 0000000..643dbe4 --- /dev/null +++ b/examples/ab-testing/fixtures/.env @@ -0,0 +1 @@ +FASTEDGE_VAR_ENV_OUTBOUND_URL=https://template-invoice-ab-test.example.com/ diff --git a/examples/ab-testing/fixtures/existing-visitor.test.json b/examples/ab-testing/fixtures/existing-visitor.test.json new file mode 100644 index 0000000..05745b2 --- /dev/null +++ b/examples/ab-testing/fixtures/existing-visitor.test.json @@ -0,0 +1,15 @@ +{ + "appType": "http-wasm", + "description": "Returning visitor — x-fastedge-abid=0.5000 reused from cookie; variants deterministic", + "request": { + "method": "GET", + "path": "/", + "headers": { + "cookie": "x-fastedge-abid=0.5000; session=abc123" + } + }, + "dotenv": { + "enabled": true, + "path": "." + } +} diff --git a/examples/ab-testing/fixtures/missing-config.test.json b/examples/ab-testing/fixtures/missing-config.test.json new file mode 100644 index 0000000..c3e974b --- /dev/null +++ b/examples/ab-testing/fixtures/missing-config.test.json @@ -0,0 +1,9 @@ +{ + "appType": "http-wasm", + "description": "OUTBOUND_URL not configured — returns 500", + "request": { + "method": "GET", + "path": "/", + "headers": {} + } +} diff --git a/examples/ab-testing/fixtures/new-visitor.test.json b/examples/ab-testing/fixtures/new-visitor.test.json new file mode 100644 index 0000000..393dd9b --- /dev/null +++ b/examples/ab-testing/fixtures/new-visitor.test.json @@ -0,0 +1,13 @@ +{ + "appType": "http-wasm", + "description": "No x-fastedge-abid cookie — generates a new xid, assigns variants, sets cookie on response", + "request": { + "method": "GET", + "path": "/", + "headers": {} + }, + "dotenv": { + "enabled": true, + "path": "." + } +} diff --git a/examples/ab-testing/package.json b/examples/ab-testing/package.json index fed21ae..c11c3c0 100644 --- a/examples/ab-testing/package.json +++ b/examples/ab-testing/package.json @@ -3,6 +3,7 @@ "version": "1.0.0", "description": "FastEdge JS example: cookie-based A/B testing", "type": "module", + "main": "src/index.js", "scripts": { "build": "fastedge-build src/index.js dist/ab-testing.wasm" }, diff --git a/examples/ab-testing/src/index.js b/examples/ab-testing/src/index.js index cc52d3f..1103f95 100644 --- a/examples/ab-testing/src/index.js +++ b/examples/ab-testing/src/index.js @@ -17,16 +17,16 @@ async function eventHandler({ request }) { const headers = createAbTestHeaders(slicedHeaders, testConfig, xid); - // This is the URL of the downstream service - i.e. could be a url to your origin + // This is the URL of the outbound service - i.e. could be a url to your origin // e.g. https://template-invoice-ab-test-123456.fastedge.cdn.gc.onl/ - const downstreamUrl = getEnv('DOWNSTREAM_URL'); - if (!downstreamUrl || !String(downstreamUrl).trim()) { - return new Response('DOWNSTREAM_URL environment variable is not configured', { + const outboundUrl = getEnv('OUTBOUND_URL'); + if (!outboundUrl || !String(outboundUrl).trim()) { + return new Response('OUTBOUND_URL environment variable is not configured', { status: 500, }); } - const response = await fetch(downstreamUrl, { headers }); + const response = await fetch(outboundUrl, { headers }); // Request/Response Headers are immutable, so we need to create a new Headers object const resHeaders = new Headers(response.headers); diff --git a/examples/bloom-filter-denylist/README.md b/examples/bloom-filter-denylist/README.md new file mode 100644 index 0000000..24a5834 --- /dev/null +++ b/examples/bloom-filter-denylist/README.md @@ -0,0 +1,40 @@ +[← Back to examples](../README.md) + +# Bloom Filter — IP Denylist + +Rejects requests from IPs present in a KV Store bloom filter. On every request, checks +`event.client.address` against a pre-populated bloom filter and returns **403** on a hit, +**200** otherwise. + +Demonstrates `fastedge::kv` `KvStore.open()` + `bfExists()` and the `ClientInfo.address` +surface. + +## Configuration + +- Environment variable `DENYLIST_STORE` — name of the KV store that holds the bloom filter. +- Bloom-filter key name — hardcoded to `blocked-ips`. Change `BLOOM_KEY` in `src/index.js` + if your key is different. + +## Behaviour + +| `bfExists('blocked-ips', ip)` | Response | +| --- | --- | +| `true` | `403` `{ "allowed": false, "ip": "..." }` | +| `false` | `200` `{ "allowed": true, "ip": "..." }` | + +## Tradeoff: false positives + +Bloom filters answer "**definitely not** in set" vs "**maybe** in set". When `bfExists` +returns `true`, the IP *probably* was added — but a small fraction of hits will be false +positives, meaning some legitimate visitors will be over-blocked. For a denylist this is +usually acceptable; for an allowlist or anything requiring exact membership, use +`KvStore.get()` against a regular key instead. + +## Populating the filter + +The edge handler is read-only. Populate `blocked-ips` in the configured KV store out of band +— for example via the FastEdge API or the gcore-api-mcp-server. + +## Related + +Rust equivalent: `FastEdge-sdk-rust/examples/http/wasi/bloom_filter_denylist/`. diff --git a/examples/bloom-filter-denylist/fixtures/.env b/examples/bloom-filter-denylist/fixtures/.env new file mode 100644 index 0000000..c02214a --- /dev/null +++ b/examples/bloom-filter-denylist/fixtures/.env @@ -0,0 +1 @@ +FASTEDGE_VAR_ENV_DENYLIST_STORE=denylist-store diff --git a/examples/bloom-filter-denylist/fixtures/happy-path.test.json b/examples/bloom-filter-denylist/fixtures/happy-path.test.json new file mode 100644 index 0000000..04b02fa --- /dev/null +++ b/examples/bloom-filter-denylist/fixtures/happy-path.test.json @@ -0,0 +1,13 @@ +{ + "appType": "http-wasm", + "description": "Happy path — looks up client IP in the bloom filter; populate the store out of band to see blocked responses", + "request": { + "method": "GET", + "path": "/", + "headers": {} + }, + "dotenv": { + "enabled": true, + "path": "." + } +} diff --git a/examples/bloom-filter-denylist/fixtures/missing-config.test.json b/examples/bloom-filter-denylist/fixtures/missing-config.test.json new file mode 100644 index 0000000..bb12ff6 --- /dev/null +++ b/examples/bloom-filter-denylist/fixtures/missing-config.test.json @@ -0,0 +1,9 @@ +{ + "appType": "http-wasm", + "description": "DENYLIST_STORE not configured — returns 500", + "request": { + "method": "GET", + "path": "/", + "headers": {} + } +} diff --git a/examples/bloom-filter-denylist/package.json b/examples/bloom-filter-denylist/package.json new file mode 100644 index 0000000..13735c5 --- /dev/null +++ b/examples/bloom-filter-denylist/package.json @@ -0,0 +1,12 @@ +{ + "name": "fastedge-example-bloom-filter-denylist", + "version": "1.0.0", + "description": "FastEdge JS example: IP denylist using a KV Store bloom filter", + "type": "module", + "scripts": { + "build": "fastedge-build src/index.js dist/bloom-filter-denylist.wasm" + }, + "dependencies": { + "@gcoredev/fastedge-sdk-js": "^2.2.2" + } +} diff --git a/examples/bloom-filter-denylist/src/index.js b/examples/bloom-filter-denylist/src/index.js new file mode 100644 index 0000000..304f485 --- /dev/null +++ b/examples/bloom-filter-denylist/src/index.js @@ -0,0 +1,40 @@ +import { getEnv } from 'fastedge::env'; +import { KvStore } from 'fastedge::kv'; + +const BLOOM_KEY = 'blocked-ips'; + +function app(event) { + const storeName = getEnv('DENYLIST_STORE'); + if (!storeName) { + return Response.json( + { error: 'DENYLIST_STORE environment variable is not configured' }, + { status: 500 }, + ); + } + + const ip = event.client.address; + if (!ip) { + return Response.json({ error: 'client address unavailable' }, { status: 500 }); + } + + let blocked; + try { + const store = KvStore.open(storeName); + blocked = store.bfExists(BLOOM_KEY, ip); + } catch (err) { + return Response.json({ error: `KV lookup failed: ${err.message}` }, { status: 500 }); + } + + if (blocked) { + // Bloom filter says "maybe in set" — a small fraction of hits will be false positives. + // Acceptable for a denylist (you over-block some legitimate users); not acceptable for + // allowlists or anything requiring exact membership — use KvStore.get() for that. + return Response.json({ allowed: false, ip }, { status: 403 }); + } + + return Response.json({ allowed: true, ip }); +} + +addEventListener('fetch', (event) => { + event.respondWith(app(event)); +}); diff --git a/examples/crypto-hmac-jwt/README.md b/examples/crypto-hmac-jwt/README.md new file mode 100644 index 0000000..66a72d3 --- /dev/null +++ b/examples/crypto-hmac-jwt/README.md @@ -0,0 +1,45 @@ + +[← Back to examples](../README.md) + +# Crypto: HMAC JWT Verification + +Verifies incoming `Authorization: Bearer ` headers as HS256 JWTs using the Web Crypto +API (`crypto.subtle.importKey` + `crypto.subtle.verify`). On success returns the decoded claims; +on failure returns 401 with a reason. + +Demonstrates `fastedge::secret` + the Web Crypto surface exposed by the SDK: HMAC key import, +signature verification, base64url decoding, and simple `exp` claim enforcement. + +## Configuration + +- Secret `JWT_SECRET` — the shared HMAC secret used to mint and verify tokens. + +## Request + +``` +GET /anything +Authorization: Bearer +``` + +## Responses + +- **200 OK** — token verified. Body: `{ "ok": true, "claims": { ... } }` +- **401 Unauthorized** — missing, malformed, expired, or bad-signature token. Body: + `{ "ok": false, "error": "..." }` +- **500 Internal Server Error** — `JWT_SECRET` is not configured. + +## Testing + +Mint a test token with any HS256 library using the same `JWT_SECRET`, then: + +```sh +curl -H "Authorization: Bearer $TOKEN" https://.fastedge.cdn.gc.onl/ +``` + +## Extending + +- Swap HS256 for RS256: import an RSA public key with `{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }` + via `importKey`, then call `crypto.subtle.verify('RSASSA-PKCS1-v1_5', ...)`. The SDK exposes + both algorithms. +- Mint tokens with `crypto.subtle.sign` using the same imported HMAC key (supply + `['sign', 'verify']` as the usages). diff --git a/examples/crypto-hmac-jwt/fixtures/.env b/examples/crypto-hmac-jwt/fixtures/.env new file mode 100644 index 0000000..f18d9b5 --- /dev/null +++ b/examples/crypto-hmac-jwt/fixtures/.env @@ -0,0 +1 @@ +FASTEDGE_VAR_SECRET_JWT_SECRET=test-shared-secret-abcdef123456 diff --git a/examples/crypto-hmac-jwt/fixtures/malformed-token.test.json b/examples/crypto-hmac-jwt/fixtures/malformed-token.test.json new file mode 100644 index 0000000..ef9b82a --- /dev/null +++ b/examples/crypto-hmac-jwt/fixtures/malformed-token.test.json @@ -0,0 +1,15 @@ +{ + "appType": "http-wasm", + "description": "Bearer token that is not a JWT — returns 401 with 'malformed token'", + "request": { + "method": "GET", + "path": "/", + "headers": { + "authorization": "Bearer not-a-real-jwt" + } + }, + "dotenv": { + "enabled": true, + "path": "." + } +} diff --git a/examples/crypto-hmac-jwt/fixtures/missing-auth.test.json b/examples/crypto-hmac-jwt/fixtures/missing-auth.test.json new file mode 100644 index 0000000..5d81015 --- /dev/null +++ b/examples/crypto-hmac-jwt/fixtures/missing-auth.test.json @@ -0,0 +1,13 @@ +{ + "appType": "http-wasm", + "description": "No Authorization header — returns 401", + "request": { + "method": "GET", + "path": "/", + "headers": {} + }, + "dotenv": { + "enabled": true, + "path": "." + } +} diff --git a/examples/crypto-hmac-jwt/fixtures/valid-token.test.json b/examples/crypto-hmac-jwt/fixtures/valid-token.test.json new file mode 100644 index 0000000..caa0505 --- /dev/null +++ b/examples/crypto-hmac-jwt/fixtures/valid-token.test.json @@ -0,0 +1,15 @@ +{ + "appType": "http-wasm", + "description": "Valid HS256 JWT — replace the token with one minted against your JWT_SECRET (e.g. at jwt.io)", + "request": { + "method": "GET", + "path": "/", + "headers": { + "authorization": "Bearer REPLACE_WITH_HS256_JWT_SIGNED_BY_JWT_SECRET" + } + }, + "dotenv": { + "enabled": true, + "path": "." + } +} diff --git a/examples/crypto-hmac-jwt/package.json b/examples/crypto-hmac-jwt/package.json new file mode 100644 index 0000000..8dc98ff --- /dev/null +++ b/examples/crypto-hmac-jwt/package.json @@ -0,0 +1,12 @@ +{ + "name": "fastedge-example-crypto-hmac-jwt", + "version": "1.0.0", + "description": "FastEdge JS example: verify HS256 JWTs with the Web Crypto API", + "type": "module", + "scripts": { + "build": "fastedge-build src/index.js dist/crypto-hmac-jwt.wasm" + }, + "dependencies": { + "@gcoredev/fastedge-sdk-js": "^2.2.2" + } +} diff --git a/examples/crypto-hmac-jwt/src/index.js b/examples/crypto-hmac-jwt/src/index.js new file mode 100644 index 0000000..7e492d8 --- /dev/null +++ b/examples/crypto-hmac-jwt/src/index.js @@ -0,0 +1,73 @@ +import { getSecret } from 'fastedge::secret'; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +function base64urlToBytes(str) { + const padded = str.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (str.length % 4)) % 4); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +async function verifyJwtHs256(token, secret) { + const parts = token.split('.'); + if (parts.length !== 3) { + throw new Error('malformed token: expected three segments'); + } + const [encodedHeader, encodedPayload, encodedSignature] = parts; + + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['verify'], + ); + + const signature = base64urlToBytes(encodedSignature); + const signedData = encoder.encode(`${encodedHeader}.${encodedPayload}`); + + const valid = await crypto.subtle.verify('HMAC', key, signature, signedData); + if (!valid) { + throw new Error('invalid signature'); + } + + const claims = JSON.parse(decoder.decode(base64urlToBytes(encodedPayload))); + + if (typeof claims.exp === 'number' && Math.floor(Date.now() / 1000) >= claims.exp) { + throw new Error('token expired'); + } + + return claims; +} + +async function app(event) { + const auth = event.request.headers.get('authorization') ?? ''; + const match = auth.match(/^Bearer\s+(.+)$/iu); + if (!match) { + return Response.json( + { ok: false, error: 'missing or malformed Authorization header' }, + { status: 401 }, + ); + } + + const secret = getSecret('JWT_SECRET'); + if (!secret) { + return Response.json({ ok: false, error: 'JWT_SECRET is not configured' }, { status: 500 }); + } + + try { + const claims = await verifyJwtHs256(match[1], secret); + return Response.json({ ok: true, claims }); + } catch (err) { + return Response.json({ ok: false, error: err.message }, { status: 401 }); + } +} + +addEventListener('fetch', (event) => { + event.respondWith(app(event)); +}); diff --git a/examples/downstream-fetch/README.md b/examples/downstream-fetch/README.md deleted file mode 100644 index 03f9377..0000000 --- a/examples/downstream-fetch/README.md +++ /dev/null @@ -1,5 +0,0 @@ -[← Back to examples](../README.md) - -# Downstream Fetch - -Fetch data from a downstream HTTP origin and return the response directly. diff --git a/examples/downstream-fetch/package.json b/examples/downstream-fetch/package.json deleted file mode 100644 index cd90472..0000000 --- a/examples/downstream-fetch/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "fastedge-example-downstream-fetch", - "version": "1.0.0", - "description": "FastEdge JS example: downstream HTTP fetch", - "type": "module", - "scripts": { - "build": "fastedge-build src/index.js dist/downstream-fetch.wasm" - }, - "dependencies": { - "@gcoredev/fastedge-sdk-js": "^2.2.2" - } -} diff --git a/examples/downstream-modify-response/README.md b/examples/downstream-modify-response/README.md deleted file mode 100644 index df6e8b1..0000000 --- a/examples/downstream-modify-response/README.md +++ /dev/null @@ -1,5 +0,0 @@ -[← Back to examples](../README.md) - -# Downstream Modify Response - -Fetch data from a downstream origin, transform the JSON response (slice to first 5 users), and return it with custom headers. diff --git a/examples/downstream-modify-response/package.json b/examples/downstream-modify-response/package.json deleted file mode 100644 index 264aa82..0000000 --- a/examples/downstream-modify-response/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "fastedge-example-downstream-modify-response", - "version": "1.0.0", - "description": "FastEdge JS example: fetch and modify downstream response", - "type": "module", - "scripts": { - "build": "fastedge-build src/index.js dist/downstream-modify-response.wasm" - }, - "dependencies": { - "@gcoredev/fastedge-sdk-js": "^2.2.2" - } -} diff --git a/examples/geo-redirect/fixtures/.env b/examples/geo-redirect/fixtures/.env new file mode 100644 index 0000000..01672c4 --- /dev/null +++ b/examples/geo-redirect/fixtures/.env @@ -0,0 +1,3 @@ +FASTEDGE_VAR_ENV_BASE_ORIGIN=https://default.example.com/ +FASTEDGE_VAR_ENV_US=https://us.example.com/ +FASTEDGE_VAR_ENV_DE=https://de.example.com/ diff --git a/examples/geo-redirect/fixtures/fallback.test.json b/examples/geo-redirect/fixtures/fallback.test.json new file mode 100644 index 0000000..2204646 --- /dev/null +++ b/examples/geo-redirect/fixtures/fallback.test.json @@ -0,0 +1,15 @@ +{ + "appType": "http-wasm", + "description": "Unmapped country (FR) — falls back to BASE_ORIGIN", + "request": { + "method": "GET", + "path": "/", + "headers": { + "geoip-country-code": "FR" + } + }, + "dotenv": { + "enabled": true, + "path": "." + } +} diff --git a/examples/geo-redirect/fixtures/germany.test.json b/examples/geo-redirect/fixtures/germany.test.json new file mode 100644 index 0000000..b31aae4 --- /dev/null +++ b/examples/geo-redirect/fixtures/germany.test.json @@ -0,0 +1,15 @@ +{ + "appType": "http-wasm", + "description": "German visitor — redirects to the DE origin configured in env", + "request": { + "method": "GET", + "path": "/", + "headers": { + "geoip-country-code": "DE" + } + }, + "dotenv": { + "enabled": true, + "path": "." + } +} diff --git a/examples/geo-redirect/fixtures/missing-config.test.json b/examples/geo-redirect/fixtures/missing-config.test.json new file mode 100644 index 0000000..76b689e --- /dev/null +++ b/examples/geo-redirect/fixtures/missing-config.test.json @@ -0,0 +1,11 @@ +{ + "appType": "http-wasm", + "description": "No BASE_ORIGIN configured — returns 500", + "request": { + "method": "GET", + "path": "/", + "headers": { + "geoip-country-code": "US" + } + } +} diff --git a/examples/geo-redirect/fixtures/us.test.json b/examples/geo-redirect/fixtures/us.test.json new file mode 100644 index 0000000..5614ea7 --- /dev/null +++ b/examples/geo-redirect/fixtures/us.test.json @@ -0,0 +1,15 @@ +{ + "appType": "http-wasm", + "description": "US visitor — redirects to the US origin configured in env", + "request": { + "method": "GET", + "path": "/", + "headers": { + "geoip-country-code": "US" + } + }, + "dotenv": { + "enabled": true, + "path": "." + } +} diff --git a/examples/headers/fixtures/.env b/examples/headers/fixtures/.env new file mode 100644 index 0000000..966910f --- /dev/null +++ b/examples/headers/fixtures/.env @@ -0,0 +1 @@ +FASTEDGE_VAR_ENV_MY_CUSTOM_ENV_VAR=hello-from-env diff --git a/examples/headers/fixtures/happy-path.test.json b/examples/headers/fixtures/happy-path.test.json new file mode 100644 index 0000000..1482189 --- /dev/null +++ b/examples/headers/fixtures/happy-path.test.json @@ -0,0 +1,15 @@ +{ + "appType": "http-wasm", + "description": "Adds my-custom-header from MY_CUSTOM_ENV_VAR and returns the request headers", + "request": { + "method": "GET", + "path": "/", + "headers": { + "x-forwarded-for": "203.0.113.10" + } + }, + "dotenv": { + "enabled": true, + "path": "." + } +} diff --git a/examples/hello-world/fixtures/happy-path.test.json b/examples/hello-world/fixtures/happy-path.test.json new file mode 100644 index 0000000..9df06d6 --- /dev/null +++ b/examples/hello-world/fixtures/happy-path.test.json @@ -0,0 +1,9 @@ +{ + "appType": "http-wasm", + "description": "Basic request — returns the request URL", + "request": { + "method": "GET", + "path": "/", + "headers": {} + } +} diff --git a/examples/kv-store-basic/fixtures/happy-path.test.json b/examples/kv-store-basic/fixtures/happy-path.test.json new file mode 100644 index 0000000..d96c902 --- /dev/null +++ b/examples/kv-store-basic/fixtures/happy-path.test.json @@ -0,0 +1,9 @@ +{ + "appType": "http-wasm", + "description": "Reads hardcoded key 'key' from KV store 'kv-store-name-as-defined-on-app' — populate the store out of band before running", + "request": { + "method": "GET", + "path": "/", + "headers": {} + } +} diff --git a/examples/kv-store/fixtures/bf-exists.test.json b/examples/kv-store/fixtures/bf-exists.test.json new file mode 100644 index 0000000..d8d5bc5 --- /dev/null +++ b/examples/kv-store/fixtures/bf-exists.test.json @@ -0,0 +1,9 @@ +{ + "appType": "http-wasm", + "description": "KV bfExists — bloom-filter membership check", + "request": { + "method": "GET", + "path": "/?store=my-store&action=bfExists&key=seen-users&item=alice", + "headers": {} + } +} diff --git a/examples/kv-store/fixtures/get.test.json b/examples/kv-store/fixtures/get.test.json new file mode 100644 index 0000000..3c6d7de --- /dev/null +++ b/examples/kv-store/fixtures/get.test.json @@ -0,0 +1,9 @@ +{ + "appType": "http-wasm", + "description": "KV get — requires ?store=&action=get&key= (populate store out of band)", + "request": { + "method": "GET", + "path": "/?store=my-store&action=get&key=greeting", + "headers": {} + } +} diff --git a/examples/kv-store/fixtures/missing-params.test.json b/examples/kv-store/fixtures/missing-params.test.json new file mode 100644 index 0000000..1928329 --- /dev/null +++ b/examples/kv-store/fixtures/missing-params.test.json @@ -0,0 +1,9 @@ +{ + "appType": "http-wasm", + "description": "No query params — returns a validation error", + "request": { + "method": "GET", + "path": "/", + "headers": {} + } +} diff --git a/examples/kv-store/fixtures/scan.test.json b/examples/kv-store/fixtures/scan.test.json new file mode 100644 index 0000000..f6379ae --- /dev/null +++ b/examples/kv-store/fixtures/scan.test.json @@ -0,0 +1,9 @@ +{ + "appType": "http-wasm", + "description": "KV scan — list keys matching a pattern", + "request": { + "method": "GET", + "path": "/?store=my-store&action=scan&match=user:*", + "headers": {} + } +} diff --git a/examples/kv-store/fixtures/zrange.test.json b/examples/kv-store/fixtures/zrange.test.json new file mode 100644 index 0000000..6a3878d --- /dev/null +++ b/examples/kv-store/fixtures/zrange.test.json @@ -0,0 +1,9 @@ +{ + "appType": "http-wasm", + "description": "KV zrange — sorted-set range by score", + "request": { + "method": "GET", + "path": "/?store=my-store&action=zrange&key=leaderboard&min=0&max=100", + "headers": {} + } +} diff --git a/examples/kv-store/fixtures/zscan.test.json b/examples/kv-store/fixtures/zscan.test.json new file mode 100644 index 0000000..5ebe3eb --- /dev/null +++ b/examples/kv-store/fixtures/zscan.test.json @@ -0,0 +1,9 @@ +{ + "appType": "http-wasm", + "description": "KV zscan — scan a sorted set with a match pattern", + "request": { + "method": "GET", + "path": "/?store=my-store&action=zscan&key=leaderboard&match=player:*", + "headers": {} + } +} diff --git a/examples/mcp-server/fixtures/initialize.test.json b/examples/mcp-server/fixtures/initialize.test.json new file mode 100644 index 0000000..225b332 --- /dev/null +++ b/examples/mcp-server/fixtures/initialize.test.json @@ -0,0 +1,13 @@ +{ + "appType": "http-wasm", + "description": "MCP JSON-RPC initialize request — handshake with protocol version and capabilities", + "request": { + "method": "POST", + "path": "/mcp", + "headers": { + "content-type": "application/json", + "accept": "application/json, text/event-stream" + }, + "body": "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"clientInfo\":{\"name\":\"debugger\",\"version\":\"1.0\"}}}" + } +} diff --git a/examples/mcp-server/fixtures/tools-list.test.json b/examples/mcp-server/fixtures/tools-list.test.json new file mode 100644 index 0000000..e098542 --- /dev/null +++ b/examples/mcp-server/fixtures/tools-list.test.json @@ -0,0 +1,13 @@ +{ + "appType": "http-wasm", + "description": "MCP tools/list — returns the advertised tool schemas", + "request": { + "method": "POST", + "path": "/mcp", + "headers": { + "content-type": "application/json", + "accept": "application/json, text/event-stream" + }, + "body": "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\"}" + } +} diff --git a/examples/outbound-fetch/README.md b/examples/outbound-fetch/README.md new file mode 100644 index 0000000..6dc81a3 --- /dev/null +++ b/examples/outbound-fetch/README.md @@ -0,0 +1,5 @@ +[← Back to examples](../README.md) + +# Outbound Fetch + +Fetch data from an outbound HTTP origin and return the response directly. diff --git a/examples/outbound-fetch/fixtures/happy-path.test.json b/examples/outbound-fetch/fixtures/happy-path.test.json new file mode 100644 index 0000000..de1681d --- /dev/null +++ b/examples/outbound-fetch/fixtures/happy-path.test.json @@ -0,0 +1,9 @@ +{ + "appType": "http-wasm", + "description": "Fetches http://jsonplaceholder.typicode.com/users and returns the response verbatim", + "request": { + "method": "GET", + "path": "/", + "headers": {} + } +} diff --git a/examples/outbound-fetch/package.json b/examples/outbound-fetch/package.json new file mode 100644 index 0000000..2d03b38 --- /dev/null +++ b/examples/outbound-fetch/package.json @@ -0,0 +1,12 @@ +{ + "name": "fastedge-example-outbound-fetch", + "version": "1.0.0", + "description": "FastEdge JS example: outbound HTTP fetch", + "type": "module", + "scripts": { + "build": "fastedge-build src/index.js dist/outbound-fetch.wasm" + }, + "dependencies": { + "@gcoredev/fastedge-sdk-js": "^2.2.2" + } +} diff --git a/examples/downstream-fetch/src/index.js b/examples/outbound-fetch/src/index.js similarity index 100% rename from examples/downstream-fetch/src/index.js rename to examples/outbound-fetch/src/index.js diff --git a/examples/outbound-modify-response/README.md b/examples/outbound-modify-response/README.md new file mode 100644 index 0000000..b21c8ce --- /dev/null +++ b/examples/outbound-modify-response/README.md @@ -0,0 +1,5 @@ +[← Back to examples](../README.md) + +# Outbound Modify Response + +Fetch data from an outbound origin, transform the JSON response (slice to first 5 users), and return it with custom headers. diff --git a/examples/outbound-modify-response/fixtures/happy-path.test.json b/examples/outbound-modify-response/fixtures/happy-path.test.json new file mode 100644 index 0000000..f503291 --- /dev/null +++ b/examples/outbound-modify-response/fixtures/happy-path.test.json @@ -0,0 +1,9 @@ +{ + "appType": "http-wasm", + "description": "Fetches users, slices to first 5, returns reshaped JSON with pagination metadata", + "request": { + "method": "GET", + "path": "/", + "headers": {} + } +} diff --git a/examples/outbound-modify-response/package.json b/examples/outbound-modify-response/package.json new file mode 100644 index 0000000..b69eea2 --- /dev/null +++ b/examples/outbound-modify-response/package.json @@ -0,0 +1,12 @@ +{ + "name": "fastedge-example-outbound-modify-response", + "version": "1.0.0", + "description": "FastEdge JS example: fetch and modify outbound response", + "type": "module", + "scripts": { + "build": "fastedge-build src/index.js dist/outbound-modify-response.wasm" + }, + "dependencies": { + "@gcoredev/fastedge-sdk-js": "^2.2.2" + } +} diff --git a/examples/downstream-modify-response/src/index.js b/examples/outbound-modify-response/src/index.js similarity index 71% rename from examples/downstream-modify-response/src/index.js rename to examples/outbound-modify-response/src/index.js index 3ceb8a5..7b36d30 100644 --- a/examples/downstream-modify-response/src/index.js +++ b/examples/outbound-modify-response/src/index.js @@ -1,6 +1,6 @@ async function app(event) { - const downstreamResponse = await fetch('http://jsonplaceholder.typicode.com/users'); - const users = await downstreamResponse.json(); + const outboundResponse = await fetch('http://jsonplaceholder.typicode.com/users'); + const users = await outboundResponse.json(); return new Response( JSON.stringify({ users: users.slice(0, 5), diff --git a/examples/react-with-hono-server/fixtures/api-hello.test.json b/examples/react-with-hono-server/fixtures/api-hello.test.json new file mode 100644 index 0000000..c4d2163 --- /dev/null +++ b/examples/react-with-hono-server/fixtures/api-hello.test.json @@ -0,0 +1,11 @@ +{ + "appType": "http-wasm", + "description": "GET /api/hello — Hono API route", + "request": { + "method": "GET", + "path": "/api/hello", + "headers": { + "accept": "application/json" + } + } +} diff --git a/examples/react-with-hono-server/fixtures/api-users-post.test.json b/examples/react-with-hono-server/fixtures/api-users-post.test.json new file mode 100644 index 0000000..a8f0711 --- /dev/null +++ b/examples/react-with-hono-server/fixtures/api-users-post.test.json @@ -0,0 +1,13 @@ +{ + "appType": "http-wasm", + "description": "POST /api/users — create a user via the Hono API", + "request": { + "method": "POST", + "path": "/api/users", + "headers": { + "content-type": "application/json", + "accept": "application/json" + }, + "body": "{\"name\":\"alice\",\"email\":\"alice@example.com\"}" + } +} diff --git a/examples/react-with-hono-server/fixtures/home.test.json b/examples/react-with-hono-server/fixtures/home.test.json new file mode 100644 index 0000000..fafeaed --- /dev/null +++ b/examples/react-with-hono-server/fixtures/home.test.json @@ -0,0 +1,11 @@ +{ + "appType": "http-wasm", + "description": "GET / — served by the static asset catch-all (returns the React index.html)", + "request": { + "method": "GET", + "path": "/", + "headers": { + "accept": "text/html" + } + } +} diff --git a/examples/request-inspection/README.md b/examples/request-inspection/README.md new file mode 100644 index 0000000..d828ad0 --- /dev/null +++ b/examples/request-inspection/README.md @@ -0,0 +1,9 @@ +[← Back to examples](../README.md) + +# Request Inspection + +Echoes the request method, URL, downstream client address, and all headers to the response body +as plain text. Useful for debugging and understanding what a FastEdge handler receives. + +Demonstrates reading `event.request` (method, url, headers) and `event.client.address` from the +`FetchEvent`. diff --git a/examples/request-inspection/fixtures/happy-path.test.json b/examples/request-inspection/fixtures/happy-path.test.json new file mode 100644 index 0000000..362fb5a --- /dev/null +++ b/examples/request-inspection/fixtures/happy-path.test.json @@ -0,0 +1,13 @@ +{ + "appType": "http-wasm", + "description": "GET request with a handful of headers — handler echoes method, URL, client, and headers", + "request": { + "method": "GET", + "path": "/debug", + "headers": { + "user-agent": "fastedge-debugger/1.0", + "accept": "text/plain", + "x-custom-trace": "trace-12345" + } + } +} diff --git a/examples/request-inspection/package.json b/examples/request-inspection/package.json new file mode 100644 index 0000000..ba669b9 --- /dev/null +++ b/examples/request-inspection/package.json @@ -0,0 +1,12 @@ +{ + "name": "fastedge-example-request-inspection", + "version": "1.0.0", + "description": "FastEdge JS example: echo the incoming request for debugging", + "type": "module", + "scripts": { + "build": "fastedge-build src/index.js dist/request-inspection.wasm" + }, + "dependencies": { + "@gcoredev/fastedge-sdk-js": "^2.2.2" + } +} diff --git a/examples/request-inspection/src/index.js b/examples/request-inspection/src/index.js new file mode 100644 index 0000000..abb0ecf --- /dev/null +++ b/examples/request-inspection/src/index.js @@ -0,0 +1,21 @@ +function app(event) { + const { request, client } = event; + + const lines = [ + `Method: ${request.method}`, + `URL: ${request.url}`, + `Client: ${client.address}`, + 'Headers:', + ]; + for (const [name, value] of request.headers) { + lines.push(` ${name}: ${value}`); + } + + return new Response(lines.join('\n') + '\n', { + headers: { 'content-type': 'text/plain; charset=utf-8' }, + }); +} + +addEventListener('fetch', (event) => { + event.respondWith(app(event)); +}); diff --git a/examples/secret-rotation/README.md b/examples/secret-rotation/README.md new file mode 100644 index 0000000..5e6e89d --- /dev/null +++ b/examples/secret-rotation/README.md @@ -0,0 +1,45 @@ +[← Back to examples](../README.md) + +# Secret Rotation + +Demonstrates slot-based secret retrieval using `getSecretEffectiveAt()` from `fastedge::secret` for +rotation scenarios. Returns the current secret value alongside the value effective at a caller- +supplied slot, plus a boolean indicating whether they match. + +## Usage + +Request headers: + +- `x-secret-name` — secret name to look up (defaults to `TOKEN_SECRET`) +- `x-slot` — slot value to query (defaults to the current unix timestamp) + +## How slots work + +Slots use a **greatest-matching** rule: the slot with the highest value that is `<=` the requested +`effectiveAt` is returned. This supports both index-based and timestamp-based rotation patterns. + +Example secret configuration: + +```json +{ + "secret": { + "name": "TOKEN_SECRET", + "secret_slots": [ + { "slot": 0, "value": "original_password" }, + { "slot": 1741790697, "value": "new_password" } + ] + } +} +``` + +**Index-based rotation:** + +- `getSecretEffectiveAt('TOKEN_SECRET', 0)` → `"original_password"` +- `getSecretEffectiveAt('TOKEN_SECRET', 3)` → `"original_password"` (slot 0 is the highest `<= 3`) +- `getSecretEffectiveAt('TOKEN_SECRET', 1741790697)` → `"new_password"` + +**Timestamp-based rotation:** + +A token's `iat` (issued-at) claim determines which password to validate against. +`getSecretEffectiveAt('TOKEN_SECRET', iat)` returns the password that was effective when the token +was issued. diff --git a/examples/secret-rotation/fixtures/.env b/examples/secret-rotation/fixtures/.env new file mode 100644 index 0000000..f1a44bc --- /dev/null +++ b/examples/secret-rotation/fixtures/.env @@ -0,0 +1,2 @@ +FASTEDGE_VAR_SECRET_TOKEN_SECRET=rotating-test-secret-abc123 +FASTEDGE_VAR_SECRET_SIGNING_KEY=alt-signing-key-xyz789 diff --git a/examples/secret-rotation/fixtures/custom-secret.test.json b/examples/secret-rotation/fixtures/custom-secret.test.json new file mode 100644 index 0000000..84a3db6 --- /dev/null +++ b/examples/secret-rotation/fixtures/custom-secret.test.json @@ -0,0 +1,16 @@ +{ + "appType": "http-wasm", + "description": "x-secret-name override — looks up SIGNING_KEY instead of TOKEN_SECRET", + "request": { + "method": "GET", + "path": "/", + "headers": { + "x-secret-name": "SIGNING_KEY", + "x-slot": "1741790697" + } + }, + "dotenv": { + "enabled": true, + "path": "." + } +} diff --git a/examples/secret-rotation/fixtures/default.test.json b/examples/secret-rotation/fixtures/default.test.json new file mode 100644 index 0000000..a099e36 --- /dev/null +++ b/examples/secret-rotation/fixtures/default.test.json @@ -0,0 +1,13 @@ +{ + "appType": "http-wasm", + "description": "No headers — uses TOKEN_SECRET and the current unix timestamp as the slot", + "request": { + "method": "GET", + "path": "/", + "headers": {} + }, + "dotenv": { + "enabled": true, + "path": "." + } +} diff --git a/examples/secret-rotation/fixtures/specific-slot.test.json b/examples/secret-rotation/fixtures/specific-slot.test.json new file mode 100644 index 0000000..cf3b8a8 --- /dev/null +++ b/examples/secret-rotation/fixtures/specific-slot.test.json @@ -0,0 +1,15 @@ +{ + "appType": "http-wasm", + "description": "x-slot=0 — fetches the earliest slot value of TOKEN_SECRET", + "request": { + "method": "GET", + "path": "/", + "headers": { + "x-slot": "0" + } + }, + "dotenv": { + "enabled": true, + "path": "." + } +} diff --git a/examples/secret-rotation/package.json b/examples/secret-rotation/package.json new file mode 100644 index 0000000..e4e5284 --- /dev/null +++ b/examples/secret-rotation/package.json @@ -0,0 +1,12 @@ +{ + "name": "fastedge-example-secret-rotation", + "version": "1.0.0", + "description": "FastEdge JS example: slot-based secret retrieval for rotation", + "type": "module", + "scripts": { + "build": "fastedge-build src/index.js dist/secret-rotation.wasm" + }, + "dependencies": { + "@gcoredev/fastedge-sdk-js": "^2.2.2" + } +} diff --git a/examples/secret-rotation/src/index.js b/examples/secret-rotation/src/index.js new file mode 100644 index 0000000..b706d4d --- /dev/null +++ b/examples/secret-rotation/src/index.js @@ -0,0 +1,38 @@ +import { getSecret, getSecretEffectiveAt } from 'fastedge::secret'; + +function app(event) { + const { request } = event; + + // Read the slot from the x-slot header, defaulting to the current unix timestamp. + // Slots can be interpreted either as indices (0, 1, 2...) or as unix timestamps; + // the host returns the value from the highest slot <= this number. + const slotHeader = request.headers.get('x-slot'); + const slot = + slotHeader !== null ? Number.parseInt(slotHeader, 10) : Math.floor(Date.now() / 1000); + + if (!Number.isFinite(slot) || slot < 0) { + return new Response('x-slot header must be a non-negative integer', { status: 400 }); + } + + const secretName = request.headers.get('x-secret-name') ?? 'TOKEN_SECRET'; + + const current = getSecret(secretName); + const effective = getSecretEffectiveAt(secretName, slot); + + const body = JSON.stringify({ + secret_name: secretName, + slot, + current, + effective_at_slot: effective, + is_same: current === effective, + }); + + return new Response(body, { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} + +addEventListener('fetch', (event) => { + event.respondWith(app(event)); +}); diff --git a/examples/static-assets/fixtures/css.test.json b/examples/static-assets/fixtures/css.test.json new file mode 100644 index 0000000..cf7cf6d --- /dev/null +++ b/examples/static-assets/fixtures/css.test.json @@ -0,0 +1,9 @@ +{ + "appType": "http-wasm", + "description": "GET /styles/index.css — served from the styles static asset manifest", + "request": { + "method": "GET", + "path": "/styles/index.css", + "headers": {} + } +} diff --git a/examples/static-assets/fixtures/home.test.json b/examples/static-assets/fixtures/home.test.json new file mode 100644 index 0000000..f643108 --- /dev/null +++ b/examples/static-assets/fixtures/home.test.json @@ -0,0 +1,11 @@ +{ + "appType": "http-wasm", + "description": "GET / — Hono renders the home page (HTML with embedded JSX)", + "request": { + "method": "GET", + "path": "/", + "headers": { + "accept": "text/html" + } + } +} diff --git a/examples/static-assets/fixtures/image.test.json b/examples/static-assets/fixtures/image.test.json new file mode 100644 index 0000000..80b0f38 --- /dev/null +++ b/examples/static-assets/fixtures/image.test.json @@ -0,0 +1,9 @@ +{ + "appType": "http-wasm", + "description": "GET /images/gcore.png — served from the images static asset manifest", + "request": { + "method": "GET", + "path": "/images/gcore.png", + "headers": {} + } +} diff --git a/examples/static-assets/fixtures/jsx.test.json b/examples/static-assets/fixtures/jsx.test.json new file mode 100644 index 0000000..06699bd --- /dev/null +++ b/examples/static-assets/fixtures/jsx.test.json @@ -0,0 +1,11 @@ +{ + "appType": "http-wasm", + "description": "GET /jsx — renders a JSX component server-side", + "request": { + "method": "GET", + "path": "/jsx", + "headers": { + "accept": "text/html" + } + } +} diff --git a/examples/static-assets/fixtures/template.test.json b/examples/static-assets/fixtures/template.test.json new file mode 100644 index 0000000..82ac216 --- /dev/null +++ b/examples/static-assets/fixtures/template.test.json @@ -0,0 +1,9 @@ +{ + "appType": "http-wasm", + "description": "GET /template — returns the templates/index.html file read from the in-memory asset cache", + "request": { + "method": "GET", + "path": "/template", + "headers": {} + } +} diff --git a/examples/streaming/README.md b/examples/streaming/README.md new file mode 100644 index 0000000..3a038c7 --- /dev/null +++ b/examples/streaming/README.md @@ -0,0 +1,35 @@ +[← Back to examples](../README.md) + +# Streaming Response + +Generates a response body on the fly — five text chunks, one every 200 ms — using a +`ReadableStream` passed to the `Response` constructor. Each chunk flows to the client as it +is enqueued, not all at once at the end. + +Demonstrates `ReadableStream` construction with an async `start()` callback, `TextEncoder`, +and `setTimeout` for per-chunk delays. + +## Testing the streaming behaviour + +```sh +curl -N https://.fastedge.cdn.gc.onl/ +``` + +`-N` disables curl's client-side buffering; without it you won't see chunks appear one at a +time. You should see `chunk 0`…`chunk 4` print at ~200ms intervals. + +## Other streaming patterns + +- **Pass-through streaming** — `new Response(upstreamResponse.body, { ... })` returns an + upstream fetch body without buffering. +- **Transform streaming** — `upstreamResponse.body.pipeThrough(new TransformStream({ ... }))` + to rewrite chunks in flight. +- **Service lifetime** — when the response body is a stream originating in the service (as + here), the service is kept alive until the stream closes. When the body is a stream from a + backend fetch, the service is not kept alive for body completion. Use + [`FetchEvent.waitUntil()`](https://developer.mozilla.org/docs/Web/API/FetchEvent/waitUntil) + if you need the service to outlive the response. + +## Related + +Mirror of `FastEdge-sdk-rust/examples/http/wasi/streaming/`. diff --git a/examples/streaming/fixtures/happy-path.test.json b/examples/streaming/fixtures/happy-path.test.json new file mode 100644 index 0000000..4044efc --- /dev/null +++ b/examples/streaming/fixtures/happy-path.test.json @@ -0,0 +1,9 @@ +{ + "appType": "http-wasm", + "description": "Generates a streaming response body — 5 chunks at 200ms intervals", + "request": { + "method": "GET", + "path": "/", + "headers": {} + } +} diff --git a/examples/streaming/package.json b/examples/streaming/package.json new file mode 100644 index 0000000..bc3ae60 --- /dev/null +++ b/examples/streaming/package.json @@ -0,0 +1,12 @@ +{ + "name": "fastedge-example-streaming", + "version": "1.0.0", + "description": "FastEdge JS example: streaming response with ReadableStream", + "type": "module", + "scripts": { + "build": "fastedge-build src/index.js dist/streaming.wasm" + }, + "dependencies": { + "@gcoredev/fastedge-sdk-js": "^2.2.2" + } +} diff --git a/examples/streaming/src/index.js b/examples/streaming/src/index.js new file mode 100644 index 0000000..f2e04e9 --- /dev/null +++ b/examples/streaming/src/index.js @@ -0,0 +1,22 @@ +function app(event) { + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + async start(controller) { + for (let i = 0; i < 5; i++) { + await new Promise((resolve) => setTimeout(resolve, 200)); + controller.enqueue(encoder.encode(`chunk ${i}\n`)); + } + controller.close(); + }, + }); + + return new Response(stream, { + status: 200, + headers: { 'content-type': 'text/plain; charset=utf-8' }, + }); +} + +addEventListener('fetch', (event) => { + event.respondWith(app(event)); +}); diff --git a/examples/template-invoice-ab-testing/fixtures/bottle-gloria.test.json b/examples/template-invoice-ab-testing/fixtures/bottle-gloria.test.json new file mode 100644 index 0000000..9706c6d --- /dev/null +++ b/examples/template-invoice-ab-testing/fixtures/bottle-gloria.test.json @@ -0,0 +1,12 @@ +{ + "appType": "http-wasm", + "description": "ab-test-logo=bottle and ab-test-font=gloria — the alternative variant pair", + "request": { + "method": "GET", + "path": "/", + "headers": { + "ab-test-logo": "bottle", + "ab-test-font": "gloria" + } + } +} diff --git a/examples/template-invoice-ab-testing/fixtures/defaults.test.json b/examples/template-invoice-ab-testing/fixtures/defaults.test.json new file mode 100644 index 0000000..1e570ab --- /dev/null +++ b/examples/template-invoice-ab-testing/fixtures/defaults.test.json @@ -0,0 +1,9 @@ +{ + "appType": "http-wasm", + "description": "No ab-test-* headers — renders with default logo and font", + "request": { + "method": "GET", + "path": "/", + "headers": {} + } +} diff --git a/examples/template-invoice-ab-testing/fixtures/hops-exo2.test.json b/examples/template-invoice-ab-testing/fixtures/hops-exo2.test.json new file mode 100644 index 0000000..371b736 --- /dev/null +++ b/examples/template-invoice-ab-testing/fixtures/hops-exo2.test.json @@ -0,0 +1,12 @@ +{ + "appType": "http-wasm", + "description": "ab-test-logo=hops and ab-test-font=exo2 — pair with the ab-testing edge handler", + "request": { + "method": "GET", + "path": "/", + "headers": { + "ab-test-logo": "hops", + "ab-test-font": "exo2" + } + } +} diff --git a/examples/template-invoice/fixtures/happy-path.test.json b/examples/template-invoice/fixtures/happy-path.test.json new file mode 100644 index 0000000..3ba29e0 --- /dev/null +++ b/examples/template-invoice/fixtures/happy-path.test.json @@ -0,0 +1,9 @@ +{ + "appType": "http-wasm", + "description": "Renders the hardcoded Homer Simpson invoice as HTML via Handlebars", + "request": { + "method": "GET", + "path": "/", + "headers": {} + } +} diff --git a/examples/tls-client-info/README.md b/examples/tls-client-info/README.md new file mode 100644 index 0000000..b1a28db --- /dev/null +++ b/examples/tls-client-info/README.md @@ -0,0 +1,39 @@ +[← Back to examples](../README.md) + +# TLS Client Info + +Returns a JSON summary of everything `event.client` exposes about the incoming TLS connection: +the client IP, negotiated protocol, cipher suite, JA3 fingerprint, and the raw client +certificate and ClientHello bytes (reported as byte length plus the first 32 bytes as hex). + +Demonstrates the `ClientInfo` surface available on every `FetchEvent`. + +## Sample response + +```json +{ + "address": "203.0.113.17", + "tlsProtocol": "TLSv1.3", + "tlsCipherOpensslName": "TLS_AES_128_GCM_SHA256", + "tlsJA3MD5": "cd08e31494f9531f560d64c695473da9", + "tlsClientCertificate": { "byteLength": 0, "firstBytesHex": null }, + "tlsClientHello": { "byteLength": 512, "firstBytesHex": "010001fc0303..." } +} +``` + +## Notes + +- TLS fields are only populated on HTTPS requests. Over plain HTTP, `tlsProtocol` and the rest + are empty strings and the byte buffers have `byteLength === 0`. +- `tlsClientCertificate` is non-empty only when mTLS is configured and the client presents a + certificate during the handshake. Parse it as DER with your cert library of choice. +- `tlsJA3MD5` is a client fingerprint derived from the ClientHello (version, ciphers, + extensions, curves, formats). It's commonly used for bot detection — maintain an allowlist or + denylist of known-good fingerprints. + +## Use-case sketches + +- **Bot detection:** Compare `tlsJA3MD5` against a list of known-good browser fingerprints. +- **mTLS authorization:** Require `tlsClientCertificate.byteLength > 0` and validate the + certificate against a trusted CA before allowing access. +- **Protocol enforcement:** Reject requests where `tlsProtocol` isn't `"TLSv1.3"`. diff --git a/examples/tls-client-info/fixtures/happy-path.test.json b/examples/tls-client-info/fixtures/happy-path.test.json new file mode 100644 index 0000000..6c9339e --- /dev/null +++ b/examples/tls-client-info/fixtures/happy-path.test.json @@ -0,0 +1,9 @@ +{ + "appType": "http-wasm", + "description": "Returns ClientInfo JSON — TLS metadata is only populated on real HTTPS requests", + "request": { + "method": "GET", + "path": "/", + "headers": {} + } +} diff --git a/examples/tls-client-info/package.json b/examples/tls-client-info/package.json new file mode 100644 index 0000000..a5e8de1 --- /dev/null +++ b/examples/tls-client-info/package.json @@ -0,0 +1,12 @@ +{ + "name": "fastedge-example-tls-client-info", + "version": "1.0.0", + "description": "FastEdge JS example: inspect TLS client metadata from event.client", + "type": "module", + "scripts": { + "build": "fastedge-build src/index.js dist/tls-client-info.wasm" + }, + "dependencies": { + "@gcoredev/fastedge-sdk-js": "^2.2.2" + } +} diff --git a/examples/tls-client-info/src/index.js b/examples/tls-client-info/src/index.js new file mode 100644 index 0000000..6cf573c --- /dev/null +++ b/examples/tls-client-info/src/index.js @@ -0,0 +1,37 @@ +function bufferPrefixHex(buf, maxBytes = 32) { + if (!buf || buf.byteLength === 0) { + return null; + } + const view = new Uint8Array(buf, 0, Math.min(buf.byteLength, maxBytes)); + let hex = ''; + for (const byte of view) { + hex += byte.toString(16).padStart(2, '0'); + } + return hex; +} + +function describeBuffer(buf) { + return { + byteLength: buf?.byteLength ?? 0, + firstBytesHex: bufferPrefixHex(buf), + }; +} + +function app(event) { + const { client } = event; + + const info = { + address: client.address, + tlsProtocol: client.tlsProtocol, + tlsCipherOpensslName: client.tlsCipherOpensslName, + tlsJA3MD5: client.tlsJA3MD5, + tlsClientCertificate: describeBuffer(client.tlsClientCertificate), + tlsClientHello: describeBuffer(client.tlsClientHello), + }; + + return Response.json(info); +} + +addEventListener('fetch', (event) => { + event.respondWith(app(event)); +}); diff --git a/examples/variables-and-secrets/fixtures/.env b/examples/variables-and-secrets/fixtures/.env new file mode 100644 index 0000000..806831c --- /dev/null +++ b/examples/variables-and-secrets/fixtures/.env @@ -0,0 +1,2 @@ +FASTEDGE_VAR_ENV_USERNAME=alice +FASTEDGE_VAR_SECRET_PASSWORD=test-password-12345 diff --git a/examples/variables-and-secrets/fixtures/happy-path.test.json b/examples/variables-and-secrets/fixtures/happy-path.test.json new file mode 100644 index 0000000..5e15b9b --- /dev/null +++ b/examples/variables-and-secrets/fixtures/happy-path.test.json @@ -0,0 +1,13 @@ +{ + "appType": "http-wasm", + "description": "Reads USERNAME env var and PASSWORD secret, returns them in the response body", + "request": { + "method": "GET", + "path": "/", + "headers": {} + }, + "dotenv": { + "enabled": true, + "path": "." + } +} diff --git a/fastedge-plugin-source/check-copilot-sync.sh b/fastedge-plugin-source/check-copilot-sync.sh index 28d1153..70d69e5 100755 --- a/fastedge-plugin-source/check-copilot-sync.sh +++ b/fastedge-plugin-source/check-copilot-sync.sh @@ -2,12 +2,13 @@ # Validates copilot-instructions.md stays in sync with the codebase: # 1. All doc files in manifest.json are referenced in the mapping table # 2. All doc files in the mapping table actually exist on disk +# 3. All example directories on disk are tracked in manifest.json # # This script is part of the fastedge-plugin pipeline contract. # Canonical template: fastedge-plugin/scripts/sync/templates/check-copilot-sync-template.sh # Each source repo gets a copy at: fastedge-plugin-source/check-copilot-sync.sh # -# Exits 0 if in sync, 1 if drift detected. +# Exits 0 if in sync (warnings don't affect exit code), 1 if drift detected. set -euo pipefail @@ -23,7 +24,7 @@ fi # --- Check 1: manifest doc files appear in the mapping table --- # Extract doc/schema paths that appear in mapping table rows (lines starting with '|') -# Use POSIX-compatible awk instead of grep -P so this works on macOS/BSD grep too +# Uses awk to parse backticked paths — works on macOS/BSD and Linux mapping_table_docs=$(awk ' /^\|/ { line = $0 @@ -45,11 +46,11 @@ if [ -f "$MANIFEST" ]; then exit 1 fi - doc_files=$(jq -r '.sources[].files[]' "$MANIFEST" | grep -E '^(docs|schemas)/' | sort -u) + doc_files=$(jq -r '.sources[].files[] | select(startswith("docs/") or startswith("schemas/"))' "$MANIFEST" | sort -u) missing=() for doc in $doc_files; do - if ! echo "$mapping_table_docs" | grep -qF "$doc"; then + if ! printf '%s\n' "$mapping_table_docs" | grep -qxF "$doc"; then missing+=("$doc") fi done @@ -88,6 +89,50 @@ else echo "OK: All doc files in copilot-instructions mapping table exist on disk" fi +# --- Check 3: example directories on disk are tracked in manifest --- +# +# Detects example projects not listed in manifest.json so the fastedge-plugin +# pipeline doesn't silently miss new examples. Uses ::warning:: annotations +# for visibility in GitHub PR checks. +# +# This check is advisory (does not affect exit code) because repos may have +# known gaps during rollout. + +if [ -d "examples" ] && [ -f "$MANIFEST" ]; then + # Get all examples/ file paths from the manifest (filter in jq to avoid + # grep exit-1 on no matches, which would kill the script under pipefail) + manifest_examples=$(jq -r '.sources[].files[] | select(startswith("examples/"))' "$MANIFEST" | sort -u) + + # Find example project directories (contain package.json, Cargo.toml, or asconfig.json) + # Handles flat (examples//) and nested (examples/cdn//) structures + untracked=() + while IFS= read -r marker_file; do + [ -z "$marker_file" ] && continue + project_dir=$(dirname "$marker_file") + # Check if any manifest file starts with this project directory + # Uses awk index() for literal prefix match (grep would treat . [ + as regex) + if [ -z "$manifest_examples" ] || ! printf '%s\n' "$manifest_examples" | awk -v prefix="${project_dir}/" 'index($0, prefix) == 1 {found=1; exit} END {exit !found}'; then + untracked+=("$project_dir") + fi + done < <(find examples/ -maxdepth 4 \( -name "package.json" -o -name "Cargo.toml" -o -name "asconfig.json" \) -not -path "*/node_modules/*" 2>/dev/null | sort) + + if [ ${#untracked[@]} -gt 0 ]; then + echo "WARN: Example directories not tracked in $MANIFEST:" + for dir in "${untracked[@]}"; do + echo " - $dir" + echo "::warning::Example directory '$dir' is not tracked in manifest.json. Add it to fastedge-plugin-source/manifest.json so the fastedge-plugin pipeline can access it." + done + echo "" + echo " To fix: add source entries for the above directories to $MANIFEST" + else + echo "OK: All example directories are tracked in manifest.json" + fi +elif [ ! -d "examples" ]; then + echo "SKIP: No examples/ directory" +else + echo "SKIP: No manifest found at $MANIFEST (cannot check examples coverage)" +fi + # --- Result --- if [ $errors -ne 0 ]; then diff --git a/fastedge-plugin-source/generate-docs.sh b/fastedge-plugin-source/generate-docs.sh index d2162fd..6521244 100755 --- a/fastedge-plugin-source/generate-docs.sh +++ b/fastedge-plugin-source/generate-docs.sh @@ -251,7 +251,10 @@ PROMPT return 130 fi - claude -p --model "$MODEL" "$prompt" > "$tmpfile" + # Pipe prompt via stdin to avoid Linux's MAX_ARG_STRLEN (~128KB per argv string). + # Large prompts (source files + existing doc for incremental updates) exceed + # this limit as an SDK grows; stdin has no such cap. + claude -p --model "$MODEL" > "$tmpfile" <<<"$prompt" # Validate: first non-empty line must start with # local first_line diff --git a/fastedge-plugin-source/manifest.json b/fastedge-plugin-source/manifest.json index 540ad7d..62f3d0e 100644 --- a/fastedge-plugin-source/manifest.json +++ b/fastedge-plugin-source/manifest.json @@ -89,19 +89,19 @@ "fetch-blueprint": { "files": [ - "examples/downstream-fetch/src/index.js", - "examples/downstream-fetch/package.json" + "examples/outbound-fetch/src/index.js", + "examples/outbound-fetch/package.json" ], "required": true, - "description": "Downstream Fetch example — scaffold blueprint extraction" + "description": "Outbound Fetch example — scaffold blueprint extraction" }, "fetch-pattern": { "files": [ - "examples/downstream-fetch/src/index.js", - "examples/downstream-fetch/package.json" + "examples/outbound-fetch/src/index.js", + "examples/outbound-fetch/package.json" ], "required": true, - "description": "Downstream Fetch example — docs pattern extraction" + "description": "Outbound Fetch example — docs pattern extraction" }, "headers-blueprint": { diff --git a/github-pages/src/content/docs/examples/downstream-fetch.mdx b/github-pages/src/content/docs/examples/downstream-fetch.mdx deleted file mode 100644 index bd19544..0000000 --- a/github-pages/src/content/docs/examples/downstream-fetch.mdx +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: Downstream Fetch -description: A Downstream Fetch example. -prev: - link: /FastEdge-sdk-js/examples/main-examples/ - label: Back to examples ---- - -import { Code } from '@astrojs/starlight/components'; -import importedCode from '@examples/downstream-fetch/src/index.js?raw'; - - diff --git a/github-pages/src/content/docs/examples/downstream-modify-response.mdx b/github-pages/src/content/docs/examples/downstream-modify-response.mdx deleted file mode 100644 index 9d21cd0..0000000 --- a/github-pages/src/content/docs/examples/downstream-modify-response.mdx +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: Downstream Modified Response -description: A downstream fetch and modified response example. -prev: - link: /FastEdge-sdk-js/examples/main-examples/ - label: Back to examples ---- - -import { Code } from '@astrojs/starlight/components'; -import importedCode from '@examples/downstream-modify-response/src/index.js?raw'; - - diff --git a/github-pages/src/content/docs/examples/main-examples.mdx b/github-pages/src/content/docs/examples/main-examples.mdx index 6042dcf..4e75a4c 100644 --- a/github-pages/src/content/docs/examples/main-examples.mdx +++ b/github-pages/src/content/docs/examples/main-examples.mdx @@ -11,10 +11,10 @@ All examples below are standalone projects you can clone and build. For the full - + diff --git a/github-pages/src/content/docs/examples/outbound-modify-response.mdx b/github-pages/src/content/docs/examples/outbound-modify-response.mdx new file mode 100644 index 0000000..26f1934 --- /dev/null +++ b/github-pages/src/content/docs/examples/outbound-modify-response.mdx @@ -0,0 +1,12 @@ +--- +title: Outbound Modified Response +description: An outbound fetch and modified response example. +prev: + link: /FastEdge-sdk-js/examples/main-examples/ + label: Back to examples +--- + +import { Code } from '@astrojs/starlight/components'; +import importedCode from '@examples/outbound-modify-response/src/index.js?raw'; + + diff --git a/github-pages/src/content/docs/reference/headers.md b/github-pages/src/content/docs/reference/headers.md index 542993c..5a2d9e8 100644 --- a/github-pages/src/content/docs/reference/headers.md +++ b/github-pages/src/content/docs/reference/headers.md @@ -22,7 +22,7 @@ new Headers(init); :::note[INFO] Request and Response Headers are immutable. This means, if you need to modify Request headers for -downstream fetch requests, or modify Response headers prior to returning a Response. You will need +outbound fetch requests, or modify Response headers prior to returning a Response. You will need to create a `new Headers()` object. ::: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 083cc72..46af264 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -127,13 +127,43 @@ importers: specifier: ^2.2.2 version: 2.2.2 - examples/downstream-fetch: + examples/outbound-fetch: dependencies: '@gcoredev/fastedge-sdk-js': specifier: ^2.2.2 version: 2.2.2 - examples/downstream-modify-response: + examples/outbound-modify-response: + dependencies: + '@gcoredev/fastedge-sdk-js': + specifier: ^2.2.2 + version: 2.2.2 + + examples/bloom-filter-denylist: + dependencies: + '@gcoredev/fastedge-sdk-js': + specifier: ^2.2.2 + version: 2.2.2 + + examples/crypto-hmac-jwt: + dependencies: + '@gcoredev/fastedge-sdk-js': + specifier: ^2.2.2 + version: 2.2.2 + + examples/request-inspection: + dependencies: + '@gcoredev/fastedge-sdk-js': + specifier: ^2.2.2 + version: 2.2.2 + + examples/secret-rotation: + dependencies: + '@gcoredev/fastedge-sdk-js': + specifier: ^2.2.2 + version: 2.2.2 + + examples/tls-client-info: dependencies: '@gcoredev/fastedge-sdk-js': specifier: ^2.2.2 @@ -255,6 +285,12 @@ importers: specifier: ^7.1.7 version: 7.3.2(@types/node@24.12.2)(terser@5.46.1)(tsx@4.21.0) + examples/streaming: + dependencies: + '@gcoredev/fastedge-sdk-js': + specifier: ^2.2.2 + version: 2.2.2 + examples/static-assets: dependencies: '@gcoredev/fastedge-sdk-js': From c18fe2497d560b3aa1d20730b60ca8d9103c2efc Mon Sep 17 00:00:00 2001 From: Gordon Farquharson Date: Wed, 29 Apr 2026 09:06:16 +0100 Subject: [PATCH 2/9] docs update --- context/CONTEXT_INDEX.md | 9 + docs/AUTH_PATTERNS.md | 168 ++++++++++++++++++ docs/HONO_PATTERNS.md | 173 +++++++++++++++++++ docs/PROXY_PATTERNS.md | 167 ++++++++++++++++++ docs/RUNTIME_CONSTRAINTS.md | 120 +++++++++++++ fastedge-plugin-source/.generation-config.md | 49 +++++- fastedge-plugin-source/manifest.json | 46 ++++- 7 files changed, 730 insertions(+), 2 deletions(-) create mode 100644 docs/AUTH_PATTERNS.md create mode 100644 docs/HONO_PATTERNS.md create mode 100644 docs/PROXY_PATTERNS.md create mode 100644 docs/RUNTIME_CONSTRAINTS.md diff --git a/context/CONTEXT_INDEX.md b/context/CONTEXT_INDEX.md index 43af626..10f1206 100644 --- a/context/CONTEXT_INDEX.md +++ b/context/CONTEXT_INDEX.md @@ -149,6 +149,15 @@ Items that need attention. Surface these when asked "what's next" or "what needs - **semantic-release** 23 → 25: Two major versions, needs CI pipeline testing. Upgrade with `conventional-changelog-eslint` 5 → 6. - **TypeScript** 5.8 → 6.0: High risk, wait for ecosystem (`typescript-eslint`, tooling) to stabilize. Run `npx @andrewbranch/ts5to6` migration tool when ready. +### Doc verification backlog (build a test example, then add to `docs/`) + +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. +- **`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. + --- ## Search Tips diff --git a/docs/AUTH_PATTERNS.md b/docs/AUTH_PATTERNS.md new file mode 100644 index 0000000..9d74c4e --- /dev/null +++ b/docs/AUTH_PATTERNS.md @@ -0,0 +1,168 @@ + + +# Authentication Patterns + +Authentication on FastEdge typically combines `getSecret` (for signing keys and shared secrets) with `crypto.subtle` (for HMAC and signature verification). This document covers Bearer-token validation and HMAC-SHA256 JWT verification — the two most common patterns. + +## Secrets Setup + +Auth credentials must never be hardcoded. Store them as FastEdge secrets and read at request time via `getSecret`: + +```typescript +import { getSecret } from "fastedge::secret"; + +const token = getSecret("API_TOKEN"); // string | null +if (token === null) { + return new Response("Server misconfigured", { status: 500 }); +} +``` + +`getSecret` returns `null` when the secret is not provisioned. Always handle the null case before using the value. + +## Bearer Token Pattern + +Extract a Bearer token from the `Authorization` header and compare against a configured shared secret: + +```typescript +import { getSecret } from "fastedge::secret"; + +addEventListener("fetch", (event) => { + event.respondWith(handle(event.request)); +}); + +async function handle(request) { + const authHeader = request.headers.get("Authorization") ?? ""; + const match = authHeader.match(/^Bearer\s+(.+)$/iu); + if (!match) { + return Response.json( + { error: "missing or malformed Authorization header" }, + { status: 401 }, + ); + } + + const expected = getSecret("API_TOKEN"); + if (expected === null) { + return Response.json({ error: "server misconfigured" }, { status: 500 }); + } + + if (match[1] !== expected) { + return Response.json({ error: "invalid token" }, { status: 403 }); + } + + return Response.json({ ok: true }); +} +``` + +For Hono apps, this pattern is wrapped as middleware: + +```typescript +import { Hono } from "hono"; +import { getSecret } from "fastedge::secret"; + +const app = new Hono(); + +app.use("/api/*", async (c, next) => { + const auth = c.req.header("Authorization") ?? ""; + const match = auth.match(/^Bearer\s+(.+)$/iu); + if (!match) return c.json({ error: "missing bearer token" }, 401); + + const expected = getSecret("API_TOKEN"); + if (expected === null) return c.json({ error: "server misconfigured" }, 500); + if (match[1] !== expected) return c.json({ error: "invalid token" }, 403); + + await next(); +}); +``` + +## HMAC-SHA256 JWT Verification + +For signed tokens, use `crypto.subtle` to verify the HMAC. The signing secret is loaded via `getSecret`. This is the pattern from `examples/crypto-hmac-jwt/`: + +```typescript +import { getSecret } from "fastedge::secret"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +function base64urlToBytes(str) { + const padded = str.replace(/-/g, "+").replace(/_/g, "/") + + "=".repeat((4 - (str.length % 4)) % 4); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +async function verifyJwtHs256(token, secret) { + const parts = token.split("."); + if (parts.length !== 3) throw new Error("malformed token"); + const [encodedHeader, encodedPayload, encodedSignature] = parts; + + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["verify"], + ); + + const signature = base64urlToBytes(encodedSignature); + const signedData = encoder.encode(`${encodedHeader}.${encodedPayload}`); + const valid = await crypto.subtle.verify("HMAC", key, signature, signedData); + if (!valid) throw new Error("invalid signature"); + + const claims = JSON.parse(decoder.decode(base64urlToBytes(encodedPayload))); + if (typeof claims.exp === "number" && Math.floor(Date.now() / 1000) >= claims.exp) { + throw new Error("token expired"); + } + return claims; +} +``` + +Use it inside a request handler: + +```typescript +async function handle(request) { + const auth = request.headers.get("Authorization") ?? ""; + const match = auth.match(/^Bearer\s+(.+)$/iu); + if (!match) { + return Response.json({ ok: false, error: "missing bearer" }, { status: 401 }); + } + + const secret = getSecret("JWT_SECRET"); + if (!secret) { + return Response.json({ ok: false, error: "JWT_SECRET not configured" }, { status: 500 }); + } + + try { + const claims = await verifyJwtHs256(match[1], secret); + return Response.json({ ok: true, claims }); + } catch (err) { + return Response.json({ ok: false, error: err.message }, { status: 401 }); + } +} +``` + +## Crypto Capabilities + +The FastEdge JS runtime supports a subset of `crypto.subtle`: + +| Operation | Algorithms supported | +|---|---| +| `digest` | SHA-1, SHA-256, SHA-384, SHA-512, MD5 | +| `sign` / `verify` | RSASSA-PKCS1-v1_5, ECDSA, HMAC | +| `importKey` | JWK, PKCS#8, SPKI, raw (HMAC) | + +`encrypt`, `decrypt`, `generateKey`, `deriveKey`, `deriveBits`, and `exportKey` are not implemented. For details on runtime constraints and SAML library compatibility, see the runtime constraints reference. + +## Operational Notes + +- **Never log secret values.** `console.log` output is captured in app logs. +- **Treat `getSecret` as request-time only.** It is not available during module initialization — call it inside the request handler. +- **Always check for `null`.** A misconfigured app should return 500, not crash with a 531 runtime error. +- **Rotate secrets via the API or portal**, not by redeploying the binary. + +## See Also + +- `examples/crypto-hmac-jwt/` — complete HMAC JWT verification example with fixtures +- `examples/secret-rotation/` — `getSecretEffectiveAt` slot-based rotation patterns diff --git a/docs/HONO_PATTERNS.md b/docs/HONO_PATTERNS.md new file mode 100644 index 0000000..c888698 --- /dev/null +++ b/docs/HONO_PATTERNS.md @@ -0,0 +1,173 @@ + + +# Hono Patterns on FastEdge + +[Hono](https://hono.dev) is the recommended HTTP framework for FastEdge JS apps. It is small, edge-native, and integrates cleanly with the FastEdge `addEventListener('fetch', ...)` Service Worker pattern. + +## FastEdge Integration + +A Hono app is wired into FastEdge by passing the `FetchEvent.request` to `app.fetch()`: + +```typescript +import { Hono } from "hono"; + +const app = new Hono(); + +app.get("/", (c) => c.text("Hello FastEdge!")); + +addEventListener("fetch", (event: FetchEvent) => { + event.respondWith(app.fetch(event.request)); +}); +``` + +Use `app.fetch(event.request)` — not `app.fire()`. `app.fire()` is deprecated in Hono and registers a global `fetch` listener of its own, which conflicts with the FastEdge `addEventListener('fetch', ...)` Service Worker integration. + +## Routing + +### Basic Routes + +```typescript +app.get("/", (c) => c.text("Home")); +app.get("/health", (c) => c.json({ status: "ok" })); +app.post("/data", async (c) => { + const body = await c.req.json(); + return c.json({ received: body }); +}); +``` + +### Path Parameters + +```typescript +app.get("/users/:id", (c) => { + const id = c.req.param("id"); + return c.json({ userId: id }); +}); +``` + +### Wildcards + +```typescript +app.get("/api/*", (c) => c.text("API route")); +``` + +### Sub-routers via `app.route()` + +Mount a child Hono instance under a path prefix. This is the pattern used by the `react-with-hono-server` example to separate API routes from static asset serving: + +```typescript +import { Hono } from "hono"; +import { api } from "./api/routes.js"; + +const app = new Hono(); + +app.route("/api", api); // mount API sub-router + +app.get("*", async (c) => { // catch-all for static assets + return staticServer.serveRequest(c.req.raw); +}); +``` + +Order matters: `app.route("/api", ...)` must be registered before any catch-all `app.get("*", ...)`, otherwise the wildcard absorbs API requests. + +## Middleware + +### Built-in Middleware + +Apply CORS, logging, and secure headers globally: + +```typescript +import { cors } from "hono/cors"; +import { logger } from "hono/logger"; +import { secureHeaders } from "hono/secure-headers"; + +app.use("/*", cors()); +app.use("/*", logger()); +app.use("/*", secureHeaders()); +``` + +### Path-scoped Middleware + +Apply middleware only to specific path patterns: + +```typescript +app.use("/api/*", async (c, next) => { + const token = c.req.header("Authorization"); + if (!token) return c.json({ error: "Unauthorized" }, 401); + await next(); +}); +``` + +### Custom Middleware + +Custom middleware is an `async (c, next) => { ... }` function. Call `await next()` to continue the chain, or return a response to short-circuit: + +```typescript +const requestId = async (c, next) => { + const id = crypto.randomUUID(); + c.header("X-Request-Id", id); + await next(); +}; + +app.use("/*", requestId); +``` + +For an authentication middleware example using `getSecret`, see the auth patterns reference. + +## Error Handling + +```typescript +app.onError((err, c) => { + console.error("Unhandled error:", err.message); + return c.json({ error: "Internal Server Error" }, 500); +}); + +app.notFound((c) => { + return c.json({ error: "Not Found" }, 404); +}); +``` + +Always register `onError` — without it, an unhandled exception in a route handler surfaces as a FastEdge 531 (runtime error) with no useful response body for the client. + +## JSON API Pattern + +```typescript +const app = new Hono(); + +app.use("/*", cors()); + +app.get("/api/items", async (c) => { + const items = await fetchItemsFromBackend(); + return c.json(items); +}); + +app.post("/api/items", async (c) => { + const body = await c.req.json(); + if (!body.name) return c.json({ error: "name required" }, 400); + const result = await createItem(body); + return c.json(result, 201); +}); + +addEventListener("fetch", (event: FetchEvent) => { + event.respondWith(app.fetch(event.request)); +}); +``` + +## Imports — Tree-shaking + +Import middleware from its specific path, not the umbrella `hono/middleware`: + +```typescript +// Good — only the cors middleware is bundled +import { cors } from "hono/cors"; + +// Bad — pulls everything in +import { cors, logger, basicAuth } from "hono/middleware"; +``` + +`fastedge-build` performs tree-shaking, but path-specific imports make the intent explicit and keep binary size predictable. + +## See Also + +- `examples/react-with-hono-server/` — full SPA + Hono API example with sub-routers and static asset serving +- Auth patterns reference — bearer-token and JWT validation patterns that can be applied as Hono middleware +- Proxy patterns reference — outbound `fetch` and response transform patterns that work inside Hono route handlers diff --git a/docs/PROXY_PATTERNS.md b/docs/PROXY_PATTERNS.md new file mode 100644 index 0000000..55e6e9b --- /dev/null +++ b/docs/PROXY_PATTERNS.md @@ -0,0 +1,167 @@ + + +# Proxy and Response Transform Patterns + +FastEdge HTTP apps can act as a thin proxy in front of an origin or upstream API — fetching a response, transforming it, and returning the result. This document covers the common proxy and transform patterns. + +## Simple Proxy + +Fetch from an upstream and return the body unchanged: + +```typescript +async function handle(request) { + const url = new URL(request.url); + const upstream = `https://backend.example.com${url.pathname}${url.search}`; + + const response = await fetch(upstream, { + method: request.method, + headers: request.headers, + body: request.method !== "GET" && request.method !== "HEAD" + ? await request.arrayBuffer() + : undefined, + }); + + return response; +} + +addEventListener("fetch", (event) => { + event.respondWith(handle(event.request)); +}); +``` + +`fetch()` returns a streamable `Response`; returning it directly forwards body chunks without buffering everything in memory. + +## Proxy with JSON Transform + +Modify the response body before returning it. This is the pattern from `examples/outbound-modify-response/`: + +```typescript +async function handle() { + const upstream = await fetch("http://jsonplaceholder.typicode.com/users"); + const users = await upstream.json(); + + const transformed = { + users: users.slice(0, 5), + total: 5, + skip: 0, + limit: 30, + }; + + return new Response(JSON.stringify(transformed), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +addEventListener("fetch", (event) => { + event.respondWith(handle()); +}); +``` + +Reading `await upstream.json()` consumes the body; you cannot read it again. Read the body once, transform what you need, then return. + +## Hono Proxy with Transform + +Inside a Hono app, use `c.req.raw.headers` to forward the inbound headers and `c.req.arrayBuffer()` for the body: + +```typescript +import { Hono } from "hono"; + +const app = new Hono(); + +app.all("/api/*", async (c) => { + const url = new URL(c.req.url); + const upstream = `https://backend.example.com${url.pathname}${url.search}`; + + const response = await fetch(upstream, { + method: c.req.method, + headers: c.req.raw.headers, + body: c.req.method !== "GET" && c.req.method !== "HEAD" + ? await c.req.arrayBuffer() + : undefined, + }); + + const data = await response.json(); + data.processedAt = new Date().toISOString(); + return c.json(data, response.status); +}); + +addEventListener("fetch", (event) => { + event.respondWith(app.fetch(event.request)); +}); +``` + +## Header Manipulation in Proxies + +Strip hop-by-hop headers before forwarding to upstream, and add diagnostic headers on the way back: + +```typescript +async function handle(request) { + const upstreamHeaders = new Headers(request.headers); + // Hop-by-hop headers should not be forwarded + upstreamHeaders.delete("connection"); + upstreamHeaders.delete("keep-alive"); + upstreamHeaders.delete("transfer-encoding"); + + const upstream = await fetch("https://backend.example.com", { + method: request.method, + headers: upstreamHeaders, + }); + + const responseHeaders = new Headers(upstream.headers); + responseHeaders.set("X-Proxied-By", "FastEdge"); + + return new Response(upstream.body, { + status: upstream.status, + headers: responseHeaders, + }); +} +``` + +`new Response(upstream.body, ...)` streams the body through without reading it into memory — preferred for large responses. + +## Cache-aware Proxy with KV + +Cache upstream responses in the KV store to avoid repeated outbound calls. Note: KV is read-only from app code; writes happen via the portal/API: + +```typescript +import { KvStore } from "fastedge::kv"; + +async function handle(request) { + const url = new URL(request.url); + + try { + const cache = KvStore.open("api-cache"); + const cached = cache.get(url.pathname); + if (cached !== null) { + return new Response(cached, { + status: 200, + headers: { "content-type": "application/json", "x-cache": "hit" }, + }); + } + } catch { + // KV store unavailable — fall through to upstream fetch + } + + const upstream = await fetch(`https://backend.example.com${url.pathname}`); + return new Response(await upstream.arrayBuffer(), { + status: upstream.status, + headers: { ...Object.fromEntries(upstream.headers), "x-cache": "miss" }, + }); +} +``` + +`KvStore.open(name)` returns a `KvStoreInstance` (it does not return null) but can throw if the named store is not provisioned — wrap the open call in `try/catch`. `cache.get(key)` returns `ArrayBuffer | null`; check for `null`, not falsy, since an empty buffer is a valid value. + +## Operational Notes + +- **Outbound fetch budget.** Each invocation has a limited number of outbound requests (5 on Basic, 20 on Pro). Parallelise where possible with `Promise.all([...])` instead of sequential `await fetch(...)`. +- **Execution time budget.** Proxying upstream + transforming counts against the 50ms (Basic) / 200ms (Pro) execution budget. Slow upstreams will trip 532 timeouts. +- **Body size limits.** Inbound and outbound bodies are subject to the configured request/response size limits. Stream where possible rather than buffering with `arrayBuffer()` / `text()` / `json()`. + +## See Also + +- `examples/outbound-modify-response/` — JSON transform of upstream response +- `examples/outbound-fetch/` — basic outbound `fetch()` patterns +- `examples/headers/` — request/response header manipulation +- `examples/kv-store/` — KV-backed caching patterns diff --git a/docs/RUNTIME_CONSTRAINTS.md b/docs/RUNTIME_CONSTRAINTS.md new file mode 100644 index 0000000..fde29d0 --- /dev/null +++ b/docs/RUNTIME_CONSTRAINTS.md @@ -0,0 +1,120 @@ + + +# FastEdge JS Runtime — Constraints & Compatibility + +This document covers research-derived runtime constraints that affect library compatibility and implementation choices. The actual SDK API surface (available Web APIs, the `crypto.subtle` operation matrix, SDK imports) is documented in the SDK API reference — that doc is auto-generated from `.d.ts` declarations and is the authoritative list of what the runtime exposes. + +## Runtime: StarlingMonkey + +The FastEdge HTTP App JS SDK (`@gcoredev/fastedge-sdk-js`) runs on +[StarlingMonkey](https://github.com/bytecodealliance/StarlingMonkey) — +a SpiderMonkey-based JS engine targeting the WASI 0.2 Component Model. + +It is a **strict WinterCG-style runtime**. It is NOT Node.js and has NO Node.js +compatibility layer (unlike Cloudflare Workers' `nodejs_compat` flag — that +does not exist here). Standard Node built-ins (`node:crypto`, `node:fs`, `node:path`, `node:buffer`, `process`, `require`) are not available, and there is no shim or flag that makes them available. WebSocket and DOM APIs are also absent. + +For the full available surface (fetch, streams, crypto.subtle, TextEncoder, SDK imports, etc.), see the SDK API reference. + +--- + +## Why Node.js Crypto Polyfills Don't Work + +`esbuild-plugin-polyfill-node` (included in `@gcoredev/fastedge-sdk-js` devDeps) +can substitute `node:crypto` with `crypto-browserify`. However: + +1. `crypto-browserify` implements `createSign` / `createVerify` synchronously using + its own pure-JS RSA — it does **not** delegate to `crypto.subtle`. +2. Even if it did, `crypto.subtle` is async — the sync/async mismatch remains. +3. `crypto-browserify` is disabled by default in the polyfill plugin and requires + explicit opt-in. +4. **No bundler polyfill can bridge synchronous Node.js crypto calls to async + Promise-returning Web Crypto.** This is a fundamental impedance mismatch, not + a configuration problem. + +The `crypto.subtle` operations actually exposed by the runtime (HMAC, RSASSA-PKCS1-v1_5, ECDSA sign/verify, digest, raw/JWK/PKCS#8/SPKI importKey) are sufficient for JWT verification, SAML assertion verification, and general signature verification workflows — see the SDK API reference for the exact algorithm matrix. The unavailable operations (`encrypt`, `decrypt`, `generateKey`, `deriveKey`, `deriveBits`, `exportKey`) are the ones that block most Node-style crypto patterns. + +--- + +## SAML on FastEdge + +### Why Standard SAML Libraries Don't Work + +All mainstream Node.js SAML libraries are incompatible with StarlingMonkey: + +| Library | Blocker | +|---|---| +| `samlify` | Depends on `xml-crypto` (sync Node crypto) and `node-rsa` | +| `@node-saml/node-saml` | Deep Node.js `crypto` dependency | +| `@boxyhq/saml20` | `xml-crypto` + `node-forge` | +| `passport-saml` | Node.js `crypto` | + +The root cause in all cases is `xml-crypto`, which calls the **synchronous** +Node.js API (`crypto.createVerify()`, `crypto.createSign()`, `crypto.createHash()`). +StarlingMonkey only has the **async** `crypto.subtle` API. No polyfill resolves this. + +### Security Note: CVE-2025-29775 (SAMLStorm) + +All libraries depending on `xml-crypto < 6.0.1` are affected by SAMLStorm — a +critical authentication bypass via XML comment injection in `DigestValue`. If +implementing custom XML signature verification, strip comments from the +canonicalized element **before** hashing. + +### Viable SAML SP Stack + +Use WebCrypto-native libraries that have no Node.js dependencies: + +| Task | Package | Notes | +|---|---|---| +| XML parsing | `@xmldom/xmldom` | Pure JS, no Node deps | +| XML Digital Signature (XMLDSig) | `xmldsigjs` | Uses `crypto.subtle` natively | +| X.509 cert → CryptoKey | `@peculiar/x509` | Uses Web Crypto internally; documents `node >= 20` but should bundle fine — validate | +| Deflate (SAMLRequest encoding) | Native `CompressionStream("deflate-raw")` or `fflate` | Both work | + +**Caveats:** +- `xmldsigjs` is not widely battle-tested in edge/WinterCG environments for SAML + specifically. There is a known open issue (#81) from a Cloudflare Workers + developer attempting this. Verify carefully against real IdP responses. +- Bundle size matters: keep the Wasm binary within typical FastEdge limits. + Check after adding these dependencies. + +### XMLDSig Verification Steps (manual reference) + +If implementing without `xmldsigjs`: + +1. Parse SAMLResponse XML with `@xmldom/xmldom` +2. Locate `` inside the `` +3. Extract `` — apply **Exclusive C14N** with enveloped-signature + transform (remove the `` element before canonicalizing) +4. `crypto.subtle.digest("SHA-256", c14nBytes)` and compare to `` +5. `crypto.subtle.verify({ name: "RSASSA-PKCS1-v1_5" }, publicKey, sigBytes, c14nSignedInfoBytes)` + +Exclusive C14N is the hardest part to implement from scratch — prefer `xmldsigjs`. + +### SAMLRequest Encoding + +Use native `CompressionStream("deflate-raw")` — available in StarlingMonkey: + +```js +async function deflateRaw(str) { + const encoded = new TextEncoder().encode(str); + const cs = new CompressionStream("deflate-raw"); + const writer = cs.writable.getWriter(); + writer.write(encoded); + writer.close(); + const chunks = []; + const reader = cs.readable.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + const total = chunks.reduce((n, c) => n + c.length, 0); + const out = new Uint8Array(total); + let off = 0; + for (const c of chunks) { out.set(c, off); off += c.length; } + return btoa(String.fromCharCode(...out)); +} +``` + +Or use `fflate` (`deflateRawSync`) as a synchronous alternative. diff --git a/fastedge-plugin-source/.generation-config.md b/fastedge-plugin-source/.generation-config.md index 01ca84a..dd926df 100644 --- a/fastedge-plugin-source/.generation-config.md +++ b/fastedge-plugin-source/.generation-config.md @@ -128,7 +128,7 @@ It is **local only** — the plugin pipeline never reads this file. ## docs/SDK_API.md -**Scope:** All runtime APIs available in WASM +**Scope:** All runtime APIs available in WASM, plus the explicit set of APIs NOT available and the detailed `crypto.subtle` algorithm support matrix **Source files:** `types/fastedge-env.d.ts`, `types/fastedge-secret.d.ts`, `types/fastedge-kv.d.ts`, `types/globals.d.ts` **Required content:** - FastEdge APIs: getEnv, getSecret, getSecretEffectiveAt, KvStore (all methods) @@ -137,6 +137,8 @@ It is **local only** — the plugin pipeline never reads this file. - ClientInfo and GeoData - Headers immutability note - Code examples for each FastEdge API +- **"Unavailable APIs" section** — see hand-curated content below +- **"`crypto.subtle` Support Matrix" subsection** under the Web Crypto coverage — see hand-curated content below **CRITICAL accuracy:** - KvStore method signatures must match `types/fastedge-kv.d.ts` @@ -144,3 +146,48 @@ It is **local only** — the plugin pipeline never reads this file. - `scan()` returns `Array` (not ArrayBuffer) - `zrangeByScore()` / `zscan()` return `Array<[ArrayBuffer, number]>` tuples - `getSecretEffectiveAt` takes `(name, effectiveAt)` where effectiveAt is a number + +### Hand-curated content for SDK_API.md + +The two sections below cannot be extracted from `types/*.d.ts` (the `.d.ts` files only declare what IS available, never what isn't). The generator must reproduce these verbatim — they are authoritative. Update this config when the runtime adds or removes support; the generator copies the lists from here into the generated doc. + +#### Unavailable APIs + +Include a top-level `## Unavailable APIs` section in `SDK_API.md`. Frame as: "These APIs are not implemented on the FastEdge JS runtime (StarlingMonkey, WinterCG-style). There is no Node.js compatibility layer." + +Bullet list: + +- `node:crypto` — not implemented; not polyfillable (sync Node crypto cannot bridge to async `crypto.subtle`). See the runtime constraints reference for why polyfills don't work. +- `node:fs`, `node:path`, `node:buffer`, `process`, `require` — not implemented +- `WebSocket` — not implemented +- DOM APIs (`document`, `window`, etc.) — not implemented (this is a server-side runtime, not a browser) + +End the section with a note: "For implementation guidance on what to use instead — particularly for crypto-heavy patterns like SAML — see the runtime constraints reference." + +#### `crypto.subtle` Support Matrix + +Within the Web APIs section's coverage of `crypto`, include a table titled "`crypto.subtle` — Supported Operations". Reproduce verbatim: + +| Operation | Supported Algorithms | +|---|---| +| `digest()` | SHA-1, SHA-256, SHA-384, SHA-512, MD5 | +| `sign()` / `verify()` | RSASSA-PKCS1-v1_5, ECDSA, HMAC | +| `importKey()` | JWK, PKCS#8, SPKI, raw (HMAC) | +| `getRandomValues()` | ✓ | +| `encrypt()` / `decrypt()` | **Not implemented** | +| `generateKey()`, `deriveKey()`, `deriveBits()` | **Not implemented** | +| `exportKey()` | **Not implemented** | + +Add a one-line note after the table: "These operations support JWT verification (HMAC / ECDSA / RSASSA-PKCS1-v1_5), SAML assertion verification (SHA-256 digest + RSASSA-PKCS1-v1_5 + SPKI importKey), and general signature verification workflows. Encryption / key generation / key export are unavailable." + +**CRITICAL accuracy:** +- Algorithm names in the matrix must match exactly — preserve the verbatim list from this config +- Do NOT add algorithms that aren't in the matrix above +- Do NOT mark anything else as "Not implemented" beyond the rows that say so +- The Unavailable APIs list is authoritative; do not extend it with speculative entries + +### Pipeline downstream effect + +Once `SDK_API.md` is regenerated with these sections, the coordinator pipeline picks them up via `agent-intent-skills/fastedge-sdk-js/sdk-reference-js.md`. That intent skill may need a corresponding update to ensure synthesis preserves the new sections — verify after the next pipeline run that `plugins/.../sdk-reference-js.md` includes Unavailable APIs and the crypto matrix. If not, extend the intent skill's "Required sections" list. + +The slimmed `RUNTIME_CONSTRAINTS.md` (in this repo's `docs/`) explicitly defers to SDK_API for these tables — it must NOT re-introduce them. diff --git a/fastedge-plugin-source/manifest.json b/fastedge-plugin-source/manifest.json index 62f3d0e..0eeccec 100644 --- a/fastedge-plugin-source/manifest.json +++ b/fastedge-plugin-source/manifest.json @@ -40,6 +40,12 @@ "description": "Installation, first build, basic usage patterns with code examples" }, + "runtime-constraints": { + "files": ["docs/RUNTIME_CONSTRAINTS.md"], + "required": true, + "description": "JS runtime constraints — StarlingMonkey, WinterCG capabilities, crypto.subtle support matrix, Node.js polyfill impossibility, SAML library compatibility. Hand-curated research" + }, + "hello-world-blueprint": { "files": [ "examples/hello-world/src/index.js", @@ -136,6 +142,24 @@ ], "required": true, "description": "Geo Redirect example — docs pattern extraction" + }, + + "hono-pattern": { + "files": ["docs/HONO_PATTERNS.md"], + "required": true, + "description": "Hono framework patterns — routing, middleware, error handling, FastEdge Service Worker integration. Source-of-truth: examples/react-with-hono-server/ + hand-curated guidance" + }, + + "auth-pattern": { + "files": ["docs/AUTH_PATTERNS.md"], + "required": true, + "description": "Auth patterns — bearer token middleware, HMAC-SHA256 JWT verification with crypto.subtle, getSecret usage. Source-of-truth: examples/crypto-hmac-jwt/ + hand-curated guidance" + }, + + "proxy-pattern": { + "files": ["docs/PROXY_PATTERNS.md"], + "required": true, + "description": "Proxy and response transform patterns — outbound fetch forwarding, JSON transform, header manipulation, KV-backed caching. Source-of-truth: examples/outbound-modify-response/ + hand-curated guidance" } }, "target_mapping": { @@ -156,7 +180,12 @@ "section": null }, "quickstart": { - "reference_file": "plugins/gcore-fastedge/skills/fastedge-docs/reference/quickstart.md", + "reference_file": "plugins/gcore-fastedge/skills/fastedge-docs/reference/quickstart-js.md", + "section": null + }, + + "runtime-constraints": { + "reference_file": "plugins/gcore-fastedge/skills/fastedge-docs/reference/js-runtime.md", "section": null }, @@ -208,6 +237,21 @@ "geo-redirect-pattern": { "reference_file": "plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-geo-redirect-js.md", "section": null + }, + + "hono-pattern": { + "reference_file": "plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-hono-js.md", + "section": null + }, + + "auth-pattern": { + "reference_file": "plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-auth-js.md", + "section": null + }, + + "proxy-pattern": { + "reference_file": "plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-proxy-js.md", + "section": null } }, "validation": { From f4a41ba628f7f171ced53c63f899b2b4a9d7f04a Mon Sep 17 00:00:00 2001 From: Gordon Farquharson Date: Tue, 12 May 2026 13:49:53 +0100 Subject: [PATCH 3/9] chore(docgen): preserve failed outputs + salvage preamble leaks Apply parity with the fastedge-plugin generate-docs-template.sh. Rejected `claude -p` outputs now persist under docs/.failures/ for prompt-debugging, and the salvage path strips one-line conversational preambles (a known Sonnet update-mode failure) rather than retrying. Tightens OUTPUT CONSTRAINT and existing-content prompt blocks to ban the "outputting verbatim" acknowledgement class explicitly. --- .gitignore | 4 +++ fastedge-plugin-source/generate-docs.sh | 47 ++++++++++++++++++++----- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 95d7b4c..7a16f88 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,7 @@ package-lock.json # example project lock files examples/**/pnpm-lock.yaml examples/**/package-lock.json + +# Doc-generator failure artifacts — rejected/preamble-leaked claude -p +# outputs preserved for prompt-debugging. Prune manually. +docs/.failures/ diff --git a/fastedge-plugin-source/generate-docs.sh b/fastedge-plugin-source/generate-docs.sh index a8c5793..427b1b2 100755 --- a/fastedge-plugin-source/generate-docs.sh +++ b/fastedge-plugin-source/generate-docs.sh @@ -209,6 +209,8 @@ $(cat "$full_path") # Existing Content for docs/$target Use this as the baseline. Preserve all accurate content and manual additions. Only change what is incorrect, incomplete, or missing per the source code. Keep sections not covered by the instructions above. Apply table formatting rules to all tables. +If the existing content is already accurate against the source code, output it verbatim with zero preamble or acknowledgement — your output starts at the # of the level-1 heading regardless of whether you made changes. + $existing_doc @@ -217,7 +219,7 @@ $existing_doc local prompt prompt="$(cat < "$tmpfile" <<<"$prompt" - # Validate: first non-empty line must start with # - local first_line - first_line=$(grep -m1 '.' "$tmpfile" || true) - if [[ "$first_line" == \#* ]]; then - mv "$tmpfile" "$DOCS_DIR/$target" + # Validate + salvage. The model intermittently leaks a conversational + # preamble like "I'll write the markdown now." before the real document, + # despite the OUTPUT CONSTRAINT in the prompt. Rather than discard those + # outputs and retry (wasting API quota on otherwise-good content), find + # the first level-1 heading and treat everything from there forward as + # the doc. The original is still saved to .failures/ so the prompt can + # be tuned later. + local stripped + stripped=$(awk '/^# / { found=1 } found' "$tmpfile") + + if [ -n "$stripped" ]; then + # Detect preamble: anything before the first '# ' line is preamble. + local first_heading_line + first_heading_line=$(grep -n -m1 '^# ' "$tmpfile" | cut -d: -f1) + if [ "${first_heading_line:-1}" -gt 1 ]; then + local preamble_copy="$failure_dir/${target}.preamble.attempt-${attempt}.$(date +%s).md" + cp "$tmpfile" "$preamble_copy" + echo " Stripped $((first_heading_line - 1)) preamble line(s) from $target (attempt $attempt) — original saved to $preamble_copy" + fi + printf '%s\n' "$stripped" > "$DOCS_DIR/$target" + rm -f "$tmpfile" echo " Done: docs/$target" return 0 fi - echo " Attempt $attempt/$max_attempts failed for $target (got conversational output), retrying..." + # No level-1 heading anywhere — genuine failure. Save and retry. + local failed_copy="$failure_dir/${target}.attempt-${attempt}.$(date +%s).md" + cp "$tmpfile" "$failed_copy" + echo " Attempt $attempt/$max_attempts failed for $target (no '# ' heading found in output) — saved to $failed_copy, retrying..." attempt=$((attempt + 1)) done From d0ed6cea3e8f5af274efaf42b529d50f5e45ab1e Mon Sep 17 00:00:00 2001 From: Gordon Farquharson Date: Tue, 19 May 2026 16:47:55 +0100 Subject: [PATCH 4/9] validated extra examples --- examples/README.md | 23 ++++--- .../fixtures/existing-visitor.live.json | 10 +++ .../ab-testing/fixtures/new-visitor.live.json | 10 +++ .../fixtures/missing-config.live.json | 6 ++ .../fixtures/01-cache-set.live.json | 6 ++ .../fixtures/01-cache-set.test.json | 9 +++ .../fixtures/02-cache-get-hit.live.json | 6 ++ .../fixtures/02-cache-get-hit.test.json | 9 +++ .../fixtures/03-cache-exists.live.json | 6 ++ .../fixtures/03-cache-exists.test.json | 9 +++ .../fixtures/04-cache-delete.live.json | 6 ++ .../fixtures/04-cache-delete.test.json | 9 +++ .../fixtures/05-cache-get-miss.live.json | 6 ++ .../fixtures/05-cache-get-miss.test.json | 9 +++ .../fixtures/06-missing-key.live.json | 6 ++ .../fixtures/06-missing-key.test.json | 9 +++ .../fixtures/07-unknown-action.live.json | 6 ++ .../fixtures/07-unknown-action.test.json | 9 +++ examples/cache/fixtures/landing.live.json | 13 ++++ .../fixtures/landing.test.json} | 2 +- examples/cache/fixtures/memo.live.json | 6 ++ examples/cache/fixtures/memo.test.json | 9 +++ .../fixtures/proxy-invalid-url.live.json | 6 ++ .../fixtures/proxy-invalid-url.test.json | 9 +++ examples/cache/fixtures/proxy-miss.live.json | 6 ++ examples/cache/fixtures/proxy-miss.test.json | 9 +++ .../fixtures/rate-limit-allowed.live.json | 6 ++ .../fixtures/rate-limit-allowed.test.json | 9 +++ .../fixtures/rate-limit-exceeded.live.json | 7 +++ .../fixtures/rate-limit-exceeded.test.json | 9 +++ .../cache/fixtures/unknown-action.live.json | 6 ++ .../cache/fixtures/unknown-action.test.json | 9 +++ .../fixtures/malformed-token.live.json | 6 ++ .../fixtures/missing-auth.live.json | 6 ++ .../fixtures/valid-token.live.json | 9 +++ .../fixtures/valid-token.test.json | 2 +- examples/geo-redirect/README.md | 58 +++++++++++++++-- .../geo-redirect/fixtures/fallback.live.json | 8 +++ .../geo-redirect/fixtures/germany.live.json | 8 +++ .../fixtures/missing-config.live.json | 6 ++ .../geo-redirect/fixtures/missing-config/.env | 3 + examples/geo-redirect/fixtures/us.live.json | 8 +++ .../headers/fixtures/happy-path.live.json | 9 +++ .../hello-world/fixtures/happy-path.live.json | 6 ++ examples/kv-store-basic/README.md | 31 ++++++++++ .../fixtures/happy-path.live.json | 6 ++ examples/kv-store/README.md | 60 +++++++++++++++--- .../fixtures/missing-params.live.json | 6 ++ examples/mcp-server/README.md | 24 ++++--- .../mcp-server/fixtures/initialize.live.json | 6 ++ .../mcp-server/fixtures/tools-list.live.json | 6 ++ examples/outbound-fetch/README.md | 38 +++++++++++- .../fixtures/happy-path.live.json | 6 ++ examples/outbound-modify-response/README.md | 45 +++++++++++++- .../fixtures/happy-path.live.json | 7 +++ .../fastedge-server/config/asset-manifest.ts | 10 +-- .../fixtures/api-hello.live.json | 6 ++ .../fixtures/api-users-post.live.json | 6 ++ .../fixtures/home.live.json | 6 ++ .../fixtures/happy-path.live.json | 9 +++ examples/secret-rotation/README.md | 62 +++++++++++++++++++ .../fixtures/custom-secret.live.json | 9 +++ .../fixtures/default.live.json | 9 +++ .../fixtures/specific-slot.live.json | 9 +++ examples/static-assets/fixtures/css.live.json | 6 ++ .../static-assets/fixtures/home.live.json | 6 ++ .../static-assets/fixtures/image.live.json | 6 ++ examples/static-assets/fixtures/jsx.live.json | 6 ++ .../static-assets/fixtures/template.live.json | 6 ++ .../streaming/fixtures/happy-path.live.json | 7 +++ .../fixtures/bottle-gloria.live.json | 7 +++ .../fixtures/defaults.live.json | 7 +++ .../fixtures/hops-exo2.live.json | 7 +++ .../fixtures/happy-path.live.json | 7 +++ examples/template-invoice/src/index.js | 2 +- examples/tls-client-info/README.md | 39 ------------ examples/tls-client-info/package.json | 12 ---- examples/tls-client-info/src/index.js | 37 ----------- .../fixtures/happy-path.live.json | 6 ++ fastedge-plugin-source/generate-docs.sh | 8 ++- 80 files changed, 784 insertions(+), 135 deletions(-) create mode 100644 examples/ab-testing/fixtures/existing-visitor.live.json create mode 100644 examples/ab-testing/fixtures/new-visitor.live.json create mode 100644 examples/bloom-filter-denylist/fixtures/missing-config.live.json create mode 100644 examples/cache-basic/fixtures/01-cache-set.live.json create mode 100644 examples/cache-basic/fixtures/01-cache-set.test.json create mode 100644 examples/cache-basic/fixtures/02-cache-get-hit.live.json create mode 100644 examples/cache-basic/fixtures/02-cache-get-hit.test.json create mode 100644 examples/cache-basic/fixtures/03-cache-exists.live.json create mode 100644 examples/cache-basic/fixtures/03-cache-exists.test.json create mode 100644 examples/cache-basic/fixtures/04-cache-delete.live.json create mode 100644 examples/cache-basic/fixtures/04-cache-delete.test.json create mode 100644 examples/cache-basic/fixtures/05-cache-get-miss.live.json create mode 100644 examples/cache-basic/fixtures/05-cache-get-miss.test.json create mode 100644 examples/cache-basic/fixtures/06-missing-key.live.json create mode 100644 examples/cache-basic/fixtures/06-missing-key.test.json create mode 100644 examples/cache-basic/fixtures/07-unknown-action.live.json create mode 100644 examples/cache-basic/fixtures/07-unknown-action.test.json create mode 100644 examples/cache/fixtures/landing.live.json rename examples/{tls-client-info/fixtures/happy-path.test.json => cache/fixtures/landing.test.json} (50%) create mode 100644 examples/cache/fixtures/memo.live.json create mode 100644 examples/cache/fixtures/memo.test.json create mode 100644 examples/cache/fixtures/proxy-invalid-url.live.json create mode 100644 examples/cache/fixtures/proxy-invalid-url.test.json create mode 100644 examples/cache/fixtures/proxy-miss.live.json create mode 100644 examples/cache/fixtures/proxy-miss.test.json create mode 100644 examples/cache/fixtures/rate-limit-allowed.live.json create mode 100644 examples/cache/fixtures/rate-limit-allowed.test.json create mode 100644 examples/cache/fixtures/rate-limit-exceeded.live.json create mode 100644 examples/cache/fixtures/rate-limit-exceeded.test.json create mode 100644 examples/cache/fixtures/unknown-action.live.json create mode 100644 examples/cache/fixtures/unknown-action.test.json create mode 100644 examples/crypto-hmac-jwt/fixtures/malformed-token.live.json create mode 100644 examples/crypto-hmac-jwt/fixtures/missing-auth.live.json create mode 100644 examples/crypto-hmac-jwt/fixtures/valid-token.live.json create mode 100644 examples/geo-redirect/fixtures/fallback.live.json create mode 100644 examples/geo-redirect/fixtures/germany.live.json create mode 100644 examples/geo-redirect/fixtures/missing-config.live.json create mode 100644 examples/geo-redirect/fixtures/missing-config/.env create mode 100644 examples/geo-redirect/fixtures/us.live.json create mode 100644 examples/headers/fixtures/happy-path.live.json create mode 100644 examples/hello-world/fixtures/happy-path.live.json create mode 100644 examples/kv-store-basic/fixtures/happy-path.live.json create mode 100644 examples/kv-store/fixtures/missing-params.live.json create mode 100644 examples/mcp-server/fixtures/initialize.live.json create mode 100644 examples/mcp-server/fixtures/tools-list.live.json create mode 100644 examples/outbound-fetch/fixtures/happy-path.live.json create mode 100644 examples/outbound-modify-response/fixtures/happy-path.live.json create mode 100644 examples/react-with-hono-server/fixtures/api-hello.live.json create mode 100644 examples/react-with-hono-server/fixtures/api-users-post.live.json create mode 100644 examples/react-with-hono-server/fixtures/home.live.json create mode 100644 examples/request-inspection/fixtures/happy-path.live.json create mode 100644 examples/secret-rotation/fixtures/custom-secret.live.json create mode 100644 examples/secret-rotation/fixtures/default.live.json create mode 100644 examples/secret-rotation/fixtures/specific-slot.live.json create mode 100644 examples/static-assets/fixtures/css.live.json create mode 100644 examples/static-assets/fixtures/home.live.json create mode 100644 examples/static-assets/fixtures/image.live.json create mode 100644 examples/static-assets/fixtures/jsx.live.json create mode 100644 examples/static-assets/fixtures/template.live.json create mode 100644 examples/streaming/fixtures/happy-path.live.json create mode 100644 examples/template-invoice-ab-testing/fixtures/bottle-gloria.live.json create mode 100644 examples/template-invoice-ab-testing/fixtures/defaults.live.json create mode 100644 examples/template-invoice-ab-testing/fixtures/hops-exo2.live.json create mode 100644 examples/template-invoice/fixtures/happy-path.live.json delete mode 100644 examples/tls-client-info/README.md delete mode 100644 examples/tls-client-info/package.json delete mode 100644 examples/tls-client-info/src/index.js create mode 100644 examples/variables-and-secrets/fixtures/happy-path.live.json diff --git a/examples/README.md b/examples/README.md index e39282f..2a1973c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -6,17 +6,17 @@ network using ## Getting Started Examples -| Example | Description | -| ----------------------------------------------------------- | -------------------------------------------------- | -| [hello-world](./hello-world/) | Simplest request handler — returns the request URL | -| [request-inspection](./request-inspection/) | Echo request method, URL, headers, and client info | -| [outbound-fetch](./outbound-fetch/) | Fetch from an outbound HTTP origin | -| [outbound-modify-response](./outbound-modify-response/) | Fetch outbound and transform the response | -| [headers](./headers/) | Header manipulation using environment variables | -| [kv-store-basic](./kv-store-basic/) | Simple KV Store get operation | -| [cache-basic](./cache-basic/) | Simple POP-local cache set/get/exists/delete | -| [variables-and-secrets](./variables-and-secrets/) | Read environment variables and secrets | -| [secret-rotation](./secret-rotation/) | Slot-based secret retrieval for rotation | +| Example | Description | +| ------------------------------------------------------- | -------------------------------------------------- | +| [hello-world](./hello-world/) | Simplest request handler — returns the request URL | +| [request-inspection](./request-inspection/) | Echo request method, URL, headers, and client info | +| [outbound-fetch](./outbound-fetch/) | Fetch from an outbound HTTP origin | +| [outbound-modify-response](./outbound-modify-response/) | Fetch outbound and transform the response | +| [headers](./headers/) | Header manipulation using environment variables | +| [kv-store-basic](./kv-store-basic/) | Simple KV Store get operation | +| [cache-basic](./cache-basic/) | Simple POP-local cache set/get/exists/delete | +| [variables-and-secrets](./variables-and-secrets/) | Read environment variables and secrets | +| [secret-rotation](./secret-rotation/) | Slot-based secret retrieval for rotation | ## Full Examples @@ -30,7 +30,6 @@ network using | [cache](./cache/) | POP-local cache patterns — per-IP rate limiting, origin-cache proxy, JSON memoisation | | [template-invoice](./template-invoice/) | HTML invoice rendered server-side using Handlebars templates | | [template-invoice-ab-testing](./template-invoice-ab-testing/) | Template invoice with logo and font variants driven by A/B test headers | -| [tls-client-info](./tls-client-info/) | Inspect TLS metadata from `event.client` — JA3 fingerprint, protocol, cert bytes | | [static-assets](./static-assets/) | Serve static assets (images, styles, templates) embedded in the wasm binary with Hono | | [streaming](./streaming/) | Generate a streaming response body with `ReadableStream` and timed chunks | | [mcp-server](./mcp-server/) | MCP server running on FastEdge — weather alerts and forecast via NWS API | diff --git a/examples/ab-testing/fixtures/existing-visitor.live.json b/examples/ab-testing/fixtures/existing-visitor.live.json new file mode 100644 index 0000000..c766e84 --- /dev/null +++ b/examples/ab-testing/fixtures/existing-visitor.live.json @@ -0,0 +1,10 @@ +{ + "expected": { + "status": 200, + "headers": { + "content-type": { "contains": "text/html" }, + "set-cookie": { "contains": "x-fastedge-abid=0.5000" } + }, + "bodyContains": ["Homer Simpson", "1729"] + } +} diff --git a/examples/ab-testing/fixtures/new-visitor.live.json b/examples/ab-testing/fixtures/new-visitor.live.json new file mode 100644 index 0000000..3c98c64 --- /dev/null +++ b/examples/ab-testing/fixtures/new-visitor.live.json @@ -0,0 +1,10 @@ +{ + "expected": { + "status": 200, + "headers": { + "content-type": { "contains": "text/html" }, + "set-cookie": { "contains": "x-fastedge-abid=" } + }, + "bodyContains": ["Homer Simpson", "1729"] + } +} diff --git a/examples/bloom-filter-denylist/fixtures/missing-config.live.json b/examples/bloom-filter-denylist/fixtures/missing-config.live.json new file mode 100644 index 0000000..f3b3534 --- /dev/null +++ b/examples/bloom-filter-denylist/fixtures/missing-config.live.json @@ -0,0 +1,6 @@ +{ + "expected": { + "status": 500, + "json": { "error": "DENYLIST_STORE environment variable is not configured" } + } +} diff --git a/examples/cache-basic/fixtures/01-cache-set.live.json b/examples/cache-basic/fixtures/01-cache-set.live.json new file mode 100644 index 0000000..d713eb4 --- /dev/null +++ b/examples/cache-basic/fixtures/01-cache-set.live.json @@ -0,0 +1,6 @@ +{ + "expected": { + "status": 200, + "json": { "action": "set", "key": "demo-key", "value": "hello", "ttl": 60 } + } +} diff --git a/examples/cache-basic/fixtures/01-cache-set.test.json b/examples/cache-basic/fixtures/01-cache-set.test.json new file mode 100644 index 0000000..d1f76bd --- /dev/null +++ b/examples/cache-basic/fixtures/01-cache-set.test.json @@ -0,0 +1,9 @@ +{ + "appType": "http-wasm", + "description": "Cache.set — write a key with 60s TTL", + "request": { + "method": "GET", + "path": "/?action=set&key=demo-key&value=hello", + "headers": {} + } +} diff --git a/examples/cache-basic/fixtures/02-cache-get-hit.live.json b/examples/cache-basic/fixtures/02-cache-get-hit.live.json new file mode 100644 index 0000000..32708ce --- /dev/null +++ b/examples/cache-basic/fixtures/02-cache-get-hit.live.json @@ -0,0 +1,6 @@ +{ + "expected": { + "status": 200, + "json": { "action": "get", "key": "demo-key", "hit": true, "value": "hello" } + } +} diff --git a/examples/cache-basic/fixtures/02-cache-get-hit.test.json b/examples/cache-basic/fixtures/02-cache-get-hit.test.json new file mode 100644 index 0000000..1aff606 --- /dev/null +++ b/examples/cache-basic/fixtures/02-cache-get-hit.test.json @@ -0,0 +1,9 @@ +{ + "appType": "http-wasm", + "description": "Cache.get — hit on a key that was set (run cache-set first)", + "request": { + "method": "GET", + "path": "/?action=get&key=demo-key", + "headers": {} + } +} diff --git a/examples/cache-basic/fixtures/03-cache-exists.live.json b/examples/cache-basic/fixtures/03-cache-exists.live.json new file mode 100644 index 0000000..341db9e --- /dev/null +++ b/examples/cache-basic/fixtures/03-cache-exists.live.json @@ -0,0 +1,6 @@ +{ + "expected": { + "status": 200, + "bodyContains": ["\"action\":\"exists\"", "\"key\":\"demo-key\"", "\"present\":true"] + } +} diff --git a/examples/cache-basic/fixtures/03-cache-exists.test.json b/examples/cache-basic/fixtures/03-cache-exists.test.json new file mode 100644 index 0000000..1398b80 --- /dev/null +++ b/examples/cache-basic/fixtures/03-cache-exists.test.json @@ -0,0 +1,9 @@ +{ + "appType": "http-wasm", + "description": "Cache.exists — presence check on demo-key", + "request": { + "method": "GET", + "path": "/?action=exists&key=demo-key", + "headers": {} + } +} diff --git a/examples/cache-basic/fixtures/04-cache-delete.live.json b/examples/cache-basic/fixtures/04-cache-delete.live.json new file mode 100644 index 0000000..02aedd7 --- /dev/null +++ b/examples/cache-basic/fixtures/04-cache-delete.live.json @@ -0,0 +1,6 @@ +{ + "expected": { + "status": 200, + "json": { "action": "delete", "key": "demo-key", "deleted": true } + } +} diff --git a/examples/cache-basic/fixtures/04-cache-delete.test.json b/examples/cache-basic/fixtures/04-cache-delete.test.json new file mode 100644 index 0000000..762fd84 --- /dev/null +++ b/examples/cache-basic/fixtures/04-cache-delete.test.json @@ -0,0 +1,9 @@ +{ + "appType": "http-wasm", + "description": "Cache.delete — remove a key (no-op if absent)", + "request": { + "method": "GET", + "path": "/?action=delete&key=demo-key", + "headers": {} + } +} diff --git a/examples/cache-basic/fixtures/05-cache-get-miss.live.json b/examples/cache-basic/fixtures/05-cache-get-miss.live.json new file mode 100644 index 0000000..cbcb9b8 --- /dev/null +++ b/examples/cache-basic/fixtures/05-cache-get-miss.live.json @@ -0,0 +1,6 @@ +{ + "expected": { + "status": 200, + "json": { "action": "get", "key": "never-set-key", "hit": false } + } +} diff --git a/examples/cache-basic/fixtures/05-cache-get-miss.test.json b/examples/cache-basic/fixtures/05-cache-get-miss.test.json new file mode 100644 index 0000000..7800c63 --- /dev/null +++ b/examples/cache-basic/fixtures/05-cache-get-miss.test.json @@ -0,0 +1,9 @@ +{ + "appType": "http-wasm", + "description": "Cache.get — miss on a key that has never been set", + "request": { + "method": "GET", + "path": "/?action=get&key=never-set-key", + "headers": {} + } +} diff --git a/examples/cache-basic/fixtures/06-missing-key.live.json b/examples/cache-basic/fixtures/06-missing-key.live.json new file mode 100644 index 0000000..89bdb82 --- /dev/null +++ b/examples/cache-basic/fixtures/06-missing-key.live.json @@ -0,0 +1,6 @@ +{ + "expected": { + "status": 500, + "json": { "error": "Missing required query parameter: \"key\"" } + } +} diff --git a/examples/cache-basic/fixtures/06-missing-key.test.json b/examples/cache-basic/fixtures/06-missing-key.test.json new file mode 100644 index 0000000..be0b3bd --- /dev/null +++ b/examples/cache-basic/fixtures/06-missing-key.test.json @@ -0,0 +1,9 @@ +{ + "appType": "http-wasm", + "description": "Error path — missing required 'key' query parameter", + "request": { + "method": "GET", + "path": "/?action=get", + "headers": {} + } +} diff --git a/examples/cache-basic/fixtures/07-unknown-action.live.json b/examples/cache-basic/fixtures/07-unknown-action.live.json new file mode 100644 index 0000000..208ad3c --- /dev/null +++ b/examples/cache-basic/fixtures/07-unknown-action.live.json @@ -0,0 +1,6 @@ +{ + "expected": { + "status": 500, + "json": { "error": "Unknown action: \"bogus\". Use one of: set, get, exists, delete." } + } +} diff --git a/examples/cache-basic/fixtures/07-unknown-action.test.json b/examples/cache-basic/fixtures/07-unknown-action.test.json new file mode 100644 index 0000000..f126be4 --- /dev/null +++ b/examples/cache-basic/fixtures/07-unknown-action.test.json @@ -0,0 +1,9 @@ +{ + "appType": "http-wasm", + "description": "Error path — unknown action value returns 500 with usage hint", + "request": { + "method": "GET", + "path": "/?action=bogus&key=x", + "headers": {} + } +} diff --git a/examples/cache/fixtures/landing.live.json b/examples/cache/fixtures/landing.live.json new file mode 100644 index 0000000..36ed3df --- /dev/null +++ b/examples/cache/fixtures/landing.live.json @@ -0,0 +1,13 @@ +{ + "expected": { + "status": 200, + "json": { + "name": "FastEdge Cache patterns", + "actions": { + "rate-limit": "/?action=rate-limit", + "proxy": "/?action=proxy&url=https://www.example.com", + "memo": "/?action=memo" + } + } + } +} diff --git a/examples/tls-client-info/fixtures/happy-path.test.json b/examples/cache/fixtures/landing.test.json similarity index 50% rename from examples/tls-client-info/fixtures/happy-path.test.json rename to examples/cache/fixtures/landing.test.json index 6c9339e..2b8bef1 100644 --- a/examples/tls-client-info/fixtures/happy-path.test.json +++ b/examples/cache/fixtures/landing.test.json @@ -1,6 +1,6 @@ { "appType": "http-wasm", - "description": "Returns ClientInfo JSON — TLS metadata is only populated on real HTTPS requests", + "description": "No action param — usage menu listing all three patterns", "request": { "method": "GET", "path": "/", diff --git a/examples/cache/fixtures/memo.live.json b/examples/cache/fixtures/memo.live.json new file mode 100644 index 0000000..bf20496 --- /dev/null +++ b/examples/cache/fixtures/memo.live.json @@ -0,0 +1,6 @@ +{ + "expected": { + "status": 200, + "bodyContains": ["\"pattern\":\"memo\"", "\"generatedAt\":"] + } +} diff --git a/examples/cache/fixtures/memo.test.json b/examples/cache/fixtures/memo.test.json new file mode 100644 index 0000000..eb421d8 --- /dev/null +++ b/examples/cache/fixtures/memo.test.json @@ -0,0 +1,9 @@ +{ + "appType": "http-wasm", + "description": "Memo pattern — getOrSet computes and caches a report for MEMO_TTL_S seconds", + "request": { + "method": "GET", + "path": "/?action=memo", + "headers": {} + } +} diff --git a/examples/cache/fixtures/proxy-invalid-url.live.json b/examples/cache/fixtures/proxy-invalid-url.live.json new file mode 100644 index 0000000..3042bb0 --- /dev/null +++ b/examples/cache/fixtures/proxy-invalid-url.live.json @@ -0,0 +1,6 @@ +{ + "expected": { + "status": 400, + "bodyContains": "\"error\":\"Invalid url:" + } +} diff --git a/examples/cache/fixtures/proxy-invalid-url.test.json b/examples/cache/fixtures/proxy-invalid-url.test.json new file mode 100644 index 0000000..5d8b9f8 --- /dev/null +++ b/examples/cache/fixtures/proxy-invalid-url.test.json @@ -0,0 +1,9 @@ +{ + "appType": "http-wasm", + "description": "Proxy pattern — invalid url param triggers URL parse error before cache", + "request": { + "method": "GET", + "path": "/?action=proxy&url=not-a-valid-url", + "headers": {} + } +} diff --git a/examples/cache/fixtures/proxy-miss.live.json b/examples/cache/fixtures/proxy-miss.live.json new file mode 100644 index 0000000..878c522 --- /dev/null +++ b/examples/cache/fixtures/proxy-miss.live.json @@ -0,0 +1,6 @@ +{ + "expected": { + "status": 200, + "headers": { "x-cache": "miss" } + } +} diff --git a/examples/cache/fixtures/proxy-miss.test.json b/examples/cache/fixtures/proxy-miss.test.json new file mode 100644 index 0000000..e79cdc0 --- /dev/null +++ b/examples/cache/fixtures/proxy-miss.test.json @@ -0,0 +1,9 @@ +{ + "appType": "http-wasm", + "description": "Proxy pattern — cache miss, fetches upstream and caches response with x-cache: miss", + "request": { + "method": "GET", + "path": "/?action=proxy&url=https://example.com", + "headers": {} + } +} diff --git a/examples/cache/fixtures/rate-limit-allowed.live.json b/examples/cache/fixtures/rate-limit-allowed.live.json new file mode 100644 index 0000000..00538e7 --- /dev/null +++ b/examples/cache/fixtures/rate-limit-allowed.live.json @@ -0,0 +1,6 @@ +{ + "expected": { + "status": 200, + "bodyContains": ["\"pattern\":\"rate-limit\"", "\"windowSeconds\":60"] + } +} diff --git a/examples/cache/fixtures/rate-limit-allowed.test.json b/examples/cache/fixtures/rate-limit-allowed.test.json new file mode 100644 index 0000000..0a13c28 --- /dev/null +++ b/examples/cache/fixtures/rate-limit-allowed.test.json @@ -0,0 +1,9 @@ +{ + "appType": "http-wasm", + "description": "Rate-limit pattern — first request in window, count under limit", + "request": { + "method": "GET", + "path": "/?action=rate-limit", + "headers": {} + } +} diff --git a/examples/cache/fixtures/rate-limit-exceeded.live.json b/examples/cache/fixtures/rate-limit-exceeded.live.json new file mode 100644 index 0000000..7420132 --- /dev/null +++ b/examples/cache/fixtures/rate-limit-exceeded.live.json @@ -0,0 +1,7 @@ +{ + "expected": { + "status": 429, + "headers": { "retry-after": "60" }, + "bodyContains": "\"error\":\"Too Many Requests\"" + } +} diff --git a/examples/cache/fixtures/rate-limit-exceeded.test.json b/examples/cache/fixtures/rate-limit-exceeded.test.json new file mode 100644 index 0000000..803c2b9 --- /dev/null +++ b/examples/cache/fixtures/rate-limit-exceeded.test.json @@ -0,0 +1,9 @@ +{ + "appType": "http-wasm", + "description": "Rate-limit pattern — 429 when count exceeds RATE_LIMIT_MAX (requires 11+ prior calls from same IP in the window)", + "request": { + "method": "GET", + "path": "/?action=rate-limit", + "headers": {} + } +} diff --git a/examples/cache/fixtures/unknown-action.live.json b/examples/cache/fixtures/unknown-action.live.json new file mode 100644 index 0000000..655ce92 --- /dev/null +++ b/examples/cache/fixtures/unknown-action.live.json @@ -0,0 +1,6 @@ +{ + "expected": { + "status": 400, + "json": { "error": "Unknown action: \"bogus\". Use one of: rate-limit, proxy, memo." } + } +} diff --git a/examples/cache/fixtures/unknown-action.test.json b/examples/cache/fixtures/unknown-action.test.json new file mode 100644 index 0000000..0e6674c --- /dev/null +++ b/examples/cache/fixtures/unknown-action.test.json @@ -0,0 +1,9 @@ +{ + "appType": "http-wasm", + "description": "Error path — unknown action returns 400 with usage hint", + "request": { + "method": "GET", + "path": "/?action=bogus", + "headers": {} + } +} diff --git a/examples/crypto-hmac-jwt/fixtures/malformed-token.live.json b/examples/crypto-hmac-jwt/fixtures/malformed-token.live.json new file mode 100644 index 0000000..14bb2b1 --- /dev/null +++ b/examples/crypto-hmac-jwt/fixtures/malformed-token.live.json @@ -0,0 +1,6 @@ +{ + "expected": { + "status": 401, + "json": { "ok": false, "error": "malformed token: expected three segments" } + } +} diff --git a/examples/crypto-hmac-jwt/fixtures/missing-auth.live.json b/examples/crypto-hmac-jwt/fixtures/missing-auth.live.json new file mode 100644 index 0000000..cc5f152 --- /dev/null +++ b/examples/crypto-hmac-jwt/fixtures/missing-auth.live.json @@ -0,0 +1,6 @@ +{ + "expected": { + "status": 401, + "json": { "ok": false, "error": "missing or malformed Authorization header" } + } +} diff --git a/examples/crypto-hmac-jwt/fixtures/valid-token.live.json b/examples/crypto-hmac-jwt/fixtures/valid-token.live.json new file mode 100644 index 0000000..6c0857a --- /dev/null +++ b/examples/crypto-hmac-jwt/fixtures/valid-token.live.json @@ -0,0 +1,9 @@ +{ + "expected": { + "status": 200, + "json": { + "ok": true, + "claims": { "sub": "test-user", "name": "Test User", "exp": 9999999999 } + } + } +} diff --git a/examples/crypto-hmac-jwt/fixtures/valid-token.test.json b/examples/crypto-hmac-jwt/fixtures/valid-token.test.json index caa0505..22d82d0 100644 --- a/examples/crypto-hmac-jwt/fixtures/valid-token.test.json +++ b/examples/crypto-hmac-jwt/fixtures/valid-token.test.json @@ -5,7 +5,7 @@ "method": "GET", "path": "/", "headers": { - "authorization": "Bearer REPLACE_WITH_HS256_JWT_SIGNED_BY_JWT_SECRET" + "authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LXVzZXIiLCJuYW1lIjoiVGVzdCBVc2VyIiwiZXhwIjo5OTk5OTk5OTk5fQ.VOPl0Bp4ABHVVMxbIcMa0uDuXrIVcRLNB0n83nTdWHI" } }, "dotenv": { diff --git a/examples/geo-redirect/README.md b/examples/geo-redirect/README.md index 3148f15..0e69ce4 100644 --- a/examples/geo-redirect/README.md +++ b/examples/geo-redirect/README.md @@ -2,13 +2,61 @@ # Geo Redirect -This application does a simple redirect based on the clients location. +Redirect incoming requests to a country-specific origin URL based on the client's location. +Uses `getEnv` to read origin URLs from environment variables and `Response.redirect` to issue a +302 redirect. -It takes a `BASE_ORIGIN` url, which is where landing clients will be redirected to by default. +## How it works -However for each additional environment_variable that is a valid country-code, it will redirect to -its value. +- `BASE_ORIGIN` (required) — the default redirect destination for all clients +- Any additional env var named with a two-letter country code (e.g. `DE`, `US`) overrides the + destination for clients from that country +- The country code comes from the `geoip-country-code` request header, which FastEdge sets + automatically from the client's IP -e.g. +``` +GET / (client from DE) → 302 Location: https://de.example.com/ +GET / (client from FR) → 302 Location: https://default.example.com/ (no FR env var) +GET / (BASE_ORIGIN unset) → 500 BASE_ORIGIN environment variable is not set +``` + +## APIs used + +- `getEnv(name)` from `fastedge::env` — reads environment variables set on the deployed app +- `Response.redirect(url, status)` — Web standard, returns a redirect response +- `request.headers.get('geoip-country-code')` — FastEdge-injected geo header + +## Environment variables + +| Variable | Required | Description | +| ------------- | -------- | ----------------------------------------------- | +| `BASE_ORIGIN` | Yes | Default redirect URL (e.g. `https://example.com/`) | +| `` | No | Per-country URL, where `` is a 2-letter ISO country code (e.g. `DE`, `US`, `GB`) | + +Example configuration: ![env_vars](images/env-vars.png) + +## Build + +```sh +npm run build +``` + +Output: `dist/geo-redirect.wasm` + +## Testing locally + +```sh +# Simulates a client from Germany (FastEdge injects geoip-country-code in production) +curl -H "geoip-country-code: DE" http://localhost:8080/ + +# No country header — falls back to BASE_ORIGIN +curl http://localhost:8080/ +``` + +Run the local dev server first: + +```sh +fastedge-run http -w dist/geo-redirect.wasm --env BASE_ORIGIN=https://default.example.com/ --env DE=https://de.example.com/ --port 8080 +``` diff --git a/examples/geo-redirect/fixtures/fallback.live.json b/examples/geo-redirect/fixtures/fallback.live.json new file mode 100644 index 0000000..92200ef --- /dev/null +++ b/examples/geo-redirect/fixtures/fallback.live.json @@ -0,0 +1,8 @@ +{ + "expected": { + "status": 302, + "headers": { + "location": { "contains": "https://default.example.com/" } + } + } +} diff --git a/examples/geo-redirect/fixtures/germany.live.json b/examples/geo-redirect/fixtures/germany.live.json new file mode 100644 index 0000000..cd4d426 --- /dev/null +++ b/examples/geo-redirect/fixtures/germany.live.json @@ -0,0 +1,8 @@ +{ + "expected": { + "status": 302, + "headers": { + "location": { "contains": "https://de.example.com/" } + } + } +} diff --git a/examples/geo-redirect/fixtures/missing-config.live.json b/examples/geo-redirect/fixtures/missing-config.live.json new file mode 100644 index 0000000..0b84313 --- /dev/null +++ b/examples/geo-redirect/fixtures/missing-config.live.json @@ -0,0 +1,6 @@ +{ + "expected": { + "status": 500, + "body": "BASE_ORIGIN environment variable is not set" + } +} diff --git a/examples/geo-redirect/fixtures/missing-config/.env b/examples/geo-redirect/fixtures/missing-config/.env new file mode 100644 index 0000000..ecc52f3 --- /dev/null +++ b/examples/geo-redirect/fixtures/missing-config/.env @@ -0,0 +1,3 @@ +# Variant env for missing-config scenario — intentionally empty. +# Used with: live-test --from fixtures/missing-config/ +# Exercises the unset-BASE_ORIGIN branch on the deployed app. diff --git a/examples/geo-redirect/fixtures/us.live.json b/examples/geo-redirect/fixtures/us.live.json new file mode 100644 index 0000000..4cabf57 --- /dev/null +++ b/examples/geo-redirect/fixtures/us.live.json @@ -0,0 +1,8 @@ +{ + "expected": { + "status": 302, + "headers": { + "location": { "contains": "https://us.example.com/" } + } + } +} diff --git a/examples/headers/fixtures/happy-path.live.json b/examples/headers/fixtures/happy-path.live.json new file mode 100644 index 0000000..06e4655 --- /dev/null +++ b/examples/headers/fixtures/happy-path.live.json @@ -0,0 +1,9 @@ +{ + "expected": { + "status": 200, + "body": "Returned all headers with a custom header added", + "headers": { + "my-custom-header": "hello-from-env" + } + } +} diff --git a/examples/hello-world/fixtures/happy-path.live.json b/examples/hello-world/fixtures/happy-path.live.json new file mode 100644 index 0000000..770b441 --- /dev/null +++ b/examples/hello-world/fixtures/happy-path.live.json @@ -0,0 +1,6 @@ +{ + "expected": { + "status": 200, + "bodyContains": "Hello, you made a request to" + } +} diff --git a/examples/kv-store-basic/README.md b/examples/kv-store-basic/README.md index 9e5c237..8a45310 100644 --- a/examples/kv-store-basic/README.md +++ b/examples/kv-store-basic/README.md @@ -5,3 +5,34 @@ The simplest KV Store example — open a named store and get a value by key. For a more complete example demonstrating all KV operations (get, scan, zrange, zscan, bfExists), see [kv-store](../kv-store/). + +## How it works + +Opens a KV store (named at deploy time via the app's store binding), reads the entry at a hardcoded +key, and returns its text value. Returns `404` if the key is not found. + +``` +GET / → 200 "The KV Store responded with: " +GET / → 404 "Key not found" (key absent from store) +GET / → 500 { "error": "..." } (store not bound or host error) +``` + +## APIs used + +- `KvStore` from `fastedge::kv` — opens a named KV store bound to the app +- `store.getEntry(key)` — reads a single entry; returns `null` on miss +- `entry.text()` — decodes the stored bytes as a UTF-8 string + +## Store binding + +The store name in the source (`'kv-store-name-as-defined-on-app'`) must match the name of a KV +store you have created and bound to your FastEdge app in the Gcore portal. Replace this string with +your actual store name before deploying. + +## Build + +```sh +npm run build +``` + +Output: `dist/kv-store-basic.wasm` diff --git a/examples/kv-store-basic/fixtures/happy-path.live.json b/examples/kv-store-basic/fixtures/happy-path.live.json new file mode 100644 index 0000000..003bcad --- /dev/null +++ b/examples/kv-store-basic/fixtures/happy-path.live.json @@ -0,0 +1,6 @@ +{ + "expected": { + "status": 200, + "bodyContains": "The KV Store responded with:" + } +} diff --git a/examples/kv-store/README.md b/examples/kv-store/README.md index d91dc0b..3e26ea8 100644 --- a/examples/kv-store/README.md +++ b/examples/kv-store/README.md @@ -2,20 +2,64 @@ # KV Store -This application uses query parameters to interact with a KV Store. +Demonstrates all five KV Store read operations via HTTP query parameters: `get`, `scan`, `zrange`, +`zscan`, and `bfExists`. All responses are `application/json`. -It will respond with the data collected as `application/json` +For the simplest possible KV example, see [kv-store-basic](../kv-store-basic/). ## Query Parameters -`store` - the name of the store you wish to open. This is the name given to a store on the application. +| Parameter | Required for | Description | +| --------- | ------------------------- | ------------------------------------------------------------------- | +| `store` | all actions | Name of the KV store bound to the app | +| `action` | all (default: `get`) | One of: `get`, `scan`, `zrange`, `zscan`, `bfExists` | +| `key` | `get`, `zrange`, `zscan`, `bfExists` | Key to look up in the store | +| `match` | `scan`, `zscan` | Prefix match pattern, must include a wildcard (e.g. `foo*`) | +| `min`/`max` | `zrange` | Score range bounds (numeric) | +| `item` | `bfExists` | Item to test for existence in the Bloom filter at `key` | -`action` - What you wish to perform. Options are "get", "scan", "zscan", "zrange", "bfExists". ( If no action is provided it will default to "get" ) +## KV Store API reference -`key` - The key you wish to access in the KV Store. +| Method | Returns | Description | +| --------------------------------- | --------------------- | ------------------------------------------------------------ | +| `KvStore.open(name)` | `KvStore` | Opens the named store bound to the app | +| `store.get(key)` | `ArrayBuffer \| null` | Reads raw bytes at `key`; `null` on miss | +| `store.scan(match)` | `string[]` | Lists all keys matching the glob pattern | +| `store.zrangeByScore(key, min, max)` | `[value, score][]` | Returns sorted-set members in the score range `[min, max]` | +| `store.zscan(key, match)` | `[value, score][]` | Returns sorted-set members whose values match the pattern | +| `store.bfExists(key, item)` | `boolean` | Tests whether `item` is probably in the Bloom filter at `key`| -`match` - A prefix match pattern, used by "scan" and "zscan". Must include a wildcard. e.g. `foo*` +## Build -`min` / `max` - Used by zrange for defining the range of scores you wish to receive results for. +```sh +npm run build +``` -`item` - Used by Bloom Filter exists function. +Uses `fastedge-build -c` (config-driven build). Output is defined in `.fastedge/build-config.js`. + +## Example requests + +```sh +# Get a value by key +curl "https://.fastedge.app/?store=my-store&action=get&key=hello" + +# Scan all keys with prefix "user:" +curl "https://.fastedge.app/?store=my-store&action=scan&match=user:*" + +# Range query on a sorted set (scores 0–100) +curl "https://.fastedge.app/?store=my-store&action=zrange&key=leaderboard&min=0&max=100" + +# Bloom filter membership test +curl "https://.fastedge.app/?store=my-store&action=bfExists&key=denylist&item=192.168.1.1" +``` + +Example response: + +```json +{ + "Store": "my-store", + "Action": "get", + "Key": "hello", + "Response": "world" +} +``` diff --git a/examples/kv-store/fixtures/missing-params.live.json b/examples/kv-store/fixtures/missing-params.live.json new file mode 100644 index 0000000..1c686cb --- /dev/null +++ b/examples/kv-store/fixtures/missing-params.live.json @@ -0,0 +1,6 @@ +{ + "expected": { + "status": 500, + "json": { "error": "Query parameters must provide 'store' for a 'get' action." } + } +} diff --git a/examples/mcp-server/README.md b/examples/mcp-server/README.md index 8bfceeb..2b880b2 100644 --- a/examples/mcp-server/README.md +++ b/examples/mcp-server/README.md @@ -158,11 +158,14 @@ it: ```ts // Register weather tools -server.tool( - 'get_alerts', - 'Get weather alerts for a state', +server.registerTool( + 'get-alerts', { - state: z.string().length(2).describe('Two-letter state code (e.g. CA, NY)'), + title: 'Get Weather Alerts', + description: 'Get weather alerts for a US state', + inputSchema: z.object({ + state: z.string().length(2).describe('Two-letter state code (e.g. CA, NY)'), + }), }, async ({ state }) => { const stateCode = state.toUpperCase(); @@ -206,12 +209,15 @@ server.tool( }, ); -server.tool( - 'get_forecast', - 'Get weather forecast for a location', +server.registerTool( + 'get-forecast', { - latitude: z.number().min(-90).max(90).describe('Latitude of the location'), - longitude: z.number().min(-180).max(180).describe('Longitude of the location'), + title: 'Get Weather Forecast', + description: 'Get weather forecast for a location', + inputSchema: z.object({ + latitude: z.number().min(-90).max(90).describe('Latitude of the location'), + longitude: z.number().min(-180).max(180).describe('Longitude of the location'), + }), }, async ({ latitude, longitude }) => { // Get grid point data diff --git a/examples/mcp-server/fixtures/initialize.live.json b/examples/mcp-server/fixtures/initialize.live.json new file mode 100644 index 0000000..f96aa5e --- /dev/null +++ b/examples/mcp-server/fixtures/initialize.live.json @@ -0,0 +1,6 @@ +{ + "expected": { + "status": 200, + "bodyContains": ["\"jsonrpc\":\"2.0\"", "\"protocolVersion\""] + } +} diff --git a/examples/mcp-server/fixtures/tools-list.live.json b/examples/mcp-server/fixtures/tools-list.live.json new file mode 100644 index 0000000..32b090a --- /dev/null +++ b/examples/mcp-server/fixtures/tools-list.live.json @@ -0,0 +1,6 @@ +{ + "expected": { + "status": 200, + "bodyContains": ["\"tools\"", "get-alerts", "get-forecast"] + } +} diff --git a/examples/outbound-fetch/README.md b/examples/outbound-fetch/README.md index 6dc81a3..f988c0e 100644 --- a/examples/outbound-fetch/README.md +++ b/examples/outbound-fetch/README.md @@ -2,4 +2,40 @@ # Outbound Fetch -Fetch data from an outbound HTTP origin and return the response directly. +Make an outbound HTTP request from a FastEdge worker and stream the response back to the caller. +Demonstrates that the standard Web `fetch()` API works inside FastEdge — no special SDK import +needed. + +## How it works + +Every incoming request triggers an outbound `fetch` to the +[JSONPlaceholder](https://jsonplaceholder.typicode.com) `/users` endpoint. The upstream response +is returned directly — status, headers, and body are streamed through unchanged. + +``` +GET / → 200 (upstream response from jsonplaceholder.typicode.com/users) +``` + +## APIs used + +- `fetch(url)` — Web standard, available globally in FastEdge workers + +## Build + +```sh +npm run build +``` + +Output: `dist/outbound-fetch.wasm` + +## Expected output + +```json +[ + { "id": 1, "name": "Leanne Graham", "username": "Bret", "email": "Sincere@april.biz", ... }, + { "id": 2, "name": "Ervin Howell", "username": "Antonette", ... }, + ... +] +``` + +The response is a JSON array of 10 user objects from JSONPlaceholder. diff --git a/examples/outbound-fetch/fixtures/happy-path.live.json b/examples/outbound-fetch/fixtures/happy-path.live.json new file mode 100644 index 0000000..58cae15 --- /dev/null +++ b/examples/outbound-fetch/fixtures/happy-path.live.json @@ -0,0 +1,6 @@ +{ + "expected": { + "status": 200, + "bodyContains": "\"username\":" + } +} diff --git a/examples/outbound-modify-response/README.md b/examples/outbound-modify-response/README.md index b21c8ce..1a92b19 100644 --- a/examples/outbound-modify-response/README.md +++ b/examples/outbound-modify-response/README.md @@ -2,4 +2,47 @@ # Outbound Modify Response -Fetch data from an outbound origin, transform the JSON response (slice to first 5 users), and return it with custom headers. +Fetch data from an upstream origin, transform the JSON body, and return a new response with custom +headers. Shows how to consume an outbound response as JSON and reshape it before sending it to the +caller. + +## How it works + +Fetches the full user list from [JSONPlaceholder](https://jsonplaceholder.typicode.com) (`/users`), +slices it to the first 5 entries, wraps it in a pagination envelope, and returns it as +`application/json`. + +``` +GET / → 200 application/json (first 5 users + pagination metadata) +``` + +## APIs used + +- `fetch(url)` — Web standard, outbound HTTP request +- `response.json()` — reads and parses the upstream response body +- `new Response(body, { headers })` — constructs the modified response + +## Build + +```sh +npm run build +``` + +Output: `dist/outbound-modify-response.wasm` + +## Expected output + +```json +{ + "users": [ + { "id": 1, "name": "Leanne Graham", "username": "Bret", "email": "Sincere@april.biz", ... }, + { "id": 2, "name": "Ervin Howell", "username": "Antonette", ... }, + { "id": 3, ... }, + { "id": 4, ... }, + { "id": 5, ... } + ], + "total": 5, + "skip": 0, + "limit": 30 +} +``` diff --git a/examples/outbound-modify-response/fixtures/happy-path.live.json b/examples/outbound-modify-response/fixtures/happy-path.live.json new file mode 100644 index 0000000..d2fc2af --- /dev/null +++ b/examples/outbound-modify-response/fixtures/happy-path.live.json @@ -0,0 +1,7 @@ +{ + "expected": { + "status": 200, + "headers": { "content-type": "application/json" }, + "bodyContains": ["\"total\":5", "\"skip\":0", "\"limit\":30"] + } +} diff --git a/examples/react-with-hono-server/fastedge-server/config/asset-manifest.ts b/examples/react-with-hono-server/fastedge-server/config/asset-manifest.ts index 8ffd044..2796242 100644 --- a/examples/react-with-hono-server/fastedge-server/config/asset-manifest.ts +++ b/examples/react-with-hono-server/fastedge-server/config/asset-manifest.ts @@ -5,11 +5,11 @@ */ const staticAssetManifest = { - '/assets/index-COcDBgFa.css': { assetKey: '/assets/index-COcDBgFa.css', contentType: 'text/css', isText: true, fileInfo: { size: 1381, hash: '053fffbd3cb2f092a85d67a83459e078b9fe405f2da931b9d21d03d6a853bf34', lastModifiedTime: 1776092001, assetPath: './dist/assets/index-COcDBgFa.css' }, lastModifiedTime: 1776092001, type: 'wasm-inline' }, - '/assets/index-DjqI1cle.js': { assetKey: '/assets/index-DjqI1cle.js', contentType: 'application/javascript', isText: true, fileInfo: { size: 195714, hash: '7b7102e112107e13c808d2d82ce9df83bcb9199808112cd2bbf4b35048e17aaa', lastModifiedTime: 1776092001, assetPath: './dist/assets/index-DjqI1cle.js' }, lastModifiedTime: 1776092001, type: 'wasm-inline' }, - '/assets/react-CHdo91hT.svg': { assetKey: '/assets/react-CHdo91hT.svg', contentType: 'image/svg+xml', isText: true, fileInfo: { size: 4126, hash: '35ef61ed53b323ae94a16a8ec659b3d0af3880698791133f23b084085ab1c2e5', lastModifiedTime: 1776092001, assetPath: './dist/assets/react-CHdo91hT.svg' }, lastModifiedTime: 1776092001, type: 'wasm-inline' }, - '/index.html': { assetKey: '/index.html', contentType: 'text/html', isText: true, fileInfo: { size: 463, hash: '96073e01ae8aa8a822a0c5960219ede99df8839186cc3598da3dad7a46b134ee', lastModifiedTime: 1776092001, assetPath: './dist/index.html' }, lastModifiedTime: 1776092001, type: 'wasm-inline' }, - '/vite.svg': { assetKey: '/vite.svg', contentType: 'image/svg+xml', isText: true, fileInfo: { size: 1497, hash: '4a748afd443918bb16591c834c401dae33e87861ab5dbad0811c3a3b4a9214fb', lastModifiedTime: 1776092001, assetPath: './dist/vite.svg' }, lastModifiedTime: 1776092001, type: 'wasm-inline' }, + '/assets/index-COcDBgFa.css': { assetKey: '/assets/index-COcDBgFa.css', contentType: 'text/css', isText: true, fileInfo: { size: 1381, hash: '053fffbd3cb2f092a85d67a83459e078b9fe405f2da931b9d21d03d6a853bf34', lastModifiedTime: 1779197809, assetPath: './dist/assets/index-COcDBgFa.css' }, lastModifiedTime: 1779197809, type: 'wasm-inline' }, + '/assets/index-DjqI1cle.js': { assetKey: '/assets/index-DjqI1cle.js', contentType: 'application/javascript', isText: true, fileInfo: { size: 195714, hash: '7b7102e112107e13c808d2d82ce9df83bcb9199808112cd2bbf4b35048e17aaa', lastModifiedTime: 1779197809, assetPath: './dist/assets/index-DjqI1cle.js' }, lastModifiedTime: 1779197809, type: 'wasm-inline' }, + '/assets/react-CHdo91hT.svg': { assetKey: '/assets/react-CHdo91hT.svg', contentType: 'image/svg+xml', isText: true, fileInfo: { size: 4126, hash: '35ef61ed53b323ae94a16a8ec659b3d0af3880698791133f23b084085ab1c2e5', lastModifiedTime: 1779197809, assetPath: './dist/assets/react-CHdo91hT.svg' }, lastModifiedTime: 1779197809, type: 'wasm-inline' }, + '/index.html': { assetKey: '/index.html', contentType: 'text/html', isText: true, fileInfo: { size: 463, hash: '96073e01ae8aa8a822a0c5960219ede99df8839186cc3598da3dad7a46b134ee', lastModifiedTime: 1779197809, assetPath: './dist/index.html' }, lastModifiedTime: 1779197809, type: 'wasm-inline' }, + '/vite.svg': { assetKey: '/vite.svg', contentType: 'image/svg+xml', isText: true, fileInfo: { size: 1497, hash: '4a748afd443918bb16591c834c401dae33e87861ab5dbad0811c3a3b4a9214fb', lastModifiedTime: 1779197809, assetPath: './dist/vite.svg' }, lastModifiedTime: 1779197809, type: 'wasm-inline' }, }; export { staticAssetManifest }; diff --git a/examples/react-with-hono-server/fixtures/api-hello.live.json b/examples/react-with-hono-server/fixtures/api-hello.live.json new file mode 100644 index 0000000..a84bc1e --- /dev/null +++ b/examples/react-with-hono-server/fixtures/api-hello.live.json @@ -0,0 +1,6 @@ +{ + "expected": { + "status": 200, + "json": { "message": "Hello from API!" } + } +} diff --git a/examples/react-with-hono-server/fixtures/api-users-post.live.json b/examples/react-with-hono-server/fixtures/api-users-post.live.json new file mode 100644 index 0000000..23cbb08 --- /dev/null +++ b/examples/react-with-hono-server/fixtures/api-users-post.live.json @@ -0,0 +1,6 @@ +{ + "expected": { + "status": 201, + "json": { "success": true, "data": { "name": "alice", "email": "alice@example.com" } } + } +} diff --git a/examples/react-with-hono-server/fixtures/home.live.json b/examples/react-with-hono-server/fixtures/home.live.json new file mode 100644 index 0000000..a341428 --- /dev/null +++ b/examples/react-with-hono-server/fixtures/home.live.json @@ -0,0 +1,6 @@ +{ + "expected": { + "status": 200, + "bodyContains": ".fastedge.app/ + +# Query a specific slot by index +curl -H "x-slot: 0" https://.fastedge.app/ + +# Query a different secret at a specific slot +curl -H "x-secret-name: SIGNING_KEY" -H "x-slot: 1741790697" https://.fastedge.app/ + +# Invalid slot value → 400 +curl -H "x-slot: -1" https://.fastedge.app/ +``` + +Example response: + +```json +{ + "secret_name": "TOKEN_SECRET", + "slot": 0, + "current": "new_password", + "effective_at_slot": "original_password", + "is_same": false +} +``` + +`is_same: true` means the secret has not been rotated since the queried slot — the current value and +the historical value are the same. diff --git a/examples/secret-rotation/fixtures/custom-secret.live.json b/examples/secret-rotation/fixtures/custom-secret.live.json new file mode 100644 index 0000000..7c17269 --- /dev/null +++ b/examples/secret-rotation/fixtures/custom-secret.live.json @@ -0,0 +1,9 @@ +{ + "expected": { + "status": 200, + "bodyContains": [ + "\"secret_name\":\"SIGNING_KEY\"", + "\"slot\":1741790697" + ] + } +} diff --git a/examples/secret-rotation/fixtures/default.live.json b/examples/secret-rotation/fixtures/default.live.json new file mode 100644 index 0000000..5c81a52 --- /dev/null +++ b/examples/secret-rotation/fixtures/default.live.json @@ -0,0 +1,9 @@ +{ + "expected": { + "status": 200, + "bodyContains": [ + "\"current\":\"rotating-test-secret-abc123\"", + "\"is_same\":true" + ] + } +} diff --git a/examples/secret-rotation/fixtures/specific-slot.live.json b/examples/secret-rotation/fixtures/specific-slot.live.json new file mode 100644 index 0000000..1e367cb --- /dev/null +++ b/examples/secret-rotation/fixtures/specific-slot.live.json @@ -0,0 +1,9 @@ +{ + "expected": { + "status": 200, + "bodyContains": [ + "\"slot\":0", + "\"secret_name\":\"TOKEN_SECRET\"" + ] + } +} diff --git a/examples/static-assets/fixtures/css.live.json b/examples/static-assets/fixtures/css.live.json new file mode 100644 index 0000000..7d6dcc1 --- /dev/null +++ b/examples/static-assets/fixtures/css.live.json @@ -0,0 +1,6 @@ +{ + "expected": { + "status": 200, + "headers": { "content-type": { "contains": "text/css" } } + } +} diff --git a/examples/static-assets/fixtures/home.live.json b/examples/static-assets/fixtures/home.live.json new file mode 100644 index 0000000..c942fba --- /dev/null +++ b/examples/static-assets/fixtures/home.live.json @@ -0,0 +1,6 @@ +{ + "expected": { + "status": 200, + "bodyContains": ["Home Page", "Basic HTML rendering"] + } +} diff --git a/examples/static-assets/fixtures/image.live.json b/examples/static-assets/fixtures/image.live.json new file mode 100644 index 0000000..b3beb1e --- /dev/null +++ b/examples/static-assets/fixtures/image.live.json @@ -0,0 +1,6 @@ +{ + "expected": { + "status": 200, + "headers": { "content-type": "image/png" } + } +} diff --git a/examples/static-assets/fixtures/jsx.live.json b/examples/static-assets/fixtures/jsx.live.json new file mode 100644 index 0000000..0d723bb --- /dev/null +++ b/examples/static-assets/fixtures/jsx.live.json @@ -0,0 +1,6 @@ +{ + "expected": { + "status": 200, + "bodyContains": "World" + } +} diff --git a/examples/static-assets/fixtures/template.live.json b/examples/static-assets/fixtures/template.live.json new file mode 100644 index 0000000..6c9f530 --- /dev/null +++ b/examples/static-assets/fixtures/template.live.json @@ -0,0 +1,6 @@ +{ + "expected": { + "status": 200, + "bodyContains": "" + } +} diff --git a/examples/streaming/fixtures/happy-path.live.json b/examples/streaming/fixtures/happy-path.live.json new file mode 100644 index 0000000..af48e4d --- /dev/null +++ b/examples/streaming/fixtures/happy-path.live.json @@ -0,0 +1,7 @@ +{ + "expected": { + "status": 200, + "headers": { "content-type": "text/plain; charset=utf-8" }, + "bodyContains": ["chunk 0", "chunk 4"] + } +} diff --git a/examples/template-invoice-ab-testing/fixtures/bottle-gloria.live.json b/examples/template-invoice-ab-testing/fixtures/bottle-gloria.live.json new file mode 100644 index 0000000..8aef882 --- /dev/null +++ b/examples/template-invoice-ab-testing/fixtures/bottle-gloria.live.json @@ -0,0 +1,7 @@ +{ + "expected": { + "status": 200, + "headers": { "content-type": "text/html" }, + "bodyContains": ["Homer Simpson", "1729", "355.00"] + } +} diff --git a/examples/template-invoice-ab-testing/fixtures/defaults.live.json b/examples/template-invoice-ab-testing/fixtures/defaults.live.json new file mode 100644 index 0000000..8aef882 --- /dev/null +++ b/examples/template-invoice-ab-testing/fixtures/defaults.live.json @@ -0,0 +1,7 @@ +{ + "expected": { + "status": 200, + "headers": { "content-type": "text/html" }, + "bodyContains": ["Homer Simpson", "1729", "355.00"] + } +} diff --git a/examples/template-invoice-ab-testing/fixtures/hops-exo2.live.json b/examples/template-invoice-ab-testing/fixtures/hops-exo2.live.json new file mode 100644 index 0000000..8aef882 --- /dev/null +++ b/examples/template-invoice-ab-testing/fixtures/hops-exo2.live.json @@ -0,0 +1,7 @@ +{ + "expected": { + "status": 200, + "headers": { "content-type": "text/html" }, + "bodyContains": ["Homer Simpson", "1729", "355.00"] + } +} diff --git a/examples/template-invoice/fixtures/happy-path.live.json b/examples/template-invoice/fixtures/happy-path.live.json new file mode 100644 index 0000000..bfe470b --- /dev/null +++ b/examples/template-invoice/fixtures/happy-path.live.json @@ -0,0 +1,7 @@ +{ + "expected": { + "status": 200, + "headers": { "content-type": "text/html" }, + "bodyContains": ["Homer Simpson", "Invoice #:", "1729", "355.00"] + } +} diff --git a/examples/template-invoice/src/index.js b/examples/template-invoice/src/index.js index c4bd02f..a7f41af 100644 --- a/examples/template-invoice/src/index.js +++ b/examples/template-invoice/src/index.js @@ -54,5 +54,5 @@ async function eventHandler() { } addEventListener('fetch', (event) => { - event.respondWith(eventHandler(event)); + event.respondWith(eventHandler()); }); diff --git a/examples/tls-client-info/README.md b/examples/tls-client-info/README.md deleted file mode 100644 index b1a28db..0000000 --- a/examples/tls-client-info/README.md +++ /dev/null @@ -1,39 +0,0 @@ -[← Back to examples](../README.md) - -# TLS Client Info - -Returns a JSON summary of everything `event.client` exposes about the incoming TLS connection: -the client IP, negotiated protocol, cipher suite, JA3 fingerprint, and the raw client -certificate and ClientHello bytes (reported as byte length plus the first 32 bytes as hex). - -Demonstrates the `ClientInfo` surface available on every `FetchEvent`. - -## Sample response - -```json -{ - "address": "203.0.113.17", - "tlsProtocol": "TLSv1.3", - "tlsCipherOpensslName": "TLS_AES_128_GCM_SHA256", - "tlsJA3MD5": "cd08e31494f9531f560d64c695473da9", - "tlsClientCertificate": { "byteLength": 0, "firstBytesHex": null }, - "tlsClientHello": { "byteLength": 512, "firstBytesHex": "010001fc0303..." } -} -``` - -## Notes - -- TLS fields are only populated on HTTPS requests. Over plain HTTP, `tlsProtocol` and the rest - are empty strings and the byte buffers have `byteLength === 0`. -- `tlsClientCertificate` is non-empty only when mTLS is configured and the client presents a - certificate during the handshake. Parse it as DER with your cert library of choice. -- `tlsJA3MD5` is a client fingerprint derived from the ClientHello (version, ciphers, - extensions, curves, formats). It's commonly used for bot detection — maintain an allowlist or - denylist of known-good fingerprints. - -## Use-case sketches - -- **Bot detection:** Compare `tlsJA3MD5` against a list of known-good browser fingerprints. -- **mTLS authorization:** Require `tlsClientCertificate.byteLength > 0` and validate the - certificate against a trusted CA before allowing access. -- **Protocol enforcement:** Reject requests where `tlsProtocol` isn't `"TLSv1.3"`. diff --git a/examples/tls-client-info/package.json b/examples/tls-client-info/package.json deleted file mode 100644 index a5e8de1..0000000 --- a/examples/tls-client-info/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "fastedge-example-tls-client-info", - "version": "1.0.0", - "description": "FastEdge JS example: inspect TLS client metadata from event.client", - "type": "module", - "scripts": { - "build": "fastedge-build src/index.js dist/tls-client-info.wasm" - }, - "dependencies": { - "@gcoredev/fastedge-sdk-js": "^2.2.2" - } -} diff --git a/examples/tls-client-info/src/index.js b/examples/tls-client-info/src/index.js deleted file mode 100644 index 6cf573c..0000000 --- a/examples/tls-client-info/src/index.js +++ /dev/null @@ -1,37 +0,0 @@ -function bufferPrefixHex(buf, maxBytes = 32) { - if (!buf || buf.byteLength === 0) { - return null; - } - const view = new Uint8Array(buf, 0, Math.min(buf.byteLength, maxBytes)); - let hex = ''; - for (const byte of view) { - hex += byte.toString(16).padStart(2, '0'); - } - return hex; -} - -function describeBuffer(buf) { - return { - byteLength: buf?.byteLength ?? 0, - firstBytesHex: bufferPrefixHex(buf), - }; -} - -function app(event) { - const { client } = event; - - const info = { - address: client.address, - tlsProtocol: client.tlsProtocol, - tlsCipherOpensslName: client.tlsCipherOpensslName, - tlsJA3MD5: client.tlsJA3MD5, - tlsClientCertificate: describeBuffer(client.tlsClientCertificate), - tlsClientHello: describeBuffer(client.tlsClientHello), - }; - - return Response.json(info); -} - -addEventListener('fetch', (event) => { - event.respondWith(app(event)); -}); diff --git a/examples/variables-and-secrets/fixtures/happy-path.live.json b/examples/variables-and-secrets/fixtures/happy-path.live.json new file mode 100644 index 0000000..3a5cd31 --- /dev/null +++ b/examples/variables-and-secrets/fixtures/happy-path.live.json @@ -0,0 +1,6 @@ +{ + "expected": { + "status": 200, + "body": "Username: alice, Password: test-password-12345" + } +} diff --git a/fastedge-plugin-source/generate-docs.sh b/fastedge-plugin-source/generate-docs.sh index 427b1b2..22425bf 100755 --- a/fastedge-plugin-source/generate-docs.sh +++ b/fastedge-plugin-source/generate-docs.sh @@ -276,12 +276,14 @@ PROMPT # the doc. The original is still saved to .failures/ so the prompt can # be tuned later. local stripped - stripped=$(awk '/^# / { found=1 } found' "$tmpfile") + stripped=$(awk '/^#/ { found=1 } found' "$tmpfile") if [ -n "$stripped" ]; then - # Detect preamble: anything before the first '# ' line is preamble. + # Detect preamble: anything before the first line starting with '#' is preamble. + # Matches the prompt's constraint ("first character is #") rather than requiring + # '# ' (hash + space), so a model that emits '#Title' still salvages. local first_heading_line - first_heading_line=$(grep -n -m1 '^# ' "$tmpfile" | cut -d: -f1) + first_heading_line=$(grep -n -m1 '^#' "$tmpfile" | cut -d: -f1) if [ "${first_heading_line:-1}" -gt 1 ]; then local preamble_copy="$failure_dir/${target}.preamble.attempt-${attempt}.$(date +%s).md" cp "$tmpfile" "$preamble_copy" From 2b63060281e440107418285928e0c60795bfa468 Mon Sep 17 00:00:00 2001 From: Gordon Farquharson Date: Tue, 19 May 2026 16:54:46 +0100 Subject: [PATCH 5/9] regenerated docs/ --- docs/BUILD_CLI.md | 28 ++++++++++---------- docs/INDEX.md | 62 ++++++++++++++++++++++---------------------- docs/SDK_API.md | 60 +++++++++++++++++++++++++++--------------- docs/STATIC_SITES.md | 36 ++++++++++++------------- llms.txt | 4 +++ 5 files changed, 106 insertions(+), 84 deletions(-) diff --git a/docs/BUILD_CLI.md b/docs/BUILD_CLI.md index b46ac57..19234cf 100644 --- a/docs/BUILD_CLI.md +++ b/docs/BUILD_CLI.md @@ -27,14 +27,14 @@ npx fastedge-build --version ## Options -| Flag | Alias | Type | Description | -| -------------- | ----- | ------------ | -------------------------------- | -| `--input` | `-i` | `String` | Input JavaScript/TypeScript file | -| `--output` | `-o` | `String` | Output WebAssembly file path | -| `--tsconfig` | `-t` | `String` | Path to tsconfig.json | -| `--config` | `-c` | `String[]` | Path(s) to build config files | -| `--help` | `-h` | `Boolean` | Show help | -| `--version` | `-v` | `Boolean` | Show version | +| Flag | Alias | Type | Description | +| ------------- | ----- | ---------- | -------------------------------- | +| `--input` | `-i` | `String` | Input JavaScript/TypeScript file | +| `--output` | `-o` | `String` | Output WebAssembly file path | +| `--tsconfig` | `-t` | `String` | Path to tsconfig.json | +| `--config` | `-c` | `String[]` | Path(s) to build config files | +| `--help` | `-h` | `Boolean` | Show help | +| `--version` | `-v` | `Boolean` | Show version | ## Build Modes @@ -123,12 +123,12 @@ export { config }; ### BuildConfig Fields -| Field | Type | Required | Description | -| -------------- | ---------------------- | -------- | ---------------------------------------------------- | -| `type` | `'http' \| 'static'` | No | Build type; must be `http` or `static` if provided | -| `entryPoint` | `string` | Yes | Input JavaScript/TypeScript file | -| `wasmOutput` | `string` | Yes | Output WASM file path | -| `tsConfigPath` | `string` | No | Path to tsconfig.json | +| Field | Type | Required | Description | +| -------------- | --------------------- | -------- | -------------------------------------------------- | +| `type` | `'http' \| 'static'` | No | Build type; must be `http` or `static` if provided | +| `entryPoint` | `string` | Yes | Input JavaScript/TypeScript file | +| `wasmOutput` | `string` | Yes | Output WASM file path | +| `tsConfigPath` | `string` | No | Path to tsconfig.json | ### Static-Only Fields diff --git a/docs/INDEX.md b/docs/INDEX.md index 141b841..d0a9e8f 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -9,7 +9,7 @@ WebAssembly components that run across global edge data centers. | Field | Value | | ----------- | --------------------------- | | **npm** | `@gcoredev/fastedge-sdk-js` | -| **Version** | `2.2.2` | +| **Version** | `2.3.0` | | **Node** | `>=22` | | **License** | `Apache-2.0` | @@ -53,10 +53,10 @@ addEventListener('fetch', (event) => { ## Build Types -| Type | Description | CLI | -| ---------- | ----------------------------------- | ----------------------------------------------------- | -| **HTTP** | Standard request handler | `fastedge-build src/index.js output.wasm` | -| **Static** | Serve static files embedded in WASM | `fastedge-build --config .fastedge/build-config.js` | +| Type | Description | CLI | +| ---------- | ----------------------------------- | --------------------------------------------------- | +| **HTTP** | Standard request handler | `fastedge-build src/index.js output.wasm` | +| **Static** | Serve static files embedded in WASM | `fastedge-build --config .fastedge/build-config.js` | ## Runtime APIs @@ -76,16 +76,16 @@ imports are resolved at compile time by the SDK. ### KvStoreInstance Methods -| Method | Signature | Description | -| ---------------------- | --------------------------------------------------------------------------------- | ------------------------------------------------------ | -| `get` | `(key: string): ArrayBuffer \| null` | Retrieve a value by key | -| `getEntry` | `(key: string): Promise` | Retrieve a value as a `KvStoreEntry` | -| `scan` | `(pattern: string): Array` | Retrieve keys matching a prefix pattern (e.g. `foo*`) | -| `zrangeByScore` | `(key: string, min: number, max: number): Array<[ArrayBuffer, number]>` | Retrieve sorted set entries by score range | -| `zrangeByScoreEntries` | `(key: string, min: number, max: number): Promise>` | `zrangeByScore` returning `KvStoreEntry` wrappers | -| `zscan` | `(key: string, pattern: string): Array<[ArrayBuffer, number]>` | Retrieve sorted set entries matching a prefix pattern | -| `zscanEntries` | `(key: string, pattern: string): Promise>` | `zscan` returning `KvStoreEntry` wrappers | -| `bfExists` | `(key: string, value: string): boolean` | Check if a value exists in a Bloom Filter | +| Method | Signature | Description | +| ---------------------- | --------------------------------------------------------------------------------- | ----------------------------------------------------- | +| `get` | `(key: string): ArrayBuffer \| null` | Retrieve a value by key | +| `getEntry` | `(key: string): Promise` | Retrieve a value as a `KvStoreEntry` | +| `scan` | `(pattern: string): Array` | Retrieve keys matching a prefix pattern (e.g. `foo*`) | +| `zrangeByScore` | `(key: string, min: number, max: number): Array<[ArrayBuffer, number]>` | Retrieve sorted set entries by score range | +| `zrangeByScoreEntries` | `(key: string, min: number, max: number): Promise>` | `zrangeByScore` returning `KvStoreEntry` wrappers | +| `zscan` | `(key: string, pattern: string): Array<[ArrayBuffer, number]>` | Retrieve sorted set entries matching a prefix pattern | +| `zscanEntries` | `(key: string, pattern: string): Promise>` | `zscan` returning `KvStoreEntry` wrappers | +| `bfExists` | `(key: string, value: string): boolean` | Check if a value exists in a Bloom Filter | ### KvStoreEntry Methods @@ -127,17 +127,17 @@ async function app(event) { addEventListener('fetch', (event) => event.respondWith(app(event))); ``` -| Method | Signature | Description | -| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -| `get` | `(key: string): Promise` | Get entry or `null` if absent or expired | -| `exists` | `(key: string): Promise` | Check key presence without transferring value | -| `set` | `(key: string, value: CacheValue, options?: WriteOptions): Promise` | Store a value, optionally with expiry | -| `delete` | `(key: string): Promise` | Remove a key; no-op if absent | -| `expire` | `(key: string, options: WriteOptions): Promise` | Update expiry; `true` if key exists, `false` if not | -| `incr` | `(key: string, delta?: number): Promise` | Atomically increment an integer; returns new value | -| `decr` | `(key: string, delta?: number): Promise` | Atomically decrement an integer; returns new value | -| `getOrSet` | `(key: string, populate: () => CacheValue \| Promise, options?: WriteOptions): Promise` | Get entry or populate, cache, and return the result | -| `getOrSet` | `(key: string, populate: () => CacheValue \| null \| Promise, options?: WriteOptions): Promise` | As above; a `null` populator result skips the write and resolves with `null` | +| Method | Signature | Description | +| ---------- | --------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| `get` | `(key: string): Promise` | Get entry or `null` if absent or expired | +| `exists` | `(key: string): Promise` | Check key presence without transferring value | +| `set` | `(key: string, value: CacheValue, options?: WriteOptions): Promise` | Store a value, optionally with expiry | +| `delete` | `(key: string): Promise` | Remove a key; no-op if absent | +| `expire` | `(key: string, options: WriteOptions): Promise` | Update expiry; `true` if key exists, `false` if not | +| `incr` | `(key: string, delta?: number): Promise` | Atomically increment an integer; returns new value | +| `decr` | `(key: string, delta?: number): Promise` | Atomically decrement an integer; returns new value | +| `getOrSet` | `(key: string, populate: () => CacheValue \| Promise, options?: WriteOptions): Promise` | Get entry or populate, cache, and return the result | +| `getOrSet` | `(key: string, populate: () => CacheValue \| null \| Promise, options?: WriteOptions): Promise` | As above; a `null` populator result skips the write and resolves with `null` | ### CacheValue @@ -153,11 +153,11 @@ type CacheValue = string | ArrayBuffer | ArrayBufferView | ReadableStream | Resp Controls how long a cache entry lives. Pass exactly one field. Omit the options bag entirely to store with no expiry. -| Field | Type | Description | -| ----------- | -------- | -------------------------------------------------------------------------------------- | -| `ttl` | `number` | Relative TTL in seconds from now. Mutually exclusive with `ttlMs` and `expiresAt`. | -| `ttlMs` | `number` | Relative TTL in milliseconds from now. Mutually exclusive with `ttl` and `expiresAt`. | -| `expiresAt` | `number` | Absolute expiry, Unix epoch seconds. Mutually exclusive with `ttl` and `ttlMs`. | +| Field | Type | Description | +| ----------- | -------- | ------------------------------------------------------------------------------------- | +| `ttl` | `number` | Relative TTL in seconds from now. Mutually exclusive with `ttlMs` and `expiresAt`. | +| `ttlMs` | `number` | Relative TTL in milliseconds from now. Mutually exclusive with `ttl` and `expiresAt`. | +| `expiresAt` | `number` | Absolute expiry, Unix epoch seconds. Mutually exclusive with `ttl` and `ttlMs`. | ### CacheEntry Methods diff --git a/docs/SDK_API.md b/docs/SDK_API.md index 7eea7da..bf96403 100644 --- a/docs/SDK_API.md +++ b/docs/SDK_API.md @@ -569,16 +569,16 @@ Information about the downstream client that made the request, available as `eve Geographic information about the downstream client, available as `event.client.geo`. Populated when `client.geo` is first accessed. -| Property | Type | Description | -| ------------- | ---------------- | ------------------------------------------------------------------------ | -| `asn` | `string` | Autonomous System Number of the client's network. Empty if unavailable. | -| `latitude` | `number \| null` | Latitude in decimal degrees, or `null` if unavailable. | -| `longitude` | `number \| null` | Longitude in decimal degrees, or `null` if unavailable. | -| `region` | `string` | Region or state code (subdivision). Empty string if unavailable. | -| `continent` | `string` | Continent code (e.g. `"EU"`, `"NA"`). Empty string if unavailable. | +| Property | Type | Description | +| ------------- | ---------------- | ---------------------------------------------------------------------------- | +| `asn` | `string` | Autonomous System Number of the client's network. Empty if unavailable. | +| `latitude` | `number \| null` | Latitude in decimal degrees, or `null` if unavailable. | +| `longitude` | `number \| null` | Longitude in decimal degrees, or `null` if unavailable. | +| `region` | `string` | Region or state code (subdivision). Empty string if unavailable. | +| `continent` | `string` | Continent code (e.g. `"EU"`, `"NA"`). Empty string if unavailable. | | `countryCode` | `string` | ISO 3166-1 alpha-2 country code (e.g. `"PT"`). Empty string if unavailable. | -| `countryName` | `string` | Country name (e.g. `"Portugal"`). Empty string if unavailable. | -| `city` | `string` | City name. Empty string when geo lookup did not resolve a city. | +| `countryName` | `string` | Country name (e.g. `"Portugal"`). Empty string if unavailable. | +| `city` | `string` | City name. Empty string when geo lookup did not resolve a city. | ```javascript /// @@ -594,11 +594,11 @@ addEventListener("fetch", event => { Information about the FastEdge POP server handling the request, available as `event.server`. The `pop` namespace is populated lazily on first access. -| Property | Type | Description | -| --------- | --------- | ------------------------------------------------------------ | -| `address` | `string` | Server-side IP address that received the request. | -| `name` | `string` | Server hostname. | -| `pop` | `PopInfo` | POP location information. Populated lazily on first access. | +| Property | Type | Description | +| --------- | --------- | ----------------------------------------------------------- | +| `address` | `string` | Server-side IP address that received the request. | +| `name` | `string` | Server hostname. | +| `pop` | `PopInfo` | POP location information. Populated lazily on first access. | ### PopInfo @@ -1064,14 +1064,19 @@ Available as `crypto.subtle`. Supported operations: | `sign` | `(algorithm: AlgorithmIdentifier \| EcdsaParams, key: CryptoKey, data: BufferSource) => Promise` | | `verify` | `(algorithm: AlgorithmIdentifier \| EcdsaParams, key: CryptoKey, signature: BufferSource, data: BufferSource) => Promise` | -Supported algorithms: +##### `crypto.subtle` — Supported Operations -| Operation | Algorithms | -| ----------- | ---------------------------------------- | -| `digest` | `SHA-1`, `SHA-256`, `SHA-384`, `SHA-512` | -| `sign` | `HMAC`, `RSASSA-PKCS1-v1_5`, `ECDSA` | -| `verify` | `HMAC`, `RSASSA-PKCS1-v1_5`, `ECDSA` | -| `importKey` | `HMAC`, `RSASSA-PKCS1-v1_5`, `ECDSA` | +| Operation | Supported Algorithms | +| ---------------------------------------------- | ------------------------------------- | +| `digest()` | SHA-1, SHA-256, SHA-384, SHA-512, MD5 | +| `sign()` / `verify()` | RSASSA-PKCS1-v1_5, ECDSA, HMAC | +| `importKey()` | JWK, PKCS#8, SPKI, raw (HMAC) | +| `getRandomValues()` | ✓ | +| `encrypt()` / `decrypt()` | **Not implemented** | +| `generateKey()`, `deriveKey()`, `deriveBits()` | **Not implemented** | +| `exportKey()` | **Not implemented** | + +These operations support JWT verification (HMAC / ECDSA / RSASSA-PKCS1-v1_5), SAML assertion verification (SHA-256 digest + RSASSA-PKCS1-v1_5 + SPKI importKey), and general signature verification workflows. Encryption / key generation / key export are unavailable. `importKey` overloads: @@ -1205,6 +1210,19 @@ new EventTarget(): EventTarget --- +## Unavailable APIs + +These APIs are not implemented on the FastEdge JS runtime (StarlingMonkey, WinterCG-style). There is no Node.js compatibility layer. + +- `node:crypto` — not implemented; not polyfillable (sync Node crypto cannot bridge to async `crypto.subtle`). See the runtime constraints reference for why polyfills don't work. +- `node:fs`, `node:path`, `node:buffer`, `process`, `require` — not implemented +- `WebSocket` — not implemented +- DOM APIs (`document`, `window`, etc.) — not implemented (this is a server-side runtime, not a browser) + +For implementation guidance on what to use instead — particularly for crypto-heavy patterns like SAML — see the runtime constraints reference. + +--- + ## See Also - [BUILD_CLI.md](BUILD_CLI.md) — `fastedge-build` CLI reference diff --git a/docs/STATIC_SITES.md b/docs/STATIC_SITES.md index 0632039..7a1e3ef 100644 --- a/docs/STATIC_SITES.md +++ b/docs/STATIC_SITES.md @@ -83,14 +83,14 @@ export { config }; The following fields apply when `type` is `'static'`. All other `BuildConfig` fields are documented in [BUILD_CLI.md](BUILD_CLI.md). -| Field | Type | Required | Description | -| ------------------- | ------------------------------ | -------- | ------------------------------------------------------------------------ | -| `publicDir` | `string` | Yes | Directory to scan for static files to embed | -| `assetManifestPath` | `string` | Yes | Output path for the generated asset manifest module | -| `contentTypes` | `Array` | No | Custom content-type rules prepended before built-in defaults | -| `ignoreDotFiles` | `boolean` | No | When `true`, excludes files and directories whose names begin with `.` | -| `ignorePaths` | `string[]` | No | Additional paths to exclude from the manifest | -| `ignoreWellKnown` | `boolean` | No | When `true`, excludes the `.well-known/` directory | +| Field | Type | Required | Description | +| ------------------- | ------------------------------ | -------- | ---------------------------------------------------------------------- | +| `publicDir` | `string` | Yes | Directory to scan for static files to embed | +| `assetManifestPath` | `string` | Yes | Output path for the generated asset manifest module | +| `contentTypes` | `Array` | No | Custom content-type rules prepended before built-in defaults | +| `ignoreDotFiles` | `boolean` | No | When `true`, excludes files and directories whose names begin with `.` | +| `ignorePaths` | `string[]` | No | Additional paths to exclude from the manifest | +| `ignoreWellKnown` | `boolean` | No | When `true`, excludes the `.well-known/` directory | ## createStaticServer @@ -105,10 +105,10 @@ Creates a static server that serves assets from an in-memory cache built from `s **Parameters:** -| Parameter | Type | Description | -| --------------------- | ----------------------- | ------------------------------------------------------------------------ | -| `staticAssetManifest` | `StaticAssetManifest` | Manifest generated by `npx fastedge-assets` or `type: 'static'` build | -| `serverConfig` | `Partial` | Server behavior options; all fields are optional | +| Parameter | Type | Description | +| --------------------- | ----------------------- | ---------------------------------------------------------------------- | +| `staticAssetManifest` | `StaticAssetManifest` | Manifest generated by `npx fastedge-assets` or `type: 'static'` build | +| `serverConfig` | `Partial` | Server behavior options; all fields are optional | **Returns:** `StaticServer` @@ -349,12 +349,12 @@ addEventListener('fetch', (event) => event.respondWith(handleRequest(event))); **What changed:** -| Area | v1.x | v2.x | -| ------------------- | --------------------------------------------- | ------------------------------------------- | -| API | `createStaticAssetsCache` + `getStaticServer` | `createStaticServer` | -| Multiple manifests | Not supported | Supported — one server per manifest | -| Read file as string | Not available | `server.readFileString(path)` | -| Manifest file name | `static-server-manifest.js` | `static-asset-manifest.js` (by convention) | +| Area | v1.x | v2.x | +| ------------------- | --------------------------------------------- | ------------------------------------------ | +| API | `createStaticAssetsCache` + `getStaticServer` | `createStaticServer` | +| Multiple manifests | Not supported | Supported — one server per manifest | +| Read file as string | Not available | `server.readFileString(path)` | +| Manifest file name | `static-server-manifest.js` | `static-asset-manifest.js` (by convention) | If you used `fastedge-init` to scaffold your project, re-running `npx fastedge-init` updates the generated `static-index.js` entry point automatically. diff --git a/llms.txt b/llms.txt index 4255f42..172491b 100644 --- a/llms.txt +++ b/llms.txt @@ -7,7 +7,11 @@ - [FastEdge JS SDK Documentation](docs/INDEX.md) - [Quickstart](docs/quickstart.md) - [fastedge-assets CLI](docs/ASSETS_CLI.md) +- [](docs/AUTH_PATTERNS.md) - [fastedge-build CLI](docs/BUILD_CLI.md) +- [](docs/HONO_PATTERNS.md) - [fastedge-init CLI](docs/INIT_CLI.md) +- [](docs/PROXY_PATTERNS.md) +- [](docs/RUNTIME_CONSTRAINTS.md) - [SDK API Reference](docs/SDK_API.md) - [Static Sites](docs/STATIC_SITES.md) From 43d3184e03e5e64fd957f2911f7732cbfc497918 Mon Sep 17 00:00:00 2001 From: Gordon Farquharson Date: Tue, 19 May 2026 17:29:33 +0100 Subject: [PATCH 6/9] copilot --- .github/copilot-instructions.md | 34 +++- docs/PROXY_PATTERNS.md | 2 +- fastedge-plugin-source/generate-docs.sh | 6 +- fastedge-plugin-source/generate-llms-txt.sh | 49 +++--- fastedge-plugin-source/manifest.json | 176 +++++++++++++++++++- pnpm-lock.yaml | 66 ++++---- 6 files changed, 257 insertions(+), 76 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a57ec8e..2cec304 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -23,16 +23,34 @@ The public API surface is defined by: Changes to these surfaces require updated `docs/`, updated tests, and a semver-appropriate version bump. -## Generated Content — `docs/` +## Content in `docs/` -Files in `docs/` are **machine-generated** from source code by `./fastedge-plugin-source/generate-docs.sh`. They must not be edited by hand — manual changes will be silently overwritten on the next generation run. +`docs/` contains two distinct types of files with different authoring rules: + +### Machine-generated docs (most files) + +These files are generated from source code by `./fastedge-plugin-source/generate-docs.sh` and **must not be edited by hand** — manual changes will be silently overwritten on the next generation run: + +- `docs/BUILD_CLI.md`, `docs/INIT_CLI.md`, `docs/ASSETS_CLI.md`, `docs/STATIC_SITES.md`, `docs/SDK_API.md` +- `docs/quickstart.md`, `docs/INDEX.md` + +### Hand-curated docs (exception) + +These files contain knowledge and best practices with no single code-source equivalent. They are **authored directly** and are not produced by `generate-docs.sh`: + +- `docs/AUTH_PATTERNS.md` — JWT/HMAC auth patterns, `crypto.subtle` usage guidance +- `docs/HONO_PATTERNS.md` — Hono framework integration patterns for FastEdge +- `docs/PROXY_PATTERNS.md` — Proxy and response transformation patterns +- `docs/RUNTIME_CONSTRAINTS.md` — StarlingMonkey JS runtime capabilities and constraints + +**For hand-curated docs:** Edit them directly. Do not run `generate-docs.sh` for these files — it will not affect them. ### When reviewing PRs that touch `docs/`: -- **Never** suggest manual edits to any file in `docs/` -- If docs are stale or incorrect, suggest: **Run `./fastedge-plugin-source/generate-docs.sh`** -- If the generated output itself is wrong (e.g., wrong structure, missing section), the fix belongs in `fastedge-plugin-source/.generation-config.md`, not in `docs/` directly -- If a PR modifies `docs/` files without a corresponding source code change, flag it — the change should come from the generation script, not a hand-edit +- For **generated** docs: never suggest manual edits. If stale or incorrect, suggest: **Run `./fastedge-plugin-source/generate-docs.sh`** +- For **hand-curated** docs (`AUTH_PATTERNS.md`, `HONO_PATTERNS.md`, `PROXY_PATTERNS.md`, `RUNTIME_CONSTRAINTS.md`): direct edits are correct and expected +- If the generated output itself is wrong (e.g., wrong structure, missing section), the fix belongs in `fastedge-plugin-source/.generation-config.md`, not in the generated `docs/` file directly +- If a PR modifies a **generated** `docs/` file without a corresponding source code change, flag it — the change should come from the generation script, not a hand-edit ### When reviewing PRs that change source code covered by `docs/`: @@ -69,6 +87,10 @@ Files in `docs/` are **machine-generated** from source code by `./fastedge-plugi | `types/globals.d.ts` | `docs/SDK_API.md` | | `package.json` (exports, bin) | `docs/INDEX.md` | | `types/`, `src/cli/`, `README.md` (quickstart examples) | `docs/quickstart.md` | +| hand-curated — JWT/HMAC auth patterns, `crypto.subtle` usage guidance | `docs/AUTH_PATTERNS.md` | +| hand-curated — Hono framework integration patterns for FastEdge | `docs/HONO_PATTERNS.md` | +| hand-curated — proxy and response transformation patterns | `docs/PROXY_PATTERNS.md` | +| hand-curated — StarlingMonkey JS runtime capabilities and constraints | `docs/RUNTIME_CONSTRAINTS.md` | | `fastedge-plugin-source/manifest.json` | `.github/copilot-instructions.md` | ### Violation example diff --git a/docs/PROXY_PATTERNS.md b/docs/PROXY_PATTERNS.md index 55e6e9b..5ef3d69 100644 --- a/docs/PROXY_PATTERNS.md +++ b/docs/PROXY_PATTERNS.md @@ -37,7 +37,7 @@ Modify the response body before returning it. This is the pattern from `examples ```typescript async function handle() { - const upstream = await fetch("http://jsonplaceholder.typicode.com/users"); + const upstream = await fetch("https://jsonplaceholder.typicode.com/users"); const users = await upstream.json(); const transformed = { diff --git a/fastedge-plugin-source/generate-docs.sh b/fastedge-plugin-source/generate-docs.sh index 22425bf..aa3cfae 100755 --- a/fastedge-plugin-source/generate-docs.sh +++ b/fastedge-plugin-source/generate-docs.sh @@ -370,8 +370,10 @@ if [ "$run_all" = true ]; then exit 1 fi - # Regenerate llms.txt from docs/ contents - "$SCRIPT_DIR/generate-llms-txt.sh" + # Regenerate llms.txt from docs/ contents (if the script is installed) + if [ -x "$SCRIPT_DIR/generate-llms-txt.sh" ]; then + "$SCRIPT_DIR/generate-llms-txt.sh" + fi else # Specific files: run sequentially (user chose explicit order) failed=() diff --git a/fastedge-plugin-source/generate-llms-txt.sh b/fastedge-plugin-source/generate-llms-txt.sh index 616dbd3..cd2d853 100755 --- a/fastedge-plugin-source/generate-llms-txt.sh +++ b/fastedge-plugin-source/generate-llms-txt.sh @@ -8,13 +8,17 @@ set -euo pipefail # LLM agents discover and navigate package documentation. # # Usage: -# ./fastedge-plugin-source/generate-llms-txt.sh # standalone -# ./fastedge-plugin-source/generate-docs.sh # calls this automatically after a full run +# ./fastedge-plugin-source/generate-llms-txt.sh +# +# Setup: +# 1. Copy this file to /fastedge-plugin-source/generate-llms-txt.sh +# 2. chmod +x fastedge-plugin-source/generate-llms-txt.sh +# 3. Called automatically by generate-docs.sh after a full generation run # # Requirements: bash 4+, jq (only if package.json is the name source) # No customization needed — package name and docs are discovered at runtime. -# Supports: package.json (Node), Cargo.toml (Rust), pyproject.toml (Python), -# or falls back to the directory name. +# Supports: package.json (Node), Cargo.toml (Rust), or falls back to the +# directory name. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" @@ -33,8 +37,8 @@ if [ ! -f "$DOCS_DIR/INDEX.md" ]; then exit 1 fi -# --- Extract package name (language-agnostic) --- -# Tries in order: package.json (Node), Cargo.toml (Rust), pyproject.toml (Python), dirname fallback +# --- Extract package name --- +# Tries in order: package.json (Node), Cargo.toml (Rust), dirname fallback detect_package_name() { if [ -f "$REPO_ROOT/package.json" ]; then @@ -45,15 +49,12 @@ detect_package_name() { fi if [ -f "$REPO_ROOT/Cargo.toml" ]; then - sed -n '/^\[package\]/,/^\[/{ s/^name *= *"\(.*\)"/\1/p; }' "$REPO_ROOT/Cargo.toml" | head -1 - return - fi - - if [ -f "$REPO_ROOT/pyproject.toml" ]; then - sed -n '/^\[project\]/,/^\[/{ s/^name *= *"\(.*\)"/\1/p; }' "$REPO_ROOT/pyproject.toml" | head -1 + # Extract name from [package] section — skip the header line, stop at next section + awk '/^\[package\]/{found=1; next} /^\[/{found=0} found && /^name *= *"/{gsub(/^name *= *"|"$/, ""); print; exit}' "$REPO_ROOT/Cargo.toml" return fi + # Fallback: directory name basename "$REPO_ROOT" } @@ -83,30 +84,18 @@ fi echo "## Documentation" echo "" - # Curated order: INDEX first (entry point), then quickstart, then rest alphabetically. - # This keeps the most useful docs near the top rather than relying on glob order - # (which puts lowercase filenames like quickstart.md last). - PRIORITY_FILES=("INDEX.md" "quickstart.md") - - for pfile in "${PRIORITY_FILES[@]}"; do - if [ -f "$DOCS_DIR/$pfile" ]; then - heading=$(head -1 "$DOCS_DIR/$pfile" | sed 's/^#\+ //') - [ -z "$heading" ] && heading="${pfile%.md}" - echo "- [$heading](docs/$pfile)" - fi - done + # INDEX.md first — it's the entry point + index_heading=$(head -1 "$DOCS_DIR/INDEX.md" | sed 's/^#\+ //') + echo "- [$index_heading](docs/INDEX.md)" - # Remaining docs alphabetically, skip priority files + # Remaining docs alphabetically, skip INDEX.md for doc in "$DOCS_DIR"/*.md; do filename=$(basename "$doc") - skip=false - for pfile in "${PRIORITY_FILES[@]}"; do - [ "$filename" = "$pfile" ] && skip=true && break - done - [ "$skip" = true ] && continue + [ "$filename" = "INDEX.md" ] && continue heading=$(head -1 "$doc" | sed 's/^#\+ //') if [ -z "$heading" ]; then + # Fallback: use filename without extension heading="${filename%.md}" fi diff --git a/fastedge-plugin-source/manifest.json b/fastedge-plugin-source/manifest.json index 3117cdc..93cb03f 100644 --- a/fastedge-plugin-source/manifest.json +++ b/fastedge-plugin-source/manifest.json @@ -43,7 +43,7 @@ "runtime-constraints": { "files": ["docs/RUNTIME_CONSTRAINTS.md"], "required": true, - "description": "JS runtime constraints — StarlingMonkey, WinterCG capabilities, crypto.subtle support matrix, Node.js polyfill impossibility, SAML library compatibility. Hand-curated research" + "description": "JS runtime constraints — StarlingMonkey, WinterCG capabilities, Node.js polyfill impossibility (sync/async mismatch), SAML library compatibility and viable WebCrypto-native alternatives. Hand-curated research. crypto.subtle algorithm matrix is in the SDK API reference." }, "hello-world-blueprint": { @@ -178,6 +178,123 @@ ], "required": true, "description": "Cache example — docs pattern extraction" + }, + + "cache-basic-blueprint": { + "files": [ + "examples/cache-basic/src/index.js", + "examples/cache-basic/package.json" + ], + "required": false, + "description": "Cache Basic example — scaffold blueprint (atomic per-POP key/value store: set/get/exists/delete)" + }, + "kv-store-basic-blueprint": { + "files": [ + "examples/kv-store-basic/src/index.js", + "examples/kv-store-basic/package.json" + ], + "required": false, + "description": "KV Store Basic example — scaffold blueprint (simple KvStore.open + getEntry pattern)" + }, + "bloom-filter-denylist-blueprint": { + "files": [ + "examples/bloom-filter-denylist/src/index.js", + "examples/bloom-filter-denylist/package.json" + ], + "required": false, + "description": "Bloom Filter Denylist example — scaffold blueprint (KV bloom filter for IP/path blocking)" + }, + "crypto-hmac-jwt-blueprint": { + "files": [ + "examples/crypto-hmac-jwt/src/index.js", + "examples/crypto-hmac-jwt/package.json" + ], + "required": false, + "description": "Crypto HMAC JWT example — scaffold blueprint (HS256 JWT verification via crypto.subtle)" + }, + "secret-rotation-blueprint": { + "files": [ + "examples/secret-rotation/src/index.js", + "examples/secret-rotation/package.json" + ], + "required": false, + "description": "Secret Rotation example — scaffold blueprint (getSecret with slot-based rotation)" + }, + "outbound-modify-response-blueprint": { + "files": [ + "examples/outbound-modify-response/src/index.js", + "examples/outbound-modify-response/package.json" + ], + "required": false, + "description": "Outbound Modify Response example — scaffold blueprint (fetch + JSON transform + header manipulation)" + }, + "streaming-blueprint": { + "files": [ + "examples/streaming/src/index.js", + "examples/streaming/package.json" + ], + "required": false, + "description": "Streaming example — scaffold blueprint (ReadableStream chunked response)" + }, + "request-inspection-blueprint": { + "files": [ + "examples/request-inspection/src/index.js", + "examples/request-inspection/package.json" + ], + "required": false, + "description": "Request Inspection example — scaffold blueprint (method, path, headers, geo introspection)" + }, + "variables-and-secrets-blueprint": { + "files": [ + "examples/variables-and-secrets/src/index.js", + "examples/variables-and-secrets/package.json" + ], + "required": false, + "description": "Variables and Secrets example — scaffold blueprint (getEnv + getSecret usage patterns)" + }, + "template-invoice-blueprint": { + "files": [ + "examples/template-invoice/src/index.js", + "examples/template-invoice/package.json" + ], + "required": false, + "description": "Template Invoice example — scaffold blueprint (Handlebars HTML rendering)" + }, + "template-invoice-ab-testing-blueprint": { + "files": [ + "examples/template-invoice-ab-testing/src/index.js", + "examples/template-invoice-ab-testing/package.json" + ], + "required": false, + "description": "Template Invoice A/B Testing example — scaffold blueprint (cookie-based variant selection + Handlebars rendering)" + }, + "mcp-server-blueprint": { + "files": [ + "examples/mcp-server/src/index.ts", + "examples/mcp-server/src/server.ts", + "examples/mcp-server/package.json", + "examples/mcp-server/tsconfig.json" + ], + "required": false, + "description": "MCP Server example — scaffold blueprint (Model Context Protocol server over FastEdge HTTP)" + }, + "react-with-hono-server-blueprint": { + "files": [ + "examples/react-with-hono-server/fastedge-server/server.ts", + "examples/react-with-hono-server/package.json", + "examples/react-with-hono-server/tsconfig.fastedge.json" + ], + "required": false, + "description": "React with Hono Server example — scaffold blueprint (Hono routing + React SSR on FastEdge)" + }, + "static-assets-blueprint": { + "files": [ + "examples/static-assets/src/index.tsx", + "examples/static-assets/package.json", + "examples/static-assets/tsconfig.json" + ], + "required": false, + "description": "Static Assets example — scaffold blueprint (createStaticServer, JSX pages, asset manifests)" } }, "target_mapping": { @@ -278,6 +395,63 @@ "cache-pattern": { "reference_file": "plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-cache-js.md", "section": null + }, + + "cache-basic-blueprint": { + "reference_file": "plugins/gcore-fastedge/skills/scaffold/reference/http/cache-basic-ts.md", + "section": null + }, + "kv-store-basic-blueprint": { + "reference_file": "plugins/gcore-fastedge/skills/scaffold/reference/http/kv-store-basic-ts.md", + "section": null + }, + "bloom-filter-denylist-blueprint": { + "reference_file": "plugins/gcore-fastedge/skills/scaffold/reference/http/bloom-filter-denylist-ts.md", + "section": null + }, + "crypto-hmac-jwt-blueprint": { + "reference_file": "plugins/gcore-fastedge/skills/scaffold/reference/http/crypto-hmac-jwt-ts.md", + "section": null + }, + "secret-rotation-blueprint": { + "reference_file": "plugins/gcore-fastedge/skills/scaffold/reference/http/secret-rotation-ts.md", + "section": null + }, + "outbound-modify-response-blueprint": { + "reference_file": "plugins/gcore-fastedge/skills/scaffold/reference/http/outbound-modify-response-ts.md", + "section": null + }, + "streaming-blueprint": { + "reference_file": "plugins/gcore-fastedge/skills/scaffold/reference/http/streaming-ts.md", + "section": null + }, + "request-inspection-blueprint": { + "reference_file": "plugins/gcore-fastedge/skills/scaffold/reference/http/request-inspection-ts.md", + "section": null + }, + "variables-and-secrets-blueprint": { + "reference_file": "plugins/gcore-fastedge/skills/scaffold/reference/http/variables-and-secrets-ts.md", + "section": null + }, + "template-invoice-blueprint": { + "reference_file": "plugins/gcore-fastedge/skills/scaffold/reference/http/template-invoice-ts.md", + "section": null + }, + "template-invoice-ab-testing-blueprint": { + "reference_file": "plugins/gcore-fastedge/skills/scaffold/reference/http/template-invoice-ab-testing-ts.md", + "section": null + }, + "mcp-server-blueprint": { + "reference_file": "plugins/gcore-fastedge/skills/scaffold/reference/http/mcp-server-ts.md", + "section": null + }, + "react-with-hono-server-blueprint": { + "reference_file": "plugins/gcore-fastedge/skills/scaffold/reference/http/react-with-hono-server-ts.md", + "section": null + }, + "static-assets-blueprint": { + "reference_file": "plugins/gcore-fastedge/skills/scaffold/reference/http/static-assets-ts.md", + "section": null } }, "validation": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e71ad3f..10d649a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,55 +130,25 @@ importers: specifier: link:../.. version: link:../.. - examples/cache: + examples/bloom-filter-denylist: dependencies: '@gcoredev/fastedge-sdk-js': specifier: link:../.. version: link:../.. - examples/cache-basic: + examples/cache: dependencies: '@gcoredev/fastedge-sdk-js': specifier: link:../.. version: link:../.. - examples/outbound-fetch: + examples/cache-basic: dependencies: '@gcoredev/fastedge-sdk-js': specifier: link:../.. version: link:../.. - examples/outbound-modify-response: - dependencies: - '@gcoredev/fastedge-sdk-js': - specifier: ^2.2.2 - version: 2.2.2 - - examples/bloom-filter-denylist: - dependencies: - '@gcoredev/fastedge-sdk-js': - specifier: ^2.2.2 - version: 2.2.2 - examples/crypto-hmac-jwt: - dependencies: - '@gcoredev/fastedge-sdk-js': - specifier: ^2.2.2 - version: 2.2.2 - - examples/request-inspection: - dependencies: - '@gcoredev/fastedge-sdk-js': - specifier: ^2.2.2 - version: 2.2.2 - - examples/secret-rotation: - dependencies: - '@gcoredev/fastedge-sdk-js': - specifier: ^2.2.2 - version: 2.2.2 - - examples/tls-client-info: dependencies: '@gcoredev/fastedge-sdk-js': specifier: link:../.. @@ -236,6 +206,18 @@ importers: specifier: ^5.9.2 version: 5.9.3 + examples/outbound-fetch: + dependencies: + '@gcoredev/fastedge-sdk-js': + specifier: link:../.. + version: link:../.. + + examples/outbound-modify-response: + dependencies: + '@gcoredev/fastedge-sdk-js': + specifier: link:../.. + version: link:../.. + examples/react-with-hono-server: dependencies: '@gcoredev/fastedge-sdk-js': @@ -300,11 +282,17 @@ importers: specifier: ^7.1.7 version: 7.3.2(@types/node@24.12.2)(terser@5.46.1)(tsx@4.21.0) - examples/streaming: + examples/request-inspection: + dependencies: + '@gcoredev/fastedge-sdk-js': + specifier: link:../.. + version: link:../.. + + examples/secret-rotation: dependencies: '@gcoredev/fastedge-sdk-js': - specifier: ^2.2.2 - version: 2.2.2 + specifier: link:../.. + version: link:../.. examples/static-assets: dependencies: @@ -319,6 +307,12 @@ importers: specifier: ^8.0.4 version: 8.0.4 + examples/streaming: + dependencies: + '@gcoredev/fastedge-sdk-js': + specifier: link:../.. + version: link:../.. + examples/template-invoice: dependencies: '@gcoredev/fastedge-sdk-js': From 38aaa7128fd23bf98272ff03f67f12bd8475502a Mon Sep 17 00:00:00 2001 From: Gordon Farquharson Date: Tue, 19 May 2026 17:42:45 +0100 Subject: [PATCH 7/9] linting --- eslint.config.js | 3 +++ examples/bloom-filter-denylist/src/index.js | 4 ++-- examples/cache/.fastedge/build-config.js | 10 +++++----- examples/crypto-hmac-jwt/src/index.js | 8 ++++---- examples/kv-store/.fastedge/build-config.js | 10 +++++----- examples/request-inspection/src/index.js | 2 +- examples/streaming/src/index.js | 3 ++- 7 files changed, 22 insertions(+), 18 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 9597ade..0521d5a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -127,6 +127,9 @@ export default [ 'no-console': 'off', 'prefer-destructuring': 'off', '@typescript-eslint/no-unused-vars': 'off', + // JSON output keys (e.g. secret_name, is_same) use snake_case to align with + // Rust SDK equivalents — they are response fields, not JS identifiers. + camelcase: ['error', { properties: 'never' }], }, }, diff --git a/examples/bloom-filter-denylist/src/index.js b/examples/bloom-filter-denylist/src/index.js index 304f485..c34a1f6 100644 --- a/examples/bloom-filter-denylist/src/index.js +++ b/examples/bloom-filter-denylist/src/index.js @@ -21,8 +21,8 @@ function app(event) { try { const store = KvStore.open(storeName); blocked = store.bfExists(BLOOM_KEY, ip); - } catch (err) { - return Response.json({ error: `KV lookup failed: ${err.message}` }, { status: 500 }); + } catch (error) { + return Response.json({ error: `KV lookup failed: ${error.message}` }, { status: 500 }); } if (blocked) { diff --git a/examples/cache/.fastedge/build-config.js b/examples/cache/.fastedge/build-config.js index ef0c708..16354bc 100644 --- a/examples/cache/.fastedge/build-config.js +++ b/examples/cache/.fastedge/build-config.js @@ -1,12 +1,12 @@ const config = { - type: "http", - tsConfigPath: "./tsconfig.json", - entryPoint: "src/index.ts", - wasmOutput: "dist/cache.wasm", + type: 'http', + tsConfigPath: './tsconfig.json', + entryPoint: 'src/index.ts', + wasmOutput: 'dist/cache.wasm', }; const serverConfig = { - type: "http", + type: 'http', }; export { config, serverConfig }; diff --git a/examples/crypto-hmac-jwt/src/index.js b/examples/crypto-hmac-jwt/src/index.js index 7e492d8..57ba4a9 100644 --- a/examples/crypto-hmac-jwt/src/index.js +++ b/examples/crypto-hmac-jwt/src/index.js @@ -4,11 +4,11 @@ const encoder = new TextEncoder(); const decoder = new TextDecoder(); function base64urlToBytes(str) { - const padded = str.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (str.length % 4)) % 4); + const padded = str.replace(/-/gu, '+').replace(/_/gu, '/') + '='.repeat((4 - (str.length % 4)) % 4); const binary = atob(padded); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); + bytes[i] = binary.codePointAt(i); } return bytes; } @@ -63,8 +63,8 @@ async function app(event) { try { const claims = await verifyJwtHs256(match[1], secret); return Response.json({ ok: true, claims }); - } catch (err) { - return Response.json({ ok: false, error: err.message }, { status: 401 }); + } catch (error) { + return Response.json({ ok: false, error: error.message }, { status: 401 }); } } diff --git a/examples/kv-store/.fastedge/build-config.js b/examples/kv-store/.fastedge/build-config.js index 042c1a1..35a27e2 100644 --- a/examples/kv-store/.fastedge/build-config.js +++ b/examples/kv-store/.fastedge/build-config.js @@ -1,12 +1,12 @@ const config = { - type: "http", - tsConfigPath: "./tsconfig.json", - entryPoint: "src/index.ts", - wasmOutput: "dist/kv-store.wasm", + type: 'http', + tsConfigPath: './tsconfig.json', + entryPoint: 'src/index.ts', + wasmOutput: 'dist/kv-store.wasm', }; const serverConfig = { - type: "http", + type: 'http', }; export { config, serverConfig }; diff --git a/examples/request-inspection/src/index.js b/examples/request-inspection/src/index.js index abb0ecf..24f1e55 100644 --- a/examples/request-inspection/src/index.js +++ b/examples/request-inspection/src/index.js @@ -11,7 +11,7 @@ function app(event) { lines.push(` ${name}: ${value}`); } - return new Response(lines.join('\n') + '\n', { + return new Response(`${lines.join('\n')}\n`, { headers: { 'content-type': 'text/plain; charset=utf-8' }, }); } diff --git a/examples/streaming/src/index.js b/examples/streaming/src/index.js index f2e04e9..8acb47e 100644 --- a/examples/streaming/src/index.js +++ b/examples/streaming/src/index.js @@ -4,7 +4,8 @@ function app(event) { const stream = new ReadableStream({ async start(controller) { for (let i = 0; i < 5; i++) { - await new Promise((resolve) => setTimeout(resolve, 200)); + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { setTimeout(resolve, 200); }); controller.enqueue(encoder.encode(`chunk ${i}\n`)); } controller.close(); From 988fc153fca5f08c2664636b977a850fa5dbf962 Mon Sep 17 00:00:00 2001 From: Gordon Farquharson Date: Tue, 19 May 2026 17:58:54 +0100 Subject: [PATCH 8/9] copilot reviews --- docs/AUTH_PATTERNS.md | 2 +- docs/SDK_API.md | 3 +-- fastedge-plugin-source/generate-llms-txt.sh | 4 ++-- llms.txt | 10 +++++----- 4 files changed, 9 insertions(+), 10 deletions(-) diff --git a/docs/AUTH_PATTERNS.md b/docs/AUTH_PATTERNS.md index 9d74c4e..a42bb89 100644 --- a/docs/AUTH_PATTERNS.md +++ b/docs/AUTH_PATTERNS.md @@ -149,7 +149,7 @@ The FastEdge JS runtime supports a subset of `crypto.subtle`: | Operation | Algorithms supported | |---|---| -| `digest` | SHA-1, SHA-256, SHA-384, SHA-512, MD5 | +| `digest` | SHA-1, SHA-256, SHA-384, SHA-512 | | `sign` / `verify` | RSASSA-PKCS1-v1_5, ECDSA, HMAC | | `importKey` | JWK, PKCS#8, SPKI, raw (HMAC) | diff --git a/docs/SDK_API.md b/docs/SDK_API.md index bf96403..2c82d65 100644 --- a/docs/SDK_API.md +++ b/docs/SDK_API.md @@ -1068,10 +1068,9 @@ Available as `crypto.subtle`. Supported operations: | Operation | Supported Algorithms | | ---------------------------------------------- | ------------------------------------- | -| `digest()` | SHA-1, SHA-256, SHA-384, SHA-512, MD5 | +| `digest()` | SHA-1, SHA-256, SHA-384, SHA-512 | | `sign()` / `verify()` | RSASSA-PKCS1-v1_5, ECDSA, HMAC | | `importKey()` | JWK, PKCS#8, SPKI, raw (HMAC) | -| `getRandomValues()` | ✓ | | `encrypt()` / `decrypt()` | **Not implemented** | | `generateKey()`, `deriveKey()`, `deriveBits()` | **Not implemented** | | `exportKey()` | **Not implemented** | diff --git a/fastedge-plugin-source/generate-llms-txt.sh b/fastedge-plugin-source/generate-llms-txt.sh index cd2d853..e8f3226 100755 --- a/fastedge-plugin-source/generate-llms-txt.sh +++ b/fastedge-plugin-source/generate-llms-txt.sh @@ -85,7 +85,7 @@ fi echo "" # INDEX.md first — it's the entry point - index_heading=$(head -1 "$DOCS_DIR/INDEX.md" | sed 's/^#\+ //') + index_heading=$(grep -m1 '^#' "$DOCS_DIR/INDEX.md" | sed 's/^#\+ //') echo "- [$index_heading](docs/INDEX.md)" # Remaining docs alphabetically, skip INDEX.md @@ -93,7 +93,7 @@ fi filename=$(basename "$doc") [ "$filename" = "INDEX.md" ] && continue - heading=$(head -1 "$doc" | sed 's/^#\+ //') + heading=$(grep -m1 '^#' "$doc" | sed 's/^#\+ //') if [ -z "$heading" ]; then # Fallback: use filename without extension heading="${filename%.md}" diff --git a/llms.txt b/llms.txt index 172491b..53a6e9b 100644 --- a/llms.txt +++ b/llms.txt @@ -5,13 +5,13 @@ ## Documentation - [FastEdge JS SDK Documentation](docs/INDEX.md) -- [Quickstart](docs/quickstart.md) - [fastedge-assets CLI](docs/ASSETS_CLI.md) -- [](docs/AUTH_PATTERNS.md) +- [Authentication Patterns](docs/AUTH_PATTERNS.md) - [fastedge-build CLI](docs/BUILD_CLI.md) -- [](docs/HONO_PATTERNS.md) +- [Hono Patterns on FastEdge](docs/HONO_PATTERNS.md) - [fastedge-init CLI](docs/INIT_CLI.md) -- [](docs/PROXY_PATTERNS.md) -- [](docs/RUNTIME_CONSTRAINTS.md) +- [Proxy and Response Transform Patterns](docs/PROXY_PATTERNS.md) +- [Quickstart](docs/quickstart.md) +- [FastEdge JS Runtime — Constraints & Compatibility](docs/RUNTIME_CONSTRAINTS.md) - [SDK API Reference](docs/SDK_API.md) - [Static Sites](docs/STATIC_SITES.md) From 7461c4c5e9907b2164ca62ae5489df0bd1e9a8da Mon Sep 17 00:00:00 2001 From: Gordon Farquharson Date: Tue, 19 May 2026 18:50:02 +0100 Subject: [PATCH 9/9] update plugin manifest --- fastedge-plugin-source/manifest.json | 30 +++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/fastedge-plugin-source/manifest.json b/fastedge-plugin-source/manifest.json index 93cb03f..afd3553 100644 --- a/fastedge-plugin-source/manifest.json +++ b/fastedge-plugin-source/manifest.json @@ -280,16 +280,40 @@ }, "react-with-hono-server-blueprint": { "files": [ - "examples/react-with-hono-server/fastedge-server/server.ts", + "examples/react-with-hono-server/index.html", + "examples/react-with-hono-server/vite.config.ts", + "examples/react-with-hono-server/tsconfig.json", + "examples/react-with-hono-server/tsconfig.app.json", + "examples/react-with-hono-server/tsconfig.node.json", + "examples/react-with-hono-server/tsconfig.fastedge.json", "examples/react-with-hono-server/package.json", - "examples/react-with-hono-server/tsconfig.fastedge.json" + "examples/react-with-hono-server/public/vite.svg", + "examples/react-with-hono-server/src/main.tsx", + "examples/react-with-hono-server/src/App.tsx", + "examples/react-with-hono-server/src/App.css", + "examples/react-with-hono-server/src/index.css", + "examples/react-with-hono-server/src/vite-env.d.ts", + "examples/react-with-hono-server/src/utils/api.ts", + "examples/react-with-hono-server/src/assets/react.svg", + "examples/react-with-hono-server/fastedge-server/server.ts", + "examples/react-with-hono-server/fastedge-server/config/server.config.ts", + "examples/react-with-hono-server/fastedge-server/config/build-config.ts", + "examples/react-with-hono-server/fastedge-server/config/asset-manifest.ts", + "examples/react-with-hono-server/fastedge-server/api/routes.ts", + "examples/react-with-hono-server/fastedge-server/dev-server.ts" ], "required": false, - "description": "React with Hono Server example — scaffold blueprint (Hono routing + React SSR on FastEdge)" + "description": "React with Hono Server example — scaffold blueprint (working React+Vite+Hono starter with FastEdge edge handler)" }, "static-assets-blueprint": { "files": [ "examples/static-assets/src/index.tsx", + "examples/static-assets/src/jsx-page.tsx", + "examples/static-assets/src/styles-static-assets.ts", + "examples/static-assets/src/images-static-assets.ts", + "examples/static-assets/src/templates-static-assets.ts", + "examples/static-assets/styles/index.css", + "examples/static-assets/templates/index.html", "examples/static-assets/package.json", "examples/static-assets/tsconfig.json" ],