@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/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