@lambdot/host-cloudflare 0.1.0 → 0.2.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 CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [[`71e5732`](https://github.com/Embers-of-the-Fire/lambdot/commit/71e57321ad4ec7d1aef3651d104123f8167ec2e7), [`71e5732`](https://github.com/Embers-of-the-Fire/lambdot/commit/71e57321ad4ec7d1aef3651d104123f8167ec2e7)]:
8
+ - @lambdot/core@0.2.0
9
+
10
+ ## 0.1.1
11
+
12
+ ### Patch Changes
13
+
14
+ - [#10](https://github.com/Embers-of-the-Fire/lambdot/pull/10) [`19d37e4`](https://github.com/Embers-of-the-Fire/lambdot/commit/19d37e42c7a5514fb62c8f31c65e1aa01916d355) Thanks [@Embers-of-the-Fire](https://github.com/Embers-of-the-Fire)! - Switch inter-package dependency pins from exact versions to `workspace:*` so workspace members always resolve against local sources during development; pnpm rewrites the protocol to exact versions at pack/publish time.
15
+ - Updated dependencies [[`19d37e4`](https://github.com/Embers-of-the-Fire/lambdot/commit/19d37e42c7a5514fb62c8f31c65e1aa01916d355)]:
16
+ - @lambdot/core@0.1.1
17
+
3
18
  ## [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
19
 
5
20
 
package/README.md ADDED
@@ -0,0 +1,157 @@
1
+ # @lambdot/host-cloudflare
2
+
3
+ A Cloudflare host integration: it embeds a composition into a worker by
4
+ turning the worker's `env` — named KV namespaces, D1 databases, R2 buckets,
5
+ Durable Object namespaces, and plain environment variables — into typed
6
+ namespace values that feature plugins consume through the composition's
7
+ visible context, plus state bridges that serve a `StateBackend` from a KV
8
+ namespace or a Durable Object's own storage, and a server-side websocket hub
9
+ for Durable Objects. The package is dependency-free (only
10
+ `@lambdot/core`): the binding types are structural subsets of
11
+ `@cloudflare/workers-types`, so real bindings from a worker's `env` are
12
+ assignable as-is, and anything Cloudflare adds beyond them stays available
13
+ through the consumer's own types.
14
+
15
+ ## Bindings as namespaces
16
+
17
+ Cloudflare bindings are named — a worker binds several KV namespaces, D1
18
+ databases, R2 buckets, and Durable Object namespaces under distinct names —
19
+ so each provider factory takes its name as a parameter and instances
20
+ multiply: compose `kvNamespace("sessions")` and `kvNamespace("cache")` side
21
+ by side, exactly like `wsTransport` in `@lambdot/websocket`. Wiring a
22
+ consumer before the provider it consumes is a compile error (the `mapping`
23
+ parameter is typed as the namespaces visible so far), and `ctx.<name>` reads
24
+ back typed with no casts. The config is just the binding as it arrives on
25
+ the fetch handler's `env` argument, passed via `option`:
26
+ `{ binding: env.SESSIONS }`.
27
+
28
+ `envVars` is the Cloudflare counterpart of `envVars` in `@lambdot/env` —
29
+ workers have no `process.env`, so plain vars and secrets arrive on `env`
30
+ next to the resource bindings. Its config is `{ source: env }`; it reads
31
+ the listed keys and emits them as a `Readonly<Record<TKey, string>>` under
32
+ the name. A missing, empty, or non-string variable throws at start, so a
33
+ misconfigured deployment surfaces before any consumer activates.
34
+
35
+ ## The KV state bridge
36
+
37
+ `kvState(name)` bridges a KV namespace provided by `kvNamespace` into the
38
+ framework's pluggable state shape: it consumes `{ kv: KVNamespace }` (wire
39
+ it through a `mapping` from the `kvNamespace` namespace), wraps the
40
+ namespace in a `StateBackend`, and emits it — typically under `"state"`, so
41
+ feature plugins reach it by declaring `{ state: StateBackend }` in their
42
+ input and building a typed view with `createStateAccessor(backend, name)`.
43
+ Values are stored as JSON under `<plugin-namespace>:<key>`. KV expiries are
44
+ whole seconds with a 60-second minimum, so a plugin's `ttlMs` is rounded up
45
+ and clamped to that floor.
46
+
47
+ ## Durable Objects
48
+
49
+ Three pieces cover Durable Objects, one per place the runtime surfaces
50
+ them:
51
+
52
+ - `durableObjectNamespace(name)` is the binding provider for the worker
53
+ side: `{ binding: env.ROOM }` via `option`, emitted under `name` exactly
54
+ like the KV/D1/R2 providers. Routing to an instance stays in the fetch
55
+ handler — `ctx.rooms.get(ctx.rooms.idFromName(name)).fetch(request)`.
56
+ - `doState()` is the per-instance counterpart of `kvState`: a Durable
57
+ Object's transactional storage arrives on its constructor state rather
58
+ than on `env` (and each instance has exactly one), so it is passed to the
59
+ composition as config — `.bind(doState(), { option: { storage } })` — and
60
+ emitted under `"state"`, so feature plugins reach it by declaring
61
+ `{ state: StateBackend }` in their input (identity wiring). Values are
62
+ structured-cloneable (no JSON round trip) and there is no TTL, since
63
+ Durable Object storage has no expiry mechanism.
64
+ - `wsHub(name)` is the server-side mirror of `wsTransport` in
65
+ `@lambdot/websocket`: instead of dialing out, the Durable Object accepts
66
+ incoming sockets. It returns a bundle — the hub the fetch handler accepts
67
+ `WebSocketPair` server ends into, and the plugin emitting that hub under
68
+ `name` — with the exact `WsConnection` shape, so the generic
69
+ `wsInput`/`wsOutput` halves (a `wsPlatform` bundle minus its transport)
70
+ drive it unchanged, wired by `mapping: (ctx) => ({ connection: ctx.room })`.
71
+ Where the transport owns one client socket, the hub
72
+ fans out: `send` broadcasts to every accepted socket, `onMessage`
73
+ receives from any of them. Create the hub per Durable Object instance,
74
+ never at module level — it keeps sockets and listeners in closures, and
75
+ co-resident instances share the isolate's module scope, so module-level
76
+ instances would cross-wire two rooms. Hold the hub in instance state and
77
+ boot the composition lazily in `fetch()` with `request.url`.
78
+
79
+ ## Usage
80
+
81
+ From a worker's fetch handler — boot the composition once per isolate and
82
+ reuse it (`start` is idempotent):
83
+
84
+ ```ts
85
+ import type { StateBackend } from "@lambdot/core";
86
+ import { createKernel, createStateAccessor, definePlugin } from "@lambdot/core";
87
+ import type { KVNamespace } from "@lambdot/host-cloudflare";
88
+ import { envVars, kvNamespace, kvState } from "@lambdot/host-cloudflare";
89
+
90
+ // Declared as a `type` (not an `interface`) so the whole object stays
91
+ // assignable to EnvVarsConfig["source"] — interfaces get no implicit
92
+ // index signature.
93
+ type Env = {
94
+ readonly PING_DEFAULT_MESSAGE: string;
95
+ readonly PINGS: KVNamespace;
96
+ };
97
+
98
+ const pingPong = definePlugin({
99
+ name: "ping-pong",
100
+ apply(input: { state: StateBackend }) {
101
+ const state = createStateAccessor<{ count: number }>(input.state, "ping-pong");
102
+ // ...
103
+ },
104
+ });
105
+
106
+ function createBot(env: Env) {
107
+ return (
108
+ createKernel()
109
+ .use(envVars("bot-env", ["PING_DEFAULT_MESSAGE"]), { option: { source: env } })
110
+ .bind(kvNamespace("pings"), { option: { binding: env.PINGS } })
111
+ .bind(kvState("state"), { mapping: (ctx) => ({ kv: ctx.pings }) })
112
+ // identity wiring: the bound "state" namespace feeds ping-pong
113
+ .use(pingPong)
114
+ );
115
+ }
116
+ ```
117
+
118
+ Consumers read `bot.ctx["bot-env"].PING_DEFAULT_MESSAGE` and
119
+ `bot.ctx["ping-pong"]` typed through the composition; `bind` keeps the KV
120
+ binding and the state backend internal to the chain (visible to `mapping`s,
121
+ absent from the final `ctx`).
122
+
123
+ ## API
124
+
125
+ Providers — each emits the binding (or snapshot) under its name:
126
+
127
+ | Export | Config | Emits |
128
+ | ------------------------------ | ------------------------------ | ------------------------------------- |
129
+ | `kvNamespace(name)` | `KVNamespaceConfig` | `KVNamespace` under `name` |
130
+ | `d1Database(name)` | `D1DatabaseConfig` | `D1Database` under `name` |
131
+ | `r2Bucket(name)` | `R2BucketConfig` | `R2Bucket` under `name` |
132
+ | `envVars(name, keys)` | `EnvVarsConfig` | `Readonly<Record<TKey, string>>` |
133
+ | `durableObjectNamespace(name)` | `DurableObjectNamespaceConfig` | `DurableObjectNamespace` under `name` |
134
+ | `kvState(name)` | none | `StateBackend` under `name` |
135
+ | `doState()` | `DoStorageConfig` | `StateBackend` under `"state"` |
136
+ | `wsHub(name)` | `WsHubConfig` | `WebSocketHub` under `name` |
137
+
138
+ All configs are `{ binding: env.X }`-style (`EnvVarsConfig` is
139
+ `{ source: env }`, `DoStorageConfig` is `{ storage }`) and are passed via
140
+ `option`, which is required since the config types are non-void. `kvState`
141
+ takes no config but declares `{ kv: KVNamespace }` as its input — wire it
142
+ with a `mapping`. `wsHub` additionally returns the `hub` control face the
143
+ fetch handler accepts sockets into.
144
+
145
+ Binding types (`KVNamespace`, `D1Database`, `R2Bucket`,
146
+ `DurableObjectNamespace`, `DurableObjectState` and their result/option
147
+ types) are re-exported from `src/bindings.ts`.
148
+
149
+ See [examples/cloudflare-bot](../../../examples/cloudflare-bot) for a
150
+ complete worker — hono + a KV-backed counter running under miniflare — and
151
+ [examples/durable-object-bot](../../../examples/durable-object-bot) for the
152
+ Durable Object half: a websocket chat room per DO instance, driven by
153
+ `wsHub` + `doState` under miniflare.
154
+
155
+ ## License
156
+
157
+ Dual-licensed under [Apache-2.0](../../../LICENSE-APACHE) and [MIT](../../../LICENSE-MIT).
package/package.json CHANGED
@@ -1,14 +1,19 @@
1
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
- }
2
+ "name": "@lambdot/host-cloudflare",
3
+ "version": "0.2.0",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "https://github.com/Embers-of-the-Fire/lambdot",
7
+ "directory": "packages/host/cloudflare"
8
+ },
9
+ "type": "module",
10
+ "exports": {
11
+ ".": "./src/index.ts"
12
+ },
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "dependencies": {
17
+ "@lambdot/core": "0.2.0"
18
+ }
19
+ }
package/src/bindings.ts CHANGED
@@ -136,3 +136,40 @@ export interface R2Bucket {
136
136
  delete(keys: string | readonly string[]): Promise<void>;
137
137
  list(options?: R2ListOptions): Promise<R2Objects>;
138
138
  }
139
+
140
+ /* ---------------------------- Durable Objects ---------------------------- */
141
+
142
+ /** A Durable Object id, minted by a {@link DurableObjectNamespace}. */
143
+ export interface DurableObjectId {
144
+ toString(): string;
145
+ equals(other: DurableObjectId): boolean;
146
+ }
147
+
148
+ /** A stub talking to one Durable Object instance: the fetch entry only. */
149
+ export interface DurableObjectStub {
150
+ fetch(request: Request): Promise<Response>;
151
+ }
152
+
153
+ /**
154
+ * The fundamental slice of a Durable Object namespace binding: name an
155
+ * instance, then talk to it through its stub.
156
+ */
157
+ export interface DurableObjectNamespace {
158
+ idFromName(name: string): DurableObjectId;
159
+ get(id: DurableObjectId): DurableObjectStub;
160
+ }
161
+
162
+ /**
163
+ * The fundamental slice of a Durable Object's transactional storage:
164
+ * per-instance, structured-cloneable values, no JSON round trip needed.
165
+ */
166
+ export interface DurableObjectStorage {
167
+ get<T = unknown>(key: string): Promise<T | undefined>;
168
+ put(key: string, value: unknown): Promise<void>;
169
+ delete(key: string): Promise<boolean>;
170
+ }
171
+
172
+ /** The slice of a Durable Object's constructor state the framework builds on. */
173
+ export interface DurableObjectState {
174
+ readonly storage: DurableObjectStorage;
175
+ }
@@ -0,0 +1,197 @@
1
+ import type { Disposer, Plugin, StateBackend } from "@lambdot/core";
2
+ import { definePlugin } from "@lambdot/core";
3
+
4
+ import type { DurableObjectNamespace, DurableObjectStorage } from "./bindings.ts";
5
+
6
+ /** Config for {@link durableObjectNamespace}: the binding as it arrives on the worker's `env`. */
7
+ export interface DurableObjectNamespaceConfig {
8
+ readonly binding: DurableObjectNamespace;
9
+ }
10
+
11
+ /**
12
+ * Emit one named Durable Object namespace binding as the plugin's namespace
13
+ * value. Instances multiply by name, exactly like `kvNamespace`. Routing to
14
+ * an instance stays in the worker's fetch handler — read the namespace back
15
+ * from the composition's ctx:
16
+ *
17
+ * ```ts
18
+ * createKernel().use(durableObjectNamespace("rooms"), { option: { binding: env.ROOM } });
19
+ * // ctx.rooms.get(ctx.rooms.idFromName(name)).fetch(request)
20
+ * ```
21
+ */
22
+ export function durableObjectNamespace<const TCap extends string>(
23
+ capability: TCap,
24
+ ): Plugin<void, DurableObjectNamespace, DurableObjectNamespaceConfig, TCap> {
25
+ return definePlugin({
26
+ name: capability,
27
+ apply(_input, _scope, config) {
28
+ return config.binding;
29
+ },
30
+ });
31
+ }
32
+
33
+ /** Config for {@link doState}: the instance's storage, from the Durable Object's constructor state. */
34
+ export interface DoStorageConfig {
35
+ readonly storage: DurableObjectStorage;
36
+ }
37
+
38
+ /**
39
+ * Bridge a Durable Object's own transactional storage into a pluggable
40
+ * `StateBackend` — the per-instance counterpart of `kvState`. The storage
41
+ * arrives on the Durable Object's constructor state rather than on `env`, so
42
+ * it is passed straight as config (each instance has exactly one). Emitted
43
+ * under `"state"`, so feature plugins reach it by declaring
44
+ * `{ state: StateBackend }` in their input (identity wiring):
45
+ *
46
+ * ```ts
47
+ * class Room extends DurableObject {
48
+ * boot() {
49
+ * return createKernel().bind(doState(), { option: { storage: this.ctx.storage } });
50
+ * }
51
+ * }
52
+ * ```
53
+ *
54
+ * Keys are namespaced `<plugin-namespace>:<key>`. Values are
55
+ * structured-cloneable, so no JSON round trip is needed, and there is no
56
+ * TTL — Durable Object storage has no expiry mechanism. State is scoped to
57
+ * the instance: two names on one namespace never share a value.
58
+ */
59
+ export function doState(): Plugin<void, StateBackend, DoStorageConfig, "state"> {
60
+ return definePlugin({
61
+ name: "state",
62
+ apply(_input, _scope, config) {
63
+ const backend: StateBackend = {
64
+ get: (ns, key) => config.storage.get(`${ns}:${key}`),
65
+ set: (ns, key, value) => config.storage.put(`${ns}:${key}`, value),
66
+ async delete(ns, key) {
67
+ await config.storage.delete(`${ns}:${key}`);
68
+ },
69
+ };
70
+ return backend;
71
+ },
72
+ });
73
+ }
74
+
75
+ /**
76
+ * A server-side websocket hub: the exact shape of `WsConnection` in
77
+ * `@lambdot/websocket` (declared locally so the package keeps its single
78
+ * `@lambdot/core` dependency), so the generic `wsInput`/`wsOutput` factories
79
+ * consume it as-is through their `{ connection }` input. Where `wsTransport`
80
+ * owns one client socket, the hub fans out over every socket a Durable
81
+ * Object has accepted: `send` broadcasts, `onMessage` receives from any of
82
+ * them.
83
+ */
84
+ export interface WebSocketHub {
85
+ readonly url: string;
86
+ /** Send a text frame to every accepted socket. */
87
+ send(data: string): void;
88
+ /** Subscribe to incoming text frames from any accepted socket. */
89
+ onMessage(listener: (data: string) => void): Disposer;
90
+ }
91
+
92
+ /**
93
+ * The handler face of a {@link wsHub} bundle, held by the Durable Object
94
+ * class: everything {@link WebSocketHub} exposes to plugins, plus the
95
+ * server-side `accept` the fetch handler calls with the server end of a
96
+ * `WebSocketPair`.
97
+ */
98
+ export interface WebSocketHubControl extends WebSocketHub {
99
+ accept(socket: WebSocket): void;
100
+ }
101
+
102
+ /** Config for a {@link wsHub} plugin: the URL the hub serves, reported as `WsConnection["url"]`. */
103
+ export interface WsHubConfig {
104
+ readonly url: string;
105
+ }
106
+
107
+ /** A {@link wsHub} bundle: the hub the Durable Object accepts sockets into, and the plugin emitting it. */
108
+ export interface WsHub<TCap extends string> {
109
+ readonly hub: WebSocketHubControl;
110
+ readonly plugin: Plugin<void, WebSocketHub, WsHubConfig, TCap>;
111
+ }
112
+
113
+ /**
114
+ * The server-side mirror of `wsTransport`: instead of dialing out, a Durable
115
+ * Object accepts incoming sockets. `wsHub` returns the two halves of that —
116
+ * the hub the Durable Object's fetch handler accepts `WebSocketPair` server
117
+ * ends into, and the plugin that emits the hub as its namespace value, so
118
+ * the generic `wsInput`/`wsOutput` halves (a `wsPlatform` bundle minus its
119
+ * transport) drive it unchanged:
120
+ *
121
+ * ```ts
122
+ * class ChatRoom extends DurableObject {
123
+ * private readonly room = wsHub("room");
124
+ * private kernel: ReturnType<typeof createRoomKernel> | undefined;
125
+ *
126
+ * async fetch(request: Request) {
127
+ * this.kernel ??= createRoomKernel(this.room, request.url);
128
+ * await this.kernel.start(); // `start` is idempotent
129
+ * const pair = new WebSocketPair();
130
+ * this.room.hub.accept(pair[1]);
131
+ * return new Response(null, { status: 101, webSocket: pair[0] });
132
+ * }
133
+ * }
134
+ *
135
+ * function createRoomKernel(room: WsHub<"room">, url: string) {
136
+ * const chat = wsPlatform("dochat", chatSpec);
137
+ * return createKernel()
138
+ * .bind(room.plugin, { option: { url } })
139
+ * .use(chat.input, { mapping: (ctx) => ({ connection: ctx.room }) })
140
+ * .bind(chat.output, {
141
+ * mapping: (ctx) => ({ connection: ctx.room, commands: ctx.reply }),
142
+ * });
143
+ * }
144
+ * ```
145
+ *
146
+ * Create the hub **per Durable Object instance**, never at module level: it
147
+ * keeps sockets and listeners in closures, and co-resident instances share
148
+ * the isolate's module scope, so module-level instances would cross-wire two
149
+ * rooms (one room's broadcasts leaking into another's sockets). The
150
+ * instance's URL is only known per request, so hold the hub in instance
151
+ * state and boot the composition lazily in `fetch()` with `request.url`.
152
+ */
153
+ export function wsHub<const TCap extends string>(capability: TCap): WsHub<TCap> {
154
+ const sockets = new Set<WebSocket>();
155
+ const listeners = new Set<(data: string) => void>();
156
+
157
+ // `url` is only known once the plugin activates with its config, so the
158
+ // hub object stays mutable behind the readonly connection face.
159
+ const hub = {
160
+ url: "",
161
+ send(data: string) {
162
+ for (const socket of sockets) socket.send(data);
163
+ },
164
+ onMessage(listener: (data: string) => void): Disposer {
165
+ listeners.add(listener);
166
+ return () => {
167
+ listeners.delete(listener);
168
+ };
169
+ },
170
+ accept(socket: WebSocket) {
171
+ // `accept()` is the server-side workers extension: the global
172
+ // `WebSocket` type this package compiles against is the
173
+ // client-side one, so pin the call down structurally.
174
+ (socket as WebSocket & { accept(): void }).accept();
175
+ sockets.add(socket);
176
+ socket.addEventListener("message", (event) => {
177
+ if (typeof event.data === "string")
178
+ for (const listener of listeners) listener(event.data);
179
+ });
180
+ const drop = () => {
181
+ sockets.delete(socket);
182
+ };
183
+ socket.addEventListener("close", drop);
184
+ socket.addEventListener("error", drop);
185
+ },
186
+ };
187
+
188
+ const plugin = definePlugin({
189
+ name: capability,
190
+ apply(_input, _scope, config: WsHubConfig) {
191
+ hub.url = config.url;
192
+ return hub as WebSocketHub;
193
+ },
194
+ });
195
+
196
+ return { hub, plugin };
197
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { Disposer, FeaturePlugin, StateBackend } from "@lambdot/core";
1
+ import type { Plugin, StateBackend } from "@lambdot/core";
2
+ import { definePlugin } from "@lambdot/core";
2
3
 
3
4
  import type { D1Database, KVNamespace, KVPutOptions, R2Bucket } from "./bindings.ts";
4
5
 
@@ -7,6 +8,11 @@ export type {
7
8
  D1ExecResult,
8
9
  D1PreparedStatement,
9
10
  D1Result,
11
+ DurableObjectId,
12
+ DurableObjectNamespace,
13
+ DurableObjectState,
14
+ DurableObjectStorage,
15
+ DurableObjectStub,
10
16
  KVListKey,
11
17
  KVListOptions,
12
18
  KVListResult,
@@ -20,30 +26,15 @@ export type {
20
26
  R2PutOptions,
21
27
  R2PutValue,
22
28
  } 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
- };
29
+ export type {
30
+ DoStorageConfig,
31
+ DurableObjectNamespaceConfig,
32
+ WebSocketHub,
33
+ WebSocketHubControl,
34
+ WsHub,
35
+ WsHubConfig,
36
+ } from "./durable-object.ts";
37
+ export { doState, durableObjectNamespace, wsHub } from "./durable-object.ts";
47
38
 
48
39
  /** Config for {@link kvNamespace}: the binding as it arrives on the worker's `env`. */
49
40
  export interface KVNamespaceConfig {
@@ -70,151 +61,129 @@ export interface EnvVarsConfig {
70
61
  }
71
62
 
72
63
  /**
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.
64
+ * Emit one named Workers KV namespace as the plugin's namespace value.
65
+ * Instances multiply by name: compose `kvNamespace("sessions")` and
66
+ * `kvNamespace("cache")` side by side, and each consumer wires its own
67
+ * through its mapping.
76
68
  *
77
69
  * ```ts
78
70
  * createKernel()
79
- * .use(kvNamespace("sessions"), { binding: env.SESSIONS })
80
- * .use(kvNamespace("cache"), { binding: env.CACHE });
71
+ * .use(kvNamespace("sessions"), { option: { binding: env.SESSIONS } })
72
+ * .use(kvNamespace("cache"), { option: { binding: env.CACHE } });
81
73
  * // ctx.sessions: KVNamespace, ctx.cache: KVNamespace
82
74
  * ```
83
75
  */
84
- export function kvNamespace<TCap extends string>(
76
+ export function kvNamespace<const TCap extends string>(
85
77
  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
- );
78
+ ): Plugin<void, KVNamespace, KVNamespaceConfig, TCap> {
79
+ return definePlugin({
80
+ name: capability,
81
+ apply(_input, _scope, config) {
82
+ return config.binding;
99
83
  },
100
- };
84
+ });
101
85
  }
102
86
 
103
87
  /**
104
- * Provide one named D1 database as a typed capability. Instances multiply
105
- * by capability name, exactly like {@link kvNamespace}.
88
+ * Emit one named D1 database as the plugin's namespace value. Instances
89
+ * multiply by name, exactly like {@link kvNamespace}.
106
90
  *
107
91
  * ```ts
108
- * createKernel().use(d1Database("db"), { binding: env.DB });
92
+ * createKernel().use(d1Database("db"), { option: { binding: env.DB } });
109
93
  * // ctx.db: D1Database
110
94
  * ```
111
95
  */
112
- export function d1Database<TCap extends string>(
96
+ export function d1Database<const TCap extends string>(
113
97
  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
- );
98
+ ): Plugin<void, D1Database, D1DatabaseConfig, TCap> {
99
+ return definePlugin({
100
+ name: capability,
101
+ apply(_input, _scope, config) {
102
+ return config.binding;
124
103
  },
125
- };
104
+ });
126
105
  }
127
106
 
128
107
  /**
129
- * Provide one named R2 bucket as a typed capability. Instances multiply by
130
- * capability name, exactly like {@link kvNamespace}.
108
+ * Emit one named R2 bucket as the plugin's namespace value. Instances
109
+ * multiply by name, exactly like {@link kvNamespace}.
131
110
  *
132
111
  * ```ts
133
- * createKernel().use(r2Bucket("uploads"), { binding: env.UPLOADS });
112
+ * createKernel().use(r2Bucket("uploads"), { option: { binding: env.UPLOADS } });
134
113
  * // ctx.uploads: R2Bucket
135
114
  * ```
136
115
  */
137
- export function r2Bucket<TCap extends string>(
116
+ export function r2Bucket<const TCap extends string>(
138
117
  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
- );
118
+ ): Plugin<void, R2Bucket, R2BucketConfig, TCap> {
119
+ return definePlugin({
120
+ name: capability,
121
+ apply(_input, _scope, config) {
122
+ return config.binding;
149
123
  },
150
- };
124
+ });
151
125
  }
