dynamodb-reactive 0.2.4 → 0.4.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/dist/hub.js ADDED
@@ -0,0 +1,154 @@
1
+ // ../client/src/hub.ts
2
+ var DEFAULT_MAX_ATTACHMENT_BYTES = 2048;
3
+ var ReactiveHubError = class extends Error {
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = "ReactiveHubError";
7
+ }
8
+ };
9
+ function isRecord(value) {
10
+ return typeof value === "object" && value !== null;
11
+ }
12
+ function isHubRequest(value) {
13
+ if (!isRecord(value)) return false;
14
+ return (value.type === "subscribe" || value.type === "unsubscribe") && typeof value.connectionId === "string" && typeof value.subscriptionId === "string" && typeof value.generation === "number" && (value.type === "unsubscribe" || typeof value.path === "string");
15
+ }
16
+ function bearerToken(request) {
17
+ const match = /^Bearer ([^\s]+)$/u.exec(request.headers.get("authorization") ?? "");
18
+ return match?.[1] ?? "";
19
+ }
20
+ function createHubSocketState(connectionId, principal) {
21
+ return { connectionId, principal, subscriptions: [] };
22
+ }
23
+ function socketState(socket) {
24
+ const state = socket.deserializeAttachment();
25
+ return { ...state, subscriptions: state.subscriptions.filter((entry) => entry.path) };
26
+ }
27
+ function send(socket, event) {
28
+ try {
29
+ socket.send(JSON.stringify(event));
30
+ } catch {
31
+ }
32
+ }
33
+ function createReactiveHub(options) {
34
+ const { sockets, versions } = options;
35
+ const maxAttachmentBytes = options.maxAttachmentBytes ?? DEFAULT_MAX_ATTACHMENT_BYTES;
36
+ const resolve = (path) => typeof options.paths === "function" ? options.paths(path) : Object.hasOwn(options.paths, path) ? options.paths[path] : void 0;
37
+ const ownedSocket = (connectionId, principal) => {
38
+ const socket = sockets.byConnectionId(connectionId);
39
+ return socket && socketState(socket).principal === principal ? socket : void 0;
40
+ };
41
+ const subscribersOf = (path) => sockets.all().flatMap(
42
+ (socket) => socketState(socket).subscriptions.filter((subscription) => subscription.path === path).map((subscription) => ({ socket, subscription }))
43
+ );
44
+ const bump = async (path) => {
45
+ const fromVersion = await versions.get(path);
46
+ const toVersion = fromVersion + 1;
47
+ await versions.set(path, toVersion);
48
+ return { fromVersion, toVersion };
49
+ };
50
+ return {
51
+ /** Answers the client's `init` and `ping` frames. */
52
+ message(socket, raw) {
53
+ let frame;
54
+ try {
55
+ frame = JSON.parse(raw);
56
+ } catch {
57
+ return;
58
+ }
59
+ const type = isRecord(frame) ? frame.type : void 0;
60
+ if (type === "init") {
61
+ send(socket, { type: "connected", connectionId: socketState(socket).connectionId });
62
+ } else if (type === "ping") {
63
+ send(socket, { type: "pong" });
64
+ }
65
+ },
66
+ async subscribe(request, principal) {
67
+ const handler = resolve(request.path);
68
+ if (!handler) throw new ReactiveHubError("Unknown subscription path");
69
+ if (!ownedSocket(request.connectionId, principal)) {
70
+ throw new ReactiveHubError("Unknown connection");
71
+ }
72
+ const context = { path: request.path, principal, input: request.input };
73
+ if (handler.authorize && !await handler.authorize(context)) {
74
+ throw new ReactiveHubError("Subscription not allowed");
75
+ }
76
+ const socket = ownedSocket(request.connectionId, principal);
77
+ if (!socket) throw new ReactiveHubError("Unknown connection");
78
+ const state = socketState(socket);
79
+ const next = {
80
+ ...state,
81
+ subscriptions: [
82
+ ...state.subscriptions.filter(
83
+ (entry) => entry.subscriptionId !== request.subscriptionId
84
+ ),
85
+ {
86
+ subscriptionId: request.subscriptionId,
87
+ generation: request.generation,
88
+ path: request.path
89
+ }
90
+ ]
91
+ };
92
+ if (new TextEncoder().encode(JSON.stringify(next)).length > maxAttachmentBytes) {
93
+ throw new ReactiveHubError("Too many subscriptions on this connection");
94
+ }
95
+ socket.serializeAttachment(next);
96
+ const version = await versions.get(request.path);
97
+ const data = await handler.snapshot(context);
98
+ return {
99
+ type: "snapshot",
100
+ subscriptionId: request.subscriptionId,
101
+ generation: request.generation,
102
+ version,
103
+ data
104
+ };
105
+ },
106
+ unsubscribe(request, principal) {
107
+ const socket = ownedSocket(request.connectionId, principal);
108
+ if (!socket) return;
109
+ const state = socketState(socket);
110
+ socket.serializeAttachment({
111
+ ...state,
112
+ subscriptions: state.subscriptions.filter(
113
+ (entry) => entry.subscriptionId !== request.subscriptionId
114
+ )
115
+ });
116
+ },
117
+ /** Bumps the path's version and asks each of its subscribers to re-fetch. */
118
+ async notify(path) {
119
+ const { fromVersion, toVersion } = await bump(path);
120
+ for (const { socket, subscription } of subscribersOf(path)) {
121
+ send(socket, {
122
+ type: "resync",
123
+ subscriptionId: subscription.subscriptionId,
124
+ generation: subscription.generation,
125
+ fromVersion,
126
+ toVersion,
127
+ messageId: crypto.randomUUID()
128
+ });
129
+ }
130
+ },
131
+ /**
132
+ * Bumps the path's version and sends the same JSON patches to each subscriber.
133
+ * Use it only when every subscriber of the path sees the same data.
134
+ */
135
+ async publish(path, patches) {
136
+ const { fromVersion, toVersion } = await bump(path);
137
+ for (const { socket, subscription } of subscribersOf(path)) {
138
+ send(socket, {
139
+ type: "patch",
140
+ subscriptionId: subscription.subscriptionId,
141
+ generation: subscription.generation,
142
+ fromVersion,
143
+ toVersion,
144
+ messageId: crypto.randomUUID(),
145
+ patches
146
+ });
147
+ }
148
+ }
149
+ };
150
+ }
151
+
152
+ export { DEFAULT_MAX_ATTACHMENT_BYTES, ReactiveHubError, bearerToken, createHubSocketState, createReactiveHub, isHubRequest };
153
+ //# sourceMappingURL=hub.js.map
154
+ //# sourceMappingURL=hub.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../client/src/hub.ts"],"names":[],"mappings":"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","file":"hub.js","sourcesContent":["import type {\n JsonPatch,\n SnapshotResponse,\n SubscribeRequest,\n UnsubscribeRequest,\n WebSocketEvent,\n} from './protocol.js';\n\n/**\n * Protocol core for a reactive hub: the server half of the wire protocol for\n * backends that hold WebSockets themselves (for example a Cloudflare Durable\n * Object). It keeps every piece of per-socket state on the socket's attachment so\n * the state survives hibernation, and it has no runtime imports, so it runs and\n * tests in Node.\n */\n\n/** Hibernation limits a socket attachment to 2,048 bytes of serialized JSON. */\nexport const DEFAULT_MAX_ATTACHMENT_BYTES = 2_048;\n\n/** One subscription held by a socket. */\nexport interface HubSubscription {\n subscriptionId: string;\n generation: number;\n path: string;\n}\n\n/** State kept on each socket's attachment. */\nexport interface HubSocketState {\n connectionId: string;\n principal: string;\n subscriptions: HubSubscription[];\n}\n\n/** The parts of a hibernatable WebSocket the hub uses; faked in tests. */\nexport interface HubSocket {\n send(message: string): void;\n deserializeAttachment(): unknown;\n serializeAttachment(value: unknown): void;\n}\n\nexport interface HubSockets {\n all(): HubSocket[];\n byConnectionId(connectionId: string): HubSocket | undefined;\n}\n\n/** Monotonic per-path versions. Versions only need to grow while sockets stay open. */\nexport interface HubVersions {\n get(path: string): Promise<number>;\n set(path: string, version: number): Promise<void>;\n}\n\nexport interface HubPathContext {\n path: string;\n principal: string;\n input: unknown;\n}\n\nexport interface HubPath {\n /**\n * Runs before the subscription is stored. Return false to reject it. Every\n * subscriber of a path receives its pushes, so authorise here, not in `snapshot`.\n */\n authorize?(context: HubPathContext): boolean | Promise<boolean>;\n /** Current data for the path, sent as the subscription snapshot. */\n snapshot(context: HubPathContext): unknown;\n}\n\n/** Fixed paths by name, or a resolver for parameterised paths such as `drafts.<id>`. */\nexport type HubPaths = Record<string, HubPath> | ((path: string) => HubPath | undefined);\n\nexport interface ReactiveHubOptions {\n sockets: HubSockets;\n versions: HubVersions;\n paths: HubPaths;\n /** Rejects subscribes that would grow an attachment past this size. */\n maxAttachmentBytes?: number;\n}\n\n/** Thrown for requests the caller is not allowed to make. */\nexport class ReactiveHubError extends Error {\n constructor(message: string) {\n super(message);\n this.name = 'ReactiveHubError';\n }\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null;\n}\n\n/** Validates a reactive HTTP body for the subscribe and unsubscribe requests a hub serves. */\nexport function isHubRequest(value: unknown): value is SubscribeRequest | UnsubscribeRequest {\n if (!isRecord(value)) return false;\n return (\n (value.type === 'subscribe' || value.type === 'unsubscribe') &&\n typeof value.connectionId === 'string' &&\n typeof value.subscriptionId === 'string' &&\n typeof value.generation === 'number' &&\n (value.type === 'unsubscribe' || typeof value.path === 'string')\n );\n}\n\n/** Returns the bearer token from an `Authorization` header, or an empty string. */\nexport function bearerToken(request: Request): string {\n const match = /^Bearer ([^\\s]+)$/u.exec(request.headers.get('authorization') ?? '');\n return match?.[1] ?? '';\n}\n\n/** Initial attachment for a newly accepted socket. */\nexport function createHubSocketState(connectionId: string, principal: string): HubSocketState {\n return { connectionId, principal, subscriptions: [] };\n}\n\nfunction socketState(socket: HubSocket): HubSocketState {\n const state = socket.deserializeAttachment() as HubSocketState;\n // Attachments written before subscriptions carried a path have no path; they\n // are dropped rather than fanned out to every notification.\n return { ...state, subscriptions: state.subscriptions.filter((entry) => entry.path) };\n}\n\nfunction send(socket: HubSocket, event: WebSocketEvent): void {\n try {\n socket.send(JSON.stringify(event));\n } catch {\n // A socket closing mid-broadcast is cleaned up by its close handler.\n }\n}\n\n/** Creates the protocol core for one hub instance. */\nexport function createReactiveHub(options: ReactiveHubOptions) {\n const { sockets, versions } = options;\n const maxAttachmentBytes = options.maxAttachmentBytes ?? DEFAULT_MAX_ATTACHMENT_BYTES;\n const resolve = (path: string): HubPath | undefined =>\n typeof options.paths === 'function'\n ? options.paths(path)\n : Object.hasOwn(options.paths, path)\n ? options.paths[path]\n : undefined;\n\n const ownedSocket = (connectionId: string, principal: string): HubSocket | undefined => {\n const socket = sockets.byConnectionId(connectionId);\n // The HTTP caller must own the WebSocket it registers against.\n return socket && socketState(socket).principal === principal ? socket : undefined;\n };\n\n const subscribersOf = (path: string) =>\n sockets.all().flatMap((socket) =>\n socketState(socket)\n .subscriptions.filter((subscription) => subscription.path === path)\n .map((subscription) => ({ socket, subscription })),\n );\n\n const bump = async (path: string) => {\n const fromVersion = await versions.get(path);\n const toVersion = fromVersion + 1;\n await versions.set(path, toVersion);\n return { fromVersion, toVersion };\n };\n\n return {\n /** Answers the client's `init` and `ping` frames. */\n message(socket: HubSocket, raw: string): void {\n let frame: unknown;\n try {\n frame = JSON.parse(raw);\n } catch {\n return;\n }\n const type = isRecord(frame) ? frame.type : undefined;\n if (type === 'init') {\n send(socket, { type: 'connected', connectionId: socketState(socket).connectionId });\n } else if (type === 'ping') {\n send(socket, { type: 'pong' });\n }\n },\n\n async subscribe(request: SubscribeRequest, principal: string): Promise<SnapshotResponse> {\n const handler = resolve(request.path);\n if (!handler) throw new ReactiveHubError('Unknown subscription path');\n if (!ownedSocket(request.connectionId, principal)) {\n throw new ReactiveHubError('Unknown connection');\n }\n const context: HubPathContext = { path: request.path, principal, input: request.input };\n if (handler.authorize && !(await handler.authorize(context))) {\n throw new ReactiveHubError('Subscription not allowed');\n }\n // Re-read after the awaits so a concurrent subscribe on the same socket is kept.\n const socket = ownedSocket(request.connectionId, principal);\n if (!socket) throw new ReactiveHubError('Unknown connection');\n const state = socketState(socket);\n const next: HubSocketState = {\n ...state,\n subscriptions: [\n ...state.subscriptions.filter(\n (entry) => entry.subscriptionId !== request.subscriptionId,\n ),\n {\n subscriptionId: request.subscriptionId,\n generation: request.generation,\n path: request.path,\n },\n ],\n };\n if (new TextEncoder().encode(JSON.stringify(next)).length > maxAttachmentBytes) {\n throw new ReactiveHubError('Too many subscriptions on this connection');\n }\n socket.serializeAttachment(next);\n // Read the version before the data so a concurrent notify can only make the\n // client re-fetch, never miss a change.\n const version = await versions.get(request.path);\n const data = await handler.snapshot(context);\n return {\n type: 'snapshot',\n subscriptionId: request.subscriptionId,\n generation: request.generation,\n version,\n data,\n };\n },\n\n unsubscribe(request: UnsubscribeRequest, principal: string): void {\n const socket = ownedSocket(request.connectionId, principal);\n if (!socket) return;\n const state = socketState(socket);\n socket.serializeAttachment({\n ...state,\n subscriptions: state.subscriptions.filter(\n (entry) => entry.subscriptionId !== request.subscriptionId,\n ),\n } satisfies HubSocketState);\n },\n\n /** Bumps the path's version and asks each of its subscribers to re-fetch. */\n async notify(path: string): Promise<void> {\n const { fromVersion, toVersion } = await bump(path);\n for (const { socket, subscription } of subscribersOf(path)) {\n send(socket, {\n type: 'resync',\n subscriptionId: subscription.subscriptionId,\n generation: subscription.generation,\n fromVersion,\n toVersion,\n messageId: crypto.randomUUID(),\n });\n }\n },\n\n /**\n * Bumps the path's version and sends the same JSON patches to each subscriber.\n * Use it only when every subscriber of the path sees the same data.\n */\n async publish(path: string, patches: JsonPatch[]): Promise<void> {\n const { fromVersion, toVersion } = await bump(path);\n for (const { socket, subscription } of subscribersOf(path)) {\n send(socket, {\n type: 'patch',\n subscriptionId: subscription.subscriptionId,\n generation: subscription.generation,\n fromVersion,\n toVersion,\n messageId: crypto.randomUUID(),\n patches,\n });\n }\n },\n };\n}\n\nexport type ReactiveHub = ReturnType<typeof createReactiveHub>;\n"]}
@@ -0,0 +1,78 @@
1
+ interface JsonPatch {
2
+ op: 'add' | 'remove' | 'replace' | 'move' | 'copy' | 'test';
3
+ path: string;
4
+ value?: unknown;
5
+ from?: string;
6
+ }
7
+ interface SnapshotResponse {
8
+ type: 'snapshot';
9
+ subscriptionId: string;
10
+ generation: number;
11
+ version: number;
12
+ data: unknown;
13
+ }
14
+ interface ErrorResponse {
15
+ type: 'error';
16
+ message: string;
17
+ }
18
+ interface PatchEvent {
19
+ type: 'patch';
20
+ subscriptionId: string;
21
+ generation: number;
22
+ fromVersion: number;
23
+ toVersion: number;
24
+ messageId: string;
25
+ patches: JsonPatch[];
26
+ }
27
+ interface ResyncEvent {
28
+ type: 'resync';
29
+ subscriptionId: string;
30
+ generation: number;
31
+ fromVersion: number;
32
+ toVersion: number;
33
+ messageId: string;
34
+ }
35
+ interface ConnectedEvent {
36
+ type: 'connected';
37
+ connectionId: string;
38
+ }
39
+ interface PongEvent {
40
+ type: 'pong';
41
+ }
42
+ type WebSocketEvent = ConnectedEvent | PongEvent | PatchEvent | ResyncEvent;
43
+ /** Frames the client sends on the WebSocket: `init` after opening, then periodic `ping`. */
44
+ type WebSocketClientMessage = {
45
+ type: 'init';
46
+ } | {
47
+ type: 'ping';
48
+ };
49
+ /**
50
+ * HTTP request bodies the client POSTs to `httpUrl`. A resync re-sends `subscribe`
51
+ * with the same subscription and a new registration; servers answer with a snapshot
52
+ * whose `version` must exceed the last one the client accepted.
53
+ */
54
+ interface SubscribeRequest {
55
+ type: 'subscribe';
56
+ connectionId: string;
57
+ subscriptionId: string;
58
+ generation: number;
59
+ registrationId: string;
60
+ path: string;
61
+ input: unknown;
62
+ }
63
+ interface UnsubscribeRequest {
64
+ type: 'unsubscribe';
65
+ connectionId: string;
66
+ subscriptionId: string;
67
+ generation: number;
68
+ registrationId: string;
69
+ }
70
+ interface CallRequest {
71
+ type: 'call';
72
+ connectionId: string;
73
+ path: string;
74
+ input: unknown;
75
+ }
76
+ type HttpRequest = SubscribeRequest | UnsubscribeRequest | CallRequest;
77
+
78
+ export type { CallRequest as C, ErrorResponse as E, HttpRequest as H, JsonPatch as J, PatchEvent as P, ResyncEvent as R, SnapshotResponse as S, UnsubscribeRequest as U, WebSocketClientMessage as W, ConnectedEvent as a, PongEvent as b, SubscribeRequest as c, WebSocketEvent as d };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dynamodb-reactive",
3
- "version": "0.2.4",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "A Serverless, Reactive tRPC replacement for AWS DynamoDB",
6
6
  "keywords": [
@@ -84,6 +84,36 @@
84
84
  "types": "./dist/infra.d.cts",
85
85
  "default": "./dist/infra.cjs"
86
86
  }
87
+ },
88
+ "./cluster": {
89
+ "import": {
90
+ "types": "./dist/cluster.d.ts",
91
+ "default": "./dist/cluster.js"
92
+ },
93
+ "require": {
94
+ "types": "./dist/cluster.d.cts",
95
+ "default": "./dist/cluster.cjs"
96
+ }
97
+ },
98
+ "./hub": {
99
+ "import": {
100
+ "types": "./dist/hub.d.ts",
101
+ "default": "./dist/hub.js"
102
+ },
103
+ "require": {
104
+ "types": "./dist/hub.d.cts",
105
+ "default": "./dist/hub.cjs"
106
+ }
107
+ },
108
+ "./cloudflare": {
109
+ "import": {
110
+ "types": "./dist/cloudflare.d.ts",
111
+ "default": "./dist/cloudflare.js"
112
+ },
113
+ "require": {
114
+ "types": "./dist/cloudflare.d.cts",
115
+ "default": "./dist/cloudflare.cjs"
116
+ }
87
117
  }
88
118
  },
89
119
  "files": [