@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 +15 -0
- package/README.md +157 -0
- package/package.json +18 -13
- package/src/bindings.ts +37 -0
- package/src/durable-object.ts +197 -0
- package/src/index.ts +79 -110
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
"
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
"
|
|
12
|
-
|
|
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 {
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
*
|
|
74
|
-
* multiply by
|
|
75
|
-
* `kvNamespace("cache")` side by side, and each consumer
|
|
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
|
-
):
|
|
87
|
-
return {
|
|
88
|
-
name:
|
|
89
|
-
apply(
|
|
90
|
-
|
|
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
|
-
*
|
|
105
|
-
* by
|
|
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
|
-
):
|
|
115
|
-
return {
|
|
116
|
-
name:
|
|
117
|
-
apply(
|
|
118
|
-
|
|
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
|
-
*
|
|
130
|
-
*
|
|
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
|
-
):
|
|
140
|
-
return {
|
|
141
|
-
name:
|
|
142
|
-
apply(
|
|
143
|
-
|
|
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
|
|
155
|
-
*
|
|
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
|
|
159
|
-
*
|
|
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
|
-
):
|
|
170
|
-
return {
|
|
171
|
-
name:
|
|
172
|
-
apply(
|
|
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
|
-
|
|
152
|
+
`${capability}: required environment variable "${key}" is not set`,
|
|
179
153
|
);
|
|
180
154
|
values[key] = value;
|
|
181
155
|
}
|
|
182
|
-
|
|
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
|
|
192
|
-
*
|
|
193
|
-
*
|
|
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
|
-
* .
|
|
199
|
-
* .use(kvState("
|
|
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
|
-
):
|
|
210
|
-
return {
|
|
211
|
-
name:
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
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
|
|
194
|
+
await kv.put(`${ns}:${key}`, JSON.stringify(value), options);
|
|
226
195
|
},
|
|
227
196
|
async delete(ns, key) {
|
|
228
|
-
await
|
|
197
|
+
await kv.delete(`${ns}:${key}`);
|
|
229
198
|
},
|
|
230
199
|
};
|
|
231
|
-
return
|
|
200
|
+
return backend;
|
|
232
201
|
},
|
|
233
|
-
};
|
|
202
|
+
});
|
|
234
203
|
}
|