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.
- package/README.md +61 -6
- package/dist/cloudflare.cjs +197 -17
- package/dist/cloudflare.cjs.map +1 -1
- package/dist/cloudflare.d.cts +135 -2
- package/dist/cloudflare.d.ts +135 -2
- package/dist/cloudflare.js +191 -18
- package/dist/cloudflare.js.map +1 -1
- package/dist/cluster.d.cts +1 -1
- package/dist/cluster.d.ts +1 -1
- package/dist/hub-cli.js +133 -0
- package/dist/{protocol-C4Syt_Bl.d.ts → protocol-xs_OqPqe.d.ts} +1 -1
- package/package.json +4 -1
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
|
|
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
|
-
|
|
634
|
+
// hub/index.ts
|
|
635
|
+
import { defineHub } from 'dynamodb-reactive/cloudflare';
|
|
635
636
|
|
|
636
|
-
const
|
|
637
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/dist/cloudflare.cjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
378
|
+
return error2("Unknown hub action", 400);
|
|
374
379
|
}
|
|
375
380
|
} catch (cause) {
|
|
376
|
-
if (cause instanceof ReactiveHubError) return
|
|
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
|
|
516
|
-
if (!options.notifySecret) return
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
576
|
+
return withCors(error2("Invalid JSON", 400), corsOrigin);
|
|
572
577
|
}
|
|
573
|
-
if (!isHubRequest(body)) return withCors(
|
|
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
|