diff --git a/context/CACHE_API_HANDOFF.md b/context/CACHE_API_HANDOFF.md new file mode 100644 index 0000000..9127012 --- /dev/null +++ b/context/CACHE_API_HANDOFF.md @@ -0,0 +1,369 @@ +# Cache API — Implementation Notes + +**Status:** All C++ + JS API surface implemented and building cleanly. Two example apps shipped +(`examples/cache-basic/`, `examples/cache/`) and proven to produce wasm with the correct host +imports. Awaiting manual host verification and integration tests. + +**Branch:** `feature/cache-api` **Sibling exploration branch:** `feature/cache-api-async` (DO NOT +MERGE — kept for reference; preview-3 async exploration only). + +## What this is + +A new `fastedge::cache` module surfacing the FastEdge POP-local cache that the runtime team has +added to the WIT. Positioned alongside `fastedge::kv`: + +- **`fastedge::kv`** — globally replicated, eventually consistent, Redis-shaped key/value store with + `scan` / `zrange` / bloom filters. Reads are fast (Redis colocated to every edge); writes are slow + (cross-region propagation). +- **`fastedge::cache`** (new) — data-center-scoped, strongly consistent within a single POP, fast + reads _and_ writes. Includes atomic `incr` so it can be used for rate limiting and other counter + primitives that the eventually-consistent KV cannot do reliably. Future work may layer + Request/Response Cache-API semantics on top of this byte cache. + +## What is implemented + +### WIT submodule + bindings + +- `runtime/FastEdge-wit` bumped to `b6fdc9f73`. Adds `cache.wit` (async), `cache-sync.wit` (sync, + identical surface), `cache-types.wit`, `utils.wit`, plus updated `world.wit`. +- `merge-wit-bindings.js` extended to strip the async `cache` interface from the merged world. + Reason: async uses WIT's `async func` syntax which compiles to component-model preview-3 ABI + (subtask handles, waitable sets, etc.). Our pinned `wit-bindgen-cli@0.30.0` cannot parse + `async func`. StarlingMonkey has no integration for preview-3 either. Async is deferred until the + runtime team confirms preview-3 canonicals are enabled in production AND the StarlingMonkey + integration is built. See `feature/cache-api-async` for an exploration of what async output looks + like (regenerated with `wit-bindgen-cli@0.57.1`). +- `cache-sync` and `utils` are now in `runtime/fastedge/host-api/bindings/bindings.h` as plain + `extern bool gcore_fastedge_cache_sync_*(...)` symbols, structurally identical to the existing + `gcore_fastedge_key_value_*` symbols. + +### Public TypeScript API + +- `types/fastedge-cache.d.ts` — full API contract with JSDoc, all methods Promise-returning. +- `types/index.d.ts` — references the new file. +- `pnpm run typecheck` passes. + +### Layer 1: Host-API wrappers + +In `runtime/fastedge/host-api/`: + +- **`include/fastedge_host_api.h`** — adds `CacheResult`, `CacheOption`, `CacheError`, + `CacheBytes`, `CacheBytesView` types (parallel to `KvStore*` templates — see "future cleanup" + below), plus declarations for the six cache wrappers and `utils_set_user_diag`. +- **`fastedge_host_api.cpp`** — implementations. Mechanical translation from the C bindings, follows + the existing `kv_store_*` pattern exactly. `cache_set` / `cache_delete` return + `std::optional` (none = success) since the host functions have void result types. + Other functions return `CacheResult`. + +Confirmed compilation produces correct C++ symbols and correctly references all +`gcore_fastedge_cache_sync_*` and `gcore_fastedge_utils_*` C imports. + +### Layer 2: Cache builtin + +`runtime/fastedge/builtins/cache.cpp` (~1100 lines, single file). Pure C++; no embedded JS shim. +Structure: + +| Component | Description | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Cache` class | Static methods: `get`, `exists`, `set`, `delete`, `expire`, `incr`, `decr`, `getOrSet`. Plus six private Promise reaction handlers for the async coercion paths in `set` and `getOrSet`. | +| `CacheEntry` class | Body-like wrapper with `arrayBuffer()`, `text()`, `json()` — all Promise-returning. Stores bytes via a Uint8Array in a reserved slot (no manual finalization needed). | +| `INFLIGHT_MAP` | Module-static `JS::PersistentRooted` for `getOrSet` coalescing. No-prototype JSObject so user keys cannot collide with inherited names like `"constructor"`. Initialised in `install()`. | +| `resolve_with` | Convenience helper — wraps a value in a resolved Promise and sets `args.rval()`. Mirrors `ReturnPromiseRejectedWithPendingError` from `builtin.h`. | +| `build_ttl_ms` | Validates `WriteOptions`, enforces mutual exclusion of `ttl` / `ttlMs` / `expiresAt`, translates everything to milliseconds. Used by `set`, `expire`, `getOrSet`. | +| `try_sync_coerce_bytes` | Sync coercion path for string / ArrayBuffer / ArrayBufferView. Used by `set` and `getOrSet`. | +| `finish_set` | Common cache-set + outer-Promise resolution path used by `set`'s sync and async paths. | +| `getOrSet_finalize` | Common cache-set + CacheEntry construction + outer-Promise resolution + inflight cleanup, used by `getOrSet`'s sync and async paths. | +| `reject_and_finish` | Cleanup helper for getOrSet error paths: capture pending exception, reject outer Promise, remove from inflight, clear `args.rval()`. | + +Async patterns: when `set` or `getOrSet` receives a `Response` / `ReadableStream` / anything with +`.arrayBuffer()`, the C++ uses `JS::Call(cx, value_obj, "arrayBuffer", ...)` to get a +`Promise`, then `JS::AddPromiseReactions(cx, inner, then_h, catch_h)` with +`create_internal_method` from `runtime/StarlingMonkey/include/builtin.h:335`. The +reaction handlers are static class members so their addresses can be passed as template arguments +(free functions in anonymous namespaces cannot be). + +### Layer 3: CMake registration + +`runtime/fastedge/CMakeLists.txt` — added `add_builtin(fastedge::cache SRC builtins/cache.cpp)`. The +build system auto-discovers and registers the namespace via +`runtime/fastedge/build-debug/starling-raw.wasm/builtins.incl` (generated, not tracked). + +### Layer 4: Module import resolution + +`src/componentize/es-bundle.ts` — extended the `fastedge::*` esbuild plugin with a case for +`'cache'` returning `export const Cache = globalThis.Cache;`. Same pattern the existing +`fastedge::kv` import already uses. + +## API design summary + +All methods are static and Promise-returning. `Cache` is never constructed. + +| Method | Signature | +| ----------------------------------- | ---------------------------------------------------------------------------------------- | +| `get(key)` | `(string) → Promise` | +| `exists(key)` | `(string) → Promise` | +| `set(key, value, options?)` | `(string, CacheValue, WriteOptions?) → Promise` | +| `delete(key)` | `(string) → Promise` | +| `expire(key, options)` | `(string, WriteOptions) → Promise` (false if missing) | +| `incr(key, delta?)` | `(string, number?) → Promise` (default delta = 1) | +| `decr(key, delta?)` | `(string, number?) → Promise` (sugar over `incr(-delta)`) | +| `getOrSet(key, populate, options?)` | `(string, () => CacheValue \| Promise, WriteOptions?) → Promise` | + +`CacheValue = string | ArrayBuffer | ArrayBufferView | ReadableStream | Response` + +`CacheEntry` exposes `arrayBuffer(): Promise`, `text(): Promise`, +`json(): Promise`. + +`WriteOptions` is `{ ttl?: number } | { ttlMs?: number } | { expiresAt?: number } | {}` with mutual +exclusion enforced at runtime. + +## Key design decisions + +### All-async surface (decided 2026-04-29) + +Initial design had reads/counters as sync and `set`/`getOrSet` as async (since those needed body +collection). Refactored mid-implementation to make every method Promise-returning, including `get` / +`exists` / `delete` / `expire` / `incr` / `decr`. + +**Why:** the `cache.wit` interface is async-first; `cache-sync` exists only as a toolchain fallback. +When wit-bindgen + StarlingMonkey gain preview-3 async support, we want to switch to the async host +calls without forcing customers to migrate `count = Cache.incr(k)` → `count = await Cache.incr(k)`. +With the all-async surface today, swapping the host implementation is invisible to user code. + +Cost is negligible: each method ends with `JS::CallOriginalPromiseResolve(...)` instead of setting +`args.rval()` directly. Validation errors still throw synchronously (caught by `await` the same way +as a rejection). + +### Pure C++ builtin, no embedded JS + +StarlingMonkey itself ships every builtin in pure C++ (`blob.cpp`, `url.cpp`, `console.cpp`, etc.). +There is no `.js`-embedded-as-C-string pattern anywhere in the upstream tree, no CMake helper for +it, no `js2c.py`-style build step. Existing FastEdge builtins are also all-C++. Embedding JS source +would establish a brand-new pattern; not worth it for the size of shim we'd save. Reference +templates for unfamiliar patterns: + +- Body-like Promise-returning methods → `runtime/StarlingMonkey/builtins/web/blob.cpp`. +- Promise reactions from C++ → `runtime/StarlingMonkey/builtins/web/fetch/request-response.cpp`. + +There's also a JS-runtime-perf argument: SpiderMonkey-on-WASM lacks its top-tier JIT (Ion/Warp need +runtime code generation, which WASM doesn't allow). JS in builtins runs at interpreter or +baseline-compiler speed; native WASM C++ runs at full LLVM-AOT speed. Builtin code on every +request's hot path matters more than user code where JIT amortisation works. + +### Body-like `CacheEntry` return from `get` + +Diverges from `KvStore`'s raw `ArrayBuffer | null` return — deliberate ergonomics improvement. Most +cache use ends in `JSON.parse(decode(bytes))`; the wrapper removes the boilerplate. Body methods are +Promise-returning to match the standard Web `Body` shape, even though we resolve synchronously +today; this leaves room for streaming when the WIT supports it. + +### `WriteOptions` mutual exclusion + +`ttl` (seconds) / `ttlMs` (milliseconds) / `expiresAt` (Unix epoch seconds) are mutually exclusive — +`build_ttl_ms` throws `TypeError` if more than one is set, or if a value is non-finite or +non-positive. Empty options bag = no expiry. The TTL knobs match the conventional cache-API unit +(seconds) plus host-native ms granularity for sub-second cases plus an absolute-time form for +"expire at midnight" patterns. + +### `getOrSet` coalescing + +Implemented in C++ via a module-static no-prototype JSObject (`INFLIGHT_MAP`). Concurrent callers +for the same key in the same WASM instance share one populator execution; the inserter is not re-run +for joiners. **Coalescing scope is in-process only** — concurrent requests handled by other WASM +instances or other POPs race independently. For a POP-local cache that's the honest guarantee. +Documented in the JSDoc. + +`getOrSet`'s populator returns the `CacheValue` directly (TTL goes in the call-site `options` bag, +not in the populator's return). The dynamic-TTL pattern (TTL derived from populator output) is not +supported in v1 — see "future work" below. + +### `Response` accepted as a write value + +Status and headers are discarded; only `await response.arrayBuffer()` is stored. The cache is a byte +cache, not an HTTP cache. The future HTTP Cache-API layer is a separate piece of work that would key +by Request and serialise full Response (likely as JSON envelope or via a separate WIT). + +### `incr` returns Number + +WIT host returns `s64`; we surface as JS `Number` for ergonomics (BigInt return would force `> 100n` +everywhere — bad DX). Values above `Number.MAX_SAFE_INTEGER` (2^53 − 1) are not represented exactly. +Practically unreachable for typical counter use cases. Documented in JSDoc. + +## Future cleanup / extension + +These are deliberate punts, not bugs. Each is purely additive — no breaking change from v1 to do +them later. + +### Generalise host-result types + fix WIT-payload ownership leak (combined refactor) + +`KvStoreResult` / `KvStoreOption` / `KvStoreError` and the parallel `CacheResult` / +`CacheOption` / `CacheError` are not actually domain-specific. A small follow-up PR after cache +ships should: + +- Rename `KvStoreResult` → `HostResult`, etc. +- Update existing kv-store call sites (one file: `kv-store.cpp` plus the host-api header). +- Drop the parallel `Cache*` types in favour of the shared generics. + +**Bundle in: WIT-payload ownership leak.** Both `kv_store_*` and `cache_*` wrappers in +`runtime/fastedge/host-api/fastedge_host_api.cpp` borrow host-allocated buffers +(`bindings_option_payload_t.val.ptr`, `bindings_string_t` inside the error `OTHER` variant, the +`list`/`list`/`list>` returns from `kv_store_get` / `kv_store_scan` / +`kv_store_zrange_by_score` / `kv_store_zscan`) without ever calling the matching +`gcore_fastedge_*_free(...)` helpers from `bindings.h`. The raw pointers are surfaced as +`KvStoreValue { uint8_t* ptr; size_t len; }` / `CacheBytes { uint8_t* ptr; size_t len; }` / +`KvStoreError.val.other { char* ptr; size_t len; }` / `CacheError.val.other { ... }` and copied at +the call site, leaving the original host allocation dangling. + +**Why we deferred.** FastEdge application instances are one-shot: each incoming request gets a fresh +WASM instance, the leaked buffers die with linear memory at end-of-request. Worst-case bound is +"what one request can leak" (a few MB for a cache-heavy request). Acceptable today. **The minute the +host platform starts reusing instances across requests for warm-start, this becomes a slow-burn +OOM** — flag this assumption to the runtime team if instance-pooling is ever proposed. + +**Recipe when doing the refactor:** + +1. Change owning shapes in `include/fastedge_host_api.h`: + - `KvStoreValue` / `CacheBytes` → `std::vector` (or a thin `HostBytes` wrapper if we + want a named type). + - `KvStoreError.val.other` / `CacheError.val.other` → `std::string`. + - List returns (`KvStoreStringList`, `KvStoreZList`) → `std::vector<...>` with owned elements. +2. In each wrapper in `fastedge_host_api.cpp`: after the host call returns success, copy + bytes/strings out of `ret` into the owning C++ types, **then** call the matching `*_free` helper + (`bindings_option_payload_free(&ret)`, `gcore_fastedge_key_value_*_free(&ret)`, etc.) before + returning. +3. Error-conversion helpers (`convert_cache_error`, the inline kv error block) take a non-const + reference and free internally after copying — keeps call sites clean. +4. Update consumers — `cache.cpp` (`bytes.ptr/len` → `bytes.data()/size()`; `err.val.other.ptr/len` + → `.c_str()/size()`) and `kv-store.cpp` (same shape). + +The leak fix and the type unification touch the same lines in the same files; doing them together is +one diff instead of two overlapping ones. Originally surfaced as a PR review comment on +`feature/cache-api` (2026-04-30). + +### Sweep `JS_EncodeStringToUTF8` + `strlen` for keys (cache + kv-store) + +The `value` path in `cache.cpp` uses `JS::GetDeflatedUTF8StringLength` to get the encoded UTF-8 byte +length (so embedded NULs are preserved). **Cache and KV `key` parameters still use the +implicit-`strlen` pattern**: `JS::UniqueChars key = JS_EncodeStringToUTF8(cx, key_str)` followed by +passing `key.get()` to a host API that takes `std::string_view` — the view's length comes from +`strlen` on the NUL-terminated buffer, so a key containing an embedded NUL silently truncates. + +Keys aren't documented as "raw bytes" so the contract violation is fuzzier than the value path, but +the behaviour is still surprising: `Cache.set("a\0b", v)` and `Cache.set("a\0c", v)` would collide. +Same bug exists pervasively in `kv-store.cpp`. + +**Sites to update** (all pass `key.get()` into a `std::string_view`-typed host API): + +- `cache.cpp`: lines ~360, 389, 408, 558, 671, 803, 846, 1108. +- `kv-store.cpp`: lines ~64, 136, 194, 251, 333, 343, 420, 430. + +**Recipe**: replace `JS::UniqueChars key = JS_EncodeStringToUTF8(...)` + `key.get()` with +`core::encode(cx, key_str)` (already in `runtime/StarlingMonkey/runtime/encode.cpp` — returns +`host_api::HostString { JS::UniqueChars ptr; size_t len; }` with the actual UTF-8 byte length). Then +pass `std::string_view(host_str.ptr.get(), host_str.len)` to the host API. Single +`#include "encode.h"` per builtin. + +Originally surfaced alongside the value-path NUL-truncation fix (PR review on `feature/cache-api`, +2026-04-30) and deferred as a wider sweep. + +### Async WIT (when toolchain supports preview-3) + +When wit-bindgen gains stable preview-3 async support AND StarlingMonkey gains the +subtask/waitable-set integration, switch the host calls from `gcore_fastedge_cache_sync_*` to the +async `gcore_fastedge_cache_*`. The JS surface is already Promise-returning; users see no change. +See `feature/cache-api-async` for what the async C surface looks like. + +### Dynamic TTL for `getOrSet` + +If a real customer use case emerges for "TTL derived from populator output" (e.g. honour +`Cache-Control: max-age=N` from an upstream Response), the _additive_ extension is to allow +`options` to also be a function `(value: CacheValue) => WriteOptions`. Backwards-compatible with all +existing call sites. Don't do speculatively. + +### `utils.set-user-diag` JS surface + +The `gcore:fastedge/utils` interface (one function: `set-user-diag(name)`) has its host-api wrapper +in place but no JS-facing surface. Decide where it belongs (a new `fastedge::utils` module, or hung +off `fastedge::env` as a sibling to `getEnv`), then add the builtin method and TypeScript +declarations. Small follow-up. + +### HTTP Cache API layer + +The byte cache is the foundation. A future `caches` global (or `fastedge::http-cache` module) could +layer Service-Worker `CacheStorage` semantics on top: keyed by `Request` (with selected `Vary` +headers), values are full `Response` envelopes (status + headers + body), TTL derived from +`Cache-Control` parsing. None of that requires changing the v1 byte cache. + +## Open questions (need runtime team) + +- **`incr` non-integer behaviour.** The JSDoc claims `incr` rejects if the stored value at `key` is + not an integer. The WIT only has the generic error variant — confirm with the runtime team that + `internal-error` or `other("not an integer")` is what gets returned in that case. Our error path + surfaces whatever the host gives; if the host returns generic `internal-error`, the message will + say "Internal cache error" rather than something more specific. + +## What's left for ship + +1. **Manual testing.** Build a cold WASM, deploy to a POP, exercise each method against a real host. + The host imports must be answered by the production runtime — none of our host calls are mocked + locally. The two new examples (`examples/cache-basic`, `examples/cache`) are the natural + fixtures: deploy each, hit every action, confirm responses match the documented shapes. +2. **Integration tests.** `integration-tests/` template is the existing kv-store flow. Need + cache-flow tests covering: round-trip set/get, TTL expiry, atomic incr (concurrent), getOrSet + coalescing, error propagation, all `CacheValue` input types. +3. **PR / review.** The branch has 5+ commits; squash or keep as-is for review depending on team + convention. + +### Done since initial handoff + +- ✅ **Example apps.** Two examples landed (2026-04-30): + - `examples/cache-basic/` (JS) — set / get / exists / delete with action-based routing. Heavy + teaching comments. + - `examples/cache/` (TS) — three flagship patterns: per-IP rate limiting (`incr` + `expire`), + origin-cache proxy (`getOrSet` + `fetch`), JSON memoisation (`getOrSet` with synchronous + populator). Uses `event.client.address` for the rate-limit key. + - Both registered in `examples/README.md`. Both build cleanly via `pnpm run build`. + `wasm-tools component wit` confirms the produced wasm imports `gcore:fastedge/cache-types` and + `gcore:fastedge/cache-sync` — the cache plumbing is wired through end-to-end inside the + workspace. +- ✅ **Workspace SDK override.** Added `overrides: { '@gcoredev/fastedge-sdk-js': 'link:.' }` to + `pnpm-workspace.yaml` so all in-workspace example builds use the in-tree SDK (with the cache + resolution case) instead of the published 2.2.2. Examples keep `^2.2.3` as their + published-artefact pin, so copy-paste-and-run remains the experience for end users once the next + SDK release lands on npm. Invisible to anyone outside the workspace. + +## How to verify + +1. **Build the runtime in debug mode**: `pnpm run build:monkey:dev`. Should produce + `lib/fastedge-runtime.wasm`. +2. **Inspect symbols**: + `/opt/wasi-sdk/bin/llvm-nm runtime/fastedge/build-debug/CMakeFiles/builtin_fastedge_cache.dir/builtins/cache.cpp.obj | grep -E "Cache|getOrSet"` + — should show all eight methods plus the four reaction handlers. +3. **Confirm the WIT host imports**: + `grep gcore_fastedge_cache_sync runtime/fastedge/host-api/bindings/bindings.h` — all six imports + should be present. +4. **Typecheck the public API**: `pnpm run typecheck`. +5. **Build a tiny WASM via fastedge-build with a cache import**: smoke-tests the esbuild plugin + path. + +## Reference: file map + +| File | Role | +| ------------------------------------------------------- | --------------------------------------------------- | +| `runtime/FastEdge-wit/` (submodule @ `b6fdc9f`) | Source-of-truth WIT | +| `runtime/fastedge/scripts/merge-wit-bindings.js` | Strips async `cache`; merges sync into host-api/wit | +| `runtime/fastedge/host-api/bindings/bindings.{h,c}` | Generated C bindings (don't edit) | +| `runtime/fastedge/host-api/include/fastedge_host_api.h` | Layer 1 — C++ types + declarations | +| `runtime/fastedge/host-api/fastedge_host_api.cpp` | Layer 1 — C++ wrappers | +| `runtime/fastedge/builtins/cache.cpp` | Layer 2 — JS-facing builtin | +| `runtime/fastedge/CMakeLists.txt` | Builtin registration | +| `src/componentize/es-bundle.ts` | esbuild plugin: `fastedge::cache` import resolution | +| `types/fastedge-cache.d.ts` | Public TS contract | +| `types/index.d.ts` | Type entrypoint reference | + +## Memory + +- `wit-bindgen-cli@0.30.0` is the pinned version. Install with + `cargo install --locked wit-bindgen-cli@0.30.0` if regenerating bindings. +- **Never** reference Fastly / Cloudflare / Redis / Memcached etc. in any user-facing artifact + (JSDoc, READMEs, examples, generated docs). Internal design discussion only. diff --git a/context/CHANGELOG.md b/context/CHANGELOG.md index 84a1c5b..01ed8c9 100644 --- a/context/CHANGELOG.md +++ b/context/CHANGELOG.md @@ -5,6 +5,102 @@ When this file grows large, use grep to search — don't read linearly. --- +## [2026-05-05] — Pin host-api bindings to wit-bindgen 0.30.0 (fix wasmtime 36 trap) + +### Overview +Reverted the wit-bindgen pin from 0.37.0 back to 0.30.0 after a canonical-ABI lowering regression in 0.37.0 caused every JS-built component to trap `pointer not aligned` on wasmtime 36 hosts (FastEdge edge), surfacing as `530: fastedge: Execute error` for all examples — including hello-world that doesn't touch any new interface. + +### Root cause +`wit-bindgen` 0.37.0 generates lowering code that reads pointer/length fields from variant-with-string types at unaligned offsets (e.g. `ptr+1` where 0.30.0 used `ptr+4`). wasi:http result/option decoding hits these helpers early on every incoming request, so the trap fires before the guest can invoke `response-outparam::set`. + +### Changes +- **Regenerated** `runtime/fastedge/host-api/bindings/{bindings.c,bindings.h,bindings_component_type.o}` with `wit-bindgen-cli@0.30.0` against the full branch WIT (cache-sync, cache-types, utils included). 0.30.0 parses these interfaces fine — the prior 0.37.0 pin was unnecessary. +- **Documented** the regression and required version in `context/development/BUILD_SYSTEM.md` (new "WIT Bindings & wit-bindgen Version" section) including diff illustrating the offset change. +- **No source changes** to `fastedge_host_api.{cpp,h}`, `world.wit`, `cache.cpp`, or any builtin — only the generated bindings layer. + +### Verification +- `examples/hello-world/dist/hello-world.wasm` runs cleanly on the wasmtime 36 edge host. +- `examples/cache-basic/dist/cache-basic.wasm` runs and exercises the `Cache.get/set/exists/delete` flow. + +### Notes +- The fix is silent at build time — only request-time execution surfaces the trap. Any future wit-bindgen bump must be retested on a wasmtime 36 host before merging. +- The `runtime/fastedge/scripts/create-wit-bindings.sh` script does not enforce the version. We intentionally did not hard-code it; the version contract is documented in `BUILD_SYSTEM.md` instead. + +--- + +## [2026-05-05] — Customer tsconfig modernisation + globals.d.ts audit + +### Overview +Brought the recommended customer `tsconfig.json` in line with what StarlingMonkey actually supports (ES2023, no DOM lib) and expanded `types/globals.d.ts` to declare every Web API the runtime exposes. Made the customer-bundle esbuild target explicit. Removed dead Fastly-inherited type declarations that had no runtime backing. + +### Changes +- **`types/globals.d.ts`** — verified each addition against `runtime/StarlingMonkey/builtins/web/` C++ sources: + - Added: `TextEncoder`, `TextDecoder`, `Event`, `CustomEvent`, full `EventTarget`, `AbortController`, `AbortSignal`, `Blob`, `File`, `FormData` and their init/option dictionaries. + - Uncommented (now backed by runtime): `Body.blob()`, `Body.formData()`, `RequestInit.signal`, `Request.signal`, `Response.redirected`, `Response.type`. Added `ResponseType`. + - Added: `Headers.getSetCookie()`. + - Updated: `BodyInit` to include `Blob | FormData`. + - Uncommented `StreamPipeOptions.signal` (was a stale comment — StarlingMonkey's `pipeTo` wrapper passes `options` through unmodified to SpiderMonkey's native `ReadableStream.prototype.pipeTo`, which fully implements signal-driven aborts; see `runtime/StarlingMonkey/builtins/web/streams/transform-stream.cpp:55`). Also lifted the misplaced JSDoc that was attached to `preventClose` up to the interface. + - Added Option-A explanatory marker blocks to `RequestInit`, `Request`, `Response`, and `SubtleCrypto` so that future maintainers can see at a glance which spec fields/methods are intentionally absent and where to look in the runtime when re-evaluating support. + - **Removed** (no implementation in either StarlingMonkey or `runtime/fastedge/builtins/`): `Request.setCacheKey`, `Request.setManualFramingHeaders`, `Response.setManualFramingHeaders`, and the corresponding `manualFramingHeaders` flags on `RequestInit`/`ResponseInit`. These were inherited from a Fastly types copy and threw `TypeError` at runtime. + - Confirmed staying commented out (not parsed by `request-response.cpp`): `cache`, `credentials`, `redirect`, `mode`, `integrity`, `keepalive`, `referrer`, `referrerPolicy`, `window`, `Response.clone()`, `Response.error()`. + +### Type-vs-runtime audit (Slice C) + +A second pass closed real gaps where the runtime exposes APIs but `globals.d.ts` either didn't declare them or narrowed them incorrectly. All findings verified against `runtime/StarlingMonkey/builtins/` C++ sources before applying. + +- **Byte streams added** — declared `ReadableStreamBYOBReader`, `ReadableStreamBYOBRequest`, `ReadableByteStreamController`, plus the BYOB overload on `ReadableStream.getReader({ mode: 'byob' })` and supporting types (`ReadableStreamGetReaderOptions`, `ReadableStreamBYOBReaderReadOptions`). Replaced the narrowed `ReadableStreamReader` and `ReadableStreamController` aliases with the spec-correct unions. Confirmed available via `runtime/StarlingMonkey/CHANGELOG.md` and the WPT expectations under `tests/wpt-harness/expectations/streams/` — the implementation comes from SpiderMonkey 140 directly with StarlingMonkey passing through. +- **Queuing strategy classes added** — `ByteLengthQueuingStrategy`, `CountQueuingStrategy`. Previously only the `QueuingStrategy` and `QueuingStrategyInit` shapes were declared; the actual classes were missing. +- **Compression streams added** — `CompressionStream`, `DecompressionStream`, `CompressionFormat = 'deflate' | 'deflate-raw' | 'gzip'`. Verified against `runtime/StarlingMonkey/builtins/web/streams/{compression,decompression}-stream.cpp` — surface is just `readable` + `writable` properties (constructors take the format string). +- **`KeyFormat` widened** to `'jwk' | 'pkcs8' | 'raw' | 'spki'` — PKCS#8 was added in StarlingMonkey 0.2.0 (per their CHANGELOG); SPKI is supported for ECDSA imports too. Confirmed via the format `switch` statements in `crypto-algorithm.cpp` (lines 1503/1650/1745/1778, etc.). +- **`SubtleCrypto.sign`/`verify` accept `EcdsaParams`** — added `EcdsaParams { name: 'ECDSA'; hash: HashAlgorithmIdentifier }` and broadened the live overloads. `crypto-algorithm.cpp:729` parses ECDSA params (extracts `hash`); without `EcdsaParams` in the type, customers can't pass `{ name: 'ECDSA', hash: 'SHA-256' }` without a cast. +- **`SubtleCrypto.importKey` algorithm union widened** — `EcKeyImportParams` now appears in both the JWK and binary-format overloads (was previously only on JWK), and `HmacImportParams` appears in both (was previously only on binary). Verified `CryptoAlgorithmECDSA_Import::importKey` supports `Jwk`/`Pkcs8`/`Raw`/`Spki` (lines 1503/1650/1745/1778) and `CryptoAlgorithmHMAC_Import::importKey` supports `Raw`/`Jwk` (line 1431). +- **`Transferable` narrowing unchanged** — confirmed only `ArrayBuffer` is transferable. Replaced the bare `// MessagePort | ImageBitmap` comment with one explaining *why* (no Worker/Canvas APIs in StarlingMonkey, so neither type is exposed). +- **MultipartFormData NOT declared** — the class is registered as a global name (`form-data-encoder.cpp:569-572,697`) but has zero JS-accessible methods, zero properties, and `NoCtorBuiltin` for its constructor. It's an internal helper used by `FormData` encoding, not part of the public API. Skipped intentionally. +- **`tsconfig.json`** — bumped SDK root `target` to `ES2023`. +- **`src/componentize/es-bundle.ts`** — set explicit `format: "esm"` and `target: "es2023"` on the customer-bundle esbuild call so the contract is documented in code rather than implicit-via-default. +- **`src/cli/fastedge-init/create-config.ts`** — bumped scaffolded `.fastedge/jsconfig.json` `target` from `ES6` to `ES2023`. +- **Examples** — updated `cache/`, `kv-store/`, `static-assets/`, `mcp-server/`, and `react-with-hono-server/tsconfig.fastedge.json` to the new recommended config: `ES2023` target/lib, `moduleResolution: "Bundler"` (except `mcp-server` which keeps `Node16` because it `tsc`-emits before `fastedge-build`), no `DOM` lib. The Vite-side configs (`tsconfig.app.json`, `tsconfig.node.json`) in `react-with-hono-server` are untouched — they target the browser/Node tooling, not the FastEdge worker. +- **`context/development/BUILD_SYSTEM.md`** — new "Customer Code Bundling" section documenting that the customer's `tsconfig.json` does **not** influence WASM output (esbuild ignores it), the actual scope of `--tsconfig` (drives `tsc --project` syntax check only), and the recommended customer config. + +### Migration / impact +- **No runtime behaviour change.** ES2023 target on esbuild is now explicit; previously it inherited esbuild's `esnext` default which already preserved modern syntax for SpiderMonkey 140 to execute. +- **`Request.setCacheKey()` / `setManualFramingHeaders()` removed from types.** Anyone calling these at runtime was already getting a `TypeError`; the type removal stops it compiling silently. +- **DOM lib no longer needed.** Customer code that relied on `document`/`window`/`localStorage` types was already broken at runtime; this change makes it broken at compile time, which is the right outcome. +- **Verification:** `pnpm run typecheck`, `pnpm run build:types`, `pnpm run test:unit:dev` (425 tests pass), `pnpm run build` in `examples/cache-basic` all pass. Each updated example tsconfig typechecks cleanly against the new globals. + +--- + +## [2026-05-04] — KvStoreEntry: entry-style accessors for KV Store + +### Overview +Added an entry-shaped read API to `fastedge::kv` mirroring the `CacheEntry` shape from `fastedge::cache`. Three new methods on `KvStoreInstance` (`getEntry`, `zrangeByScoreEntries`, `zscanEntries`) return `KvStoreEntry` wrappers exposing `arrayBuffer()`, `text()`, and `json()` Promise-returning accessors. The existing `get` / `zrangeByScore` / `zscan` methods are unchanged and remain fully supported. + +### Changes +- **`runtime/fastedge/builtins/kv-store.{h,cpp}`** — added `KvStoreEntry` class (anonymous-namespace, mirrors `CacheEntry` from `cache.cpp`) and three new `KvStore` methods (`get_entry`, `zrange_by_score_entries`, `zscan_entries`) registered as `getEntry`, `zrangeByScoreEntries`, `zscanEntries` on the JS instance prototype. No WIT changes — the new methods reuse the existing `kv_store_get` / `kv_store_zrange_by_score` / `kv_store_zscan` host functions. +- **`types/fastedge-kv.d.ts`** — added `KvStoreEntry` interface and three new method signatures. Existing methods cross-referenced via `@see` tags pointing to their entry-style counterparts. Top-of-file example updated to use `getEntry().text()`. +- **`examples/kv-store-basic/src/index.js`** — switched to `getEntry().text()` (the previous template-literal interpolation of an `ArrayBuffer` rendered as `[object ArrayBuffer]` rather than the stored value). +- **`github-pages/src/content/docs/reference/fastedge/kv/key-value.md`** and **`zset.md`** — added documentation for `getEntry`, `zrangeByScoreEntries`, and `zscanEntries`; fixed the buggy template-literal example in the `get` section. Language is neutral — neither form is presented as preferred. + +### Migration +- **No breaking changes.** Code using `get` / `zrangeByScore` / `zscan` continues to work indefinitely; the entry-style methods are additive and recommended for new code. +- The entry methods are Promise-returning (matching `Cache`); the legacy methods remain synchronous. + +--- + +## [2026-04-30] — Cache API examples + workspace SDK override + +### Overview +Added two new examples covering the `fastedge::cache` API and introduced a workspace-level pnpm override so example builds resolve the in-tree SDK during development without changing the public dependency pin. + +### Changes +- **`examples/cache-basic/`** — minimal getting-started example. Single JS file (`src/index.js`) with action-based routing covering `Cache.set` / `get` / `exists` / `delete`. Heavy explanatory comments throughout for users learning the platform. Registered under "Getting Started Examples" in `examples/README.md`. +- **`examples/cache/`** — flagship full example. TypeScript with three patterns: per-IP rate limiting via atomic `incr` + `expire` (uses `event.client.address`), origin-cache proxy via `getOrSet` with a `fetch` populator, and JSON memoisation via `getOrSet` with a synchronous populator. Registered under "Full Examples". +- **`pnpm-workspace.yaml`** — added `overrides: { '@gcoredev/fastedge-sdk-js': 'link:.' }`. Inside the workspace, all examples now symlink to the root SDK package; outside the workspace (copy-pasted examples), the override is invisible and `pnpm install` resolves the published artefact from npm as users expect. +- **Verification:** both examples build cleanly via `pnpm run build`; `wasm-tools component wit` confirms the produced wasm imports `gcore:fastedge/cache-types` and `gcore:fastedge/cache-sync`, proving the cache resolution case in `src/componentize/es-bundle.ts:38-44` is wired through end-to-end. +- **Pin:** examples use `@gcoredev/fastedge-sdk-js@^2.2.3`, anticipating the next published release that will include cache support. Until that release ships, builds inside the workspace use the symlinked SDK; outside the workspace they would warn ("fastedge:cache has no exports") since the published 2.2.2 predates this branch. + +--- + ## [2026-04-08] — ESLint 10 Migration + Dependency Upgrades ### Overview diff --git a/context/CONTEXT_INDEX.md b/context/CONTEXT_INDEX.md index 43af626..2bcec07 100644 --- a/context/CONTEXT_INDEX.md +++ b/context/CONTEXT_INDEX.md @@ -135,6 +135,18 @@ Items that need attention. Surface these when asked "what's next" or "what needs work". +### Cache API — implemented, awaiting verification +- **Branch:** `feature/cache-api` +- **State:** All C++ + JS API surface implemented and building cleanly. `Cache` global + `CacheEntry` Body-like wrapper + in-process `getOrSet` coalescing all in place. All methods Promise-returning for forward-compat with future async WIT. +- **What's left:** manual testing against a real host, integration tests, example app, PR review. +- **Read first:** `context/CACHE_API_HANDOFF.md` — full implementation map, design decisions (including the all-async pivot), open questions, and verification steps. +- **Sibling branch:** `feature/cache-api-async` is exploration-only (preview-3 async ABI investigation) — do not merge. + +### `gcore:fastedge/utils` interface — not yet exposed to JS +- **What:** The WIT bump for the cache work also added a `utils` interface with one function: `set-user-diag(name: string)` — sets a user diagnostic context string associated with the current request, surfaced in call statistics. +- **State:** The C binding is generated and visible as `gcore_fastedge_utils_set_user_diag(...)` in `runtime/fastedge/host-api/bindings/bindings.h`. The cache-api branch adds a host-api C++ wrapper for it. **No JS-facing surface exists yet.** +- **What's needed:** Decide where it belongs (a new `fastedge::utils` module, or hung off `fastedge::env` as a sibling to `getEnv`), then add the builtin method and TypeScript declarations. Likely a small follow-up after the cache work ships. + ### `moduleResolution: node` deprecation in syntax checker (HIGH PRIORITY) - **File:** `src/utils/syntax-checker.ts` (lines 71-80) - **Problem:** The `fastedge-build` CLI passes `--moduleResolution node` to `tsc` when validating user TypeScript files. `node` resolves to `node10`, which is deprecated since TS 5.0 and will be **removed in TypeScript 7.0**. diff --git a/context/development/BUILD_SYSTEM.md b/context/development/BUILD_SYSTEM.md index d026d9a..09533da 100644 --- a/context/development/BUILD_SYSTEM.md +++ b/context/development/BUILD_SYSTEM.md @@ -91,6 +91,68 @@ Defined in `tsconfig.json` and mirrored in `config/jest/jest.config.js`: **Important:** When adding a new path alias, update both `tsconfig.json` and `config/jest/jest.config.js`. +## Customer Code Bundling (`fastedge-build`) + +The `fastedge-build` CLI bundles the customer's entry file via esbuild before +componentizing it to WASM. This pipeline is intentionally independent of the +customer's `tsconfig.json`. + +### What esbuild does (in `src/componentize/es-bundle.ts`) + +| Setting | Value | Why | +|---------|-------|-----| +| `bundle` | `true` | Single-file output for componentize | +| `format` | `"esm"` | StarlingMonkey expects ESM | +| `target` | `"es2023"` | Matches StarlingMonkey (SpiderMonkey 140) capability | +| `tsconfig` | `undefined` | Customer tsconfig is **not** consulted by esbuild | + +The bundler does not read the customer's `tsconfig.json` for `target`, `lib`, +`module`, `paths`, or anything else. The WASM output is identical regardless +of the customer's tsconfig contents. + +### What the customer's `tsconfig.json` actually controls + +It affects two things, both **outside** the bundle pipeline: + +1. **Editor IntelliSense** — what types the customer's IDE understands. +2. **Pre-build `tsc` syntax check** — only when `--tsconfig ` is passed + to `fastedge-build`, the syntax checker (`src/utils/syntax-checker.ts`) + runs `tsc --project ` against the customer's source. This is a + correctness gate, not a build step. The path is **not** forwarded to + esbuild. + +So the customer's `target`, `lib`, `module`, `moduleResolution`, `strict`, +etc. are DX choices — they have no effect on the produced WASM. + +### Recommended customer `tsconfig.json` + +```jsonc +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "lib": ["ES2023"], + "types": ["@gcoredev/fastedge-sdk-js"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} +``` + +Notes: +- **No `"DOM"` in `lib`.** The SDK ships ambient declarations for every Web + API that StarlingMonkey actually exposes (fetch, streams, crypto, encoding, + events, abort, blob/file/formdata, timers, etc.). Including `"DOM"` would + surface types like `document`, `window`, and `localStorage` that compile but + throw at runtime. +- **`moduleResolution: "Bundler"`** — describes the actual customer pipeline + (esbuild) and honors `package.json` `"exports"` correctly. +- **`target: "ES2023"`** matches the runtime; nothing transpiles below this. + ## npm Package Contents The `files` field in `package.json` controls what ships to npm: @@ -104,3 +166,47 @@ README.md ``` **Not shipped:** `src/`, `runtime/`, `config/`, `esbuild/`, `examples/`, `github-pages/`, `integration-tests/` + +## WIT Bindings & wit-bindgen Version + +The host-api C bindings under `runtime/fastedge/host-api/bindings/{bindings.c,bindings.h,bindings_component_type.o}` are generated from the WIT in `runtime/fastedge/host-api/wit/` by `pnpm run wit:bindings` (which calls `runtime/fastedge/scripts/create-wit-bindings.sh`). The script invokes whatever `~/.cargo/bin/wit-bindgen` happens to be installed — there is no version enforcement. + +### Required version: `wit-bindgen-cli@0.30.0` + +Install (Linux x86_64): + +``` +curl -sLO https://github.com/bytecodealliance/wit-bindgen/releases/download/v0.30.0/wit-bindgen-0.30.0-x86_64-linux.tar.gz +tar xzf wit-bindgen-0.30.0-x86_64-linux.tar.gz +cp wit-bindgen-0.30.0-x86_64-linux/wit-bindgen ~/.cargo/bin/wit-bindgen +~/.cargo/bin/wit-bindgen --version # wit-bindgen-cli 0.30.0 +``` + +After installing, `pnpm run generate:wit-world` (and the underlying `pnpm run wit:bindings`) will produce 0.30.0-stamped bindings. + +### Why not 0.37.0 (or later) + +`wit-bindgen` 0.37.0 has a regression in canonical-ABI lowering for variant types that contain string payloads (used pervasively in `wasi:http` result/option decoding). The generated C reads pointer/length fields at unaligned offsets (e.g. `ptr+1` for the pointer instead of `ptr+4`), which traps `pointer not aligned` on wasmtime 36 hosts before the guest can invoke `response-outparam::set`. Symptom on the FastEdge edge: every JS-built component returns `530: fastedge: Execute error`, regardless of whether the user code touches cache/kv/etc. + +Diff that captures the regression: + +```c +// wit-bindgen 0.30.0 (works) +option.val = (bindings_string_t) { (uint8_t*)(*((uint8_t **) (ptr + 4))), (*((size_t*) (ptr + 8))) }; + +// wit-bindgen 0.37.0 (traps) +option.val = (bindings_string_t) { (uint8_t*)(*((uint8_t **) (ptr + 1))), (*((size_t*) (ptr + 5))) }; +``` + +The 0.30.0 layout reserves a 4-byte slot for the variant tag (matching natural u32 alignment for the pointer field). The 0.37.0 layout packs the tag into a single byte and then reads pointer/length from unaligned offsets — wasmtime 36 enforces alignment hints on `i32.load`, so the load traps. + +### Verification + +After regenerating bindings, confirm: + +``` +head -1 runtime/fastedge/host-api/bindings/bindings.h +# → // Generated by `wit-bindgen` 0.30.0. DO NOT EDIT! +``` + +If a future SDK upgrade requires a newer wit-bindgen (e.g. for newer WIT features), retest the produced binaries on the wasmtime 36 host before merging — the regression is silent at build time and only surfaces at request execution. diff --git a/docs/ASSETS_CLI.md b/docs/ASSETS_CLI.md index b8aa718..a56aca6 100644 --- a/docs/ASSETS_CLI.md +++ b/docs/ASSETS_CLI.md @@ -33,7 +33,7 @@ npx fastedge-assets --version | `--input ` | `-i` | `string` | Path to the directory of source assets (e.g. `./public`) | | `--output ` | `-o` | `string` | Output file path for the generated manifest (e.g. `./src/asset-manifest.ts`) | | `--config ` | `-c` | `string` | Path to an asset config file containing `AssetCacheConfig` fields | -| `--version` | `-v` | `boolean` | Print the package version | +| `--version` | `-v` | `boolean` | Print the package version | | `--help` | `-h` | `boolean` | Print usage information | **Notes:** @@ -160,7 +160,6 @@ const staticAssetManifest = { lastModifiedTime: 1768985591, assetPath: './styles/index.css', }, - lastModifiedTime: 1768985591, type: 'wasm-inline', }, }; @@ -172,38 +171,38 @@ export { staticAssetManifest }; The following MIME types are detected automatically by file extension. Custom `contentTypes` entries are checked first. -| Extension(s) | Content-Type | Text | -| --------------------- | ------------------------------- | ----- | -| `.txt` | `text/plain` | yes | -| `.html`, `.htm` | `text/html` | yes | -| `.xml` | `application/xml` | yes | -| `.json` | `application/json` | yes | -| `.map` | `application/json` | yes | -| `.js` | `application/javascript` | yes | -| `.ts` | `application/typescript` | yes | -| `.css` | `text/css` | yes | -| `.svg` | `image/svg+xml` | yes | -| `.bmp` | `image/bmp` | no | -| `.png` | `image/png` | no | -| `.gif` | `image/gif` | no | -| `.jpg`, `.jpeg` | `image/jpeg` | no | -| `.ico` | `image/vnd.microsoft.icon` | no | -| `.tif`, `.tiff` | `image/png` | no | -| `.aac` | `audio/aac` | no | -| `.mp3` | `audio/mpeg` | no | -| `.avi` | `video/x-msvideo` | no | -| `.mp4` | `video/mp4` | no | -| `.mpeg` | `video/mpeg` | no | -| `.webm` | `video/webm` | no | -| `.pdf` | `application/pdf` | no | -| `.tar` | `application/x-tar` | no | -| `.zip` | `application/zip` | no | -| `.eot` | `application/vnd.ms-fontobject` | no | -| `.otf` | `font/otf` | no | -| `.ttf` | `font/ttf` | no | -| `.woff` | `font/woff` | no | -| `.woff2` | `font/woff2` | no | -| (no match) | `application/octet-stream` | no | +| Extension(s) | Content-Type | Text | +| --------------- | ------------------------------- | ---- | +| `.txt` | `text/plain` | yes | +| `.html`, `.htm` | `text/html` | yes | +| `.xml` | `application/xml` | yes | +| `.json` | `application/json` | yes | +| `.map` | `application/json` | yes | +| `.js` | `application/javascript` | yes | +| `.ts` | `application/typescript` | yes | +| `.css` | `text/css` | yes | +| `.svg` | `image/svg+xml` | yes | +| `.bmp` | `image/bmp` | no | +| `.png` | `image/png` | no | +| `.gif` | `image/gif` | no | +| `.jpg`, `.jpeg` | `image/jpeg` | no | +| `.ico` | `image/vnd.microsoft.icon` | no | +| `.tif`, `.tiff` | `image/png` | no | +| `.aac` | `audio/aac` | no | +| `.mp3` | `audio/mpeg` | no | +| `.avi` | `video/x-msvideo` | no | +| `.mp4` | `video/mp4` | no | +| `.mpeg` | `video/mpeg` | no | +| `.webm` | `video/webm` | no | +| `.pdf` | `application/pdf` | no | +| `.tar` | `application/x-tar` | no | +| `.zip` | `application/zip` | no | +| `.eot` | `application/vnd.ms-fontobject` | no | +| `.otf` | `font/otf` | no | +| `.ttf` | `font/ttf` | no | +| `.woff` | `font/woff` | no | +| `.woff2` | `font/woff2` | no | +| (no match) | `application/octet-stream` | no | ## When to Use diff --git a/docs/BUILD_CLI.md b/docs/BUILD_CLI.md index 44ad65e..b46ac57 100644 --- a/docs/BUILD_CLI.md +++ b/docs/BUILD_CLI.md @@ -14,7 +14,8 @@ npx fastedge-build --input src/index.js --output app.wasm # Config-driven build npx fastedge-build --config .fastedge/build-config.js -npx fastedge-build -c # uses default config path +npx fastedge-build .fastedge/build-config.js # single positional arg as config file +npx fastedge-build -c # uses default config path # Multiple configs npx fastedge-build -c config1.js -c config2.js @@ -26,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 @@ -72,6 +73,12 @@ For projects using a build config file (created by `fastedge-init`): npx fastedge-build --config .fastedge/build-config.js ``` +A single positional argument is also accepted as a config file path: + +```bash +npx fastedge-build .fastedge/build-config.js +``` + Config-driven builds support both `http` and `static` build types. Multiple config files are processed sequentially, each producing its own output `.wasm` file: ```bash @@ -116,25 +123,25 @@ 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 -When `type` is `'static'`, the following fields from `AssetCacheConfig` apply: +When `type` is `'static'`, the following fields from `AssetCacheConfig` apply. Because `BuildConfig extends Partial`, all are optional at the type level; however, a static build requires `publicDir` and `assetManifestPath` to produce output. -| Field | Type | Required | Description | -| --------------------- | ------------------------------ | -------- | -------------------------------------------- | -| `publicDir` | `string` | Yes | Directory containing static files to embed | -| `assetManifestPath` | `string` | Yes | Output path for the generated asset manifest | -| `contentTypes` | `Array` | No | Custom content type mappings | -| `ignoreDotFiles` | `boolean` | No | Skip files beginning with `.` | -| `ignorePaths` | `string[]` | No | Paths to exclude from the manifest | -| `ignoreWellKnown` | `boolean` | No | Skip the `.well-known/` directory | +| Field | Type | Required | Description | +| ------------------- | ------------------------------ | -------- | -------------------------------------------- | +| `publicDir` | `string` | No | Directory containing static files to embed | +| `assetManifestPath` | `string` | No | Output path for the generated asset manifest | +| `contentTypes` | `Array` | No | Custom content type mappings | +| `ignoreDotFiles` | `boolean` | No | Skip files beginning with `.` | +| `ignorePaths` | `string[]` | No | Paths to exclude from the manifest | +| `ignoreWellKnown` | `boolean` | No | Skip the `.well-known/` directory | ### ContentTypeDefinition @@ -152,7 +159,7 @@ Custom content-type rules are merged with the built-in defaults. Each rule match ### `type: 'http'` -Runs the standard pipeline: esbuild → Wizer → JCO. Produces a single `.wasm` component from the entry point. +Runs the standard pipeline: esbuild → regex precompilation → Wizer → JCO. Produces a single `.wasm` component from the entry point. ```js const config = { diff --git a/docs/INDEX.md b/docs/INDEX.md index 160fe37..141b841 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -1,38 +1,42 @@ # FastEdge JS SDK Documentation -The FastEdge JS SDK (`@gcoredev/fastedge-sdk-js`) is the JavaScript/TypeScript development toolkit for building serverless edge applications on Gcore's FastEdge platform. It compiles your code into WebAssembly components that run across global edge data centers. +The FastEdge JS SDK (`@gcoredev/fastedge-sdk-js`) is the JavaScript/TypeScript development toolkit +for building serverless edge applications on Gcore's FastEdge platform. It compiles your code into +WebAssembly components that run across global edge data centers. ## Package | Field | Value | | ----------- | --------------------------- | | **npm** | `@gcoredev/fastedge-sdk-js` | -| **Version** | `2.1.0` | +| **Version** | `2.2.2` | | **Node** | `>=22` | | **License** | `Apache-2.0` | ## CLI Tools -| Tool | Command | Purpose | -| ----------------------- | ---------------------------------------------- | ------------------------------- | -| **fastedge-build** | `npx fastedge-build ` | Compile JS/TS to WebAssembly | -| **fastedge-init** | `npx fastedge-init` | Interactive project scaffolding | -| **fastedge-assets** | `npx fastedge-assets ` | Generate static asset manifest | +| Tool | Command | Purpose | +| ------------------- | -------------------------------------- | ------------------------------- | +| **fastedge-build** | `npx fastedge-build ` | Compile JS/TS to WebAssembly | +| **fastedge-init** | `npx fastedge-init` | Interactive project scaffolding | +| **fastedge-assets** | `npx fastedge-assets ` | Generate static asset manifest | ## Documentation -| Document | Description | -| -------------------------------------- | -------------------------------------------- | -| [Quickstart](quickstart.md) | Installation and first build | -| [fastedge-build CLI](BUILD_CLI.md) | Compile JavaScript to WebAssembly | -| [fastedge-init CLI](INIT_CLI.md) | Scaffold a new FastEdge project | -| [fastedge-assets CLI](ASSETS_CLI.md) | Generate static asset manifests | -| [Static Sites](STATIC_SITES.md) | Serve static websites from WASM | -| [SDK Runtime API](SDK_API.md) | Environment, KV Store, Secrets, and Web APIs | +| Document | Description | +| ------------------------------------ | --------------------------------------------------- | +| [Quickstart](quickstart.md) | Installation and first build | +| [fastedge-build CLI](BUILD_CLI.md) | Compile JavaScript to WebAssembly | +| [fastedge-init CLI](INIT_CLI.md) | Scaffold a new FastEdge project | +| [fastedge-assets CLI](ASSETS_CLI.md) | Generate static asset manifests | +| [Static Sites](STATIC_SITES.md) | Serve static websites from WASM | +| [SDK Runtime API](SDK_API.md) | Environment, KV Store, Cache, Secrets, and Web APIs | ## Application Model -FastEdge apps use the Service Worker API pattern. The `addEventListener('fetch', ...)` call must be at the top level. The callback must synchronously call `event.respondWith()` with a handler that returns a `Response` (or `Promise`). +FastEdge apps use the Service Worker API pattern. The `addEventListener('fetch', ...)` call must be +at the top level. The callback must synchronously call `event.respondWith()` with a handler that +returns a `Response` (or `Promise`). ```js /// @@ -49,33 +53,122 @@ 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 -Runtime APIs are available via `fastedge::` module specifiers inside your application code. These imports are resolved at compile time by the SDK. +Runtime APIs are available via `fastedge::` module specifiers inside your application code. These +imports are resolved at compile time by the SDK. ### FastEdge APIs -| Import | Export | Signature | -| ----------------------- | -------------------------- | ------------------------------------------------------- | -| `fastedge::env` | `getEnv` | `(name: string): string \| null` | -| `fastedge::secret` | `getSecret` | `(name: string): string \| null` | -| `fastedge::secret` | `getSecretEffectiveAt` | `(name: string, effectiveAt: number): string \| null` | -| `fastedge::kv` | `KvStore.open` | `(name: string): KvStoreInstance` | +| Import | Export | Signature | +| ------------------ | ---------------------- | ----------------------------------------------------- | +| `fastedge::env` | `getEnv` | `(name: string): string \| null` | +| `fastedge::secret` | `getSecret` | `(name: string): string \| null` | +| `fastedge::secret` | `getSecretEffectiveAt` | `(name: string, effectiveAt: number): string \| null` | +| `fastedge::kv` | `KvStore.open` | `(name: string): KvStoreInstance` | +| `fastedge::cache` | `Cache` | static class — see Cache Methods | +| `fastedge::fs` | `readFileSync` | `(path: string): Uint8Array` — build-time only | ### KvStoreInstance Methods -| Method | Signature | Description | -| ------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------ | -| `get` | `(key: string): ArrayBuffer \| null` | Retrieve a value by key | -| `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 | -| `zscan` | `(key: string, pattern: string): Array<[ArrayBuffer, number]>` | Retrieve sorted set entries matching a prefix pattern | -| `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 + +The `getEntry`, `zrangeByScoreEntries`, and `zscanEntries` methods return `KvStoreEntry` objects +with the following accessors. The bytes are already in memory when the entry is returned; the +`Promise`-returning methods resolve immediately. + +| Method | Signature | Description | +| ------------- | -------------------------- | ---------------------------------------- | +| `arrayBuffer` | `(): Promise` | Read the entry as an `ArrayBuffer` | +| `text` | `(): Promise` | Read the entry as a UTF-8 decoded string | +| `json` | `(): Promise` | Read the entry as parsed JSON | + +### Cache Methods + +Import the `Cache` class from `fastedge::cache`. All methods are static; `Cache` is never +instantiated. The cache is POP-local: values written in one data center are not visible to another. +Use `fastedge::kv` for globally-replicated storage. + +```js +/// + +import { Cache } from 'fastedge::cache'; + +async function app(event) { + const ip = event.client.address || 'unknown'; + const count = await Cache.incr(`rl:${ip}`); + if (count === 1) await Cache.expire(`rl:${ip}`, { ttl: 60 }); + if (count > 100) return new Response('Too Many Requests', { status: 429 }); + + const entry = await Cache.getOrSet('result', async () => JSON.stringify(await compute()), { + ttl: 300, + }); + return new Response(await entry.text(), { + headers: { 'content-type': 'application/json' }, + }); +} + +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` | + +### CacheValue + +`CacheValue` is the union of types accepted by `Cache.set`, `Cache.getOrSet`, and the `populate` +callback. All forms are coerced to raw bytes before storage. + +```ts +type CacheValue = string | ArrayBuffer | ArrayBufferView | ReadableStream | Response; +``` + +### WriteOptions + +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`. | + +### CacheEntry Methods + +`Cache.get` and `Cache.getOrSet` return `CacheEntry` objects. The bytes are already in memory; the +`Promise`-returning methods resolve immediately. + +| Method | Signature | Description | +| ------------- | -------------------------- | ---------------------------------------- | +| `arrayBuffer` | `(): Promise` | Read the entry as an `ArrayBuffer` | +| `text` | `(): Promise` | Read the entry as a UTF-8 decoded string | +| `json` | `(): Promise` | Read the entry as parsed JSON | ### Web APIs @@ -84,9 +177,15 @@ Standard Web APIs available globally: - `fetch`, `Request`, `Response`, `Headers` - `URL`, `URLSearchParams` - `ReadableStream`, `WritableStream`, `TransformStream` +- `CompressionStream`, `DecompressionStream` - `TextEncoder`, `TextDecoder` -- `crypto` (SubtleCrypto) +- `Blob`, `File`, `FormData` +- `crypto` (SubtleCrypto: `digest`, `importKey`, `sign`, `verify`; also `getRandomValues`, `randomUUID`) +- `AbortController`, `AbortSignal` - `setTimeout`, `clearTimeout`, `setInterval`, `clearInterval` +- `queueMicrotask`, `structuredClone` +- `performance`, `location`, `console` +- `atob`, `btoa` ## See Also diff --git a/docs/INIT_CLI.md b/docs/INIT_CLI.md index bf65486..b830505 100644 --- a/docs/INIT_CLI.md +++ b/docs/INIT_CLI.md @@ -1,6 +1,7 @@ # fastedge-init CLI -Interactive scaffolding tool that generates build configuration and starter files for a new FastEdge application. +Interactive scaffolding tool that generates build configuration and starter files for a new FastEdge +application. ## Usage @@ -8,9 +9,11 @@ Interactive scaffolding tool that generates build configuration and starter file npx fastedge-init ``` -`fastedge-init` has no flags. All configuration is collected interactively. Run it from the root of your project directory. +`fastedge-init` has no flags. All configuration is collected interactively. Run it from the root of +your project directory. -If `.fastedge/build-config.js` already exists, the tool warns you and asks whether to overwrite it before proceeding. Answering `N` or pressing Enter exits without making changes. +If `.fastedge/build-config.js` already exists, the tool warns you and asks whether to overwrite it +before proceeding. Answering `N` or pressing Enter exits without making changes. ## Application Types @@ -42,14 +45,14 @@ The first prompt asks what you are building: ```js const config = { - "type": "http", - "tsConfigPath": "./tsconfig.json", - "entryPoint": "src/index.js", - "wasmOutput": ".fastedge/dist/main.wasm" + type: 'http', + tsConfigPath: './tsconfig.json', + entryPoint: 'src/index.js', + wasmOutput: '.fastedge/dist/main.wasm', }; const serverConfig = { - "type": "http" + type: 'http', }; export { config, serverConfig }; @@ -61,12 +64,12 @@ Your entry file must register a `fetch` event listener at the top level: ```js async function handleRequest(event) { - return new Response("Hello from FastEdge!", { - headers: { "Content-Type": "text/plain" }, + return new Response('Hello from FastEdge!', { + headers: { 'Content-Type': 'text/plain' }, }); } -addEventListener("fetch", (event) => { +addEventListener('fetch', (event) => { event.respondWith(handleRequest(event)); }); ``` @@ -83,21 +86,21 @@ npx fastedge-build --config .fastedge/build-config.js ### Prompts -| Prompt | Default | Validation | -| ---------------------------------------- | ------------------------------ | ------------------------------------------- | -| Path to your output file | `.fastedge/dist/fastedge.wasm` | Must end with `.wasm` | -| Path to your public directory | `./build` | Directory must exist | -| Is your site a single page application? | `No` | — | -| Path to your SPA entrypoint *(SPA only)* | `./index.html` | File must exist inside the public directory | +| Prompt | Default | Validation | +| ---------------------------------------- | ------------------------------ | ---------------------------------------------------------- | +| Path to your output file | `.fastedge/dist/fastedge.wasm` | Must end with `.wasm` | +| Path to your public directory | `./build` | Directory must exist | +| Is your site a single page application? | `No` | — | +| Path to your SPA entrypoint _(SPA only)_ | `./index.html` | Warns if `index.html` is not found in the public directory | ### Files Created -| File | Description | -| --------------------------- | ---------------------------------------------------------- | -| `.fastedge/static-index.js` | Generated entry file that wires the static server together | -| `.fastedge/build-config.js` | Build and server configuration module | -| `.fastedge/package.json` | Marks `.fastedge/` as an ES module project | -| `.fastedge/jsconfig.json` | Sets the compiler target to ES6 for the project directory | +| File | Description | +| --------------------------- | ------------------------------------------------------------ | +| `.fastedge/static-index.js` | Generated entry file that wires the static server together | +| `.fastedge/build-config.js` | Build and server configuration module | +| `.fastedge/package.json` | Marks `.fastedge/` as an ES module project | +| `.fastedge/jsconfig.json` | Sets the compiler target to ES2023 for the project directory | ### Generated Entry File @@ -132,31 +135,32 @@ addEventListener("fetch", (event) => event.respondWith(handleRequest(event))); ```js const config = { - "type": "static", - "entryPoint": ".fastedge/static-index.js", - "ignoreDotFiles": true, - "ignoreDirs": ["./node_modules"], - "ignoreWellKnown": false, - "tsConfigPath": "./tsconfig.json", - "wasmOutput": ".fastedge/dist/fastedge.wasm", - "publicDir": "./build" + type: 'static', + entryPoint: '.fastedge/static-index.js', + ignoreDotFiles: true, + ignoreDirs: ['./node_modules'], + ignoreWellKnown: false, + tsConfigPath: './tsconfig.json', + wasmOutput: '.fastedge/dist/fastedge.wasm', + publicDir: './build', }; const serverConfig = { - "type": "static", - "extendedCache": [], - "publicDirPrefix": "", - "compression": [], - "notFoundPage": "/404.html", - "autoExt": [], - "autoIndex": ["index.html", "index.htm"], - "spaEntrypoint": null + type: 'static', + extendedCache: [], + publicDirPrefix: '', + compression: [], + notFoundPage: '/404.html', + autoExt: [], + autoIndex: ['index.html', 'index.htm'], + spaEntrypoint: null, }; export { config, serverConfig }; ``` -For a SPA, `spaEntrypoint` is set to the normalized path entered at the prompt (e.g., `"/index.html"`). +For a SPA, `spaEntrypoint` is set to the normalized path entered at the prompt (e.g., +`"/index.html"`). ### Config Fields diff --git a/docs/SDK_API.md b/docs/SDK_API.md index d181423..7eea7da 100644 --- a/docs/SDK_API.md +++ b/docs/SDK_API.md @@ -45,10 +45,10 @@ addEventListener("fetch", event => event.respondWith(app(event))); import { getSecret, getSecretEffectiveAt } from "fastedge::secret"; ``` -| Function | Signature | Returns | -| ----------------------------------------- | --------------------------------------------------------------- | ---------------- | -| `getSecret(name)` | `(name: string) => string \| null` | `string \| null` | -| `getSecretEffectiveAt(name, effectiveAt)` | `(name: string, effectiveAt: number) => string \| null` | `string \| null` | +| Function | Signature | Returns | +| ----------------------------------------- | ------------------------------------------------------- | ---------------- | +| `getSecret(name)` | `(name: string) => string \| null` | `string \| null` | +| `getSecretEffectiveAt(name, effectiveAt)` | `(name: string, effectiveAt: number) => string \| null` | `string \| null` | **Note:** Secrets can only be read during request processing, not during build-time initialization. @@ -104,6 +104,8 @@ import { KvStore } from "fastedge::kv"; The `KvStore` class provides access to key-value stores attached to the application. Open a store by name using the static `open` method, then use the returned instance to query data. +`KvStore` is globally replicated with eventual consistency. Values written in one data center eventually become visible in others. It is suited to globally-shared configuration, lookup tables, and sorted sets. For strongly-consistent, POP-local storage with atomic counter primitives, see [Cache](#cache) below. + #### `KvStore.open` ```typescript @@ -136,15 +138,30 @@ async function app(event) { addEventListener("fetch", event => event.respondWith(app(event))); ``` +#### KvStoreEntry + +A handle to a value retrieved from the KV store. The bytes are already in memory when you receive a `KvStoreEntry`; the accessor methods return `Promise` to align with the standard Web `Body` interface, but they resolve immediately. + +| Method | Signature | Returns | +| --------------- | ---------------------------- | ---------------------- | +| `arrayBuffer()` | `() => Promise` | `Promise` | +| `text()` | `() => Promise` | `Promise` | +| `json()` | `() => Promise` | `Promise` | + +`json()` rejects with a `SyntaxError` if the bytes are not valid JSON. + #### KvStoreInstance methods -| Method | Signature | Returns | -| ------------------------------ | ------------------------------------------------------------------------- | ------------------------------ | -| `get(key)` | `(key: string) => ArrayBuffer \| null` | `ArrayBuffer \| null` | -| `scan(pattern)` | `(pattern: string) => Array` | `Array` | -| `zrangeByScore(key, min, max)` | `(key: string, min: number, max: number) => Array<[ArrayBuffer, number]>` | `Array<[ArrayBuffer, number]>` | -| `zscan(key, pattern)` | `(key: string, pattern: string) => Array<[ArrayBuffer, number]>` | `Array<[ArrayBuffer, number]>` | -| `bfExists(key, value)` | `(key: string, value: string) => boolean` | `boolean` | +| Method | Signature | Returns | +| ------------------------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------- | +| `get(key)` | `(key: string) => ArrayBuffer \| null` | `ArrayBuffer \| null` | +| `getEntry(key)` | `(key: string) => Promise` | `Promise` | +| `scan(pattern)` | `(pattern: string) => Array` | `Array` | +| `zrangeByScore(key, min, max)` | `(key: string, min: number, max: number) => Array<[ArrayBuffer, number]>` | `Array<[ArrayBuffer, number]>` | +| `zrangeByScoreEntries(key, min, max)` | `(key: string, min: number, max: number) => Promise>` | `Promise>` | +| `zscan(key, pattern)` | `(key: string, pattern: string) => Array<[ArrayBuffer, number]>` | `Array<[ArrayBuffer, number]>` | +| `zscanEntries(key, pattern)` | `(key: string, pattern: string) => Promise>` | `Promise>` | +| `bfExists(key, value)` | `(key: string, value: string) => boolean` | `boolean` | ##### `get` @@ -157,6 +174,30 @@ if (buf !== null) { } ``` +##### `getEntry` + +Retrieves the value for a key as a `KvStoreEntry` with `text()`, `json()`, and `arrayBuffer()` accessors. Use this instead of `get` when you want to decode the value as a string or JSON without manual `TextDecoder` work. + +```javascript +/// + +import { KvStore } from "fastedge::kv"; + +async function app(event) { + const kv = KvStore.open("my-store"); + const entry = await kv.getEntry("user:42"); + + if (entry === null) { + return new Response("not found", { status: 404 }); + } + + const user = await entry.json(); + return Response.json(user, { status: 200 }); +} + +addEventListener("fetch", event => event.respondWith(app(event))); +``` + ##### `scan` Returns all keys matching a prefix pattern. The pattern must include a wildcard character (e.g., `"prefix*"`). Returns an empty array if no keys match. @@ -178,6 +219,29 @@ for (const [buf, score] of entries) { } ``` +##### `zrangeByScoreEntries` + +Equivalent to `zrangeByScore` but each tuple's value is a `KvStoreEntry` instead of a raw `ArrayBuffer`. + +```javascript +/// + +import { KvStore } from "fastedge::kv"; + +async function app(event) { + const kv = KvStore.open("my-store"); + const entries = await kv.zrangeByScoreEntries("leaderboard", 100, 500); + + const rows = await Promise.all( + entries.map(async ([entry, score]) => ({ name: await entry.text(), score })) + ); + + return Response.json(rows, { status: 200 }); +} + +addEventListener("fetch", event => event.respondWith(app(event))); +``` + ##### `zscan` Returns all entries from a sorted set whose values match a prefix pattern. The pattern must include a wildcard (e.g., `"foo*"`). Each entry is a `[value, score]` tuple where `value` is an `ArrayBuffer`. Returns an empty array if no entries match. @@ -190,6 +254,29 @@ for (const [buf, score] of entries) { } ``` +##### `zscanEntries` + +Equivalent to `zscan` but each tuple's value is a `KvStoreEntry` instead of a raw `ArrayBuffer`. + +```javascript +/// + +import { KvStore } from "fastedge::kv"; + +async function app(event) { + const kv = KvStore.open("my-store"); + const entries = await kv.zscanEntries("leaderboard", "user:*"); + + const rows = await Promise.all( + entries.map(async ([entry, score]) => ({ name: await entry.text(), score })) + ); + + return Response.json(rows, { status: 200 }); +} + +addEventListener("fetch", event => event.respondWith(app(event))); +``` + ##### `bfExists` Checks whether a value is present in a Bloom Filter stored under the given key. Returns `true` if the value likely exists, `false` if it definitely does not. @@ -200,6 +287,227 @@ const seen = kv.bfExists("visited-ips", event.client.address); --- +### Cache + +**Module:** `fastedge::cache` + +```typescript +import { Cache } from "fastedge::cache"; +``` + +`Cache` is a POP-local key/value store with TTL and atomic counter primitives. It is strongly consistent within a single point-of-presence and is designed for transient, request-time state: rate limiting, hit counters, response memoisation, and deduplicated origin fetches. A value written from one data center is not visible to another. + +**`Cache` vs `KvStore` at a glance:** + +| Concern | `Cache` | `KvStore` | +| ----------------- | -------------------------------------------- | ----------------------------------------- | +| Consistency scope | Strong within a POP; independent across POPs | Eventual; globally replicated | +| Atomic operations | `incr`, `decr`, `getOrSet` coalescing | Not available | +| Typical use cases | Rate limits, counters, request coalescing | Configuration, lookup tables, sorted sets | +| Data persistence | Evicted; no durability guarantee | Durable; persists across deployments | + +Strong per-POP consistency makes `Cache.incr` / `Cache.decr` reliable for per-POP rate limits and `Cache.getOrSet` coalescing reliable for deduplicating concurrent origin fetches within a single POP. For globally-shared data that must be visible across all POPs, use `fastedge::kv`. + +#### CacheValue + +Values accepted by `Cache.set` and the `populate` callback of `Cache.getOrSet`: + +```typescript +type CacheValue = string | ArrayBuffer | ArrayBufferView | ReadableStream | Response; +``` + +All forms are stored as raw bytes: + +- `string` — encoded as UTF-8. +- `ArrayBuffer` / `ArrayBufferView` — used directly. +- `ReadableStream` — fully consumed before storage. +- `Response` — `response.arrayBuffer()` is consumed; status and headers are discarded. The cache stores bytes only. To round-trip status or headers, encode them into the value (e.g., as a JSON envelope). + +#### WriteOptions + +Controls how long a cache entry lives. Pass exactly one of `ttl`, `ttlMs`, or `expiresAt`. Passing more than one, or a zero or negative value, throws `TypeError`. Omitting `options` entirely stores the entry with no expiry (subject to host eviction policy). + +| Field | Type | Description | +| ----------- | -------- | -------------------------------------------------------------------------------- | +| `ttl` | `number` | Relative TTL, seconds from now. Mutually exclusive with `ttlMs`, `expiresAt`. | +| `ttlMs` | `number` | Relative TTL, milliseconds from now. Mutually exclusive with `ttl`, `expiresAt`. | +| `expiresAt` | `number` | Absolute expiry, Unix epoch seconds. Mutually exclusive with `ttl`, `ttlMs`. | + +#### CacheEntry + +A handle to a cached value. The bytes are already in memory when you receive a `CacheEntry`; the accessor methods return `Promise` to align with the standard Web `Body` interface, but they resolve immediately. + +| Method | Signature | Returns | +| --------------- | ---------------------------- | ---------------------- | +| `arrayBuffer()` | `() => Promise` | `Promise` | +| `text()` | `() => Promise` | `Promise` | +| `json()` | `() => Promise` | `Promise` | + +`json()` rejects with a `SyntaxError` if the bytes are not valid JSON. + +#### Cache methods + +All methods are static; `Cache` is never constructed. All methods return `Promise`. Operational errors surface as Promise rejections. Argument validation errors (wrong types, conflicting `WriteOptions` fields) throw synchronously; both are caught the same way by `try`/`catch` around an `await`. + +| Method | Signature | Returns | +| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | +| `get(key)` | `(key: string) => Promise` | `Promise` | +| `exists(key)` | `(key: string) => Promise` | `Promise` | +| `set(key, value, options?)` | `(key: string, value: CacheValue, options?: WriteOptions) => Promise` | `Promise` | +| `delete(key)` | `(key: string) => Promise` | `Promise` | +| `expire(key, options)` | `(key: string, options: WriteOptions) => Promise` | `Promise` | +| `incr(key, delta?)` | `(key: string, delta?: number) => Promise` | `Promise` | +| `decr(key, delta?)` | `(key: string, delta?: number) => Promise` | `Promise` | +| `getOrSet(key, populate, options?)` | `(key: string, populate: () => CacheValue \| Promise, options?: WriteOptions) => Promise` | `Promise` | +| `getOrSet(key, populate, options?)` | `(key: string, populate: () => CacheValue \| null \| Promise, options?: WriteOptions) => Promise` | `Promise` | + +##### `get` + +Returns the entry for `key`, or `null` if absent or expired. + +```javascript +/// + +import { Cache } from "fastedge::cache"; + +async function app(event) { + const entry = await Cache.get("user:42"); + + if (entry === null) { + return new Response("not found", { status: 404 }); + } + + const user = await entry.json(); + return Response.json(user, { status: 200 }); +} + +addEventListener("fetch", event => event.respondWith(app(event))); +``` + +##### `exists` + +Returns `true` if `key` is present in the cache. Cheaper than `get` when you only need presence, as no value bytes are transferred. + +```javascript +const present = await Cache.exists("feature-flag:beta"); +``` + +##### `set` + +Stores `value` under `key`, optionally with an expiry. Overwrites any existing value at `key`. + +```javascript +/// + +import { Cache } from "fastedge::cache"; + +async function app(event) { + const session = { userId: 42, role: "admin" }; + + // Store for 10 minutes + await Cache.set("session:abc", JSON.stringify(session), { ttl: 600 }); + + // Store with sub-second TTL + await Cache.set("nonce:xyz", "used", { ttlMs: 500 }); + + // Store until a fixed deadline + await Cache.set("promo:summer", "active", { expiresAt: 1751328000 }); + + return new Response("ok", { status: 200 }); +} + +addEventListener("fetch", event => event.respondWith(app(event))); +``` + +##### `delete` + +Removes `key` from the cache. A no-op if the key does not exist. + +```javascript +await Cache.delete("session:abc"); +``` + +##### `expire` + +Updates the expiry of an existing key without changing its value. Resolves to `true` if the expiry was set, `false` if the key does not exist. + +```javascript +await Cache.expire("rl:1.2.3.4", { ttl: 60 }); +``` + +##### `incr` and `decr` + +Atomically increment or decrement an integer stored at `key`. If the key does not exist, it is initialised to `0` before the operation. Resolves to the new value after the operation. Rejects if the stored value is not an integer. + +`delta` defaults to `1`. `Cache.decr` is sugar for `Cache.incr(key, -(delta ?? 1))`. `delta` may be any integer; prefer `decr` when subtracting for readability. + +Strong per-POP consistency makes these operations reliable for per-POP rate limits and hit counters. Set the TTL on the first increment to establish the window: + +```javascript +/// + +import { Cache } from "fastedge::cache"; + +async function app(event) { + const ip = event.client.address; + const key = `rl:${ip}`; + const count = await Cache.incr(key); + + if (count === 1) { + // First request in this window — set the 60-second expiry + await Cache.expire(key, { ttl: 60 }); + } + + if (count > 100) { + return new Response("Too Many Requests", { status: 429 }); + } + + return new Response("ok", { status: 200 }); +} + +addEventListener("fetch", event => event.respondWith(app(event))); +``` + +##### `getOrSet` + +Returns the entry for `key`, or calls `populate` on a cache miss and stores the result. All concurrent callers for the same key within the same WASM instance share a single `populate` execution — the callback is not duplicated for joiners. Concurrent requests handled by other WASM instances race independently and may each call `populate`. + +If `populate` throws or its Promise rejects, the rejection propagates to all current waiters. The next call after a failure retries `populate` (no negative caching). + +**Skip-cache signal:** if `populate` resolves with `null`, the value is _not_ written to the cache and `getOrSet` resolves with `null`. Use this to wrap fallible work and only pin successes — for example, caching only successful upstream responses so that a transient error response does not get stored for the duration of the TTL window. To also return the error response to the caller, use a manual `Cache.get` + conditional `Cache.set` instead. + +```javascript +/// + +import { Cache } from "fastedge::cache"; + +async function app(event) { + const url = new URL(event.request.url); + + // Cache only successful upstream responses; null skips the write. + const entry = await Cache.getOrSet( + `proxy:${url.pathname}`, + async () => { + const r = await fetch(`https://origin.example.com${url.pathname}`); + return r.ok ? r : null; + }, + { ttl: 30 }, + ); + + if (entry === null) { + return Response.json({ error: "upstream unavailable" }, { status: 503 }); + } + + return new Response(await entry.arrayBuffer(), { + headers: { "content-type": "application/json" }, + }); +} + +addEventListener("fetch", event => event.respondWith(app(event))); +``` + +--- + ## Fetch Event Every FastEdge application handles incoming requests by registering a listener for the `"fetch"` event. @@ -210,12 +518,13 @@ addEventListener("fetch", (event: FetchEvent) => void); ### FetchEvent -| Property / Method | Type | Description | -| ----------------- | ------------------------------------------------------- | ------------------------------------------------------- | -| `request` | `Request` | The incoming HTTP request from the client. | -| `client` | `ClientInfo` | Information about the downstream client. | -| `respondWith` | `(response: Response \| PromiseLike) => void` | Sends a response back to the client. | -| `waitUntil` | `(promise: Promise) => void` | Extends the service lifetime until the promise settles. | +| Property / Method | Type | Description | +| ----------------- | ------------------------------------------------------- | -------------------------------------------------------- | +| `request` | `Request` | The incoming HTTP request from the client. | +| `client` | `ClientInfo` | Information about the downstream client. | +| `server` | `ServerInfo` | Information about the FastEdge POP handling the request. | +| `respondWith` | `(response: Response \| PromiseLike) => void` | Sends a response back to the client. | +| `waitUntil` | `(promise: Promise) => void` | Extends the service lifetime until the promise settles. | `respondWith` must be called synchronously within the event listener, but may be passed a `Promise`. The service is kept alive until the response is fully sent. Use `waitUntil` to perform work (e.g., logging, telemetry) after the response has been sent. @@ -247,16 +556,73 @@ async function logRequest(url, ip) { ### ClientInfo -Information about the downstream client that made the request, available as `event.client`. +Information about the downstream client that made the request, available as `event.client`. All fields are derived from headers the FastEdge edge POP injects into the request. The `geo` namespace is populated lazily on first access. -| Property | Type | Description | -| ---------------------- | ------------- | ---------------------------------------------------- | -| `address` | `string` | IPv4 or IPv6 address of the downstream client. | -| `tlsJA3MD5` | `string` | JA3 MD5 fingerprint of the TLS client hello. | -| `tlsCipherOpensslName` | `string` | OpenSSL name of the negotiated TLS cipher. | -| `tlsProtocol` | `string` | Negotiated TLS protocol version string. | -| `tlsClientCertificate` | `ArrayBuffer` | Raw bytes of the client TLS certificate, if present. | -| `tlsClientHello` | `ArrayBuffer` | Raw bytes of the TLS client hello message. | +| Property | Type | Description | +| ----------- | --------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `address` | `string` | IPv4 or IPv6 address of the downstream client. Empty string if unavailable. | +| `tlsJA3MD5` | `string` | JA3 TLS-handshake fingerprint as an MD5 hex string. Empty string for non-TLS requests or when fingerprinting is unavailable. | +| `protocol` | `string` | Protocol family — `"https"` or `"http"`. Not the TLS version string. | +| `geo` | `GeoInfo` | Client geographic information. Populated lazily on first access. | + +### GeoInfo + +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. | +| `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. | + +```javascript +/// + +addEventListener("fetch", event => { + const { address, geo } = event.client; + console.log(`Request from ${address} in ${geo.city}, ${geo.countryCode}`); + event.respondWith(new Response("ok", { status: 200 })); +}); +``` + +### ServerInfo + +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. | + +### PopInfo + +Geographic information about the FastEdge POP serving the request, available as `event.server.pop`. + +| Property | Type | Description | +| ------------- | ---------------- | ----------------------------------------------------------------- | +| `latitude` | `number \| null` | POP latitude in decimal degrees, or `null` if unavailable. | +| `longitude` | `number \| null` | POP longitude in decimal degrees, or `null` if unavailable. | +| `region` | `string` | POP region or state code. Empty string if unavailable. | +| `continent` | `string` | POP continent code. Empty string if unavailable. | +| `countryCode` | `string` | ISO 3166-1 alpha-2 POP country code. Empty string if unavailable. | +| `countryName` | `string` | POP country name. Empty string if unavailable. | +| `city` | `string` | POP city. Empty string when not resolved. | + +```javascript +/// + +addEventListener("fetch", event => { + const { name, pop } = event.server; + console.log(`Served by ${name} in ${pop.city}, ${pop.countryCode}`); + event.respondWith(new Response("ok", { status: 200 })); +}); +``` --- @@ -289,26 +655,27 @@ const data = await response.json(); new Request(input: RequestInfo | URL, init?: RequestInit): Request ``` -| `RequestInit` field | Type | Description | -| ---------------------- | ------------------ | ---------------------------------------------------------- | -| `method` | `string` | HTTP method. Defaults to `"GET"`. | -| `headers` | `HeadersInit` | Request headers. | -| `body` | `BodyInit \| null` | Request body. | -| `manualFramingHeaders` | `boolean` | When `true`, disables automatic framing header management. | - -| `Request` property / method | Type | Description | -| --------------------------------- | ------------------------------------ | ------------------------------------------------- | -| `method` | `string` | HTTP method. | -| `url` | `string` | Request URL as a string. | -| `headers` | `Headers` | Request headers (read-only on incoming requests). | -| `body` | `ReadableStream \| null` | Request body stream. | -| `bodyUsed` | `boolean` | Whether the body has already been consumed. | -| `clone()` | `() => Request` | Creates a copy of the request. | -| `text()` | `() => Promise` | Reads body as a string. | -| `json()` | `() => Promise` | Reads body and parses as JSON. | -| `arrayBuffer()` | `() => Promise` | Reads body as an `ArrayBuffer`. | -| `setCacheKey(key)` | `(key: string) => void` | Sets a custom cache key for the request. | -| `setManualFramingHeaders(manual)` | `(manual: boolean) => void` | Toggles manual framing header control. | +| `RequestInit` field | Type | Description | +| ------------------- | --------------------- | --------------------------------- | +| `method` | `string` | HTTP method. Defaults to `"GET"`. | +| `headers` | `HeadersInit` | Request headers. | +| `body` | `BodyInit \| null` | Request body. | +| `signal` | `AbortSignal \| null` | Abort signal for the request. | + +| `Request` property / method | Type | Description | +| --------------------------- | ------------------------------------ | ------------------------------------------------- | +| `method` | `string` | HTTP method. | +| `url` | `string` | Request URL as a string. | +| `headers` | `Headers` | Request headers (read-only on incoming requests). | +| `signal` | `AbortSignal` | Abort signal associated with this request. | +| `body` | `ReadableStream \| null` | Request body stream. | +| `bodyUsed` | `boolean` | Whether the body has already been consumed. | +| `clone()` | `() => Request` | Creates a copy of the request. | +| `text()` | `() => Promise` | Reads body as a string. | +| `json()` | `() => Promise` | Reads body and parses as JSON. | +| `arrayBuffer()` | `() => Promise` | Reads body as an `ArrayBuffer`. | +| `blob()` | `() => Promise` | Reads body as a `Blob`. | +| `formData()` | `() => Promise` | Reads body as `FormData`. | **Note:** The `headers` property on an incoming `request` object (from `event.request`) is immutable — calls to `append`, `set`, or `delete` will throw. Clone the request or construct a new `Headers` object to modify headers. @@ -320,26 +687,28 @@ Response.redirect(url: string | URL, status?: number): Response Response.json(data: any, init?: ResponseInit): Response ``` -| `ResponseInit` field | Type | Description | -| ---------------------- | ------------- | ---------------------------------------------------------- | -| `status` | `number` | HTTP status code. Defaults to `200`. | -| `statusText` | `string` | HTTP status text. | -| `headers` | `HeadersInit` | Response headers. | -| `manualFramingHeaders` | `boolean` | When `true`, disables automatic framing header management. | - -| `Response` property / method | Type | Description | -| --------------------------------- | ------------------------------------ | ------------------------------------------- | -| `status` | `number` | HTTP status code. | -| `statusText` | `string` | HTTP status text. | -| `ok` | `boolean` | `true` if status is in the range 200–299. | -| `url` | `string` | URL of the response. | -| `headers` | `Headers` | Response headers. | -| `body` | `ReadableStream \| null` | Response body stream. | -| `bodyUsed` | `boolean` | Whether the body has already been consumed. | -| `text()` | `() => Promise` | Reads body as a string. | -| `json()` | `() => Promise` | Reads body and parses as JSON. | -| `arrayBuffer()` | `() => Promise` | Reads body as an `ArrayBuffer`. | -| `setManualFramingHeaders(manual)` | `(manual: boolean) => void` | Toggles manual framing header control. | +| `ResponseInit` field | Type | Description | +| -------------------- | ------------- | ------------------------------------ | +| `status` | `number` | HTTP status code. Defaults to `200`. | +| `statusText` | `string` | HTTP status text. | +| `headers` | `HeadersInit` | Response headers. | + +| `Response` property / method | Type | Description | +| ---------------------------- | ------------------------------------ | ------------------------------------------- | +| `status` | `number` | HTTP status code. | +| `statusText` | `string` | HTTP status text. | +| `ok` | `boolean` | `true` if status is in the range 200–299. | +| `redirected` | `boolean` | `true` if the response was redirected. | +| `url` | `string` | URL of the response. | +| `type` | `ResponseType` | Response type (e.g., `"basic"`, `"cors"`). | +| `headers` | `Headers` | Response headers. | +| `body` | `ReadableStream \| null` | Response body stream. | +| `bodyUsed` | `boolean` | Whether the body has already been consumed. | +| `text()` | `() => Promise` | Reads body as a string. | +| `json()` | `() => Promise` | Reads body and parses as JSON. | +| `arrayBuffer()` | `() => Promise` | Reads body as an `ArrayBuffer`. | +| `blob()` | `() => Promise` | Reads body as a `Blob`. | +| `formData()` | `() => Promise` | Reads body as `FormData`. | #### `Headers` @@ -356,10 +725,11 @@ new Headers(init?: HeadersInit): Headers | `set(name, value)` | `(name: string, value: string) => void` | | `append(name, value)` | `(name: string, value: string) => void` | | `delete(name)` | `(name: string) => void` | +| `getSetCookie()` | `() => string[]` | | `forEach(callback)` | `(callback: (value: string, key: string, parent: Headers) => void) => void` | | `entries()` | `() => IterableIterator<[string, string]>` | -| `keys()` | `() => IterableIterator` | -| `values()` | `() => IterableIterator` | +| `keys()` | `() => IterableIterator` | +| `values()` | `() => IterableIterator` | **Immutability note:** The `headers` object on an incoming `event.request` is read-only. Attempting to mutate it will throw a `TypeError`. To add or change headers, construct a new `Headers` object: @@ -456,6 +826,8 @@ new ReadableStream(underlyingSource?: UnderlyingSource, strategy?: Queuing | `tee()` | `() => [ReadableStream, ReadableStream]` | | `cancel(reason?)` | `(reason?: any) => Promise` | +To read a byte stream with a caller-supplied buffer, call `getReader({ mode: 'byob' })` which returns a `ReadableStreamBYOBReader`. The BYOB reader's `read(view)` method fills the provided `ArrayBufferView` in-place. + ```javascript const stream = new ReadableStream({ start(controller) { @@ -494,6 +866,47 @@ new TransformStream( | `readable` | `ReadableStream` | The readable side of the transform. | | `writable` | `WritableStream` | The writable side of the transform. | +#### Queuing Strategies + +Two built-in queuing strategies control backpressure. Both accept `{ highWaterMark: number }`. + +```typescript +new ByteLengthQueuingStrategy(init: QueuingStrategyInit): ByteLengthQueuingStrategy +new CountQueuingStrategy(init: QueuingStrategyInit): CountQueuingStrategy +``` + +| Strategy | Counts | +| --------------------------- | ------------------------------------------- | +| `ByteLengthQueuingStrategy` | Byte length of each `ArrayBufferView` chunk | +| `CountQueuingStrategy` | Each chunk as a single unit | + +#### Compression Streams + +```typescript +new CompressionStream(format: CompressionFormat): CompressionStream +new DecompressionStream(format: CompressionFormat): DecompressionStream +``` + +`CompressionFormat` is one of `"deflate"`, `"deflate-raw"`, or `"gzip"`. Both implement the transform-stream shape (`readable` / `writable`) and can be piped directly with `pipeThrough`. + +```javascript +/// + +async function app(event) { + const upstream = await fetch("https://origin.example.com/data"); + const compressed = upstream.body.pipeThrough(new CompressionStream("gzip")); + + return new Response(compressed, { + headers: { + "content-type": upstream.headers.get("content-type") ?? "application/octet-stream", + "content-encoding": "gzip", + }, + }); +} + +addEventListener("fetch", event => event.respondWith(app(event))); +``` + --- ### Encoding API @@ -507,6 +920,8 @@ const encoded = new TextEncoder().encode("hello"); // Uint8Array const decoded = new TextDecoder().decode(encoded); // "hello" ``` +`TextDecoder` accepts an optional encoding label (default `"utf-8"`) and options `{ fatal?: boolean, ignoreBOM?: boolean }`. `TextEncoder` always encodes as UTF-8 and additionally exposes `encodeInto(source, destination)` which writes into a pre-allocated `Uint8Array` and returns `{ read, written }`. + #### Base64 ```typescript @@ -526,6 +941,106 @@ const decoded = atob(encoded); // "hello world" --- +### File API + +#### `Blob` + +```typescript +new Blob(blobParts?: BlobPart[], options?: BlobPropertyBag): Blob +``` + +`BlobPart` is `BufferSource | Blob | string`. `BlobPropertyBag` accepts `{ type?: string, endings?: "native" | "transparent" }`. + +| `Blob` property / method | Type / Signature | Description | +| ----------------------------------- | -------------------------------------------------------------- | ---------------------------------------- | +| `size` | `number` | Total byte length. | +| `type` | `string` | MIME type string. | +| `arrayBuffer()` | `() => Promise` | Reads content as an `ArrayBuffer`. | +| `bytes()` | `() => Promise` | Reads content as a `Uint8Array`. | +| `text()` | `() => Promise` | Reads content as a UTF-8 string. | +| `stream()` | `() => ReadableStream` | Returns a `ReadableStream` of the bytes. | +| `slice(start?, end?, contentType?)` | `(start?: number, end?: number, contentType?: string) => Blob` | Returns a sub-blob. | + +#### `File` + +```typescript +new File(fileBits: BlobPart[], fileName: string, options?: FilePropertyBag): File +``` + +`File` extends `Blob` and adds: + +| Property | Type | Description | +| -------------- | -------- | ----------------------------------------- | +| `name` | `string` | File name as provided to the constructor. | +| `lastModified` | `number` | Last modified timestamp in milliseconds. | + +#### `FormData` + +```typescript +new FormData(): FormData +``` + +`FormDataEntryValue` is `File | string`. + +| Method | Signature | +| --------------------- | ---------------------------------------------------------------------------------------- | +| `append(name, value)` | `(name: string, value: string \| Blob, fileName?: string) => void` | +| `delete(name)` | `(name: string) => void` | +| `get(name)` | `(name: string) => FormDataEntryValue \| null` | +| `getAll(name)` | `(name: string) => FormDataEntryValue[]` | +| `has(name)` | `(name: string) => boolean` | +| `set(name, value)` | `(name: string, value: string \| Blob, fileName?: string) => void` | +| `forEach(callback)` | `(callback: (value: FormDataEntryValue, key: string, parent: FormData) => void) => void` | +| `entries()` | `() => IterableIterator<[string, FormDataEntryValue]>` | +| `keys()` | `() => IterableIterator` | +| `values()` | `() => IterableIterator` | + +--- + +### Abort API + +#### `AbortController` / `AbortSignal` + +```typescript +new AbortController(): AbortController +``` + +| `AbortController` member | Type / Signature | Description | +| ------------------------ | ------------------------ | ------------------------------------ | +| `signal` | `AbortSignal` | The associated signal object. | +| `abort(reason?)` | `(reason?: any) => void` | Triggers the signal's aborted state. | + +| `AbortSignal` member | Type / Signature | Description | +| ---------------------------- | ----------------------------------------- | --------------------------------------------------- | +| `aborted` | `boolean` | Whether the signal has been aborted. | +| `reason` | `any` | The abort reason, if any. | +| `onabort` | `((ev: Event) => any) \| null` | Event handler fired when the signal aborts. | +| `throwIfAborted()` | `() => void` | Throws the abort reason if the signal is aborted. | +| `AbortSignal.abort(reason?)` | `(reason?: any) => AbortSignal` | Returns an already-aborted signal. | +| `AbortSignal.timeout(ms)` | `(milliseconds: number) => AbortSignal` | Returns a signal that aborts after the given delay. | +| `AbortSignal.any(signals)` | `(signals: AbortSignal[]) => AbortSignal` | Returns a signal that aborts when any input aborts. | + +Pass a signal via `RequestInit.signal` to cancel an in-flight `fetch`: + +```javascript +/// + +async function app(event) { + try { + const response = await fetch("https://slow-origin.example.com/data", { + signal: AbortSignal.timeout(5000), + }); + return new Response(await response.text(), { status: 200 }); + } catch (err) { + return new Response("upstream timeout", { status: 504 }); + } +} + +addEventListener("fetch", event => event.respondWith(app(event))); +``` + +--- + ### Crypto API #### `crypto` @@ -542,12 +1057,21 @@ crypto.subtle: SubtleCrypto Available as `crypto.subtle`. Supported operations: -| Method | Signature | -| ----------- | ------------------------------------------------------------------------------------------------------------------- | -| `digest` | `(algorithm: AlgorithmIdentifier, data: BufferSource) => Promise` | -| `importKey` | See overloads below | -| `sign` | `(algorithm: AlgorithmIdentifier, key: CryptoKey, data: BufferSource) => Promise` | -| `verify` | `(algorithm: AlgorithmIdentifier, key: CryptoKey, signature: BufferSource, data: BufferSource) => Promise` | +| Method | Signature | +| ----------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `digest` | `(algorithm: AlgorithmIdentifier, data: BufferSource) => Promise` | +| `importKey` | See overloads below | +| `sign` | `(algorithm: AlgorithmIdentifier \| EcdsaParams, key: CryptoKey, data: BufferSource) => Promise` | +| `verify` | `(algorithm: AlgorithmIdentifier \| EcdsaParams, key: CryptoKey, signature: BufferSource, data: BufferSource) => Promise` | + +Supported algorithms: + +| 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` | `importKey` overloads: @@ -556,22 +1080,30 @@ Available as `crypto.subtle`. Supported operations: subtle.importKey( format: 'jwk', keyData: JsonWebKey, - algorithm: AlgorithmIdentifier | RsaHashedImportParams | EcKeyImportParams, + algorithm: AlgorithmIdentifier | HmacImportParams | RsaHashedImportParams | EcKeyImportParams, extractable: boolean, keyUsages: ReadonlyArray, ): Promise -// Raw / other formats +// Raw / SPKI / PKCS#8 formats subtle.importKey( format: Exclude, keyData: BufferSource, - algorithm: AlgorithmIdentifier | RsaHashedImportParams | HmacImportParams, + algorithm: AlgorithmIdentifier | HmacImportParams | RsaHashedImportParams | EcKeyImportParams, extractable: boolean, keyUsages: KeyUsage[], ): Promise ``` -Supported `KeyFormat` values: `"jwk"`, `"raw"`. +Supported `(algorithm, format)` combinations: + +| Algorithm | Supported formats | +| ------------------- | -------------------------------------- | +| `HMAC` | `'raw'`, `'jwk'` | +| `RSASSA-PKCS1-v1_5` | `'jwk'`, `'spki'`, `'pkcs8'` | +| `ECDSA` | `'jwk'`, `'raw'`, `'spki'`, `'pkcs8'` | + +`ECDSA` requires `EcdsaParams` (`{ name: 'ECDSA', hash: AlgorithmIdentifier }`) for `sign` and `verify` so that the hash function can be specified. ```javascript // Compute SHA-256 digest @@ -646,6 +1178,20 @@ console.log(`elapsed: ${elapsed}ms`); --- +### DOM Events + +The standard `Event`, `EventTarget`, and `CustomEvent` interfaces are available as globals. These underpin the `FetchEvent` mechanism and can be used to implement custom event dispatch within an application. + +```typescript +new Event(type: string, eventInitDict?: EventInit): Event +new CustomEvent(type: string, eventInitDict?: CustomEventInit): CustomEvent +new EventTarget(): EventTarget +``` + +`EventTarget` exposes `addEventListener`, `removeEventListener`, and `dispatchEvent`. `CustomEvent` extends `Event` and adds a `detail` property carrying application-defined data. + +--- + ### Additional Globals | Global | Type / Signature | Description | @@ -655,11 +1201,12 @@ console.log(`elapsed: ${elapsed}ms`); | `queueMicrotask(callback)` | `(callback: () => void) => void` | Queues a microtask. | | `structuredClone(value, opts?)` | `(value: any, options?: StructuredSerializeOptions) => any` | Deep-clones a value. Transferable: `ArrayBuffer`. | +`WorkerLocation` exposes `href`, `origin`, `protocol`, `host`, `hostname`, `port`, `pathname`, `search`, and `hash` as read-only string properties. + --- ## See Also -- [quickstart.md](quickstart.md) — Getting started with your first FastEdge application - [BUILD_CLI.md](BUILD_CLI.md) — `fastedge-build` CLI reference - [INIT_CLI.md](INIT_CLI.md) — `fastedge-init` CLI reference - [STATIC_SITES.md](STATIC_SITES.md) — Serving static assets from WASM diff --git a/docs/STATIC_SITES.md b/docs/STATIC_SITES.md index 7a1e3ef..0632039 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 90a5983..617de38 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -147,14 +147,13 @@ addEventListener('fetch', (event) => { event.respondWith( (async () => { const store = KvStore.open('my-store'); - const value = store.get('my-key'); + const entry = await store.getEntry('my-key'); - if (value) { - const text = new TextDecoder().decode(value); - return new Response(text); + if (entry === null) { + return new Response('Key not found', { status: 404 }); } - return new Response('Key not found', { status: 404 }); + return new Response(await entry.text()); })(), ); }); @@ -164,11 +163,70 @@ addEventListener('fetch', (event) => { - `KvStore.open(name: string): KvStoreInstance` - `KvStoreInstance.get(key: string): ArrayBuffer | null` +- `KvStoreInstance.getEntry(key: string): Promise` - `KvStoreInstance.scan(pattern: string): Array` - `KvStoreInstance.zrangeByScore(key: string, min: number, max: number): Array<[ArrayBuffer, number]>` +- `KvStoreInstance.zrangeByScoreEntries(key: string, min: number, max: number): Promise>` - `KvStoreInstance.zscan(key: string, pattern: string): Array<[ArrayBuffer, number]>` +- `KvStoreInstance.zscanEntries(key: string, pattern: string): Promise>` - `KvStoreInstance.bfExists(key: string, value: string): boolean` +**`KvStoreEntry` methods:** + +- `arrayBuffer(): Promise` +- `text(): Promise` +- `json(): Promise` + +## Using Cache + +The `fastedge::cache` module provides a POP-local key/value store with TTL and atomic counter primitives. Unlike `fastedge::kv`, which is globally replicated across all data centers, the cache is strongly consistent within a single point of presence and invisible to other POPs — making it suited for request-time state such as rate limiting, hit counters, and response memoisation where low latency within a POP matters more than global durability. + +```js +/// +import { Cache } from 'fastedge::cache'; + +addEventListener('fetch', (event) => { + event.respondWith( + (async () => { + const ip = event.request.headers.get('x-forwarded-for') ?? 'unknown'; + + // Atomic per-IP counter; initialised to 0 on first call + const count = await Cache.incr(`rl:${ip}`); + if (count === 1) await Cache.expire(`rl:${ip}`, { ttl: 60 }); + if (count > 100) { + return new Response('Too Many Requests', { status: 429 }); + } + + // Cache-aside: serve from cache, populate on miss + const entry = await Cache.getOrSet( + 'upstream-data', + async () => { + const r = await fetch('https://example.com/data'); + return r.ok ? r : null; + }, + { ttl: 30 }, + ); + + if (entry === null) { + return new Response('Upstream unavailable', { status: 503 }); + } + + return new Response(await entry.text()); + })(), + ); +}); +``` + +**Key signatures:** + +- `Cache.get(key: string): Promise` +- `Cache.set(key: string, value: CacheValue, options?: WriteOptions): Promise` +- `Cache.getOrSet(key: string, populate: () => CacheValue | Promise, options?: WriteOptions): Promise` +- `Cache.incr(key: string, delta?: number): Promise` +- `Cache.expire(key: string, options: WriteOptions): Promise` + +See [SDK Runtime API](SDK_API.md) for the complete `Cache` method reference, `CacheValue` type, and `WriteOptions` fields. + ## Next Steps - [fastedge-build CLI](BUILD_CLI.md) — build options and configuration diff --git a/examples/README.md b/examples/README.md index 312cf75..a3d1e0b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -13,6 +13,7 @@ network using | [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 | ## Full Examples @@ -22,6 +23,7 @@ network using | [ab-testing](./ab-testing/) | Cookie-based A/B testing — assigns weighted variants to returning users | | [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 | diff --git a/examples/cache-basic/README.md b/examples/cache-basic/README.md new file mode 100644 index 0000000..bf1f972 --- /dev/null +++ b/examples/cache-basic/README.md @@ -0,0 +1,33 @@ +[← Back to examples](../README.md) + +# Cache Basic + +The simplest cache example — `set`, `get`, `exists`, and `delete` against the FastEdge POP-local cache, driven by URL query parameters. + +## Try it + +After deploying the wasm to a FastEdge app, call each action in turn: + +```sh +GET /?action=set&key=greeting&value=hello # Stores "greeting" with a 60s TTL +GET /?action=get&key=greeting # { hit: true, value: "hello" } +GET /?action=exists&key=greeting # { present: true } +GET /?action=delete&key=greeting # { deleted: true } +GET /?action=get&key=greeting # { hit: false } +``` + +Wait 60 seconds between `set` and `get` to see TTL expiry produce a miss. + +## What this demonstrates + +- `Cache.set(key, value, { ttl })` — write a value with an optional expiry +- `Cache.get(key)` — read a value, returns `null` on miss or expiry +- `CacheEntry.text()` — decode the cached bytes as a UTF-8 string +- `Cache.exists(key)` — cheap presence check that does not transfer the value +- `Cache.delete(key)` — remove an entry (no-op if absent) + +For atomic counters (`incr` / `decr`), the `getOrSet` populate-on-miss pattern, and rate-limiting / origin-cache examples, see [cache](../cache/). + +## Notes + +The cache is **strongly consistent within a POP** but **not replicated between POPs** — a value written in one data center is not visible to another. For globally-replicated key/value storage, use [`fastedge::kv`](../kv-store/). diff --git a/examples/cache-basic/package.json b/examples/cache-basic/package.json new file mode 100644 index 0000000..4b36f2b --- /dev/null +++ b/examples/cache-basic/package.json @@ -0,0 +1,12 @@ +{ + "name": "fastedge-example-cache-basic", + "version": "1.0.0", + "description": "FastEdge JS example: simple Cache set/get/exists/delete operations", + "type": "module", + "scripts": { + "build": "fastedge-build src/index.js dist/cache-basic.wasm" + }, + "dependencies": { + "@gcoredev/fastedge-sdk-js": "^2.2.3" + } +} diff --git a/examples/cache-basic/src/index.js b/examples/cache-basic/src/index.js new file mode 100644 index 0000000..3a984a2 --- /dev/null +++ b/examples/cache-basic/src/index.js @@ -0,0 +1,90 @@ +// FastEdge Cache — basic operations +// +// The `fastedge::cache` module gives you a fast, data-center-scoped +// key/value store. Values written here are stored in the same point of +// presence (POP) that runs the worker, so reads and writes are very fast, +// and writes from one POP are not visible to others. +// +// Use this for transient, request-time state — short-lived caches, hit +// counters, rate limit windows, deduplicated work. For globally +// replicated storage, use the `fastedge::kv` module instead. +// +// This example demonstrates the four most common operations: +// +// GET /?action=set&key=foo&value=bar -> Cache.set +// GET /?action=get&key=foo -> Cache.get +// GET /?action=exists&key=foo -> Cache.exists +// GET /?action=delete&key=foo -> Cache.delete + +import { Cache } from 'fastedge::cache'; + +async function eventHandler(event) { + try { + const url = new URL(event.request.url); + const action = url.searchParams.get('action'); + const key = url.searchParams.get('key'); + + if (!key) { + throw new Error('Missing required query parameter: "key"'); + } + + switch (action) { + case 'set': { + // Cache.set writes a value under `key`. Accepts strings, + // ArrayBuffers, ArrayBufferViews, ReadableStreams, and Response + // objects (the body is consumed; status and headers are not stored). + // + // The `{ ttl: 60 }` option means "expire 60 seconds from now". You + // can also use `ttlMs` for sub-second precision, or `expiresAt` for + // a fixed Unix-epoch deadline. Omit options entirely for no expiry. + const value = url.searchParams.get('value') ?? ''; + await Cache.set(key, value, { ttl: 60 }); + return Response.json({ action, key, value, ttl: 60 }); + } + + case 'get': { + // Cache.get returns a CacheEntry on a hit, or `null` on a miss + // (key absent or expired). The cache stores raw bytes, so on read + // you choose how to decode using one of: + // entry.text() -> Promise (UTF-8) + // entry.json() -> Promise (parsed JSON) + // entry.arrayBuffer() -> Promise + const entry = await Cache.get(key); + if (entry === null) { + return Response.json({ action, key, hit: false }); + } + const value = await entry.text(); + return Response.json({ action, key, hit: true, value }); + } + + case 'exists': { + // Cache.exists is a cheap presence check — useful when you only + // need to know whether a key is set without transferring its value + // (e.g. idempotency-key checks, "have we seen this token?"). + const present = await Cache.exists(key); + return Response.json({ action, key, present }); + } + + case 'delete': { + // Cache.delete removes the entry. It is a no-op if the key is + // already absent — no error is thrown. + await Cache.delete(key); + return Response.json({ action, key, deleted: true }); + } + + default: + throw new Error( + `Unknown action: "${action}". Use one of: set, get, exists, delete.`, + ); + } + } catch (error) { + // Validation errors (e.g. wrong types, conflicting WriteOptions fields) + // are thrown synchronously; host errors (access denied, internal error) + // arrive as Promise rejections. Both are caught by this single handler. + return Response.json({ error: error.message }, { status: 500 }); + } +} + +addEventListener('fetch', (event) => { + event.respondWith(eventHandler(event)); +}); diff --git a/examples/cache/.fastedge/build-config.js b/examples/cache/.fastedge/build-config.js new file mode 100644 index 0000000..ef0c708 --- /dev/null +++ b/examples/cache/.fastedge/build-config.js @@ -0,0 +1,12 @@ +const config = { + type: "http", + tsConfigPath: "./tsconfig.json", + entryPoint: "src/index.ts", + wasmOutput: "dist/cache.wasm", +}; + +const serverConfig = { + type: "http", +}; + +export { config, serverConfig }; diff --git a/examples/cache/README.md b/examples/cache/README.md new file mode 100644 index 0000000..84f3d62 --- /dev/null +++ b/examples/cache/README.md @@ -0,0 +1,76 @@ +[← Back to examples](../README.md) + +# Cache + +Three flagship patterns for the FastEdge POP-local cache, each demonstrating a capability that is hard to build correctly without an in-POP, strongly-consistent byte store. + +For the absolute basics (`set` / `get` / `exists` / `delete`), see [cache-basic](../cache-basic/). + +## Patterns + +### 1. Rate limiting — `?action=rate-limit` + +Per-IP request counter with a 60-second fixed window anchored to the first request. Each call increments an atomic counter keyed by `event.client.address`; on the first hit of a window the TTL is attached, so the counter expires 60 seconds after that first request and the next request opens a new window. + +```sh +GET /?action=rate-limit +# { count: 1, remaining: 9, ... } + +# Repeat 10x within 60s ... +# { error: "Too Many Requests", count: 11 } (HTTP 429) +``` + +**Why the cache and not `fastedge::kv`:** atomic `incr` requires strong consistency under concurrent load. The eventually-consistent KV store cannot guarantee a single global count. + +### 2. Origin-cache proxy — `?action=proxy&url=...` + +Fetches an upstream URL on the first request and caches the response bytes for 30 seconds. Only successful (`response.ok`) responses are cached; non-2xx and redirects flow through unchanged so callers see the real status code instead of a synthetic 200. The pattern uses manual `Cache.get` + conditional `Cache.set` because `getOrSet`'s populator can't signal "fetched, but don't cache" (see Pattern 3 for `getOrSet` with in-process coalescing). + +```sh +GET /?action=proxy&url=https://example.com +# First call: fetches example.com, caches body, returns it +# Within 30s: served from cache, no upstream call +``` + +**What is and isn't preserved:** the response *body* is cached; status and headers are not. The cache is a byte cache, not an HTTP cache. To round-trip headers, encode them into a JSON envelope on write. + +### 3. JSON memoisation — `?action=memo` + +Generates a small report once, caches the JSON for 60 seconds, and returns it. Refresh the endpoint within the window — the embedded `generatedAt` timestamp stays constant. After 60 seconds it changes. + +```sh +GET /?action=memo +# { report: { generatedAt: "...", topItems: [...] } } +``` + +Use this shape for any expensive synchronous computation: signed-token verification, derived rollups, JSON transformations of slow-changing source data. + +## Build + +```sh +pnpm install +pnpm run build # produces dist/cache.wasm +``` + +The build is configured via `.fastedge/build-config.js` (TypeScript entry point, output path). + +## Cache API quick reference + +| Method | Purpose | +|---|---| +| `Cache.get(key)` | Read; returns `CacheEntry \| null`. | +| `Cache.set(key, value, options?)` | Write any string / ArrayBuffer / ReadableStream / Response. | +| `Cache.exists(key)` | Cheap presence check. | +| `Cache.delete(key)` | Remove (no-op if absent). | +| `Cache.expire(key, options)` | Set/refresh TTL on an existing key. | +| `Cache.incr(key, delta?)` | Atomic counter increment, returns new value. | +| `Cache.decr(key, delta?)` | Atomic counter decrement. | +| `Cache.getOrSet(key, populate, options?)` | Read or populate-then-write on miss; coalesces concurrent populators. | + +`WriteOptions` is one of `{ ttl: seconds }`, `{ ttlMs: milliseconds }`, `{ expiresAt: unixSeconds }`, or `{}` for no expiry. + +## Notes + +- **POP-local:** values written in one data center are not visible to another. For globally-replicated, eventually-consistent storage use [`fastedge::kv`](../kv-store/). +- **Coalescing scope:** `getOrSet` deduplicates populator runs *within one WASM instance*. Other workers in the same POP race independently. +- **Strong typing:** import types from `fastedge::cache` (`CacheValue`, `CacheEntry`, `WriteOptions`) for compile-time safety on the populator signature. diff --git a/examples/cache/package.json b/examples/cache/package.json new file mode 100644 index 0000000..5f393b1 --- /dev/null +++ b/examples/cache/package.json @@ -0,0 +1,12 @@ +{ + "name": "fastedge-example-cache", + "version": "1.0.0", + "description": "FastEdge JS example: Cache patterns — rate limiting, origin-cache proxy, memoisation", + "type": "module", + "scripts": { + "build": "fastedge-build -c" + }, + "dependencies": { + "@gcoredev/fastedge-sdk-js": "^2.2.3" + } +} diff --git a/examples/cache/src/index.ts b/examples/cache/src/index.ts new file mode 100644 index 0000000..7b6249e --- /dev/null +++ b/examples/cache/src/index.ts @@ -0,0 +1,233 @@ +// FastEdge Cache — flagship patterns +// +// This example demonstrates the three highest-value uses of the +// `fastedge::cache` module: +// +// 1. Per-IP rate limiting (atomic counters) +// 2. Origin-cache proxy (manual get/set with conditional caching) +// 3. JSON memoisation (getOrSet with a computed populator) +// +// All three patterns rely on the cache being: +// - **Strongly consistent within a POP** — atomic `incr` returns a +// correct count under concurrent load, which `fastedge::kv` cannot. +// - **Fast for both reads and writes** — sub-millisecond on the hot +// path, so caching is cheaper than recomputing or refetching. +// - **POP-local** — values do not replicate across data centers. +// This is acceptable (and often desirable) for transient state. + +import { Cache } from 'fastedge::cache'; + +// --------------------------------------------------------------------------- +// Pattern 1 — Rate limiting via atomic incr + expire +// --------------------------------------------------------------------------- +// +// Increment a per-IP counter. On the first hit (count === 1) we attach +// a TTL to create a fixed 60-second window anchored to that request: +// the counter resets 60 seconds after the user's *first* request, not +// after every request. +// +// `Cache.incr` is atomic: under concurrent load, two simultaneous +// requests cannot both see "count === 1" and double-set the expiry. +// This is the property that makes the cache suitable for limiting, +// quotas, locks, and other counter primitives. + +const RATE_LIMIT_MAX = 10; // Requests per window. +const RATE_LIMIT_WINDOW_S = 60; // Window length, seconds. + +async function rateLimit(event: FetchEvent): Promise { + // `event.client.address` is the trusted-edge client IP. Sourced from + // `x-real-ip` (with fallback to `x-forwarded-for`); both are set by + // the FastEdge POP, not the client, so they're safe to key on. + const ip = event.client.address || 'unknown'; + + const key = `rl:${ip}`; + + const count = await Cache.incr(key); + + // Only set the expiry on the first hit of a new window. If we set it + // on every request, the window would never close — each new request + // would push the deadline another 60 seconds out. + if (count === 1) { + await Cache.expire(key, { ttl: RATE_LIMIT_WINDOW_S }); + } + + if (count > RATE_LIMIT_MAX) { + return Response.json( + { error: 'Too Many Requests', limit: RATE_LIMIT_MAX, count }, + { status: 429, headers: { 'retry-after': String(RATE_LIMIT_WINDOW_S) } }, + ); + } + + return Response.json({ + pattern: 'rate-limit', + ip, + count, + remaining: RATE_LIMIT_MAX - count, + windowSeconds: RATE_LIMIT_WINDOW_S, + }); +} + +// --------------------------------------------------------------------------- +// Pattern 2 — Origin-cache proxy with conditional caching +// --------------------------------------------------------------------------- +// +// Cache successful upstream responses for PROXY_TTL_S seconds; pass +// non-2xx and redirects through *without* caching, so a transient 404 +// or 500 doesn't get pinned for the rest of the window. The cache is +// a byte cache (no status/headers), so we only cache when "200 OK with +// application/octet-stream" is a faithful replay of the upstream. +// +// `getOrSet` is not used here because its populator can't signal +// "fetched, but don't cache" — we need that distinction to handle +// error responses safely. See Pattern 3 for `getOrSet` in a context +// where every populator output is cacheable. + +const PROXY_TTL_S = 30; + +async function proxy(url: string): Promise { + // Validate the URL before we use it as a cache key. + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return Response.json({ error: `Invalid url: "${url}"` }, { status: 400 }); + } + + // Strip the fragment: fetch() never sends it to the origin, so + // `https://example.com/#a` and `#b` are the same upstream resource + // and must share one cache entry. + parsed.hash = ''; + + const key = `proxy:${parsed.toString()}`; + + // Cache hit — replay the bytes as 200 OK. Status/headers from the + // original response are not preserved by the byte cache. + const cached = await Cache.get(key); + if (cached !== null) { + return new Response(await cached.arrayBuffer(), { + headers: { + 'content-type': 'application/octet-stream', + 'x-cache': 'hit', + 'x-cache-ttl': String(PROXY_TTL_S), + }, + }); + } + + // Cache miss — fetch upstream and only cache successful responses. + // Non-2xx and redirects flow through unchanged so callers see the + // real status code instead of a synthetic 200. + const upstream = await fetch(parsed.toString()); + if (!upstream.ok) { + return upstream; + } + + const bytes = await upstream.arrayBuffer(); + await Cache.set(key, bytes, { ttl: PROXY_TTL_S }); + return new Response(bytes, { + headers: { + 'content-type': 'application/octet-stream', + 'x-cache': 'miss', + 'x-cache-ttl': String(PROXY_TTL_S), + }, + }); +} + +// --------------------------------------------------------------------------- +// Pattern 3 — JSON memoisation via getOrSet with a computed populator +// --------------------------------------------------------------------------- +// +// Same shape as the proxy pattern, but the populator does CPU work +// instead of network I/O. Use this whenever you compute the same +// expensive answer many times in a row — search index lookups, +// signed-token verification, derived report rollups, JSON +// transformations of slow-changing source data. +// +// We embed `generatedAt` in the result so a client refreshing the +// page can see the timestamp stay constant within the cache window +// and update once it expires. + +const MEMO_TTL_S = 60; + +async function memo(): Promise { + const entry = await Cache.getOrSet( + 'memo:report', + () => { + // Stand-in for "expensive computation". The populator can be + // synchronous or async — both are accepted. + const report = { + generatedAt: new Date().toISOString(), + topItems: ['alpha', 'beta', 'gamma'].map((name, i) => ({ + name, + score: Math.round(Math.random() * 1000) / 10, + rank: i + 1, + })), + }; + // The populator returns the value to store. Because we want + // structured JSON back later, we serialise here and re-parse + // via `entry.json()` on read. + return JSON.stringify(report); + }, + { ttl: MEMO_TTL_S }, + ); + + // `entry.json()` parses the cached UTF-8 bytes as JSON. Use + // `entry.text()` for a string, or `entry.arrayBuffer()` for bytes. + const report = await entry.json(); + + return Response.json({ + pattern: 'memo', + note: `Cached for ${MEMO_TTL_S}s. Refresh to confirm 'generatedAt' stays the same until expiry.`, + report, + }); +} + +// --------------------------------------------------------------------------- +// Default landing — usage menu when no action is supplied +// --------------------------------------------------------------------------- + +function landing(): Response { + return Response.json({ + name: 'FastEdge Cache patterns', + actions: { + 'rate-limit': '/?action=rate-limit', + proxy: '/?action=proxy&url=https://www.example.com', + memo: '/?action=memo', + }, + }); +} + +// --------------------------------------------------------------------------- +// Router +// --------------------------------------------------------------------------- + +async function eventHandler(event: FetchEvent): Promise { + try { + const url = new URL(event.request.url); + const action = url.searchParams.get('action'); + + switch (action) { + case 'rate-limit': + return await rateLimit(event); + case 'proxy': + return await proxy(url.searchParams.get('url') ?? ''); + case 'memo': + return await memo(); + case null: + return landing(); + default: + return Response.json( + { error: `Unknown action: "${action}". Use one of: rate-limit, proxy, memo.` }, + { status: 400 }, + ); + } + } catch (error: unknown) { + // Validation errors thrown by Cache.* (e.g. conflicting WriteOptions + // fields) are synchronous; host errors arrive as Promise rejections. + // Both are caught by this single handler. + return Response.json({ error: (error as Error).message }, { status: 500 }); + } +} + +addEventListener('fetch', (event: FetchEvent) => { + event.respondWith(eventHandler(event)); +}); diff --git a/examples/cache/tsconfig.json b/examples/cache/tsconfig.json new file mode 100644 index 0000000..c678d62 --- /dev/null +++ b/examples/cache/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "lib": ["ES2023"], + "types": ["@gcoredev/fastedge-sdk-js"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/examples/kv-store-basic/src/index.js b/examples/kv-store-basic/src/index.js index aff2b18..954df67 100644 --- a/examples/kv-store-basic/src/index.js +++ b/examples/kv-store-basic/src/index.js @@ -3,8 +3,13 @@ import { KvStore } from 'fastedge::kv'; async function eventHandler(event) { try { const myStore = KvStore.open('kv-store-name-as-defined-on-app'); - const value = myStore.get('key'); - return new Response(`The KV Store responded with: ${value}`); + const entry = await myStore.getEntry('key'); + + if (entry === null) { + return new Response('Key not found', { status: 404 }); + } + + return new Response(`The KV Store responded with: ${await entry.text()}`); } catch (error) { return Response.json({ error: error.message }, { status: 500 }); } diff --git a/examples/kv-store/tsconfig.json b/examples/kv-store/tsconfig.json index 817d68a..c678d62 100644 --- a/examples/kv-store/tsconfig.json +++ b/examples/kv-store/tsconfig.json @@ -1,17 +1,14 @@ { "compilerOptions": { - "target": "ES2020", + "target": "ES2023", "module": "ESNext", + "moduleResolution": "Bundler", "strict": true, - "esModuleInterop": true, - "moduleResolution": "Node", - "rootDir": "./", - "noEmit": true, "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "lib": ["ES2020", "DOM"], + "noEmit": true, + "lib": ["ES2023"], "types": ["@gcoredev/fastedge-sdk-js"] }, - "include": ["src/**/*", "./node_modules/@gcoredev/fastedge-sdk-js/types"], + "include": ["src/**/*"], "exclude": ["node_modules"] } diff --git a/examples/mcp-server/tsconfig.json b/examples/mcp-server/tsconfig.json index 91cd525..11447f5 100644 --- a/examples/mcp-server/tsconfig.json +++ b/examples/mcp-server/tsconfig.json @@ -1,14 +1,13 @@ { "compilerOptions": { - "target": "ES2022", + "target": "ES2023", "module": "Node16", "moduleResolution": "Node16", "outDir": "./build", "rootDir": "./src", "strict": true, - "esModuleInterop": true, "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, + "lib": ["ES2023"], "types": ["@gcoredev/fastedge-sdk-js"] }, "include": ["src/**/*"], diff --git a/examples/react-with-hono-server/tsconfig.fastedge.json b/examples/react-with-hono-server/tsconfig.fastedge.json index 67999c5..e646a4f 100644 --- a/examples/react-with-hono-server/tsconfig.fastedge.json +++ b/examples/react-with-hono-server/tsconfig.fastedge.json @@ -1,14 +1,13 @@ { "compilerOptions": { - "target": "ES2022", - "module": "Node16", - "moduleResolution": "Node16", + "target": "ES2023", + "module": "ESNext", + "moduleResolution": "Bundler", "rootDir": "./fastedge-server", "strict": true, - "noEmit": true, - "esModuleInterop": true, "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, + "noEmit": true, + "lib": ["ES2023"], "types": ["@gcoredev/fastedge-sdk-js"] }, "include": ["./fastedge-server/**/*"], diff --git a/examples/static-assets/tsconfig.json b/examples/static-assets/tsconfig.json index 741afb6..937db5e 100644 --- a/examples/static-assets/tsconfig.json +++ b/examples/static-assets/tsconfig.json @@ -3,15 +3,13 @@ "allowJs": true, "jsx": "react-jsx", "jsxImportSource": "hono/jsx", - "target": "ES2020", + "target": "ES2023", "module": "ESNext", - "moduleResolution": "Node", - "rootDir": "./src", - "noEmit": true, + "moduleResolution": "Bundler", "strict": true, - "esModuleInterop": true, "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, + "noEmit": true, + "lib": ["ES2023"], "types": ["@gcoredev/fastedge-sdk-js"] }, "include": ["src/**/*"], diff --git a/fastedge-plugin-source/.generation-config.md b/fastedge-plugin-source/.generation-config.md index 01ca84a..c028b54 100644 --- a/fastedge-plugin-source/.generation-config.md +++ b/fastedge-plugin-source/.generation-config.md @@ -1,48 +1,56 @@ # FastEdge-sdk-js Documentation Generation Config -This file contains specifications for maintaining the documentation in `docs/`. -It is **local only** — the plugin pipeline never reads this file. +This file contains specifications for maintaining the documentation in `docs/`. It is **local only** +— the plugin pipeline never reads this file. ## Global Rules ### Audience + - Application developers using the FastEdge JS SDK - Agents helping users build FastEdge applications - NOT SDK contributors (that's what `context/` is for) ### Style + - Technical prose only — no marketing language, no superlatives, no "easily" or "simply" - Use code signatures for all parameters and return types (TypeScript) - Use fenced code blocks for all examples (language-tagged) - Use tables for structured data (API signatures, config fields, CLI flags) -- Tables must use padded columns aligned to the widest cell in each column. Pad every cell with spaces so columns line up visually in raw markdown. The separator row dashes must match each column's padded width. - Good: `| Variable | Type | Default |` - Bad: `| Variable | Type | Default |` - Good: `| -------------------- | -------- | ------- |` - Bad: `|---|---|---|` +- Tables must use padded columns aligned to the widest cell in each column. Pad every cell with + spaces so columns line up visually in raw markdown. The separator row dashes must match each + column's padded width. Good: `| Variable | Type | Default |` Bad: + `| Variable | Type | Default |` Good: `| -------------------- | -------- | ------- |` Bad: + `|---|---|---|` - Every code example must be self-contained and runnable - Use `@gcoredev/fastedge-sdk-js` package name (not relative paths) ### Structure + - Every doc file starts with a level-1 heading and a one-line description - Use level-2 headings for major sections, level-3 for subsections - No table of contents — keep files navigable by heading structure alone - End each file with a "See Also" section linking to related doc files ### Exclusions (apply to all files) + - No internal implementation details (how the code works internally) - No source code file paths or line numbers - No version history or changelog entries - No references to `context/` or `CLAUDE.md` (those are internal developer docs) ### Accuracy + - All TypeScript signatures must match `types/` declarations exactly - All CLI flags must match `src/cli/*/` source code - All import paths must use `fastedge::` specifiers (not Node module paths) - All config field names must match the actual TypeScript interfaces -- Never hardcode version numbers, Node requirements, or other values that exist in source files (e.g. `package.json` `engines.node`). Instead, instruct the generator to read them from the source. Hardcoded values drift silently. +- Never hardcode version numbers, Node requirements, or other values that exist in source files + (e.g. `package.json` `engines.node`). Instead, instruct the generator to read them from the + source. Hardcoded values drift silently. ### Coverage Rule + - Every public API must appear in at least one doc file - Every CLI flag must be documented - Every config field must be documented with type and description @@ -51,9 +59,9 @@ It is **local only** — the plugin pipeline never reads this file. ## docs/INDEX.md -**Scope:** Navigation hub — package overview, CLI tools, doc links, application model -**Source files:** `package.json`, `README.md` -**Required content:** +**Scope:** Navigation hub — package overview, CLI tools, doc links, application model **Source +files:** `package.json`, `README.md` **Required content:** + - Package name, version, license - Table of 3 CLI tools with commands - Table linking to all other doc files @@ -61,22 +69,37 @@ It is **local only** — the plugin pipeline never reads this file. - Runtime API import summary - External links (GitHub, npm, FastEdge platform) +**CRITICAL accuracy:** + +- If a `Cache` method table is present, it must list both `Cache.getOrSet` overloads — the + non-nullable form (`populate: () => CacheValue | Promise` → `Promise`) and + the nullable form (`populate: () => CacheValue | null | Promise` → + `Promise`, where a `null` populator result skips the cache write). Match + `types/fastedge-cache.d.ts` and `docs/SDK_API.md`. + ## docs/quickstart.md -**Scope:** First-time setup — install, scaffold, build, deploy -**Source files:** `README.md`, `src/cli/fastedge-init/init.ts`, `src/cli/fastedge-build/build.ts` -**Required content:** -- Prerequisites — read the minimum Node version from `package.json` `engines.node` field (do NOT hardcode a version number) +**Scope:** First-time setup — install, scaffold, build, deploy **Source files:** `package.json`, +`README.md`, `src/cli/fastedge-init/init.ts`, `src/cli/fastedge-build/build.ts` **Required +content:** + +- Prerequisites — read the minimum Node version from `package.json` `engines.node` field (do NOT + hardcode a version number) - npm install command - Two paths: scaffold with fastedge-init OR build directly - Complete "first app" example with env vars, secrets, and KV store +- Short "Using Cache" section after "Using KV Store" — one paragraph contrasting POP-local Cache vs + globally-replicated KV, plus a minimal example covering `Cache.get`/`Cache.set` (or + `Cache.getOrSet`) and `Cache.incr`. Defer full method tables to `SDK_API.md` via a "See Also" link + rather than duplicating them here. - Next steps links ## docs/BUILD_CLI.md -**Scope:** fastedge-build CLI — all flags, modes, config format -**Source files:** `src/cli/fastedge-build/build.ts`, `src/cli/fastedge-build/types.ts`, `src/cli/fastedge-build/config-build.ts` -**Required content:** +**Scope:** fastedge-build CLI — all flags, modes, config format **Source files:** +`src/cli/fastedge-build/build.ts`, `src/cli/fastedge-build/types.ts`, +`src/cli/fastedge-build/config-build.ts` **Required content:** + - All CLI flags with aliases, types, descriptions - Direct mode vs config-driven mode - BuildConfig interface (all fields) @@ -85,15 +108,18 @@ It is **local only** — the plugin pipeline never reads this file. - Output description **CRITICAL accuracy:** + - Flag table must match `arg()` definition in `build.ts` - BuildConfig must match interface in `types.ts` - Build types must match switch statement in `config-build.ts` ## docs/INIT_CLI.md -**Scope:** fastedge-init CLI — what it does, what it generates -**Source files:** `src/cli/fastedge-init/init.ts`, `src/cli/fastedge-init/http-handler.ts`, `src/cli/fastedge-init/static-site.ts`, `src/cli/fastedge-init/create-config.ts` -**Required content:** +**Scope:** fastedge-init CLI — what it does, what it generates **Source files:** +`src/cli/fastedge-init/init.ts`, `src/cli/fastedge-init/http-handler.ts`, +`src/cli/fastedge-init/static-site.ts`, `src/cli/fastedge-init/create-config.ts` **Required +content:** + - Usage (no flags — interactive only) - HTTP handler setup (files created, config structure) - Static website setup (files created, config structure, additional prompts) @@ -101,18 +127,20 @@ It is **local only** — the plugin pipeline never reads this file. ## docs/ASSETS_CLI.md -**Scope:** fastedge-assets CLI — manifest generation -**Source files:** `src/cli/fastedge-assets/asset-cli.ts`, `src/server/static-assets/asset-manifest/create-manifest.ts` +**Scope:** fastedge-assets CLI — manifest generation **Source files:** +`src/cli/fastedge-assets/asset-cli.ts`, `src/server/static-assets/asset-manifest/create-manifest.ts` **Required content:** + - CLI flags and usage modes - Manifest structure (asset metadata format) - When to use vs automatic generation in static builds ## docs/STATIC_SITES.md -**Scope:** Static site support — full workflow, createStaticServer API, server config -**Source files:** `src/server/static-assets/static-server/create-static-server.ts`, `types/server/static-assets/` -**Required content:** +**Scope:** Static site support — full workflow, createStaticServer API, server config **Source +files:** `src/server/static-assets/static-server/create-static-server.ts`, +`types/server/static-assets/` **Required content:** + - How embedding works (Wizer pre-initialization) - Quick start workflow - Build config fields (static-specific) @@ -122,25 +150,54 @@ It is **local only** — the plugin pipeline never reads this file. - v1 → v2 migration **CRITICAL accuracy:** + - ServerConfig fields must match `create-static-server.ts` defaults - StaticServer methods must match type declarations - Wizer constraint must be clearly stated ## docs/SDK_API.md -**Scope:** All runtime APIs available in WASM -**Source files:** `types/fastedge-env.d.ts`, `types/fastedge-secret.d.ts`, `types/fastedge-kv.d.ts`, `types/globals.d.ts` -**Required content:** -- FastEdge APIs: getEnv, getSecret, getSecretEffectiveAt, KvStore (all methods) -- Web APIs: fetch, Request, Response, Headers, URL, URLSearchParams, streams, encoding, timers, crypto +**Scope:** All runtime APIs available in WASM **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:** + +- FastEdge APIs: `getEnv`, `getSecret`, `getSecretEffectiveAt`, `KvStore` (raw-bytes and entry-style + methods), `KvStoreEntry`, `Cache` (all static methods), `CacheEntry`, `WriteOptions`, `CacheValue` +- Web APIs: fetch, Request, Response, Headers, URL, URLSearchParams, streams, encoding, timers, + crypto - FetchEvent: request, client, respondWith - ClientInfo and GeoData - Headers immutability note -- Code examples for each FastEdge API +- Positioning: when introducing `Cache`, contrast it with `KvStore` so developers know when to use + which. `Cache` is POP-local with strong per-POP consistency, which is what makes atomic operations + (`incr`, `decr`, `getOrSet` coalescing) reliable for things like per-POP rate limits and + request-coalescing fetches. `KvStore` is globally replicated with eventual consistency, suited to + 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) **CRITICAL accuracy:** + - KvStore method signatures must match `types/fastedge-kv.d.ts` -- `get()` returns `ArrayBuffer | null` (not string) -- `scan()` returns `Array` (not ArrayBuffer) -- `zrangeByScore()` / `zscan()` return `Array<[ArrayBuffer, number]>` tuples +- Raw-bytes KV methods are synchronous: `get()` → `ArrayBuffer | null`; `scan()` → `Array`; + `zrangeByScore()` / `zscan()` → `Array<[ArrayBuffer, number]>`; `bfExists()` → `boolean` +- Entry-style KV methods are async: `getEntry()` → `Promise`; + `zrangeByScoreEntries()` / `zscanEntries()` → `Promise>` +- `KvStoreEntry` exposes `arrayBuffer()` / `text()` / `json()`, all Promise-returning +- Document both KV read shapes neutrally — neither is presented as preferred or deprecated; both are + first-class APIs +- Cache method signatures must match `types/fastedge-cache.d.ts`; all `Cache` methods are static and + Promise-returning +- `Cache.get()` → `Promise`; `Cache.exists()` → `Promise`; + `Cache.set(key, value, options?)` → `Promise` and accepts `CacheValue` + (`string | ArrayBuffer | ArrayBufferView | ReadableStream | Response`); `Cache.delete()` → + `Promise`; `Cache.expire(key, options)` → `Promise`; `Cache.incr(key, delta?)` / + `Cache.decr(key, delta?)` → `Promise` (atomic); `Cache.getOrSet(key, populate, options?)` + is overloaded — populator returning `CacheValue | Promise` resolves to + `Promise`; populator returning `CacheValue | null | Promise` + resolves to `Promise` and a `null` populator result skips the cache write (use + this to wrap fallible work and only pin successes) +- `WriteOptions` accepts exactly one of `ttl` (seconds), `ttlMs` (milliseconds), or `expiresAt` + (Unix epoch seconds) +- `CacheEntry` exposes `arrayBuffer()` / `text()` / `json()`, all Promise-returning - `getSecretEffectiveAt` takes `(name, effectiveAt)` where effectiveAt is a number diff --git a/fastedge-plugin-source/generate-docs.sh b/fastedge-plugin-source/generate-docs.sh index d2162fd..a8c5793 100755 --- a/fastedge-plugin-source/generate-docs.sh +++ b/fastedge-plugin-source/generate-docs.sh @@ -93,7 +93,7 @@ SOURCE_FILES[BUILD_CLI.md]="src/cli/fastedge-build/build.ts src/cli/fastedge-bui SOURCE_FILES[INIT_CLI.md]="src/cli/fastedge-init/init.ts src/cli/fastedge-init/http-handler.ts src/cli/fastedge-init/static-site.ts src/cli/fastedge-init/create-config.ts" SOURCE_FILES[ASSETS_CLI.md]="src/cli/fastedge-assets/asset-cli.ts src/server/static-assets/asset-manifest/create-manifest.ts" SOURCE_FILES[STATIC_SITES.md]="src/server/static-assets/static-server/create-static-server.ts" -SOURCE_FILES[SDK_API.md]="types/fastedge-env.d.ts types/fastedge-secret.d.ts types/fastedge-kv.d.ts types/globals.d.ts" +SOURCE_FILES[SDK_API.md]="types/fastedge-env.d.ts types/fastedge-secret.d.ts types/fastedge-kv.d.ts types/fastedge-cache.d.ts types/globals.d.ts" # ============================================================================= # === CUSTOMIZE: Package name for the generation prompt === @@ -251,7 +251,10 @@ PROMPT return 130 fi - claude -p --model "$MODEL" "$prompt" > "$tmpfile" + # Pipe prompt via stdin to avoid Linux's MAX_ARG_STRLEN (~128KB per argv string). + # Large prompts (source files + existing doc for incremental updates) exceed + # this limit as an SDK grows; stdin has no such cap. + claude -p --model "$MODEL" > "$tmpfile" <<<"$prompt" # Validate: first non-empty line must start with # local first_line diff --git a/fastedge-plugin-source/manifest.json b/fastedge-plugin-source/manifest.json index 540ad7d..cac4c14 100644 --- a/fastedge-plugin-source/manifest.json +++ b/fastedge-plugin-source/manifest.json @@ -1,14 +1,14 @@ { "$schema": "https://fastedge-plugin-source/manifest/v1", "repo_id": "fastedge-sdk-js", - "version": "1.1.0", + "version": "1.2.0", "sources": { "sdk-api": { "files": [ "docs/SDK_API.md" ], "required": true, - "description": "Runtime APIs — environment variables, secrets, KV store, Web APIs available in WASM" + "description": "Runtime APIs — environment variables, secrets, KV store, Cache, Web APIs available in WASM" }, "build-cli": { "files": [ @@ -136,6 +136,25 @@ ], "required": true, "description": "Geo Redirect example — docs pattern extraction" + }, + + "cache-blueprint": { + "files": [ + "examples/cache/src/index.ts", + "examples/cache/package.json", + "examples/cache/tsconfig.json" + ], + "required": true, + "description": "Cache example — scaffold blueprint extraction (per-IP rate limiting, getOrSet origin proxy, JSON memoisation)" + }, + "cache-pattern": { + "files": [ + "examples/cache/src/index.ts", + "examples/cache/package.json", + "examples/cache/tsconfig.json" + ], + "required": true, + "description": "Cache example — docs pattern extraction" } }, "target_mapping": { @@ -208,6 +227,15 @@ "geo-redirect-pattern": { "reference_file": "plugins/gcore-fastedge/skills/fastedge-docs/reference/http/examples-geo-redirect-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 } }, "validation": { diff --git a/github-pages/src/content/docs/reference/fastedge/kv/key-value.md b/github-pages/src/content/docs/reference/fastedge/kv/key-value.md index 5421fa3..10bccee 100644 --- a/github-pages/src/content/docs/reference/fastedge/kv/key-value.md +++ b/github-pages/src/content/docs/reference/fastedge/kv/key-value.md @@ -5,7 +5,8 @@ description: How to access Key-value pairs from a FastEdge Kv Instance. To access key-value pairs in a KV Store. First create a `KV Instance` using `KvStore.open()` -This instance will then provide the `get` and `scan` methods you can use to access key-value pairs. +This instance provides `get` and `getEntry` for retrieving a value by +key, and `scan` for enumerating keys by prefix. ## get @@ -16,7 +17,7 @@ async function eventHandler(event) { try { const myStore = KvStore.open('kv-store-name-as-defined-on-app'); const value = myStore.get('key'); - return new Response(`The KV Store responded with: ${value}`); + return new Response(value); } catch (error) { return Response.json({ error: error.message }, { status: 500 }); } @@ -39,7 +40,50 @@ storeInstance.get(key); ##### Return Value -An `ArrayBuffer` of the value for the given key. If the key does not exist, null is returned. +An `ArrayBuffer` of the value for the given key. If the key does not +exist, `null` is returned. To decode the bytes as a string or JSON, +either use `getEntry` or wrap the result with a `TextDecoder`. + +## getEntry + +```js +import { KvStore } from 'fastedge::kv'; + +async function eventHandler(event) { + try { + const myStore = KvStore.open('kv-store-name-as-defined-on-app'); + const entry = await myStore.getEntry('key'); + + if (entry === null) { + return new Response('Key not found', { status: 404 }); + } + + return new Response(`The KV Store responded with: ${await entry.text()}`); + } catch (error) { + return Response.json({ error: error.message }, { status: 500 }); + } +} + +addEventListener('fetch', (event) => { + event.respondWith(eventHandler(event)); +}); +``` + +```js title="SYNTAX" +storeInstance.getEntry(key); +``` + +##### Parameters + +- `key` (required) + + A string containing the key you want to retrieve the value of. + +##### Return Value + +A `Promise`. The entry exposes `arrayBuffer()`, +`text()`, and `json()` accessor methods, each returning a `Promise`. If +the key does not exist, the Promise resolves to `null`. ## scan diff --git a/github-pages/src/content/docs/reference/fastedge/kv/zset.md b/github-pages/src/content/docs/reference/fastedge/kv/zset.md index 7a8ac00..3a85758 100644 --- a/github-pages/src/content/docs/reference/fastedge/kv/zset.md +++ b/github-pages/src/content/docs/reference/fastedge/kv/zset.md @@ -5,8 +5,11 @@ description: How to access Sorted Set values from a FastEdge Kv Instance. To access Sorted Set values in a KV Store. First create a `KV Instance` using `KvStore.open()` -This instance will then provide the `zrangeByScore` and `zscan` methods you can use to access Sorted -Set values. +This instance provides `zrangeByScore` / `zrangeByScoreEntries` to +retrieve values within a score range, and `zscan` / `zscanEntries` to +match values by prefix. The `*Entries` variants return `KvStoreEntry` +wrappers with `text()` / `json()` / `arrayBuffer()` decode helpers; the +non-entry variants return raw `ArrayBuffer` values. ## zrangeByScore @@ -51,6 +54,44 @@ storeInstance.zrangeByScore(key, min, max); An `Array<[ArrayBuffer, number]>`. It returns a list of tuples, containing the value in an ArrayBuffer and the score as a number. +## zrangeByScoreEntries + +```js +import { KvStore } from 'fastedge::kv'; + +async function eventHandler(event) { + try { + const myStore = KvStore.open('kv-store-name-as-defined-on-app'); + const results = await myStore.zrangeByScoreEntries('key', 0, 10); + const decoded = await Promise.all( + results.map(async ([entry, score]) => [await entry.text(), score]), + ); + return new Response(`The KV Store responded with: ${JSON.stringify(decoded)}`); + } catch (error) { + return Response.json({ error: error.message }, { status: 500 }); + } +} + +addEventListener('fetch', (event) => { + event.respondWith(eventHandler(event)); +}); +``` + +```js title="SYNTAX" +storeInstance.zrangeByScoreEntries(key, min, max); +``` + +##### Parameters + +Same as `zrangeByScore`. + +##### Return Value + +A `Promise>`. Each tuple contains a +`KvStoreEntry` (with `text()` / `json()` / `arrayBuffer()` accessors) +and the score as a number. Resolves to an empty array if no values +match. + ## zscan ```js @@ -92,3 +133,41 @@ all values that start with `pre` An `Array<[ArrayBuffer, number]>`. It returns a list of tuples, containing the value in an ArrayBuffer and the score as a number. + +## zscanEntries + +```js +import { KvStore } from 'fastedge::kv'; + +async function eventHandler(event) { + try { + const myStore = KvStore.open('kv-store-name-as-defined-on-app'); + const results = await myStore.zscanEntries('key', 'pre*'); + const decoded = await Promise.all( + results.map(async ([entry, score]) => [await entry.text(), score]), + ); + return new Response(`The KV Store responded with: ${JSON.stringify(decoded)}`); + } catch (error) { + return Response.json({ error: error.message }, { status: 500 }); + } +} + +addEventListener('fetch', (event) => { + event.respondWith(eventHandler(event)); +}); +``` + +```js title="SYNTAX" +storeInstance.zscanEntries(key, pattern); +``` + +##### Parameters + +Same as `zscan`. + +##### Return Value + +A `Promise>`. Each tuple contains a +`KvStoreEntry` (with `text()` / `json()` / `arrayBuffer()` accessors) +and the score as a number. Resolves to an empty array if no values +match. diff --git a/llms.txt b/llms.txt index 7a92689..4255f42 100644 --- a/llms.txt +++ b/llms.txt @@ -1,6 +1,6 @@ # @gcoredev/fastedge-sdk-js -> The FastEdge JS SDK (`@gcoredev/fastedge-sdk-js`) is the JavaScript/TypeScript development toolkit for building serverless edge applications on Gcore's FastEdge platform. It compiles your code into WebAssembly components that run across global edge data centers. +> The FastEdge JS SDK (`@gcoredev/fastedge-sdk-js`) is the JavaScript/TypeScript development toolkit ## Documentation diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 083cc72..00245c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + '@gcoredev/fastedge-sdk-js': link:. + importers: .: @@ -124,56 +127,68 @@ importers: examples/ab-testing: dependencies: '@gcoredev/fastedge-sdk-js': - specifier: ^2.2.2 - version: 2.2.2 + specifier: link:../.. + version: link:../.. + + examples/cache: + dependencies: + '@gcoredev/fastedge-sdk-js': + specifier: link:../.. + version: link:../.. + + examples/cache-basic: + dependencies: + '@gcoredev/fastedge-sdk-js': + specifier: link:../.. + version: link:../.. examples/downstream-fetch: dependencies: '@gcoredev/fastedge-sdk-js': - specifier: ^2.2.2 - version: 2.2.2 + specifier: link:../.. + version: link:../.. examples/downstream-modify-response: dependencies: '@gcoredev/fastedge-sdk-js': - specifier: ^2.2.2 - version: 2.2.2 + specifier: link:../.. + version: link:../.. examples/geo-redirect: dependencies: '@gcoredev/fastedge-sdk-js': - specifier: ^2.2.2 - version: 2.2.2 + specifier: link:../.. + version: link:../.. examples/headers: dependencies: '@gcoredev/fastedge-sdk-js': - specifier: ^2.2.2 - version: 2.2.2 + specifier: link:../.. + version: link:../.. examples/hello-world: dependencies: '@gcoredev/fastedge-sdk-js': - specifier: ^2.2.2 - version: 2.2.2 + specifier: link:../.. + version: link:../.. examples/kv-store: dependencies: '@gcoredev/fastedge-sdk-js': - specifier: ^2.2.2 - version: 2.2.2 + specifier: link:../.. + version: link:../.. examples/kv-store-basic: dependencies: '@gcoredev/fastedge-sdk-js': - specifier: ^2.2.2 - version: 2.2.2 + specifier: link:../.. + version: link:../.. examples/mcp-server: dependencies: '@gcoredev/fastedge-sdk-js': - specifier: ^2.2.2 - version: 2.2.2 + specifier: link:../.. + version: link:../.. '@hono/mcp': specifier: ^0.2.5 version: 0.2.5(@modelcontextprotocol/sdk@1.29.0(zod@4.3.6))(hono-rate-limiter@0.4.2(hono@4.12.12))(hono@4.12.12)(zod@4.3.6) @@ -194,8 +209,8 @@ importers: examples/react-with-hono-server: dependencies: '@gcoredev/fastedge-sdk-js': - specifier: ^2.2.2 - version: 2.2.2 + specifier: link:../.. + version: link:../.. hono: specifier: ^4.9.8 version: 4.12.5 @@ -258,8 +273,8 @@ importers: examples/static-assets: dependencies: '@gcoredev/fastedge-sdk-js': - specifier: ^2.2.2 - version: 2.2.2 + specifier: link:../.. + version: link:../.. hono: specifier: ^4.11.9 version: 4.12.5 @@ -271,8 +286,8 @@ importers: examples/template-invoice: dependencies: '@gcoredev/fastedge-sdk-js': - specifier: ^2.2.2 - version: 2.2.2 + specifier: link:../.. + version: link:../.. handlebars: specifier: ^4.7.9 version: 4.7.9 @@ -280,8 +295,8 @@ importers: examples/template-invoice-ab-testing: dependencies: '@gcoredev/fastedge-sdk-js': - specifier: ^2.2.2 - version: 2.2.2 + specifier: link:../.. + version: link:../.. handlebars: specifier: ^4.7.9 version: 4.7.9 @@ -289,8 +304,8 @@ importers: examples/variables-and-secrets: dependencies: '@gcoredev/fastedge-sdk-js': - specifier: ^2.2.2 - version: 2.2.2 + specifier: link:../.. + version: link:../.. github-pages: dependencies: @@ -1562,11 +1577,6 @@ packages: '@expressive-code/plugin-text-markers@0.41.7': resolution: {integrity: sha512-Ewpwuc5t6eFdZmWlFyeuy3e1PTQC0jFvw2Q+2bpcWXbOZhPLsT7+h8lsSIJxb5mS7wZko7cKyQ2RLYDyK6Fpmw==} - '@gcoredev/fastedge-sdk-js@2.2.2': - resolution: {integrity: sha512-x6cdO7yAXDS5Jp95B83C34xNMIHBq25Qq3L9mbWQkPjYHOhPjTjlySfwrXi8gFhyEBGrRaynk809RC3v8mq21Q==} - engines: {node: '>=22', pnpm: '>=10'} - hasBin: true - '@gmrchk/cli-testing-library@0.1.2': resolution: {integrity: sha512-/oALX+4Zti+92hJaR7trZlDPzI2EekIHEYoDXH3oGTdCdgq5lDT3vHAMDzTjbIfUA0X/XC8sVA2tbtSoUPB5xw==} @@ -7553,21 +7563,6 @@ snapshots: dependencies: '@expressive-code/core': 0.41.7 - '@gcoredev/fastedge-sdk-js@2.2.2': - dependencies: - '@bytecodealliance/jco': 1.17.6 - '@bytecodealliance/wizer': 10.0.0 - acorn: 8.16.0 - acorn-walk: 8.3.5 - arg: 5.0.2 - enquirer: 2.4.1 - esbuild: 0.28.0 - event-target-polyfill: 0.0.4 - magic-string: 0.30.21 - npm-run-all2: 8.0.4 - prompts: 2.4.2 - regexpu-core: 5.3.2 - '@gmrchk/cli-testing-library@0.1.2': dependencies: keycode: 2.2.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ec00f4a..5b226bd 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,9 @@ packages: - github-pages - examples/* +overrides: + '@gcoredev/fastedge-sdk-js': 'link:.' + onlyBuiltDependencies: - esbuild - sharp diff --git a/runtime/FastEdge-wit b/runtime/FastEdge-wit index 561aa99..de9bd0d 160000 --- a/runtime/FastEdge-wit +++ b/runtime/FastEdge-wit @@ -1 +1 @@ -Subproject commit 561aa99135425fb2a7a01feb989614fcbd083a50 +Subproject commit de9bd0dc20a8f577f753aefa16b367a733a8511c diff --git a/runtime/fastedge/CMakeLists.txt b/runtime/fastedge/CMakeLists.txt index 43ca59e..9874576 100644 --- a/runtime/fastedge/CMakeLists.txt +++ b/runtime/fastedge/CMakeLists.txt @@ -5,6 +5,8 @@ include("../StarlingMonkey/cmake/add_as_subproject.cmake") # add_builtin(fastedge::runtime SRC handler.cpp) add_builtin(fastedge::fastedge SRC builtins/fastedge.cpp) add_builtin(fastedge::kv_store SRC builtins/kv-store.cpp) +add_builtin(fastedge::cache SRC builtins/cache.cpp) +add_builtin(fastedge::request_info SRC builtins/request-info.cpp) add_builtin(fastedge::console_override SRC builtins/console-override.cpp) diff --git a/runtime/fastedge/builtins/cache.cpp b/runtime/fastedge/builtins/cache.cpp new file mode 100644 index 0000000..75dfcdf --- /dev/null +++ b/runtime/fastedge/builtins/cache.cpp @@ -0,0 +1,1226 @@ +#include "builtin.h" +#include "encode.h" + +#include "../host-api/include/fastedge_host_api.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace fastedge::cache { + +namespace { + +api::Engine *ENGINE; + +// Process-local map of in-flight `getOrSet` populators. Keyed by user +// cache key, values are pending Promise. Initialised in +// `install()` to a JSObject with no prototype (so user keys cannot +// accidentally collide with Object.prototype names like "constructor"). +JS::PersistentRooted *INFLIGHT_MAP = nullptr; + +// Resolve `args.rval()` with a fresh Promise resolved to `value`. +bool resolve_with(JSContext *cx, JS::HandleValue value, JS::CallArgs &args) { + JS::RootedObject promise(cx, JS::CallOriginalPromiseResolve(cx, value)); + if (!promise) return false; + args.rval().setObject(*promise); + return true; +} + +void throw_cache_error(JSContext *cx, const host_api::CacheError &err) { + switch (err.tag) { + case host_api::CacheErrorTag::ACCESS_DENIED: + JS_ReportErrorUTF8(cx, "Access denied to cache"); + break; + case host_api::CacheErrorTag::INTERNAL_ERROR: + JS_ReportErrorUTF8(cx, "Internal cache error"); + break; + case host_api::CacheErrorTag::OTHER: + JS_ReportErrorUTF8(cx, "Cache error: %.*s", + static_cast(err.val.other.len), + err.val.other.ptr); + break; + } +} + +// Upper bound for any computed TTL in milliseconds. Equals +// Number.MAX_SAFE_INTEGER (2^53 − 1): the largest integer JS Number can +// represent without loss, and well within uint64_t. ~285,000 years — +// any legitimate TTL is orders of magnitude below this. Values at or +// above the cap are rejected so we never wrap on the static_cast +// conversion below. +static constexpr double MAX_TTL_MS = 9007199254740991.0; + +// Parse a `WriteOptions` JS value into milliseconds-from-now. +// +// undefined / null / {} → out=nullopt (no expiry) +// { ttl: N } → out=N*1000 +// { ttlMs: N } → out=N +// { expiresAt: N } → out=(N*1000 - Date.now()) +// +// Throws and returns false on validation error (multiple fields, non-finite, +// non-positive, beyond MAX_TTL_MS, or non-object). +bool build_ttl_ms(JSContext *cx, JS::HandleValue options_val, + std::optional *out) { + if (options_val.isNullOrUndefined()) { + *out = std::nullopt; + return true; + } + if (!options_val.isObject()) { + JS_ReportErrorUTF8(cx, "WriteOptions must be an object"); + return false; + } + + JS::RootedObject options(cx, &options_val.toObject()); + JS::RootedValue ttl_val(cx); + JS::RootedValue ttl_ms_val(cx); + JS::RootedValue expires_at_val(cx); + + if (!JS_GetProperty(cx, options, "ttl", &ttl_val) || + !JS_GetProperty(cx, options, "ttlMs", &ttl_ms_val) || + !JS_GetProperty(cx, options, "expiresAt", &expires_at_val)) { + return false; + } + + bool has_ttl = !ttl_val.isUndefined(); + bool has_ttl_ms = !ttl_ms_val.isUndefined(); + bool has_expires_at = !expires_at_val.isUndefined(); + int count = (has_ttl ? 1 : 0) + (has_ttl_ms ? 1 : 0) + (has_expires_at ? 1 : 0); + + if (count > 1) { + JS_ReportErrorUTF8(cx, + "WriteOptions: pass only one of ttl, ttlMs, expiresAt"); + return false; + } + if (count == 0) { + *out = std::nullopt; + return true; + } + + double ms; + if (has_ttl) { + double secs; + if (!JS::ToNumber(cx, ttl_val, &secs)) return false; + ms = secs * 1000.0; + } else if (has_ttl_ms) { + if (!JS::ToNumber(cx, ttl_ms_val, &ms)) return false; + } else { // has_expires_at + double epoch_secs; + if (!JS::ToNumber(cx, expires_at_val, &epoch_secs)) return false; + auto now_ms = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + ms = epoch_secs * 1000.0 - static_cast(now_ms); + } + + if (!std::isfinite(ms) || ms <= 0.0) { + JS_ReportErrorUTF8(cx, "WriteOptions: TTL must be positive"); + return false; + } + if (ms >= MAX_TTL_MS) { + JS_ReportErrorUTF8(cx, + "WriteOptions: TTL must be less than %.0f ms (Number.MAX_SAFE_INTEGER)", + MAX_TTL_MS); + return false; + } + + *out = static_cast(ms); + return true; +} + +class CacheEntry { +public: + enum class Slot : uint32_t { + Bytes = 0, + Count + }; + + static const JSClass class_; + static const JSFunctionSpec methods[]; + + static bool arrayBuffer(JSContext *cx, unsigned argc, JS::Value *vp); + static bool text(JSContext *cx, unsigned argc, JS::Value *vp); + static bool json(JSContext *cx, unsigned argc, JS::Value *vp); + + // Allocates a Uint8Array and copies `bytes` into it, then wraps the array + // in a freshly-created CacheEntry. Returns nullptr on allocation failure + // (with a pending JS exception). + static JSObject *create(JSContext *cx, const uint8_t *bytes, size_t len); +}; + +const JSClass CacheEntry::class_ = { + "CacheEntry", + JSCLASS_HAS_RESERVED_SLOTS(static_cast(CacheEntry::Slot::Count))}; + +class Cache { +public: + static bool get(JSContext *cx, unsigned argc, JS::Value *vp); + static bool exists(JSContext *cx, unsigned argc, JS::Value *vp); + static bool set(JSContext *cx, unsigned argc, JS::Value *vp); + static bool delete_op(JSContext *cx, unsigned argc, JS::Value *vp); + static bool expire(JSContext *cx, unsigned argc, JS::Value *vp); + static bool incr(JSContext *cx, unsigned argc, JS::Value *vp); + static bool decr(JSContext *cx, unsigned argc, JS::Value *vp); + static bool getOrSet(JSContext *cx, unsigned argc, JS::Value *vp); + + // Promise reaction handlers used by `set` for the async coercion path. + // Static (not in the anon namespace) so their addresses can be passed as + // template arguments to `create_internal_method<...>`. + // + // For both: `receiver` is the outer Promise we resolve/reject; for + // `set_then` `extra` is `{ key: string, ttlMs: number }`. + static bool set_then(JSContext *cx, JS::HandleObject receiver, + JS::HandleValue extra, JS::CallArgs args); + static bool set_catch(JSContext *cx, JS::HandleObject receiver, + JS::HandleValue extra, JS::CallArgs args); + + // Reaction handlers for `getOrSet`. + // + // `populate_then`/`populate_catch`: reactions on the populator's Promise. + // `populate_then` extracts the populator's CacheValue and either + // finalises immediately (sync coercion) or chains to `bytes_then` + // for async coercion (Response/ReadableStream). + // `bytes_then`: reaction on `value.arrayBuffer()` for the async path — + // extracts bytes from the resolved ArrayBuffer and finalises. + // + // For all three: `receiver` is the outer Promise, `extra` is + // `{ key: string, ttlMs: number }` (ttlMs == -1 means no expiry). + static bool getOrSet_populate_then(JSContext *cx, JS::HandleObject receiver, + JS::HandleValue extra, JS::CallArgs args); + static bool getOrSet_populate_catch(JSContext *cx, JS::HandleObject receiver, + JS::HandleValue extra, JS::CallArgs args); + static bool getOrSet_bytes_then(JSContext *cx, JS::HandleObject receiver, + JS::HandleValue extra, JS::CallArgs args); +}; + +// Forward declarations — used by Cache::set / Cache::getOrSet, defined further below. +bool try_sync_coerce_bytes(JSContext *cx, JS::HandleValue value, + std::vector *out, bool *done); +bool finish_set(JSContext *cx, JS::HandleObject outer_promise, + JS::HandleString key_jsstring, const uint8_t *bytes, + size_t len, std::optional ttl_ms); + +// In-flight map helpers (process-local coalescing). +JSObject *inflight_get(JSContext *cx, JS::HandleString key); +bool inflight_set(JSContext *cx, JS::HandleString key, JS::HandleObject promise); +void inflight_delete(JSContext *cx, JS::HandleString key); + +// Capture pending JS exception, remove `key` from inflight, reject the +// outer Promise with the captured exception, and clear `args.rval()`. +bool reject_and_finish(JSContext *cx, JS::HandleObject outer_promise, + JS::HandleString key_jsstring, JS::CallArgs &args); + +// Finalise an in-flight populate: cache_set the bytes, build a CacheEntry, +// resolve the outer Promise, and remove `key` from the inflight map. +// On host or allocation error: reject the outer Promise with the captured +// exception; still removes from inflight. +bool getOrSet_finalize(JSContext *cx, JS::HandleObject outer_promise, + JS::HandleString key_jsstring, + std::optional ttl_ms, const uint8_t *bytes, + size_t len); + +// Get the Uint8Array stored in a CacheEntry's reserved slot. +JSObject *cache_entry_bytes(JSContext *cx, JS::HandleObject self) { + JS::Value v = JS::GetReservedSlot( + self, static_cast(CacheEntry::Slot::Bytes)); + if (!v.isObject()) { + JS_ReportErrorUTF8(cx, "Invalid CacheEntry"); + return nullptr; + } + return &v.toObject(); +} + +// Decode the bytes of a Uint8Array as UTF-8 into a fresh JS string. +JSString *decode_utf8_string(JSContext *cx, JS::HandleObject bytes_array) { + size_t len = JS_GetTypedArrayLength(bytes_array); + std::vector buf(len); + if (len > 0) { + JS::AutoCheckCannotGC noGC(cx); + bool is_shared; + void *data = JS_GetArrayBufferViewData(bytes_array, &is_shared, noGC); + memcpy(buf.data(), data, len); + } + return JS_NewStringCopyUTF8N(cx, JS::UTF8Chars(buf.data(), buf.size())); +} + +JSObject *CacheEntry::create(JSContext *cx, const uint8_t *bytes, size_t len) { + JS::RootedObject byte_array(cx, JS_NewUint8Array(cx, len)); + if (!byte_array) return nullptr; + + if (len > 0) { + JS::AutoCheckCannotGC noGC(cx); + bool is_shared; + void *dst = JS_GetArrayBufferViewData(byte_array, &is_shared, noGC); + memcpy(dst, bytes, len); + } + + JS::RootedObject entry(cx, + JS_NewObjectWithGivenProto(cx, &CacheEntry::class_, nullptr)); + if (!entry) return nullptr; + + JS::SetReservedSlot(entry, static_cast(Slot::Bytes), + JS::ObjectValue(*byte_array)); + + if (!JS_DefineFunctions(cx, entry, CacheEntry::methods)) return nullptr; + + return entry; +} + +bool CacheEntry::arrayBuffer(JSContext *cx, unsigned argc, JS::Value *vp) { + JS::CallArgs args = JS::CallArgsFromVp(argc, vp); + if (!args.thisv().isObject()) { + JS_ReportErrorUTF8(cx, "Invalid CacheEntry"); + return false; + } + JS::RootedObject self(cx, &args.thisv().toObject()); + JS::RootedObject bytes_array(cx, cache_entry_bytes(cx, self)); + if (!bytes_array) return false; + + size_t len = JS_GetTypedArrayLength(bytes_array); + + // Allocate a fresh ArrayBuffer and copy the bytes in. We don't share the + // entry's underlying buffer because callers could otherwise mutate it and + // corrupt subsequent reads from this same entry. + JS::RootedObject ab(cx, JS::NewArrayBuffer(cx, len)); + if (!ab) return false; + + if (len > 0) { + JS::AutoCheckCannotGC noGC(cx); + bool is_shared; + void *src = JS_GetArrayBufferViewData(bytes_array, &is_shared, noGC); + void *dst = JS::GetArrayBufferData(ab, &is_shared, noGC); + memcpy(dst, src, len); + } + + JS::RootedValue ab_val(cx, JS::ObjectValue(*ab)); + JS::RootedObject promise(cx, JS::CallOriginalPromiseResolve(cx, ab_val)); + if (!promise) return false; + args.rval().setObject(*promise); + return true; +} + +bool CacheEntry::text(JSContext *cx, unsigned argc, JS::Value *vp) { + JS::CallArgs args = JS::CallArgsFromVp(argc, vp); + if (!args.thisv().isObject()) { + JS_ReportErrorUTF8(cx, "Invalid CacheEntry"); + return false; + } + JS::RootedObject self(cx, &args.thisv().toObject()); + JS::RootedObject bytes_array(cx, cache_entry_bytes(cx, self)); + if (!bytes_array) return false; + + JS::RootedString str(cx, decode_utf8_string(cx, bytes_array)); + if (!str) return false; + + JS::RootedValue str_val(cx, JS::StringValue(str)); + JS::RootedObject promise(cx, JS::CallOriginalPromiseResolve(cx, str_val)); + if (!promise) return false; + args.rval().setObject(*promise); + return true; +} + +bool CacheEntry::json(JSContext *cx, unsigned argc, JS::Value *vp) { + JS::CallArgs args = JS::CallArgsFromVp(argc, vp); + if (!args.thisv().isObject()) { + JS_ReportErrorUTF8(cx, "Invalid CacheEntry"); + return false; + } + JS::RootedObject self(cx, &args.thisv().toObject()); + JS::RootedObject bytes_array(cx, cache_entry_bytes(cx, self)); + if (!bytes_array) return false; + + JS::RootedString str(cx, decode_utf8_string(cx, bytes_array)); + if (!str) return false; + + // Parse JSON. On success → resolved Promise; on failure → rejected Promise + // (matching the standard Body.json() contract; SyntaxError is async, not + // a synchronous throw). + JS::RootedValue parsed(cx); + JS::RootedObject promise(cx); + if (JS_ParseJSON(cx, str, &parsed)) { + promise = JS::CallOriginalPromiseResolve(cx, parsed); + } else { + JS::RootedValue exc(cx); + if (!JS_GetPendingException(cx, &exc)) return false; + JS_ClearPendingException(cx); + promise = JS::CallOriginalPromiseReject(cx, exc); + } + + if (!promise) return false; + args.rval().setObject(*promise); + return true; +} + +const JSFunctionSpec CacheEntry::methods[] = { + JS_FN("arrayBuffer", CacheEntry::arrayBuffer, 0, JSPROP_ENUMERATE), + JS_FN("text", CacheEntry::text, 0, JSPROP_ENUMERATE), + JS_FN("json", CacheEntry::json, 0, JSPROP_ENUMERATE), + JS_FS_END, +}; + +bool Cache::get(JSContext *cx, unsigned argc, JS::Value *vp) { + JS::CallArgs args = JS::CallArgsFromVp(argc, vp); + if (!args.requireAtLeast(cx, "get", 1)) return false; + + JS::RootedString key_str(cx, JS::ToString(cx, args[0])); + if (!key_str) return false; + auto key = core::encode(cx, key_str); + if (!key) return false; + + auto result = host_api::cache_get(std::string_view(key.ptr.get(), key.len)); + if (!result.is_ok()) { + throw_cache_error(cx, result.unwrap_err()); + return ReturnPromiseRejectedWithPendingError(cx, args); + } + + auto value_option = result.unwrap(); + if (!value_option.is_some()) { + JS::RootedValue null_val(cx, JS::NullValue()); + return resolve_with(cx, null_val, args); + } + + auto bytes = value_option.unwrap(); + JS::RootedObject entry(cx, CacheEntry::create(cx, bytes.ptr, bytes.len)); + if (!entry) return false; + + JS::RootedValue entry_val(cx, JS::ObjectValue(*entry)); + return resolve_with(cx, entry_val, args); +} + +bool Cache::exists(JSContext *cx, unsigned argc, JS::Value *vp) { + JS::CallArgs args = JS::CallArgsFromVp(argc, vp); + if (!args.requireAtLeast(cx, "exists", 1)) return false; + + JS::RootedString key_str(cx, JS::ToString(cx, args[0])); + if (!key_str) return false; + auto key = core::encode(cx, key_str); + if (!key) return false; + + auto result = host_api::cache_exists(std::string_view(key.ptr.get(), key.len)); + if (!result.is_ok()) { + throw_cache_error(cx, result.unwrap_err()); + return ReturnPromiseRejectedWithPendingError(cx, args); + } + + JS::RootedValue rv(cx, JS::BooleanValue(result.unwrap())); + return resolve_with(cx, rv, args); +} + +bool Cache::delete_op(JSContext *cx, unsigned argc, JS::Value *vp) { + JS::CallArgs args = JS::CallArgsFromVp(argc, vp); + if (!args.requireAtLeast(cx, "delete", 1)) return false; + + JS::RootedString key_str(cx, JS::ToString(cx, args[0])); + if (!key_str) return false; + auto key = core::encode(cx, key_str); + if (!key) return false; + + auto err = host_api::cache_delete(std::string_view(key.ptr.get(), key.len)); + if (err) { + throw_cache_error(cx, *err); + return ReturnPromiseRejectedWithPendingError(cx, args); + } + + JS::RootedValue undef(cx, JS::UndefinedValue()); + return resolve_with(cx, undef, args); +} + +bool Cache::set(JSContext *cx, unsigned argc, JS::Value *vp) { + JS::CallArgs args = JS::CallArgsFromVp(argc, vp); + if (!args.requireAtLeast(cx, "set", 2)) return false; + + // 1. Validate key — sync throw on failure. + JS::RootedString key_jsstring(cx, JS::ToString(cx, args[0])); + if (!key_jsstring) return false; + + // 2. Parse options — sync throw on failure. + std::optional ttl_ms; + if (args.length() > 2 && !args[2].isUndefined()) { + if (!build_ttl_ms(cx, args[2], &ttl_ms)) return false; + } + + // 3. Build the outer Promise we'll always return. + JS::RootedObject outer_promise(cx, JS::NewPromiseObject(cx, nullptr)); + if (!outer_promise) return false; + + // 4. Try sync coercion (string / ArrayBuffer / ArrayBufferView). + std::vector bytes; + bool sync_done = false; + JS::RootedValue value(cx, args[1]); + if (!try_sync_coerce_bytes(cx, value, &bytes, &sync_done)) { + // Capture pending exception, reject outer Promise. + JS::RootedValue exc(cx); + if (!JS_GetPendingException(cx, &exc)) return false; + JS_ClearPendingException(cx); + if (!JS::RejectPromise(cx, outer_promise, exc)) return false; + args.rval().setObject(*outer_promise); + return true; + } + + if (sync_done) { + // Sync path: do the host call, resolve/reject outer. + if (!finish_set(cx, outer_promise, key_jsstring, + bytes.empty() ? nullptr : bytes.data(), bytes.size(), + ttl_ms)) { + return false; + } + args.rval().setObject(*outer_promise); + return true; + } + + // 5. Async path. If value is a ReadableStream, wrap it in a Response so + // we can use the unified `value.arrayBuffer()` path below. Anything + // else (Response, Blob, anything with `arrayBuffer()`) goes through as-is. + if (!value.isObject()) { + JS_ReportErrorUTF8(cx, "set: value must be a string, ArrayBuffer, " + "ArrayBufferView, ReadableStream, or Response"); + JS::RootedValue exc(cx); + if (!JS_GetPendingException(cx, &exc)) return false; + JS_ClearPendingException(cx); + if (!JS::RejectPromise(cx, outer_promise, exc)) return false; + args.rval().setObject(*outer_promise); + return true; + } + + JS::RootedObject value_obj(cx, &value.toObject()); + + if (JS::IsReadableStream(value_obj)) { + JS::RootedValue response_ctor_val(cx); + if (!JS_GetProperty(cx, ENGINE->global(), "Response", + &response_ctor_val)) { + return false; + } + JS::RootedValueArray<1> ctor_args(cx); + ctor_args[0].setObject(*value_obj); + JS::RootedObject response_obj(cx); + if (!JS::Construct(cx, response_ctor_val, ctor_args, &response_obj)) { + JS::RootedValue exc(cx); + if (!JS_GetPendingException(cx, &exc)) return false; + JS_ClearPendingException(cx); + if (!JS::RejectPromise(cx, outer_promise, exc)) return false; + args.rval().setObject(*outer_promise); + return true; + } + value_obj = response_obj; + value.setObject(*value_obj); + } + + // Call `value.arrayBuffer()`. We expect it to exist and return a + // Promise; if not, the call (or the resulting Promise) + // surfaces the error which we forward via the catch handler. + JS::RootedValue inner_promise_val(cx); + if (!JS::Call(cx, value_obj, "arrayBuffer", JS::HandleValueArray::empty(), + &inner_promise_val)) { + JS::RootedValue exc(cx); + if (!JS_GetPendingException(cx, &exc)) return false; + JS_ClearPendingException(cx); + if (!JS::RejectPromise(cx, outer_promise, exc)) return false; + args.rval().setObject(*outer_promise); + return true; + } + + if (!inner_promise_val.isObject()) { + JS_ReportErrorUTF8(cx, "set: value.arrayBuffer() did not return a Promise"); + JS::RootedValue exc(cx); + if (!JS_GetPendingException(cx, &exc)) return false; + JS_ClearPendingException(cx); + if (!JS::RejectPromise(cx, outer_promise, exc)) return false; + args.rval().setObject(*outer_promise); + return true; + } + JS::RootedObject inner_promise(cx, &inner_promise_val.toObject()); + + // Build the state object passed to the async then-handler. + JS::RootedObject state(cx, JS_NewPlainObject(cx)); + if (!state) return false; + JS::RootedValue key_val(cx, JS::StringValue(key_jsstring)); + JS::RootedValue ttl_val(cx, JS::NumberValue( + ttl_ms.has_value() ? static_cast(*ttl_ms) : -1.0)); + if (!JS_DefineProperty(cx, state, "key", key_val, 0)) return false; + if (!JS_DefineProperty(cx, state, "ttlMs", ttl_val, 0)) return false; + JS::RootedValue extra(cx, JS::ObjectValue(*state)); + + JS::RootedObject then_handler(cx, create_internal_method( + cx, outer_promise, extra)); + if (!then_handler) return false; + JS::RootedObject catch_handler(cx, create_internal_method( + cx, outer_promise)); + if (!catch_handler) return false; + + if (!JS::AddPromiseReactions(cx, inner_promise, then_handler, catch_handler)) { + return false; + } + + args.rval().setObject(*outer_promise); + return true; +} + +bool Cache::getOrSet(JSContext *cx, unsigned argc, JS::Value *vp) { + JS::CallArgs args = JS::CallArgsFromVp(argc, vp); + if (!args.requireAtLeast(cx, "getOrSet", 2)) return false; + + // 1. Validate key — sync throw. + JS::RootedString key_jsstring(cx, JS::ToString(cx, args[0])); + if (!key_jsstring) return false; + auto key_chars = core::encode(cx, key_jsstring); + if (!key_chars) return false; + + // 2. Validate populate — must be callable. + if (!args[1].isObject() || !JS::IsCallable(&args[1].toObject())) { + JS_ReportErrorUTF8(cx, "getOrSet: populate must be a function"); + return false; + } + JS::RootedValue populate_fn(cx, args[1]); + + // 3. Parse options — sync throw on invalid WriteOptions. + std::optional ttl_ms; + if (args.length() > 2 && !args[2].isUndefined()) { + if (!build_ttl_ms(cx, args[2], &ttl_ms)) return false; + } + + // 4. Cache hit fast path: return resolved Promise. + auto cache_result = host_api::cache_get(std::string_view(key_chars.ptr.get(), key_chars.len)); + if (!cache_result.is_ok()) { + throw_cache_error(cx, cache_result.unwrap_err()); + return ReturnPromiseRejectedWithPendingError(cx, args); + } + auto opt = cache_result.unwrap(); + if (opt.is_some()) { + auto bytes = opt.unwrap(); + JS::RootedObject entry(cx, CacheEntry::create(cx, bytes.ptr, bytes.len)); + if (!entry) return false; + JS::RootedValue entry_val(cx, JS::ObjectValue(*entry)); + return resolve_with(cx, entry_val, args); + } + + // 5. Coalesce: if a populator is already running for this key, return + // that pending Promise. + JS::RootedObject existing(cx, inflight_get(cx, key_jsstring)); + if (existing) { + args.rval().setObject(*existing); + return true; + } + + // 6. Create the outer Promise we'll return; register it in inflight so + // concurrent callers join us. + JS::RootedObject outer_promise(cx, JS::NewPromiseObject(cx, nullptr)); + if (!outer_promise) return false; + if (!inflight_set(cx, key_jsstring, outer_promise)) return false; + + // 7. Build the state passed through reactions: { key, ttlMs }. + // ttlMs == -1.0 is the sentinel for "no expiry". + JS::RootedObject state(cx, JS_NewPlainObject(cx)); + if (!state) { + inflight_delete(cx, key_jsstring); + return false; + } + JS::RootedValue key_val(cx, JS::StringValue(key_jsstring)); + JS::RootedValue ttl_val(cx, JS::NumberValue( + ttl_ms.has_value() ? static_cast(*ttl_ms) : -1.0)); + if (!JS_DefineProperty(cx, state, "key", key_val, 0) || + !JS_DefineProperty(cx, state, "ttlMs", ttl_val, 0)) { + inflight_delete(cx, key_jsstring); + return false; + } + JS::RootedValue extra(cx, JS::ObjectValue(*state)); + + // 8. Call populate(). Synchronous throws are converted into outer-Promise + // rejections so the caller experience is consistent with async throws. + JS::RootedValue populate_result(cx); + JS::RootedObject this_obj(cx); // null this + if (!JS::Call(cx, this_obj, populate_fn, JS::HandleValueArray::empty(), + &populate_result)) { + if (!reject_and_finish(cx, outer_promise, key_jsstring, args)) return false; + args.rval().setObject(*outer_promise); + return true; + } + + // 9. Promise.resolve(populate_result) — handles raw values and Promises + // uniformly. + JS::RootedObject populate_promise(cx, + JS::CallOriginalPromiseResolve(cx, populate_result)); + if (!populate_promise) { + inflight_delete(cx, key_jsstring); + return false; + } + + // 10. Chain reactions on the populator's Promise. The then/catch handlers + // drive the rest of the lifecycle. + JS::RootedObject then_h(cx, + create_internal_method(cx, outer_promise, + extra)); + if (!then_h) { + inflight_delete(cx, key_jsstring); + return false; + } + JS::RootedObject catch_h(cx, + create_internal_method(cx, outer_promise, + extra)); + if (!catch_h) { + inflight_delete(cx, key_jsstring); + return false; + } + if (!JS::AddPromiseReactions(cx, populate_promise, then_h, catch_h)) { + inflight_delete(cx, key_jsstring); + return false; + } + + args.rval().setObject(*outer_promise); + return true; +} + +bool Cache::expire(JSContext *cx, unsigned argc, JS::Value *vp) { + JS::CallArgs args = JS::CallArgsFromVp(argc, vp); + if (!args.requireAtLeast(cx, "expire", 2)) return false; + + JS::RootedString key_str(cx, JS::ToString(cx, args[0])); + if (!key_str) return false; + auto key = core::encode(cx, key_str); + if (!key) return false; + + std::optional ttl_ms; + if (!build_ttl_ms(cx, args[1], &ttl_ms)) return false; + if (!ttl_ms) { + JS_ReportErrorUTF8(cx, "expire: WriteOptions must specify ttl, ttlMs, or expiresAt"); + return false; + } + + auto result = host_api::cache_expire(std::string_view(key.ptr.get(), key.len), *ttl_ms); + if (!result.is_ok()) { + throw_cache_error(cx, result.unwrap_err()); + return ReturnPromiseRejectedWithPendingError(cx, args); + } + + JS::RootedValue rv(cx, JS::BooleanValue(result.unwrap())); + return resolve_with(cx, rv, args); +} + +// Try to coerce `value` to bytes synchronously. +// +// On success: *done = true and *out is filled. +// On unsupported type: *done = false (caller should try async path). +// On error: returns false with a pending JS exception. +bool try_sync_coerce_bytes(JSContext *cx, JS::HandleValue value, + std::vector *out, bool *done) { + *done = false; + + if (value.isString()) { + JS::RootedString str(cx, value.toString()); + JS::UniqueChars utf8 = JS_EncodeStringToUTF8(cx, str); + if (!utf8) return false; + // Use the encoded UTF-8 byte length, not strlen — values are raw bytes + // and may legitimately contain embedded NULs. + JSLinearString *linear = JS_EnsureLinearString(cx, str); + if (!linear) return false; + size_t len = JS::GetDeflatedUTF8StringLength(linear); + out->assign(reinterpret_cast(utf8.get()), + reinterpret_cast(utf8.get()) + len); + *done = true; + return true; + } + + if (!value.isObject()) { + return true; // not sync-coercible (e.g., number, bool); caller decides + } + + JS::RootedObject obj(cx, &value.toObject()); + + if (JS::IsArrayBufferObject(obj)) { + size_t len = JS::GetArrayBufferByteLength(obj); + out->resize(len); + if (len > 0) { + JS::AutoCheckCannotGC noGC(cx); + bool is_shared; + void *src = JS::GetArrayBufferData(obj, &is_shared, noGC); + memcpy(out->data(), src, len); + } + *done = true; + return true; + } + + if (JS_IsArrayBufferViewObject(obj)) { + size_t len = JS_GetArrayBufferViewByteLength(obj); + out->resize(len); + if (len > 0) { + JS::AutoCheckCannotGC noGC(cx); + bool is_shared; + void *src = JS_GetArrayBufferViewData(obj, &is_shared, noGC); + memcpy(out->data(), src, len); + } + *done = true; + return true; + } + + return true; // async path (Response, ReadableStream, etc.) +} + +// Capture the pending JS exception, remove `key` from the inflight map, +// reject `outer_promise` with the captured exception, and clear `args.rval()`. +// Used by the getOrSet reaction handlers for any error path. +bool reject_and_finish(JSContext *cx, JS::HandleObject outer_promise, + JS::HandleString key_jsstring, JS::CallArgs &args) { + JS::RootedValue exc(cx); + if (!JS_GetPendingException(cx, &exc)) { + inflight_delete(cx, key_jsstring); + return false; + } + JS_ClearPendingException(cx); + inflight_delete(cx, key_jsstring); + args.rval().setUndefined(); + return JS::RejectPromise(cx, outer_promise, exc); +} + +JSObject *inflight_get(JSContext *cx, JS::HandleString key) { + if (!INFLIGHT_MAP) return nullptr; + JS::RootedObject map(cx, *INFLIGHT_MAP); + JS::RootedId id(cx); + if (!JS_StringToId(cx, key, &id)) return nullptr; + + bool found; + if (!JS_HasOwnPropertyById(cx, map, id, &found)) return nullptr; + if (!found) return nullptr; + + JS::RootedValue val(cx); + if (!JS_GetPropertyById(cx, map, id, &val)) return nullptr; + if (!val.isObject()) return nullptr; + return &val.toObject(); +} + +bool inflight_set(JSContext *cx, JS::HandleString key, JS::HandleObject promise) { + if (!INFLIGHT_MAP) return false; + JS::RootedObject map(cx, *INFLIGHT_MAP); + JS::RootedId id(cx); + if (!JS_StringToId(cx, key, &id)) return false; + JS::RootedValue val(cx, JS::ObjectValue(*promise)); + return JS_SetPropertyById(cx, map, id, val); +} + +void inflight_delete(JSContext *cx, JS::HandleString key) { + if (!INFLIGHT_MAP) return; + JS::RootedObject map(cx, *INFLIGHT_MAP); + JS::RootedId id(cx); + if (!JS_StringToId(cx, key, &id)) return; // best-effort cleanup + JS::ObjectOpResult result; + (void)JS_DeletePropertyById(cx, map, id, result); +} + +// Finalise an in-flight populate: cache_set the bytes, build a CacheEntry, +// resolve the outer Promise with the entry, and remove `key` from inflight. +// On any failure: reject the outer Promise (still removes from inflight). +bool getOrSet_finalize(JSContext *cx, JS::HandleObject outer_promise, + JS::HandleString key_jsstring, + std::optional ttl_ms, const uint8_t *bytes, + size_t len) { + auto key_chars = core::encode(cx, key_jsstring); + if (!key_chars) { + inflight_delete(cx, key_jsstring); + return false; + } + + host_api::CacheBytesView view{bytes, len}; + auto err = host_api::cache_set(std::string_view(key_chars.ptr.get(), key_chars.len), view, ttl_ms); + if (err) { + throw_cache_error(cx, *err); + JS::RootedValue exc(cx); + if (!JS_GetPendingException(cx, &exc)) { + inflight_delete(cx, key_jsstring); + return false; + } + JS_ClearPendingException(cx); + inflight_delete(cx, key_jsstring); + return JS::RejectPromise(cx, outer_promise, exc); + } + + JS::RootedObject entry(cx, CacheEntry::create(cx, bytes, len)); + if (!entry) { + JS::RootedValue exc(cx); + if (!JS_GetPendingException(cx, &exc)) { + inflight_delete(cx, key_jsstring); + return false; + } + JS_ClearPendingException(cx); + inflight_delete(cx, key_jsstring); + return JS::RejectPromise(cx, outer_promise, exc); + } + + JS::RootedValue entry_val(cx, JS::ObjectValue(*entry)); + inflight_delete(cx, key_jsstring); + return JS::ResolvePromise(cx, outer_promise, entry_val); +} + +// Issues the host_api::cache_set call and resolves `outer_promise`, or +// rejects it with a CacheError. The pending JS exception (if any) is +// captured into the rejection. +bool finish_set(JSContext *cx, JS::HandleObject outer_promise, + JS::HandleString key_jsstring, const uint8_t *bytes, + size_t len, std::optional ttl_ms) { + auto key_chars = core::encode(cx, key_jsstring); + if (!key_chars) return false; + + host_api::CacheBytesView view{bytes, len}; + auto err = host_api::cache_set(std::string_view(key_chars.ptr.get(), key_chars.len), view, ttl_ms); + if (err) { + throw_cache_error(cx, *err); + JS::RootedValue exc(cx); + if (!JS_GetPendingException(cx, &exc)) return false; + JS_ClearPendingException(cx); + return JS::RejectPromise(cx, outer_promise, exc); + } + + JS::RootedValue undef(cx, JS::UndefinedValue()); + return JS::ResolvePromise(cx, outer_promise, undef); +} + +} // namespace (local helpers above) + +// Async then-handler: receives the resolved ArrayBuffer from +// value.arrayBuffer(), copies it out, performs the host_api::cache_set, +// resolves or rejects the outer Promise. +// +// receiver = outer Promise +// extra = { key: string, ttlMs: number | -1 (sentinel: no expiry) } +bool Cache::set_then(JSContext *cx, JS::HandleObject outer_promise, + JS::HandleValue extra, JS::CallArgs args) { + // args[0] is the resolved ArrayBuffer (or ArrayBufferView from a transformed body). + if (!args.get(0).isObject()) { + JS_ReportErrorUTF8(cx, "set: expected ArrayBuffer from value.arrayBuffer()"); + JS::RootedValue exc(cx); + if (!JS_GetPendingException(cx, &exc)) return false; + JS_ClearPendingException(cx); + return JS::RejectPromise(cx, outer_promise, exc); + } + + JS::RootedObject ab(cx, &args[0].toObject()); + std::vector bytes; + if (JS::IsArrayBufferObject(ab)) { + size_t len = JS::GetArrayBufferByteLength(ab); + bytes.resize(len); + if (len > 0) { + JS::AutoCheckCannotGC noGC(cx); + bool is_shared; + void *src = JS::GetArrayBufferData(ab, &is_shared, noGC); + memcpy(bytes.data(), src, len); + } + } else if (JS_IsArrayBufferViewObject(ab)) { + size_t len = JS_GetArrayBufferViewByteLength(ab); + bytes.resize(len); + if (len > 0) { + JS::AutoCheckCannotGC noGC(cx); + bool is_shared; + void *src = JS_GetArrayBufferViewData(ab, &is_shared, noGC); + memcpy(bytes.data(), src, len); + } + } else { + JS_ReportErrorUTF8(cx, "set: unexpected non-buffer body resolution"); + JS::RootedValue exc(cx); + if (!JS_GetPendingException(cx, &exc)) return false; + JS_ClearPendingException(cx); + return JS::RejectPromise(cx, outer_promise, exc); + } + + // Unpack extra: { key, ttlMs } + JS::RootedObject state(cx, &extra.toObject()); + JS::RootedValue key_val(cx); + JS::RootedValue ttl_val(cx); + if (!JS_GetProperty(cx, state, "key", &key_val)) return false; + if (!JS_GetProperty(cx, state, "ttlMs", &ttl_val)) return false; + + JS::RootedString key_jsstring(cx, key_val.toString()); + + std::optional ttl_ms; + double ttl_n = ttl_val.toNumber(); + if (ttl_n >= 0.0) ttl_ms = static_cast(ttl_n); + + args.rval().setUndefined(); + return finish_set(cx, outer_promise, key_jsstring, + bytes.empty() ? nullptr : bytes.data(), bytes.size(), + ttl_ms); +} + +// Async catch-handler: forwards the inner Promise's rejection reason to +// the outer Promise. +bool Cache::set_catch(JSContext *cx, JS::HandleObject outer_promise, + JS::HandleValue extra, JS::CallArgs args) { + JS::RootedValue reason(cx, args.get(0)); + args.rval().setUndefined(); + return JS::RejectPromise(cx, outer_promise, reason); +} + +// getOrSet then-handler on the populator's Promise. Receives the populator's +// resolved CacheValue and either finalises immediately (sync coercion) or +// chains to bytes_then via value.arrayBuffer() (async coercion). +// +// receiver = outer Promise; extra = { key, ttlMs }. +bool Cache::getOrSet_populate_then(JSContext *cx, + JS::HandleObject outer_promise, + JS::HandleValue extra, JS::CallArgs args) { + JS::RootedObject state(cx, &extra.toObject()); + + JS::RootedValue key_val(cx); + JS::RootedValue ttl_val(cx); + if (!JS_GetProperty(cx, state, "key", &key_val) || + !JS_GetProperty(cx, state, "ttlMs", &ttl_val)) { + return false; + } + JS::RootedString key_jsstring(cx, key_val.toString()); + + std::optional ttl_ms; + double ttl_n = ttl_val.toNumber(); + if (ttl_n >= 0.0) ttl_ms = static_cast(ttl_n); + + JS::RootedValue value(cx, args.get(0)); + + // null from the populator → don't cache; resolve outer Promise with null. + // Coalesced waiters share the same outer Promise and receive null as well. + // This lets users wrap fallible work and only pin successes: + // getOrSet(k, async () => { const r = await fetch(u); return r.ok ? r : null; }, ...) + if (value.isNull()) { + inflight_delete(cx, key_jsstring); + JS::RootedValue null_val(cx, JS::NullValue()); + args.rval().setUndefined(); + return JS::ResolvePromise(cx, outer_promise, null_val); + } + + // Sync coercion (string / ArrayBuffer / ArrayBufferView). + std::vector bytes; + bool sync_done = false; + if (!try_sync_coerce_bytes(cx, value, &bytes, &sync_done)) { + return reject_and_finish(cx, outer_promise, key_jsstring, args); + } + if (sync_done) { + args.rval().setUndefined(); + return getOrSet_finalize(cx, outer_promise, key_jsstring, ttl_ms, + bytes.empty() ? nullptr : bytes.data(), + bytes.size()); + } + + // Async coercion. Reject for primitives that aren't sync-coercible. + if (!value.isObject()) { + JS_ReportErrorUTF8(cx, + "getOrSet: populate must return string, ArrayBuffer, ArrayBufferView, " + "ReadableStream, or Response"); + return reject_and_finish(cx, outer_promise, key_jsstring, args); + } + + JS::RootedObject value_obj(cx, &value.toObject()); + + // Wrap raw ReadableStream in a Response so the unified arrayBuffer() path + // covers it. + if (JS::IsReadableStream(value_obj)) { + JS::RootedValue response_ctor_val(cx); + if (!JS_GetProperty(cx, ENGINE->global(), "Response", + &response_ctor_val)) { + return reject_and_finish(cx, outer_promise, key_jsstring, args); + } + JS::RootedValueArray<1> ctor_args(cx); + ctor_args[0].setObject(*value_obj); + JS::RootedObject response_obj(cx); + if (!JS::Construct(cx, response_ctor_val, ctor_args, &response_obj)) { + return reject_and_finish(cx, outer_promise, key_jsstring, args); + } + value_obj = response_obj; + } + + // value.arrayBuffer() → Promise. + JS::RootedValue inner_promise_val(cx); + if (!JS::Call(cx, value_obj, "arrayBuffer", JS::HandleValueArray::empty(), + &inner_promise_val)) { + return reject_and_finish(cx, outer_promise, key_jsstring, args); + } + if (!inner_promise_val.isObject()) { + JS_ReportErrorUTF8(cx, "getOrSet: arrayBuffer() did not return a Promise"); + return reject_and_finish(cx, outer_promise, key_jsstring, args); + } + JS::RootedObject inner_promise(cx, &inner_promise_val.toObject()); + + // Chain to bytes_then. populate_catch handles inner-promise rejection + // (it cleans up the inflight entry the same way). + JS::RootedObject bytes_then_h(cx, + create_internal_method(cx, outer_promise, + extra)); + if (!bytes_then_h) return false; + JS::RootedObject bytes_catch_h(cx, + create_internal_method(cx, outer_promise, + extra)); + if (!bytes_catch_h) return false; + + if (!JS::AddPromiseReactions(cx, inner_promise, bytes_then_h, bytes_catch_h)) { + return false; + } + args.rval().setUndefined(); + return true; +} + +// getOrSet catch-handler. Forwards rejection to the outer Promise and removes +// the inflight entry so the next caller can populate again. +bool Cache::getOrSet_populate_catch(JSContext *cx, + JS::HandleObject outer_promise, + JS::HandleValue extra, JS::CallArgs args) { + JS::RootedObject state(cx, &extra.toObject()); + JS::RootedValue key_val(cx); + if (JS_GetProperty(cx, state, "key", &key_val) && key_val.isString()) { + JS::RootedString key_jsstring(cx, key_val.toString()); + inflight_delete(cx, key_jsstring); + } + args.rval().setUndefined(); + JS::RootedValue reason(cx, args.get(0)); + return JS::RejectPromise(cx, outer_promise, reason); +} + +// getOrSet bytes-then handler: receives the resolved ArrayBuffer (from the +// async coercion path), extracts the bytes, and finalises. +bool Cache::getOrSet_bytes_then(JSContext *cx, JS::HandleObject outer_promise, + JS::HandleValue extra, JS::CallArgs args) { + JS::RootedObject state(cx, &extra.toObject()); + JS::RootedValue key_val(cx); + JS::RootedValue ttl_val(cx); + if (!JS_GetProperty(cx, state, "key", &key_val) || + !JS_GetProperty(cx, state, "ttlMs", &ttl_val)) { + return false; + } + JS::RootedString key_jsstring(cx, key_val.toString()); + + std::optional ttl_ms; + double ttl_n = ttl_val.toNumber(); + if (ttl_n >= 0.0) ttl_ms = static_cast(ttl_n); + + if (!args.get(0).isObject()) { + JS_ReportErrorUTF8(cx, "getOrSet: arrayBuffer() resolved to a non-object"); + return reject_and_finish(cx, outer_promise, key_jsstring, args); + } + + JS::RootedObject ab(cx, &args[0].toObject()); + std::vector bytes; + if (JS::IsArrayBufferObject(ab)) { + size_t len = JS::GetArrayBufferByteLength(ab); + bytes.resize(len); + if (len > 0) { + JS::AutoCheckCannotGC noGC(cx); + bool is_shared; + void *src = JS::GetArrayBufferData(ab, &is_shared, noGC); + memcpy(bytes.data(), src, len); + } + } else if (JS_IsArrayBufferViewObject(ab)) { + size_t len = JS_GetArrayBufferViewByteLength(ab); + bytes.resize(len); + if (len > 0) { + JS::AutoCheckCannotGC noGC(cx); + bool is_shared; + void *src = JS_GetArrayBufferViewData(ab, &is_shared, noGC); + memcpy(bytes.data(), src, len); + } + } else { + JS_ReportErrorUTF8(cx, "getOrSet: arrayBuffer() resolved to a non-buffer"); + return reject_and_finish(cx, outer_promise, key_jsstring, args); + } + + args.rval().setUndefined(); + return getOrSet_finalize(cx, outer_promise, key_jsstring, ttl_ms, + bytes.empty() ? nullptr : bytes.data(), + bytes.size()); +} + +namespace { +bool incr_common(JSContext *cx, JS::CallArgs &args, const char *fn_name, + bool negate) { + if (!args.requireAtLeast(cx, fn_name, 1)) return false; + + JS::RootedString key_str(cx, JS::ToString(cx, args[0])); + if (!key_str) return false; + auto key = core::encode(cx, key_str); + if (!key) return false; + + int64_t delta = 1; + if (args.length() > 1 && !args[1].isUndefined()) { + double d; + if (!JS::ToNumber(cx, args[1], &d)) return false; + if (!std::isfinite(d)) { + JS_ReportErrorUTF8(cx, "%s: delta must be a finite number", fn_name); + return false; + } + if (std::trunc(d) != d) { + JS_ReportErrorUTF8(cx, "%s: delta must be an integer", fn_name); + return false; + } + // Beyond Number.MAX_SAFE_INTEGER, JS Numbers can't represent integers + // exactly. The bound also keeps `-delta` (decr path) and the int64 cast + // safe — INT64_MIN/MAX would otherwise be reachable as UB. + constexpr double max_safe = 9007199254740991.0; // 2^53 - 1 + if (d > max_safe || d < -max_safe) { + JS_ReportErrorUTF8(cx, + "%s: delta must be within ±Number.MAX_SAFE_INTEGER", fn_name); + return false; + } + delta = static_cast(d); + } + if (negate) delta = -delta; + + auto result = host_api::cache_incr(std::string_view(key.ptr.get(), key.len), delta); + if (!result.is_ok()) { + throw_cache_error(cx, result.unwrap_err()); + return ReturnPromiseRejectedWithPendingError(cx, args); + } + + JS::RootedValue rv(cx, JS::NumberValue(static_cast(result.unwrap()))); + return resolve_with(cx, rv, args); +} +} // namespace + +bool Cache::incr(JSContext *cx, unsigned argc, JS::Value *vp) { + JS::CallArgs args = JS::CallArgsFromVp(argc, vp); + return incr_common(cx, args, "incr", /*negate=*/false); +} + +bool Cache::decr(JSContext *cx, unsigned argc, JS::Value *vp) { + JS::CallArgs args = JS::CallArgsFromVp(argc, vp); + return incr_common(cx, args, "decr", /*negate=*/true); +} + +const JSFunctionSpec cache_methods[] = { + JS_FN("get", Cache::get, 1, JSPROP_ENUMERATE), + JS_FN("exists", Cache::exists, 1, JSPROP_ENUMERATE), + JS_FN("set", Cache::set, 2, JSPROP_ENUMERATE), + JS_FN("delete", Cache::delete_op, 1, JSPROP_ENUMERATE), + JS_FN("expire", Cache::expire, 2, JSPROP_ENUMERATE), + JS_FN("incr", Cache::incr, 1, JSPROP_ENUMERATE), + JS_FN("decr", Cache::decr, 1, JSPROP_ENUMERATE), + JS_FN("getOrSet", Cache::getOrSet, 2, JSPROP_ENUMERATE), + JS_FS_END, +}; + +bool install(api::Engine *engine) { + ENGINE = engine; + + // Inflight map for getOrSet coalescing — a JSObject with no prototype so + // user keys cannot collide with inherited names like "constructor". + // Persistent-rooted for the engine's lifetime. + JS::RootedObject inflight_obj(engine->cx(), JS_NewPlainObject(engine->cx())); + if (!inflight_obj) return false; + if (!JS_SetPrototype(engine->cx(), inflight_obj, nullptr)) return false; + INFLIGHT_MAP = + new JS::PersistentRooted(engine->cx(), inflight_obj); + + JS::RootedObject cache_obj(engine->cx(), JS_NewPlainObject(engine->cx())); + if (!cache_obj) return false; + + if (!JS_DefineFunctions(engine->cx(), cache_obj, cache_methods)) { + return false; + } + + if (!JS_DefineProperty(engine->cx(), engine->global(), "Cache", cache_obj, 0)) { + return false; + } + + return true; +} + +} // namespace fastedge::cache diff --git a/runtime/fastedge/builtins/console-override.cpp b/runtime/fastedge/builtins/console-override.cpp index 66ed076..c57ecf9 100644 --- a/runtime/fastedge/builtins/console-override.cpp +++ b/runtime/fastedge/builtins/console-override.cpp @@ -41,9 +41,7 @@ void builtin_impl_console_log(Console::LogType log_ty, const char *msg) { } fprintf(stdout, "%s %s\n", prefix, msg); - if (log_ty == Console::LogType::Warn || log_ty == Console::LogType::Error) { - fflush(stdout); - } + fflush(stdout); } } // namespace builtins::web::console diff --git a/runtime/fastedge/builtins/kv-store.cpp b/runtime/fastedge/builtins/kv-store.cpp index b99ddf6..70731ce 100644 --- a/runtime/fastedge/builtins/kv-store.cpp +++ b/runtime/fastedge/builtins/kv-store.cpp @@ -1,5 +1,14 @@ #include "kv-store.h" +#include "encode.h" + #include +#include +#include + +#include +#include +#include +#include using fastedge::kv_store::KvStore; @@ -30,9 +39,12 @@ const JSFunctionSpec KvStore::static_methods[] = { // Instance methods for KvStore objects const JSFunctionSpec KvStore::methods[] = { JS_FN("get", KvStore::get, 1, JSPROP_ENUMERATE), + JS_FN("getEntry", KvStore::get_entry, 1, JSPROP_ENUMERATE), JS_FN("scan", KvStore::scan, 1, JSPROP_ENUMERATE), JS_FN("zrangeByScore", KvStore::zrange_by_score, 3, JSPROP_ENUMERATE), + JS_FN("zrangeByScoreEntries", KvStore::zrange_by_score_entries, 3, JSPROP_ENUMERATE), JS_FN("zscan", KvStore::zscan, 2, JSPROP_ENUMERATE), + JS_FN("zscanEntries", KvStore::zscan_entries, 2, JSPROP_ENUMERATE), JS_FN("bfExists", KvStore::bf_exists, 2, JSPROP_ENUMERATE), JS_FS_END }; @@ -61,13 +73,13 @@ bool KvStore::open(JSContext *cx, unsigned argc, JS::Value *vp) { return false; } - JS::UniqueChars store_name = JS_EncodeStringToUTF8(cx, store_name_str); + auto store_name = core::encode(cx, store_name_str); if (!store_name) { return false; } // Call the host API to open the store - auto result = host_api::kv_store_open(store_name.get()); + auto result = host_api::kv_store_open(std::string_view(store_name.ptr.get(), store_name.len)); // THROW ERRORS... if (!result.is_ok()) { @@ -75,16 +87,16 @@ bool KvStore::open(JSContext *cx, unsigned argc, JS::Value *vp) { auto error = result.unwrap_err(); switch (error.tag) { case host_api::KvStoreErrorTag::NO_SUCH_STORE: - JS_ReportErrorUTF8(cx, "No such store: %s", store_name.get()); + JS_ReportErrorUTF8(cx, "No such store: %s", store_name.ptr.get()); break; case host_api::KvStoreErrorTag::ACCESS_DENIED: - JS_ReportErrorUTF8(cx, "Access denied to store: %s", store_name.get()); + JS_ReportErrorUTF8(cx, "Access denied to store: %s", store_name.ptr.get()); break; case host_api::KvStoreErrorTag::INTERNAL_ERROR: - JS_ReportErrorUTF8(cx, "Internal error opening store: %s", store_name.get()); + JS_ReportErrorUTF8(cx, "Internal error opening store: %s", store_name.ptr.get()); break; case host_api::KvStoreErrorTag::OTHER: - JS_ReportErrorUTF8(cx, "Error opening store %s: %s", store_name.get(), error.val.other.ptr); + JS_ReportErrorUTF8(cx, "Error opening store %s: %s", store_name.ptr.get(), error.val.other.ptr); break; } return false; @@ -133,17 +145,17 @@ bool KvStore::get(JSContext *cx, unsigned argc, JS::Value *vp) { return false; } - JS::UniqueChars key = JS_EncodeStringToUTF8(cx, key_str); + auto key = core::encode(cx, key_str); if (!key) { return false; } // Call the host API - auto result = host_api::kv_store_get(store->store_handle_, key.get()); + auto result = host_api::kv_store_get(store->store_handle_, std::string_view(key.ptr.get(), key.len)); if (!result.is_ok()) { // Handle error - JS_ReportErrorUTF8(cx, "Error getting key: %s", key.get()); + JS_ReportErrorUTF8(cx, "Error getting key: %s", key.ptr.get()); return false; } @@ -191,15 +203,15 @@ bool KvStore::scan(JSContext *cx, unsigned argc, JS::Value *vp) { return false; } - JS::UniqueChars pattern = JS_EncodeStringToUTF8(cx, pattern_str); + auto pattern = core::encode(cx, pattern_str); if (!pattern) { return false; } - auto result = host_api::kv_store_scan(store->store_handle_, pattern.get()); + auto result = host_api::kv_store_scan(store->store_handle_, std::string_view(pattern.ptr.get(), pattern.len)); if (!result.is_ok()) { - JS_ReportErrorUTF8(cx, "Error scanning with pattern: %s (Only prefix matching is supported. e.g. 'foo*')", pattern.get()); + JS_ReportErrorUTF8(cx, "Error scanning with pattern: %s (Only prefix matching is supported. e.g. 'foo*')", pattern.ptr.get()); return false; } @@ -248,7 +260,7 @@ bool KvStore::zrange_by_score(JSContext *cx, unsigned argc, JS::Value *vp) { return false; } - JS::UniqueChars key = JS_EncodeStringToUTF8(cx, key_str); + auto key = core::encode(cx, key_str); if (!key) { return false; } @@ -258,10 +270,10 @@ bool KvStore::zrange_by_score(JSContext *cx, unsigned argc, JS::Value *vp) { return false; } - auto result = host_api::kv_store_zrange_by_score(store->store_handle_, key.get(), min, max); + auto result = host_api::kv_store_zrange_by_score(store->store_handle_, std::string_view(key.ptr.get(), key.len), min, max); if (!result.is_ok()) { - JS_ReportErrorUTF8(cx, "Error in zrangeByScore for key: %s", key.get()); + JS_ReportErrorUTF8(cx, "Error in zrangeByScore for key: %s", key.ptr.get()); return false; } @@ -330,7 +342,7 @@ bool KvStore::zscan(JSContext *cx, unsigned argc, JS::Value *vp) { return false; } - JS::UniqueChars key = JS_EncodeStringToUTF8(cx, key_str); + auto key = core::encode(cx, key_str); if (!key) { return false; } @@ -340,15 +352,15 @@ bool KvStore::zscan(JSContext *cx, unsigned argc, JS::Value *vp) { return false; } - JS::UniqueChars pattern = JS_EncodeStringToUTF8(cx, pattern_str); + auto pattern = core::encode(cx, pattern_str); if (!pattern) { return false; } - auto result = host_api::kv_store_zscan(store->store_handle_, key.get(), pattern.get()); + auto result = host_api::kv_store_zscan(store->store_handle_, std::string_view(key.ptr.get(), key.len), std::string_view(pattern.ptr.get(), pattern.len)); if (!result.is_ok()) { - JS_ReportErrorUTF8(cx, "Error in zscan for key: %s", key.get()); + JS_ReportErrorUTF8(cx, "Error in zscan for key: %s", key.ptr.get()); return false; } @@ -417,7 +429,7 @@ bool KvStore::bf_exists(JSContext *cx, unsigned argc, JS::Value *vp) { return false; } - JS::UniqueChars key = JS_EncodeStringToUTF8(cx, key_str); + auto key = core::encode(cx, key_str); if (!key) { return false; } @@ -427,15 +439,15 @@ bool KvStore::bf_exists(JSContext *cx, unsigned argc, JS::Value *vp) { return false; } - JS::UniqueChars item = JS_EncodeStringToUTF8(cx, item_str); + auto item = core::encode(cx, item_str); if (!item) { return false; } - auto result = host_api::kv_store_bf_exists(store->store_handle_, key.get(), item.get()); + auto result = host_api::kv_store_bf_exists(store->store_handle_, std::string_view(key.ptr.get(), key.len), std::string_view(item.ptr.get(), item.len)); if (!result.is_ok()) { - JS_ReportErrorUTF8(cx, "Error checking bloom filter for key: %s", key.get()); + JS_ReportErrorUTF8(cx, "Error checking bloom filter for key: %s", key.ptr.get()); return false; } @@ -443,6 +455,342 @@ bool KvStore::bf_exists(JSContext *cx, unsigned argc, JS::Value *vp) { return true; } +namespace { + +// Resolve `args.rval()` with a fresh Promise resolved to `value`. +bool resolve_with(JSContext *cx, JS::HandleValue value, JS::CallArgs &args) { + JS::RootedObject promise(cx, JS::CallOriginalPromiseResolve(cx, value)); + if (!promise) return false; + args.rval().setObject(*promise); + return true; +} + +// Decode the bytes of a Uint8Array as UTF-8 into a fresh JS string. +JSString *decode_utf8_string(JSContext *cx, JS::HandleObject bytes_array) { + size_t len = JS_GetTypedArrayLength(bytes_array); + std::vector buf(len); + if (len > 0) { + JS::AutoCheckCannotGC noGC(cx); + bool is_shared; + void *data = JS_GetArrayBufferViewData(bytes_array, &is_shared, noGC); + memcpy(buf.data(), data, len); + } + return JS_NewStringCopyUTF8N(cx, JS::UTF8Chars(buf.data(), buf.size())); +} + +class KvStoreEntry { +public: + enum class Slot : uint32_t { + Bytes = 0, + Count + }; + + static const JSClass class_; + static const JSFunctionSpec methods[]; + + static bool arrayBuffer(JSContext *cx, unsigned argc, JS::Value *vp); + static bool text(JSContext *cx, unsigned argc, JS::Value *vp); + static bool json(JSContext *cx, unsigned argc, JS::Value *vp); + + // Allocates a Uint8Array, copies `bytes` into it, and wraps it in a + // freshly-created KvStoreEntry. Returns nullptr on allocation failure + // (with a pending JS exception). + static JSObject *create(JSContext *cx, const uint8_t *bytes, size_t len); +}; + +const JSClass KvStoreEntry::class_ = { + "KvStoreEntry", + JSCLASS_HAS_RESERVED_SLOTS(static_cast(KvStoreEntry::Slot::Count)) +}; + +// Get the Uint8Array stored in a KvStoreEntry's reserved slot. +JSObject *kv_store_entry_bytes(JSContext *cx, JS::HandleObject self) { + JS::Value v = JS::GetReservedSlot( + self, static_cast(KvStoreEntry::Slot::Bytes)); + if (!v.isObject()) { + JS_ReportErrorUTF8(cx, "Invalid KvStoreEntry"); + return nullptr; + } + return &v.toObject(); +} + +JSObject *KvStoreEntry::create(JSContext *cx, const uint8_t *bytes, size_t len) { + JS::RootedObject byte_array(cx, JS_NewUint8Array(cx, len)); + if (!byte_array) return nullptr; + + if (len > 0) { + JS::AutoCheckCannotGC noGC(cx); + bool is_shared; + void *dst = JS_GetArrayBufferViewData(byte_array, &is_shared, noGC); + memcpy(dst, bytes, len); + } + + JS::RootedObject entry(cx, + JS_NewObjectWithGivenProto(cx, &KvStoreEntry::class_, nullptr)); + if (!entry) return nullptr; + + JS::SetReservedSlot(entry, static_cast(Slot::Bytes), + JS::ObjectValue(*byte_array)); + + if (!JS_DefineFunctions(cx, entry, KvStoreEntry::methods)) return nullptr; + + return entry; +} + +bool KvStoreEntry::arrayBuffer(JSContext *cx, unsigned argc, JS::Value *vp) { + JS::CallArgs args = JS::CallArgsFromVp(argc, vp); + if (!args.thisv().isObject()) { + JS_ReportErrorUTF8(cx, "Invalid KvStoreEntry"); + return false; + } + JS::RootedObject self(cx, &args.thisv().toObject()); + JS::RootedObject bytes_array(cx, kv_store_entry_bytes(cx, self)); + if (!bytes_array) return false; + + size_t len = JS_GetTypedArrayLength(bytes_array); + + // Allocate a fresh ArrayBuffer and copy bytes in. Don't share the entry's + // underlying buffer because callers could otherwise mutate it and corrupt + // subsequent reads from this same entry. + JS::RootedObject ab(cx, JS::NewArrayBuffer(cx, len)); + if (!ab) return false; + + if (len > 0) { + JS::AutoCheckCannotGC noGC(cx); + bool is_shared; + void *src = JS_GetArrayBufferViewData(bytes_array, &is_shared, noGC); + void *dst = JS::GetArrayBufferData(ab, &is_shared, noGC); + memcpy(dst, src, len); + } + + JS::RootedValue ab_val(cx, JS::ObjectValue(*ab)); + JS::RootedObject promise(cx, JS::CallOriginalPromiseResolve(cx, ab_val)); + if (!promise) return false; + args.rval().setObject(*promise); + return true; +} + +bool KvStoreEntry::text(JSContext *cx, unsigned argc, JS::Value *vp) { + JS::CallArgs args = JS::CallArgsFromVp(argc, vp); + if (!args.thisv().isObject()) { + JS_ReportErrorUTF8(cx, "Invalid KvStoreEntry"); + return false; + } + JS::RootedObject self(cx, &args.thisv().toObject()); + JS::RootedObject bytes_array(cx, kv_store_entry_bytes(cx, self)); + if (!bytes_array) return false; + + JS::RootedString str(cx, decode_utf8_string(cx, bytes_array)); + if (!str) return false; + + JS::RootedValue str_val(cx, JS::StringValue(str)); + JS::RootedObject promise(cx, JS::CallOriginalPromiseResolve(cx, str_val)); + if (!promise) return false; + args.rval().setObject(*promise); + return true; +} + +bool KvStoreEntry::json(JSContext *cx, unsigned argc, JS::Value *vp) { + JS::CallArgs args = JS::CallArgsFromVp(argc, vp); + if (!args.thisv().isObject()) { + JS_ReportErrorUTF8(cx, "Invalid KvStoreEntry"); + return false; + } + JS::RootedObject self(cx, &args.thisv().toObject()); + JS::RootedObject bytes_array(cx, kv_store_entry_bytes(cx, self)); + if (!bytes_array) return false; + + JS::RootedString str(cx, decode_utf8_string(cx, bytes_array)); + if (!str) return false; + + // On success → resolved Promise; on failure → rejected Promise (matching + // the standard Body.json() contract; SyntaxError is async, not a sync throw). + JS::RootedValue parsed(cx); + JS::RootedObject promise(cx); + if (JS_ParseJSON(cx, str, &parsed)) { + promise = JS::CallOriginalPromiseResolve(cx, parsed); + } else { + JS::RootedValue exc(cx); + if (!JS_GetPendingException(cx, &exc)) return false; + JS_ClearPendingException(cx); + promise = JS::CallOriginalPromiseReject(cx, exc); + } + + if (!promise) return false; + args.rval().setObject(*promise); + return true; +} + +const JSFunctionSpec KvStoreEntry::methods[] = { + JS_FN("arrayBuffer", KvStoreEntry::arrayBuffer, 0, JSPROP_ENUMERATE), + JS_FN("text", KvStoreEntry::text, 0, JSPROP_ENUMERATE), + JS_FN("json", KvStoreEntry::json, 0, JSPROP_ENUMERATE), + JS_FS_END, +}; + +} // anonymous namespace + +bool KvStore::get_entry(JSContext *cx, unsigned argc, JS::Value *vp) { + JS::CallArgs args = JS::CallArgsFromVp(argc, vp); + + if (!args.requireAtLeast(cx, "getEntry", 1)) { + return false; + } + + JS::RootedObject this_obj(cx, &args.thisv().toObject()); + KvStore* store = get_instance(cx, this_obj); + if (!store) { + JS_ReportErrorUTF8(cx, "Invalid KvStore instance"); + return false; + } + + JS::RootedString key_str(cx, JS::ToString(cx, args[0])); + if (!key_str) return false; + + auto key = core::encode(cx, key_str); + if (!key) return false; + + auto result = host_api::kv_store_get(store->store_handle_, std::string_view(key.ptr.get(), key.len)); + if (!result.is_ok()) { + JS_ReportErrorUTF8(cx, "Error getting key: %s", key.ptr.get()); + return ReturnPromiseRejectedWithPendingError(cx, args); + } + + auto value_option = result.unwrap(); + if (!value_option.is_some()) { + JS::RootedValue null_val(cx, JS::NullValue()); + return resolve_with(cx, null_val, args); + } + + auto value = value_option.unwrap(); + JS::RootedObject entry(cx, KvStoreEntry::create(cx, value.ptr, value.len)); + if (!entry) return false; + + JS::RootedValue entry_val(cx, JS::ObjectValue(*entry)); + return resolve_with(cx, entry_val, args); +} + +bool KvStore::zrange_by_score_entries(JSContext *cx, unsigned argc, JS::Value *vp) { + JS::CallArgs args = JS::CallArgsFromVp(argc, vp); + + if (!args.requireAtLeast(cx, "zrangeByScoreEntries", 3)) { + return false; + } + + JS::RootedObject this_obj(cx, &args.thisv().toObject()); + KvStore* store = get_instance(cx, this_obj); + if (!store) { + JS_ReportErrorUTF8(cx, "Invalid KvStore instance"); + return false; + } + + JS::RootedString key_str(cx, JS::ToString(cx, args[0])); + if (!key_str) return false; + + auto key = core::encode(cx, key_str); + if (!key) return false; + + double min, max; + if (!JS::ToNumber(cx, args[1], &min) || !JS::ToNumber(cx, args[2], &max)) { + return false; + } + + auto result = host_api::kv_store_zrange_by_score(store->store_handle_, std::string_view(key.ptr.get(), key.len), min, max); + if (!result.is_ok()) { + JS_ReportErrorUTF8(cx, "Error in zrangeByScoreEntries for key: %s", key.ptr.get()); + return ReturnPromiseRejectedWithPendingError(cx, args); + } + + auto tuples = result.unwrap(); + + JS::RootedObject tuples_array(cx, JS::NewArrayObject(cx, tuples.len)); + if (!tuples_array) return false; + + for (size_t i = 0; i < tuples.len; i++) { + JS::RootedObject entry(cx, + KvStoreEntry::create(cx, tuples.ptr[i].f0.ptr, tuples.ptr[i].f0.len)); + if (!entry) return false; + + JS::RootedObject tuple(cx, JS::NewArrayObject(cx, 2)); + if (!tuple) return false; + + JS::RootedValue entry_val(cx, JS::ObjectValue(*entry)); + JS::RootedValue score_val(cx, JS::DoubleValue(tuples.ptr[i].f1)); + + if (!JS_SetElement(cx, tuple, 0, entry_val) || + !JS_SetElement(cx, tuple, 1, score_val)) { + return false; + } + + JS::RootedValue tuple_val(cx, JS::ObjectValue(*tuple)); + if (!JS_SetElement(cx, tuples_array, i, tuple_val)) return false; + } + + JS::RootedValue arr_val(cx, JS::ObjectValue(*tuples_array)); + return resolve_with(cx, arr_val, args); +} + +bool KvStore::zscan_entries(JSContext *cx, unsigned argc, JS::Value *vp) { + JS::CallArgs args = JS::CallArgsFromVp(argc, vp); + + if (!args.requireAtLeast(cx, "zscanEntries", 2)) { + return false; + } + + JS::RootedObject this_obj(cx, &args.thisv().toObject()); + KvStore* store = get_instance(cx, this_obj); + if (!store) { + JS_ReportErrorUTF8(cx, "Invalid KvStore instance"); + return false; + } + + JS::RootedString key_str(cx, JS::ToString(cx, args[0])); + if (!key_str) return false; + + auto key = core::encode(cx, key_str); + if (!key) return false; + + JS::RootedString pattern_str(cx, JS::ToString(cx, args[1])); + if (!pattern_str) return false; + + auto pattern = core::encode(cx, pattern_str); + if (!pattern) return false; + + auto result = host_api::kv_store_zscan(store->store_handle_, std::string_view(key.ptr.get(), key.len), std::string_view(pattern.ptr.get(), pattern.len)); + if (!result.is_ok()) { + JS_ReportErrorUTF8(cx, "Error in zscanEntries for key: %s", key.ptr.get()); + return ReturnPromiseRejectedWithPendingError(cx, args); + } + + auto tuples = result.unwrap(); + + JS::RootedObject tuples_array(cx, JS::NewArrayObject(cx, tuples.len)); + if (!tuples_array) return false; + + for (size_t i = 0; i < tuples.len; i++) { + JS::RootedObject entry(cx, + KvStoreEntry::create(cx, tuples.ptr[i].f0.ptr, tuples.ptr[i].f0.len)); + if (!entry) return false; + + JS::RootedObject tuple(cx, JS::NewArrayObject(cx, 2)); + if (!tuple) return false; + + JS::RootedValue entry_val(cx, JS::ObjectValue(*entry)); + JS::RootedValue score_val(cx, JS::DoubleValue(tuples.ptr[i].f1)); + + if (!JS_SetElement(cx, tuple, 0, entry_val) || + !JS_SetElement(cx, tuple, 1, score_val)) { + return false; + } + + JS::RootedValue tuple_val(cx, JS::ObjectValue(*tuple)); + if (!JS_SetElement(cx, tuples_array, i, tuple_val)) return false; + } + + JS::RootedValue arr_val(cx, JS::ObjectValue(*tuples_array)); + return resolve_with(cx, arr_val, args); +} + bool install(api::Engine *engine) { ENGINE = engine; diff --git a/runtime/fastedge/builtins/kv-store.h b/runtime/fastedge/builtins/kv-store.h index 586a776..77d7241 100644 --- a/runtime/fastedge/builtins/kv-store.h +++ b/runtime/fastedge/builtins/kv-store.h @@ -11,9 +11,12 @@ class KvStore : public builtins::BuiltinNoConstructor { static bool open(JSContext *cx, unsigned argc, JS::Value *vp); static bool get(JSContext *cx, unsigned argc, JS::Value *vp); + static bool get_entry(JSContext *cx, unsigned argc, JS::Value *vp); static bool scan(JSContext *cx, unsigned argc, JS::Value *vp); static bool zrange_by_score(JSContext *cx, unsigned argc, JS::Value *vp); + static bool zrange_by_score_entries(JSContext *cx, unsigned argc, JS::Value *vp); static bool zscan(JSContext *cx, unsigned argc, JS::Value *vp); + static bool zscan_entries(JSContext *cx, unsigned argc, JS::Value *vp); static bool bf_exists(JSContext *cx, unsigned argc, JS::Value *vp); static void finalize(JS::GCContext *gcx, JSObject *obj); diff --git a/runtime/fastedge/builtins/request-info.cpp b/runtime/fastedge/builtins/request-info.cpp new file mode 100644 index 0000000..22e2814 --- /dev/null +++ b/runtime/fastedge/builtins/request-info.cpp @@ -0,0 +1,355 @@ +#include "request-info.h" + +#include "builtin.h" + +#include "../../StarlingMonkey/builtins/web/fetch/fetch_event.h" +#include "../../StarlingMonkey/builtins/web/fetch/headers.h" +#include "../../StarlingMonkey/builtins/web/fetch/request-response.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace fastedge::request_info { + +using builtins::web::fetch::fetch_event::FetchEvent; +using builtins::web::fetch::Headers; +using builtins::web::fetch::RequestOrResponse; + +namespace { + +// Read a single header value off the FetchEvent's incoming Request. +// Returns "" if the header isn't present. +std::string read_header(JSContext *cx, JS::HandleObject fetch_event, + std::string_view name) { + JS::Value request_val = JS::GetReservedSlot( + fetch_event, static_cast(FetchEvent::Slots::Request)); + if (!request_val.isObject()) return ""; + JS::RootedObject request(cx, &request_val.toObject()); + JS::RootedObject headers(cx, RequestOrResponse::headers(cx, request)); + if (!headers) return ""; + auto idx = Headers::lookup(cx, headers, name); + if (!idx.has_value()) return ""; + auto entry = Headers::get_index(cx, headers, *idx); + if (!entry) return ""; + const auto &value = std::get<1>(*entry); + return std::string(value.ptr.get(), value.len); +} + +// Try several header names in order; return the first non-empty value. +// Used for client.address: x-real-ip is the trusted source, x-forwarded-for +// is the fallback when the platform omits x-real-ip on a given pathway. +std::string read_header_fallback(JSContext *cx, JS::HandleObject fetch_event, + std::initializer_list names) { + for (auto name : names) { + auto val = read_header(cx, fetch_event, name); + if (!val.empty()) return val; + } + return ""; +} + +// Define a read-only string data property on `obj`. +bool define_str(JSContext *cx, JS::HandleObject obj, const char *name, + std::string_view value) { + JS::RootedString js_str(cx, JS_NewStringCopyN(cx, value.data(), value.length())); + if (!js_str) return false; + JS::RootedValue val(cx, JS::StringValue(js_str)); + return JS_DefineProperty(cx, obj, name, val, + JSPROP_READONLY | JSPROP_PERMANENT | JSPROP_ENUMERATE); +} + +// Define a `number | null` data property on `obj`. +// Empty / non-finite / unparseable input → null. Otherwise the parsed double. +bool define_decimal_or_null(JSContext *cx, JS::HandleObject obj, const char *name, + std::string_view raw) { + JS::RootedValue val(cx); + if (raw.empty()) { + val.setNull(); + } else { + char *end = nullptr; + std::string buf(raw); // strtod needs NUL-terminated input + double n = std::strtod(buf.c_str(), &end); + if (end == buf.c_str() || !std::isfinite(n)) { + val.setNull(); + } else { + val.setNumber(n); + } + } + return JS_DefineProperty(cx, obj, name, val, + JSPROP_READONLY | JSPROP_PERMANENT | JSPROP_ENUMERATE); +} + +// Replace this lazy getter with a data property holding `value` on `instance`. +// Subsequent accesses on this instance hit the data property without +// dispatching through the prototype getter. +bool cache_on_instance(JSContext *cx, JS::HandleObject instance, + const char *name, JS::HandleValue value) { + return JS_DefineProperty(cx, instance, name, value, + JSPROP_READONLY | JSPROP_PERMANENT | JSPROP_ENUMERATE); +} + +// `event.client` getter — installed on FetchEvent.prototype. +bool fetch_event_client_get(JSContext *cx, unsigned argc, JS::Value *vp) { + JS::CallArgs args = JS::CallArgsFromVp(argc, vp); + if (!args.thisv().isObject()) { + JS_ReportErrorUTF8(cx, "client: receiver must be a FetchEvent"); + return false; + } + JS::RootedObject self(cx, &args.thisv().toObject()); + JS::RootedObject info(cx, ClientInfo::create(cx, self)); + if (!info) return false; + JS::RootedValue value(cx, JS::ObjectValue(*info)); + if (!cache_on_instance(cx, self, "client", value)) return false; + args.rval().set(value); + return true; +} + +// `event.server` getter — installed on FetchEvent.prototype. +bool fetch_event_server_get(JSContext *cx, unsigned argc, JS::Value *vp) { + JS::CallArgs args = JS::CallArgsFromVp(argc, vp); + if (!args.thisv().isObject()) { + JS_ReportErrorUTF8(cx, "server: receiver must be a FetchEvent"); + return false; + } + JS::RootedObject self(cx, &args.thisv().toObject()); + JS::RootedObject info(cx, ServerInfo::create(cx, self)); + if (!info) return false; + JS::RootedValue value(cx, JS::ObjectValue(*info)); + if (!cache_on_instance(cx, self, "server", value)) return false; + args.rval().set(value); + return true; +} + +// `clientInfo.geo` getter — installed on ClientInfo.prototype. +bool client_info_geo_get(JSContext *cx, unsigned argc, JS::Value *vp) { + JS::CallArgs args = JS::CallArgsFromVp(argc, vp); + if (!ClientInfo::check_receiver(cx, args.thisv(), "geo")) return false; + JS::RootedObject self(cx, &args.thisv().toObject()); + + JS::Value fe_val = JS::GetReservedSlot( + self, static_cast(ClientInfo::Slots::FetchEvent)); + if (!fe_val.isObject()) { + JS_ReportErrorUTF8(cx, "ClientInfo.geo: missing FetchEvent reference"); + return false; + } + JS::RootedObject fe(cx, &fe_val.toObject()); + JS::RootedObject geo(cx, GeoInfo::create(cx, fe)); + if (!geo) return false; + JS::RootedValue value(cx, JS::ObjectValue(*geo)); + if (!cache_on_instance(cx, self, "geo", value)) return false; + args.rval().set(value); + return true; +} + +// `serverInfo.pop` getter — installed on ServerInfo.prototype. +bool server_info_pop_get(JSContext *cx, unsigned argc, JS::Value *vp) { + JS::CallArgs args = JS::CallArgsFromVp(argc, vp); + if (!ServerInfo::check_receiver(cx, args.thisv(), "pop")) return false; + JS::RootedObject self(cx, &args.thisv().toObject()); + + JS::Value fe_val = JS::GetReservedSlot( + self, static_cast(ServerInfo::Slots::FetchEvent)); + if (!fe_val.isObject()) { + JS_ReportErrorUTF8(cx, "ServerInfo.pop: missing FetchEvent reference"); + return false; + } + JS::RootedObject fe(cx, &fe_val.toObject()); + JS::RootedObject pop(cx, PopInfo::create(cx, fe)); + if (!pop) return false; + JS::RootedValue value(cx, JS::ObjectValue(*pop)); + if (!cache_on_instance(cx, self, "pop", value)) return false; + args.rval().set(value); + return true; +} + +} // namespace + +// === ClientInfo === + +const JSFunctionSpec ClientInfo::methods[] = {JS_FS_END}; +const JSPropertySpec ClientInfo::properties[] = { + JS_PSG("geo", client_info_geo_get, JSPROP_ENUMERATE), + JS_PS_END, +}; +const JSFunctionSpec ClientInfo::static_methods[] = {JS_FS_END}; +const JSPropertySpec ClientInfo::static_properties[] = {JS_PS_END}; + +JSObject *ClientInfo::create(JSContext *cx, JS::HandleObject fetch_event) { + JS::RootedObject self(cx, JS_NewObjectWithGivenProto(cx, &class_, proto_obj)); + if (!self) return nullptr; + + // Back-ref to FetchEvent so the lazy `geo` getter can read headers later. + JS::SetReservedSlot(self, static_cast(Slots::FetchEvent), + JS::ObjectValue(*fetch_event)); + + // Direct fields — eager. Fallback chain on address: x-real-ip is the + // trusted edge-set value; x-forwarded-for is the fallback if the platform + // ever stops setting x-real-ip on a given path. + std::string address = + read_header_fallback(cx, fetch_event, {"x-real-ip", "x-forwarded-for"}); + if (!define_str(cx, self, "address", address)) return nullptr; + if (!define_str(cx, self, "tlsJA3MD5", read_header(cx, fetch_event, "x-ja3"))) { + return nullptr; + } + if (!define_str(cx, self, "protocol", + read_header(cx, fetch_event, "x-forwarded-proto"))) { + return nullptr; + } + + return self; +} + +// === GeoInfo === + +const JSFunctionSpec GeoInfo::methods[] = {JS_FS_END}; +const JSPropertySpec GeoInfo::properties[] = {JS_PS_END}; +const JSFunctionSpec GeoInfo::static_methods[] = {JS_FS_END}; +const JSPropertySpec GeoInfo::static_properties[] = {JS_PS_END}; + +JSObject *GeoInfo::create(JSContext *cx, JS::HandleObject fetch_event) { + JS::RootedObject self(cx, JS_NewObjectWithGivenProto(cx, &class_, proto_obj)); + if (!self) return nullptr; + + if (!define_str(cx, self, "asn", read_header(cx, fetch_event, "geoip-asn"))) { + return nullptr; + } + if (!define_decimal_or_null(cx, self, "latitude", + read_header(cx, fetch_event, "geoip-lat"))) { + return nullptr; + } + if (!define_decimal_or_null(cx, self, "longitude", + read_header(cx, fetch_event, "geoip-long"))) { + return nullptr; + } + if (!define_str(cx, self, "region", read_header(cx, fetch_event, "geoip-reg"))) { + return nullptr; + } + if (!define_str(cx, self, "continent", + read_header(cx, fetch_event, "geoip-continent"))) { + return nullptr; + } + if (!define_str(cx, self, "countryCode", + read_header(cx, fetch_event, "geoip-country-code"))) { + return nullptr; + } + if (!define_str(cx, self, "countryName", + read_header(cx, fetch_event, "geoip-country-name"))) { + return nullptr; + } + if (!define_str(cx, self, "city", read_header(cx, fetch_event, "geoip-city"))) { + return nullptr; + } + + return self; +} + +// === ServerInfo === + +const JSFunctionSpec ServerInfo::methods[] = {JS_FS_END}; +const JSPropertySpec ServerInfo::properties[] = { + JS_PSG("pop", server_info_pop_get, JSPROP_ENUMERATE), + JS_PS_END, +}; +const JSFunctionSpec ServerInfo::static_methods[] = {JS_FS_END}; +const JSPropertySpec ServerInfo::static_properties[] = {JS_PS_END}; + +JSObject *ServerInfo::create(JSContext *cx, JS::HandleObject fetch_event) { + JS::RootedObject self(cx, JS_NewObjectWithGivenProto(cx, &class_, proto_obj)); + if (!self) return nullptr; + + JS::SetReservedSlot(self, static_cast(Slots::FetchEvent), + JS::ObjectValue(*fetch_event)); + + if (!define_str(cx, self, "address", + read_header(cx, fetch_event, "server_addr"))) { + return nullptr; + } + if (!define_str(cx, self, "name", read_header(cx, fetch_event, "server_name"))) { + return nullptr; + } + + return self; +} + +// === PopInfo === + +const JSFunctionSpec PopInfo::methods[] = {JS_FS_END}; +const JSPropertySpec PopInfo::properties[] = {JS_PS_END}; +const JSFunctionSpec PopInfo::static_methods[] = {JS_FS_END}; +const JSPropertySpec PopInfo::static_properties[] = {JS_PS_END}; + +JSObject *PopInfo::create(JSContext *cx, JS::HandleObject fetch_event) { + JS::RootedObject self(cx, JS_NewObjectWithGivenProto(cx, &class_, proto_obj)); + if (!self) return nullptr; + + if (!define_decimal_or_null(cx, self, "latitude", + read_header(cx, fetch_event, "pop-lat"))) { + return nullptr; + } + if (!define_decimal_or_null(cx, self, "longitude", + read_header(cx, fetch_event, "pop-long"))) { + return nullptr; + } + if (!define_str(cx, self, "region", read_header(cx, fetch_event, "pop-reg"))) { + return nullptr; + } + if (!define_str(cx, self, "continent", + read_header(cx, fetch_event, "pop-continent"))) { + return nullptr; + } + if (!define_str(cx, self, "countryCode", + read_header(cx, fetch_event, "pop-country-code"))) { + return nullptr; + } + if (!define_str(cx, self, "countryName", + read_header(cx, fetch_event, "pop-country-name"))) { + return nullptr; + } + if (!define_str(cx, self, "city", read_header(cx, fetch_event, "pop-city"))) { + return nullptr; + } + + return self; +} + +// === install === + +bool install(api::Engine *engine) { + JSContext *cx = engine->cx(); + JS::RootedObject global(cx, engine->global()); + + // Initialise the four classes. BuiltinNoConstructor::init_class registers + // the JSClass via JS_InitClass (which creates the prototype) and then + // removes the class name from globalThis so user code can't `new` them. + if (!ClientInfo::init_class(cx, global)) return false; + if (!GeoInfo::init_class(cx, global)) return false; + if (!ServerInfo::init_class(cx, global)) return false; + if (!PopInfo::init_class(cx, global)) return false; + + // Patch FetchEvent.prototype with the lazy `client` and `server` getters. + // FetchEvent itself is a BuiltinNoConstructor, which means its + // constructor is deleted from globalThis after init_class runs — so we + // can't reach the prototype via `globalThis.FetchEvent.prototype`. + // BuiltinImpl exposes the rooted prototype as a static `proto_obj`, + // which is what we want. + JS::RootedObject fe_proto(cx, FetchEvent::proto_obj); + if (!fe_proto) { + JS_ReportErrorUTF8(cx, "request_info.install: FetchEvent.proto_obj is null"); + return false; + } + + static const JSPropertySpec fetch_event_extension[] = { + JS_PSG("client", fetch_event_client_get, JSPROP_ENUMERATE), + JS_PSG("server", fetch_event_server_get, JSPROP_ENUMERATE), + JS_PS_END, + }; + if (!JS_DefineProperties(cx, fe_proto, fetch_event_extension)) return false; + + return true; +} + +} // namespace fastedge::request_info diff --git a/runtime/fastedge/builtins/request-info.h b/runtime/fastedge/builtins/request-info.h new file mode 100644 index 0000000..6042f0a --- /dev/null +++ b/runtime/fastedge/builtins/request-info.h @@ -0,0 +1,92 @@ +#ifndef FASTEDGE_BUILTINS_REQUEST_INFO_H +#define FASTEDGE_BUILTINS_REQUEST_INFO_H + +#include "builtin.h" + +namespace fastedge::request_info { + +// Lazy `event.client` namespace. +// Direct fields (address, tlsJA3MD5, protocol) are populated as data +// properties when the instance is constructed; the nested `geo` namespace +// is a separate getter on this class's prototype that builds GeoInfo on +// first access and replaces itself with a data property on the instance. +class ClientInfo final : public ::builtins::BuiltinNoConstructor { +public: + static constexpr const char *class_name = "ClientInfo"; + + enum class Slots : uint8_t { + FetchEvent, // Back-reference; needed by the lazy `geo` getter. + Count, + }; + + static const JSFunctionSpec methods[]; + static const JSPropertySpec properties[]; + static const JSFunctionSpec static_methods[]; + static const JSPropertySpec static_properties[]; + + static JSObject *create(JSContext *cx, JS::HandleObject fetch_event); +}; + +// Lazy `event.client.geo` namespace. +// All eight geo fields are eagerly populated when the instance is built — +// once a caller has asked for any geo data they typically read several +// fields, so reading the geo headers in one batch is cheaper than +// per-field laziness. +class GeoInfo final : public ::builtins::BuiltinNoConstructor { +public: + static constexpr const char *class_name = "GeoInfo"; + + enum class Slots : uint8_t { + Count, + }; + + static const JSFunctionSpec methods[]; + static const JSPropertySpec properties[]; + static const JSFunctionSpec static_methods[]; + static const JSPropertySpec static_properties[]; + + static JSObject *create(JSContext *cx, JS::HandleObject fetch_event); +}; + +// Lazy `event.server` namespace. +// Direct fields (address, name) populated eagerly; nested `pop` namespace +// is lazy on first access. +class ServerInfo final : public ::builtins::BuiltinNoConstructor { +public: + static constexpr const char *class_name = "ServerInfo"; + + enum class Slots : uint8_t { + FetchEvent, // Back-reference; needed by the lazy `pop` getter. + Count, + }; + + static const JSFunctionSpec methods[]; + static const JSPropertySpec properties[]; + static const JSFunctionSpec static_methods[]; + static const JSPropertySpec static_properties[]; + + static JSObject *create(JSContext *cx, JS::HandleObject fetch_event); +}; + +// Lazy `event.server.pop` namespace. +class PopInfo final : public ::builtins::BuiltinNoConstructor { +public: + static constexpr const char *class_name = "PopInfo"; + + enum class Slots : uint8_t { + Count, + }; + + static const JSFunctionSpec methods[]; + static const JSPropertySpec properties[]; + static const JSFunctionSpec static_methods[]; + static const JSPropertySpec static_properties[]; + + static JSObject *create(JSContext *cx, JS::HandleObject fetch_event); +}; + +bool install(api::Engine *engine); + +} // namespace fastedge::request_info + +#endif diff --git a/runtime/fastedge/host-api/bindings/bindings.c b/runtime/fastedge/host-api/bindings/bindings.c index 1113ad0..6f4e59e 100644 --- a/runtime/fastedge/host-api/bindings/bindings.c +++ b/runtime/fastedge/host-api/bindings/bindings.c @@ -36,6 +36,31 @@ extern void __wasm_import_gcore_fastedge_key_value_method_store_zscan(int32_t, u __attribute__((__import_module__("gcore:fastedge/key-value"), __import_name__("[method]store.bf-exists"))) extern void __wasm_import_gcore_fastedge_key_value_method_store_bf_exists(int32_t, uint8_t *, size_t, uint8_t *, size_t, uint8_t *); +// Imported Functions from `gcore:fastedge/utils` + +__attribute__((__import_module__("gcore:fastedge/utils"), __import_name__("set-user-diag"))) +extern void __wasm_import_gcore_fastedge_utils_set_user_diag(uint8_t *, size_t); + +// Imported Functions from `gcore:fastedge/cache-sync` + +__attribute__((__import_module__("gcore:fastedge/cache-sync"), __import_name__("get"))) +extern void __wasm_import_gcore_fastedge_cache_sync_get(uint8_t *, size_t, uint8_t *); + +__attribute__((__import_module__("gcore:fastedge/cache-sync"), __import_name__("set"))) +extern void __wasm_import_gcore_fastedge_cache_sync_set(uint8_t *, size_t, uint8_t *, size_t, int32_t, int64_t, uint8_t *); + +__attribute__((__import_module__("gcore:fastedge/cache-sync"), __import_name__("delete"))) +extern void __wasm_import_gcore_fastedge_cache_sync_delete(uint8_t *, size_t, uint8_t *); + +__attribute__((__import_module__("gcore:fastedge/cache-sync"), __import_name__("exists"))) +extern void __wasm_import_gcore_fastedge_cache_sync_exists(uint8_t *, size_t, uint8_t *); + +__attribute__((__import_module__("gcore:fastedge/cache-sync"), __import_name__("incr"))) +extern void __wasm_import_gcore_fastedge_cache_sync_incr(uint8_t *, size_t, int64_t, uint8_t *); + +__attribute__((__import_module__("gcore:fastedge/cache-sync"), __import_name__("expire"))) +extern void __wasm_import_gcore_fastedge_cache_sync_expire(uint8_t *, size_t, int64_t, uint8_t *); + // Imported Functions from `wasi:cli/environment@0.2.3` __attribute__((__import_module__("wasi:cli/environment@0.2.3"), __import_name__("get-environment"))) @@ -752,6 +777,73 @@ void gcore_fastedge_key_value_result_bool_error_free(gcore_fastedge_key_value_re } } +void gcore_fastedge_cache_types_payload_free(gcore_fastedge_cache_types_payload_t *ptr) { + size_t list_len = ptr->len; + if (list_len > 0) { + uint8_t *list_ptr = ptr->ptr; + for (size_t i = 0; i < list_len; i++) { + } + free(list_ptr); + } +} + +void gcore_fastedge_cache_types_error_free(gcore_fastedge_cache_types_error_t *ptr) { + switch ((int32_t) ptr->tag) { + case 2: { + bindings_string_free(&ptr->val.other); + break; + } + } +} + +void gcore_fastedge_cache_sync_payload_free(gcore_fastedge_cache_sync_payload_t *ptr) { + gcore_fastedge_cache_types_payload_free(ptr); +} + +void gcore_fastedge_cache_sync_error_free(gcore_fastedge_cache_sync_error_t *ptr) { + gcore_fastedge_cache_types_error_free(ptr); +} + +void bindings_option_payload_free(bindings_option_payload_t *ptr) { + if (ptr->is_some) { + gcore_fastedge_cache_sync_payload_free(&ptr->val); + } +} + +void gcore_fastedge_cache_sync_result_option_payload_error_free(gcore_fastedge_cache_sync_result_option_payload_error_t *ptr) { + if (!ptr->is_err) { + bindings_option_payload_free(&ptr->val.ok); + } else { + gcore_fastedge_cache_sync_error_free(&ptr->val.err); + } +} + +void bindings_option_u64_free(bindings_option_u64_t *ptr) { + if (ptr->is_some) { + } +} + +void gcore_fastedge_cache_sync_result_void_error_free(gcore_fastedge_cache_sync_result_void_error_t *ptr) { + if (!ptr->is_err) { + } else { + gcore_fastedge_cache_sync_error_free(&ptr->val.err); + } +} + +void gcore_fastedge_cache_sync_result_bool_error_free(gcore_fastedge_cache_sync_result_bool_error_t *ptr) { + if (!ptr->is_err) { + } else { + gcore_fastedge_cache_sync_error_free(&ptr->val.err); + } +} + +void gcore_fastedge_cache_sync_result_s64_error_free(gcore_fastedge_cache_sync_result_s64_error_t *ptr) { + if (!ptr->is_err) { + } else { + gcore_fastedge_cache_sync_error_free(&ptr->val.err); + } +} + void bindings_tuple2_string_string_free(bindings_tuple2_string_string_t *ptr) { bindings_string_free(&ptr->f0); bindings_string_free(&ptr->f1); @@ -1371,11 +1463,6 @@ void wasi_http_types_field_size_payload_free(wasi_http_types_field_size_payload_ bindings_option_u32_free(&ptr->field_size); } -void bindings_option_u64_free(bindings_option_u64_t *ptr) { - if (ptr->is_some) { - } -} - void wasi_http_types_option_field_size_payload_free(wasi_http_types_option_field_size_payload_t *ptr) { if (ptr->is_some) { wasi_http_types_field_size_payload_free(&ptr->val); @@ -1393,7 +1480,6 @@ void wasi_http_types_error_code_free(wasi_http_types_error_code_t *ptr) { break; } case 17: { - bindings_option_u64_free(&ptr->val.http_request_body_size); break; } case 21: { @@ -1421,7 +1507,6 @@ void wasi_http_types_error_code_free(wasi_http_types_error_code_t *ptr) { break; } case 28: { - bindings_option_u64_free(&ptr->val.http_response_body_size); break; } case 29: { @@ -2185,6 +2270,285 @@ bool gcore_fastedge_key_value_method_store_bf_exists(gcore_fastedge_key_value_bo } } +void gcore_fastedge_utils_set_user_diag(bindings_string_t *name) { + __wasm_import_gcore_fastedge_utils_set_user_diag((uint8_t *) (*name).ptr, (*name).len); +} + +bool gcore_fastedge_cache_sync_get(bindings_string_t *key, bindings_option_payload_t *ret, gcore_fastedge_cache_sync_error_t *err) { + __attribute__((__aligned__(4))) + uint8_t ret_area[16]; + uint8_t *ptr = (uint8_t *) &ret_area; + __wasm_import_gcore_fastedge_cache_sync_get((uint8_t *) (*key).ptr, (*key).len, ptr); + gcore_fastedge_cache_sync_result_option_payload_error_t result; + switch ((int32_t) *((uint8_t*) (ptr + 0))) { + case 0: { + result.is_err = false; + bindings_option_payload_t option; + switch ((int32_t) *((uint8_t*) (ptr + 4))) { + case 0: { + option.is_some = false; + break; + } + case 1: { + option.is_some = true; + option.val = (gcore_fastedge_cache_types_payload_t) { (uint8_t*)(*((uint8_t **) (ptr + 8))), (*((size_t*) (ptr + 12))) }; + break; + } + } + + result.val.ok = option; + break; + } + case 1: { + result.is_err = true; + gcore_fastedge_cache_types_error_t variant; + variant.tag = (int32_t) *((uint8_t*) (ptr + 4)); + switch ((int32_t) variant.tag) { + case 0: { + break; + } + case 1: { + break; + } + case 2: { + variant.val.other = (bindings_string_t) { (uint8_t*)(*((uint8_t **) (ptr + 8))), (*((size_t*) (ptr + 12))) }; + break; + } + } + + result.val.err = variant; + break; + } + } + if (!result.is_err) { + *ret = result.val.ok; + return 1; + } else { + *err = result.val.err; + return 0; + } +} + +bool gcore_fastedge_cache_sync_set(bindings_string_t *key, gcore_fastedge_cache_sync_payload_t *value, uint64_t *maybe_ttl_ms, gcore_fastedge_cache_sync_error_t *err) { + __attribute__((__aligned__(4))) + uint8_t ret_area[16]; + bindings_option_u64_t ttl_ms; + ttl_ms.is_some = maybe_ttl_ms != NULL;if (maybe_ttl_ms) { + ttl_ms.val = *maybe_ttl_ms; + } + int32_t option; + int64_t option1; + if ((ttl_ms).is_some) { + const uint64_t *payload0 = &(ttl_ms).val; + option = 1; + option1 = (int64_t) (*payload0); + } else { + option = 0; + option1 = 0; + } + uint8_t *ptr = (uint8_t *) &ret_area; + __wasm_import_gcore_fastedge_cache_sync_set((uint8_t *) (*key).ptr, (*key).len, (uint8_t *) (*value).ptr, (*value).len, option, option1, ptr); + gcore_fastedge_cache_sync_result_void_error_t result; + switch ((int32_t) *((uint8_t*) (ptr + 0))) { + case 0: { + result.is_err = false; + break; + } + case 1: { + result.is_err = true; + gcore_fastedge_cache_types_error_t variant; + variant.tag = (int32_t) *((uint8_t*) (ptr + 4)); + switch ((int32_t) variant.tag) { + case 0: { + break; + } + case 1: { + break; + } + case 2: { + variant.val.other = (bindings_string_t) { (uint8_t*)(*((uint8_t **) (ptr + 8))), (*((size_t*) (ptr + 12))) }; + break; + } + } + + result.val.err = variant; + break; + } + } + if (!result.is_err) { + return 1; + } else { + *err = result.val.err; + return 0; + } +} + +bool gcore_fastedge_cache_sync_delete(bindings_string_t *key, gcore_fastedge_cache_sync_error_t *err) { + __attribute__((__aligned__(4))) + uint8_t ret_area[16]; + uint8_t *ptr = (uint8_t *) &ret_area; + __wasm_import_gcore_fastedge_cache_sync_delete((uint8_t *) (*key).ptr, (*key).len, ptr); + gcore_fastedge_cache_sync_result_void_error_t result; + switch ((int32_t) *((uint8_t*) (ptr + 0))) { + case 0: { + result.is_err = false; + break; + } + case 1: { + result.is_err = true; + gcore_fastedge_cache_types_error_t variant; + variant.tag = (int32_t) *((uint8_t*) (ptr + 4)); + switch ((int32_t) variant.tag) { + case 0: { + break; + } + case 1: { + break; + } + case 2: { + variant.val.other = (bindings_string_t) { (uint8_t*)(*((uint8_t **) (ptr + 8))), (*((size_t*) (ptr + 12))) }; + break; + } + } + + result.val.err = variant; + break; + } + } + if (!result.is_err) { + return 1; + } else { + *err = result.val.err; + return 0; + } +} + +bool gcore_fastedge_cache_sync_exists(bindings_string_t *key, bool *ret, gcore_fastedge_cache_sync_error_t *err) { + __attribute__((__aligned__(4))) + uint8_t ret_area[16]; + uint8_t *ptr = (uint8_t *) &ret_area; + __wasm_import_gcore_fastedge_cache_sync_exists((uint8_t *) (*key).ptr, (*key).len, ptr); + gcore_fastedge_cache_sync_result_bool_error_t result; + switch ((int32_t) *((uint8_t*) (ptr + 0))) { + case 0: { + result.is_err = false; + result.val.ok = (int32_t) *((uint8_t*) (ptr + 4)); + break; + } + case 1: { + result.is_err = true; + gcore_fastedge_cache_types_error_t variant; + variant.tag = (int32_t) *((uint8_t*) (ptr + 4)); + switch ((int32_t) variant.tag) { + case 0: { + break; + } + case 1: { + break; + } + case 2: { + variant.val.other = (bindings_string_t) { (uint8_t*)(*((uint8_t **) (ptr + 8))), (*((size_t*) (ptr + 12))) }; + break; + } + } + + result.val.err = variant; + break; + } + } + if (!result.is_err) { + *ret = result.val.ok; + return 1; + } else { + *err = result.val.err; + return 0; + } +} + +bool gcore_fastedge_cache_sync_incr(bindings_string_t *key, int64_t delta, int64_t *ret, gcore_fastedge_cache_sync_error_t *err) { + __attribute__((__aligned__(8))) + uint8_t ret_area[24]; + uint8_t *ptr = (uint8_t *) &ret_area; + __wasm_import_gcore_fastedge_cache_sync_incr((uint8_t *) (*key).ptr, (*key).len, delta, ptr); + gcore_fastedge_cache_sync_result_s64_error_t result; + switch ((int32_t) *((uint8_t*) (ptr + 0))) { + case 0: { + result.is_err = false; + result.val.ok = *((int64_t*) (ptr + 8)); + break; + } + case 1: { + result.is_err = true; + gcore_fastedge_cache_types_error_t variant; + variant.tag = (int32_t) *((uint8_t*) (ptr + 8)); + switch ((int32_t) variant.tag) { + case 0: { + break; + } + case 1: { + break; + } + case 2: { + variant.val.other = (bindings_string_t) { (uint8_t*)(*((uint8_t **) (ptr + 12))), (*((size_t*) (ptr + 16))) }; + break; + } + } + + result.val.err = variant; + break; + } + } + if (!result.is_err) { + *ret = result.val.ok; + return 1; + } else { + *err = result.val.err; + return 0; + } +} + +bool gcore_fastedge_cache_sync_expire(bindings_string_t *key, uint64_t ttl_ms, bool *ret, gcore_fastedge_cache_sync_error_t *err) { + __attribute__((__aligned__(4))) + uint8_t ret_area[16]; + uint8_t *ptr = (uint8_t *) &ret_area; + __wasm_import_gcore_fastedge_cache_sync_expire((uint8_t *) (*key).ptr, (*key).len, (int64_t) (ttl_ms), ptr); + gcore_fastedge_cache_sync_result_bool_error_t result; + switch ((int32_t) *((uint8_t*) (ptr + 0))) { + case 0: { + result.is_err = false; + result.val.ok = (int32_t) *((uint8_t*) (ptr + 4)); + break; + } + case 1: { + result.is_err = true; + gcore_fastedge_cache_types_error_t variant; + variant.tag = (int32_t) *((uint8_t*) (ptr + 4)); + switch ((int32_t) variant.tag) { + case 0: { + break; + } + case 1: { + break; + } + case 2: { + variant.val.other = (bindings_string_t) { (uint8_t*)(*((uint8_t **) (ptr + 8))), (*((size_t*) (ptr + 12))) }; + break; + } + } + + result.val.err = variant; + break; + } + } + if (!result.is_err) { + *ret = result.val.ok; + return 1; + } else { + *err = result.val.err; + return 0; + } +} + void wasi_cli_environment_get_environment(bindings_list_tuple2_string_string_t *ret) { __attribute__((__aligned__(4))) uint8_t ret_area[8]; diff --git a/runtime/fastedge/host-api/bindings/bindings.h b/runtime/fastedge/host-api/bindings/bindings.h index fc430f8..bd2b69a 100644 --- a/runtime/fastedge/host-api/bindings/bindings.h +++ b/runtime/fastedge/host-api/bindings/bindings.h @@ -133,6 +133,72 @@ typedef struct { } val; } gcore_fastedge_key_value_result_bool_error_t; +typedef struct gcore_fastedge_cache_types_payload_t { + uint8_t *ptr; + size_t len; +} gcore_fastedge_cache_types_payload_t; + +// The set of errors that may be returned by cache operations. +typedef struct gcore_fastedge_cache_types_error_t { + uint8_t tag; + union { + bindings_string_t other; + } val; +} gcore_fastedge_cache_types_error_t; + +// The requesting component does not have access to the specified cache +// (which may or may not exist). +#define GCORE_FASTEDGE_CACHE_TYPES_ERROR_ACCESS_DENIED 0 +// An unexpected internal error occurred. +#define GCORE_FASTEDGE_CACHE_TYPES_ERROR_INTERNAL_ERROR 1 +// An implementation-specific error occurred (for example, I/O). +#define GCORE_FASTEDGE_CACHE_TYPES_ERROR_OTHER 2 + +typedef gcore_fastedge_cache_types_payload_t gcore_fastedge_cache_sync_payload_t; + +typedef gcore_fastedge_cache_types_error_t gcore_fastedge_cache_sync_error_t; + +typedef struct { + bool is_some; + gcore_fastedge_cache_sync_payload_t val; +} bindings_option_payload_t; + +typedef struct { + bool is_err; + union { + bindings_option_payload_t ok; + gcore_fastedge_cache_sync_error_t err; + } val; +} gcore_fastedge_cache_sync_result_option_payload_error_t; + +typedef struct { + bool is_some; + uint64_t val; +} bindings_option_u64_t; + +typedef struct { + bool is_err; + union { + gcore_fastedge_cache_sync_error_t err; + } val; +} gcore_fastedge_cache_sync_result_void_error_t; + +typedef struct { + bool is_err; + union { + bool ok; + gcore_fastedge_cache_sync_error_t err; + } val; +} gcore_fastedge_cache_sync_result_bool_error_t; + +typedef struct { + bool is_err; + union { + int64_t ok; + gcore_fastedge_cache_sync_error_t err; + } val; +} gcore_fastedge_cache_sync_result_s64_error_t; + typedef struct { bindings_string_t f0; bindings_string_t f1; @@ -1008,11 +1074,6 @@ typedef struct wasi_http_types_field_size_payload_t { bindings_option_u32_t field_size; } wasi_http_types_field_size_payload_t; -typedef struct { - bool is_some; - uint64_t val; -} bindings_option_u64_t; - typedef struct { bool is_some; wasi_http_types_field_size_payload_t val; @@ -1441,6 +1502,55 @@ extern bool gcore_fastedge_key_value_method_store_zscan(gcore_fastedge_key_value // and 'false' means that key does not exist or that item had not been added to the filter. extern bool gcore_fastedge_key_value_method_store_bf_exists(gcore_fastedge_key_value_borrow_store_t self, bindings_string_t *key, bindings_string_t *item, bool *ret, gcore_fastedge_key_value_error_t *err); +// Imported Functions from `gcore:fastedge/utils` +// Set and save into call statistic specified user diagnostic context to be associated with the current request. +extern void gcore_fastedge_utils_set_user_diag(bindings_string_t *name); + +// Imported Functions from `gcore:fastedge/cache-sync` +// Get the value associated with `key`. +// +// Returns: +// - `ok(some(value))` if the key exists. +// - `ok(none)` if the key does not exist. +// - `err(error)` if the operation fails. +extern bool gcore_fastedge_cache_sync_get(bindings_string_t *key, bindings_option_payload_t *ret, gcore_fastedge_cache_sync_error_t *err); +// Set the value for `key` with an optional expiry. +// +// If the key already exists, its current value is overwritten. +// If the key does not exist, a new key-value pair is created. +// +// `ttl-ms` is the time-to-live in milliseconds. Pass `none` for no expiry. +// +// Returns `err(error)` if the operation fails. +extern bool gcore_fastedge_cache_sync_set(bindings_string_t *key, gcore_fastedge_cache_sync_payload_t *value, uint64_t *maybe_ttl_ms, gcore_fastedge_cache_sync_error_t *err); +// Delete the key-value pair associated with `key`. +// +// If the key does not exist, this operation is a no-op. +// +// Returns `err(error)` if the operation fails. +extern bool gcore_fastedge_cache_sync_delete(bindings_string_t *key, gcore_fastedge_cache_sync_error_t *err); +// Check whether `key` exists in the cache. +// +// Returns: +// - `ok(true)` if the key exists. +// - `ok(false)` if the key does not exist. +// - `err(error)` if the operation fails. +extern bool gcore_fastedge_cache_sync_exists(bindings_string_t *key, bool *ret, gcore_fastedge_cache_sync_error_t *err); +// Increment the integer value stored at `key` by `delta`. +// +// If the key does not exist, it is initialised to `0` before incrementing. +// The operation is atomic. `delta` may be negative to decrement. +// +// Returns the new value after the increment, or `err(error)` if the +// operation fails (for example, if the stored value is not an integer). +extern bool gcore_fastedge_cache_sync_incr(bindings_string_t *key, int64_t delta, int64_t *ret, gcore_fastedge_cache_sync_error_t *err); +// Set or update the expiry of `key` to `ttl-ms` milliseconds from now. +// +// If the key does not exist, returns `ok(false)`. +// If the expiry was updated successfully, returns `ok(true)`. +// Returns `err(error)` if the operation fails. +extern bool gcore_fastedge_cache_sync_expire(bindings_string_t *key, uint64_t ttl_ms, bool *ret, gcore_fastedge_cache_sync_error_t *err); + // Imported Functions from `wasi:cli/environment@0.2.3` // Get the POSIX-style environment variables. // @@ -1985,6 +2095,26 @@ void gcore_fastedge_key_value_result_list_tuple2_value_f64_error_free(gcore_fast void gcore_fastedge_key_value_result_bool_error_free(gcore_fastedge_key_value_result_bool_error_t *ptr); +void gcore_fastedge_cache_types_payload_free(gcore_fastedge_cache_types_payload_t *ptr); + +void gcore_fastedge_cache_types_error_free(gcore_fastedge_cache_types_error_t *ptr); + +void gcore_fastedge_cache_sync_payload_free(gcore_fastedge_cache_sync_payload_t *ptr); + +void gcore_fastedge_cache_sync_error_free(gcore_fastedge_cache_sync_error_t *ptr); + +void bindings_option_payload_free(bindings_option_payload_t *ptr); + +void gcore_fastedge_cache_sync_result_option_payload_error_free(gcore_fastedge_cache_sync_result_option_payload_error_t *ptr); + +void bindings_option_u64_free(bindings_option_u64_t *ptr); + +void gcore_fastedge_cache_sync_result_void_error_free(gcore_fastedge_cache_sync_result_void_error_t *ptr); + +void gcore_fastedge_cache_sync_result_bool_error_free(gcore_fastedge_cache_sync_result_bool_error_t *ptr); + +void gcore_fastedge_cache_sync_result_s64_error_free(gcore_fastedge_cache_sync_result_s64_error_t *ptr); + void bindings_tuple2_string_string_free(bindings_tuple2_string_string_t *ptr); void bindings_list_tuple2_string_string_free(bindings_list_tuple2_string_string_t *ptr); @@ -2185,8 +2315,6 @@ void bindings_option_u32_free(bindings_option_u32_t *ptr); void wasi_http_types_field_size_payload_free(wasi_http_types_field_size_payload_t *ptr); -void bindings_option_u64_free(bindings_option_u64_t *ptr); - void wasi_http_types_option_field_size_payload_free(wasi_http_types_option_field_size_payload_t *ptr); void wasi_http_types_error_code_free(wasi_http_types_error_code_t *ptr); diff --git a/runtime/fastedge/host-api/bindings/bindings_component_type.o b/runtime/fastedge/host-api/bindings/bindings_component_type.o index ff505ff..9eda28a 100644 Binary files a/runtime/fastedge/host-api/bindings/bindings_component_type.o and b/runtime/fastedge/host-api/bindings/bindings_component_type.o differ diff --git a/runtime/fastedge/host-api/fastedge_host_api.cpp b/runtime/fastedge/host-api/fastedge_host_api.cpp index 089b7fa..7598ac7 100644 --- a/runtime/fastedge/host-api/fastedge_host_api.cpp +++ b/runtime/fastedge/host-api/fastedge_host_api.cpp @@ -219,5 +219,118 @@ KvStoreResult kv_store_bf_exists(int32_t store_handle, std::string_view ke } } +// Cache implementations + +namespace { + CacheError convert_cache_error(const gcore_fastedge_cache_sync_error_t& err) { + CacheError error; + error.tag = static_cast(err.tag); + if (err.tag == GCORE_FASTEDGE_CACHE_TYPES_ERROR_OTHER) { + error.val.other.ptr = (char*)err.val.other.ptr; + error.val.other.len = err.val.other.len; + } + return error; + } +} // namespace + +CacheResult> cache_get(std::string_view key) { + auto key_str = string_view_to_world_string(key); + bindings_option_payload_t ret{}; + gcore_fastedge_cache_sync_error_t err{}; + + bool success = gcore_fastedge_cache_sync_get(&key_str, &ret, &err); + + if (success) { + if (ret.is_some) { + CacheBytes bytes; + bytes.ptr = ret.val.ptr; + bytes.len = ret.val.len; + return CacheResult>::ok(CacheOption::some(bytes)); + } else { + return CacheResult>::ok(CacheOption::none()); + } + } else { + return CacheResult>::err(convert_cache_error(err)); + } +} + +std::optional cache_set(std::string_view key, CacheBytesView value, std::optional ttl_ms) { + auto key_str = string_view_to_world_string(key); + + gcore_fastedge_cache_sync_payload_t payload{}; + // wit-bindgen takes the payload pointer as non-const; we don't mutate. + payload.ptr = const_cast(value.ptr); + payload.len = value.len; + + uint64_t ttl_value = ttl_ms.value_or(0); + uint64_t* ttl_ptr = ttl_ms.has_value() ? &ttl_value : nullptr; + + gcore_fastedge_cache_sync_error_t err{}; + bool success = gcore_fastedge_cache_sync_set(&key_str, &payload, ttl_ptr, &err); + + if (success) { + return std::nullopt; + } + return convert_cache_error(err); +} + +std::optional cache_delete(std::string_view key) { + auto key_str = string_view_to_world_string(key); + gcore_fastedge_cache_sync_error_t err{}; + + bool success = gcore_fastedge_cache_sync_delete(&key_str, &err); + + if (success) { + return std::nullopt; + } + return convert_cache_error(err); +} + +CacheResult cache_exists(std::string_view key) { + auto key_str = string_view_to_world_string(key); + bool ret = false; + gcore_fastedge_cache_sync_error_t err{}; + + bool success = gcore_fastedge_cache_sync_exists(&key_str, &ret, &err); + + if (success) { + return CacheResult::ok(ret); + } + return CacheResult::err(convert_cache_error(err)); +} + +CacheResult cache_incr(std::string_view key, int64_t delta) { + auto key_str = string_view_to_world_string(key); + int64_t ret = 0; + gcore_fastedge_cache_sync_error_t err{}; + + bool success = gcore_fastedge_cache_sync_incr(&key_str, delta, &ret, &err); + + if (success) { + return CacheResult::ok(ret); + } + return CacheResult::err(convert_cache_error(err)); +} + +CacheResult cache_expire(std::string_view key, uint64_t ttl_ms) { + auto key_str = string_view_to_world_string(key); + bool ret = false; + gcore_fastedge_cache_sync_error_t err{}; + + bool success = gcore_fastedge_cache_sync_expire(&key_str, ttl_ms, &ret, &err); + + if (success) { + return CacheResult::ok(ret); + } + return CacheResult::err(convert_cache_error(err)); +} + +// Utils + +void utils_set_user_diag(std::string_view name) { + auto name_str = string_view_to_world_string(name); + gcore_fastedge_utils_set_user_diag(&name_str); +} + } // namespace host_api diff --git a/runtime/fastedge/host-api/include/fastedge_host_api.h b/runtime/fastedge/host-api/include/fastedge_host_api.h index 87ec03a..50949f0 100644 --- a/runtime/fastedge/host-api/include/fastedge_host_api.h +++ b/runtime/fastedge/host-api/include/fastedge_host_api.h @@ -3,6 +3,8 @@ #include "host_api.h" +#include + typedef uint32_t FastEdgeHandle; struct JSErrorFormatString; @@ -115,6 +117,102 @@ KvStoreResult kv_store_zrange_by_score(int32_t store_handle, std:: KvStoreResult kv_store_zscan(int32_t store_handle, std::string_view key, std::string_view pattern); KvStoreResult kv_store_bf_exists(int32_t store_handle, std::string_view key, std::string_view item); +// Cache types and enums +// +// NOTE: CacheResult / CacheOption / CacheError parallel the KvStore* templates +// above. They are scheduled to be unified into HostResult / HostOption / +// HostError in a follow-up cleanup — see context/CACHE_API_HANDOFF.md. + +enum class CacheErrorTag : uint8_t { + ACCESS_DENIED = 0, + INTERNAL_ERROR = 1, + OTHER = 2 +}; + +struct CacheError { + CacheErrorTag tag; + union { + struct { + char* ptr; + size_t len; + } other; + } val; +}; + +template +class CacheResult { +public: + bool is_ok() const { return ok_; } + const T& unwrap() const { return value_; } + const CacheError& unwrap_err() const { return error_; } + + static CacheResult ok(T value) { + CacheResult result; + result.ok_ = true; + result.value_ = std::move(value); + return result; + } + + static CacheResult err(CacheError error) { + CacheResult result; + result.ok_ = false; + result.error_ = error; + return result; + } + +private: + bool ok_; + T value_; + CacheError error_; +}; + +template +class CacheOption { +public: + bool is_some() const { return has_value_; } + const T& unwrap() const { return value_; } + + static CacheOption some(T value) { + CacheOption option; + option.has_value_ = true; + option.value_ = std::move(value); + return option; + } + + static CacheOption none() { + CacheOption option; + option.has_value_ = false; + return option; + } + +private: + bool has_value_ = false; + T value_; +}; + +// Bytes returned by the host (host-allocated, mutable). +struct CacheBytes { + uint8_t* ptr; + size_t len; +}; + +// View into caller-owned bytes; used as cache_set input. +struct CacheBytesView { + const uint8_t* ptr; + size_t len; +}; + +// Cache functions +CacheResult> cache_get(std::string_view key); +std::optional cache_set(std::string_view key, CacheBytesView value, std::optional ttl_ms); +std::optional cache_delete(std::string_view key); +CacheResult cache_exists(std::string_view key); +CacheResult cache_incr(std::string_view key, int64_t delta); +CacheResult cache_expire(std::string_view key, uint64_t ttl_ms); + +// Utils +void utils_set_user_diag(std::string_view name); + } // namespace host_api #endif // FASTEDGE_HOST_API_H diff --git a/runtime/fastedge/host-api/wit/deps/fastedge/cache-sync.wit b/runtime/fastedge/host-api/wit/deps/fastedge/cache-sync.wit new file mode 100644 index 0000000..01e0d8e --- /dev/null +++ b/runtime/fastedge/host-api/wit/deps/fastedge/cache-sync.wit @@ -0,0 +1,53 @@ +/// FastEdge cache interface (synchronous variant). +interface cache-sync { + use cache-types.{payload, error}; + + /// Get the value associated with `key`. + /// + /// Returns: + /// - `ok(some(value))` if the key exists. + /// - `ok(none)` if the key does not exist. + /// - `err(error)` if the operation fails. + get: func(key: string) -> result, error>; + + /// Set the value for `key` with an optional expiry. + /// + /// If the key already exists, its current value is overwritten. + /// If the key does not exist, a new key-value pair is created. + /// + /// `ttl-ms` is the time-to-live in milliseconds. Pass `none` for no expiry. + /// + /// Returns `err(error)` if the operation fails. + set: func(key: string, value: payload, ttl-ms: option) -> result<_, error>; + + /// Delete the key-value pair associated with `key`. + /// + /// If the key does not exist, this operation is a no-op. + /// + /// Returns `err(error)` if the operation fails. + delete: func(key: string) -> result<_, error>; + + /// Check whether `key` exists in the cache. + /// + /// Returns: + /// - `ok(true)` if the key exists. + /// - `ok(false)` if the key does not exist. + /// - `err(error)` if the operation fails. + exists: func(key: string) -> result; + + /// Increment the integer value stored at `key` by `delta`. + /// + /// If the key does not exist, it is initialised to `0` before incrementing. + /// The operation is atomic. `delta` may be negative to decrement. + /// + /// Returns the new value after the increment, or `err(error)` if the + /// operation fails (for example, if the stored value is not an integer). + incr: func(key: string, delta: s64) -> result; + + /// Set or update the expiry of `key` to `ttl-ms` milliseconds from now. + /// + /// If the key does not exist, returns `ok(false)`. + /// If the expiry was updated successfully, returns `ok(true)`. + /// Returns `err(error)` if the operation fails. + expire: func(key: string, ttl-ms: u64) -> result; +} diff --git a/runtime/fastedge/host-api/wit/deps/fastedge/cache-types.wit b/runtime/fastedge/host-api/wit/deps/fastedge/cache-types.wit new file mode 100644 index 0000000..ea98bf8 --- /dev/null +++ b/runtime/fastedge/host-api/wit/deps/fastedge/cache-types.wit @@ -0,0 +1,15 @@ +/// Shared cache types for cache interfaces. +interface cache-types { + type payload = list; + + /// The set of errors that may be returned by cache operations. + variant error { + /// The requesting component does not have access to the specified cache + /// (which may or may not exist). + access-denied, + /// An unexpected internal error occurred. + internal-error, + /// An implementation-specific error occurred (for example, I/O). + other(string) + } +} diff --git a/runtime/fastedge/host-api/wit/deps/fastedge/utils.wit b/runtime/fastedge/host-api/wit/deps/fastedge/utils.wit new file mode 100644 index 0000000..6acd561 --- /dev/null +++ b/runtime/fastedge/host-api/wit/deps/fastedge/utils.wit @@ -0,0 +1,4 @@ +interface utils { + /// Set and save into call statistic specified user diagnostic context to be associated with the current request. + set-user-diag: func(name: string); +} \ No newline at end of file diff --git a/runtime/fastedge/host-api/wit/deps/fastedge/world.wit b/runtime/fastedge/host-api/wit/deps/fastedge/world.wit index 7c318cd..7d9589c 100644 --- a/runtime/fastedge/host-api/wit/deps/fastedge/world.wit +++ b/runtime/fastedge/host-api/wit/deps/fastedge/world.wit @@ -1,9 +1,10 @@ package gcore:fastedge; world reactor { - import dictionary; import secret; import key-value; + import utils; + import cache-sync; } \ No newline at end of file diff --git a/runtime/fastedge/scripts/merge-wit-bindings.js b/runtime/fastedge/scripts/merge-wit-bindings.js index fc5f96a..2741fa4 100755 --- a/runtime/fastedge/scripts/merge-wit-bindings.js +++ b/runtime/fastedge/scripts/merge-wit-bindings.js @@ -10,7 +10,8 @@ const dirname = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../' const witDir = path.join(dirname, 'host-api/wit'); const starlingHostApisDir = path.resolve(dirname, '../StarlingMonkey/host-apis'); const fastedgeWitDir = path.resolve(dirname, '../FastEdge-wit'); -const fastedgeDepsToRemove = ['http-client', 'http-handler', 'http']; // These are Rust specific. we use wasi-http +// http* interfaces are Rust-specific (we use wasi-http). +const fastedgeDepsToRemove = ['http-client', 'http-handler', 'http']; function clearExistingWitFiles() { // Remove all files from the ./wit directory diff --git a/src/cli/fastedge-init/create-config.ts b/src/cli/fastedge-init/create-config.ts index db092ee..93309b4 100644 --- a/src/cli/fastedge-init/create-config.ts +++ b/src/cli/fastedge-init/create-config.ts @@ -118,7 +118,7 @@ async function createConfigFile( /** * Creates basic `package.json` and `jsconfig.json` files for the FastEdge project directory. - * Ensures the project uses ES6 modules. + * Targets ES2023 to match the StarlingMonkey runtime. */ async function createProjectFiles(): Promise { await createOutputDirectory(PROJECT_DIRECTORY); @@ -127,16 +127,20 @@ async function createProjectFiles(): Promise { '{', ' "name": "fastedge-build",', ' "version": "1.0.0",', - ' "description": "fastedge-build project folder uses ES6",', + ' "description": "fastedge-build project folder (ES2023)",', ' "type": "module"', '}', ].join('\n'); await writeFile(`${PROJECT_DIRECTORY}/package.json`, packageJsonContents, 'utf-8'); - const jsConfigContents = ['{', ' "compilerOptions": {', ' "target": "ES6"', ' }', '}'].join( - '\n', - ); + const jsConfigContents = [ + '{', + ' "compilerOptions": {', + ' "target": "ES2023"', + ' }', + '}', + ].join('\n'); await writeFile(`${PROJECT_DIRECTORY}/jsconfig.json`, jsConfigContents, 'utf-8'); } diff --git a/src/componentize/__tests__/es-bundle.test.ts b/src/componentize/__tests__/es-bundle.test.ts new file mode 100644 index 0000000..d7250c6 --- /dev/null +++ b/src/componentize/__tests__/es-bundle.test.ts @@ -0,0 +1,68 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { esBundle } from '~componentize/es-bundle.ts'; + +describe('es-bundle - fastedge:: namespace resolver', () => { + let workDir: string; + + beforeEach(async () => { + workDir = await mkdtemp(join(tmpdir(), 'fastedge-es-bundle-')); + }); + + afterEach(async () => { + await rm(workDir, { recursive: true, force: true }); + }); + + const bundle = async (source: string): Promise => { + const entry = join(workDir, 'entry.js'); + await writeFile(entry, source, 'utf8'); + return esBundle(entry); + }; + + it('resolves fastedge::env to globalThis.fastedge.getEnv', async () => { + expect.assertions(1); + const out = await bundle(`import { getEnv } from 'fastedge::env'; export { getEnv };`); + expect(out).toContain('globalThis.fastedge.getEnv'); + }); + + it('resolves fastedge::fs to globalThis.fastedge.readFileSync', async () => { + expect.assertions(1); + const out = await bundle( + `import { readFileSync } from 'fastedge::fs'; export { readFileSync };`, + ); + expect(out).toContain('globalThis.fastedge.readFileSync'); + }); + + it('resolves fastedge::secret to both getSecret and getSecretEffectiveAt', async () => { + expect.assertions(2); + const out = await bundle(` + import { getSecret, getSecretEffectiveAt } from 'fastedge::secret'; + export { getSecret, getSecretEffectiveAt }; + `); + expect(out).toContain('globalThis.fastedge.getSecret'); + expect(out).toContain('globalThis.fastedge.getSecretEffectiveAt'); + }); + + it('resolves fastedge::kv to globalThis.KvStore', async () => { + expect.assertions(2); + const out = await bundle(`import { KvStore } from 'fastedge::kv'; export { KvStore };`); + expect(out).toContain('globalThis.KvStore'); + expect(out).not.toContain('globalThis.fastedge.KvStore'); + }); + + it('resolves fastedge::cache to globalThis.Cache', async () => { + expect.assertions(2); + const out = await bundle(`import { Cache } from 'fastedge::cache'; export { Cache };`); + expect(out).toContain('globalThis.Cache'); + expect(out).not.toContain('globalThis.fastedge.Cache'); + }); + + it('returns empty contents for unknown fastedge:: imports', async () => { + expect.assertions(2); + const out = await bundle(`import * as unknown from 'fastedge::unknown'; export { unknown };`); + expect(out).not.toContain('globalThis.fastedge.unknown'); + expect(out).not.toContain('globalThis.unknown'); + }); +}); diff --git a/src/componentize/es-bundle.ts b/src/componentize/es-bundle.ts index cfa001a..2da3581 100644 --- a/src/componentize/es-bundle.ts +++ b/src/componentize/es-bundle.ts @@ -35,6 +35,13 @@ const fastedgePackagePlugin: Plugin = { `, }; } + case 'cache': { + return { + contents: ` + export const Cache = globalThis.Cache; + `, + }; + } default: { return { contents: '' }; } @@ -54,6 +61,8 @@ async function esBundle(input: string): Promise { entryPoints: [input], bundle: true, write: false, + format: 'esm', + target: 'es2023', tsconfig: undefined, plugins: [fastedgePackagePlugin], }); diff --git a/tsconfig.json b/tsconfig.json index 0f10171..4439fa6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "ES2022", + "target": "ES2023", "module": "nodenext", "moduleResolution": "nodenext", "allowJs": true, diff --git a/types/fastedge-cache.d.ts b/types/fastedge-cache.d.ts new file mode 100644 index 0000000..1896cc3 --- /dev/null +++ b/types/fastedge-cache.d.ts @@ -0,0 +1,284 @@ +declare module 'fastedge::cache' { + /** + * The FastEdge cache is a fast, data-center-scoped key/value store with + * TTL and atomic counter primitives. It is intended for transient, + * request-time state — rate limiting, hit counters, response memoisation, + * deduplicated work — where speed and strong consistency within a single + * point-of-presence matter more than global durability. + * + * **Consistency:** strongly consistent within a POP, independent across + * POPs. A value written from one data center is not visible to another. + * For globally-replicated, eventually-consistent storage use the + * `fastedge::kv` module instead. + * + * **Storage:** values are stored as raw bytes. The cache does not record + * what type the developer originally passed; on read, callers decode + * using whichever `CacheEntry` accessor they need (`text`, `json`, + * `arrayBuffer`). + * + * @example + * ```js + * /// + * + * import { Cache } from "fastedge::cache"; + * + * async function app(event) { + * // Rate-limit by client IP — 100 requests per minute + * const ip = event.request.headers.get("x-forwarded-for") ?? "unknown"; + * const count = await Cache.incr(`rl:${ip}`); + * if (count === 1) await Cache.expire(`rl:${ip}`, { ttl: 60 }); + * if (count > 100) { + * return new Response("Too Many Requests", { status: 429 }); + * } + * + * // Memoise an expensive computation for 5 minutes + * const entry = await Cache.getOrSet( + * "expensive-result", + * async () => JSON.stringify(await compute()), + * { ttl: 300 }, + * ); + * return new Response(await entry.text(), { + * headers: { "content-type": "application/json" }, + * }); + * } + * + * addEventListener("fetch", event => event.respondWith(app(event))); + * ``` + */ + + /** + * Values that can be written to the cache. All forms are coerced to + * raw bytes before storage: + * + * - `string` — encoded as UTF-8. + * - `ArrayBuffer` / `ArrayBufferView` — used directly. + * - `ReadableStream` — fully consumed, then stored as a single byte buffer. + * - `Response` — `await response.arrayBuffer()` is consumed; the response + * status and headers are discarded. The cache stores bytes only; if you + * need to round-trip status or headers, encode them into the value + * yourself (for example, as a JSON envelope). + */ + export type CacheValue = + | string + | ArrayBuffer + | ArrayBufferView + | ReadableStream + | Response; + + /** + * Options controlling how long a cache entry lives. + * + * Pass exactly one of `ttl`, `ttlMs`, or `expiresAt`. Passing more than + * one — or passing zero or negative values — throws `TypeError`. If the + * options bag is omitted or empty, the entry has no expiry and persists + * until explicitly deleted (subject to the host's eviction policy). + * + * - `ttl` — relative TTL, **seconds** from now. The conventional unit + * for cache and key/value APIs. + * - `ttlMs` — relative TTL, **milliseconds** from now. Use when you need + * sub-second granularity (e.g. short rate-limit windows in tests). + * - `expiresAt` — absolute expiry, **Unix epoch seconds**. Use for + * "expire at midnight" / fixed-deadline patterns. + */ + export interface WriteOptions { + /** + * Relative TTL, in **seconds** from now. + * Mutually exclusive with `ttlMs` and `expiresAt`. + */ + ttl?: number; + + /** + * Relative TTL, in **milliseconds** from now. + * Mutually exclusive with `ttl` and `expiresAt`. + */ + ttlMs?: number; + + /** + * Absolute expiry as a **Unix epoch (seconds)**. + * Mutually exclusive with `ttl` and `ttlMs`. + */ + expiresAt?: number; + } + + /** + * A handle to a cached value. The bytes are already in memory by the + * time you receive a `CacheEntry`; the body accessor methods are + * Promise-returning to align with the standard Web `Body` interface, + * but they resolve immediately. + */ + export interface CacheEntry { + /** + * Read the entry as an `ArrayBuffer`. + */ + arrayBuffer(): Promise; + + /** + * Read the entry as a UTF-8 decoded string. + */ + text(): Promise; + + /** + * Read the entry as parsed JSON. Rejects with a `SyntaxError` if the + * bytes are not valid JSON. + */ + json(): Promise; + } + + /** + * Static interface to the FastEdge POP-local cache. + * + * All methods are static; `Cache` is never constructed. Every method + * returns a `Promise` so the API stays stable as the underlying host + * interface evolves: the cache is sync today (using the `cache-sync` + * WIT) and will become async once the toolchain supports the async + * `cache` WIT — application code keeps working unchanged either way. + * + * Operational errors from the host (access denied, internal error, + * implementation-specific I/O) surface as Promise rejections. + * Validation errors on call arguments (wrong types, conflicting + * `WriteOptions` fields) are thrown synchronously; both forms are + * caught the same way by `try`/`catch` around an `await`. + */ + export class Cache { + /** + * Get the entry for `key`, or `null` if absent or expired. + * + * @example + * ```js + * const entry = await Cache.get("user:42"); + * if (entry) { + * const user = await entry.json(); + * // ... + * } + * ``` + */ + static get(key: string): Promise; + + /** + * Test whether `key` exists in the cache without transferring its value. + * + * Cheaper than `get` when you only need presence. + */ + static exists(key: string): Promise; + + /** + * Store `value` under `key`, optionally with an expiry. + * + * Overwrites any existing value at `key`. Pass an empty options bag — + * or omit `options` — to store with no expiry. + * + * @example + * ```js + * await Cache.set("session:abc", JSON.stringify(session), { ttl: 600 }); + * await Cache.set("manifest", await fetch("/manifest.json")); + * ``` + */ + static set( + key: string, + value: CacheValue, + options?: WriteOptions, + ): Promise; + + /** + * Remove `key` from the cache. + * + * A no-op if the key does not exist. + */ + static delete(key: string): Promise; + + /** + * Update the expiry of an existing key. + * + * Resolves to `true` if the expiry was set, `false` if the key does + * not exist. + * + * @example + * ```js + * await Cache.expire("rl:1.2.3.4", { ttl: 60 }); + * ``` + */ + static expire(key: string, options: WriteOptions): Promise; + + /** + * Atomically increment the integer at `key` by `delta` (default 1). + * + * If the key does not exist, it is initialised to `0` before + * incrementing. Resolves to the new value. + * + * `delta` may be negative; for readability prefer `Cache.decr` in + * that case. Rejects if the stored value at `key` is not an integer. + * + * Note on precision: integer values larger than + * `Number.MAX_SAFE_INTEGER` (2^53 − 1) are not represented exactly. + * This is unreachable for typical counter use cases. + * + * @example + * ```js + * // Per-IP request counter, reset every minute + * const count = await Cache.incr(`rl:${ip}`); + * if (count === 1) await Cache.expire(`rl:${ip}`, { ttl: 60 }); + * ``` + */ + static incr(key: string, delta?: number): Promise; + + /** + * Atomically decrement the integer at `key` by `delta` (default 1). + * + * Sugar for `incr(key, -(delta ?? 1))`. Resolves to the new value. + */ + static decr(key: string, delta?: number): Promise; + + /** + * Get the entry for `key`, or run `populate` and cache its result. + * + * On a cache miss, `populate` is invoked once and the resolved value + * is stored under `key` with the supplied `options`. Concurrent callers + * for the same key in the same WASM instance share one `populate` + * execution; the inserter is not re-run for joiners. + * + * **Coalescing scope:** in-process only. Concurrent requests handled + * by other WASM instances — including other workers in the same POP — + * race independently and may each run `populate`. For a POP-local + * cache this is the honest guarantee. + * + * **Errors:** if `populate` throws or its Promise rejects, the + * rejection propagates to all current waiters. The next call after + * the rejection retries `populate` (no negative caching). + * + * **Skip-cache signal:** if `populate` resolves with `null`, the + * value is *not* written to the cache and `getOrSet` resolves with + * `null`. Use this to wrap fallible work and only pin successes — + * for example, caching only `response.ok` upstream fetches so that a + * transient 5xx doesn't get held for the rest of the TTL window. To + * also surface the original error response (e.g. return a 404 to the + * caller), use manual `Cache.get` + conditional `Cache.set` instead. + * + * @example + * ```js + * // Cache only successful upstream responses; null skips the write. + * const entry = await Cache.getOrSet( + * `proxy:${url}`, + * async () => { + * const r = await fetch(url); + * return r.ok ? r : null; + * }, + * { ttl: 30 }, + * ); + * if (entry === null) { + * return Response.json({ error: 'upstream unavailable' }, { status: 503 }); + * } + * return new Response(await entry.arrayBuffer()); + * ``` + */ + static getOrSet( + key: string, + populate: () => CacheValue | Promise, + options?: WriteOptions, + ): Promise; + static getOrSet( + key: string, + populate: () => CacheValue | null | Promise, + options?: WriteOptions, + ): Promise; + } +} diff --git a/types/fastedge-kv.d.ts b/types/fastedge-kv.d.ts index 2ec2b12..7f882f1 100644 --- a/types/fastedge-kv.d.ts +++ b/types/fastedge-kv.d.ts @@ -11,9 +11,13 @@ declare module 'fastedge::kv' { * async function app(event) { * try { * const kv = KvStore.open("my-kv-store"); - * const value = kv.get("key"); + * const entry = await kv.getEntry("key"); * - * return new Response(value, { + * if (entry === null) { + * return new Response("Not found", { status: 404 }); + * } + * + * return new Response(await entry.text(), { * status: 200 * }); * @@ -39,6 +43,30 @@ declare module 'fastedge::kv' { static open(name: string): KvStoreInstance; } + /** + * A handle to a value retrieved from the KV store. The bytes are + * already in memory by the time you receive a `KvStoreEntry`; the body + * accessor methods are Promise-returning to align with the standard + * Web `Body` interface, but they resolve immediately. + */ + export interface KvStoreEntry { + /** + * Read the entry as an `ArrayBuffer`. + */ + arrayBuffer(): Promise; + + /** + * Read the entry as a UTF-8 decoded string. + */ + text(): Promise; + + /** + * Read the entry as parsed JSON. Rejects with a `SyntaxError` if the + * bytes are not valid JSON. + */ + json(): Promise; + } + export interface KvStoreInstance { /** * Retrieves the value associated with the given key from the KV store. @@ -46,9 +74,33 @@ declare module 'fastedge::kv' { * @param {string} key The key to retrieve the value for. * * @returns {ArrayBuffer | null} The value associated with the key, or null if not found. + * + * @see {@link KvStoreInstance.getEntry} for an entry-style API with `text()` / `json()` helpers. */ get(key: string): ArrayBuffer | null; + /** + * Retrieves the value associated with the given key as a `KvStoreEntry` + * with `text()` / `json()` / `arrayBuffer()` accessors. + * + * Use this when you want to decode the value as a string or JSON + * without manual `TextDecoder` work. + * + * @param {string} key The key to retrieve the value for. + * + * @returns {Promise} A `KvStoreEntry` for the + * value, or `null` if the key is not present. + * + * @example + * ```js + * const entry = await kv.getEntry("user:42"); + * if (entry) { + * const user = await entry.json(); + * } + * ``` + */ + getEntry(key: string): Promise; + /** * Retrieves all key prefix matches from the KV store. * @@ -66,9 +118,31 @@ declare module 'fastedge::kv' { * @param {number} max The maximum score for the range. * * @returns {Array<[ArrayBuffer, number]>} Array of [value, score] tuples within range for the key, or an empty array if none found. + * + * @see {@link KvStoreInstance.zrangeByScoreEntries} for an entry-style API with `text()` / `json()` helpers. */ zrangeByScore(key: string, min: number, max: number): Array<[ArrayBuffer, number]>; + /** + * Retrieves all values from a Sorted Set with scores between the given + * range, returned as `KvStoreEntry` wrappers. + * + * Equivalent to `zrangeByScore(key, min, max)` but each tuple's value + * is a `KvStoreEntry` instead of a raw `ArrayBuffer`. + * + * @param {string} key The key for the Sorted Set. + * @param {number} min The minimum score for the range. + * @param {number} max The maximum score for the range. + * + * @returns {Promise>} Array of + * [entry, score] tuples within range, or an empty array if none found. + */ + zrangeByScoreEntries( + key: string, + min: number, + max: number, + ): Promise>; + /** * Retrieves all value prefix matches from the KV ZSet. * @@ -76,9 +150,31 @@ declare module 'fastedge::kv' { * @param {string} pattern The prefix pattern to match values against. e.g. 'foo*' ( Must include wildcard ) * * @returns {Array<[ArrayBuffer, number]>} Array of [value, score] tuples which match the prefix pattern, or an empty array if none found. + * + * @see {@link KvStoreInstance.zscanEntries} for an entry-style API with `text()` / `json()` helpers. */ zscan(key: string, pattern: string): Array<[ArrayBuffer, number]>; + /** + * Retrieves all value prefix matches from the KV Sorted Set, returned + * as `KvStoreEntry` wrappers. + * + * Equivalent to `zscan(key, pattern)` but each tuple's value is a + * `KvStoreEntry` instead of a raw `ArrayBuffer`. + * + * @param {string} key The key for the Sorted Set. + * @param {string} pattern The prefix pattern to match values against. + * e.g. 'foo*' (must include wildcard). + * + * @returns {Promise>} Array of + * [entry, score] tuples matching the prefix, or an empty array if + * none found. + */ + zscanEntries( + key: string, + pattern: string, + ): Promise>; + /** * Checks if a given value exists within the KV stores Bloom Filter. * diff --git a/types/globals.d.ts b/types/globals.d.ts index 6cf2cec..ab04233 100644 --- a/types/globals.d.ts +++ b/types/globals.d.ts @@ -44,9 +44,17 @@ declare function addEventListener( */ declare interface FetchEvent { /** - * Information about the downstream client that made the request + * Information about the downstream client that made the request, + * including its IP address, TLS fingerprint and geo (`client.geo`). + * Lazy: nothing is parsed until first access. */ readonly client: ClientInfo; + /** + * Information about the FastEdge POP server handling this request, + * including its address, name, and POP location (`server.pop`). + * Lazy: nothing is parsed until first access. + */ + readonly server: ServerInfo; /** * The downstream request that came from the client */ @@ -91,17 +99,98 @@ declare interface FetchEvent { /** * Information about the downstream client making the request. + * + * All fields are derived from headers the FastEdge edge POP injects into + * the request. Direct fields are populated when this object is first + * accessed; the nested {@link ClientInfo.geo} namespace is populated only + * if you read it. */ declare interface ClientInfo { /** - * A string representation of the IPv4 or IPv6 address of the downstream client. + * Downstream client IP (IPv4 or IPv6). Read from the platform-set + * `x-real-ip` header, falling back to `x-forwarded-for` if `x-real-ip` + * is absent. Empty string if neither header is present. + * + * Both headers are set by the trusted edge POP and not by the client, + * so they're safe to use for rate-limiting, geofencing, and similar + * trust decisions on this platform. */ readonly address: string; + /** + * JA3 TLS-handshake fingerprint as an MD5 hex string, from the + * platform-set `x-ja3` header. Empty string for non-TLS requests or + * when fingerprinting is unavailable. + */ readonly tlsJA3MD5: string; - readonly tlsCipherOpensslName: string; - readonly tlsProtocol: string; - readonly tlsClientCertificate: ArrayBuffer; - readonly tlsClientHello: ArrayBuffer; + /** + * Protocol family — `"https"` or `"http"`. Sourced from + * `x-forwarded-proto`. This is *not* the TLS version (e.g. "TLSv1.3"); + * the platform doesn't currently expose that. + */ + readonly protocol: string; + /** + * Client geographic information (lazy; populated on first access). + */ + readonly geo: GeoInfo; +} + +/** + * Geographic information about the downstream client, derived from the + * platform's `geoip-*` headers. Populated when {@link ClientInfo.geo} is + * first accessed. + */ +declare interface GeoInfo { + /** Autonomous System Number of the client's network as a string. Empty if unavailable. */ + readonly asn: string; + /** Latitude in decimal degrees, or `null` if unavailable. `0` is a real coordinate, not a sentinel. */ + readonly latitude: number | null; + /** Longitude in decimal degrees, or `null` if unavailable. */ + readonly longitude: number | null; + /** Region/state code (subdivision). Empty string if unavailable. */ + readonly region: string; + /** Continent code (e.g. `"EU"`, `"NA"`). Empty string if unavailable. */ + readonly continent: string; + /** ISO 3166-1 alpha-2 country code (e.g. `"PT"`). Empty string if unavailable. */ + readonly countryCode: string; + /** Country name (e.g. `"Portugal"`). Empty string if unavailable. */ + readonly countryName: string; + /** City name. Empty string when geo lookup didn't resolve a city. */ + readonly city: string; +} + +/** + * Information about the FastEdge POP server handling this request, + * including its network identity and POP location (`server.pop`). + */ +declare interface ServerInfo { + /** Server-side IP that received the request (`server_addr` header). */ + readonly address: string; + /** Server hostname (`server_name` header). */ + readonly name: string; + /** POP location (lazy; populated on first access). */ + readonly pop: PopInfo; +} + +/** + * Geographic information about the FastEdge POP serving the request, + * derived from the platform's `pop-*` headers. Populated when + * {@link ServerInfo.pop} is first accessed. + */ +declare interface PopInfo { + /** POP latitude in decimal degrees, or `null` if unavailable. */ + readonly latitude: number | null; + /** POP longitude in decimal degrees, or `null` if unavailable. */ + readonly longitude: number | null; + /** POP region/state code. Empty string if unavailable. */ + readonly region: string; + /** POP continent code. Empty string if unavailable. */ + readonly continent: string; + /** ISO 3166-1 alpha-2 POP country code. Empty string if unavailable. */ + readonly countryCode: string; + /** POP country name. Empty string if unavailable. */ + readonly countryName: string; + /** POP city. Empty string when not resolved. */ + readonly city: string; } /** @@ -257,7 +346,14 @@ and limitations under the License. * ({@linkcode Request}, and {@linkcode Response}) * @group Fetch API */ -declare type BodyInit = ReadableStream | ArrayBufferView | ArrayBuffer | URLSearchParams | string; +declare type BodyInit = + | ReadableStream + | ArrayBufferView + | ArrayBuffer + | Blob + | FormData + | URLSearchParams + | string; /** * Body for Fetch HTTP Requests and Responses @@ -269,8 +365,8 @@ declare interface Body { readonly body: ReadableStream | null; readonly bodyUsed: boolean; arrayBuffer(): Promise; - // blob(): Promise; - // formData(): Promise; + blob(): Promise; + formData(): Promise; json(): Promise; text(): Promise; } @@ -294,31 +390,31 @@ declare type RequestInfo = Request | string; declare interface RequestInit { /** A BodyInit object or null to set request's body. */ body?: BodyInit | null; - // /** A string indicating how the request will interact with the browser's cache to set request's cache. */ - // cache?: RequestCache; - // /** A string indicating whether credentials will be sent with the request always, never, or only when sent to a same-origin URL. Sets request's credentials. */ - // credentials?: RequestCredentials; /** A Headers object, an object literal, or an array of two-item arrays to set request's headers. */ headers?: HeadersInit; - // /** A cryptographic hash of the resource to be fetched by request. Sets request's integrity. */ - // integrity?: string; - // /** A boolean to set request's keepalive. */ - // keepalive?: boolean; /** A string to set request's method. */ method?: string; - // /** A string to indicate whether the request will use CORS, or will be restricted to same-origin URLs. Sets request's mode. */ + /** An AbortSignal to set request's signal. */ + signal?: AbortSignal | null; + + // --------------------------------------------------------------------------- + // Spec fields not implemented by the StarlingMonkey runtime + // --------------------------------------------------------------------------- + // The Request constructor only parses method/headers/body/signal. The fields + // below are part of the WHATWG Fetch spec but are silently dropped at runtime + // if passed. They will be uncommented when the runtime adds parsing and a + // matching property getter on Request. See: + // runtime/StarlingMonkey/builtins/web/fetch/request-response.cpp + // + // cache?: RequestCache; + // credentials?: RequestCredentials; + // integrity?: string; + // keepalive?: boolean; // mode?: RequestMode; - // /** A string indicating whether request follows redirects, results in an error upon encountering a redirect, or returns the redirect (in an opaque fashion). Sets request's redirect. */ // redirect?: RequestRedirect; - // /** A string whose value is a same-origin URL, "about:client", or the empty string, to set request's referrer. */ // referrer?: string; - // /** A referrer policy to set request's referrerPolicy. */ // referrerPolicy?: ReferrerPolicy; - // /** An AbortSignal to set request's signal. */ - // signal?: AbortSignal | null; - // /** Can only be null. Used to disassociate request from any Window. */ // window?: null; - manualFramingHeaders?: boolean; } /** @@ -330,38 +426,35 @@ declare interface RequestInit { * @group Fetch API */ interface Request extends Body { - // /** Returns the cache mode associated with request, which is a string indicating how the request will interact with the browser's cache when fetching. */ - // readonly cache: RequestCache; - // /** Returns the credentials mode associated with request, which is a string indicating whether credentials will be sent with the request always, never, or only when sent to a same-origin URL. */ - // readonly credentials: RequestCredentials; - // /** Returns the kind of resource requested by request, e.g., "document" or "script". */ - // readonly destination: RequestDestination; /** Returns a Headers object consisting of the headers associated with request. */ readonly headers: Headers; - // /** Returns request's subresource integrity metadata, which is a cryptographic hash of the resource being fetched. Its value consists of multiple hashes separated by whitespace. [SRI] */ - // readonly integrity: string; - // /** Returns a boolean indicating whether or not request can outlive the global in which it was created. */ - // readonly keepalive: boolean; /** Returns request's HTTP method, which is "GET" by default. */ readonly method: string; - // /** Returns the mode associated with request, which is a string indicating whether the request will use CORS, or will be restricted to same-origin URLs. */ - // readonly mode: RequestMode; - // /** Returns the redirect mode associated with request, which is a string indicating how redirects for the request will be handled during fetching. A request will follow redirects by default. */ - // readonly redirect: RequestRedirect; - // /** Returns the referrer of request. Its value can be a same-origin URL if explicitly set in init, the empty string to indicate no referrer, and "about:client" when defaulting to the global's default. This is used during fetching to determine the value of the `Referer` header of the request being made. */ - // readonly referrer: string; - // /** Returns the referrer policy associated with request. This is used during fetching to compute the value of the request's referrer. */ - // readonly referrerPolicy: ReferrerPolicy; - // /** Returns the signal associated with request, which is an AbortSignal object indicating whether or not request has been aborted, and its abort event handler. */ - // readonly signal: AbortSignal; + /** Returns the signal associated with request, which is an AbortSignal object indicating whether or not request has been aborted, and its abort event handler. */ + readonly signal: AbortSignal; /** Returns the URL of request as a string. */ readonly url: string; - // /** Creates a copy of the current Request object. */ + /** Creates a copy of the current Request object. */ clone(): Request; - setCacheKey(key: string): void; - setManualFramingHeaders(manual: boolean): void; + // --------------------------------------------------------------------------- + // Spec properties not implemented by the StarlingMonkey runtime + // --------------------------------------------------------------------------- + // No corresponding property getter is registered on the Request class, so + // these accessors are unavailable at runtime. They will be uncommented when + // the runtime exposes them. See: + // runtime/StarlingMonkey/builtins/web/fetch/request-response.cpp + // + // readonly cache: RequestCache; + // readonly credentials: RequestCredentials; + // readonly destination: RequestDestination; + // readonly integrity: string; + // readonly keepalive: boolean; + // readonly mode: RequestMode; + // readonly redirect: RequestRedirect; + // readonly referrer: string; + // readonly referrerPolicy: ReferrerPolicy; } /** @@ -381,9 +474,13 @@ declare interface ResponseInit { headers?: HeadersInit; status?: number; statusText?: string; - manualFramingHeaders?: boolean; } +/** + * @group Fetch API + */ +type ResponseType = 'basic' | 'cors' | 'default' | 'error' | 'opaque' | 'opaqueredirect'; + /** * The Response class as [specified by WHATWG](https://fetch.spec.whatwg.org/#ref-for-dom-response%E2%91%A0) * @@ -395,13 +492,11 @@ declare interface ResponseInit { interface Response extends Body { readonly headers: Headers; readonly ok: boolean; - // readonly redirected: boolean; + readonly redirected: boolean; readonly status: number; readonly statusText: string; - // readonly type: ResponseType; + readonly type: ResponseType; readonly url: string; - // clone(): Response; - setManualFramingHeaders(manual: boolean): void; } /** @@ -410,21 +505,29 @@ interface Response extends Body { declare var Response: { prototype: Response; new (body?: BodyInit | null, init?: ResponseInit): Response; - // error(): Response; redirect(url: string | URL, status?: number): Response; json(data: any, init?: ResponseInit): Response; + + // --------------------------------------------------------------------------- + // Spec static methods not implemented by the StarlingMonkey runtime + // --------------------------------------------------------------------------- + // Will be uncommented when the runtime exposes them. See: + // runtime/StarlingMonkey/builtins/web/fetch/request-response.cpp + // + // error(): Response; }; /** * @group Streams API */ -type ReadableStreamReader = ReadableStreamDefaultReader; -// type ReadableStreamReader = ReadableStreamDefaultReader | ReadableStreamBYOBReader; +type ReadableStreamReader = ReadableStreamDefaultReader | ReadableStreamBYOBReader; + /** * @group Streams API */ -type ReadableStreamController = ReadableStreamDefaultController; -// type ReadableStreamController = ReadableStreamDefaultController | ReadableByteStreamController; +type ReadableStreamController = + | ReadableStreamDefaultController + | ReadableByteStreamController; /** * @group Streams API @@ -503,30 +606,49 @@ interface UnderlyingSource { type ReadableStreamType = 'bytes'; /** + * Options for {@linkcode ReadableStream.pipeTo} and {@linkcode ReadableStream.pipeThrough}. + * + * The behaviour of the piping process under various error conditions can be + * customised with these options. It returns a promise that fulfills when the + * piping process completes successfully, or rejects if any errors were + * encountered. + * + * Piping a stream will lock it for the duration of the pipe, preventing any + * other consumer from acquiring a reader. + * + * Errors and closures of the source and destination streams propagate as + * follows: + * + * - An error in the source readable stream will abort destination, unless + * `preventAbort` is truthy. The returned promise will be rejected with the + * source's error, or with any error that occurs during aborting the + * destination. + * - An error in destination will cancel the source readable stream, unless + * `preventCancel` is truthy. The returned promise will be rejected with the + * destination's error, or with any error that occurs during canceling the + * source. + * - When the source readable stream closes, destination will be closed, + * unless `preventClose` is truthy. The returned promise will be fulfilled + * once this process completes, unless an error is encountered while + * closing the destination, in which case it will be rejected with that + * error. + * - If destination starts out closed or closing, the source readable stream + * will be canceled, unless `preventCancel` is true. The returned promise + * will be rejected with an error indicating piping to a closed stream + * failed, or with any error that occurs during canceling the source. + * - The `signal` option can be set to an {@linkcode AbortSignal} to allow + * aborting an ongoing pipe operation via the corresponding + * {@linkcode AbortController}. In this case, the source readable stream + * will be canceled, and destination aborted, unless the respective options + * `preventCancel` or `preventAbort` are set. + * * @group Streams API */ interface StreamPipeOptions { preventAbort?: boolean; preventCancel?: boolean; - /** - * Pipes this readable stream to a given writable stream destination. The way in which the piping process behaves under various error conditions can be customized with a number of passed options. It returns a promise that fulfills when the piping process completes successfully, or rejects if any errors were encountered. - * - * Piping a stream will lock it for the duration of the pipe, preventing any other consumer from acquiring a reader. - * - * Errors and closures of the source and destination streams propagate as follows: - * - * An error in this source readable stream will abort destination, unless preventAbort is truthy. The returned promise will be rejected with the source's error, or with any error that occurs during aborting the destination. - * - * An error in destination will cancel this source readable stream, unless preventCancel is truthy. The returned promise will be rejected with the destination's error, or with any error that occurs during canceling the source. - * - * When this source readable stream closes, destination will be closed, unless preventClose is truthy. The returned promise will be fulfilled once this process completes, unless an error is encountered while closing the destination, in which case it will be rejected with that error. - * - * If destination starts out closed or closing, this source readable stream will be canceled, unless preventCancel is true. The returned promise will be rejected with an error indicating piping to a closed stream failed, or with any error that occurs during canceling the source. - * - * The signal option can be set to an AbortSignal to allow aborting an ongoing pipe operation via the corresponding AbortController. In this case, this source readable stream will be canceled, and destination aborted, unless the respective options preventCancel or preventAbort are set. - */ preventClose?: boolean; - // signal?: AbortSignal; + signal?: AbortSignal; } /** @@ -549,13 +671,53 @@ interface QueuingStrategy { */ interface QueuingStrategyInit { /** - * Creates a new ByteLengthQueuingStrategy with the provided high water mark. - * - * Note that the provided high water mark will not be validated ahead of time. Instead, if it is negative, NaN, or not a number, the resulting ByteLengthQueuingStrategy will cause the corresponding stream constructor to throw. + * The provided high water mark is not validated ahead of time. If it is + * negative, `NaN`, or not a number, the resulting strategy will cause the + * corresponding stream constructor to throw. */ highWaterMark: number; } +/** + * A queuing strategy that counts byte length. Used as the + * `queuingStrategy` argument when constructing a {@linkcode ReadableStream} + * or {@linkcode WritableStream} of byte data. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ByteLengthQueuingStrategy) + * @group Streams API + */ +interface ByteLengthQueuingStrategy extends QueuingStrategy { + readonly highWaterMark: number; + readonly size: QueuingStrategySize; +} + +/** + * @group Streams API + */ +declare var ByteLengthQueuingStrategy: { + prototype: ByteLengthQueuingStrategy; + new (init: QueuingStrategyInit): ByteLengthQueuingStrategy; +}; + +/** + * A queuing strategy that counts each chunk as a single unit. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CountQueuingStrategy) + * @group Streams API + */ +interface CountQueuingStrategy extends QueuingStrategy { + readonly highWaterMark: number; + readonly size: QueuingStrategySize; +} + +/** + * @group Streams API + */ +declare var CountQueuingStrategy: { + prototype: CountQueuingStrategy; + new (init: QueuingStrategyInit): CountQueuingStrategy; +}; + /** * @group Streams API */ @@ -600,6 +762,8 @@ interface ReadableStream { readonly locked: boolean; cancel(reason?: any): Promise; getReader(): ReadableStreamDefaultReader; + getReader(options: { mode: 'byob' }): ReadableStreamBYOBReader; + getReader(options?: ReadableStreamGetReaderOptions): ReadableStreamReader; pipeThrough( transform: ReadableWritablePair, options?: StreamPipeOptions, @@ -664,6 +828,87 @@ interface ReadableStreamGenericReader { cancel(reason?: any): Promise; } +/** + * @group Streams API + */ +interface ReadableStreamGetReaderOptions { + mode?: 'byob'; +} + +/** + * @group Streams API + */ +interface ReadableStreamBYOBReaderReadOptions { + /** Minimum number of elements to read before resolving. Default 1. */ + min?: number; +} + +/** + * Reader for a "byte" {@linkcode ReadableStream}, allowing the consumer to + * supply the buffer that the stream writes into (Bring-Your-Own-Buffer). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBReader) + * @group Streams API + */ +interface ReadableStreamBYOBReader extends ReadableStreamGenericReader { + read( + view: T, + options?: ReadableStreamBYOBReaderReadOptions, + ): Promise>; + releaseLock(): void; +} + +/** + * @group Streams API + */ +declare var ReadableStreamBYOBReader: { + prototype: ReadableStreamBYOBReader; + new (stream: ReadableStream): ReadableStreamBYOBReader; +}; + +/** + * Represents a "pull-into" descriptor passed to a {@linkcode ReadableByteStreamController}. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableStreamBYOBRequest) + * @group Streams API + */ +interface ReadableStreamBYOBRequest { + readonly view: ArrayBufferView | null; + respond(bytesWritten: number): void; + respondWithNewView(view: ArrayBufferView): void; +} + +/** + * @group Streams API + */ +declare var ReadableStreamBYOBRequest: { + prototype: ReadableStreamBYOBRequest; + new (): ReadableStreamBYOBRequest; +}; + +/** + * Controller for a "byte" {@linkcode ReadableStream} (i.e. one constructed + * with `{ type: 'bytes' }`). + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/ReadableByteStreamController) + * @group Streams API + */ +interface ReadableByteStreamController { + readonly byobRequest: ReadableStreamBYOBRequest | null; + readonly desiredSize: number | null; + close(): void; + enqueue(chunk: ArrayBufferView): void; + error(e?: any): void; +} + +/** + * @group Streams API + */ +declare var ReadableByteStreamController: { + prototype: ReadableByteStreamController; + new (): ReadableByteStreamController; +}; + /** * This Streams API interface provides a standard abstraction for writing streaming data to a destination, known as a sink. This object comes with built-in backpressure and queuing. * @group Streams API @@ -800,6 +1045,58 @@ interface TransformerTransformCallback { (chunk: I, controller: TransformStreamDefaultController): void | PromiseLike; } +/** + * Compression formats accepted by {@linkcode CompressionStream} and + * {@linkcode DecompressionStream}. + * + * @group Compression Streams API + */ +type CompressionFormat = 'deflate' | 'deflate-raw' | 'gzip'; + +/** + * Compresses a stream of data using the specified format. + * + * Used as a {@linkcode TransformStream}: write uncompressed bytes to its + * `writable` side and read compressed bytes from its `readable` side. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CompressionStream) + * @group Compression Streams API + */ +interface CompressionStream { + readonly readable: ReadableStream; + readonly writable: WritableStream; +} + +/** + * @group Compression Streams API + */ +declare var CompressionStream: { + prototype: CompressionStream; + new (format: CompressionFormat): CompressionStream; +}; + +/** + * Decompresses a stream of data compressed in the specified format. + * + * Used as a {@linkcode TransformStream}: write compressed bytes to its + * `writable` side and read uncompressed bytes from its `readable` side. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/DecompressionStream) + * @group Compression Streams API + */ +interface DecompressionStream { + readonly readable: ReadableStream; + readonly writable: WritableStream; +} + +/** + * @group Compression Streams API + */ +declare var DecompressionStream: { + prototype: DecompressionStream; + new (format: CompressionFormat): DecompressionStream; +}; + /** * @group Fetch API */ @@ -815,6 +1112,7 @@ interface Headers { append(name: string, value: string): void; delete(name: string): void; get(name: string): string | null; + getSetCookie(): string[]; has(name: string): boolean; set(name: string, value: string): void; forEach(callbackfn: (value: string, key: string, parent: Headers) => void, thisArg?: any): void; @@ -945,8 +1243,11 @@ interface StructuredSerializeOptions { /** * @group DOM APIs */ +// The full WHATWG type is `ArrayBuffer | MessagePort | ImageBitmap`. The +// StarlingMonkey runtime exposes neither MessagePort (no Worker API) nor +// ImageBitmap (no Canvas API), so only ArrayBuffer can be transferred via +// structuredClone. See runtime/StarlingMonkey/builtins/web/structured-clone.cpp type Transferable = ArrayBuffer; -// type Transferable = ArrayBuffer | MessagePort | ImageBitmap; /** * @group Web APIs @@ -990,21 +1291,30 @@ type BufferSource = ArrayBufferView | ArrayBuffer; declare class SubtleCrypto { constructor(); - // decrypt(algorithm: AlgorithmIdentifier | RsaOaepParams | AesCtrParams | AesCbcParams | AesGcmParams, key: CryptoKey, data: BufferSource): Promise; - // deriveBits(algorithm: AlgorithmIdentifier | EcdhKeyDeriveParams | HkdfParams | Pbkdf2Params, baseKey: CryptoKey, length: number): Promise; - // deriveKey(algorithm: AlgorithmIdentifier | EcdhKeyDeriveParams | HkdfParams | Pbkdf2Params, baseKey: CryptoKey, derivedKeyType: AlgorithmIdentifier | AesDerivedKeyParams | HmacImportParams | HkdfParams | Pbkdf2Params, extractable: boolean, keyUsages: KeyUsage[]): Promise; + + /** + * Computes a digest (hash) of the given data. Supported algorithms: + * `SHA-1`, `SHA-256`, `SHA-384`, `SHA-512`. + */ digest(algorithm: AlgorithmIdentifier, data: BufferSource): Promise; - // encrypt(algorithm: AlgorithmIdentifier | RsaOaepParams | AesCtrParams | AesCbcParams | AesGcmParams, key: CryptoKey, data: BufferSource): Promise; - // exportKey(format: "jwk", key: CryptoKey): Promise; - // exportKey(format: Exclude, key: CryptoKey): Promise; - // generateKey(algorithm: RsaHashedKeyGenParams | EcKeyGenParams, extractable: boolean, keyUsages: ReadonlyArray): Promise; - // generateKey(algorithm: AesKeyGenParams | HmacKeyGenParams | Pbkdf2Params, extractable: boolean, keyUsages: ReadonlyArray): Promise; - // generateKey(algorithm: AlgorithmIdentifier, extractable: boolean, keyUsages: KeyUsage[]): Promise; - // importKey(format: "jwk", keyData: JsonWebKey, algorithm: AlgorithmIdentifier | RsaHashedImportParams | EcKeyImportParams | HmacImportParams | AesKeyAlgorithm, extractable: boolean, keyUsages: ReadonlyArray): Promise; + + /** + * Imports a key from external key material. Supported algorithms: + * `HMAC`, `RSASSA-PKCS1-v1_5`, `ECDSA`. + * + * Supported (algorithm, format) combinations: + * - `HMAC` — `'raw'`, `'jwk'` + * - `RSASSA-PKCS1-v1_5` — `'jwk'`, `'spki'`, `'pkcs8'` + * - `ECDSA` — `'jwk'`, `'raw'`, `'spki'`, `'pkcs8'` + */ importKey( format: 'jwk', keyData: JsonWebKey, - algorithm: AlgorithmIdentifier | RsaHashedImportParams | EcKeyImportParams, + algorithm: + | AlgorithmIdentifier + | HmacImportParams + | RsaHashedImportParams + | EcKeyImportParams, extractable: boolean, keyUsages: ReadonlyArray, ): Promise; @@ -1013,24 +1323,67 @@ declare class SubtleCrypto { keyData: BufferSource, algorithm: | AlgorithmIdentifier + | HmacImportParams | RsaHashedImportParams - // | EcKeyImportParams - | HmacImportParams, - // | AesKeyAlgorithm + | EcKeyImportParams, extractable: boolean, keyUsages: KeyUsage[], ): Promise; - // sign(algorithm: AlgorithmIdentifier | RsaPssParams | EcdsaParams, key: CryptoKey, data: BufferSource): Promise; - sign(algorithm: AlgorithmIdentifier, key: CryptoKey, data: BufferSource): Promise; - // unwrapKey(format: KeyFormat, wrappedKey: BufferSource, unwrappingKey: CryptoKey, unwrapAlgorithm: AlgorithmIdentifier | RsaOaepParams | AesCtrParams | AesCbcParams | AesGcmParams, unwrappedKeyAlgorithm: AlgorithmIdentifier | RsaHashedImportParams | EcKeyImportParams | HmacImportParams | AesKeyAlgorithm, extractable: boolean, keyUsages: KeyUsage[]): Promise; - // verify(algorithm: AlgorithmIdentifier | RsaPssParams | EcdsaParams, key: CryptoKey, signature: BufferSource, data: BufferSource): Promise; + + /** + * Signs data with a private (or symmetric) key. Supported algorithms: + * `HMAC`, `RSASSA-PKCS1-v1_5`, `ECDSA`. ECDSA requires {@link EcdsaParams} + * (`{ name: 'ECDSA', hash: ... }`) so the hash function can be specified. + */ + sign( + algorithm: AlgorithmIdentifier | EcdsaParams, + key: CryptoKey, + data: BufferSource, + ): Promise; + + /** + * Verifies a signature against the original data. Supported algorithms: + * `HMAC`, `RSASSA-PKCS1-v1_5`, `ECDSA`. ECDSA requires {@link EcdsaParams}. + */ verify( - algorithm: AlgorithmIdentifier, + algorithm: AlgorithmIdentifier | EcdsaParams, key: CryptoKey, signature: BufferSource, data: BufferSource, ): Promise; + + // --------------------------------------------------------------------------- + // Spec methods not implemented by the StarlingMonkey runtime + // --------------------------------------------------------------------------- + // The runtime currently implements only `digest`, `importKey`, `sign`, and + // `verify`. The methods below are part of the WebCrypto spec but throw + // `TypeError` at runtime if called. See: + // runtime/StarlingMonkey/builtins/web/crypto/subtle-crypto.cpp + // + // decrypt(algorithm: AlgorithmIdentifier | RsaOaepParams | AesCtrParams | AesCbcParams | AesGcmParams, key: CryptoKey, data: BufferSource): Promise; + // deriveBits(algorithm: AlgorithmIdentifier | EcdhKeyDeriveParams | HkdfParams | Pbkdf2Params, baseKey: CryptoKey, length: number): Promise; + // deriveKey(algorithm: AlgorithmIdentifier | EcdhKeyDeriveParams | HkdfParams | Pbkdf2Params, baseKey: CryptoKey, derivedKeyType: AlgorithmIdentifier | AesDerivedKeyParams | HmacImportParams | HkdfParams | Pbkdf2Params, extractable: boolean, keyUsages: KeyUsage[]): Promise; + // encrypt(algorithm: AlgorithmIdentifier | RsaOaepParams | AesCtrParams | AesCbcParams | AesGcmParams, key: CryptoKey, data: BufferSource): Promise; + // exportKey(format: 'jwk', key: CryptoKey): Promise; + // exportKey(format: Exclude, key: CryptoKey): Promise; + // generateKey(algorithm: RsaHashedKeyGenParams | EcKeyGenParams, extractable: boolean, keyUsages: ReadonlyArray): Promise; + // generateKey(algorithm: AesKeyGenParams | HmacKeyGenParams | Pbkdf2Params, extractable: boolean, keyUsages: ReadonlyArray): Promise; + // generateKey(algorithm: AlgorithmIdentifier, extractable: boolean, keyUsages: KeyUsage[]): Promise; + // unwrapKey(format: KeyFormat, wrappedKey: BufferSource, unwrappingKey: CryptoKey, unwrapAlgorithm: AlgorithmIdentifier | RsaOaepParams | AesCtrParams | AesCbcParams | AesGcmParams, unwrappedKeyAlgorithm: AlgorithmIdentifier | RsaHashedImportParams | EcKeyImportParams | HmacImportParams | AesKeyAlgorithm, extractable: boolean, keyUsages: KeyUsage[]): Promise; // wrapKey(format: KeyFormat, key: CryptoKey, wrappingKey: CryptoKey, wrapAlgorithm: AlgorithmIdentifier | RsaOaepParams | AesCtrParams | AesCbcParams | AesGcmParams): Promise; + // + // The following algorithm-parameter unions are intentionally absent from the + // live overloads because the underlying algorithms are not registered in + // crypto-algorithm.cpp: + // + // `RsaPssParams` (RSA-PSS) — sign/verify + // `RsaOaepParams` (RSA-OAEP) — encrypt/decrypt/wrap/unwrap + // `AesCtrParams`, `AesCbcParams`, `AesGcmParams`, `AesKeyAlgorithm`, + // `AesKeyGenParams`, `AesDerivedKeyParams` (all AES variants) + // `EcdhKeyDeriveParams` (ECDH) — deriveBits/deriveKey + // `HkdfParams`, `Pbkdf2Params` (HKDF, PBKDF2) — deriveBits/deriveKey + // `HmacKeyGenParams`, `RsaHashedKeyGenParams`, `EcKeyGenParams` — + // generateKey } interface HmacImportParams extends Algorithm { @@ -1043,11 +1396,20 @@ interface RsaHashedImportParams extends Algorithm { } type HashAlgorithmIdentifier = AlgorithmIdentifier; -interface EcKeyImportParams { +interface EcKeyImportParams extends Algorithm { name: 'ECDSA'; namedCurve: 'P-256' | 'P-384' | 'P-521'; } +/** + * Parameter dictionary for {@linkcode SubtleCrypto.sign} and + * {@linkcode SubtleCrypto.verify} when using ECDSA. + */ +interface EcdsaParams extends Algorithm { + name: 'ECDSA'; + hash: HashAlgorithmIdentifier; +} + interface JsonWebKey { alg?: string; crv?: string; @@ -1126,11 +1488,7 @@ interface KeyAlgorithm { name: string; } -type KeyFormat = - | 'jwk' - // | "pkcs8" - | 'raw'; -// | "spki"; +type KeyFormat = 'jwk' | 'pkcs8' | 'raw' | 'spki'; type KeyType = 'private' | 'public' | 'secret'; type KeyUsage = | 'decrypt' @@ -1143,17 +1501,144 @@ type KeyUsage = | 'wrapKey'; /** - * EventTarget is a DOM interface implemented by objects that can receive events and may have listeners for them. + * @group DOM Events + */ +interface EventInit { + bubbles?: boolean; + cancelable?: boolean; + composed?: boolean; +} + +/** + * An event which takes place in the DOM. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Event) + * @group DOM Events + */ +interface Event { + readonly bubbles: boolean; + readonly cancelable: boolean; + readonly composed: boolean; + readonly currentTarget: EventTarget | null; + readonly defaultPrevented: boolean; + readonly eventPhase: number; + readonly isTrusted: boolean; + readonly srcElement: EventTarget | null; + readonly target: EventTarget | null; + readonly timeStamp: DOMHighResTimeStamp; + readonly type: string; + returnValue: boolean; + composedPath(): EventTarget[]; + initEvent(type: string, bubbles?: boolean, cancelable?: boolean): void; + preventDefault(): void; + stopImmediatePropagation(): void; + stopPropagation(): void; + readonly NONE: 0; + readonly CAPTURING_PHASE: 1; + readonly AT_TARGET: 2; + readonly BUBBLING_PHASE: 3; +} + +/** + * @group DOM Events + */ +declare var Event: { + prototype: Event; + new (type: string, eventInitDict?: EventInit): Event; + readonly NONE: 0; + readonly CAPTURING_PHASE: 1; + readonly AT_TARGET: 2; + readonly BUBBLING_PHASE: 3; +}; + +/** + * @group DOM Events + */ +interface CustomEventInit extends EventInit { + detail?: T; +} + +/** + * Events initialised by an application for any purpose. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/CustomEvent) + * @group DOM Events + */ +interface CustomEvent extends Event { + readonly detail: T; +} + +/** + * @group DOM Events + */ +declare var CustomEvent: { + prototype: CustomEvent; + new (type: string, eventInitDict?: CustomEventInit): CustomEvent; +}; + +/** + * @group DOM Events + */ +interface EventListener { + (evt: Event): void; +} + +/** + * @group DOM Events + */ +interface EventListenerObject { + handleEvent(object: Event): void; +} + +/** + * @group DOM Events + */ +type EventListenerOrEventListenerObject = EventListener | EventListenerObject; + +/** + * @group DOM Events + */ +interface EventListenerOptions { + capture?: boolean; +} + +/** + * @group DOM Events + */ +interface AddEventListenerOptions extends EventListenerOptions { + once?: boolean; + passive?: boolean; + signal?: AbortSignal; +} + +/** + * EventTarget is an interface implemented by objects that can receive events and may have listeners for them. * * [MDN Reference](https://developer.mozilla.org/docs/Web/API/EventTarget) * @group DOM Events */ interface EventTarget { - //addEventListener(type: string, callback: EventListenerOrEventListenerObject | null, options?: AddEventListenerOptions | boolean): void; - //dispatchEvent(event: Event): boolean; - //removeEventListener(type: string, callback: EventListenerOrEventListenerObject | null, options?: EventListenerOptions | boolean): void; + addEventListener( + type: string, + callback: EventListenerOrEventListenerObject | null, + options?: AddEventListenerOptions | boolean, + ): void; + dispatchEvent(event: Event): boolean; + removeEventListener( + type: string, + callback: EventListenerOrEventListenerObject | null, + options?: EventListenerOptions | boolean, + ): void; } +/** + * @group DOM Events + */ +declare var EventTarget: { + prototype: EventTarget; + new (): EventTarget; +}; + /** * Provides access to performance-related information for the current page. It's part of the High Resolution Time API, but is enhanced by the Performance Timeline API, the Navigation Timing API, the User Timing API, and the Resource Timing API. * @@ -1181,4 +1666,250 @@ declare var Performance: { declare var performance: Performance; type DOMHighResTimeStamp = number; + +// --------------------------------------------------------------------------- +// Abort API +// --------------------------------------------------------------------------- + +/** + * @group Abort API + */ +interface AbortSignalEventMap { + abort: Event; +} + +/** + * A signal object that allows you to communicate with a request and abort it + * if required via an {@linkcode AbortController}. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortSignal) + * @group Abort API + */ +interface AbortSignal extends EventTarget { + /** Whether the request has been aborted. */ + readonly aborted: boolean; + /** The reason the signal aborted, if any. */ + readonly reason: any; + onabort: ((this: AbortSignal, ev: Event) => any) | null; + /** Throws the signal's abort `reason` if the signal has been aborted. */ + throwIfAborted(): void; +} + +/** + * @group Abort API + */ +declare var AbortSignal: { + prototype: AbortSignal; + new (): AbortSignal; + /** Returns an `AbortSignal` instance that is already aborted. */ + abort(reason?: any): AbortSignal; + /** Returns an `AbortSignal` that aborts after `milliseconds` have elapsed. */ + timeout(milliseconds: number): AbortSignal; + /** Returns an `AbortSignal` that aborts when any of the supplied signals abort. */ + any(signals: AbortSignal[]): AbortSignal; +}; + +/** + * A controller object that allows you to abort one or more requests as + * desired through an associated {@linkcode AbortSignal}. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/AbortController) + * @group Abort API + */ +interface AbortController { + /** The {@linkcode AbortSignal} associated with this controller. */ + readonly signal: AbortSignal; + /** Causes the signal to transition to its aborted state. */ + abort(reason?: any): void; +} + +/** + * @group Abort API + */ +declare var AbortController: { + prototype: AbortController; + new (): AbortController; +}; + +// --------------------------------------------------------------------------- +// Encoding API (TextEncoder / TextDecoder) +// --------------------------------------------------------------------------- + +/** + * @group Encoding API + */ +interface TextDecoderOptions { + fatal?: boolean; + ignoreBOM?: boolean; +} + +/** + * @group Encoding API + */ +interface TextDecodeOptions { + stream?: boolean; +} + +/** + * Decodes a stream of bytes (e.g. {@linkcode ArrayBuffer} or + * {@linkcode Uint8Array}) into a string using a given encoding. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder) + * @group Encoding API + */ +interface TextDecoder { + readonly encoding: string; + readonly fatal: boolean; + readonly ignoreBOM: boolean; + decode(input?: BufferSource, options?: TextDecodeOptions): string; +} + +/** + * @group Encoding API + */ +declare var TextDecoder: { + prototype: TextDecoder; + new (label?: string, options?: TextDecoderOptions): TextDecoder; +}; + +/** + * @group Encoding API + */ +interface TextEncoderEncodeIntoResult { + read: number; + written: number; +} + +/** + * Encodes a string into a stream of UTF-8 bytes. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextEncoder) + * @group Encoding API + */ +interface TextEncoder { + /** Always `"utf-8"`. */ + readonly encoding: string; + encode(input?: string): Uint8Array; + encodeInto(source: string, destination: Uint8Array): TextEncoderEncodeIntoResult; +} + +/** + * @group Encoding API + */ +declare var TextEncoder: { + prototype: TextEncoder; + new (): TextEncoder; +}; + +// --------------------------------------------------------------------------- +// Blob / File / FormData +// --------------------------------------------------------------------------- + +/** + * @group File API + */ +type EndingType = 'native' | 'transparent'; + +/** + * @group File API + */ +type BlobPart = BufferSource | Blob | string; + +/** + * @group File API + */ +interface BlobPropertyBag { + endings?: EndingType; + type?: string; +} + +/** + * A file-like object of immutable, raw data. Blobs represent data that isn't + * necessarily in a JavaScript-native format. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob) + * @group File API + */ +interface Blob { + readonly size: number; + readonly type: string; + arrayBuffer(): Promise; + bytes(): Promise; + slice(start?: number, end?: number, contentType?: string): Blob; + stream(): ReadableStream; + text(): Promise; +} + +/** + * @group File API + */ +declare var Blob: { + prototype: Blob; + new (blobParts?: BlobPart[], options?: BlobPropertyBag): Blob; +}; + +/** + * @group File API + */ +interface FilePropertyBag extends BlobPropertyBag { + lastModified?: number; +} + +/** + * Provides information about files and allows JavaScript to access their + * content. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/File) + * @group File API + */ +interface File extends Blob { + readonly lastModified: number; + readonly name: string; +} + +/** + * @group File API + */ +declare var File: { + prototype: File; + new (fileBits: BlobPart[], fileName: string, options?: FilePropertyBag): File; +}; + +/** + * @group File API + */ +type FormDataEntryValue = File | string; + +/** + * Provides a way to easily construct a set of key/value pairs representing + * form fields and their values. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/FormData) + * @group File API + */ +interface FormData { + append(name: string, value: string | Blob, fileName?: string): void; + delete(name: string): void; + get(name: string): FormDataEntryValue | null; + getAll(name: string): FormDataEntryValue[]; + has(name: string): boolean; + set(name: string, value: string | Blob, fileName?: string): void; + forEach( + callbackfn: (value: FormDataEntryValue, key: string, parent: FormData) => void, + thisArg?: any, + ): void; + entries(): IterableIterator<[string, FormDataEntryValue]>; + keys(): IterableIterator; + values(): IterableIterator; + [Symbol.iterator](): IterableIterator<[string, FormDataEntryValue]>; +} + +/** + * @group File API + */ +declare var FormData: { + prototype: FormData; + new (): FormData; +}; + 0; diff --git a/types/index.d.ts b/types/index.d.ts index 6d5f977..51eb607 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -2,6 +2,7 @@ /// /// /// +/// /// export * from './server/static-assets/index.d.ts';