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.
package/README.md CHANGED
@@ -628,16 +628,73 @@ override alarm() {
628
628
 
629
629
  ### Hubs for apps hosted elsewhere
630
630
 
631
- An app on Vercel or AWS can still use a Cloudflare hub. Add `routes.notify` with a `notifySecret`, and call it after writes from any backend:
631
+ An app on Vercel or AWS cannot hold WebSockets cheaply, so it runs a packaged hub on Cloudflare: the Cloudflare counterpart of `ReactiveEngine`. `defineHub` is the whole Worker; the app supplies only its path rules.
632
632
 
633
633
  ```ts
634
- import { remoteHub } from 'dynamodb-reactive/cloudflare';
634
+ // hub/index.ts
635
+ import { defineHub } from 'dynamodb-reactive/cloudflare';
635
636
 
636
- const hub = remoteHub({ url: 'https://hub.example.com/api/reactive/notify', secret });
637
- await hub.notify('scraper.status');
637
+ const isWorker = (principal: string) => principal.startsWith('worker:');
638
+
639
+ const hub = defineHub({
640
+ paths: (context) => ({
641
+ 'workers.status': {
642
+ authorize: ({ principal }) => principal.startsWith('admin:'),
643
+ snapshot: () => ({ online: context.presentPrincipals() }),
644
+ },
645
+ 'jobs.queued': {
646
+ authorize: ({ principal }) => isWorker(principal),
647
+ snapshot: () => ({ ready: true }),
648
+ },
649
+ }),
650
+ // Machine workers that hold a socket count as online; their status path is notified on change.
651
+ presence: { principals: isWorker, notify: ['workers.status'] },
652
+ // Machine workers get tickets from the hub with the notify secret.
653
+ machinePrincipal: isWorker,
654
+ });
655
+
656
+ export const ReactiveHub = hub.ReactiveHub;
657
+ export default hub.worker;
658
+ ```
659
+
660
+ Deploy it with the bundled command, which writes the Wrangler config (Durable Object binding and migration, `workers_dev`, allowed origins), deploys, and puts the secrets. It needs `wrangler` installed and `CLOUDFLARE_API_TOKEN`:
661
+
662
+ ```sh
663
+ dynamodb-reactive-hub deploy --name my-hub --entry hub/index.ts --account <account-id> --allowed-origins https://my.app --secret-root-env APP_SECRET
664
+ ```
665
+
666
+ The hub is served at `https://my-hub.<account subdomain>.workers.dev`; keep that URL as a constant in the app. Its routes are `HUB_ROUTES`:
667
+
668
+ | Route | Auth | Purpose |
669
+ | -------------------------------- | ------------- | --------------------------------------------------------- |
670
+ | `POST /api/hub/token?principal=` | notify secret | ticket for a `machinePrincipal` |
671
+ | `GET /api/hub/socket?token=` | signed ticket | WebSocket upgrade |
672
+ | `POST /api/hub/reactive` | signed ticket | subscribe and unsubscribe |
673
+ | `POST /api/hub/notify` | notify secret | `{ path, patches? }` after the data behind a path changed |
674
+ | `GET /api/hub/presence` | notify secret | `{ principals }` holding a live socket |
675
+
676
+ No hub secret has to be distributed. Both are HMAC digests of a secret the app already holds (`--secret-root-env`), and every backend derives them with `deriveHubSecrets(root, name)`:
677
+
678
+ ```ts
679
+ import { deriveHubSecrets, mintHubTicket, remoteHub } from 'dynamodb-reactive/cloudflare';
680
+
681
+ const HUB_ORIGIN = 'https://my-hub.example.workers.dev';
682
+ const secrets = await deriveHubSecrets(process.env.APP_SECRET!, 'my-hub');
683
+
684
+ // After a write, from Vercel, a Lambda or a worker:
685
+ await remoteHub({ url: `${HUB_ORIGIN}/api/hub/notify`, secret: secrets.notify }).notify(
686
+ 'jobs.queued',
687
+ );
688
+
689
+ // In the app's ticket route, for the signed-in user:
690
+ const ticket = await mintHubTicket({
691
+ origin: HUB_ORIGIN,
692
+ secret: secrets.ticket,
693
+ principal: 'admin:me',
694
+ });
638
695
  ```
639
696
 
640
- The app can mint browser tickets itself with `createSignedClusterTicket` and the hub's ticket secret, pointing `url` at the hub's socket route. Pass `allowedOrigins` so browsers on the app's origin may call the ticket and reactive routes cross-origin. When the data lives outside Cloudflare, a path's snapshot can be a small marker such as `{ version }`, and the page re-fetches from its own API whenever the marker changes.
697
+ `remoteHub` calls abort after `timeoutMs` (default 3000 ms) and throw on a timeout or non-2xx answer, so wrap them where a lost notify must not fail the write. `fetchHubPresence` reads the presence route. When the data lives outside Cloudflare, a path's snapshot can be a small marker such as `{ ready: true }`, and the page re-fetches from its own API whenever it is pushed.
641
698
 
642
699
  ## Delivery model
643
700
 
@@ -1,5 +1,10 @@
1
1
  'use strict';
2
2
 
3
+ // ../client/src/log.ts
4
+ function error(...values) {
5
+ console.error(...values);
6
+ }
7
+
3
8
  // ../client/src/cluster.ts
4
9
  var DEFAULT_TICKET_LIFETIME_SECONDS = 60;
5
10
  function isRecord(value) {
@@ -263,7 +268,7 @@ var HEARTBEAT_TIMEOUT_CLOSE_CODE = 4001;
263
268
  function json(value, status = 200) {
264
269
  return Response.json(value, { status, headers: { "cache-control": "no-store" } });
265
270
  }
266
- function error(message, status) {
271
+ function error2(message, status) {
267
272
  return json({ type: "error", message }, status);
268
273
  }
269
274
  function workersGlobal(name) {
@@ -335,7 +340,7 @@ function createDurableObjectHub(ctx, options) {
335
340
  };
336
341
  const accept = async (request) => {
337
342
  const principal = request.headers.get(HUB_PRINCIPAL_HEADER);
338
- if (!principal) return error("Unauthorized", 401);
343
+ if (!principal) return error2("Unauthorized", 401);
339
344
  const Pair = options.WebSocketPair ?? workersGlobal("WebSocketPair");
340
345
  if (!Pair) throw new Error("WebSocketPair is not available in this runtime");
341
346
  const pair = new Pair();
@@ -370,10 +375,10 @@ function createDurableObjectHub(ctx, options) {
370
375
  await hub.publish(action.path, action.patches);
371
376
  return json({ type: "ok" });
372
377
  default:
373
- return error("Unknown hub action", 400);
378
+ return error2("Unknown hub action", 400);
374
379
  }
375
380
  } catch (cause) {
376
- if (cause instanceof ReactiveHubError) return error(cause.message, 400);
381
+ if (cause instanceof ReactiveHubError) return error2(cause.message, 400);
377
382
  throw cause;
378
383
  }
379
384
  };
@@ -455,12 +460,14 @@ function hubClient(stub) {
455
460
  publish: async (path, patches) => ok(await post({ action: "publish", path, patches }))
456
461
  };
457
462
  }
463
+ var DEFAULT_REMOTE_HUB_TIMEOUT_MS = 3e3;
458
464
  function remoteHub(options) {
459
465
  const send2 = async (body) => {
460
466
  const response = await (options.fetch ?? fetch)(options.url, {
461
467
  method: "POST",
462
468
  headers: { authorization: `Bearer ${options.secret}`, "content-type": "application/json" },
463
- body: JSON.stringify(body)
469
+ body: JSON.stringify(body),
470
+ signal: AbortSignal.timeout(options.timeoutMs ?? DEFAULT_REMOTE_HUB_TIMEOUT_MS)
464
471
  });
465
472
  if (!response.ok) throw new Error(`Hub notify failed: HTTP ${response.status}`);
466
473
  };
@@ -510,31 +517,31 @@ async function handleReactiveRoutes(request, options) {
510
517
  });
511
518
  }
512
519
  if (url.pathname === routes.notify) {
513
- if (request.method !== "POST") return error("Method not allowed", 405);
514
- if (!options.notifySecret) return error("Hub notify is not configured", 503);
520
+ if (request.method !== "POST") return error2("Method not allowed", 405);
521
+ if (!options.notifySecret) return error2("Hub notify is not configured", 503);
515
522
  if (!sameSecret(bearerToken(request), options.notifySecret)) {
516
- return error("Unauthorized", 401);
523
+ return error2("Unauthorized", 401);
517
524
  }
518
525
  let body2;
519
526
  try {
520
527
  body2 = await request.json();
521
528
  } catch {
522
- return error("Invalid JSON", 400);
529
+ return error2("Invalid JSON", 400);
523
530
  }
524
531
  const { path, patches } = body2 ?? {};
525
532
  if (typeof path !== "string" || patches !== void 0 && !Array.isArray(patches)) {
526
- return error("Expected { path, patches? }", 400);
533
+ return error2("Expected { path, patches? }", 400);
527
534
  }
528
535
  const client = hubClient(options.hub());
529
536
  if (patches) await client.publish(path, patches);
530
537
  else await client.notify(path);
531
538
  return json({ type: "ok" });
532
539
  }
533
- if (!secret) return withCors(error("Reactive hub is not configured", 503), corsOrigin);
540
+ if (!secret) return withCors(error2("Reactive hub is not configured", 503), corsOrigin);
534
541
  if (url.pathname === routes.ticket) {
535
- if (request.method !== "POST") return withCors(error("Method not allowed", 405), corsOrigin);
542
+ if (request.method !== "POST") return withCors(error2("Method not allowed", 405), corsOrigin);
536
543
  const principal2 = await options.authenticate(request);
537
- if (!principal2) return withCors(error("Unauthorized", 401), corsOrigin);
544
+ if (!principal2) return withCors(error2("Unauthorized", 401), corsOrigin);
538
545
  const socketUrl = new URL(routes.socket, url);
539
546
  socketUrl.protocol = "wss:";
540
547
  return withCors(
@@ -551,7 +558,7 @@ async function handleReactiveRoutes(request, options) {
551
558
  }
552
559
  if (url.pathname === routes.socket) {
553
560
  if (request.headers.get("upgrade") !== "websocket") {
554
- return error("Expected a WebSocket upgrade", 426);
561
+ return error2("Expected a WebSocket upgrade", 426);
555
562
  }
556
563
  const principal2 = await verifySignedClusterTicket(url.searchParams.get("token") ?? "", secret);
557
564
  if (!principal2) return new Response("Unauthorized", { status: 401 });
@@ -559,24 +566,199 @@ async function handleReactiveRoutes(request, options) {
559
566
  forwarded.headers.set(HUB_PRINCIPAL_HEADER, principal2);
560
567
  return options.hub().fetch(forwarded);
561
568
  }
562
- if (request.method !== "POST") return withCors(error("Method not allowed", 405), corsOrigin);
569
+ if (request.method !== "POST") return withCors(error2("Method not allowed", 405), corsOrigin);
563
570
  const principal = await verifySignedClusterTicket(bearerToken(request), secret);
564
- if (!principal) return withCors(error("Unauthorized", 401), corsOrigin);
571
+ if (!principal) return withCors(error2("Unauthorized", 401), corsOrigin);
565
572
  let body;
566
573
  try {
567
574
  body = await request.json();
568
575
  } catch {
569
- return withCors(error("Invalid JSON", 400), corsOrigin);
576
+ return withCors(error2("Invalid JSON", 400), corsOrigin);
570
577
  }
571
- if (!isHubRequest(body)) return withCors(error("Unsupported reactive request", 400), corsOrigin);
578
+ if (!isHubRequest(body)) return withCors(error2("Unsupported reactive request", 400), corsOrigin);
572
579
  return withCors(await hubClient(options.hub()).request(body, principal), corsOrigin);
573
580
  }
574
581
 
582
+ // ../client/src/cloudflare-hub.ts
583
+ var HUB_ROUTES = {
584
+ /** `POST` with the notify secret and `?principal=`: a ticket for a machine client. */
585
+ ticket: "/api/hub/token",
586
+ /** WebSocket upgrade with a signed ticket in `?token=`. */
587
+ socket: "/api/hub/socket",
588
+ /** Reactive HTTP subscribe and unsubscribe, with a signed ticket as the bearer. */
589
+ reactive: "/api/hub/reactive",
590
+ /** `POST { path, patches? }` with the notify secret, after the data behind a path changed. */
591
+ notify: "/api/hub/notify",
592
+ /** `GET` with the notify secret: `{ principals }`, the tracked principals holding a live socket. */
593
+ presence: "/api/hub/presence"
594
+ };
595
+ var HUB_ENV = {
596
+ TICKET_SECRET: "REACTIVE_TICKET_SECRET",
597
+ NOTIFY_SECRET: "HUB_NOTIFY_SECRET",
598
+ /** Comma-separated browser origins allowed to call the hub cross-origin. */
599
+ ALLOWED_ORIGINS: "ALLOWED_ORIGINS"
600
+ };
601
+ var HUB_DURABLE_OBJECT = { className: "ReactiveHub", binding: "REACTIVE_HUB" };
602
+ var HUB_INSTANCE = "hub";
603
+ var PRESENCE_ACTION_URL = "https://reactive-hub/presence";
604
+ function sameSecret2(given, expected) {
605
+ const left = new TextEncoder().encode(given);
606
+ const right = new TextEncoder().encode(expected);
607
+ let difference = left.length ^ right.length;
608
+ for (let index = 0; index < right.length; index++) {
609
+ difference |= (left[index] ?? 0) ^ right[index];
610
+ }
611
+ return difference === 0;
612
+ }
613
+ function parseOrigins(value) {
614
+ return (value ?? "").split(",").map((origin) => origin.trim().replace(/\/+$/u, "")).filter(Boolean);
615
+ }
616
+ function defineHub(config) {
617
+ class ReactiveHub {
618
+ #ctx;
619
+ #reactive;
620
+ constructor(ctx, _env) {
621
+ this.#ctx = ctx;
622
+ const context = {
623
+ present: (principal) => this.#reactive.present(principal),
624
+ presentPrincipals: () => this.#presentPrincipals()
625
+ };
626
+ const presence = config.presence;
627
+ this.#reactive = createDurableObjectHub(ctx, {
628
+ paths: config.paths(context),
629
+ ...presence ? {
630
+ presence: {
631
+ principals: presence.principals,
632
+ ...presence.timeoutMs === void 0 ? {} : { timeoutMs: presence.timeoutMs },
633
+ onChange: async () => {
634
+ for (const path of presence.notify ?? []) {
635
+ await this.#reactive.hub.notify(path);
636
+ }
637
+ }
638
+ }
639
+ } : {}
640
+ });
641
+ }
642
+ #presentPrincipals() {
643
+ const principals = /* @__PURE__ */ new Set();
644
+ for (const socket of this.#ctx.getWebSockets()) {
645
+ const principal = this.#reactive.principalOf(socket);
646
+ if (this.#reactive.present(principal)) principals.add(principal);
647
+ }
648
+ return [...principals].sort();
649
+ }
650
+ async fetch(request) {
651
+ if (request.url === PRESENCE_ACTION_URL) {
652
+ return Response.json(
653
+ { principals: this.#presentPrincipals() },
654
+ { headers: { "cache-control": "no-store" } }
655
+ );
656
+ }
657
+ return this.#reactive.fetch(request);
658
+ }
659
+ webSocketMessage(socket, message) {
660
+ return this.#reactive.webSocketMessage(socket, message);
661
+ }
662
+ webSocketClose(socket, code) {
663
+ return this.#reactive.webSocketClose(socket, code);
664
+ }
665
+ webSocketError(socket) {
666
+ return this.#reactive.webSocketError(socket);
667
+ }
668
+ alarm() {
669
+ return this.#reactive.alarm();
670
+ }
671
+ }
672
+ const worker = {
673
+ async fetch(request, env) {
674
+ const stub = () => env.REACTIVE_HUB.get(env.REACTIVE_HUB.idFromName(HUB_INSTANCE));
675
+ const notifySecret = env.HUB_NOTIFY_SECRET;
676
+ const authorized = () => Boolean(notifySecret) && sameSecret2(bearerToken(request), notifySecret);
677
+ try {
678
+ if (new URL(request.url).pathname === HUB_ROUTES.presence) {
679
+ if (request.method !== "GET") return new Response(null, { status: 405 });
680
+ if (!notifySecret) return new Response(null, { status: 503 });
681
+ if (!authorized()) return new Response(null, { status: 401 });
682
+ return await stub().fetch(new Request(PRESENCE_ACTION_URL));
683
+ }
684
+ const response = await handleReactiveRoutes(request, {
685
+ routes: HUB_ROUTES,
686
+ secret: env.REACTIVE_TICKET_SECRET,
687
+ notifySecret,
688
+ allowedOrigins: parseOrigins(env.ALLOWED_ORIGINS),
689
+ hub: stub,
690
+ authenticate: (incoming) => {
691
+ const principal = new URL(incoming.url).searchParams.get("principal") ?? "";
692
+ return authorized() && config.machinePrincipal?.(principal) ? principal : void 0;
693
+ }
694
+ });
695
+ return response ?? new Response("Not found", { status: 404 });
696
+ } catch (cause) {
697
+ error("hub request failed", request.method, new URL(request.url).pathname, cause);
698
+ return Response.json({ error: "Hub error" }, { status: 500 });
699
+ }
700
+ }
701
+ };
702
+ return { ReactiveHub, worker };
703
+ }
704
+ async function hmacHex(key, label) {
705
+ const encoder2 = new TextEncoder();
706
+ const cryptoKey = await crypto.subtle.importKey(
707
+ "raw",
708
+ encoder2.encode(key),
709
+ { name: "HMAC", hash: "SHA-256" },
710
+ false,
711
+ ["sign"]
712
+ );
713
+ const signature = new Uint8Array(
714
+ await crypto.subtle.sign("HMAC", cryptoKey, encoder2.encode(label))
715
+ );
716
+ return Array.from(signature, (byte) => byte.toString(16).padStart(2, "0")).join("");
717
+ }
718
+ async function deriveHubSecrets(root, name) {
719
+ const [ticket, notify] = await Promise.all([
720
+ hmacHex(root, `${name}-ticket`),
721
+ hmacHex(root, `${name}-notify`)
722
+ ]);
723
+ return { ticket, notify };
724
+ }
725
+ async function mintHubTicket(options) {
726
+ const socketUrl = new URL(HUB_ROUTES.socket, options.origin);
727
+ socketUrl.protocol = "wss:";
728
+ const ticket = await createSignedClusterTicket({
729
+ secret: options.secret,
730
+ principal: options.principal,
731
+ url: socketUrl.toString(),
732
+ ...options.lifetimeSeconds === void 0 ? {} : { lifetimeSeconds: options.lifetimeSeconds }
733
+ });
734
+ return { ...ticket, httpUrl: new URL(HUB_ROUTES.reactive, options.origin).toString() };
735
+ }
736
+ async function fetchHubPresence(options) {
737
+ try {
738
+ const response = await (options.fetch ?? fetch)(new URL(HUB_ROUTES.presence, options.origin), {
739
+ headers: { authorization: `Bearer ${options.secret}` },
740
+ signal: AbortSignal.timeout(options.timeoutMs ?? 3e3)
741
+ });
742
+ if (!response.ok) return void 0;
743
+ const { principals } = await response.json();
744
+ return Array.isArray(principals) && principals.every((value) => typeof value === "string") ? principals : void 0;
745
+ } catch {
746
+ return void 0;
747
+ }
748
+ }
749
+
575
750
  exports.HEARTBEAT_TIMEOUT_CLOSE_CODE = HEARTBEAT_TIMEOUT_CLOSE_CODE;
751
+ exports.HUB_DURABLE_OBJECT = HUB_DURABLE_OBJECT;
752
+ exports.HUB_ENV = HUB_ENV;
576
753
  exports.HUB_PRINCIPAL_HEADER = HUB_PRINCIPAL_HEADER;
754
+ exports.HUB_ROUTES = HUB_ROUTES;
577
755
  exports.createDurableObjectHub = createDurableObjectHub;
756
+ exports.defineHub = defineHub;
757
+ exports.deriveHubSecrets = deriveHubSecrets;
758
+ exports.fetchHubPresence = fetchHubPresence;
578
759
  exports.handleReactiveRoutes = handleReactiveRoutes;
579
760
  exports.hubClient = hubClient;
761
+ exports.mintHubTicket = mintHubTicket;
580
762
  exports.remoteHub = remoteHub;
581
763
  //# sourceMappingURL=cloudflare.cjs.map
582
764
  //# sourceMappingURL=cloudflare.cjs.map