dynamodb-reactive 0.6.1 → 0.7.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.
@@ -1,5 +1,7 @@
1
1
  import { SubscribeRequest, SnapshotResponse, UnsubscribeRequest, JsonPatch } from './cluster.js';
2
2
  import { HubSocket, HubPaths } from './hub.js';
3
+ import { ClusterTicket } from './cluster.js';
4
+ import './client-BPAct-Dz.js';
3
5
 
4
6
  /**
5
7
  * Cloudflare Durable Object shell for the reactive hub. It is structural: it
@@ -140,6 +142,8 @@ interface RemoteHubOptions {
140
142
  url: string;
141
143
  /** The Worker's `notifySecret`. Keep it server-side. */
142
144
  secret: string;
145
+ /** Aborts a notify that takes longer than this. Default 3000 ms. */
146
+ timeoutMs?: number;
143
147
  /** Injectable for tests. */
144
148
  fetch?: typeof fetch;
145
149
  }
@@ -181,5 +185,136 @@ interface ReactiveRoutesOptions {
181
185
  /** Serves the ticket, socket, reactive and notify routes; `undefined` for any other path. */
182
186
  declare function handleReactiveRoutes(request: Request, options: ReactiveRoutesOptions): Promise<Response | undefined>;
183
187
 
184
- export { HEARTBEAT_TIMEOUT_CLOSE_CODE, HUB_PRINCIPAL_HEADER, createDurableObjectHub, handleReactiveRoutes, hubClient, remoteHub };
185
- export type { DurableObjectHub, DurableObjectHubOptions, DurableObjectStateLike, DurableObjectWebSocket, HubClientFrame, HubPresenceOptions, HubStub, ReactiveRoutes, ReactiveRoutesOptions, RemoteHubOptions };
188
+ /** Routes a `defineHub` Worker serves. */
189
+ declare const HUB_ROUTES: {
190
+ /** `POST` with the notify secret and `?principal=`: a ticket for a machine client. */
191
+ readonly ticket: "/api/hub/token";
192
+ /** WebSocket upgrade with a signed ticket in `?token=`. */
193
+ readonly socket: "/api/hub/socket";
194
+ /** Reactive HTTP subscribe and unsubscribe, with a signed ticket as the bearer. */
195
+ readonly reactive: "/api/hub/reactive";
196
+ /** `POST { path, patches? }` with the notify secret, after the data behind a path changed. */
197
+ readonly notify: "/api/hub/notify";
198
+ /** `GET` with the notify secret: `{ principals }`, the tracked principals holding a live socket. */
199
+ readonly presence: "/api/hub/presence";
200
+ };
201
+ /** Names of the Worker secrets and variables a `defineHub` Worker reads. */
202
+ declare const HUB_ENV: {
203
+ readonly TICKET_SECRET: "REACTIVE_TICKET_SECRET";
204
+ readonly NOTIFY_SECRET: "HUB_NOTIFY_SECRET";
205
+ /** Comma-separated browser origins allowed to call the hub cross-origin. */
206
+ readonly ALLOWED_ORIGINS: "ALLOWED_ORIGINS";
207
+ };
208
+ /** The Durable Object class name and binding a `defineHub` Worker uses. */
209
+ declare const HUB_DURABLE_OBJECT: {
210
+ readonly className: "ReactiveHub";
211
+ readonly binding: "REACTIVE_HUB";
212
+ };
213
+ /** The secrets and variables a `defineHub` Worker reads. */
214
+ interface HubWorkerEnv {
215
+ REACTIVE_TICKET_SECRET?: string;
216
+ HUB_NOTIFY_SECRET?: string;
217
+ ALLOWED_ORIGINS?: string;
218
+ REACTIVE_HUB: {
219
+ idFromName(name: string): unknown;
220
+ get(id: unknown): HubStub;
221
+ };
222
+ }
223
+ /** What a hub's path rules can read from the Durable Object. */
224
+ interface HubContext {
225
+ /** Whether `principal` holds a live socket. Needs `presence`. */
226
+ present(principal: string): boolean;
227
+ /** Every tracked principal holding a live socket, sorted. Needs `presence`. */
228
+ presentPrincipals(): string[];
229
+ }
230
+ interface HubConfig {
231
+ /** Path rules: who may watch each path and its snapshot. */
232
+ paths: (hub: HubContext) => HubPaths;
233
+ /**
234
+ * Principals whose live socket counts as presence, such as machine workers.
235
+ * They must ping more often than `timeoutMs`; `connectClusterWorker` does.
236
+ */
237
+ presence?: {
238
+ principals: readonly string[] | ((principal: string) => boolean);
239
+ /** @default 90000 */
240
+ timeoutMs?: number;
241
+ /** Paths notified when a principal comes online or goes offline, such as a status page. */
242
+ notify?: readonly string[];
243
+ };
244
+ /**
245
+ * Machine principals that may get a ticket from `HUB_ROUTES.ticket` by presenting
246
+ * the notify secret. Browsers get tickets from the app instead (`mintHubTicket`).
247
+ */
248
+ machinePrincipal?: (principal: string) => boolean;
249
+ }
250
+ /** The Durable Object a `defineHub` Worker exports as `ReactiveHub`. */
251
+ interface HubDurableObject {
252
+ fetch(request: Request): Promise<Response>;
253
+ webSocketMessage(socket: DurableObjectWebSocket, message: string | ArrayBuffer): Promise<void>;
254
+ webSocketClose(socket: DurableObjectWebSocket, code: number): Promise<void>;
255
+ webSocketError(socket: DurableObjectWebSocket): Promise<void>;
256
+ alarm(): Promise<void>;
257
+ }
258
+ /** What `defineHub` returns: export `ReactiveHub` by that name and `worker` as the default. */
259
+ interface DefinedHub {
260
+ ReactiveHub: new (ctx: DurableObjectStateLike, env: unknown) => HubDurableObject;
261
+ worker: {
262
+ fetch(request: Request, env: HubWorkerEnv): Promise<Response>;
263
+ };
264
+ }
265
+ /**
266
+ * A complete hub Worker: the Durable Object class and the Worker handler, with
267
+ * the ticket, socket, reactive, notify and presence routes. The application
268
+ * supplies only its path rules.
269
+ *
270
+ * ```ts
271
+ * const hub = defineHub({ paths: () => ({ 'status': { authorize: () => true, snapshot: () => ({}) } }) });
272
+ * export const ReactiveHub = hub.ReactiveHub;
273
+ * export default hub.worker;
274
+ * ```
275
+ *
276
+ * Deploy it with `dynamodb-reactive-hub deploy`, which writes the matching
277
+ * Wrangler config and puts the secrets.
278
+ */
279
+ declare function defineHub(config: HubConfig): DefinedHub;
280
+ /** The hub's two secrets, derived from one root secret the application already holds. */
281
+ interface HubSecrets {
282
+ /** `REACTIVE_TICKET_SECRET`: signs and verifies tickets. */
283
+ ticket: string;
284
+ /** `HUB_NOTIFY_SECRET`: notify, presence and machine ticket routes. */
285
+ notify: string;
286
+ }
287
+ /**
288
+ * Derives the hub secrets as HMAC-SHA256 hex of `root` under `<name>-ticket` and
289
+ * `<name>-notify`, where `name` is the hub Worker's name. Everything that already
290
+ * holds `root` derives them itself; `dynamodb-reactive-hub deploy` puts the same
291
+ * values on the Worker, so no hub secret has to be distributed.
292
+ */
293
+ declare function deriveHubSecrets(root: string, name: string): Promise<HubSecrets>;
294
+ interface MintHubTicketOptions {
295
+ /** The hub's origin, such as `https://my-hub.example.workers.dev`. */
296
+ origin: string;
297
+ /** The hub's ticket secret. */
298
+ secret: string;
299
+ principal: string;
300
+ /** @default 60 */
301
+ lifetimeSeconds?: number;
302
+ }
303
+ /** A browser ticket for the hub, minted by the app that knows the signed-in user. */
304
+ declare function mintHubTicket(options: MintHubTicketOptions): Promise<ClusterTicket & {
305
+ httpUrl: string;
306
+ }>;
307
+ /**
308
+ * The tracked principals holding a live socket, from `HUB_ROUTES.presence`, or
309
+ * undefined when the hub cannot be reached.
310
+ */
311
+ declare function fetchHubPresence(options: {
312
+ origin: string;
313
+ secret: string;
314
+ /** @default 3000 */
315
+ timeoutMs?: number;
316
+ fetch?: typeof fetch;
317
+ }): Promise<string[] | undefined>;
318
+
319
+ export { HEARTBEAT_TIMEOUT_CLOSE_CODE, HUB_DURABLE_OBJECT, HUB_ENV, HUB_PRINCIPAL_HEADER, HUB_ROUTES, createDurableObjectHub, defineHub, deriveHubSecrets, fetchHubPresence, handleReactiveRoutes, hubClient, mintHubTicket, remoteHub };
320
+ export type { DefinedHub, DurableObjectHub, DurableObjectHubOptions, DurableObjectStateLike, DurableObjectWebSocket, HubClientFrame, HubConfig, HubContext, HubDurableObject, HubPresenceOptions, HubSecrets, HubStub, HubWorkerEnv, MintHubTicketOptions, ReactiveRoutes, ReactiveRoutesOptions, RemoteHubOptions };
@@ -1,3 +1,8 @@
1
+ // ../client/src/log.ts
2
+ function error(...values) {
3
+ console.error(...values);
4
+ }
5
+
1
6
  // ../client/src/cluster.ts
2
7
  var DEFAULT_TICKET_LIFETIME_SECONDS = 60;
3
8
  function isRecord(value) {
@@ -261,7 +266,7 @@ var HEARTBEAT_TIMEOUT_CLOSE_CODE = 4001;
261
266
  function json(value, status = 200) {
262
267
  return Response.json(value, { status, headers: { "cache-control": "no-store" } });
263
268
  }
264
- function error(message, status) {
269
+ function error2(message, status) {
265
270
  return json({ type: "error", message }, status);
266
271
  }
267
272
  function workersGlobal(name) {
@@ -333,7 +338,7 @@ function createDurableObjectHub(ctx, options) {
333
338
  };
334
339
  const accept = async (request) => {
335
340
  const principal = request.headers.get(HUB_PRINCIPAL_HEADER);
336
- if (!principal) return error("Unauthorized", 401);
341
+ if (!principal) return error2("Unauthorized", 401);
337
342
  const Pair = options.WebSocketPair ?? workersGlobal("WebSocketPair");
338
343
  if (!Pair) throw new Error("WebSocketPair is not available in this runtime");
339
344
  const pair = new Pair();
@@ -368,10 +373,10 @@ function createDurableObjectHub(ctx, options) {
368
373
  await hub.publish(action.path, action.patches);
369
374
  return json({ type: "ok" });
370
375
  default:
371
- return error("Unknown hub action", 400);
376
+ return error2("Unknown hub action", 400);
372
377
  }
373
378
  } catch (cause) {
374
- if (cause instanceof ReactiveHubError) return error(cause.message, 400);
379
+ if (cause instanceof ReactiveHubError) return error2(cause.message, 400);
375
380
  throw cause;
376
381
  }
377
382
  };
@@ -453,12 +458,14 @@ function hubClient(stub) {
453
458
  publish: async (path, patches) => ok(await post({ action: "publish", path, patches }))
454
459
  };
455
460
  }
461
+ var DEFAULT_REMOTE_HUB_TIMEOUT_MS = 3e3;
456
462
  function remoteHub(options) {
457
463
  const send2 = async (body) => {
458
464
  const response = await (options.fetch ?? fetch)(options.url, {
459
465
  method: "POST",
460
466
  headers: { authorization: `Bearer ${options.secret}`, "content-type": "application/json" },
461
- body: JSON.stringify(body)
467
+ body: JSON.stringify(body),
468
+ signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_REMOTE_HUB_TIMEOUT_MS)
462
469
  });
463
470
  if (!response.ok) throw new Error(`Hub notify failed: HTTP ${response.status}`);
464
471
  };
@@ -508,31 +515,31 @@ async function handleReactiveRoutes(request, options) {
508
515
  });
509
516
  }
