@thomasfosterau/legation-alchemy-cloudflare 0.0.0-stage → 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 +172 -0
- package/LICENSE +21 -0
- package/README.md +222 -2
- package/dist/index.d.ts +329 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +613 -0
- package/dist/index.js.map +1 -0
- package/dist/rolldown-runtime-D8jQpDG7.js +12 -0
- package/package.json +58 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-D8jQpDG7.js";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as Stream from "effect/Stream";
|
|
4
|
+
import { Authorization, Backend, BackendError, Query, StoreError, TransportError } from "@thomasfosterau/legation";
|
|
5
|
+
import { Cvr, FanoutBus, HttpCache, InvalidationBus, Protocol, SyncServer } from "@thomasfosterau/legation/internal";
|
|
6
|
+
import { RuntimeContext } from "alchemy/RuntimeContext";
|
|
7
|
+
import * as Arr from "effect/Array";
|
|
8
|
+
import * as Duration from "effect/Duration";
|
|
9
|
+
import * as Exit from "effect/Exit";
|
|
10
|
+
import * as PubSub from "effect/PubSub";
|
|
11
|
+
import * as R from "effect/Record";
|
|
12
|
+
import * as Option from "effect/Option";
|
|
13
|
+
import * as Schema from "effect/Schema";
|
|
14
|
+
import * as Workers from "@alchemy.run/cloudflare/Workers";
|
|
15
|
+
import * as Clock from "effect/Clock";
|
|
16
|
+
import * as HttpServerRequest from "effect/http/HttpServerRequest";
|
|
17
|
+
import * as HttpServerResponse from "effect/http/HttpServerResponse";
|
|
18
|
+
import * as Ref from "effect/Ref";
|
|
19
|
+
import * as Scope from "effect/Scope";
|
|
20
|
+
import * as Queue from "effect/Queue";
|
|
21
|
+
// src/RoomProtocol.ts
|
|
22
|
+
var RoomProtocol_exports = /* @__PURE__ */ __exportAll({
|
|
23
|
+
decodePullRequest: () => decodePullRequest,
|
|
24
|
+
decodePushRequest: () => decodePushRequest,
|
|
25
|
+
decodeReadRequest: () => decodeReadRequest,
|
|
26
|
+
pokeFrame: () => pokeFrame,
|
|
27
|
+
routeOf: () => routeOf,
|
|
28
|
+
routes: () => routes
|
|
29
|
+
});
|
|
30
|
+
/**
|
|
31
|
+
* The room's HTTP routes. A room answers a route by the request path's last
|
|
32
|
+
* segment, so a request forwarded with its base path intact (`/sync/push`)
|
|
33
|
+
* reaches the same route as `/push`.
|
|
34
|
+
*/
|
|
35
|
+
const routes = {
|
|
36
|
+
push: "/push",
|
|
37
|
+
pull: "/pull",
|
|
38
|
+
read: "/read",
|
|
39
|
+
/** WebSocket upgrade: the poke channel. */
|
|
40
|
+
socket: "/socket"
|
|
41
|
+
};
|
|
42
|
+
const ROUTES = /* @__PURE__ */ new Set([
|
|
43
|
+
routes.push,
|
|
44
|
+
routes.pull,
|
|
45
|
+
routes.read,
|
|
46
|
+
routes.socket
|
|
47
|
+
]);
|
|
48
|
+
/**
|
|
49
|
+
* The route a request path addresses: its last segment, when that names one of
|
|
50
|
+
* the {@link routes}.
|
|
51
|
+
*/
|
|
52
|
+
const routeOf = (pathname) => {
|
|
53
|
+
const route = pathname.slice(pathname.lastIndexOf("/"));
|
|
54
|
+
return ROUTES.has(route) ? route : void 0;
|
|
55
|
+
};
|
|
56
|
+
/** The single poke frame a room socket sends, as a value to serialize. */
|
|
57
|
+
const pokeFrame = { type: "poke" };
|
|
58
|
+
/** Re-exported request decoders — the room's HTTP bodies are the protocol's. */
|
|
59
|
+
const decodePushRequest = Protocol.decodePushRequest;
|
|
60
|
+
/** The protocol's pull-request decoder. */
|
|
61
|
+
const decodePullRequest = Protocol.decodePullRequest;
|
|
62
|
+
/** The protocol's read-request decoder. */
|
|
63
|
+
const decodeReadRequest = Protocol.decodeReadRequest;
|
|
64
|
+
// src/BindingTransport.ts
|
|
65
|
+
/**
|
|
66
|
+
* BindingTransport
|
|
67
|
+
*
|
|
68
|
+
* A `ClientTransport` that reaches the sync server over a Cloudflare
|
|
69
|
+
* **service binding** (`env.API.fetch`) rather than over the public network.
|
|
70
|
+
*
|
|
71
|
+
* This is the carrier for the ordinary two-Worker shape: a SvelteKit Worker
|
|
72
|
+
* owns the domain and every HTML/SSR request, and the API Worker holding the
|
|
73
|
+
* database is *binding-only* — `workersDev: false`, no custom domain — so the
|
|
74
|
+
* front door stays the single enforcement point for security headers and
|
|
75
|
+
* cache/auth ordering. `RoomTransport` covers the browser (public HTTP plus a
|
|
76
|
+
* poke socket); nothing covered `env.API.fetch(...)`, which is what an SSR
|
|
77
|
+
* render has to use.
|
|
78
|
+
*
|
|
79
|
+
* Two things have to survive the hop, and both are about *identity*:
|
|
80
|
+
*
|
|
81
|
+
* - **The inbound cookie.** The bound Worker resolves the caller's room from
|
|
82
|
+
* the session, so a call that arrives without the browser's cookie is a
|
|
83
|
+
* different caller — at best rejected, at worst answered from some other
|
|
84
|
+
* room. Its CVR store is not the browser's, so its cookie names a
|
|
85
|
+
* conversation the browser is not in, and the browser's first pull degrades
|
|
86
|
+
* to the reset path with nothing surfaced.
|
|
87
|
+
* - **The origin.** The bound Worker sees which front door the request came
|
|
88
|
+
* through — origin checks, per-origin config, logging. A binding call
|
|
89
|
+
* carries no origin of its own, so the inbound one is forwarded, and a
|
|
90
|
+
* relative sync path resolves against the inbound URL.
|
|
91
|
+
*
|
|
92
|
+
* The client group id and the pull cookie need no special handling here: they
|
|
93
|
+
* travel in the protocol bodies (`PullRequest.clientGroupId`,
|
|
94
|
+
* `PullResponse.cookie`), which this carrier moves verbatim. That is the whole
|
|
95
|
+
* CVR conversation, and it is what {@link Transport.ClientTransport} promises.
|
|
96
|
+
*
|
|
97
|
+
* There is no poke channel over a binding: a service binding is
|
|
98
|
+
* request/response, and an SSR render pulls once. `pokes` is empty, which
|
|
99
|
+
* costs nothing — pull is the correctness path (ADR-0008) and pokes are a
|
|
100
|
+
* latency optimization for a long-lived client.
|
|
101
|
+
*/
|
|
102
|
+
var BindingTransport_exports = /* @__PURE__ */ __exportAll({ make: () => make$6 });
|
|
103
|
+
/** The headers a binding call carries over from the inbound request. */
|
|
104
|
+
const forwarded = (request) => {
|
|
105
|
+
const headers = {};
|
|
106
|
+
const cookie = request.headers.get("cookie");
|
|
107
|
+
if (cookie !== null && cookie !== "") headers.cookie = cookie;
|
|
108
|
+
headers.origin = request.headers.get("origin") ?? new URL(request.url).origin;
|
|
109
|
+
return headers;
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* Build the transport. Each call is one `binding.fetch` of a JSON POST —
|
|
113
|
+
* no network hop, no public route on the bound Worker.
|
|
114
|
+
*/
|
|
115
|
+
const make$6 = (options) => {
|
|
116
|
+
const base = new URL(options.path ?? "/sync", options.request.url);
|
|
117
|
+
const carried = forwarded(options.request);
|
|
118
|
+
const prefix = base.href.replace(/\/+$/, "");
|
|
119
|
+
const post = (route) => (body) => Effect.tryPromise({
|
|
120
|
+
try: async () => {
|
|
121
|
+
const response = await options.binding.fetch(new Request(`${prefix}${route}`, {
|
|
122
|
+
method: "POST",
|
|
123
|
+
body: JSON.stringify(body),
|
|
124
|
+
headers: {
|
|
125
|
+
"content-type": "application/json",
|
|
126
|
+
...carried,
|
|
127
|
+
...options.headers
|
|
128
|
+
}
|
|
129
|
+
}));
|
|
130
|
+
if (!response.ok) {
|
|
131
|
+
const detail = await response.json().catch(() => ({}));
|
|
132
|
+
throw new TransportError({ message: detail.error ?? `binding ${route} answered ${response.status}` });
|
|
133
|
+
}
|
|
134
|
+
return await response.json();
|
|
135
|
+
},
|
|
136
|
+
catch: (cause) => cause instanceof TransportError ? cause : new TransportError({
|
|
137
|
+
message: `binding ${route} request failed`,
|
|
138
|
+
cause
|
|
139
|
+
})
|
|
140
|
+
});
|
|
141
|
+
return {
|
|
142
|
+
push: post(routes.push),
|
|
143
|
+
pull: post(routes.pull),
|
|
144
|
+
read: post(routes.read),
|
|
145
|
+
pokes: Stream.empty,
|
|
146
|
+
status: Effect.succeed("connected"),
|
|
147
|
+
statusChanges: Stream.succeed("connected")
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
// src/D1Executor.ts
|
|
151
|
+
var D1Executor_exports = /* @__PURE__ */ __exportAll({ make: () => make$5 });
|
|
152
|
+
/**
|
|
153
|
+
* Build the executor over a bound D1 client. Needs the `RuntimeContext` of the
|
|
154
|
+
* Worker or Durable Object it runs in (the inner, per-instance phase of an
|
|
155
|
+
* Alchemy Durable Object has it).
|
|
156
|
+
*/
|
|
157
|
+
const make$5 = (db) => Effect.map(Effect.service(RuntimeContext), (runtime) => {
|
|
158
|
+
/** Run one statement with the captured context; a failed statement is a `BackendError`. */
|
|
159
|
+
const execute = (operation, statement) => statement.pipe(Effect.provideService(RuntimeContext, runtime), Effect.catchDefect((cause) => Effect.fail(new BackendError({
|
|
160
|
+
message: `D1 ${operation} failed`,
|
|
161
|
+
cause
|
|
162
|
+
}))));
|
|
163
|
+
return {
|
|
164
|
+
query: (fragment) => execute("query", db.prepare(fragment.text).bind(...fragment.params).all()).pipe(Effect.map((result) => result.results ?? [])),
|
|
165
|
+
run: (fragment) => execute("run", db.prepare(fragment.text).bind(...fragment.params).run()).pipe(Effect.asVoid),
|
|
166
|
+
transaction: (effect) => effect,
|
|
167
|
+
transactional: false
|
|
168
|
+
};
|
|
169
|
+
});
|
|
170
|
+
// src/DurableCvrStore.ts
|
|
171
|
+
var DurableCvrStore_exports = /* @__PURE__ */ __exportAll({ make: () => make$4 });
|
|
172
|
+
const KEY_PREFIX = "legation:cvr:";
|
|
173
|
+
/** Build the store over the room's `state.storage`, in the room's runtime context. */
|
|
174
|
+
const make$4 = (storage) => Effect.map(Effect.service(RuntimeContext), (runtime) => {
|
|
175
|
+
/** Run one storage call with the captured context; a failure is a `StoreError`. */
|
|
176
|
+
const execute = (operation, call) => call.pipe(Effect.provideService(RuntimeContext, runtime), Effect.catchDefect((cause) => Effect.fail(new StoreError({
|
|
177
|
+
message: `Durable Object storage ${operation} failed`,
|
|
178
|
+
cause
|
|
179
|
+
}))));
|
|
180
|
+
return {
|
|
181
|
+
get: (cookie) => execute("get", storage.get(`${KEY_PREFIX}${cookie}`)).pipe(Effect.map((rows) => rows === void 0 ? void 0 : { rows })),
|
|
182
|
+
set: (cookie, cvr) => execute("put", storage.put(`${KEY_PREFIX}${cookie}`, cvr.rows)),
|
|
183
|
+
remove: (cookie) => execute("delete", storage.delete(`${KEY_PREFIX}${cookie}`)).pipe(Effect.asVoid)
|
|
184
|
+
};
|
|
185
|
+
});
|
|
186
|
+
// src/DurableFanoutBroker.ts
|
|
187
|
+
var DurableFanoutBroker_exports = /* @__PURE__ */ __exportAll({ make: () => make$3 });
|
|
188
|
+
/** A shard's answer when its call failed or timed out: nobody to poke. */
|
|
189
|
+
const noRooms = [];
|
|
190
|
+
/** How many shard (or room) calls run at once. */
|
|
191
|
+
const CONCURRENCY = 8;
|
|
192
|
+
/**
|
|
193
|
+
* Build the room's {@link DurableBroker} over the shard namespace; its poke feed
|
|
194
|
+
* lives as long as the scope. Needs the room's `RuntimeContext`, which it
|
|
195
|
+
* captures for its calls.
|
|
196
|
+
*/
|
|
197
|
+
const make$3 = (options) => Effect.gen(function* () {
|
|
198
|
+
const runtime = yield* RuntimeContext;
|
|
199
|
+
const hub = yield* Effect.acquireRelease(PubSub.dropping({ capacity: 256 }), (pubsub) => PubSub.shutdown(pubsub));
|
|
200
|
+
const timeout = options.timeout ?? "5 seconds";
|
|
201
|
+
/**
|
|
202
|
+
* One bounded, fire-and-forget call: a failure (the stub's `RpcCallError`
|
|
203
|
+
* included, which its types do not show) is dropped, since pull is the
|
|
204
|
+
* correctness path; a call past the bound is logged and dropped.
|
|
205
|
+
*/
|
|
206
|
+
const call = (what, effect, fallback) => effect.pipe(Effect.provideService(RuntimeContext, runtime), Effect.exit, Effect.map(Exit.match({
|
|
207
|
+
onFailure: () => fallback,
|
|
208
|
+
onSuccess: (value) => value
|
|
209
|
+
})), Effect.timeoutOrElse({
|
|
210
|
+
duration: timeout,
|
|
211
|
+
orElse: () => Effect.as(Effect.logWarning(`DurableFanoutBroker: ${what} timed out; dropped`), fallback)
|
|
212
|
+
}));
|
|
213
|
+
return {
|
|
214
|
+
broker: {
|
|
215
|
+
register: (roomId, topics, ttl) => Effect.forEach(topics, (topic) => call(`register to shard "${topic}"`, options.shards.getByName(topic).register(roomId, Duration.toMillis(ttl)), void 0), {
|
|
216
|
+
discard: true,
|
|
217
|
+
concurrency: CONCURRENCY
|
|
218
|
+
}),
|
|
219
|
+
publish: (topics) => Effect.gen(function* () {
|
|
220
|
+
const answers = yield* Effect.forEach([...topics], (topic) => call(`publish to shard "${topic}"`, options.shards.getByName(topic).publish(), noRooms).pipe(Effect.map((rooms) => ({
|
|
221
|
+
topic,
|
|
222
|
+
rooms
|
|
223
|
+
}))), { concurrency: CONCURRENCY });
|
|
224
|
+
const matched = Arr.groupBy(Arr.flatMap(answers, ({ topic, rooms }) => Arr.map(rooms, (roomId) => ({
|
|
225
|
+
roomId,
|
|
226
|
+
topic
|
|
227
|
+
}))), ({ roomId }) => roomId);
|
|
228
|
+
yield* Effect.forEach(R.toEntries(matched), ([roomId, matches]) => call(`poke of room "${roomId}"`, options.poke(roomId, Arr.map(matches, ({ topic }) => topic)), void 0), {
|
|
229
|
+
discard: true,
|
|
230
|
+
concurrency: CONCURRENCY
|
|
231
|
+
});
|
|
232
|
+
}),
|
|
233
|
+
pokes: () => Effect.gen(function* () {
|
|
234
|
+
const subscription = yield* PubSub.subscribe(hub);
|
|
235
|
+
return { take: PubSub.take(subscription) };
|
|
236
|
+
})
|
|
237
|
+
},
|
|
238
|
+
notify: (topics) => PubSub.publish(hub, topics).pipe(Effect.asVoid)
|
|
239
|
+
};
|
|
240
|
+
});
|
|
241
|
+
// src/EdgeInvalidation.ts
|
|
242
|
+
/**
|
|
243
|
+
* EdgeInvalidation
|
|
244
|
+
*
|
|
245
|
+
* Server-side cache eviction on Cloudflare from the hints a committed write
|
|
246
|
+
* publishes (#206): the `InvalidationSink`s for a Workers KV read-through cache
|
|
247
|
+
* and for the Workers cache's tag purge, and the hop between the two Workers
|
|
248
|
+
* that a typical deployment has.
|
|
249
|
+
*
|
|
250
|
+
* - {@link kv} deletes `{collection}:{id}` for every row a write touched.
|
|
251
|
+
* - {@link cachePurge} purges the edge cache by tag, with the tag spelling of
|
|
252
|
+
* `HttpCache` (`collection:{name}`, `collection:{name}:{id}`), the same one
|
|
253
|
+
* the browser's Cache API invalidation uses: one convention, one place.
|
|
254
|
+
* - {@link forward} and {@link receive} carry what a write touched from the
|
|
255
|
+
* Worker that commits it to the Worker that holds the cache.
|
|
256
|
+
*
|
|
257
|
+
* ## Where the cache is
|
|
258
|
+
*
|
|
259
|
+
* `cache.purge({ tags })` purges only the calling Worker's own cache. In the
|
|
260
|
+
* usual two-Worker shape the cache belongs to the front door (the SvelteKit
|
|
261
|
+
* Worker that serves every page), while writes land in the API Worker behind
|
|
262
|
+
* a service binding (`BindingTransport`). So the API Worker runs
|
|
263
|
+
* `InvalidationSink.run(EdgeInvalidation.forward(target))` where its bus is,
|
|
264
|
+
* and the front door runs the sinks: its RPC entrypoint passes each payload to
|
|
265
|
+
* {@link receive}. Use an RPC method on a `WorkerEntrypoint` for the target,
|
|
266
|
+
* not a `fetch` route: an RPC method is reachable only through the binding,
|
|
267
|
+
* while a route on the front door's `fetch` is reachable from the internet,
|
|
268
|
+
* and anyone could then purge its cache.
|
|
269
|
+
*
|
|
270
|
+
* ## Failure
|
|
271
|
+
*
|
|
272
|
+
* Fire-and-forget. A failed KV delete, a failed forward and a purge that
|
|
273
|
+
* rejects are logged, and so is a purge that resolves `{ success: false }`:
|
|
274
|
+
* `cache.purge` reports a Cloudflare-side failure that way rather than by
|
|
275
|
+
* rejecting, so a `catch` alone never sees it. None of them fails or delays
|
|
276
|
+
* the write, which has already committed by the time its hint is published.
|
|
277
|
+
* The cache's TTL is the backstop for a lost eviction.
|
|
278
|
+
*/
|
|
279
|
+
var EdgeInvalidation_exports = /* @__PURE__ */ __exportAll({
|
|
280
|
+
cachePurge: () => cachePurge,
|
|
281
|
+
encode: () => encode,
|
|
282
|
+
forward: () => forward,
|
|
283
|
+
kv: () => kv,
|
|
284
|
+
kvKey: () => kvKey,
|
|
285
|
+
receive: () => receive
|
|
286
|
+
});
|
|
287
|
+
/** The default KV key of a row: `{collection}:{id}`. */
|
|
288
|
+
const kvKey = (collection, id) => `${collection}:${id}`;
|
|
289
|
+
/**
|
|
290
|
+
* A sink that deletes each touched row's entry from a KV read-through cache.
|
|
291
|
+
* A failed delete is logged; the entry's TTL is the backstop.
|
|
292
|
+
*/
|
|
293
|
+
const kv = (namespace, options = {}) => {
|
|
294
|
+
const keyFor = options.keyFor ?? kvKey;
|
|
295
|
+
return { invalidate: (touched) => Effect.forEach(Arr.dedupe(touched.rows.map((row) => keyFor(row.collection, row.id))), (key) => Effect.tryPromise(() => namespace.delete(key)).pipe(Effect.catch((error) => Effect.logWarning(`EdgeInvalidation: KV delete of "${key}" failed`, error))), {
|
|
296
|
+
concurrency: options.concurrency ?? 8,
|
|
297
|
+
discard: true
|
|
298
|
+
}) };
|
|
299
|
+
};
|
|
300
|
+
/**
|
|
301
|
+
* A sink that purges the edge cache by tag: the coarse tag of every touched
|
|
302
|
+
* collection and the fine tag of every touched row (`HttpCache.evictionTags`).
|
|
303
|
+
* A purge that rejects, and one that resolves `{ success: false }`, is logged.
|
|
304
|
+
*/
|
|
305
|
+
const cachePurge = (cache, options = {}) => {
|
|
306
|
+
const size = Math.max(1, options.maxTagsPerPurge ?? 30);
|
|
307
|
+
return { invalidate: (touched) => Effect.forEach(Arr.chunksOf(HttpCache.evictionTags(touched, options), size), (tags) => Effect.tryPromise(() => cache.purge({ tags: [...tags] })).pipe(Effect.flatMap((result) => result.success ? Effect.void : Effect.logWarning("EdgeInvalidation: cache purge reported failure", result.errors ?? [])), Effect.catch((error) => Effect.logWarning("EdgeInvalidation: cache purge failed", error))), {
|
|
308
|
+
concurrency: 1,
|
|
309
|
+
discard: true
|
|
310
|
+
}) };
|
|
311
|
+
};
|
|
312
|
+
/** The wire form of what a write touched. */
|
|
313
|
+
const TouchedJson = Schema.fromJsonString(Schema.Struct({
|
|
314
|
+
collections: Schema.Array(Schema.String),
|
|
315
|
+
rows: Schema.Array(Schema.Struct({
|
|
316
|
+
collection: Schema.String,
|
|
317
|
+
id: Schema.String
|
|
318
|
+
}))
|
|
319
|
+
}));
|
|
320
|
+
/** Encode what a write touched for {@link InvalidationTarget.invalidate}. */
|
|
321
|
+
const encode = (touched) => Schema.encodeSync(TouchedJson)({
|
|
322
|
+
collections: [...touched.collections],
|
|
323
|
+
rows: touched.rows.map((row) => ({
|
|
324
|
+
collection: row.collection,
|
|
325
|
+
id: row.id
|
|
326
|
+
}))
|
|
327
|
+
});
|
|
328
|
+
/**
|
|
329
|
+
* A sink that forwards what a write touched to the Worker holding the cache.
|
|
330
|
+
* A failed call is logged.
|
|
331
|
+
*/
|
|
332
|
+
const forward = (target) => ({ invalidate: (touched) => Effect.tryPromise(() => target.invalidate(encode(touched))).pipe(Effect.catch((error) => Effect.logWarning("EdgeInvalidation: forwarding an invalidation failed", error))) });
|
|
333
|
+
/**
|
|
334
|
+
* The receiving end of {@link forward}: decode a payload and run `sink` on it.
|
|
335
|
+
* A payload that does not decode is logged and dropped. Call it from the RPC
|
|
336
|
+
* method the binding reaches.
|
|
337
|
+
*/
|
|
338
|
+
const receive = (sink) => (payload) => Option.match(Schema.decodeUnknownOption(TouchedJson)(payload), {
|
|
339
|
+
onNone: () => Effect.logWarning("EdgeInvalidation: dropped a payload that does not decode"),
|
|
340
|
+
onSome: (touched) => sink.invalidate(touched)
|
|
341
|
+
});
|
|
342
|
+
// src/FanoutShard.ts
|
|
343
|
+
/**
|
|
344
|
+
* FanoutShard
|
|
345
|
+
*
|
|
346
|
+
* The Durable Object side of ADR-0023's broker, as an Alchemy Durable Object:
|
|
347
|
+
* one shard instance per topic (the object is named BY the topic, so
|
|
348
|
+
* cardinality is managed by the platform — cold topics cost nothing until
|
|
349
|
+
* registered). The shard holds **leases** in its transactional storage:
|
|
350
|
+
* `roomId → expiresAt` for its topic. `register` writes a lease; `publish`
|
|
351
|
+
* answers the unexpired rooms, and expired leases are dropped as publishes pass
|
|
352
|
+
* by — crashed rooms age out with no distributed cleanup.
|
|
353
|
+
*
|
|
354
|
+
* The publishing room pokes the rooms a shard answers
|
|
355
|
+
* (`DurableFanoutBroker`), rather than the shard calling the rooms itself:
|
|
356
|
+
* a shard that called rooms would need the room namespace, and the room needs
|
|
357
|
+
* the shard namespace, which Alchemy's layers cannot provide to each other.
|
|
358
|
+
* Answering the room ids keeps the shard free of any binding.
|
|
359
|
+
*
|
|
360
|
+
* The app declares the class and binds this implementation to it:
|
|
361
|
+
*
|
|
362
|
+
* ```ts
|
|
363
|
+
* export class LegationShard extends Cloudflare.DurableObject<
|
|
364
|
+
* LegationShard,
|
|
365
|
+
* FanoutShard.ShardObject
|
|
366
|
+
* >()("LegationShard") {}
|
|
367
|
+
*
|
|
368
|
+
* export const LegationShardLive = LegationShard.make(FanoutShard.make());
|
|
369
|
+
* ```
|
|
370
|
+
*/
|
|
371
|
+
var FanoutShard_exports = /* @__PURE__ */ __exportAll({
|
|
372
|
+
leases: () => leases,
|
|
373
|
+
make: () => make$2
|
|
374
|
+
});
|
|
375
|
+
const LEASE_PREFIX = "legation:lease:";
|
|
376
|
+
/** The lease operations, storage-backed (exported for direct testing). */
|
|
377
|
+
const leases = {
|
|
378
|
+
register: (storage, roomId, ttlMillis, now) => storage.put(`${LEASE_PREFIX}${roomId}`, now + ttlMillis),
|
|
379
|
+
/** The unexpired room ids, in listing order; expired entries are deleted as a side effect. */
|
|
380
|
+
live: (storage, now) => Effect.gen(function* () {
|
|
381
|
+
const entries = [...yield* storage.list({ prefix: LEASE_PREFIX })];
|
|
382
|
+
const expired = entries.filter(([, expiresAt]) => expiresAt <= now).map(([key]) => key);
|
|
383
|
+
if (Arr.isReadonlyArrayNonEmpty(expired)) yield* storage.delete(expired);
|
|
384
|
+
return entries.filter(([, expiresAt]) => expiresAt > now).map(([key]) => key.slice(15));
|
|
385
|
+
})
|
|
386
|
+
};
|
|
387
|
+
/**
|
|
388
|
+
* The shard's Durable Object implementation, for `LegationShard.make(...)`:
|
|
389
|
+
* its state is resolved at init, its leases read and written per call.
|
|
390
|
+
*/
|
|
391
|
+
const make$2 = () => Effect.gen(function* () {
|
|
392
|
+
const state = yield* Workers.DurableObjectState;
|
|
393
|
+
return Effect.succeed({
|
|
394
|
+
register: (roomId, ttlMillis) => Effect.flatMap(Clock.currentTimeMillis, (now) => leases.register(state.storage, roomId, ttlMillis, now)),
|
|
395
|
+
publish: () => Effect.flatMap(Clock.currentTimeMillis, (now) => leases.live(state.storage, now))
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
// src/Room.ts
|
|
399
|
+
/**
|
|
400
|
+
* Room
|
|
401
|
+
*
|
|
402
|
+
* The per-user Durable Object of ADR-0022's reference topology, as an Alchemy
|
|
403
|
+
* Durable Object: it runs the sync `Server` for one user — push/pull/read over
|
|
404
|
+
* stateless HTTP (hibernation-proof), CVRs in the object's transactional
|
|
405
|
+
* storage (co-located with the user), a `FanoutBus` over the topic shards for
|
|
406
|
+
* cross-room fan-out, and WebSockets that carry exactly one thing: contentless
|
|
407
|
+
* poke frames, sent when a publish matches the room's leases, so hibernated
|
|
408
|
+
* sockets cost nothing between writes.
|
|
409
|
+
*
|
|
410
|
+
* The app declares the class (the shard class beside it) and binds
|
|
411
|
+
* {@link make} to it, resolving the bindings the room needs at init:
|
|
412
|
+
*
|
|
413
|
+
* ```ts
|
|
414
|
+
* export class LegationRoom extends Cloudflare.DurableObject<LegationRoom, Room.RoomObject>()(
|
|
415
|
+
* "LegationRoom"
|
|
416
|
+
* ) {}
|
|
417
|
+
*
|
|
418
|
+
* export const LegationRoomLive = LegationRoom.make(
|
|
419
|
+
* Effect.gen(function* () {
|
|
420
|
+
* const db = yield* Cloudflare.D1.QueryDatabase(AppDatabase);
|
|
421
|
+
* return yield* Room.make({
|
|
422
|
+
* nodes,
|
|
423
|
+
* backend: Effect.map(D1Executor.make(db), (executor) => SqlBackend.make({ executor, ... })),
|
|
424
|
+
* policy: Effect.succeed(Authorization.policy({ ... })),
|
|
425
|
+
* shards: LegationShard,
|
|
426
|
+
* });
|
|
427
|
+
* })
|
|
428
|
+
* );
|
|
429
|
+
* ```
|
|
430
|
+
*
|
|
431
|
+
* The server is built once per in-memory instance, in the object's
|
|
432
|
+
* per-instance phase (Alchemy runs it under `blockConcurrencyWhile` when the
|
|
433
|
+
* object is constructed, a wake from hibernation included).
|
|
434
|
+
*/
|
|
435
|
+
var Room_exports = /* @__PURE__ */ __exportAll({ make: () => make$1 });
|
|
436
|
+
/** Run an effect on the room's clock: the configured one, or the real clock when none is set. */
|
|
437
|
+
const onRoomClock = (clock, effect) => clock === void 0 ? effect : effect.pipe(Effect.provideService(Clock.Clock, clock));
|
|
438
|
+
/** A failed protocol request, answered as a 400 carrying the error's message. */
|
|
439
|
+
const badRequest = (error) => Effect.succeed(HttpServerResponse.jsonUnsafe({ error: error.message }, { status: 400 }));
|
|
440
|
+
/** Answer one JSON protocol request with the matching handler operation. */
|
|
441
|
+
const answer = (handle) => handle.pipe(Effect.map((response) => HttpServerResponse.jsonUnsafe(response)), Effect.catchTags({
|
|
442
|
+
ProtocolError: badRequest,
|
|
443
|
+
TransportError: badRequest,
|
|
444
|
+
VersionNotSupportedError: badRequest
|
|
445
|
+
}));
|
|
446
|
+
const notFound = HttpServerResponse.text("not found", { status: 404 });
|
|
447
|
+
/**
|
|
448
|
+
* The room's Durable Object implementation, for `LegationRoom.make(...)`. Init
|
|
449
|
+
* resolves the object's state, its own namespace (to poke sibling rooms) and
|
|
450
|
+
* the shard namespace; the per-instance phase builds the sync server.
|
|
451
|
+
*/
|
|
452
|
+
const make$1 = (config) => Effect.gen(function* () {
|
|
453
|
+
const state = yield* Workers.DurableObjectState;
|
|
454
|
+
const rooms = yield* Workers.DurableObjectScope;
|
|
455
|
+
const shards = yield* config.shards;
|
|
456
|
+
return onRoomClock(config.clock, Effect.gen(function* () {
|
|
457
|
+
const scope = yield* Scope.make();
|
|
458
|
+
const runtime = yield* RuntimeContext;
|
|
459
|
+
const roomId = state.id.name ?? state.id.toString();
|
|
460
|
+
/** Every poke socket gets one contentless frame. */
|
|
461
|
+
const nudgeSockets = Effect.flatMap(state.getWebSockets(), (sockets) => Effect.forEach(sockets, (socket) => socket.send(JSON.stringify(pokeFrame)), {
|
|
462
|
+
concurrency: "unbounded",
|
|
463
|
+
discard: true
|
|
464
|
+
}));
|
|
465
|
+
return yield* Effect.gen(function* () {
|
|
466
|
+
const durable = yield* make$3({
|
|
467
|
+
shards,
|
|
468
|
+
poke: (target, topics) => target === roomId ? deliver(topics) : rooms.getByName(target).poke(topics),
|
|
469
|
+
timeout: config.publishTimeout
|
|
470
|
+
});
|
|
471
|
+
/** An inbound poke: the bus's matcher pokes the affected groups' pull loops. */
|
|
472
|
+
const deliver = (topics) => Effect.andThen(durable.notify(topics), nudgeSockets);
|
|
473
|
+
const subsetsRef = yield* Ref.make(Effect.succeed([]));
|
|
474
|
+
const bus = yield* FanoutBus.make({
|
|
475
|
+
roomId,
|
|
476
|
+
broker: durable.broker,
|
|
477
|
+
nodes: config.nodes,
|
|
478
|
+
subsets: Ref.get(subsetsRef).pipe(Effect.flatten),
|
|
479
|
+
heartbeat: config.heartbeat ?? "30 seconds",
|
|
480
|
+
timeout: config.publishTimeout
|
|
481
|
+
});
|
|
482
|
+
const cvrs = yield* make$4(state.storage);
|
|
483
|
+
let build = SyncServer.makeWithIntrospection.pipe(Effect.provideService(Backend.Backend, yield* config.backend), Effect.provideService(Authorization.AuthPolicy, yield* config.policy), Effect.provideService(Cvr.CvrStore, cvrs), Effect.provideService(InvalidationBus.InvalidationBus, bus));
|
|
484
|
+
if (config.identity !== void 0) build = build.pipe(Effect.provideService(Authorization.IdentityResolver, yield* config.identity));
|
|
485
|
+
if (config.queries !== void 0) build = build.pipe(Effect.provideService(Query.QueryResolver, yield* config.queries));
|
|
486
|
+
const { handler, introspection } = yield* build;
|
|
487
|
+
yield* Ref.set(subsetsRef, introspection.groups.pipe(Effect.map((groups) => groups.flatMap((group) => group.subsets))));
|
|
488
|
+
return {
|
|
489
|
+
fetch: onRoomClock(config.clock, Effect.gen(function* () {
|
|
490
|
+
const request = yield* HttpServerRequest.HttpServerRequest;
|
|
491
|
+
const route = routeOf(new URL(request.url, "https://room").pathname);
|
|
492
|
+
if (route === routes.socket) {
|
|
493
|
+
const [response] = yield* Workers.upgrade();
|
|
494
|
+
return response;
|
|
495
|
+
}
|
|
496
|
+
if (route === routes.push) {
|
|
497
|
+
const body = yield* request.json;
|
|
498
|
+
return yield* answer(Effect.flatMap(decodePushRequest(body), (decoded) => handler.handlePush(decoded)));
|
|
499
|
+
}
|
|
500
|
+
if (route === routes.pull) {
|
|
501
|
+
const body = yield* request.json;
|
|
502
|
+
return yield* answer(Effect.flatMap(decodePullRequest(body), (decoded) => handler.handlePull(decoded)));
|
|
503
|
+
}
|
|
504
|
+
if (route === routes.read) {
|
|
505
|
+
const body = yield* request.json;
|
|
506
|
+
return yield* answer(Effect.flatMap(decodeReadRequest(body), (decoded) => handler.handleRead(decoded)));
|
|
507
|
+
}
|
|
508
|
+
return notFound;
|
|
509
|
+
})),
|
|
510
|
+
poke: (topics) => onRoomClock(config.clock, deliver(topics).pipe(Effect.provideService(RuntimeContext, runtime))),
|
|
511
|
+
webSocketMessage: () => Effect.void,
|
|
512
|
+
webSocketClose: () => Effect.void
|
|
513
|
+
};
|
|
514
|
+
}).pipe(Scope.provide(scope));
|
|
515
|
+
}));
|
|
516
|
+
});
|
|
517
|
+
// src/RoomTransport.ts
|
|
518
|
+
/**
|
|
519
|
+
* RoomTransport
|
|
520
|
+
*
|
|
521
|
+
* The browser-side `ClientTransport` for the room topology: push, pull,
|
|
522
|
+
* and fallthrough reads travel as stateless HTTP POSTs (each one wakes the
|
|
523
|
+
* hibernated room, is answered, and lets it sleep again), while a single
|
|
524
|
+
* WebSocket carries the room's contentless poke frames. The socket
|
|
525
|
+
* reconnects with backoff and its state drives the connection status; sync
|
|
526
|
+
* stays correct over HTTP alone (a dead socket only delays pokes — the
|
|
527
|
+
* pull interval remains the safety net).
|
|
528
|
+
*
|
|
529
|
+
* Runs on standard `fetch`/`WebSocket` globals — no Workers types — so the
|
|
530
|
+
* same transport serves browsers, service workers, and Node 22+.
|
|
531
|
+
*/
|
|
532
|
+
var RoomTransport_exports = /* @__PURE__ */ __exportAll({ make: () => make });
|
|
533
|
+
const post = (options, route) => (body) => Effect.tryPromise({
|
|
534
|
+
try: async () => {
|
|
535
|
+
const response = await fetch(`${options.url}${route}`, {
|
|
536
|
+
method: "POST",
|
|
537
|
+
body: JSON.stringify(body),
|
|
538
|
+
headers: {
|
|
539
|
+
"content-type": "application/json",
|
|
540
|
+
...options.headers
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
if (!response.ok) {
|
|
544
|
+
const detail = await response.json().catch(() => ({}));
|
|
545
|
+
throw new TransportError({ message: detail.error ?? `room ${route} answered ${response.status}` });
|
|
546
|
+
}
|
|
547
|
+
return await response.json();
|
|
548
|
+
},
|
|
549
|
+
catch: (cause) => cause instanceof TransportError ? cause : new TransportError({
|
|
550
|
+
message: `room ${route} request failed`,
|
|
551
|
+
cause
|
|
552
|
+
})
|
|
553
|
+
});
|
|
554
|
+
/**
|
|
555
|
+
* Build the transport. The poke socket dials lazily on first subscription
|
|
556
|
+
* and re-dials with linear backoff; each frame becomes one contentless
|
|
557
|
+
* poke.
|
|
558
|
+
*/
|
|
559
|
+
const make = (options) => {
|
|
560
|
+
const socketUrl = `${options.url.replace(/^http/, "ws")}${routes.socket}`;
|
|
561
|
+
return {
|
|
562
|
+
push: (request) => post(options, routes.push)(request),
|
|
563
|
+
pull: (request) => post(options, routes.pull)(request),
|
|
564
|
+
read: (request) => post(options, routes.read)(request),
|
|
565
|
+
pokes: Stream.callback((queue) => Effect.acquireRelease(Effect.sync(() => {
|
|
566
|
+
let socket;
|
|
567
|
+
let closed = false;
|
|
568
|
+
let attempt = 0;
|
|
569
|
+
const dial = () => {
|
|
570
|
+
if (closed) return;
|
|
571
|
+
socket = new WebSocket(socketUrl);
|
|
572
|
+
socket.onmessage = () => {
|
|
573
|
+
attempt = 0;
|
|
574
|
+
Queue.offer(queue, {}).pipe(Effect.runPromise);
|
|
575
|
+
};
|
|
576
|
+
socket.onclose = () => {
|
|
577
|
+
if (closed) return;
|
|
578
|
+
attempt += 1;
|
|
579
|
+
const delay = Math.min(1e3 * attempt, options.maxReconnectDelayMillis ?? 1e4);
|
|
580
|
+
setTimeout(dial, delay);
|
|
581
|
+
};
|
|
582
|
+
socket.onerror = () => socket?.close();
|
|
583
|
+
};
|
|
584
|
+
dial();
|
|
585
|
+
return () => {
|
|
586
|
+
closed = true;
|
|
587
|
+
socket?.close();
|
|
588
|
+
};
|
|
589
|
+
}), (close) => Effect.sync(close))),
|
|
590
|
+
status: Effect.succeed("connected"),
|
|
591
|
+
statusChanges: Stream.succeed("connected")
|
|
592
|
+
};
|
|
593
|
+
};
|
|
594
|
+
// src/Worker.ts
|
|
595
|
+
var Worker_exports = /* @__PURE__ */ __exportAll({ routeSync: () => routeSync });
|
|
596
|
+
/**
|
|
597
|
+
* Route one request: `Option.none()` for a path outside the base, a 404 for an
|
|
598
|
+
* unknown route under it, a 401 for an anonymous caller, otherwise the room's
|
|
599
|
+
* own response.
|
|
600
|
+
*/
|
|
601
|
+
const routeSync = (config) => Effect.gen(function* () {
|
|
602
|
+
const request = yield* HttpServerRequest.HttpServerRequest;
|
|
603
|
+
const base = config.basePath ?? "/sync";
|
|
604
|
+
const pathname = new URL(request.url, "https://worker").pathname;
|
|
605
|
+
if (!pathname.startsWith(`${base}/`)) return Option.none();
|
|
606
|
+
if (routeOf(pathname) === void 0 || pathname.lastIndexOf("/") !== base.length) return Option.some(HttpServerResponse.text("not found", { status: 404 }));
|
|
607
|
+
const roomId = yield* config.roomIdOf(request);
|
|
608
|
+
if (roomId === void 0) return Option.some(HttpServerResponse.text("unauthorized", { status: 401 }));
|
|
609
|
+
return Option.some(yield* config.rooms.getByName(roomId).fetch(request));
|
|
610
|
+
});
|
|
611
|
+
export { BindingTransport_exports as BindingTransport, D1Executor_exports as D1Executor, DurableCvrStore_exports as DurableCvrStore, DurableFanoutBroker_exports as DurableFanoutBroker, EdgeInvalidation_exports as EdgeInvalidation, FanoutShard_exports as FanoutShard, Room_exports as Room, RoomProtocol_exports as RoomProtocol, RoomTransport_exports as RoomTransport, Worker_exports as Worker };
|
|
612
|
+
|
|
613
|
+
//# sourceMappingURL=index.js.map
|