diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 492153f..2cec304 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -23,11 +23,45 @@ The public API surface is defined by: Changes to these surfaces require updated `docs/`, updated tests, and a semver-appropriate version bump. -## Documentation Freshness +## Content in `docs/` + +`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/` 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. +- `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/`: + +- 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/`: + +- 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/` @@ -53,19 +87,23 @@ Changes to these surfaces require updated `docs/`, updated tests, and a semver-a | `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 -> 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..7a16f88 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,11 @@ package-lock.json # FastEdge debugger artifacts **/.fastedge-debug/ + +# 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/context/CONTEXT_INDEX.md b/context/CONTEXT_INDEX.md index 2bcec07..1d71188 100644 --- a/context/CONTEXT_INDEX.md +++ b/context/CONTEXT_INDEX.md @@ -161,6 +161,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..a42bb89 --- /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 | +| `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/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/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/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/PROXY_PATTERNS.md b/docs/PROXY_PATTERNS.md new file mode 100644 index 0000000..5ef3d69 --- /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("https://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/docs/SDK_API.md b/docs/SDK_API.md index 7eea7da..2c82d65 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,18 @@ 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 | +| `sign()` / `verify()` | RSASSA-PKCS1-v1_5, ECDSA, HMAC | +| `importKey()` | JWK, PKCS#8, SPKI, raw (HMAC) | +| `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 +1209,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/docs/quickstart.md b/docs/quickstart.md index 617de38..c31d6f2 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/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/README.md b/examples/README.md index a3d1e0b..2a1973c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -6,27 +6,32 @@ network using ## Getting Started Examples -| 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 | -| [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 | +| 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 | 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 | | [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 | | [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.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/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.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/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 7f8f82b..33b0255 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.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/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..c34a1f6 --- /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 (error) { + return Response.json({ error: `KV lookup failed: ${error.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/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/.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/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/cache/fixtures/landing.test.json b/examples/cache/fixtures/landing.test.json new file mode 100644 index 0000000..2b8bef1 --- /dev/null +++ b/examples/cache/fixtures/landing.test.json @@ -0,0 +1,9 @@ +{ + "appType": "http-wasm", + "description": "No action param — usage menu listing all three patterns", + "request": { + "method": "GET", + "path": "/", + "headers": {} + } +} 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/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.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/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.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/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.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 new file mode 100644 index 0000000..22d82d0 --- /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 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0LXVzZXIiLCJuYW1lIjoiVGVzdCBVc2VyIiwiZXhwIjo5OTk5OTk5OTk5fQ.VOPl0Bp4ABHVVMxbIcMa0uDuXrIVcRLNB0n83nTdWHI" + } + }, + "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..57ba4a9 --- /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(/-/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.codePointAt(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 (error) { + return Response.json({ ok: false, error: error.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 677b0ed..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.3.0" - } -} 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 d23476b..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.3.0" - } -} 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/.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.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/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.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/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.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.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/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/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.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/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.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/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/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-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/.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/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/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.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/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/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/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.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/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..f988c0e --- /dev/null +++ b/examples/outbound-fetch/README.md @@ -0,0 +1,41 @@ +[← Back to examples](../README.md) + +# Outbound Fetch + +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-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..1a92b19 --- /dev/null +++ b/examples/outbound-modify-response/README.md @@ -0,0 +1,48 @@ +[← Back to examples](../README.md) + +# Outbound Modify Response + +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/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/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-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.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/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.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": " { + event.respondWith(app(event)); +}); diff --git a/examples/secret-rotation/README.md b/examples/secret-rotation/README.md new file mode 100644 index 0000000..aec619b --- /dev/null +++ b/examples/secret-rotation/README.md @@ -0,0 +1,107 @@ +[← 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. + +## APIs used + +- `getSecret(name)` from `fastedge::secret` — reads the current (highest-slot) value of a named secret +- `getSecretEffectiveAt(name, slot)` from `fastedge::secret` — reads the secret value whose slot number is the highest value ≤ the given slot + +## Configuring secrets with slots + +In the Gcore portal, secrets support multiple versioned slots. Each slot has a numeric value and a +secret value. The example expects a secret named `TOKEN_SECRET` (configurable via `x-secret-name`): + +```json +{ + "secret_slots": [ + { "slot": 0, "value": "original_password" }, + { "slot": 1741790697, "value": "new_password" } + ] +} +``` + +Slots are a platform concept — you define them when creating or updating a secret in the portal or +via the Gcore API. The slot value can be any non-negative integer; unix timestamps work naturally +for time-based rotation. + +## Build + +```sh +npm run build +``` + +Output: `dist/secret-rotation.wasm` + +## Testing + +```sh +# Default: current unix timestamp as slot, secret name TOKEN_SECRET +curl https://.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/.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.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/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.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/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.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/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.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/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.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/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.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/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.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/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.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/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.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/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..8acb47e --- /dev/null +++ b/examples/streaming/src/index.js @@ -0,0 +1,23 @@ +function app(event) { + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + async start(controller) { + for (let i = 0; i < 5; i++) { + // eslint-disable-next-line no-await-in-loop + 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.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/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.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/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.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-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.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/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/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/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.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/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/.generation-config.md b/fastedge-plugin-source/.generation-config.md index c028b54..a4eaa9a 100644 --- a/fastedge-plugin-source/.generation-config.md +++ b/fastedge-plugin-source/.generation-config.md @@ -157,7 +157,8 @@ files:** `src/server/static-assets/static-server/create-static-server.ts`, ## docs/SDK_API.md -**Scope:** All runtime APIs available in WASM **Source files:** `types/fastedge-env.d.ts`, +**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/fastedge-cache.d.ts`, `types/globals.d.ts` **Required content:** @@ -175,6 +176,9 @@ files:** `src/server/static-assets/static-server/create-static-server.ts`, globally-shared configuration, lookup tables, and sorted sets. - Code examples for each FastEdge API (covering both raw-bytes and entry-style KV reads, Cache `get`/`set`, `getOrSet`, atomic counters) +- **"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:** @@ -201,3 +205,66 @@ files:** `src/server/static-assets/static-server/create-static-server.ts`, (Unix epoch seconds) - `CacheEntry` exposes `arrayBuffer()` / `text()` / `json()`, all Promise-returning - `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/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 a8c5793..aa3cfae 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 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) + 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 @@ -337,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..e8f3226 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=$(grep -m1 '^#' "$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/^#\+ //') + heading=$(grep -m1 '^#' "$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 cac4c14..afd3553 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, 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": { "files": [ "examples/hello-world/src/index.js", @@ -89,19 +95,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": { @@ -138,6 +144,23 @@ "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" + }, "cache-blueprint": { "files": [ "examples/cache/src/index.ts", @@ -155,6 +178,147 @@ ], "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/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/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 (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" + ], + "required": false, + "description": "Static Assets example — scaffold blueprint (createStaticServer, JSX pages, asset manifests)" } }, "target_mapping": { @@ -175,7 +339,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 }, @@ -229,13 +398,84 @@ "section": null }, - "cache-blueprint": { - "reference_file": "plugins/gcore-fastedge/skills/scaffold/reference/http/cache-ts.md", + "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 + }, + "cache-blueprint": { + "reference_file": "plugins/gcore-fastedge/skills/scaffold/reference/http/cache-ts.md", + "section": null + }, "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/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 4a94670..8faf6ef 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/llms.txt b/llms.txt index 4255f42..53a6e9b 100644 --- a/llms.txt +++ b/llms.txt @@ -5,9 +5,13 @@ ## Documentation - [FastEdge JS SDK Documentation](docs/INDEX.md) -- [Quickstart](docs/quickstart.md) - [fastedge-assets CLI](docs/ASSETS_CLI.md) +- [Authentication Patterns](docs/AUTH_PATTERNS.md) - [fastedge-build CLI](docs/BUILD_CLI.md) +- [Hono Patterns on FastEdge](docs/HONO_PATTERNS.md) - [fastedge-init CLI](docs/INIT_CLI.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) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 00245c0..10d649a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,25 +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/downstream-fetch: + examples/cache-basic: dependencies: '@gcoredev/fastedge-sdk-js': specifier: link:../.. version: link:../.. - examples/downstream-modify-response: + examples/crypto-hmac-jwt: dependencies: '@gcoredev/fastedge-sdk-js': specifier: link:../.. @@ -206,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': @@ -270,6 +282,18 @@ importers: specifier: ^7.1.7 version: 7.3.2(@types/node@24.12.2)(terser@5.46.1)(tsx@4.21.0) + examples/request-inspection: + dependencies: + '@gcoredev/fastedge-sdk-js': + specifier: link:../.. + version: link:../.. + + examples/secret-rotation: + dependencies: + '@gcoredev/fastedge-sdk-js': + specifier: link:../.. + version: link:../.. + examples/static-assets: dependencies: '@gcoredev/fastedge-sdk-js': @@ -283,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':