510
517
  if (url.pathname === routes.notify) {
511
- if (request.method !== "POST") return error("Method not allowed", 405);
512
- if (!options.notifySecret) return error("Hub notify is not configured", 503);
518
+ if (request.method !== "POST") return error2("Method not allowed", 405);
519
+ if (!options.notifySecret) return error2("Hub notify is not configured", 503);
513
520
  if (!sameSecret(bearerToken(request), options.notifySecret)) {
514
- return error("Unauthorized", 401);
521
+ return error2("Unauthorized", 401);
515
522
  }
516
523
  let body2;
517
524
  try {
518
525
  body2 = await request.json();
519
526
  } catch {
520
- return error("Invalid JSON", 400);
527
+ return error2("Invalid JSON", 400);
521
528
  }
522
529
  const { path, patches } = body2 ?? {};
523
530
  if (typeof path !== "string" || patches !== void 0 && !Array.isArray(patches)) {
524
- return error("Expected { path, patches? }", 400);
531
+ return error2("Expected { path, patches? }", 400);
525
532
  }
526
533
  const client = hubClient(options.hub());
527
534
  if (patches) await client.publish(path, patches);
528
535
  else await client.notify(path);
529
536
  return json({ type: "ok" });
530
537
  }
531
- if (!secret) return withCors(error("Reactive hub is not configured", 503), corsOrigin);
538
+ if (!secret) return withCors(error2("Reactive hub is not configured", 503), corsOrigin);
532
539
  if (url.pathname === routes.ticket) {
533
- if (request.method !== "POST") return withCors(error("Method not allowed", 405), corsOrigin);
540
+ if (request.method !== "POST") return withCors(error2("Method not allowed", 405), corsOrigin);
534
541
  const principal2 = await options.authenticate(request);
535
- if (!principal2) return withCors(error("Unauthorized", 401), corsOrigin);
542
+ if (!principal2) return withCors(error2("Unauthorized", 401), corsOrigin);
536
543
  const socketUrl = new URL(routes.socket, url);
537
544
  socketUrl.protocol = "wss:";
538
545
  return withCors(
@@ -549,7 +556,7 @@ async function handleReactiveRoutes(request, options) {
549
556
  }
550
557
  if (url.pathname === routes.socket) {
551
558
  if (request.headers.get("upgrade") !== "websocket") {
552
- return error("Expected a WebSocket upgrade", 426);
559
+ return error2("Expected a WebSocket upgrade", 426);
553
560
  }
554
561
  const principal2 = await verifySignedClusterTicket(url.searchParams.get("token") ?? "", secret);
555
562
  if (!principal2) return new Response("Unauthorized", { status: 401 });
@@ -557,19 +564,187 @@ async function handleReactiveRoutes(request, options) {
557
564
  forwarded.headers.set(HUB_PRINCIPAL_HEADER, principal2);
558
565
  return options.hub().fetch(forwarded);
559
566
  }
560
- if (request.method !== "POST") return withCors(error("Method not allowed", 405), corsOrigin);
567
+ if (request.method !== "POST") return withCors(error2("Method not allowed", 405), corsOrigin);
561
568
  const principal = await verifySignedClusterTicket(bearerToken(request), secret);
562
- if (!principal) return withCors(error("Unauthorized", 401), corsOrigin);
569
+ if (!principal) return withCors(error2("Unauthorized", 401), corsOrigin);
563
570
  let body;
564
571
  try {
565
572
  body = await request.json();
566
573
  } catch {
567
- return withCors(error("Invalid JSON", 400), corsOrigin);
574
+ return withCors(error2("Invalid JSON", 400), corsOrigin);
568
575
  }
569
- if (!isHubRequest(body)) return withCors(error("Unsupported reactive request", 400), corsOrigin);
576
+ if (!isHubRequest(body)) return withCors(error2("Unsupported reactive request", 400), corsOrigin);
570
577
  return withCors(await hubClient(options.hub()).request(body, principal), corsOrigin);
571
578
  }
572
579
 
573
- export { HEARTBEAT_TIMEOUT_CLOSE_CODE, HUB_PRINCIPAL_HEADER, createDurableObjectHub, handleReactiveRoutes, hubClient, remoteHub };
580
+ // ../client/src/cloudflare-hub.ts
581
+ var HUB_ROUTES = {
582
+ /** `POST` with the notify secret and `?principal=`: a ticket for a machine client. */
583
+ ticket: "/api/hub/token",
584
+ /** WebSocket upgrade with a signed ticket in `?token=`. */
585
+ socket: "/api/hub/socket",
586
+ /** Reactive HTTP subscribe and unsubscribe, with a signed ticket as the bearer. */
587
+ reactive: "/api/hub/reactive",
588
+ /** `POST { path, patches? }` with the notify secret, after the data behind a path changed. */
589
+ notify: "/api/hub/notify",
590
+ /** `GET` with the notify secret: `{ principals }`, the tracked principals holding a live socket. */
591
+ presence: "/api/hub/presence"
592
+ };
593
+ var HUB_ENV = {
594
+ TICKET_SECRET: "REACTIVE_TICKET_SECRET",
595
+ NOTIFY_SECRET: "HUB_NOTIFY_SECRET",
596
+ /** Comma-separated browser origins allowed to call the hub cross-origin. */
597
+ ALLOWED_ORIGINS: "ALLOWED_ORIGINS"
598
+ };
599
+ var HUB_DURABLE_OBJECT = { className: "ReactiveHub", binding: "REACTIVE_HUB" };
600
+ var HUB_INSTANCE = "hub";
601
+ var PRESENCE_ACTION_URL = "https://reactive-hub/presence";
602
+ function sameSecret2(given, expected) {
603
+ const left = new TextEncoder().encode(given);
604
+ const right = new TextEncoder().encode(expected);
605
+ let difference = left.length ^ right.length;
606
+ for (let index = 0; index < right.length; index++) {
607
+ difference |= (left[index] ?? 0) ^ right[index];
608
+ }
609
+ return difference === 0;
610
+ }
611
+ function parseOrigins(value) {
612
+ return (value ?? "").split(",").map((origin) => origin.trim().replace(/\/+$/u, "")).filter(Boolean);
613
+ }
614
+ function defineHub(config) {
615
+ class ReactiveHub {
616
+ #ctx;
617
+ #reactive;
618
+ constructor(ctx, _env) {
619
+ this.#ctx = ctx;
620
+ const context = {
621
+ present: (principal) => this.#reactive.present(principal),
622
+ presentPrincipals: () => this.#presentPrincipals()
623
+ };
624
+ const presence = config.presence;
625
+ this.#reactive = createDurableObjectHub(ctx, {
626
+ paths: config.paths(context),
627
+ ...presence ? {
628
+ presence: {
629
+ principals: presence.principals,
630
+ ...presence.timeoutMs === void 0 ? {} : { timeoutMs: presence.timeoutMs },
631
+ onChange: async () => {
632
+ for (const path of presence.notify ?? []) {
633
+ await this.#reactive.hub.notify(path);
634
+ }
635
+ }
636
+ }
637
+ } : {}
638
+ });
639
+ }
640
+ #presentPrincipals() {
641
+ const principals = /* @__PURE__ */ new Set();
642
+ for (const socket of this.#ctx.getWebSockets()) {
643
+ const principal = this.#reactive.principalOf(socket);
644
+ if (this.#reactive.present(principal)) principals.add(principal);
645
+ }
646
+ return [...principals].sort();
647
+ }
648
+ async fetch(request) {
649
+ if (request.url === PRESENCE_ACTION_URL) {
650
+ return Response.json(
651
+ { principals: this.#presentPrincipals() },
652
+ { headers: { "cache-control": "no-store" } }
653
+ );
654
+ }
655
+ return this.#reactive.fetch(request);
656
+ }
657
+ webSocketMessage(socket, message) {
658
+ return this.#reactive.webSocketMessage(socket, message);
659
+ }
660
+ webSocketClose(socket, code) {
661
+ return this.#reactive.webSocketClose(socket, code);
662
+ }
663
+ webSocketError(socket) {
664
+ return this.#reactive.webSocketError(socket);
665
+ }
666
+ alarm() {
667
+ return this.#reactive.alarm();
668
+ }
669
+ }
670
+ const worker = {
671
+ async fetch(request, env) {
672
+ const stub = () => env.REACTIVE_HUB.get(env.REACTIVE_HUB.idFromName(HUB_INSTANCE));
673
+ const notifySecret = env.HUB_NOTIFY_SECRET;
674
+ const authorized = () => Boolean(notifySecret) && sameSecret2(bearerToken(request), notifySecret);
675
+ try {
676
+ if (new URL(request.url).pathname === HUB_ROUTES.presence) {
677
+ if (request.method !== "GET") return new Response(null, { status: 405 });
678
+ if (!notifySecret) return new Response(null, { status: 503 });
679
+ if (!authorized()) return new Response(null, { status: 401 });
680
+ return await stub().fetch(new Request(PRESENCE_ACTION_URL));
681
+ }
682
+ const response = await handleReactiveRoutes(request, {
683
+ routes: HUB_ROUTES,
684
+ secret: env.REACTIVE_TICKET_SECRET,
685
+ notifySecret,
686
+ allowedOrigins: parseOrigins(env.ALLOWED_ORIGINS),
687
+ hub: stub,
688
+ authenticate: (incoming) => {
689
+ const principal = new URL(incoming.url).searchParams.get("principal") ?? "";
690
+ return authorized() && config.machinePrincipal?.(principal) ? principal : void 0;
691
+ }
692
+ });
693
+ return response ?? new Response("Not found", { status: 404 });
694
+ } catch (cause) {
695
+ error("hub request failed", request.method, new URL(request.url).pathname, cause);
696
+ return Response.json({ error: "Hub error" }, { status: 500 });
697
+ }
698
+ }
699
+ };
700
+ return { ReactiveHub, worker };
701
+ }
702
+ async function hmacHex(key, label) {
703
+ const encoder2 = new TextEncoder();
704
+ const cryptoKey = await crypto.subtle.importKey(
705
+ "raw",
706
+ encoder2.encode(key),
707
+ { name: "HMAC", hash: "SHA-256" },
708
+ false,
709
+ ["sign"]
710
+ );
711
+ const signature = new Uint8Array(
712
+ await crypto.subtle.sign("HMAC", cryptoKey, encoder2.encode(label))
713
+ );
714
+ return Array.from(signature, (byte) => byte.toString(16).padStart(2, "0")).join("");
715
+ }
716
+ async function deriveHubSecrets(root, name) {
717
+ const [ticket, notify] = await Promise.all([
718
+ hmacHex(root, `${name}-ticket`),
719
+ hmacHex(root, `${name}-notify`)
720
+ ]);
721
+ return { ticket, notify };
722
+ }
723
+ async function mintHubTicket(options) {
724
+ const socketUrl = new URL(HUB_ROUTES.socket, options.origin);
725
+ socketUrl.protocol = "wss:";
726
+ const ticket = await createSignedClusterTicket({
727
+ secret: options.secret,
728
+ principal: options.principal,
729
+ url: socketUrl.toString(),
730
+ ...options.lifetimeSeconds === void 0 ? {} : { lifetimeSeconds: options.lifetimeSeconds }
731
+ });
732
+ return { ...ticket, httpUrl: new URL(HUB_ROUTES.reactive, options.origin).toString() };
733
+ }
734
+ async function fetchHubPresence(options) {
735
+ try {
736
+ const response = await (options.fetch ?? fetch)(new URL(HUB_ROUTES.presence, options.origin), {
737
+ headers: { authorization: `Bearer ${options.secret}` },
738
+ signal: AbortSignal.timeout(options.timeoutMs ?? 3e3)
739
+ });
740
+ if (!response.ok) return void 0;
741
+ const { principals } = await response.json();
742
+ return Array.isArray(principals) && principals.every((value) => typeof value === "string") ? principals : void 0;
743
+ } catch {
744
+ return void 0;
745
+ }
746
+ }
747
+
748
+ export { HEARTBEAT_TIMEOUT_CLOSE_CODE, HUB_DURABLE_OBJECT, HUB_ENV, HUB_PRINCIPAL_HEADER, HUB_ROUTES, createDurableObjectHub, defineHub, deriveHubSecrets, fetchHubPresence, handleReactiveRoutes, hubClient, mintHubTicket, remoteHub };
574
749
  //# sourceMappingURL=cloudflare.js.map
575
750
  //# sourceMappingURL=cloudflare.js.map