@helloleo/runtime 0.1.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/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@helloleo/runtime",
3
+ "version": "0.1.0",
4
+ "authors": [
5
+ "Herbie Vine <herbie@terros>"
6
+ ],
7
+ "license": "UNLICENSED",
8
+ "type": "module",
9
+ "module": "src/index.ts",
10
+ "exports": {
11
+ ".": "./src/index.ts",
12
+ "./contracts": "./src/contracts.ts",
13
+ "./cf-shim": "./src/cf-shim.ts"
14
+ },
15
+ "scripts": {
16
+ "typecheck": "bunx tsc --noEmit"
17
+ },
18
+ "dependencies": {
19
+ "aws4fetch": "^1.0.20",
20
+ "drizzle-orm": "^0.36.4"
21
+ },
22
+ "devDependencies": {
23
+ "@cloudflare/workers-types": "^4.20250906.0",
24
+ "@libsql/client": "^0.14.0",
25
+ "@types/bun": "latest"
26
+ },
27
+ "peerDependencies": {
28
+ "@libsql/client": "*"
29
+ },
30
+ "peerDependenciesMeta": {
31
+ "@libsql/client": {
32
+ "optional": true
33
+ }
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ }
38
+ }
@@ -0,0 +1,23 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import type { KvLike } from "../contracts.ts";
3
+
4
+ // Adapt a Cloudflare KV binding (or the dev shim's KVNamespace-compatible
5
+ // object) to the neutral KvLike contract. The CF binding already matches this
6
+ // shape closely; this thin wrapper pins it to the contract so a non-CF adapter
7
+ // (Redis, Upstash, DynamoDB) can implement the same interface later.
8
+ export function kvFromBinding(ns: KVNamespace): KvLike {
9
+ return {
10
+ get: (key) => ns.get(key),
11
+ put: (key, value, opts) =>
12
+ ns.put(key, value, opts?.expirationTtl ? { expirationTtl: opts.expirationTtl } : undefined),
13
+ delete: (key) => ns.delete(key),
14
+ async list(opts) {
15
+ const res = await ns.list({ prefix: opts?.prefix, cursor: opts?.cursor, limit: opts?.limit });
16
+ return {
17
+ keys: res.keys.map((k) => ({ name: k.name, expiration: k.expiration })),
18
+ cursor: res.list_complete ? undefined : res.cursor,
19
+ listComplete: res.list_complete,
20
+ };
21
+ },
22
+ };
23
+ }
@@ -0,0 +1,91 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import { AwsClient } from "aws4fetch";
3
+ import type { PutBody, StorageLike, StoredObject } from "../contracts.ts";
4
+
5
+ export interface R2PresignConfig {
6
+ accessKeyId?: string;
7
+ secretAccessKey?: string;
8
+ endpoint?: string; // https://<account>.r2.cloudflarestorage.com
9
+ bucket?: string;
10
+ }
11
+
12
+ // Adapt a Cloudflare R2 binding to the neutral StorageLike contract.
13
+ //
14
+ // get/put/delete/head/list use the binding directly (fast, no signing, no
15
+ // creds). presign* can't use the binding (R2 bindings have no presign), so they
16
+ // sign against R2's S3-compatible endpoint with aws4fetch — which means
17
+ // presigned URLs need R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY / R2_S3_ENDPOINT
18
+ // set as Worker secrets. Same code path works for any S3-compatible store.
19
+ export function storageFromBinding(bucket: R2Bucket, cfg: R2PresignConfig): StorageLike {
20
+ let signer: AwsClient | undefined;
21
+ const presignBase = (): { client: AwsClient; base: string } => {
22
+ if (!cfg.accessKeyId || !cfg.secretAccessKey || !cfg.endpoint || !cfg.bucket) {
23
+ throw new Error(
24
+ "Presigned URLs require R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_S3_ENDPOINT and R2_BUCKET_NAME. " +
25
+ "The R2 binding alone cannot presign.",
26
+ );
27
+ }
28
+ signer ??= new AwsClient({
29
+ accessKeyId: cfg.accessKeyId,
30
+ secretAccessKey: cfg.secretAccessKey,
31
+ service: "s3",
32
+ region: "auto",
33
+ });
34
+ return { client: signer, base: `${cfg.endpoint.replace(/\/$/, "")}/${cfg.bucket}` };
35
+ };
36
+
37
+ return {
38
+ async get(key) {
39
+ const obj = await bucket.get(key);
40
+ if (!obj) return null;
41
+ return {
42
+ body: obj.body,
43
+ size: obj.size,
44
+ contentType: obj.httpMetadata?.contentType,
45
+ } satisfies StoredObject;
46
+ },
47
+ async put(key, body: PutBody, opts) {
48
+ await bucket.put(key, body, {
49
+ httpMetadata: opts?.contentType ? { contentType: opts.contentType } : undefined,
50
+ });
51
+ },
52
+ delete: (key) => bucket.delete(key),
53
+ async head(key) {
54
+ const obj = await bucket.head(key);
55
+ if (!obj) return null;
56
+ return { size: obj.size, contentType: obj.httpMetadata?.contentType };
57
+ },
58
+ async list(opts) {
59
+ const res = await bucket.list({
60
+ prefix: opts?.prefix,
61
+ cursor: opts?.cursor,
62
+ limit: opts?.limit,
63
+ });
64
+ return {
65
+ keys: res.objects.map((o) => ({ key: o.key, size: o.size })),
66
+ cursor: res.truncated ? res.cursor : undefined,
67
+ };
68
+ },
69
+ async presignGet(key, ttlSeconds) {
70
+ const { client, base } = presignBase();
71
+ const signed = await client.sign(
72
+ `${base}/${encodeURIComponent(key)}?X-Amz-Expires=${ttlSeconds}`,
73
+ {
74
+ method: "GET",
75
+ aws: { signQuery: true },
76
+ },
77
+ );
78
+ return signed.url;
79
+ },
80
+ async presignPut(key, ttlSeconds, opts) {
81
+ const { client, base } = presignBase();
82
+ const url = `${base}/${encodeURIComponent(key)}?X-Amz-Expires=${ttlSeconds}`;
83
+ const signed = await client.sign(url, {
84
+ method: "PUT",
85
+ headers: opts?.contentType ? { "content-type": opts.contentType } : undefined,
86
+ aws: { signQuery: true },
87
+ });
88
+ return signed.url;
89
+ },
90
+ };
91
+ }
package/src/cf-shim.ts ADDED
@@ -0,0 +1,230 @@
1
+ // DEV-ONLY shim for "cloudflare:workers".
2
+ //
3
+ // @helloleo/vite-config aliases "cloudflare:workers" → this module when running
4
+ // in a sandbox (CodeSandbox) where workerd can't run nested. It exports an
5
+ // `env` whose bindings are backed by local, Miniflare-equivalent adapters:
6
+ // - DB: libsql (.dev.db), presented with D1's async surface
7
+ // - KV: libsql _kv table in the same file (persists + shares like D1)
8
+ // - BUCKET: filesystem (./dev-storage), R2Bucket-compatible subset
9
+ //
10
+ // The runtime (env.ts → index.ts) reads these exactly as it reads real
11
+ // bindings, so SSR + server functions behave like prod. Only the I/O backend
12
+ // differs. This module imports @libsql/client and node:fs, so it must NEVER be
13
+ // imported in the workerd build — the alias only fires in dev (command==="serve"
14
+ // && sandbox), so it isn't.
15
+
16
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
17
+ import { readdir } from "node:fs/promises";
18
+ import { join } from "node:path";
19
+ import { createClient } from "@libsql/client";
20
+
21
+ // ---- D1 (libsql) ------------------------------------------------------
22
+ //
23
+ // libsql (not better-sqlite3) because it runs under BOTH node and bun — the
24
+ // sandbox dev runtime; better-sqlite3 is a native addon Bun can't dlopen. Same
25
+ // sqlite dialect as D1, file-backed locally, and the same client speaks to Turso
26
+ // later by swapping the url. Wrapped into D1's async surface so the runtime
27
+ // stays on a single drizzle-orm/d1 driver in both dev and prod.
28
+ const dbFile = process.env.DEV_DB_PATH ?? ".dev.db";
29
+ const client = createClient({ url: `file:${dbFile}` });
30
+
31
+ // Apply schema.sql on boot if present, so a query in a route loader never hits
32
+ // "no such table" on a fresh sandbox.
33
+ if (existsSync("schema.sql")) {
34
+ try {
35
+ await client.executeMultiple(readFileSync("schema.sql", "utf-8"));
36
+ } catch (e) {
37
+ console.error("[cf-shim] failed to apply schema.sql:", e);
38
+ }
39
+ }
40
+
41
+ // Minimal D1Database surface over libsql. Covers what drizzle-orm/d1 calls:
42
+ // prepare().bind().all()/first()/run()/raw() and batch().
43
+ function makeD1() {
44
+ const prepare = (sql: string) => {
45
+ const make = (params: unknown[]) => {
46
+ const args = params as Array<string | number | bigint | boolean | null | Uint8Array>;
47
+ return {
48
+ async all<T = unknown>() {
49
+ const r = await client.execute({ sql, args });
50
+ return { results: r.rows as unknown as T[], success: true, meta: {} };
51
+ },
52
+ async first<T = unknown>(col?: string) {
53
+ const r = await client.execute({ sql, args });
54
+ const row = r.rows[0] as Record<string, unknown> | undefined;
55
+ if (!row) return null;
56
+ return (col ? row[col] : row) as T;
57
+ },
58
+ async run() {
59
+ const r = await client.execute({ sql, args });
60
+ return {
61
+ success: true,
62
+ meta: {
63
+ changes: r.rowsAffected,
64
+ last_row_id: Number(r.lastInsertRowid ?? 0),
65
+ duration: 0,
66
+ },
67
+ };
68
+ },
69
+ async raw<T = unknown[]>() {
70
+ const r = await client.execute({ sql, args });
71
+ return r.rows.map((row) =>
72
+ r.columns.map((c) => (row as Record<string, unknown>)[c]),
73
+ ) as T[];
74
+ },
75
+ };
76
+ };
77
+ return { bind: (...params: unknown[]) => make(params), ...make([]) };
78
+ };
79
+ return {
80
+ prepare,
81
+ async batch(stmts: Array<{ all: () => Promise<unknown> }>) {
82
+ const out = [];
83
+ for (const s of stmts) out.push(await s.all());
84
+ return out;
85
+ },
86
+ async exec(sql: string) {
87
+ await client.executeMultiple(sql);
88
+ return { count: 0, duration: 0 };
89
+ },
90
+ };
91
+ }
92
+
93
+ // ---- KV (libsql table + TTL) ------------------------------------------
94
+ //
95
+ // Backed by the SAME libsql file as D1 (a _kv table) so dev KV persists across
96
+ // restarts and is shared across processes, exactly like D1/R2. In-memory was a
97
+ // footgun: each `vite dev` had its own KV, and it reset on restart.
98
+ await client.executeMultiple(
99
+ "CREATE TABLE IF NOT EXISTS _kv (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at INTEGER);",
100
+ );
101
+
102
+ function makeKv() {
103
+ const liveRow = async (key: string) => {
104
+ const r = await client.execute({
105
+ sql: "SELECT value, expires_at FROM _kv WHERE key = ?",
106
+ args: [key],
107
+ });
108
+ const row = r.rows[0] as { value: string; expires_at: number | null } | undefined;
109
+ if (!row) return null;
110
+ if (row.expires_at && Date.now() > row.expires_at) {
111
+ await client.execute({ sql: "DELETE FROM _kv WHERE key = ?", args: [key] });
112
+ return null;
113
+ }
114
+ return row;
115
+ };
116
+ return {
117
+ async get(key: string) {
118
+ return (await liveRow(key))?.value ?? null;
119
+ },
120
+ async put(key: string, value: string, opts?: { expirationTtl?: number }) {
121
+ const expiresAt = opts?.expirationTtl ? Date.now() + opts.expirationTtl * 1000 : null;
122
+ await client.execute({
123
+ sql: "INSERT INTO _kv (key, value, expires_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, expires_at = excluded.expires_at",
124
+ args: [key, value, expiresAt],
125
+ });
126
+ },
127
+ async delete(key: string) {
128
+ await client.execute({ sql: "DELETE FROM _kv WHERE key = ?", args: [key] });
129
+ },
130
+ async list(opts?: { prefix?: string; limit?: number }) {
131
+ const prefix = `${opts?.prefix ?? ""}%`;
132
+ const r = await client.execute({
133
+ sql: "SELECT key FROM _kv WHERE key LIKE ? AND (expires_at IS NULL OR expires_at > ?) ORDER BY key",
134
+ args: [prefix, Date.now()],
135
+ });
136
+ const names = r.rows.map((row) => (row as unknown as { key: string }).key);
137
+ const limited = opts?.limit ? names.slice(0, opts.limit) : names;
138
+ return {
139
+ keys: limited.map((name) => ({ name })),
140
+ list_complete: limited.length === names.length,
141
+ cursor: "",
142
+ };
143
+ },
144
+ };
145
+ }
146
+
147
+ // ---- R2 (filesystem) --------------------------------------------------
148
+
149
+ function makeR2() {
150
+ const root = process.env.DEV_STORAGE_DIR ?? "dev-storage";
151
+ const pathFor = (key: string) => join(root, key.replace(/\.\./g, "_"));
152
+ mkdirSync(root, { recursive: true });
153
+
154
+ const toBuffer = async (body: unknown): Promise<Buffer> => {
155
+ if (typeof body === "string") return Buffer.from(body);
156
+ if (body instanceof ArrayBuffer) return Buffer.from(body);
157
+ if (ArrayBuffer.isView(body)) return Buffer.from(body.buffer, body.byteOffset, body.byteLength);
158
+ if (body instanceof ReadableStream) {
159
+ const chunks: Uint8Array[] = [];
160
+ const reader = body.getReader();
161
+ for (;;) {
162
+ const { done, value } = await reader.read();
163
+ if (done) break;
164
+ if (value) chunks.push(value);
165
+ }
166
+ return Buffer.concat(chunks);
167
+ }
168
+ throw new Error("[cf-shim] unsupported R2 put body");
169
+ };
170
+
171
+ const meta = new Map<string, string | undefined>(); // key -> contentType
172
+
173
+ return {
174
+ async get(key: string) {
175
+ const p = pathFor(key);
176
+ if (!existsSync(p)) return null;
177
+ const buf = readFileSync(p);
178
+ return {
179
+ size: buf.byteLength,
180
+ httpMetadata: { contentType: meta.get(key) },
181
+ body: new ReadableStream({
182
+ start(c) {
183
+ c.enqueue(new Uint8Array(buf));
184
+ c.close();
185
+ },
186
+ }),
187
+ };
188
+ },
189
+ async put(key: string, body: unknown, opts?: { httpMetadata?: { contentType?: string } }) {
190
+ const p = pathFor(key);
191
+ mkdirSync(join(p, ".."), { recursive: true });
192
+ writeFileSync(p, await toBuffer(body));
193
+ meta.set(key, opts?.httpMetadata?.contentType);
194
+ },
195
+ async delete(key: string) {
196
+ const p = pathFor(key);
197
+ if (existsSync(p)) rmSync(p);
198
+ meta.delete(key);
199
+ },
200
+ async head(key: string) {
201
+ const p = pathFor(key);
202
+ if (!existsSync(p)) return null;
203
+ return { size: statSync(p).size, httpMetadata: { contentType: meta.get(key) } };
204
+ },
205
+ async list(opts?: { prefix?: string; limit?: number }) {
206
+ const all = existsSync(root) ? await readdir(root, { recursive: true }) : [];
207
+ const prefix = opts?.prefix ?? "";
208
+ const keys = all.filter(
209
+ (k) => k.startsWith(prefix) && existsSync(pathFor(k)) && statSync(pathFor(k)).isFile(),
210
+ );
211
+ const limited = opts?.limit ? keys.slice(0, opts.limit) : keys;
212
+ return {
213
+ objects: limited.map((key) => ({ key, size: statSync(pathFor(key)).size })),
214
+ truncated: false,
215
+ cursor: undefined,
216
+ };
217
+ },
218
+ };
219
+ }
220
+
221
+ export const env = {
222
+ DB: makeD1(),
223
+ KV: makeKv(),
224
+ BUCKET: makeR2(),
225
+ R2_ACCESS_KEY_ID: process.env.R2_ACCESS_KEY_ID,
226
+ R2_SECRET_ACCESS_KEY: process.env.R2_SECRET_ACCESS_KEY,
227
+ R2_S3_ENDPOINT: process.env.R2_S3_ENDPOINT,
228
+ R2_BUCKET_NAME: process.env.R2_BUCKET_NAME,
229
+ // biome-ignore lint/suspicious/noExplicitAny: dev shim presents the CF env shape
230
+ } as any;
@@ -0,0 +1,60 @@
1
+ // Provider-neutral storage contracts.
2
+ //
3
+ // The interfaces are the lowest common denominator across providers so app
4
+ // code never imports a vendor SDK directly. Today's adapters: CF bindings
5
+ // (prod) and local shims (dev). Tomorrow's (Redis/Upstash for KV, S3/MinIO for
6
+ // storage, Turso for db) implement the SAME interface — swapping a provider is
7
+ // then an adapter change, not a call-site rewrite.
8
+ //
9
+ // Anything ABOVE the intersection (KV metadata, R2 multipart, D1 interactive
10
+ // transactions) is deliberately left off these contracts. Adapters that can't
11
+ // honor a method must throw `NotSupported` — fail loud, never silently degrade.
12
+
13
+ export class NotSupported extends Error {
14
+ constructor(feature: string, adapter: string) {
15
+ super(`${feature} is not supported by the ${adapter} adapter`);
16
+ this.name = "NotSupported";
17
+ }
18
+ }
19
+
20
+ // ---- KV ----------------------------------------------------------------
21
+
22
+ export interface KvListResult {
23
+ keys: { name: string; expiration?: number }[];
24
+ cursor?: string;
25
+ listComplete: boolean;
26
+ }
27
+
28
+ export interface KvLike {
29
+ get(key: string): Promise<string | null>;
30
+ put(key: string, value: string, opts?: { expirationTtl?: number }): Promise<void>;
31
+ delete(key: string): Promise<void>;
32
+ list(opts?: { prefix?: string; cursor?: string; limit?: number }): Promise<KvListResult>;
33
+ }
34
+
35
+ // ---- Object storage ----------------------------------------------------
36
+
37
+ export interface StoredObject {
38
+ body: ReadableStream;
39
+ size: number;
40
+ contentType?: string;
41
+ }
42
+
43
+ export interface StorageListResult {
44
+ keys: { key: string; size: number }[];
45
+ cursor?: string;
46
+ }
47
+
48
+ export type PutBody = ArrayBuffer | ArrayBufferView | ReadableStream | string;
49
+
50
+ export interface StorageLike {
51
+ get(key: string): Promise<StoredObject | null>;
52
+ put(key: string, body: PutBody, opts?: { contentType?: string }): Promise<void>;
53
+ delete(key: string): Promise<void>;
54
+ head(key: string): Promise<{ size: number; contentType?: string } | null>;
55
+ list(opts?: { prefix?: string; cursor?: string; limit?: number }): Promise<StorageListResult>;
56
+ // Presigned URLs let the browser GET/PUT directly, bypassing the Worker.
57
+ // R2 bindings can't presign — these always go through the S3 API + creds.
58
+ presignGet(key: string, ttlSeconds: number): Promise<string>;
59
+ presignPut(key: string, ttlSeconds: number, opts?: { contentType?: string }): Promise<string>;
60
+ }
package/src/env.ts ADDED
@@ -0,0 +1,35 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+
3
+ // Single entry point to the per-request Cloudflare environment.
4
+ //
5
+ // PROD (workerd / Workers for Platforms): "cloudflare:workers" is the real
6
+ // module; `env` holds the live bindings. @helloleo/vite-config externalizes
7
+ // this specifier in the workerd build.
8
+ //
9
+ // DEV (CodeSandbox / sandbox): @helloleo/vite-config aliases
10
+ // "cloudflare:workers" → "@helloleo/runtime/cf-shim", which provides an `env`
11
+ // whose bindings are backed by local Miniflare-equivalent adapters
12
+ // (better-sqlite3 D1, in-memory KV, filesystem R2). Same import, same shape —
13
+ // the env-switch lives entirely in that one alias.
14
+ import { env as cfEnv } from "cloudflare:workers";
15
+
16
+ // Bindings + secrets this runtime expects. Apps extend their own Env in
17
+ // worker-configuration.d.ts; this is the subset the runtime reads.
18
+ export interface RuntimeEnv {
19
+ // HelloLeo Cloud database (D1, or the dev shim's sqlite-backed equivalent).
20
+ DB: D1Database;
21
+ // Optional KV namespace. Throw at access time if a feature needs it but it's
22
+ // unbound, rather than failing silently.
23
+ KV?: KVNamespace;
24
+ // Optional R2 bucket for object storage.
25
+ BUCKET?: R2Bucket;
26
+
27
+ // R2 S3-API credentials. Required ONLY for presigned URLs — the R2 binding
28
+ // itself cannot presign, so storage.presign*() signs against the S3 endpoint.
29
+ R2_ACCESS_KEY_ID?: string;
30
+ R2_SECRET_ACCESS_KEY?: string;
31
+ R2_S3_ENDPOINT?: string;
32
+ R2_BUCKET_NAME?: string;
33
+ }
34
+
35
+ export const env = cfEnv as unknown as RuntimeEnv;
package/src/index.ts ADDED
@@ -0,0 +1,65 @@
1
+ // @helloleo/runtime — env-agnostic data access for HelloLeo apps.
2
+ //
3
+ // import { db, kv, storage } from "@helloleo/runtime";
4
+ //
5
+ // Same import in dev (CodeSandbox) and prod (Workers for Platforms). The
6
+ // dev/prod fork lives in ONE place: @helloleo/vite-config aliases
7
+ // "cloudflare:workers" to the local shim in dev, so `env` carries
8
+ // local-backed bindings; in prod it's the real CF env. This module never
9
+ // branches on the target.
10
+ //
11
+ // All three are lazy singletons (see lazy.ts) so the top-level import is safe
12
+ // on workerd, where bindings only exist per-request. Use them INSIDE a server
13
+ // function / route loader / handler — never at module scope of app code.
14
+ import { drizzle } from "drizzle-orm/d1";
15
+ import { kvFromBinding } from "./adapters/kv-binding.ts";
16
+ import { storageFromBinding } from "./adapters/storage-binding.ts";
17
+ import type { KvLike, StorageLike } from "./contracts.ts";
18
+ import { env } from "./env.ts";
19
+ import { lazy } from "./lazy.ts";
20
+
21
+ // Schema-less Drizzle client. Good for `db.select().from(table)` style queries
22
+ // where the app imports its own table objects. Sqlite dialect → portable across
23
+ // D1 (prod), the dev shim, and Turso/libsql by swapping the driver here only.
24
+ export const db = lazy(() => drizzle(env.DB));
25
+
26
+ // Typed Drizzle client bound to an app schema (enables `db.query.*` relational
27
+ // queries + full inference). Still lazy, so safe to call at app module scope.
28
+ //
29
+ // export const db = createDb(schema);
30
+ export function createDb<TSchema extends Record<string, unknown>>(schema: TSchema) {
31
+ return lazy(() => drizzle(env.DB, { schema }));
32
+ }
33
+
34
+ export const kv: KvLike = lazy<KvLike>(() => {
35
+ if (!env.KV) {
36
+ throw new Error("KV is not bound. Add a kv_namespaces entry named 'KV' to wrangler.jsonc.");
37
+ }
38
+ return kvFromBinding(env.KV);
39
+ });
40
+
41
+ export const storage: StorageLike = lazy<StorageLike>(() => {
42
+ if (!env.BUCKET) {
43
+ throw new Error(
44
+ "Storage is not bound. Add an r2_buckets entry named 'BUCKET' to wrangler.jsonc.",
45
+ );
46
+ }
47
+ return storageFromBinding(env.BUCKET, {
48
+ accessKeyId: env.R2_ACCESS_KEY_ID,
49
+ secretAccessKey: env.R2_SECRET_ACCESS_KEY,
50
+ endpoint: env.R2_S3_ENDPOINT,
51
+ bucket: env.R2_BUCKET_NAME,
52
+ });
53
+ });
54
+
55
+ export type {
56
+ KvLike,
57
+ KvListResult,
58
+ PutBody,
59
+ StorageLike,
60
+ StorageListResult,
61
+ StoredObject,
62
+ } from "./contracts.ts";
63
+ export { NotSupported } from "./contracts.ts";
64
+ export type { RuntimeEnv } from "./env.ts";
65
+ export { env } from "./env.ts";
package/src/lazy.ts ADDED
@@ -0,0 +1,31 @@
1
+ // Lazy singleton via Proxy.
2
+ //
3
+ // On workerd, Cloudflare bindings (env.DB / env.KV / env.BUCKET) only exist at
4
+ // REQUEST time — reading them at module scope yields undefined. A plain
5
+ // `export const db = drizzle(env.DB)` therefore crashes at isolate boot.
6
+ //
7
+ // `lazy()` exports a value (importable at the top of any file) but defers
8
+ // construction to the first property access, which is always inside a request.
9
+ // `import { db }` looks eager; the real client is built — and cached — on first
10
+ // use. The build() callback is the single place the dev/prod fork lives (it
11
+ // reads `env`, whose bindings are swapped by the cf-shim alias in dev).
12
+ //
13
+ // RULE: only use this for the CONNECTION (stateless, isolate-shared). Anything
14
+ // carrying identity (a per-user, token-scoped client) must be built per-request
15
+ // inside middleware, never cached in a module singleton — it would leak across
16
+ // requests.
17
+ export function lazy<T extends object>(build: () => T): T {
18
+ let instance: T | undefined;
19
+ const resolve = (): T => {
20
+ if (!instance) instance = build();
21
+ return instance;
22
+ };
23
+ return new Proxy({} as T, {
24
+ get(_target, prop, receiver) {
25
+ return Reflect.get(resolve(), prop, receiver);
26
+ },
27
+ has(_target, prop) {
28
+ return Reflect.has(resolve(), prop);
29
+ },
30
+ });
31
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "types": ["@cloudflare/workers-types", "bun"]
5
+ },
6
+ "include": ["src/**/*.ts"]
7
+ }