@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.
- package/README.md +54 -0
- package/dist/api-dispatch.d.ts +44 -0
- package/dist/api-dispatch.d.ts.map +1 -0
- package/dist/api-dispatch.js +64 -0
- package/dist/env.d.ts +33 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +30 -0
- package/dist/env.test.d.ts +2 -0
- package/dist/env.test.d.ts.map +1 -0
- package/dist/env.test.js +21 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/memory-kv.d.ts +49 -0
- package/dist/memory-kv.d.ts.map +1 -0
- package/dist/memory-kv.js +69 -0
- package/dist/memory-kv.test.d.ts +2 -0
- package/dist/memory-kv.test.d.ts.map +1 -0
- package/dist/memory-kv.test.js +44 -0
- package/dist/memory-r2.d.ts +4 -0
- package/dist/memory-r2.d.ts.map +1 -0
- package/dist/memory-r2.js +76 -0
- package/dist/memory-r2.test.d.ts +2 -0
- package/dist/memory-r2.test.d.ts.map +1 -0
- package/dist/memory-r2.test.js +45 -0
- package/dist/node-server.d.ts +56 -0
- package/dist/node-server.d.ts.map +1 -0
- package/dist/node-server.js +74 -0
- package/dist/node-server.test.d.ts +2 -0
- package/dist/node-server.test.d.ts.map +1 -0
- package/dist/node-server.test.js +86 -0
- package/dist/r2.d.ts +41 -0
- package/dist/r2.d.ts.map +1 -0
- package/dist/r2.js +147 -0
- package/dist/r2.test.d.ts +2 -0
- package/dist/r2.test.d.ts.map +1 -0
- package/dist/r2.test.js +81 -0
- package/dist/ssr-manifest.d.ts +51 -0
- package/dist/ssr-manifest.d.ts.map +1 -0
- package/dist/ssr-manifest.js +49 -0
- package/dist/trust.d.ts +31 -0
- package/dist/trust.d.ts.map +1 -0
- package/dist/trust.js +49 -0
- package/dist/trust.test.d.ts +2 -0
- package/dist/trust.test.d.ts.map +1 -0
- package/dist/trust.test.js +42 -0
- package/dist/types.d.ts +54 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/wait-until.d.ts +25 -0
- package/dist/wait-until.d.ts.map +1 -0
- package/dist/wait-until.js +50 -0
- package/dist/wait-until.test.d.ts +2 -0
- package/dist/wait-until.test.d.ts.map +1 -0
- package/dist/wait-until.test.js +40 -0
- package/dist/worker-fetch.d.ts +31 -0
- package/dist/worker-fetch.d.ts.map +1 -0
- package/dist/worker-fetch.js +37 -0
- 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 @@
|
|
|
1
|
+
{"version":3,"file":"env.test.d.ts","sourceRoot":"","sources":["../src/env.test.ts"],"names":[],"mappings":""}
|
package/dist/env.test.js
ADDED
|
@@ -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
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -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 @@
|
|
|
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 @@
|
|
|
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 @@
|
|
|
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
|
+
});
|