152
126
 
153
127
  /**
154
- * Read variables from a worker's bindings object and provide them as a
155
- * typed capability — the Cloudflare counterpart of `envVars` in
128
+ * Read variables from a worker's bindings object and emit them as the
129
+ * plugin's namespace value — the Cloudflare counterpart of `envVars` in
156
130
  * `@lambdot/env`: workers have no `process.env`, so plain vars and secrets
157
131
  * 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.
132
+ * non-string variable fails activation loudly at start, so a misconfigured
133
+ * deployment surfaces before any consumer activates.
160
134
  *
161
135
  * ```ts
162
- * createKernel().use(envVars("bot-env", ["BOT_TOKEN"]), { source: env });
136
+ * createKernel().use(envVars("bot-env", ["BOT_TOKEN"]), { option: { source: env } });
163
137
  * // ctx["bot-env"].BOT_TOKEN: string
164
138
  * ```
165
139
  */
166
- export function envVars<TCap extends string, TKey extends string>(
140
+ export function envVars<const TCap extends string, const TKey extends string>(
167
141
  capability: TCap,
168
142
  keys: readonly TKey[],
169
- ): FeaturePlugin<{}, {}, undefined, EnvVarsConfig, `env:${TCap}`, EnvCapability<TCap, TKey>> {
170
- return {
171
- name: `env:${capability}`,
172
- apply(ctx, config) {
143
+ ): Plugin<void, Readonly<Record<TKey, string>>, EnvVarsConfig, TCap> {
144
+ return definePlugin({
145
+ name: capability,
146
+ apply(_input, _scope, config) {
173
147
  const values: Record<string, string> = {};
174
148
  for (const key of keys) {
175
149
  const value = config.source[key];
176
150
  if (typeof value !== "string" || value === "")
177
151
  throw new Error(
178
- `env:${capability}: required environment variable "${key}" is not set`,
152
+ `${capability}: required environment variable "${key}" is not set`,
179
153
  );
180
154
  values[key] = value;
181
155
  }
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>>);
156
+ return values as Readonly<Record<TKey, string>>;
186
157
  },
187
- };
158
+ });
188
159
  }
189
160
 
190
161
  /**
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:
162
+ * Bridge a Workers KV namespace into a pluggable `StateBackend`, so stateful
163
+ * features can build typed accessors with `createStateAccessor`. Wire the KV
164
+ * binding through the mapping:
195
165
  *
196
166
  * ```ts
197
167
  * createKernel()
198
- * .use(kvNamespace("kv"), { binding: env.BOT_KV })
199
- * .use(kvState("kv"))
200
- * .use(myStatefulFeature);
168
+ * .bind(kvNamespace("pings"), { option: { binding: env.PINGS } })
169
+ * .use(kvState("state"), { mapping: (ctx) => ({ kv: ctx.pings }) })
170
+ * .use(myStatefulFeature); // declares { state: StateBackend } — identity wiring
201
171
  * ```
202
172
  *
203
173
  * Values are stored as JSON under `<plugin-namespace>:<key>`. KV expiries
204
174
  * are whole seconds with a 60-second minimum, so `ttlMs` is rounded up and
205
175
  * clamped to that floor.
206
176
  */
207
- export function kvState<TCap extends string>(
177
+ export function kvState<const TCap extends string>(
208
178
  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];
179
+ ): Plugin<{ kv: KVNamespace }, StateBackend, void, TCap> {
180
+ return definePlugin({
181
+ name: capability,
182
+ apply(input) {
183
+ const { kv } = input;
215
184
  const backend: StateBackend = {
216
185
  async get(ns, key) {
217
- const value = await binding.get(`${ns}:${key}`, { type: "json" });
186
+ const value = await kv.get(`${ns}:${key}`, { type: "json" });
218
187
  return value === null ? undefined : value;
219
188
  },
220
189
  async set(ns, key, value, ttlMs) {
@@ -222,13 +191,13 @@ export function kvState<TCap extends string>(
222
191
  ttlMs === undefined
223
192
  ? {}
224
193
  : { expirationTtl: Math.max(60, Math.ceil(ttlMs / 1000)) };
225
- await binding.put(`${ns}:${key}`, JSON.stringify(value), options);
194
+ await kv.put(`${ns}:${key}`, JSON.stringify(value), options);
226
195
  },
227
196
  async delete(ns, key) {
228
- await binding.delete(`${ns}:${key}`);
197
+ await kv.delete(`${ns}:${key}`);
229
198
  },
230
199
  };
231
- return ctx.provide("state", backend);
200
+ return backend;
232
201
  },
233
- };
202
+ });
234
203
  }