@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
@@ -0,0 +1,56 @@
1
+ import { type ServerType } from "@hono/node-server";
2
+ import type { ExecutionContextLike, FetchHandler, ScheduledHandler } from "./types.js";
3
+ import { type WaitUntilRegistry } from "./wait-until.js";
4
+ /** Path that health probes hit — always exempt from the origin-trust gate. */
5
+ export declare const HEALTHZ_PATH = "/healthz";
6
+ /** Trust-protected HTTP hook Cloud Scheduler POSTs to trigger `scheduled()`. */
7
+ export declare const SCHEDULED_PATH = "/__voyant/scheduled";
8
+ export interface ScheduledHandlerArgs<Env> {
9
+ request: Request;
10
+ scheduled: ScheduledHandler<Env>;
11
+ env: Env;
12
+ ctx: ExecutionContextLike;
13
+ }
14
+ /**
15
+ * Invoke a Worker-style `scheduled()` handler from an HTTP request. Reads the
16
+ * cron expression from the `cron` query param. Returns `202` on success, `500`
17
+ * on handler error, `400` when `cron` is missing. Exported standalone so it can
18
+ * be mounted into any server loop, not just {@link createNodeServer}.
19
+ */
20
+ export declare function scheduledHandler<Env>(args: ScheduledHandlerArgs<Env>): Promise<Response>;
21
+ export interface CreateNodeServerOptions<Env> {
22
+ /** The app's Worker-style fetch entrypoint — identical to the Workers build. */
23
+ fetch: FetchHandler<Env>;
24
+ /** Optional Worker-style scheduled handler, exposed at {@link SCHEDULED_PATH}. */
25
+ scheduled?: ScheduledHandler<Env>;
26
+ /** The composed env bag (see `composeNodeEnv`). */
27
+ env: Env;
28
+ /** Listen port. Defaults to `PORT` env or `8080`. */
29
+ port?: number;
30
+ /**
31
+ * Per-app shared secret for the `x-voyant-origin-trust` header. When set,
32
+ * every request except {@link HEALTHZ_PATH} must present a matching header or
33
+ * receives `403`. Leave unset only for a fully private network boundary.
34
+ */
35
+ originTrustSecret?: string;
36
+ /** Milliseconds to wait for in-flight `waitUntil` work on shutdown. Default 10s. */
37
+ drainTimeoutMs?: number;
38
+ }
39
+ export interface NodeServerHandle {
40
+ server: ServerType;
41
+ /** The `waitUntil` registry backing per-request execution contexts. */
42
+ registry: WaitUntilRegistry;
43
+ /** Resolved listen port. */
44
+ port: number;
45
+ /** Stop accepting, drain background work, close. Idempotent. */
46
+ close(): Promise<void>;
47
+ }
48
+ /**
49
+ * Boot a Node HTTP server that runs an operator app's `fetch(request, env, ctx)`
50
+ * unchanged. Adds the pieces Cloudflare provides implicitly: a real per-request
51
+ * `waitUntil` context, an origin-trust gate, an HTTP `scheduled()` hook for
52
+ * Cloud Scheduler, and graceful SIGTERM/SIGINT shutdown that drains in-flight
53
+ * background work before exit.
54
+ */
55
+ export declare function createNodeServer<Env>(options: CreateNodeServerOptions<Env>): NodeServerHandle;
56
+ //# sourceMappingURL=node-server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"node-server.d.ts","sourceRoot":"","sources":["../src/node-server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,UAAU,EAAS,MAAM,mBAAmB,CAAA;AAG1D,OAAO,KAAK,EAAE,oBAAoB,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAA;AACtF,OAAO,EAA2B,KAAK,iBAAiB,EAAE,MAAM,iBAAiB,CAAA;AAEjF,8EAA8E;AAC9E,eAAO,MAAM,YAAY,aAAa,CAAA;AACtC,gFAAgF;AAChF,eAAO,MAAM,cAAc,wBAAwB,CAAA;AAEnD,MAAM,WAAW,oBAAoB,CAAC,GAAG;IACvC,OAAO,EAAE,OAAO,CAAA;IAChB,SAAS,EAAE,gBAAgB,CAAC,GAAG,CAAC,CAAA;IAChC,GAAG,EAAE,GAAG,CAAA;IACR,GAAG,EAAE,oBAAoB,CAAA;CAC1B;AAED;;;;;GAKG;AACH,wBAAsB,gBAAgB,CAAC,GAAG,EAAE,IAAI,EAAE,oBAAoB,CAAC,GAAG,CAAC,GAAG,OAAO,CAAC,QAAQ,CAAC,CAY9F;AAED,MAAM,WAAW,uBAAuB,CAAC,GAAG;IAC1C,gFAAgF;IAChF,KAAK,EAAE,YAAY,CAAC,GAAG,CAAC,CAAA;IACxB,kFAAkF;IAClF,SAAS,CAAC,EAAE,gBAAgB,CAAC,GAAG,CAAC,CAAA;IACjC,mDAAmD;IACnD,GAAG,EAAE,GAAG,CAAA;IACR,qDAAqD;IACrD,IAAI,CAAC,EAAE,MAAM,CAAA;IACb;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,oFAAoF;IACpF,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB;AAED,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,UAAU,CAAA;IAClB,uEAAuE;IACvE,QAAQ,EAAE,iBAAiB,CAAA;IAC3B,4BAA4B;IAC5B,IAAI,EAAE,MAAM,CAAA;IACZ,gEAAgE;IAChE,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;CACvB;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,OAAO,EAAE,uBAAuB,CAAC,GAAG,CAAC,GAAG,gBAAgB,CAmD7F"}
@@ -0,0 +1,74 @@
1
+ import { serve } from "@hono/node-server";
2
+ import { originTrustMiddleware } from "./trust.js";
3
+ import { createWaitUntilRegistry } from "./wait-until.js";
4
+ /** Path that health probes hit — always exempt from the origin-trust gate. */
5
+ export const HEALTHZ_PATH = "/healthz";
6
+ /** Trust-protected HTTP hook Cloud Scheduler POSTs to trigger `scheduled()`. */
7
+ export const SCHEDULED_PATH = "/__voyant/scheduled";
8
+ /**
9
+ * Invoke a Worker-style `scheduled()` handler from an HTTP request. Reads the
10
+ * cron expression from the `cron` query param. Returns `202` on success, `500`
11
+ * on handler error, `400` when `cron` is missing. Exported standalone so it can
12
+ * be mounted into any server loop, not just {@link createNodeServer}.
13
+ */
14
+ export async function scheduledHandler(args) {
15
+ const cron = new URL(args.request.url).searchParams.get("cron");
16
+ if (!cron) {
17
+ return new Response("Missing `cron` query param", { status: 400 });
18
+ }
19
+ try {
20
+ await args.scheduled({ cron, scheduledTime: Date.now() }, args.env, args.ctx);
21
+ return new Response(null, { status: 202 });
22
+ }
23
+ catch (error) {
24
+ const message = error instanceof Error ? error.message : String(error);
25
+ return new Response(`scheduled handler failed: ${message}`, { status: 500 });
26
+ }
27
+ }
28
+ /**
29
+ * Boot a Node HTTP server that runs an operator app's `fetch(request, env, ctx)`
30
+ * unchanged. Adds the pieces Cloudflare provides implicitly: a real per-request
31
+ * `waitUntil` context, an origin-trust gate, an HTTP `scheduled()` hook for
32
+ * Cloud Scheduler, and graceful SIGTERM/SIGINT shutdown that drains in-flight
33
+ * background work before exit.
34
+ */
35
+ export function createNodeServer(options) {
36
+ const port = options.port ?? Number.parseInt(process.env.PORT ?? "8080", 10);
37
+ const registry = createWaitUntilRegistry();
38
+ const trustGate = options.originTrustSecret
39
+ ? originTrustMiddleware(options.originTrustSecret, { exemptPaths: [HEALTHZ_PATH] })
40
+ : undefined;
41
+ async function handler(request) {
42
+ const url = new URL(request.url);
43
+ if (url.pathname === HEALTHZ_PATH) {
44
+ return new Response("ok", { status: 200 });
45
+ }
46
+ if (trustGate) {
47
+ const rejection = trustGate(request);
48
+ if (rejection)
49
+ return rejection;
50
+ }
51
+ const ctx = registry.context();
52
+ if (options.scheduled && request.method === "POST" && url.pathname === SCHEDULED_PATH) {
53
+ return scheduledHandler({ request, scheduled: options.scheduled, env: options.env, ctx });
54
+ }
55
+ return options.fetch(request, options.env, ctx);
56
+ }
57
+ const server = serve({ fetch: handler, port });
58
+ let closing;
59
+ const close = () => {
60
+ closing ??= (async () => {
61
+ await new Promise((resolve, reject) => {
62
+ server.close((err) => (err ? reject(err) : resolve()));
63
+ });
64
+ await registry.drain(options.drainTimeoutMs);
65
+ })();
66
+ return closing;
67
+ };
68
+ const onSignal = () => {
69
+ void close().then(() => process.exit(0), () => process.exit(1));
70
+ };
71
+ process.once("SIGTERM", onSignal);
72
+ process.once("SIGINT", onSignal);
73
+ return { server, registry, port, close };
74
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=node-server.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"node-server.test.d.ts","sourceRoot":"","sources":["../src/node-server.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,86 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { createNodeServer } from "./node-server.js";
3
+ import { ORIGIN_TRUST_HEADER } from "./trust.js";
4
+ const handles = [];
5
+ afterEach(async () => {
6
+ while (handles.length) {
7
+ await handles.pop()?.close();
8
+ }
9
+ });
10
+ async function ready(handle) {
11
+ const server = handle.server;
12
+ if (!server.listening) {
13
+ await new Promise((resolve) => server.once("listening", () => resolve()));
14
+ }
15
+ const addr = server.address();
16
+ return addr.port;
17
+ }
18
+ function boot(options) {
19
+ const handle = createNodeServer({ ...options, port: 0 });
20
+ handles.push(handle);
21
+ return handle;
22
+ }
23
+ describe("createNodeServer", () => {
24
+ it("runs the app fetch with the composed env and a real waitUntil ctx", async () => {
25
+ let sideEffectDone = false;
26
+ const handle = boot({
27
+ env: { GREETING: "hi" },
28
+ fetch: (_req, env, ctx) => {
29
+ ctx.waitUntil(new Promise((resolve) => setTimeout(() => {
30
+ sideEffectDone = true;
31
+ resolve();
32
+ }, 20)));
33
+ return new Response(env.GREETING);
34
+ },
35
+ });
36
+ const port = await ready(handle);
37
+ const res = await fetch(`http://127.0.0.1:${port}/anything`);
38
+ expect(await res.text()).toBe("hi");
39
+ await handle.close();
40
+ expect(sideEffectDone).toBe(true);
41
+ });
42
+ it("rejects requests without a valid origin-trust header, exempting /healthz", async () => {
43
+ const handle = boot({
44
+ env: {},
45
+ originTrustSecret: "top-secret",
46
+ fetch: () => new Response("app"),
47
+ });
48
+ const port = await ready(handle);
49
+ const denied = await fetch(`http://127.0.0.1:${port}/v1/admin/x`);
50
+ expect(denied.status).toBe(403);
51
+ const health = await fetch(`http://127.0.0.1:${port}/healthz`);
52
+ expect(health.status).toBe(200);
53
+ const allowed = await fetch(`http://127.0.0.1:${port}/v1/admin/x`, {
54
+ headers: { [ORIGIN_TRUST_HEADER]: "top-secret" },
55
+ });
56
+ expect(await allowed.text()).toBe("app");
57
+ });
58
+ it("dispatches the scheduled handler over HTTP with the cron expression", async () => {
59
+ let seenCron;
60
+ const handle = boot({
61
+ env: {},
62
+ originTrustSecret: "top-secret",
63
+ fetch: () => new Response("app"),
64
+ scheduled: (event) => {
65
+ seenCron = event.cron;
66
+ },
67
+ });
68
+ const port = await ready(handle);
69
+ const res = await fetch(`http://127.0.0.1:${port}/__voyant/scheduled?cron=${encodeURIComponent("*/2 * * * *")}`, { method: "POST", headers: { [ORIGIN_TRUST_HEADER]: "top-secret" } });
70
+ expect(res.status).toBe(202);
71
+ expect(seenCron).toBe("*/2 * * * *");
72
+ });
73
+ it("requires trust for the scheduled hook", async () => {
74
+ const handle = boot({
75
+ env: {},
76
+ originTrustSecret: "top-secret",
77
+ fetch: () => new Response("app"),
78
+ scheduled: () => undefined,
79
+ });
80
+ const port = await ready(handle);
81
+ const res = await fetch(`http://127.0.0.1:${port}/__voyant/scheduled?cron=x`, {
82
+ method: "POST",
83
+ });
84
+ expect(res.status).toBe(403);
85
+ });
86
+ });
package/dist/r2.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ import type { R2BucketLike, R2ObjectLike } from "@voyant-travel/storage/providers/r2";
2
+ /**
3
+ * An `R2Bucket`-shaped client backed by R2's S3-compatible API. Signing reuses
4
+ * `@voyant-travel/storage`'s SigV4 implementation (verified against AWS test
5
+ * vectors) rather than reimplementing it here. The shim satisfies the
6
+ * structural {@link R2BucketLike} the storage providers depend on, plus the
7
+ * richer `bucket.get(key) -> { arrayBuffer(), body, httpMetadata }` surface the
8
+ * operator's document routes use.
9
+ */
10
+ export type R2Fetch = typeof fetch;
11
+ /** The object returned by {@link R2BucketShim.get}. Superset of {@link R2ObjectLike}. */
12
+ export interface R2ShimObject extends R2ObjectLike {
13
+ /** Buffered body bytes, re-readable as a stream. */
14
+ body: ReadableStream<Uint8Array> | null;
15
+ httpMetadata?: {
16
+ contentType?: string;
17
+ };
18
+ customMetadata?: Record<string, string>;
19
+ size: number;
20
+ }
21
+ export interface R2BucketShim extends R2BucketLike {
22
+ get(key: string): Promise<R2ShimObject | null>;
23
+ head(key: string): Promise<Omit<R2ShimObject, "arrayBuffer" | "body"> | null>;
24
+ }
25
+ export interface R2BucketShimOptions {
26
+ /** R2 S3 endpoint, e.g. `https://<accountid>.r2.cloudflarestorage.com`. */
27
+ endpoint: string;
28
+ /** Bucket name. */
29
+ bucket: string;
30
+ accessKeyId: string;
31
+ secretAccessKey: string;
32
+ /** S3 region — R2 ignores it but SigV4 requires one. Defaults to `"auto"`. */
33
+ region?: string;
34
+ /** Override the fetch implementation (tests / custom agents). */
35
+ fetchImpl?: R2Fetch;
36
+ }
37
+ /**
38
+ * Build an {@link R2BucketShim} over the R2 S3-compatible API.
39
+ */
40
+ export declare function createR2BucketShim(options: R2BucketShimOptions): R2BucketShim;
41
+ //# sourceMappingURL=r2.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"r2.d.ts","sourceRoot":"","sources":["../src/r2.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,YAAY,EACZ,YAAY,EAEb,MAAM,qCAAqC,CAAA;AAE5C;;;;;;;GAOG;AACH,MAAM,MAAM,OAAO,GAAG,OAAO,KAAK,CAAA;AAElC,yFAAyF;AACzF,MAAM,WAAW,YAAa,SAAQ,YAAY;IAChD,oDAAoD;IACpD,IAAI,EAAE,cAAc,CAAC,UAAU,CAAC,GAAG,IAAI,CAAA;IACvC,YAAY,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;IACvC,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACvC,IAAI,EAAE,MAAM,CAAA;CACb;AAED,MAAM,WAAW,YAAa,SAAQ,YAAY;IAChD,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAAA;IAC9C,IAAI,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,YAAY,EAAE,aAAa,GAAG,MAAM,CAAC,GAAG,IAAI,CAAC,CAAA;CAC9E;AAED,MAAM,WAAW,mBAAmB;IAClC,2EAA2E;IAC3E,QAAQ,EAAE,MAAM,CAAA;IAChB,mBAAmB;IACnB,MAAM,EAAE,MAAM,CAAA;IACd,WAAW,EAAE,MAAM,CAAA;IACnB,eAAe,EAAE,MAAM,CAAA;IACvB,8EAA8E;IAC9E,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,iEAAiE;IACjE,SAAS,CAAC,EAAE,OAAO,CAAA;CACpB;AAID;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,mBAAmB,GAAG,YAAY,CA4G7E"}
package/dist/r2.js ADDED
@@ -0,0 +1,147 @@
1
+ import { signRequest } from "@voyant-travel/storage/lib/sigv4";
2
+ const CUSTOM_META_PREFIX = "x-amz-meta-";
3
+ /**
4
+ * Build an {@link R2BucketShim} over the R2 S3-compatible API.
5
+ */
6
+ export function createR2BucketShim(options) {
7
+ const fetchImpl = options.fetchImpl ?? fetch;
8
+ const region = options.region ?? "auto";
9
+ const endpoint = options.endpoint.replace(/\/$/, "");
10
+ const credentials = {
11
+ accessKeyId: options.accessKeyId,
12
+ secretAccessKey: options.secretAccessKey,
13
+ };
14
+ function objectUrl(key) {
15
+ return `${endpoint}/${encodeURIComponent(options.bucket)}/${encodeKey(key)}`;
16
+ }
17
+ async function send(method, key, init = {}) {
18
+ const url = objectUrl(key);
19
+ const signed = await signRequest({
20
+ method,
21
+ url,
22
+ headers: init.headers,
23
+ body: init.body,
24
+ credentials,
25
+ region,
26
+ service: "s3",
27
+ });
28
+ return fetchImpl(url, {
29
+ method,
30
+ headers: signed.headers,
31
+ body: init.body,
32
+ });
33
+ }
34
+ function readMetadata(response) {
35
+ const contentType = response.headers.get("content-type") ?? undefined;
36
+ const customMetadata = {};
37
+ response.headers.forEach((value, name) => {
38
+ if (name.toLowerCase().startsWith(CUSTOM_META_PREFIX)) {
39
+ customMetadata[name.slice(CUSTOM_META_PREFIX.length)] = value;
40
+ }
41
+ });
42
+ const contentLength = response.headers.get("content-length");
43
+ const result = { size: contentLength ? Number.parseInt(contentLength, 10) : 0 };
44
+ if (contentType !== undefined)
45
+ result.httpMetadata = { contentType };
46
+ if (Object.keys(customMetadata).length > 0)
47
+ result.customMetadata = customMetadata;
48
+ return result;
49
+ }
50
+ return {
51
+ async put(key, value, putOptions) {
52
+ const bytes = await toBytes(value);
53
+ const headers = putHeaders(putOptions);
54
+ const response = await send("PUT", key, { headers, body: bytes });
55
+ if (!response.ok) {
56
+ const text = await response.text().catch(() => "");
57
+ throw new Error(`R2 put failed (${response.status}): ${text}`);
58
+ }
59
+ return { key };
60
+ },
61
+ async delete(key) {
62
+ const keys = Array.isArray(key) ? key : [key];
63
+ for (const k of keys) {
64
+ const response = await send("DELETE", k);
65
+ // 204 on success, 404 on missing — both are effectively "gone".
66
+ if (!response.ok && response.status !== 404) {
67
+ const text = await response.text().catch(() => "");
68
+ throw new Error(`R2 delete failed (${response.status}): ${text}`);
69
+ }
70
+ }
71
+ },
72
+ async get(key) {
73
+ const response = await send("GET", key);
74
+ if (response.status === 404)
75
+ return null;
76
+ if (!response.ok) {
77
+ const text = await response.text().catch(() => "");
78
+ throw new Error(`R2 get failed (${response.status}): ${text}`);
79
+ }
80
+ const buffer = await response.arrayBuffer();
81
+ const meta = readMetadata(response);
82
+ const object = {
83
+ arrayBuffer: async () => buffer.slice(0),
84
+ body: bytesToStream(new Uint8Array(buffer)),
85
+ size: meta.size || buffer.byteLength,
86
+ };
87
+ if (meta.httpMetadata)
88
+ object.httpMetadata = meta.httpMetadata;
89
+ if (meta.customMetadata)
90
+ object.customMetadata = meta.customMetadata;
91
+ return object;
92
+ },
93
+ async head(key) {
94
+ const response = await send("HEAD", key);
95
+ if (response.status === 404)
96
+ return null;
97
+ if (!response.ok) {
98
+ const text = await response.text().catch(() => "");
99
+ throw new Error(`R2 head failed (${response.status}): ${text}`);
100
+ }
101
+ return readMetadata(response);
102
+ },
103
+ };
104
+ }
105
+ function putHeaders(putOptions) {
106
+ const headers = {};
107
+ if (putOptions?.httpMetadata?.contentType) {
108
+ headers["content-type"] = putOptions.httpMetadata.contentType;
109
+ }
110
+ if (putOptions?.customMetadata) {
111
+ for (const [k, v] of Object.entries(putOptions.customMetadata)) {
112
+ headers[`${CUSTOM_META_PREFIX}${k.toLowerCase()}`] = v;
113
+ }
114
+ }
115
+ return headers;
116
+ }
117
+ function encodeKey(key) {
118
+ return key
119
+ .split("/")
120
+ .map((segment) => encodeURIComponent(segment).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`))
121
+ .join("/");
122
+ }
123
+ async function toBytes(value) {
124
+ if (value === null)
125
+ return new Uint8Array();
126
+ if (typeof value === "string")
127
+ return new TextEncoder().encode(value);
128
+ if (value instanceof Uint8Array)
129
+ return value;
130
+ if (value instanceof ArrayBuffer)
131
+ return new Uint8Array(value);
132
+ if (ArrayBuffer.isView(value)) {
133
+ return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
134
+ }
135
+ if (value instanceof Blob)
136
+ return new Uint8Array(await value.arrayBuffer());
137
+ // ReadableStream
138
+ return new Uint8Array(await new Response(value).arrayBuffer());
139
+ }
140
+ function bytesToStream(bytes) {
141
+ return new ReadableStream({
142
+ start(controller) {
143
+ controller.enqueue(bytes);
144
+ controller.close();
145
+ },
146
+ });
147
+ }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=r2.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"r2.test.d.ts","sourceRoot":"","sources":["../src/r2.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createR2BucketShim } from "./r2.js";
3
+ function mockFetch(responder) {
4
+ const calls = [];
5
+ const fetch = async (input, init = {}) => {
6
+ const rec = {
7
+ url: String(input),
8
+ method: init.method ?? "GET",
9
+ headers: init.headers ?? {},
10
+ body: init.body,
11
+ };
12
+ calls.push(rec);
13
+ const { status = 200, body, headers } = responder(rec);
14
+ return new Response(body ?? null, { status, headers });
15
+ };
16
+ return { fetch, calls };
17
+ }
18
+ const base = {
19
+ endpoint: "https://acct.r2.cloudflarestorage.com",
20
+ bucket: "documents",
21
+ accessKeyId: "AKIA_TEST",
22
+ secretAccessKey: "secret",
23
+ region: "auto",
24
+ };
25
+ describe("createR2BucketShim", () => {
26
+ it("PUTs a signed, path-style object URL with content-type and custom metadata", async () => {
27
+ const { fetch, calls } = mockFetch(() => ({ status: 200 }));
28
+ const bucket = createR2BucketShim({ ...base, fetchImpl: fetch });
29
+ await bucket.put("invoices/2026/x.pdf", "PDFDATA", {
30
+ httpMetadata: { contentType: "application/pdf" },
31
+ customMetadata: { Owner: "acme" },
32
+ });
33
+ const call = calls[0];
34
+ expect(call.method).toBe("PUT");
35
+ expect(call.url).toBe("https://acct.r2.cloudflarestorage.com/documents/invoices/2026/x.pdf");
36
+ expect(call.headers.Authorization).toMatch(/^AWS4-HMAC-SHA256 Credential=AKIA_TEST\//);
37
+ expect(call.headers["x-amz-date"]).toMatch(/^\d{8}T\d{6}Z$/);
38
+ expect(call.headers["content-type"]).toBe("application/pdf");
39
+ expect(call.headers["x-amz-meta-owner"]).toBe("acme");
40
+ });
41
+ it("GETs an object exposing arrayBuffer, body, size, and httpMetadata", async () => {
42
+ const { fetch } = mockFetch(() => ({
43
+ body: "file-bytes",
44
+ headers: { "content-type": "text/plain", "x-amz-meta-tag": "v1" },
45
+ }));
46
+ const bucket = createR2BucketShim({ ...base, fetchImpl: fetch });
47
+ const obj = await bucket.get("a.txt");
48
+ expect(obj).not.toBeNull();
49
+ const bytes = new Uint8Array(await obj.arrayBuffer());
50
+ expect(new TextDecoder().decode(bytes)).toBe("file-bytes");
51
+ expect(obj.httpMetadata?.contentType).toBe("text/plain");
52
+ expect(obj.customMetadata?.tag).toBe("v1");
53
+ expect(obj.body).toBeInstanceOf(ReadableStream);
54
+ });
55
+ it("returns null when the object is missing (404)", async () => {
56
+ const { fetch } = mockFetch(() => ({ status: 404 }));
57
+ const bucket = createR2BucketShim({ ...base, fetchImpl: fetch });
58
+ expect(await bucket.get("missing")).toBeNull();
59
+ });
60
+ it("DELETEs and tolerates a 404", async () => {
61
+ const { fetch, calls } = mockFetch(() => ({ status: 404 }));
62
+ const bucket = createR2BucketShim({ ...base, fetchImpl: fetch });
63
+ await expect(bucket.delete("gone")).resolves.toBeUndefined();
64
+ expect(calls[0]?.method).toBe("DELETE");
65
+ });
66
+ it("supports array delete keys", async () => {
67
+ const { fetch, calls } = mockFetch(() => ({ status: 204 }));
68
+ const bucket = createR2BucketShim({ ...base, fetchImpl: fetch });
69
+ await bucket.delete(["a", "b"]);
70
+ expect(calls.map((c) => c.method)).toEqual(["DELETE", "DELETE"]);
71
+ });
72
+ it("HEAD returns metadata without a body", async () => {
73
+ const { fetch } = mockFetch(() => ({
74
+ headers: { "content-type": "image/png", "content-length": "1024" },
75
+ }));
76
+ const bucket = createR2BucketShim({ ...base, fetchImpl: fetch });
77
+ const meta = await bucket.head("img.png");
78
+ expect(meta?.httpMetadata?.contentType).toBe("image/png");
79
+ expect(meta?.size).toBe(1024);
80
+ });
81
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * SSR-manifest restriction for TanStack Start workers.
3
+ *
4
+ * By default the SSR stream emits preload/asset hints for every route in the
5
+ * manifest; on large admins that floods the first paint with hundreds of
6
+ * speculative preloads. Restricting the manifest to the matched routes keeps
7
+ * the initial response lean. Typed structurally so this package needs no
8
+ * dependency on the router — any router exposing `stores.matches` and an
9
+ * `ssr.manifest` fits.
10
+ */
11
+ type ManifestRoute = {
12
+ assets?: Array<unknown>;
13
+ preloads?: Array<unknown>;
14
+ };
15
+ export type SsrManifest = {
16
+ inlineCss?: unknown;
17
+ routes: Record<string, ManifestRoute | undefined>;
18
+ };
19
+ export interface SsrManifestRouter {
20
+ stores: {
21
+ matches: {
22
+ get(): ReadonlyArray<{
23
+ routeId: string;
24
+ }>;
25
+ };
26
+ };
27
+ ssr?: {
28
+ readonly manifest?: SsrManifest;
29
+ };
30
+ }
31
+ /**
32
+ * Replace `router.ssr.manifest` with a view filtered to the active route
33
+ * matches. The filter runs lazily (getter) because matches settle after the
34
+ * handler installs the restriction.
35
+ */
36
+ export declare function restrictSsrManifestToActiveRoutes(router: SsrManifestRouter): void;
37
+ /**
38
+ * Wrap a TanStack Start stream-handler callback so every SSR render gets the
39
+ * active-route manifest restriction:
40
+ *
41
+ * ```ts
42
+ * const startHandler = createStartHandler(
43
+ * withActiveRouteSsrManifest(defaultStreamHandler),
44
+ * )
45
+ * ```
46
+ */
47
+ export declare function withActiveRouteSsrManifest<TCtx extends {
48
+ router: unknown;
49
+ }, TResult>(handler: (ctx: TCtx) => TResult): (ctx: TCtx) => TResult;
50
+ export {};
51
+ //# sourceMappingURL=ssr-manifest.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ssr-manifest.d.ts","sourceRoot":"","sources":["../src/ssr-manifest.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,KAAK,aAAa,GAAG;IACnB,MAAM,CAAC,EAAE,KAAK,CAAC,OAAO,CAAC,CAAA;IACvB,QAAQ,CAAC,EAAE,KAAK,CAAC,OAAO,CAAC,CAAA;CAC1B,CAAA;AAED,MAAM,MAAM,WAAW,GAAG;IACxB,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,GAAG,SAAS,CAAC,CAAA;CAClD,CAAA;AAED,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE;QACN,OAAO,EAAE;YACP,GAAG,IAAI,aAAa,CAAC;gBAAE,OAAO,EAAE,MAAM,CAAA;aAAE,CAAC,CAAA;SAC1C,CAAA;KACF,CAAA;IACD,GAAG,CAAC,EAAE;QACJ,QAAQ,CAAC,QAAQ,CAAC,EAAE,WAAW,CAAA;KAChC,CAAA;CACF;AAED;;;;GAIG;AACH,wBAAgB,iCAAiC,CAAC,MAAM,EAAE,iBAAiB,GAAG,IAAI,CAoBjF;AAED;;;;;;;;;GASG;AACH,wBAAgB,0BAA0B,CAAC,IAAI,SAAS;IAAE,MAAM,EAAE,OAAO,CAAA;CAAE,EAAE,OAAO,EAClF,OAAO,EAAE,CAAC,GAAG,EAAE,IAAI,KAAK,OAAO,GAC9B,CAAC,GAAG,EAAE,IAAI,KAAK,OAAO,CAKxB"}
@@ -0,0 +1,49 @@
1
+ /**
2
+ * SSR-manifest restriction for TanStack Start workers.
3
+ *
4
+ * By default the SSR stream emits preload/asset hints for every route in the
5
+ * manifest; on large admins that floods the first paint with hundreds of
6
+ * speculative preloads. Restricting the manifest to the matched routes keeps
7
+ * the initial response lean. Typed structurally so this package needs no
8
+ * dependency on the router — any router exposing `stores.matches` and an
9
+ * `ssr.manifest` fits.
10
+ */
11
+ /**
12
+ * Replace `router.ssr.manifest` with a view filtered to the active route
13
+ * matches. The filter runs lazily (getter) because matches settle after the
14
+ * handler installs the restriction.
15
+ */
16
+ export function restrictSsrManifestToActiveRoutes(router) {
17
+ const ssr = router.ssr;
18
+ if (!ssr?.manifest)
19
+ return;
20
+ router.ssr = {
21
+ get manifest() {
22
+ const manifest = ssr.manifest;
23
+ if (!manifest)
24
+ return manifest;
25
+ const activeRouteIds = new Set(router.stores.matches.get().map((match) => match.routeId));
26
+ const routes = Object.fromEntries(Object.entries(manifest.routes).filter(([routeId]) => activeRouteIds.has(routeId)));
27
+ return {
28
+ ...manifest,
29
+ routes,
30
+ };
31
+ },
32
+ };
33
+ }
34
+ /**
35
+ * Wrap a TanStack Start stream-handler callback so every SSR render gets the
36
+ * active-route manifest restriction:
37
+ *
38
+ * ```ts
39
+ * const startHandler = createStartHandler(
40
+ * withActiveRouteSsrManifest(defaultStreamHandler),
41
+ * )
42
+ * ```
43
+ */
44
+ export function withActiveRouteSsrManifest(handler) {
45
+ return (ctx) => {
46
+ restrictSsrManifestToActiveRoutes(ctx.router);
47
+ return handler(ctx);
48
+ };
49
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Pinned cross-repo constant: the header the platform dispatcher stamps on
3
+ * every request it forwards to a dedicated (Cloud Run) app. The value is a
4
+ * per-app shared secret. Do NOT rename — the platform side depends on it.
5
+ */
6
+ export declare const ORIGIN_TRUST_HEADER = "x-voyant-origin-trust";
7
+ /**
8
+ * Constant-time string comparison. Returns `false` for length mismatches
9
+ * without leaking the comparison via early return timing (the length check is
10
+ * unavoidable and is itself not secret-length-dependent beyond equality).
11
+ */
12
+ export declare function constantTimeEqual(a: string, b: string): boolean;
13
+ /**
14
+ * Verify that a request carries a valid origin-trust header for `secret`.
15
+ */
16
+ export declare function verifyOriginTrust(request: Request, secret: string): boolean;
17
+ export interface OriginTrustOptions {
18
+ /**
19
+ * Paths that skip the trust check entirely (exact match). Defaults to
20
+ * `["/healthz"]` so container health probes never need the secret.
21
+ */
22
+ exemptPaths?: string[];
23
+ }
24
+ /**
25
+ * Build a low-level trust gate. Returns a function that, given a request,
26
+ * yields a `403` {@link Response} when the request must be rejected, or
27
+ * `undefined` when it may proceed. Composable — used by {@link createNodeServer}
28
+ * but exported so callers can wire it into their own server loop.
29
+ */
30
+ export declare function originTrustMiddleware(secret: string, options?: OriginTrustOptions): (request: Request) => Response | undefined;
31
+ //# sourceMappingURL=trust.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"trust.d.ts","sourceRoot":"","sources":["../src/trust.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,0BAA0B,CAAA;AAE1D;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CAU/D;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAI3E;AAED,MAAM,WAAW,kBAAkB;IACjC;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,EAAE,CAAA;CACvB;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,GAAE,kBAAuB,IAE5E,SAAS,OAAO,KAAG,QAAQ,GAAG,SAAS,CAMhD"}