dynamodb-reactive 0.6.2 → 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,18 +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;
638
658
  ```
639
659
 
640
- Each call aborts after `timeoutMs` (default 3000 ms) and throws on a timeout or non-2xx answer, so wrap it where a lost notify must not fail the write.
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
+ });
695
+ ```
641
696
 
642
- 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.
643
698
 
644
699
  ## Delivery model
645
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
  };
@@ -512,31 +517,31 @@ async function handleReactiveRoutes(request, options) {
512
517
  });
513
518
  }
514
519
  if (url.pathname === routes.notify) {
515
- if (request.method !== "POST") return error("Method not allowed", 405);
516
- 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);
517
522
  if (!sameSecret(bearerToken(request), options.notifySecret)) {
518
- return error("Unauthorized", 401);
523
+ return error2("Unauthorized", 401);
519
524
  }
520
525
  let body2;
521
526
  try {
522
527
  body2 = await request.json();
523
528
  } catch {
524
- return error("Invalid JSON", 400);
529
+ return error2("Invalid JSON", 400);
525
530
  }
526
531
  const { path, patches } = body2 ?? {};
527
532
  if (typeof path !== "string" || patches !== void 0 && !Array.isArray(patches)) {
528
- return error("Expected { path, patches? }", 400);
533
+ return error2("Expected { path, patches? }", 400);
529
534
  }
530
535
  const client = hubClient(options.hub());
531
536
  if (patches) await client.publish(path, patches);
532
537
  else await client.notify(path);
533
538
  return json({ type: "ok" });
534
539
  }
535
- 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);
536
541
  if (url.pathname === routes.ticket) {
537
- 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);
538
543
  const principal2 = await options.authenticate(request);
539
- if (!principal2) return withCors(error("Unauthorized", 401), corsOrigin);
544
+ if (!principal2) return withCors(error2("Unauthorized", 401), corsOrigin);
540
545
  const socketUrl = new URL(routes.socket, url);
541
546
  socketUrl.protocol = "wss:";
542
547
  return withCors(
@@ -553,7 +558,7 @@ async function handleReactiveRoutes(request, options) {
553
558
  }
554
559
  if (url.pathname === routes.socket) {
555
560
  if (request.headers.get("upgrade") !== "websocket") {
556
- return error("Expected a WebSocket upgrade", 426);
561
+ return error2("Expected a WebSocket upgrade", 426);
557
562
  }
558
563
  const principal2 = await verifySignedClusterTicket(url.searchParams.get("token") ?? "", secret);
559
564
  if (!principal2) return new Response("Unauthorized", { status: 401 });
@@ -561,24 +566,199 @@ async function handleReactiveRoutes(request, options) {
561
566
  forwarded.headers.set(HUB_PRINCIPAL_HEADER, principal2);
562
567
  return options.hub().fetch(forwarded);
563
568
  }
564
- 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);
565
570
  const principal = await verifySignedClusterTicket(bearerToken(request), secret);
566
- if (!principal) return withCors(error("Unauthorized", 401), corsOrigin);
571
+ if (!principal) return withCors(error2("Unauthorized", 401), corsOrigin);
567
572
  let body;
568
573
  try {
569
574
  body = await request.json();
570
575
  } catch {
571
- return withCors(error("Invalid JSON", 400), corsOrigin);
576
+ return withCors(error2("Invalid JSON", 400), corsOrigin);
572
577
  }
573
- if (!isHubRequest(body)) return withCors(error("Unsupported reactive request", 400), corsOrigin);
578
+ if (!isHubRequest(body)) return withCors(error2("Unsupported reactive request", 400), corsOrigin);
574
579
  return withCors(await hubClient(options.hub()).request(body, principal), corsOrigin);
575
580
  }
576
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
+
577
750
  exports.HEARTBEAT_TIMEOUT_CLOSE_CODE = HEARTBEAT_TIMEOUT_CLOSE_CODE;
751
+ exports.HUB_DURABLE_OBJECT = HUB_DURABLE_OBJECT;
752
+ exports.HUB_ENV = HUB_ENV;
578
753
  exports.HUB_PRINCIPAL_HEADER = HUB_PRINCIPAL_HEADER;
754
+ exports.HUB_ROUTES = HUB_ROUTES;
579
755
  exports.createDurableObjectHub = createDurableObjectHub;
756
+ exports.defineHub = defineHub;
757
+ exports.deriveHubSecrets = deriveHubSecrets;
758
+ exports.fetchHubPresence = fetchHubPresence;
580
759
  exports.handleReactiveRoutes = handleReactiveRoutes;
581
760
  exports.hubClient = hubClient;
761
+ exports.mintHubTicket = mintHubTicket;
582
762
  exports.remoteHub = remoteHub;
583
763
  //# sourceMappingURL=cloudflare.cjs.map
584
764
  //# sourceMappingURL=cloudflare.cjs.map