@lambdot/host-cloudflare 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/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0](https://github.com/Embers-of-the-Fire/lambdot/compare/host-cloudflare-v0.0.1...host-cloudflare-v0.1.0) (2026-08-29)
4
+
5
+
6
+ ### Features
7
+
8
+ * **host-cloudflare:** named KV/D1/R2 binding capabilities with hono+miniflare example ([fe2d527](https://github.com/Embers-of-the-Fire/lambdot/commit/fe2d527f7b40380df4d05ab643814987a004566d))
9
+ * **host-cloudflare:** worker environment variables as a typed capability ([7319c5f](https://github.com/Embers-of-the-Fire/lambdot/commit/7319c5f0d656c2dd7e32c33c2be26b384f7e398e))
package/package.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "@lambdot/host-cloudflare",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": "./src/index.ts"
7
+ },
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "dependencies": {
12
+ "@lambdot/core": "workspace:*"
13
+ }
14
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Structural subsets of Cloudflare's worker binding types (normally supplied
3
+ * by `@cloudflare/workers-types`). Declared locally so the package stays
4
+ * dependency-free: real bindings from a worker's `env` are assignable to
5
+ * these interfaces structurally, and anything the platform adds beyond them
6
+ * (sessions, metadata options, ...) stays available to consumers through
7
+ * their own types.
8
+ */
9
+
10
+ /* ------------------------------ Workers KV ------------------------------- */
11
+
12
+ /** Options accepted by {@link KVNamespace.list}. */
13
+ export interface KVListOptions {
14
+ readonly prefix?: string;
15
+ readonly limit?: number;
16
+ readonly cursor?: string;
17
+ }
18
+
19
+ /** One entry returned by {@link KVNamespace.list}. */
20
+ export interface KVListKey {
21
+ readonly name: string;
22
+ readonly expiration?: number;
23
+ }
24
+
25
+ /** One page from {@link KVNamespace.list}. */
26
+ export interface KVListResult {
27
+ readonly keys: readonly KVListKey[];
28
+ readonly list_complete: boolean;
29
+ /** Present only while more pages remain. */
30
+ readonly cursor?: string;
31
+ }
32
+
33
+ /** Write options accepted by {@link KVNamespace.put}. */
34
+ export interface KVPutOptions {
35
+ /** Relative TTL in seconds. Cloudflare enforces a 60-second minimum. */
36
+ readonly expirationTtl?: number;
37
+ /** Absolute expiry, seconds since the epoch. Same 60-second floor. */
38
+ readonly expiration?: number;
39
+ }
40
+
41
+ /**
42
+ * The fundamental slice of a Workers KV namespace: JSON and text reads,
43
+ * string writes with optional expiry, delete, and listing.
44
+ */
45
+ export interface KVNamespace {
46
+ /** Read a key, parsing the stored value as JSON. `null` on a miss. */
47
+ get(key: string, options: { type: "json" }): Promise<unknown>;
48
+ /** Read a key as text. `null` on a miss. */
49
+ get(key: string): Promise<string | null>;
50
+ put(key: string, value: string, options?: KVPutOptions): Promise<void>;
51
+ delete(key: string): Promise<void>;
52
+ list(options?: KVListOptions): Promise<KVListResult>;
53
+ }
54
+
55
+ /* ---------------------------------- D1 ----------------------------------- */
56
+
57
+ /** The outcome of a D1 statement that returns rows. */
58
+ export interface D1Result<T = unknown> {
59
+ readonly results: T[];
60
+ readonly success: boolean;
61
+ readonly meta: Record<string, unknown>;
62
+ readonly error?: string;
63
+ }
64
+
65
+ /** The outcome of `D1Database.exec` (schema migrations, bulk statements). */
66
+ export interface D1ExecResult {
67
+ readonly count: number;
68
+ readonly duration: number;
69
+ }
70
+
71
+ /** A prepared D1 statement. */
72
+ export interface D1PreparedStatement {
73
+ bind(...values: unknown[]): D1PreparedStatement;
74
+ /** The first row (or one column of it), `null` when the statement matched nothing. */
75
+ first<T = unknown>(column?: string): Promise<T | null>;
76
+ run<T = unknown>(): Promise<D1Result<T>>;
77
+ all<T = unknown>(): Promise<D1Result<T>>;
78
+ raw<T = unknown>(): Promise<T[]>;
79
+ }
80
+
81
+ /** The fundamental slice of a D1 database. */
82
+ export interface D1Database {
83
+ prepare(query: string): D1PreparedStatement;
84
+ batch<T = unknown>(statements: D1PreparedStatement[]): Promise<D1Result<T>[]>;
85
+ exec(query: string): Promise<D1ExecResult>;
86
+ }
87
+
88
+ /* ---------------------------------- R2 ----------------------------------- */
89
+
90
+ /** Values an R2 bucket accepts on `put`. */
91
+ export type R2PutValue = string | ArrayBuffer | ArrayBufferView | ReadableStream | Blob;
92
+
93
+ /** Write options accepted by {@link R2Bucket.put}. */
94
+ export interface R2PutOptions {
95
+ readonly customMetadata?: Record<string, string>;
96
+ }
97
+
98
+ /** Metadata of an object stored in R2. */
99
+ export interface R2Object {
100
+ readonly key: string;
101
+ readonly size: number;
102
+ readonly etag: string;
103
+ readonly uploaded: Date;
104
+ readonly customMetadata?: Record<string, string>;
105
+ }
106
+
107
+ /** An R2 object together with its body. */
108
+ export interface R2ObjectBody extends R2Object {
109
+ readonly body: ReadableStream;
110
+ text(): Promise<string>;
111
+ json<T = unknown>(): Promise<T>;
112
+ arrayBuffer(): Promise<ArrayBuffer>;
113
+ }
114
+
115
+ /** Options accepted by {@link R2Bucket.list}. */
116
+ export interface R2ListOptions {
117
+ readonly prefix?: string;
118
+ readonly limit?: number;
119
+ readonly cursor?: string;
120
+ readonly delimiter?: string;
121
+ }
122
+
123
+ /** One page from {@link R2Bucket.list}. */
124
+ export interface R2Objects {
125
+ readonly objects: readonly R2Object[];
126
+ readonly truncated: boolean;
127
+ /** Present only while more pages remain. */
128
+ readonly cursor?: string;
129
+ readonly delimitedPrefixes: readonly string[];
130
+ }
131
+
132
+ /** The fundamental slice of an R2 bucket. */
133
+ export interface R2Bucket {
134
+ get(key: string): Promise<R2ObjectBody | null>;
135
+ put(key: string, value: R2PutValue, options?: R2PutOptions): Promise<R2Object | null>;
136
+ delete(keys: string | readonly string[]): Promise<void>;
137
+ list(options?: R2ListOptions): Promise<R2Objects>;
138
+ }
package/src/index.ts ADDED
@@ -0,0 +1,234 @@
1
+ import type { Disposer, FeaturePlugin, StateBackend } from "@lambdot/core";
2
+
3
+ import type { D1Database, KVNamespace, KVPutOptions, R2Bucket } from "./bindings.ts";
4
+
5
+ export type {
6
+ D1Database,
7
+ D1ExecResult,
8
+ D1PreparedStatement,
9
+ D1Result,
10
+ KVListKey,
11
+ KVListOptions,
12
+ KVListResult,
13
+ KVNamespace,
14
+ KVPutOptions,
15
+ R2Bucket,
16
+ R2ListOptions,
17
+ R2Object,
18
+ R2ObjectBody,
19
+ R2Objects,
20
+ R2PutOptions,
21
+ R2PutValue,
22
+ } from "./bindings.ts";
23
+
24
+ /**
25
+ * The typed capability contracts shared by a binding provider and its
26
+ * consumers, parameterized by capability name: the provider declares it as
27
+ * `TProvides`, consumers as `TInjects`. Cloudflare bindings are named — a
28
+ * worker binds several KV namespaces, D1 databases, and R2 buckets under
29
+ * distinct names — so each provider instance takes its own capability name
30
+ * and distinct names fold side by side
31
+ * (`KVCapability<"sessions"> & KVCapability<"cache">`), exactly like
32
+ * `WsCapability` in `@lambdot/websocket`.
33
+ */
34
+ export type KVCapability<TCap extends string> = { readonly [K in TCap]: KVNamespace };
35
+ export type D1Capability<TCap extends string> = { readonly [K in TCap]: D1Database };
36
+ export type R2Capability<TCap extends string> = { readonly [K in TCap]: R2Bucket };
37
+
38
+ /**
39
+ * The typed capability contract shared by an environment provider and its
40
+ * consumers — the same shape as `EnvCapability` in `@lambdot/env`, declared
41
+ * locally so the package stays dependency-free. The two are structurally
42
+ * identical, so a consumer typed against either accepts both providers.
43
+ */
44
+ export type EnvCapability<TCap extends string, TKey extends string> = {
45
+ readonly [K in TCap]: Readonly<Record<TKey, string>>;
46
+ };
47
+
48
+ /** Config for {@link kvNamespace}: the binding as it arrives on the worker's `env`. */
49
+ export interface KVNamespaceConfig {
50
+ readonly binding: KVNamespace;
51
+ }
52
+
53
+ /** Config for {@link d1Database}: the binding as it arrives on the worker's `env`. */
54
+ export interface D1DatabaseConfig {
55
+ readonly binding: D1Database;
56
+ }
57
+
58
+ /** Config for {@link r2Bucket}: the binding as it arrives on the worker's `env`. */
59
+ export interface R2BucketConfig {
60
+ readonly binding: R2Bucket;
61
+ }
62
+
63
+ /**
64
+ * Config for {@link envVars}: the worker's bindings object as it arrives on
65
+ * the fetch handler's `env` argument, carrying plain vars and secrets next
66
+ * to the resource bindings.
67
+ */
68
+ export interface EnvVarsConfig {
69
+ readonly source: Record<string, unknown>;
70
+ }
71
+
72
+ /**
73
+ * Provide one named Workers KV namespace as a typed capability. Instances
74
+ * multiply by capability name: register `kvNamespace("sessions")` and
75
+ * `kvNamespace("cache")` side by side, and each consumer injects its own.
76
+ *
77
+ * ```ts
78
+ * createKernel()
79
+ * .use(kvNamespace("sessions"), { binding: env.SESSIONS })
80
+ * .use(kvNamespace("cache"), { binding: env.CACHE });
81
+ * // ctx.sessions: KVNamespace, ctx.cache: KVNamespace
82
+ * ```
83
+ */
84
+ export function kvNamespace<TCap extends string>(
85
+ capability: TCap,
86
+ ): FeaturePlugin<{}, {}, undefined, KVNamespaceConfig, `kv:${TCap}`, KVCapability<TCap>> {
87
+ return {
88
+ name: `kv:${capability}`,
89
+ apply(ctx, config) {
90
+ // The kernel's `provide` keeps its value parameter behind a
91
+ // conditional type that stays deferred for a generic capability
92
+ // name; `KVCapability<TCap>` already ties this name to
93
+ // `KVNamespace`, so pin the call down here.
94
+ return (ctx.provide as (name: TCap, value: KVNamespace) => Disposer).call(
95
+ ctx,
96
+ capability,
97
+ config.binding,
98
+ );
99
+ },
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Provide one named D1 database as a typed capability. Instances multiply
105
+ * by capability name, exactly like {@link kvNamespace}.
106
+ *
107
+ * ```ts
108
+ * createKernel().use(d1Database("db"), { binding: env.DB });
109
+ * // ctx.db: D1Database
110
+ * ```
111
+ */
112
+ export function d1Database<TCap extends string>(
113
+ capability: TCap,
114
+ ): FeaturePlugin<{}, {}, undefined, D1DatabaseConfig, `d1:${TCap}`, D1Capability<TCap>> {
115
+ return {
116
+ name: `d1:${capability}`,
117
+ apply(ctx, config) {
118
+ // See `kvNamespace` for why `provide` is pinned here.
119
+ return (ctx.provide as (name: TCap, value: D1Database) => Disposer).call(
120
+ ctx,
121
+ capability,
122
+ config.binding,
123
+ );
124
+ },
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Provide one named R2 bucket as a typed capability. Instances multiply by
130
+ * capability name, exactly like {@link kvNamespace}.
131
+ *
132
+ * ```ts
133
+ * createKernel().use(r2Bucket("uploads"), { binding: env.UPLOADS });
134
+ * // ctx.uploads: R2Bucket
135
+ * ```
136
+ */
137
+ export function r2Bucket<TCap extends string>(
138
+ capability: TCap,
139
+ ): FeaturePlugin<{}, {}, undefined, R2BucketConfig, `r2:${TCap}`, R2Capability<TCap>> {
140
+ return {
141
+ name: `r2:${capability}`,
142
+ apply(ctx, config) {
143
+ // See `kvNamespace` for why `provide` is pinned here.
144
+ return (ctx.provide as (name: TCap, value: R2Bucket) => Disposer).call(
145
+ ctx,
146
+ capability,
147
+ config.binding,
148
+ );
149
+ },
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Read variables from a worker's bindings object and provide them as a
155
+ * typed capability — the Cloudflare counterpart of `envVars` in
156
+ * `@lambdot/env`: workers have no `process.env`, so plain vars and secrets
157
+ * arrive on `env` next to the resource bindings. A missing, empty, or
158
+ * non-string variable fails activation loudly at kernel start, so a
159
+ * misconfigured deployment surfaces before any consumer activates.
160
+ *
161
+ * ```ts
162
+ * createKernel().use(envVars("bot-env", ["BOT_TOKEN"]), { source: env });
163
+ * // ctx["bot-env"].BOT_TOKEN: string
164
+ * ```
165
+ */
166
+ export function envVars<TCap extends string, TKey extends string>(
167
+ capability: TCap,
168
+ keys: readonly TKey[],
169
+ ): FeaturePlugin<{}, {}, undefined, EnvVarsConfig, `env:${TCap}`, EnvCapability<TCap, TKey>> {
170
+ return {
171
+ name: `env:${capability}`,
172
+ apply(ctx, config) {
173
+ const values: Record<string, string> = {};
174
+ for (const key of keys) {
175
+ const value = config.source[key];
176
+ if (typeof value !== "string" || value === "")
177
+ throw new Error(
178
+ `env:${capability}: required environment variable "${key}" is not set`,
179
+ );
180
+ values[key] = value;
181
+ }
182
+ // See `kvNamespace` for why `provide` is pinned here.
183
+ return (
184
+ ctx.provide as (name: TCap, value: Readonly<Record<TKey, string>>) => Disposer
185
+ ).call(ctx, capability, values as Readonly<Record<TKey, string>>);
186
+ },
187
+ };
188
+ }
189
+
190
+ /**
191
+ * Bridge a named Workers KV namespace into the framework's pluggable state
192
+ * slot, so feature plugins reach it through `ctx.state`. Injects the
193
+ * capability provided by {@link kvNamespace} — the fold enforces
194
+ * registration order at compile time:
195
+ *
196
+ * ```ts
197
+ * createKernel()
198
+ * .use(kvNamespace("kv"), { binding: env.BOT_KV })
199
+ * .use(kvState("kv"))
200
+ * .use(myStatefulFeature);
201
+ * ```
202
+ *
203
+ * Values are stored as JSON under `<plugin-namespace>:<key>`. KV expiries
204
+ * are whole seconds with a 60-second minimum, so `ttlMs` is rounded up and
205
+ * clamped to that floor.
206
+ */
207
+ export function kvState<TCap extends string>(
208
+ capability: TCap,
209
+ ): FeaturePlugin<{}, {}, undefined, void, `state-kv:${TCap}`, {}, KVCapability<TCap>> {
210
+ return {
211
+ name: `state-kv:${capability}`,
212
+ inject: [capability],
213
+ apply(ctx) {
214
+ const binding = ctx[capability];
215
+ const backend: StateBackend = {
216
+ async get(ns, key) {
217
+ const value = await binding.get(`${ns}:${key}`, { type: "json" });
218
+ return value === null ? undefined : value;
219
+ },
220
+ async set(ns, key, value, ttlMs) {
221
+ const options: KVPutOptions =
222
+ ttlMs === undefined
223
+ ? {}
224
+ : { expirationTtl: Math.max(60, Math.ceil(ttlMs / 1000)) };
225
+ await binding.put(`${ns}:${key}`, JSON.stringify(value), options);
226
+ },
227
+ async delete(ns, key) {
228
+ await binding.delete(`${ns}:${key}`);
229
+ },
230
+ };
231
+ return ctx.provide("state", backend);
232
+ },
233
+ };
234
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "include": ["src"]
4
+ }