dynamodb-reactive 0.6.2 → 0.7.1

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
@@ -183,5 +185,136 @@ interface ReactiveRoutesOptions {
183
185
  /** Serves the ticket, socket, reactive and notify routes; `undefined` for any other path. */
184
186
  declare function handleReactiveRoutes(request: Request, options: ReactiveRoutesOptions): Promise<Response | undefined>;
185
187
 
186
- export { HEARTBEAT_TIMEOUT_CLOSE_CODE, HUB_PRINCIPAL_HEADER, createDurableObjectHub, handleReactiveRoutes, hubClient, remoteHub };
187
- 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
  };
@@ -510,31 +515,31 @@ async function handleReactiveRoutes(request, options) {
510
515
  });
511
516
  }
512
517
  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);
518
+ if (request.method !== "POST") return error2("Method not allowed", 405);
519
+ if (!options.notifySecret) return error2("Hub notify is not configured", 503);
515
520
  if (!sameSecret(bearerToken(request), options.notifySecret)) {
516
- return error("Unauthorized", 401);
521
+ return error2("Unauthorized", 401);
517
522
  }
518
523
  let body2;
519
524
  try {
520
525
  body2 = await request.json();
521
526
  } catch {
522
- return error("Invalid JSON", 400);
527
+ return error2("Invalid JSON", 400);
523
528
  }
524
529
  const { path, patches } = body2 ?? {};
525
530
  if (typeof path !== "string" || patches !== void 0 && !Array.isArray(patches)) {
526
- return error("Expected { path, patches? }", 400);
531
+ return error2("Expected { path, patches? }", 400);
527
532
  }
528
533
  const client = hubClient(options.hub());
529
534
  if (patches) await client.publish(path, patches);
530
535
  else await client.notify(path);
531
536
  return json({ type: "ok" });
532
537
  }
533
- 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);
534
539
  if (url.pathname === routes.ticket) {
535
- 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);
536
541
  const principal2 = await options.authenticate(request);
537
- if (!principal2) return withCors(error("Unauthorized", 401), corsOrigin);
542
+ if (!principal2) return withCors(error2("Unauthorized", 401), corsOrigin);
538
543
  const socketUrl = new URL(routes.socket, url);
539
544
  socketUrl.protocol = "wss:";
540
545
  return withCors(
@@ -551,7 +556,7 @@ async function handleReactiveRoutes(request, options) {
551
556
  }
552
557
  if (url.pathname === routes.socket) {
553
558
  if (request.headers.get("upgrade") !== "websocket") {
554
- return error("Expected a WebSocket upgrade", 426);
559
+ return error2("Expected a WebSocket upgrade", 426);
555
560
  }
556
561
  const principal2 = await verifySignedClusterTicket(url.searchParams.get("token") ?? "", secret);
557
562
  if (!principal2) return new Response("Unauthorized", { status: 401 });
@@ -559,19 +564,187 @@ async function handleReactiveRoutes(request, options) {
559
564
  forwarded.headers.set(HUB_PRINCIPAL_HEADER, principal2);
560
565
  return options.hub().fetch(forwarded);
561
566
  }
562
- 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);
563
568
  const principal = await verifySignedClusterTicket(bearerToken(request), secret);
564
- if (!principal) return withCors(error("Unauthorized", 401), corsOrigin);
569
+ if (!principal) return withCors(error2("Unauthorized", 401), corsOrigin);
565
570
  let body;
566
571
  try {
567
572
  body = await request.json();
568
573
  } catch {
569
- return withCors(error("Invalid JSON", 400), corsOrigin);
574
+ return withCors(error2("Invalid JSON", 400), corsOrigin);
570
575
  }
571
- if (!isHubRequest(body)) return withCors(error("Unsupported reactive request", 400), corsOrigin);
576
+ if (!isHubRequest(body)) return withCors(error2("Unsupported reactive request", 400), corsOrigin);
572
577
  return withCors(await hubClient(options.hub()).request(body, principal), corsOrigin);
573
578
  }
574
579
 
575
- 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 };
576
749
  //# sourceMappingURL=cloudflare.js.map
577
750
  //# sourceMappingURL=cloudflare.js.map