@voyant-travel/runtime 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +54 -0
  2. package/dist/api-dispatch.d.ts +44 -0
  3. package/dist/api-dispatch.d.ts.map +1 -0
  4. package/dist/api-dispatch.js +64 -0
  5. package/dist/env.d.ts +33 -0
  6. package/dist/env.d.ts.map +1 -0
  7. package/dist/env.js +30 -0
  8. package/dist/env.test.d.ts +2 -0
  9. package/dist/env.test.d.ts.map +1 -0
  10. package/dist/env.test.js +21 -0
  11. package/dist/index.d.ts +21 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +10 -0
  14. package/dist/memory-kv.d.ts +49 -0
  15. package/dist/memory-kv.d.ts.map +1 -0
  16. package/dist/memory-kv.js +69 -0
  17. package/dist/memory-kv.test.d.ts +2 -0
  18. package/dist/memory-kv.test.d.ts.map +1 -0
  19. package/dist/memory-kv.test.js +44 -0
  20. package/dist/memory-r2.d.ts +4 -0
  21. package/dist/memory-r2.d.ts.map +1 -0
  22. package/dist/memory-r2.js +76 -0
  23. package/dist/memory-r2.test.d.ts +2 -0
  24. package/dist/memory-r2.test.d.ts.map +1 -0
  25. package/dist/memory-r2.test.js +45 -0
  26. package/dist/node-server.d.ts +56 -0
  27. package/dist/node-server.d.ts.map +1 -0
  28. package/dist/node-server.js +74 -0
  29. package/dist/node-server.test.d.ts +2 -0
  30. package/dist/node-server.test.d.ts.map +1 -0
  31. package/dist/node-server.test.js +86 -0
  32. package/dist/r2.d.ts +41 -0
  33. package/dist/r2.d.ts.map +1 -0
  34. package/dist/r2.js +147 -0
  35. package/dist/r2.test.d.ts +2 -0
  36. package/dist/r2.test.d.ts.map +1 -0
  37. package/dist/r2.test.js +81 -0
  38. package/dist/ssr-manifest.d.ts +51 -0
  39. package/dist/ssr-manifest.d.ts.map +1 -0
  40. package/dist/ssr-manifest.js +49 -0
  41. package/dist/trust.d.ts +31 -0
  42. package/dist/trust.d.ts.map +1 -0
  43. package/dist/trust.js +49 -0
  44. package/dist/trust.test.d.ts +2 -0
  45. package/dist/trust.test.d.ts.map +1 -0
  46. package/dist/trust.test.js +42 -0
  47. package/dist/types.d.ts +54 -0
  48. package/dist/types.d.ts.map +1 -0
  49. package/dist/types.js +1 -0
  50. package/dist/wait-until.d.ts +25 -0
  51. package/dist/wait-until.d.ts.map +1 -0
  52. package/dist/wait-until.js +50 -0
  53. package/dist/wait-until.test.d.ts +2 -0
  54. package/dist/wait-until.test.d.ts.map +1 -0
  55. package/dist/wait-until.test.js +40 -0
  56. package/dist/worker-fetch.d.ts +31 -0
  57. package/dist/worker-fetch.d.ts.map +1 -0
  58. package/dist/worker-fetch.js +37 -0
  59. package/package.json +114 -0
