@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 +9 -0
- package/package.json +14 -0
- package/src/bindings.ts +138 -0
- package/src/index.ts +234 -0
- package/tsconfig.json +4 -0
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
package/src/bindings.ts
ADDED
|
@@ -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