package/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # @voyant-travel/runtime
2
+
3
+ The runtime glue a Voyant app entry uses — request dispatch **and** the Node
4
+ server it runs on — as one versioned package instead of copied template files.
5
+ Merges the former `@voyant-travel/worker-runtime` (dispatch) and
6
+ `@voyant-travel/dedicated-runtime` (Node server + providers): with Node the
7
+ first-class runtime (voyant#2966), "worker"/"dedicated" were both stale names for
8
+ what is simply the runtime.
9
+
10
+ ## Request dispatch (runtime-neutral)
11
+
12
+ The `fetch`/API/SSR routing an app entry composes. Reused verbatim on Node and
13
+ Cloudflare Workers.
14
+
15
+ - `createWorkerFetch({ api, ssr })` — the `(request, env, ctx)` entry that routes
16
+ `/api/*` to the API dispatch and everything else to SSR.
17
+ - `lazySsr(() => import("./ssr-handler"))` — memoize the dynamic import of the
18
+ TanStack Start server graph (React + `react-dom/server`) so it stays out of the
19
+ module's startup cost.
20
+ - `createApiDispatch` / `lazyApp` — prefix-routed `/api/*` app dispatch with a
21
+ memoized loader.
22
+ - `restrictSsrManifestToActiveRoutes` / `withActiveRouteSsrManifest` — trim the
23
+ TanStack SSR preload manifest to matched routes.
24
+
25
+ ## Node runtime
26
+
27
+ The resident-process server plus the real providers it wires. See
28
+ [docs/architecture/deployment-targets.md](../../docs/architecture/deployment-targets.md).
29
+
30
+ - `createNodeServer({ fetch, scheduled, env, originTrustSecret })` — boots a Node
31
+ HTTP server (`@hono/node-server`) running the app's `fetch` unchanged, adding a
32
+ real per-request `waitUntil` (background work tracked + drained on shutdown), an
33
+ origin-trust gate, an HTTP `scheduled()` hook at `POST /__voyant/scheduled`, and
34
+ graceful SIGTERM/SIGINT drain.
35
+ - `composeNodeEnv(process.env, { kv, r2 })` — assemble the env bag app code reads
36
+ (`env.CACHE`, `env.MEDIA_BUCKET`, …) from string vars + real provider objects.
37
+ - `createMemoryKvNamespace()` — in-process KV (`Map` + TTL + LRU) for
38
+ `CACHE`/`RATE_LIMIT` in a single resident process.
39
+ - `createR2BucketShim({ endpoint, bucket, … })` — S3-compatible object store for
40
+ production; `createMemoryR2Bucket()` — in-process store for dev/offline.
41
+ - `originTrustMiddleware` / `verifyOriginTrust` / `constantTimeEqual` — the
42
+ `x-voyant-origin-trust` gate the platform dispatcher stamps.
43
+ - `createWaitUntilRegistry()` — the in-process `waitUntil` registry + drain.
44
+
45
+ ## Out of scope
46
+
47
+ - **Cache API (`caches.default`)** — not shimmed; the public-cache middleware
48
+ reads `env.CACHE` KV directly on Node.
49
+ - **Distributed KV / object store** — the in-process providers are single-process;
50
+ a multi-instance deployment swaps them for a shared KV/S3 provider behind the
51
+ same interface (platform#940). The S3-backed `createR2BucketShim` covers durable
52
+ object storage.
53
+ - **Durable Objects, Analytics Engine, `request.cf`** — no in-process equivalent;
54
+ app code tolerates their absence on Node.
@@ -0,0 +1,44 @@
1
+ import type { AppLoader, FetchApp, WaitUntilContext } from "./types.js";
2
+ /**
3
+ * Memoize an app loader so the underlying dynamic `import()` runs once per
4
+ * isolate, no matter how many requests race on it.
5
+ */
6
+ export declare function lazyApp<Env, Ctx extends WaitUntilContext = WaitUntilContext>(load: () => Promise<FetchApp<Env, Ctx>>): AppLoader<Env, Ctx>;
7
+ export interface CreateApiDispatchOptions<Env, Ctx extends WaitUntilContext = WaitUntilContext> {
8
+ /** Loads the full API app (the heavy module graph). Wrap with {@link lazyApp}. */
9
+ loadApiApp: AppLoader<Env, Ctx>;
10
+ /**
11
+ * Optional lean auth app. When set, requests under `authPrefix` dispatch to
12
+ * it WITHOUT loading the full API graph — the fix for the cold-start outage
13
+ * where the first `/api/auth/*` call instantiated the whole API and hung.
14
+ * Set `warmApiOnAuth` to opt into background API warm-up after auth traffic.
15
+ */
16
+ loadAuthApp?: AppLoader<Env, Ctx>;
17
+ /** Hosting prefix stripped before dispatch. Default `/api`. */
18
+ apiPrefix?: string;
19
+ /**
20
+ * Optional post-strip app-path rewrite. Runs after `/api` is removed and
21
+ * before the request is forwarded to the app.
22
+ */
23
+ rewriteAppPath?: (pathname: string) => string;
24
+ /** Auth sub-prefix served by the lean app. Default `${apiPrefix}/auth`. */
25
+ authPrefix?: string;
26
+ /** Background-warm the full app on auth traffic. Default false. */
27
+ warmApiOnAuth?: boolean;
28
+ /** Called when the background warm-up fails. Defaults to `console.error`. */
29
+ onWarmError?: (error: unknown) => void;
30
+ }
31
+ export interface ApiDispatch<Env, Ctx extends WaitUntilContext = WaitUntilContext> {
32
+ isApiRequest(pathname: string): boolean;
33
+ isAuthRequest(pathname: string): boolean;
34
+ /** Strip the hosting prefix (`/api/v1/x` → `/v1/x`), preserving search/body. */
35
+ toAppRequest(request: Request): Request;
36
+ dispatch(request: Request, env: Env, ctx: Ctx): Promise<Response>;
37
+ }
38
+ /**
39
+ * Prefix-routed dispatch from a hosting Worker URL space (`/api/*`) onto a
40
+ * Hono-style app surface (`/v1/*`, `/auth/*`, `/health`). Framework-owned:
41
+ * apps supply only the loaders for their own modules.
42
+ */
43
+ export declare function createApiDispatch<Env, Ctx extends WaitUntilContext = WaitUntilContext>(options: CreateApiDispatchOptions<Env, Ctx>): ApiDispatch<Env, Ctx>;
44
+ //# sourceMappingURL=api-dispatch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api-dispatch.d.ts","sourceRoot":"","sources":["../src/api-dispatch.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAA;AAIvE;;;GAGG;AACH,wBAAgB,OAAO,CAAC,GAAG,EAAE,GAAG,SAAS,gBAAgB,GAAG,gBAAgB,EAC1E,IAAI,EAAE,MAAM,OAAO,CAAC,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,GACtC,SAAS,CAAC,GAAG,EAAE,GAAG,CAAC,CAMrB;AAED,MAAM,WAAW,wBAAwB,CAAC,GAAG,EAAE,GAAG,SAAS,gBAAgB,GAAG,gBAAgB;IAC5F,kFAAkF;IAClF,UAAU,EAAE,SAAS,CAAC,GAAG,EAAE,GAAG,CAAC,CAAA;IAC/B;;;;;OAKG;IACH,WAAW,CAAC,EAAE,SAAS,CAAC,GAAG,EAAE,GAAG,CAAC,CAAA;IACjC,+DAA+D;IAC/D,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;OAGG;IACH,cAAc,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,CAAA;IAC7C,2EAA2E;IAC3E,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,mEAAmE;IACnE,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,6EAA6E;IAC7E,WAAW,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAA;CACvC;AAED,MAAM,WAAW,WAAW,CAAC,GAAG,EAAE,GAAG,SAAS,gBAAgB,GAAG,gBAAgB;IAC/E,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAA;IACvC,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAA;IACxC,gFAAgF;IAChF,YAAY,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAA;IACvC,QAAQ,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;CAClE;AAMD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,GAAG,SAAS,gBAAgB,GAAG,gBAAgB,EACpF,OAAO,EAAE,wBAAwB,CAAC,GAAG,EAAE,GAAG,CAAC,GAC1C,WAAW,CAAC,GAAG,EAAE,GAAG,CAAC,CAiDvB"}
@@ -0,0 +1,64 @@
1
+ const DEFAULT_API_PREFIX = "/api";
2
+ /**
3
+ * Memoize an app loader so the underlying dynamic `import()` runs once per
4
+ * isolate, no matter how many requests race on it.
5
+ */
6
+ export function lazyApp(load) {
7
+ let promise;
8
+ return () => {
9
+ promise ??= load();
10
+ return promise;
11
+ };
12
+ }
13
+ function matchesPrefix(pathname, prefix) {
14
+ return pathname === prefix || pathname.startsWith(`${prefix}/`);
15
+ }
16
+ /**
17
+ * Prefix-routed dispatch from a hosting Worker URL space (`/api/*`) onto a
18
+ * Hono-style app surface (`/v1/*`, `/auth/*`, `/health`). Framework-owned:
19
+ * apps supply only the loaders for their own modules.
20
+ */
21
+ export function createApiDispatch(options) {
22
+ const apiPrefix = options.apiPrefix ?? DEFAULT_API_PREFIX;
23
+ const authPrefix = options.authPrefix ?? `${apiPrefix}/auth`;
24
+ const warmApiOnAuth = options.warmApiOnAuth ?? false;
25
+ const onWarmError = options.onWarmError ??
26
+ ((error) => {
27
+ console.error("[worker-runtime] background API warm failed:", error);
28
+ });
29
+ function toAppRequest(request) {
30
+ const url = new URL(request.url);
31
+ const stripped = url.pathname.slice(apiPrefix.length) || "/";
32
+ const appPath = options.rewriteAppPath?.(stripped) ?? stripped;
33
+ const appUrl = new URL(appPath, url.origin);
34
+ appUrl.search = url.search;
35
+ const bodyless = request.method === "GET" || request.method === "HEAD";
36
+ return new Request(appUrl.toString(), {
37
+ method: request.method,
38
+ headers: request.headers,
39
+ body: bodyless ? null : request.body,
40
+ redirect: request.redirect,
41
+ signal: request.signal,
42
+ ...(bodyless ? {} : { duplex: "half" }),
43
+ });
44
+ }
45
+ async function dispatch(request, env, ctx) {
46
+ const { loadApiApp, loadAuthApp } = options;
47
+ if (loadAuthApp && matchesPrefix(new URL(request.url).pathname, authPrefix)) {
48
+ const authApp = await loadAuthApp();
49
+ const response = await authApp.fetch(toAppRequest(request), env, ctx);
50
+ if (warmApiOnAuth && request.method !== "OPTIONS") {
51
+ ctx.waitUntil(loadApiApp().then(() => undefined, onWarmError));
52
+ }
53
+ return response;
54
+ }
55
+ const apiApp = await loadApiApp();
56
+ return apiApp.fetch(toAppRequest(request), env, ctx);
57
+ }
58
+ return {
59
+ isApiRequest: (pathname) => matchesPrefix(pathname, apiPrefix),
60
+ isAuthRequest: (pathname) => matchesPrefix(pathname, authPrefix),
61
+ toAppRequest,
62
+ dispatch,
63
+ };
64
+ }
package/dist/env.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ import type { KvNamespaceShim } from "./memory-kv.js";
2
+ import type { R2BucketShim } from "./r2.js";
3
+ export interface NodeEnvBindings {
4
+ /** KV namespace bindings, keyed by the binding name app code reads (e.g. `CACHE`). */
5
+ kv?: Record<string, KvNamespaceShim>;
6
+ /** R2 bucket bindings, keyed by the binding name (e.g. `DOCUMENTS_BUCKET`). */
7
+ r2?: Record<string, R2BucketShim>;
8
+ }
9
+ /**
10
+ * The env bag handed to `app.fetch(request, env, ctx)`. On Workers this is the
11
+ * bindings object the runtime injects; on Node we compose it from string
12
+ * process-env vars plus the real provider objects so app code sees the same
13
+ * shape (`env.CACHE`, `env.MEDIA_BUCKET`, …) without any Workers emulation.
14
+ */
15
+ export type NodeEnv = Record<string, string | KvNamespaceShim | R2BucketShim>;
16
+ /**
17
+ * Compose the env bag for a Node deployment. All string vars from `processEnv`
18
+ * (undefined values dropped) are spread first, then the KV and R2 providers are
19
+ * attached under their binding names — the same shape the Workers runtime used
20
+ * to inject, so the same `fetch(req, env, ctx)` runs unchanged. Unlike the old
21
+ * bindings emulation, the attached providers are real in-process / S3-backed
22
+ * stores, not shims over a Cloudflare binding.
23
+ *
24
+ * Binding names must not collide with a string var of the same name; the
25
+ * provider wins (a KV/R2 binding is never legitimately also a string).
26
+ *
27
+ * The `Env` type parameter lets a deployment present the bag as its own bindings
28
+ * interface (e.g. the operator's `CloudflareBindings`) at the boundary without a
29
+ * cast at the call site — the runtime bag genuinely carries those string vars +
30
+ * providers.
31
+ */
32
+ export declare function composeNodeEnv<Env = NodeEnv>(processEnv: Record<string, string | undefined>, bindings?: NodeEnvBindings): Env;
33
+ //# sourceMappingURL=env.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"env.d.ts","sourceRoot":"","sources":["../src/env.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAA;AACrD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAA;AAE3C,MAAM,WAAW,eAAe;IAC9B,sFAAsF;IACtF,EAAE,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAAA;IACpC,+EAA+E;IAC/E,EAAE,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAAA;CAClC;AAED;;;;;GAKG;AACH,MAAM,MAAM,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,eAAe,GAAG,YAAY,CAAC,CAAA;AAE7E;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,cAAc,CAAC,GAAG,GAAG,OAAO,EAC1C,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,EAC9C,QAAQ,GAAE,eAAoB,GAC7B,GAAG,CAYL"}
package/dist/env.js ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Compose the env bag for a Node deployment. All string vars from `processEnv`
3
+ * (undefined values dropped) are spread first, then the KV and R2 providers are
4
+ * attached under their binding names — the same shape the Workers runtime used
5
+ * to inject, so the same `fetch(req, env, ctx)` runs unchanged. Unlike the old
6
+ * bindings emulation, the attached providers are real in-process / S3-backed
7
+ * stores, not shims over a Cloudflare binding.
8
+ *
9
+ * Binding names must not collide with a string var of the same name; the
10
+ * provider wins (a KV/R2 binding is never legitimately also a string).
11
+ *
12
+ * The `Env` type parameter lets a deployment present the bag as its own bindings
13
+ * interface (e.g. the operator's `CloudflareBindings`) at the boundary without a
14
+ * cast at the call site — the runtime bag genuinely carries those string vars +
15
+ * providers.
16
+ */
17
+ export function composeNodeEnv(processEnv, bindings = {}) {
18
+ const env = {};
19
+ for (const [key, value] of Object.entries(processEnv)) {
20
+ if (value !== undefined)
21
+ env[key] = value;
22
+ }
23
+ for (const [name, provider] of Object.entries(bindings.kv ?? {})) {
24
+ env[name] = provider;
25
+ }
26
+ for (const [name, provider] of Object.entries(bindings.r2 ?? {})) {
27
+ env[name] = provider;
28
+ }
29
+ return env;
30
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=env.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"env.test.d.ts","sourceRoot":"","sources":["../src/env.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { composeNodeEnv } from "./env.js";
3
+ const fakeKv = {};
4
+ const fakeR2 = {};
5
+ describe("composeNodeEnv", () => {
6
+ it("spreads defined string vars and drops undefined", () => {
7
+ const env = composeNodeEnv({ DATABASE_URL: "postgres://x", MISSING: undefined });
8
+ expect(env.DATABASE_URL).toBe("postgres://x");
9
+ expect("MISSING" in env).toBe(false);
10
+ });
11
+ it("attaches KV and R2 providers under their binding names", () => {
12
+ const env = composeNodeEnv({ FOO: "bar" }, { kv: { CACHE: fakeKv }, r2: { DOCUMENTS_BUCKET: fakeR2 } });
13
+ expect(env.FOO).toBe("bar");
14
+ expect(env.CACHE).toBe(fakeKv);
15
+ expect(env.DOCUMENTS_BUCKET).toBe(fakeR2);
16
+ });
17
+ it("lets a binding provider win over a same-named string var", () => {
18
+ const env = composeNodeEnv({ CACHE: "should-be-overwritten" }, { kv: { CACHE: fakeKv } });
19
+ expect(env.CACHE).toBe(fakeKv);
20
+ });
21
+ });
@@ -0,0 +1,21 @@
1
+ export type { ApiDispatch, CreateApiDispatchOptions } from "./api-dispatch.js";
2
+ export { createApiDispatch, lazyApp } from "./api-dispatch.js";
3
+ export type { NodeEnv, NodeEnvBindings } from "./env.js";
4
+ export { composeNodeEnv } from "./env.js";
5
+ export type { KvGetOptions, KvNamespaceShim, KvPutOptions, KvValueType, MemoryKvOptions, } from "./memory-kv.js";
6
+ export { createMemoryKvNamespace } from "./memory-kv.js";
7
+ export { createMemoryR2Bucket } from "./memory-r2.js";
8
+ export type { CreateNodeServerOptions, NodeServerHandle, ScheduledHandlerArgs, } from "./node-server.js";
9
+ export { createNodeServer, HEALTHZ_PATH, SCHEDULED_PATH, scheduledHandler, } from "./node-server.js";
10
+ export type { R2BucketShim, R2BucketShimOptions, R2Fetch, R2ShimObject, } from "./r2.js";
11
+ export { createR2BucketShim } from "./r2.js";
12
+ export type { SsrManifest, SsrManifestRouter } from "./ssr-manifest.js";
13
+ export { restrictSsrManifestToActiveRoutes, withActiveRouteSsrManifest } from "./ssr-manifest.js";
14
+ export type { OriginTrustOptions } from "./trust.js";
15
+ export { constantTimeEqual, ORIGIN_TRUST_HEADER, originTrustMiddleware, verifyOriginTrust, } from "./trust.js";
16
+ export type { AppLoader, ExecutionContextLike, FetchApp, FetchHandler, ScheduledEventLike, ScheduledHandler, WaitUntilContext, } from "./types.js";
17
+ export type { WaitUntilRegistry } from "./wait-until.js";
18
+ export { createWaitUntilRegistry } from "./wait-until.js";
19
+ export type { CreateWorkerFetchOptions, SsrHandler, SsrLoader } from "./worker-fetch.js";
20
+ export { createWorkerFetch, lazySsr } from "./worker-fetch.js";
21
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,YAAY,EAAE,WAAW,EAAE,wBAAwB,EAAE,MAAM,mBAAmB,CAAA;AAC9E,OAAO,EAAE,iBAAiB,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAA;AAE9D,YAAY,EAAE,OAAO,EAAE,eAAe,EAAE,MAAM,UAAU,CAAA;AACxD,OAAO,EAAE,cAAc,EAAE,MAAM,UAAU,CAAA;AACzC,YAAY,EACV,YAAY,EACZ,eAAe,EACf,YAAY,EACZ,WAAW,EACX,eAAe,GAChB,MAAM,gBAAgB,CAAA;AACvB,OAAO,EAAE,uBAAuB,EAAE,MAAM,gBAAgB,CAAA;AACxD,OAAO,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAA;AACrD,YAAY,EACV,uBAAuB,EACvB,gBAAgB,EAChB,oBAAoB,GACrB,MAAM,kBAAkB,CAAA;AACzB,OAAO,EACL,gBAAgB,EAChB,YAAY,EACZ,cAAc,EACd,gBAAgB,GACjB,MAAM,kBAAkB,CAAA;AACzB,YAAY,EACV,YAAY,EACZ,mBAAmB,EACnB,OAAO,EACP,YAAY,GACb,MAAM,SAAS,CAAA;AAChB,OAAO,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAA;AAC5C,YAAY,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAA;AACvE,OAAO,EAAE,iCAAiC,EAAE,0BAA0B,EAAE,MAAM,mBAAmB,CAAA;AACjG,YAAY,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAA;AACpD,OAAO,EACL,iBAAiB,EACjB,mBAAmB,EACnB,qBAAqB,EACrB,iBAAiB,GAClB,MAAM,YAAY,CAAA;AAEnB,YAAY,EACV,SAAS,EACT,oBAAoB,EACpB,QAAQ,EACR,YAAY,EACZ,kBAAkB,EAClB,gBAAgB,EAChB,gBAAgB,GACjB,MAAM,YAAY,CAAA;AACnB,YAAY,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAA;AACxD,OAAO,EAAE,uBAAuB,EAAE,MAAM,iBAAiB,CAAA;AACzD,YAAY,EAAE,wBAAwB,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAA;AACxF,OAAO,EAAE,iBAAiB,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ export { createApiDispatch, lazyApp } from "./api-dispatch.js";
2
+ export { composeNodeEnv } from "./env.js";
3
+ export { createMemoryKvNamespace } from "./memory-kv.js";
4
+ export { createMemoryR2Bucket } from "./memory-r2.js";
5
+ export { createNodeServer, HEALTHZ_PATH, SCHEDULED_PATH, scheduledHandler, } from "./node-server.js";
6
+ export { createR2BucketShim } from "./r2.js";
7
+ export { restrictSsrManifestToActiveRoutes, withActiveRouteSsrManifest } from "./ssr-manifest.js";
8
+ export { constantTimeEqual, ORIGIN_TRUST_HEADER, originTrustMiddleware, verifyOriginTrust, } from "./trust.js";
9
+ export { createWaitUntilRegistry } from "./wait-until.js";
10
+ export { createWorkerFetch, lazySsr } from "./worker-fetch.js";
@@ -0,0 +1,49 @@
1
+ /**
2
+ * An in-process `KVNamespace`-shaped store for a resident Node deployment.
3
+ *
4
+ * On Cloudflare Workers the `KV` binding is injected by the runtime; on a
5
+ * Node-only operator there is no binding and — with no Workers lane — nothing
6
+ * to emulate. A resident process instead holds its own map, so hot reads never
7
+ * leave the process. The surface is kept identical to the binding the operator
8
+ * packages already use (`env.CACHE` / `env.RATE_LIMIT`): `get(key)`,
9
+ * `get(key, "json")`, `put(key, value, { expirationTtl })`, `delete(key)` — so
10
+ * the same middleware (`publicResponseCache`, `rateLimit`, auth caches) runs
11
+ * unchanged against `KVStore` (`@voyant-travel/utils`).
12
+ *
13
+ * This is a single-process store: entries live only in the process that wrote
14
+ * them. That is the correct model for the dedicated per-app runtime (one
15
+ * resident process per deployment). A future multi-instance deployment swaps
16
+ * this for a shared KV/Redis provider behind the same interface (platform#940).
17
+ */
18
+ export type KvValueType = "text" | "json";
19
+ export interface KvGetOptions {
20
+ type?: KvValueType;
21
+ }
22
+ export interface KvPutOptions {
23
+ /** TTL in seconds — the entry is treated as absent once it elapses. */
24
+ expirationTtl?: number;
25
+ }
26
+ /** Minimal `KVNamespace` surface consumed across the operator packages. */
27
+ export interface KvNamespaceShim {
28
+ get(key: string): Promise<string | null>;
29
+ get<T = unknown>(key: string, type: "json"): Promise<T | null>;
30
+ get<T = unknown>(key: string, options: KvGetOptions): Promise<T | string | null>;
31
+ put(key: string, value: string, options?: KvPutOptions): Promise<void>;
32
+ delete(key: string): Promise<void>;
33
+ }
34
+ export interface MemoryKvOptions {
35
+ /**
36
+ * Cap on resident entries before least-recently-used eviction. A resident
37
+ * process is long-lived, so an unbounded map would grow without limit;
38
+ * defaults to 10,000. Set `0` to disable eviction (unbounded).
39
+ */
40
+ maxEntries?: number;
41
+ /** Injectable clock (ms) for deterministic TTL tests. Defaults to `Date.now`. */
42
+ now?: () => number;
43
+ }
44
+ /**
45
+ * Build an in-process {@link KvNamespaceShim}. Backed by a `Map` with per-entry
46
+ * TTL and LRU eviction — the real Node KV, not a shim over a remote binding.
47
+ */
48
+ export declare function createMemoryKvNamespace(options?: MemoryKvOptions): KvNamespaceShim;
49
+ //# sourceMappingURL=memory-kv.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"memory-kv.d.ts","sourceRoot":"","sources":["../src/memory-kv.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,MAAM,MAAM,WAAW,GAAG,MAAM,GAAG,MAAM,CAAA;AAEzC,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,EAAE,WAAW,CAAA;CACnB;AAED,MAAM,WAAW,YAAY;IAC3B,uEAAuE;IACvE,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB;AAED,2EAA2E;AAC3E,MAAM,WAAW,eAAe;IAC9B,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAA;IACxC,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAAA;IAC9D,GAAG,CAAC,CAAC,GAAG,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,CAAC,GAAG,MAAM,GAAG,IAAI,CAAC,CAAA;IAChF,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACtE,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CACnC;AAED,MAAM,WAAW,eAAe;IAC9B;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,iFAAiF;IACjF,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB;AAUD;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,GAAE,eAAoB,GAAG,eAAe,CAiDtF"}
@@ -0,0 +1,69 @@
1
+ /**
2
+ * An in-process `KVNamespace`-shaped store for a resident Node deployment.
3
+ *
4
+ * On Cloudflare Workers the `KV` binding is injected by the runtime; on a
5
+ * Node-only operator there is no binding and — with no Workers lane — nothing
6
+ * to emulate. A resident process instead holds its own map, so hot reads never
7
+ * leave the process. The surface is kept identical to the binding the operator
8
+ * packages already use (`env.CACHE` / `env.RATE_LIMIT`): `get(key)`,
9
+ * `get(key, "json")`, `put(key, value, { expirationTtl })`, `delete(key)` — so
10
+ * the same middleware (`publicResponseCache`, `rateLimit`, auth caches) runs
11
+ * unchanged against `KVStore` (`@voyant-travel/utils`).
12
+ *
13
+ * This is a single-process store: entries live only in the process that wrote
14
+ * them. That is the correct model for the dedicated per-app runtime (one
15
+ * resident process per deployment). A future multi-instance deployment swaps
16
+ * this for a shared KV/Redis provider behind the same interface (platform#940).
17
+ */
18
+ const DEFAULT_MAX_ENTRIES = 10_000;
19
+ /**
20
+ * Build an in-process {@link KvNamespaceShim}. Backed by a `Map` with per-entry
21
+ * TTL and LRU eviction — the real Node KV, not a shim over a remote binding.
22
+ */
23
+ export function createMemoryKvNamespace(options = {}) {
24
+ const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
25
+ const now = options.now ?? Date.now;
26
+ const map = new Map();
27
+ function readFresh(key) {
28
+ const entry = map.get(key);
29
+ if (!entry)
30
+ return null;
31
+ if (entry.expiresAt <= now()) {
32
+ map.delete(key);
33
+ return null;
34
+ }
35
+ // Refresh recency for LRU.
36
+ map.delete(key);
37
+ map.set(key, entry);
38
+ return entry.value;
39
+ }
40
+ async function get(key, typeOrOptions) {
41
+ const type = typeof typeOrOptions === "string" ? typeOrOptions : (typeOrOptions?.type ?? "text");
42
+ const raw = readFresh(key);
43
+ if (raw === null)
44
+ return null;
45
+ return type === "json" ? JSON.parse(raw) : raw;
46
+ }
47
+ return {
48
+ get: get,
49
+ async put(key, value, putOptions) {
50
+ if (map.has(key))
51
+ map.delete(key);
52
+ const expiresAt = putOptions?.expirationTtl === undefined
53
+ ? Number.POSITIVE_INFINITY
54
+ : now() + putOptions.expirationTtl * 1000;
55
+ map.set(key, { value, expiresAt });
56
+ if (maxEntries > 0) {
57
+ while (map.size > maxEntries) {
58
+ const oldest = map.keys().next().value;
59
+ if (oldest === undefined)
60
+ break;
61
+ map.delete(oldest);
62
+ }
63
+ }
64
+ },
65
+ async delete(key) {
66
+ map.delete(key);
67
+ },
68
+ };
69
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=memory-kv.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"memory-kv.test.d.ts","sourceRoot":"","sources":["../src/memory-kv.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createMemoryKvNamespace } from "./memory-kv.js";
3
+ describe("createMemoryKvNamespace", () => {
4
+ it("puts and gets text values", async () => {
5
+ const kv = createMemoryKvNamespace();
6
+ await kv.put("k", "v");
7
+ expect(await kv.get("k")).toBe("v");
8
+ });
9
+ it("returns null for missing keys", async () => {
10
+ const kv = createMemoryKvNamespace();
11
+ expect(await kv.get("nope")).toBeNull();
12
+ });
13
+ it("parses JSON with the json type", async () => {
14
+ const kv = createMemoryKvNamespace();
15
+ await kv.put("obj", JSON.stringify({ a: 1 }));
16
+ expect(await kv.get("obj", "json")).toEqual({ a: 1 });
17
+ expect(await kv.get("obj", { type: "json" })).toEqual({ a: 1 });
18
+ });
19
+ it("deletes keys", async () => {
20
+ const kv = createMemoryKvNamespace();
21
+ await kv.put("k", "v");
22
+ await kv.delete("k");
23
+ expect(await kv.get("k")).toBeNull();
24
+ });
25
+ it("expires entries after expirationTtl seconds", async () => {
26
+ let clock = 1_000;
27
+ const kv = createMemoryKvNamespace({ now: () => clock });
28
+ await kv.put("k", "v", { expirationTtl: 30 });
29
+ expect(await kv.get("k")).toBe("v");
30
+ clock += 30_001;
31
+ expect(await kv.get("k")).toBeNull();
32
+ });
33
+ it("evicts least-recently-used entries past maxEntries", async () => {
34
+ const kv = createMemoryKvNamespace({ maxEntries: 2 });
35
+ await kv.put("a", "1");
36
+ await kv.put("b", "2");
37
+ // Touch "a" so "b" becomes the LRU victim.
38
+ await kv.get("a");
39
+ await kv.put("c", "3");
40
+ expect(await kv.get("a")).toBe("1");
41
+ expect(await kv.get("b")).toBeNull();
42
+ expect(await kv.get("c")).toBe("3");
43
+ });
44
+ });
@@ -0,0 +1,4 @@
1
+ import type { R2BucketShim } from "./r2.js";
2
+ /** Build an in-memory {@link R2BucketShim} for local/dev runs. */
3
+ export declare function createMemoryR2Bucket(): R2BucketShim;
4
+ //# sourceMappingURL=memory-r2.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"memory-r2.d.ts","sourceRoot":"","sources":["../src/memory-r2.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAgB,MAAM,SAAS,CAAA;AAwDzD,kEAAkE;AAClE,wBAAgB,oBAAoB,IAAI,YAAY,CA8BnD"}
@@ -0,0 +1,76 @@
1
+ function bytesToStream(bytes) {
2
+ return new ReadableStream({
3
+ start(controller) {
4
+ controller.enqueue(bytes);
5
+ controller.close();
6
+ },
7
+ });
8
+ }
9
+ async function toBytes(value) {
10
+ if (value === null)
11
+ return new Uint8Array();
12
+ if (typeof value === "string")
13
+ return new TextEncoder().encode(value);
14
+ if (value instanceof Uint8Array)
15
+ return value;
16
+ if (value instanceof ArrayBuffer)
17
+ return new Uint8Array(value);
18
+ if (ArrayBuffer.isView(value)) {
19
+ return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
20
+ }
21
+ if (value instanceof Blob)
22
+ return new Uint8Array(await value.arrayBuffer());
23
+ return new Uint8Array(await new Response(value).arrayBuffer());
24
+ }
25
+ function toObject(stored) {
26
+ const { bytes } = stored;
27
+ const object = {
28
+ // Copy only this view's bytes — `bytes` may be a subarray of a larger
29
+ // backing buffer (e.g. a Node Buffer passed through `StorageProvider.upload`),
30
+ // so returning the whole `.buffer` would leak unrelated bytes.
31
+ arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
32
+ body: bytesToStream(bytes),
33
+ size: bytes.byteLength,
34
+ };
35
+ if (stored.contentType)
36
+ object.httpMetadata = { contentType: stored.contentType };
37
+ if (stored.customMetadata)
38
+ object.customMetadata = stored.customMetadata;
39
+ return object;
40
+ }
41
+ /** Build an in-memory {@link R2BucketShim} for local/dev runs. */
42
+ export function createMemoryR2Bucket() {
43
+ const map = new Map();
44
+ return {
45
+ async put(key, value, putOptions) {
46
+ const stored = { bytes: await toBytes(value) };
47
+ if (putOptions?.httpMetadata?.contentType) {
48
+ stored.contentType = putOptions.httpMetadata.contentType;
49
+ }
50
+ if (putOptions?.customMetadata)
51
+ stored.customMetadata = putOptions.customMetadata;
52
+ map.set(key, stored);
53
+ return { key };
54
+ },
55
+ async delete(key) {
56
+ const keys = Array.isArray(key) ? key : [key];
57
+ for (const k of keys)
58
+ map.delete(k);
59
+ },
60
+ async get(key) {
61
+ const stored = map.get(key);
62
+ return stored ? toObject(stored) : null;
63
+ },
64
+ async head(key) {
65
+ const stored = map.get(key);
66
+ if (!stored)
67
+ return null;
68
+ const meta = { size: stored.bytes.byteLength };
69
+ if (stored.contentType)
70
+ meta.httpMetadata = { contentType: stored.contentType };
71
+ if (stored.customMetadata)
72
+ meta.customMetadata = stored.customMetadata;
73
+ return meta;
74
+ },
75
+ };
76
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=memory-r2.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"memory-r2.test.d.ts","sourceRoot":"","sources":["../src/memory-r2.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createMemoryR2Bucket } from "./memory-r2.js";
3
+ describe("createMemoryR2Bucket", () => {
4
+ it("puts and gets an object with metadata", async () => {
5
+ const bucket = createMemoryR2Bucket();
6
+ await bucket.put("docs/a.txt", "hello", {
7
+ httpMetadata: { contentType: "text/plain" },
8
+ customMetadata: { owner: "u1" },
9
+ });
10
+ const object = await bucket.get("docs/a.txt");
11
+ expect(object).not.toBeNull();
12
+ expect(new TextDecoder().decode(await object.arrayBuffer())).toBe("hello");
13
+ expect(object.httpMetadata?.contentType).toBe("text/plain");
14
+ expect(object.customMetadata?.owner).toBe("u1");
15
+ expect(object.size).toBe(5);
16
+ });
17
+ it("returns only the stored view's bytes when put a subarray of a larger buffer", async () => {
18
+ const bucket = createMemoryR2Bucket();
19
+ const backing = new Uint8Array([1, 2, 3, 4, 5, 6]);
20
+ await bucket.put("k", backing.subarray(2, 4)); // [3, 4], offset into a 6-byte buffer
21
+ const object = await bucket.get("k");
22
+ expect(new Uint8Array(await object.arrayBuffer())).toEqual(new Uint8Array([3, 4]));
23
+ expect(object.size).toBe(2);
24
+ });
25
+ it("returns null for a missing object", async () => {
26
+ const bucket = createMemoryR2Bucket();
27
+ expect(await bucket.get("missing")).toBeNull();
28
+ expect(await bucket.head("missing")).toBeNull();
29
+ });
30
+ it("heads without the body", async () => {
31
+ const bucket = createMemoryR2Bucket();
32
+ await bucket.put("k", "abc");
33
+ const head = await bucket.head("k");
34
+ expect(head?.size).toBe(3);
35
+ });
36
+ it("deletes one and many keys", async () => {
37
+ const bucket = createMemoryR2Bucket();
38
+ await bucket.put("a", "1");
39
+ await bucket.put("b", "2");
40
+ await bucket.delete("a");
41
+ await bucket.delete(["b"]);
42
+ expect(await bucket.get("a")).toBeNull();
43
+ expect(await bucket.get("b")).toBeNull();
44
+ });
45
+ });