dynamodb-reactive 0.3.0 → 0.5.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 +121 -43
- package/dist/{client-Zb67giI5.d.ts → client-wZQ9XC9S.d.ts} +11 -0
- package/dist/client.cjs +53 -55
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +1 -1
- package/dist/client.d.ts +1 -1
- package/dist/client.js +53 -55
- package/dist/client.js.map +1 -1
- package/dist/cloudflare.cjs +461 -0
- package/dist/cloudflare.cjs.map +1 -0
- package/dist/cloudflare.d.cts +129 -0
- package/dist/cloudflare.d.ts +129 -0
- package/dist/cloudflare.js +456 -0
- package/dist/cloudflare.js.map +1 -0
- package/dist/cluster.cjs +66 -66
- package/dist/cluster.cjs.map +1 -1
- package/dist/cluster.d.cts +2 -78
- package/dist/cluster.d.ts +2 -78
- package/dist/cluster.js +66 -66
- package/dist/cluster.js.map +1 -1
- package/dist/core.cjs +1 -3
- package/dist/core.cjs.map +1 -1
- package/dist/core.js +1 -3
- package/dist/core.js.map +1 -1
- package/dist/hub.cjs +164 -0
- package/dist/hub.cjs.map +1 -0
- package/dist/hub.d.cts +91 -0
- package/dist/hub.d.ts +91 -0
- package/dist/hub.js +157 -0
- package/dist/hub.js.map +1 -0
- package/dist/index.cjs +1 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +1 -3
- package/dist/index.js.map +1 -1
- package/dist/infra.cjs +19 -33
- package/dist/infra.cjs.map +1 -1
- package/dist/infra.js +19 -33
- package/dist/infra.js.map +1 -1
- package/dist/protocol-C4Syt_Bl.d.ts +78 -0
- package/dist/react.cjs +56 -65
- package/dist/react.cjs.map +1 -1
- package/dist/react.js +56 -65
- package/dist/react.js.map +1 -1
- package/dist/server.cjs +46 -139
- package/dist/server.cjs.map +1 -1
- package/dist/server.js +46 -139
- package/dist/server.js.map +1 -1
- package/package.json +42 -24
package/README.md
CHANGED
|
@@ -24,15 +24,17 @@ pnpm add aws-cdk-lib constructs esbuild
|
|
|
24
24
|
|
|
25
25
|
Every public entry point supplies ESM (`.js` and `.d.ts`) and CommonJS (`.cjs` and `.d.cts`) conditions, so both `import` and `require()` consumers are supported:
|
|
26
26
|
|
|
27
|
-
| Import
|
|
28
|
-
|
|
|
29
|
-
| `dynamodb-reactive`
|
|
30
|
-
| `dynamodb-reactive/core`
|
|
31
|
-
| `dynamodb-reactive/server`
|
|
32
|
-
| `dynamodb-reactive/client`
|
|
33
|
-
| `dynamodb-reactive/react`
|
|
34
|
-
| `dynamodb-reactive/infra`
|
|
35
|
-
| `dynamodb-reactive/cluster`
|
|
27
|
+
| Import | Contents |
|
|
28
|
+
| ------------------------------ | --------------------------------------------------------------------------- |
|
|
29
|
+
| `dynamodb-reactive` | Core table and schema exports |
|
|
30
|
+
| `dynamodb-reactive/core` | Same core API, explicitly named |
|
|
31
|
+
| `dynamodb-reactive/server` | Routers, database context, and HTTP/stream handlers |
|
|
32
|
+
| `dynamodb-reactive/client` | React-free client, WebSocket manager, and query-key helpers |
|
|
33
|
+
| `dynamodb-reactive/react` | Provider and React hooks |
|
|
34
|
+
| `dynamodb-reactive/infra` | `ReactiveEngine` and its configuration types |
|
|
35
|
+
| `dynamodb-reactive/cluster` | Cluster-worker connection, signed tickets, and wire-protocol types |
|
|
36
|
+
| `dynamodb-reactive/hub` | Runtime-free reactive hub core for backends that hold WebSockets themselves |
|
|
37
|
+
| `dynamodb-reactive/cloudflare` | Durable Object hub shell and ticket, socket, and reactive HTTP routes |
|
|
36
38
|
|
|
37
39
|
React code must import the provider and hooks from `dynamodb-reactive/react`. Importing `dynamodb-reactive/client` does not load React.
|
|
38
40
|
|
|
@@ -84,20 +86,18 @@ const t = initReactive<AppContext>();
|
|
|
84
86
|
|
|
85
87
|
export const appRouter = t.router({
|
|
86
88
|
todos: {
|
|
87
|
-
list: t.procedure
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
.execute();
|
|
100
|
-
}),
|
|
89
|
+
list: t.procedure.input(z.object({ ownerId: z.string() })).query(({ ctx, input }) => {
|
|
90
|
+
if (input.ownerId !== ctx.principalId) {
|
|
91
|
+
throw new Error('Forbidden');
|
|
92
|
+
}
|
|
93
|
+
return ctx.db
|
|
94
|
+
.query(TodoTable)
|
|
95
|
+
.useIndex('byOwner')
|
|
96
|
+
.filter((q) => q.eq(TodoTable.field.ownerId, input.ownerId))
|
|
97
|
+
.sortDescending()
|
|
98
|
+
.take(100)
|
|
99
|
+
.execute();
|
|
100
|
+
}),
|
|
101
101
|
|
|
102
102
|
create: t.procedure
|
|
103
103
|
.input(z.object({ text: z.string().min(1) }))
|
|
@@ -126,15 +126,13 @@ export const appRouter = t.router({
|
|
|
126
126
|
),
|
|
127
127
|
),
|
|
128
128
|
|
|
129
|
-
delete: t.procedure
|
|
130
|
-
.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
return { id: input.id };
|
|
137
|
-
}),
|
|
129
|
+
delete: t.procedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
|
|
130
|
+
await ctx.db.delete(TodoTable, {
|
|
131
|
+
pk: `USER#${ctx.principalId}`,
|
|
132
|
+
sk: `TODO#${input.id}`,
|
|
133
|
+
});
|
|
134
|
+
return { id: input.id };
|
|
135
|
+
}),
|
|
138
136
|
},
|
|
139
137
|
});
|
|
140
138
|
|
|
@@ -297,10 +295,7 @@ The HTTP principal must own the WebSocket connection named in the request. The c
|
|
|
297
295
|
|
|
298
296
|
```ts
|
|
299
297
|
// reactive-api.ts
|
|
300
|
-
import {
|
|
301
|
-
createReactiveHandler,
|
|
302
|
-
type ReactiveRequest,
|
|
303
|
-
} from 'dynamodb-reactive/server';
|
|
298
|
+
import { createReactiveHandler, type ReactiveRequest } from 'dynamodb-reactive/server';
|
|
304
299
|
import { z } from 'zod';
|
|
305
300
|
|
|
306
301
|
import { authenticateBearer } from './auth.js';
|
|
@@ -428,9 +423,7 @@ export function Todos({ ownerId }: { ownerId: string }) {
|
|
|
428
423
|
return (
|
|
429
424
|
<section aria-busy={todos.loading || todos.stale}>
|
|
430
425
|
<p>Connection: {connection}</p>
|
|
431
|
-
<button onClick={() => void createTodo.mutate({ text: 'New todo' })}>
|
|
432
|
-
Add
|
|
433
|
-
</button>
|
|
426
|
+
<button onClick={() => void createTodo.mutate({ text: 'New todo' })}>Add</button>
|
|
434
427
|
{todos.data?.map((todo) => (
|
|
435
428
|
<div key={todo.id}>
|
|
436
429
|
<button
|
|
@@ -444,9 +437,7 @@ export function Todos({ ownerId }: { ownerId: string }) {
|
|
|
444
437
|
{todo.completed ? 'Undo' : 'Complete'}
|
|
445
438
|
</button>
|
|
446
439
|
<span>{todo.text}</span>
|
|
447
|
-
<button onClick={() => void deleteTodo.mutate({ id: todo.id })}>
|
|
448
|
-
Delete
|
|
449
|
-
</button>
|
|
440
|
+
<button onClick={() => void deleteTodo.mutate({ id: todo.id })}>Delete</button>
|
|
450
441
|
</div>
|
|
451
442
|
))}
|
|
452
443
|
</section>
|
|
@@ -544,6 +535,93 @@ Applications can mint stateless tickets with `createSignedClusterTicket({ secret
|
|
|
544
535
|
|
|
545
536
|
The module also exports the wire-protocol types (`SubscribeRequest`, `SnapshotResponse`, `ResyncEvent`, `WebSocketEvent`, and the rest) for backends that serve the protocol without the AWS engine. A server that only sends `resync` events is correct: the client answers a resync by re-sending `subscribe`, and accepts the new snapshot only when its `version` is higher than the last one.
|
|
546
537
|
|
|
538
|
+
## Reactive hubs and Cloudflare Durable Objects
|
|
539
|
+
|
|
540
|
+
`dynamodb-reactive/hub` is the server half of the wire protocol for backends that hold WebSockets themselves. `createReactiveHub({ sockets, versions, paths })` answers `init` and `ping`, stores subscriptions on each socket's attachment so they survive hibernation, checks that an HTTP caller owns the connection it subscribes, and fans out `notify(path)` as `resync` events or `publish(path, patches)` as `patch` events. Each path has its own version. `paths` maps fixed names to handlers, or is a function that resolves parameterised paths such as `drafts.<id>`:
|
|
541
|
+
|
|
542
|
+
```ts
|
|
543
|
+
const paths = (path: string) =>
|
|
544
|
+
path.startsWith('drafts.')
|
|
545
|
+
? {
|
|
546
|
+
// Runs before the subscription is stored; every subscriber of a path receives its pushes.
|
|
547
|
+
authorize: ({ principal }) => ownsDraft(principal, path.slice(7)),
|
|
548
|
+
snapshot: () => loadDraft(path.slice(7)),
|
|
549
|
+
}
|
|
550
|
+
: undefined;
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
`publish` sends the same patches to every subscriber of a path, so use it only for paths whose subscribers all see the same data. Subscriptions are rejected once a socket's attachment would exceed 2,048 bytes, the hibernation limit.
|
|
554
|
+
|
|
555
|
+
`dynamodb-reactive/cloudflare` wires the hub to a Durable Object. It takes the object's `ctx` instead of extending `DurableObject`, so it imports nothing from `cloudflare:workers`, and your class keeps its own name and migrations:
|
|
556
|
+
|
|
557
|
+
```ts
|
|
558
|
+
import { DurableObject } from 'cloudflare:workers';
|
|
559
|
+
import {
|
|
560
|
+
createDurableObjectHub,
|
|
561
|
+
handleReactiveRoutes,
|
|
562
|
+
hubClient,
|
|
563
|
+
} from 'dynamodb-reactive/cloudflare';
|
|
564
|
+
|
|
565
|
+
export class ReactiveHubObject extends DurableObject<Env> {
|
|
566
|
+
private readonly hub = createDurableObjectHub(this.ctx, { paths });
|
|
567
|
+
override fetch(request: Request) {
|
|
568
|
+
return this.hub.fetch(request);
|
|
569
|
+
}
|
|
570
|
+
override webSocketMessage(socket: WebSocket, message: string | ArrayBuffer) {
|
|
571
|
+
this.hub.webSocketMessage(socket, message);
|
|
572
|
+
}
|
|
573
|
+
override webSocketClose(socket: WebSocket, code: number) {
|
|
574
|
+
return this.hub.webSocketClose(socket, code);
|
|
575
|
+
}
|
|
576
|
+
override webSocketError(socket: WebSocket) {
|
|
577
|
+
return this.hub.webSocketError(socket);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const hub = (env: Env) => env.HUB.get(env.HUB.idFromName('hub'));
|
|
582
|
+
|
|
583
|
+
export default {
|
|
584
|
+
async fetch(request: Request, env: Env) {
|
|
585
|
+
const response = await handleReactiveRoutes(request, {
|
|
586
|
+
routes: {
|
|
587
|
+
ticket: '/api/reactive/token',
|
|
588
|
+
socket: '/api/reactive/socket',
|
|
589
|
+
reactive: '/api/reactive',
|
|
590
|
+
},
|
|
591
|
+
secret: env.REACTIVE_TICKET_SECRET,
|
|
592
|
+
hub: () => hub(env),
|
|
593
|
+
authenticate: (request) => principalFor(request), // undefined answers 401
|
|
594
|
+
});
|
|
595
|
+
return response ?? new Response('Not found', { status: 404 });
|
|
596
|
+
},
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
// Anywhere in the Worker, after the data behind a path changes:
|
|
600
|
+
await hubClient(hub(env)).notify('drafts.123');
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
`handleReactiveRoutes` issues signed tickets, verifies them on the WebSocket upgrade and on reactive HTTP requests, and forwards both to the hub with the verified principal. It answers 503 while `secret` is unset. By default the object answers `ping` with `pong` through the runtime's WebSocket auto-response, so heartbeats do not wake a hibernating object. Versions are kept in the object's storage under `version:<path>`; pass `versionKey` to change that. To shard, pick the instance per path with `idFromName`.
|
|
604
|
+
|
|
605
|
+
### Presence
|
|
606
|
+
|
|
607
|
+
Pass `presence` to know whether a principal, such as a cluster worker, is online without it polling over HTTP:
|
|
608
|
+
|
|
609
|
+
```ts
|
|
610
|
+
private readonly hub = createDurableObjectHub(this.ctx, {
|
|
611
|
+
paths,
|
|
612
|
+
presence: {
|
|
613
|
+
principals: ['worker'],
|
|
614
|
+
timeoutMs: 90_000, // the default; clients must ping more often than this
|
|
615
|
+
onChange: () => hubClient(stub).notify('cluster.status'),
|
|
616
|
+
},
|
|
617
|
+
});
|
|
618
|
+
override alarm() {
|
|
619
|
+
return this.hub.alarm();
|
|
620
|
+
}
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
`hub.present(principal)` is true while the principal holds a socket that pinged within `timeoutMs`. The runtime records each auto-answered ping without waking the object, so presence costs nothing per heartbeat. An alarm wakes the object when the stalest tracked socket would time out, closes silent sockets with code `4001`, and reschedules while tracked sockets remain. That sweep is what catches a peer that vanished without a close frame, which an open socket alone never reveals. `onChange` runs when a principal gains its first live socket or loses its last one; the last reported state is stored under `presence:<principal>`, so each change is reported once. Presence needs `autoPong` and the object's `alarm` delegated to the hub.
|
|
624
|
+
|
|
547
625
|
## Delivery model
|
|
548
626
|
|
|
549
627
|
1. The client establishes a WebSocket and receives its API Gateway connection ID.
|
|
@@ -553,7 +631,7 @@ The module also exports the wire-protocol types (`SubscribeRequest`, `SnapshotRe
|
|
|
553
631
|
|
|
554
632
|
Pending delivery is durable until a send completes, so retries are at least once. A send followed by a failed clear can produce a duplicate; clients must keep duplicate handling enabled. Patch messages larger than 30 KiB become a small `resync` instruction rather than a WebSocket payload. Serialized subscription state is limited to 300 KiB.
|
|
555
633
|
|
|
556
|
-
Connection and subscription records receive three-hour TTLs. Each authorized HTTP operation refreshes the connection lease; registration and re-registration renew the query and dependency records. The browser sends a WebSocket heartbeat every five minutes, but API Gateway can still close idle or maximum-age connections; the client reconnects and re-registers active subscriptions. DynamoDB TTL removes expired records asynchronously and is cleanup, not an authorization or immediate-deletion mechanism. HTTP ownership checks explicitly enforce connection status and lease expiry.
|
|
634
|
+
Connection and subscription records receive three-hour TTLs. Each authorized HTTP operation refreshes the connection lease; registration and re-registration renew the query and dependency records. The browser sends a WebSocket heartbeat every five minutes (`heartbeatInterval`), but API Gateway can still close idle or maximum-age connections; the client reconnects and re-registers active subscriptions. A heartbeat unanswered within `heartbeatTimeout` (15 seconds by default) marks the socket dead, so a server or network that vanished without a close frame is replaced instead of trusted. `connectClusterWorker` pings every 30 seconds. DynamoDB TTL removes expired records asynchronously and is cleanup, not an authorization or immediate-deletion mechanism. HTTP ownership checks explicitly enforce connection status and lease expiry.
|
|
557
635
|
|
|
558
636
|
## Security
|
|
559
637
|
|
|
@@ -45,6 +45,17 @@ interface ReactiveClientConfig {
|
|
|
45
45
|
connectionTimeout?: number;
|
|
46
46
|
/** Maximum time for the init/connected/pong handshake. @default 10000 */
|
|
47
47
|
handshakeTimeout?: number;
|
|
48
|
+
/**
|
|
49
|
+
* Interval between `ping` frames once connected. The default suits API Gateway, which bills
|
|
50
|
+
* every message and closes sockets idle for 10 minutes; Durable Object hubs answer pings
|
|
51
|
+
* without waking, so cluster workers can ping far more often. @default 300000
|
|
52
|
+
*/
|
|
53
|
+
heartbeatInterval?: number;
|
|
54
|
+
/**
|
|
55
|
+
* Time a `ping` may go unanswered before the socket is treated as dead and replaced. A server
|
|
56
|
+
* that vanishes without closing the connection is otherwise never noticed. @default 15000
|
|
57
|
+
*/
|
|
58
|
+
heartbeatTimeout?: number;
|
|
48
59
|
/**
|
|
49
60
|
* Callback when connected
|
|
50
61
|
*/
|
package/dist/client.cjs
CHANGED
|
@@ -29,9 +29,7 @@ function canonicalize(value, seen, key, arrayItem = false, applyToJSON = true) {
|
|
|
29
29
|
if (Array.isArray(value)) {
|
|
30
30
|
const items = [];
|
|
31
31
|
for (let index = 0; index < value.length; index++) {
|
|
32
|
-
items.push(
|
|
33
|
-
canonicalize(value[index], seen, String(index), true) ?? "null"
|
|
34
|
-
);
|
|
32
|
+
items.push(canonicalize(value[index], seen, String(index), true) ?? "null");
|
|
35
33
|
}
|
|
36
34
|
return `[${items.join(",")}]`;
|
|
37
35
|
}
|
|
@@ -48,6 +46,9 @@ function canonicalize(value, seen, key, arrayItem = false, applyToJSON = true) {
|
|
|
48
46
|
seen.delete(value);
|
|
49
47
|
}
|
|
50
48
|
}
|
|
49
|
+
case "bigint":
|
|
50
|
+
case "function":
|
|
51
|
+
case "symbol":
|
|
51
52
|
default:
|
|
52
53
|
throw new TypeError(`Unsupported query input value: ${typeof value}`);
|
|
53
54
|
}
|
|
@@ -55,7 +56,6 @@ function canonicalize(value, seen, key, arrayItem = false, applyToJSON = true) {
|
|
|
55
56
|
function getQueryKey(path, input) {
|
|
56
57
|
return `${JSON.stringify(path)}:${canonicalize(input, /* @__PURE__ */ new Set(), "") ?? "undefined"}`;
|
|
57
58
|
}
|
|
58
|
-
var { applyPatch } = jsonpatch__default.default;
|
|
59
59
|
function applyPatches(document, patches) {
|
|
60
60
|
if (patches.length === 0) {
|
|
61
61
|
return document;
|
|
@@ -74,7 +74,7 @@ function applyPatches(document, patches) {
|
|
|
74
74
|
return op;
|
|
75
75
|
});
|
|
76
76
|
try {
|
|
77
|
-
const result = applyPatch(
|
|
77
|
+
const result = jsonpatch__default.default.applyPatch(
|
|
78
78
|
structuredClone(document),
|
|
79
79
|
operations,
|
|
80
80
|
true,
|
|
@@ -94,12 +94,13 @@ function error(...values) {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
// ../client/src/websocket.ts
|
|
97
|
-
var HEARTBEAT_INTERVAL_MS = 5 * 60 * 1e3;
|
|
98
97
|
function withDefaults(config) {
|
|
99
98
|
return {
|
|
100
99
|
autoReconnect: true,
|
|
101
100
|
connectionTimeout: 1e4,
|
|
102
101
|
handshakeTimeout: 1e4,
|
|
102
|
+
heartbeatInterval: 5 * 60 * 1e3,
|
|
103
|
+
heartbeatTimeout: 15e3,
|
|
103
104
|
maxReconnectAttempts: 10,
|
|
104
105
|
reconnectDelay: 1e3,
|
|
105
106
|
...config
|
|
@@ -113,6 +114,7 @@ var WebSocketManager = class {
|
|
|
113
114
|
reconnectTimer = null;
|
|
114
115
|
handshakeTimer = null;
|
|
115
116
|
heartbeatTimer = null;
|
|
117
|
+
pongTimer = null;
|
|
116
118
|
messageHandlers = /* @__PURE__ */ new Set();
|
|
117
119
|
stateHandlers = /* @__PURE__ */ new Set();
|
|
118
120
|
handshakeHandlers = /* @__PURE__ */ new Set();
|
|
@@ -212,16 +214,7 @@ var WebSocketManager = class {
|
|
|
212
214
|
};
|
|
213
215
|
nextSocket.onclose = () => {
|
|
214
216
|
if (!this.isCurrent(nextSocket, generation)) return;
|
|
215
|
-
this.
|
|
216
|
-
this.ws = null;
|
|
217
|
-
this._connectionId = null;
|
|
218
|
-
this.setState("disconnected");
|
|
219
|
-
this.invokeLifecycleCallback("onDisconnect", this.config.onDisconnect);
|
|
220
|
-
if (this.config.autoReconnect && !this.intentionalDisconnect) {
|
|
221
|
-
this.scheduleReconnect();
|
|
222
|
-
} else {
|
|
223
|
-
this.rejectWaiters(new Error("WebSocket disconnected"));
|
|
224
|
-
}
|
|
217
|
+
this.handleClose();
|
|
225
218
|
};
|
|
226
219
|
nextSocket.onerror = () => {
|
|
227
220
|
if (!this.isCurrent(nextSocket, generation)) return;
|
|
@@ -310,6 +303,7 @@ var WebSocketManager = class {
|
|
|
310
303
|
return;
|
|
311
304
|
}
|
|
312
305
|
if (message.type === "pong") {
|
|
306
|
+
this.clearPongTimer();
|
|
313
307
|
if (!this._connectionId || this.state === "connected") return;
|
|
314
308
|
this.clearHandshakeTimer();
|
|
315
309
|
this.lastConnectionError = void 0;
|
|
@@ -338,6 +332,18 @@ var WebSocketManager = class {
|
|
|
338
332
|
socket.close(4e3, "Handshake timeout");
|
|
339
333
|
}, this.config.handshakeTimeout);
|
|
340
334
|
}
|
|
335
|
+
handleClose() {
|
|
336
|
+
this.clearConnectionTimers();
|
|
337
|
+
this.ws = null;
|
|
338
|
+
this._connectionId = null;
|
|
339
|
+
this.setState("disconnected");
|
|
340
|
+
this.invokeLifecycleCallback("onDisconnect", this.config.onDisconnect);
|
|
341
|
+
if (this.config.autoReconnect && !this.intentionalDisconnect) {
|
|
342
|
+
this.scheduleReconnect();
|
|
343
|
+
} else {
|
|
344
|
+
this.rejectWaiters(new Error("WebSocket disconnected"));
|
|
345
|
+
}
|
|
346
|
+
}
|
|
341
347
|
startHeartbeat(socket, generation) {
|
|
342
348
|
this.clearHeartbeatTimer();
|
|
343
349
|
this.heartbeatTimer = setInterval(() => {
|
|
@@ -345,7 +351,17 @@ var WebSocketManager = class {
|
|
|
345
351
|
return;
|
|
346
352
|
}
|
|
347
353
|
socket.send(JSON.stringify({ type: "ping" }));
|
|
348
|
-
|
|
354
|
+
this.pongTimer ??= setTimeout(() => {
|
|
355
|
+
this.pongTimer = null;
|
|
356
|
+
if (!this.isCurrent(socket, generation)) return;
|
|
357
|
+
const failure = new Error("WebSocket heartbeat timed out");
|
|
358
|
+
this.lastConnectionError = failure;
|
|
359
|
+
this.invokeLifecycleCallback("onError", this.config.onError, failure);
|
|
360
|
+
socket.onclose = null;
|
|
361
|
+
socket.close(4001, "Heartbeat timeout");
|
|
362
|
+
this.handleClose();
|
|
363
|
+
}, this.config.heartbeatTimeout);
|
|
364
|
+
}, this.config.heartbeatInterval);
|
|
349
365
|
}
|
|
350
366
|
isCurrent(socket, generation) {
|
|
351
367
|
return this.ws === socket && this.socketGeneration === generation;
|
|
@@ -412,9 +428,15 @@ var WebSocketManager = class {
|
|
|
412
428
|
clearInterval(this.heartbeatTimer);
|
|
413
429
|
this.heartbeatTimer = null;
|
|
414
430
|
}
|
|
431
|
+
clearPongTimer() {
|
|
432
|
+
if (!this.pongTimer) return;
|
|
433
|
+
clearTimeout(this.pongTimer);
|
|
434
|
+
this.pongTimer = null;
|
|
435
|
+
}
|
|
415
436
|
clearConnectionTimers() {
|
|
416
437
|
this.clearHandshakeTimer();
|
|
417
438
|
this.clearHeartbeatTimer();
|
|
439
|
+
this.clearPongTimer();
|
|
418
440
|
}
|
|
419
441
|
clearTimers() {
|
|
420
442
|
this.clearConnectionTimers();
|
|
@@ -514,10 +536,7 @@ var ReactiveClient = class {
|
|
|
514
536
|
this.subscriptionsByKey.set(key, shared);
|
|
515
537
|
this.subscriptionsById.set(shared.id, shared);
|
|
516
538
|
if (this.connectionState === "connected") {
|
|
517
|
-
void this.ensureRegistered(
|
|
518
|
-
shared,
|
|
519
|
-
this.wsManager.generation
|
|
520
|
-
).catch(() => void 0);
|
|
539
|
+
void this.ensureRegistered(shared, this.wsManager.generation).catch(() => void 0);
|
|
521
540
|
}
|
|
522
541
|
}
|
|
523
542
|
const leaseId = generateId();
|
|
@@ -526,9 +545,7 @@ var ReactiveClient = class {
|
|
|
526
545
|
onError: options.onError
|
|
527
546
|
});
|
|
528
547
|
if (this.connectionState === "connected" && (!shared.hasRegistered || shared.stale) && !shared.registrationPromise) {
|
|
529
|
-
void this.ensureRegistered(shared, this.wsManager.generation).catch(
|
|
530
|
-
() => void 0
|
|
531
|
-
);
|
|
548
|
+
void this.ensureRegistered(shared, this.wsManager.generation).catch(() => void 0);
|
|
532
549
|
}
|
|
533
550
|
const state = shared;
|
|
534
551
|
let released = false;
|
|
@@ -617,9 +634,7 @@ var ReactiveClient = class {
|
|
|
617
634
|
registerActiveSubscriptions(socketGeneration) {
|
|
618
635
|
for (const state of this.subscriptionsByKey.values()) {
|
|
619
636
|
if (state.active) {
|
|
620
|
-
void this.ensureRegistered(state, socketGeneration).catch(
|
|
621
|
-
() => void 0
|
|
622
|
-
);
|
|
637
|
+
void this.ensureRegistered(state, socketGeneration).catch(() => void 0);
|
|
623
638
|
}
|
|
624
639
|
}
|
|
625
640
|
}
|
|
@@ -672,9 +687,7 @@ var ReactiveClient = class {
|
|
|
672
687
|
})
|
|
673
688
|
});
|
|
674
689
|
if (!response.ok) {
|
|
675
|
-
throw new Error(
|
|
676
|
-
`Subscribe failed: ${response.status} ${response.statusText}`
|
|
677
|
-
);
|
|
690
|
+
throw new Error(`Subscribe failed: ${response.status} ${response.statusText}`);
|
|
678
691
|
}
|
|
679
692
|
const result = await response.json();
|
|
680
693
|
if (result.type === "error") {
|
|
@@ -693,12 +706,7 @@ var ReactiveClient = class {
|
|
|
693
706
|
throw cause;
|
|
694
707
|
} finally {
|
|
695
708
|
if ((!state.active || !isCurrent()) && requestIssued) {
|
|
696
|
-
void this.sendUnsubscribe(
|
|
697
|
-
registrationConnectionId,
|
|
698
|
-
state.id,
|
|
699
|
-
generation,
|
|
700
|
-
registrationId
|
|
701
|
-
);
|
|
709
|
+
void this.sendUnsubscribe(registrationConnectionId, state.id, generation, registrationId);
|
|
702
710
|
}
|
|
703
711
|
if (state.registrationPromise === request) {
|
|
704
712
|
state.registrationPromise = void 0;
|
|
@@ -803,15 +811,12 @@ var ReactiveClient = class {
|
|
|
803
811
|
return;
|
|
804
812
|
}
|
|
805
813
|
this.discardStalePatches(state);
|
|
806
|
-
|
|
807
|
-
const next = [...state.patchBuffer.values()].filter((patch) => patch.fromVersion === state.version).sort((left, right) => left.toVersion - right.toVersion)
|
|
814
|
+
for (; ; ) {
|
|
815
|
+
const next = [...state.patchBuffer.values()].filter((patch) => patch.fromVersion === state.version).sort((left, right) => left.toVersion - right.toVersion).at(0);
|
|
808
816
|
if (!next) break;
|
|
809
817
|
state.patchBuffer.delete(next.messageId);
|
|
810
818
|
try {
|
|
811
|
-
state.authoritativeData = applyPatches(
|
|
812
|
-
state.authoritativeData,
|
|
813
|
-
next.patches
|
|
814
|
-
);
|
|
819
|
+
state.authoritativeData = applyPatches(state.authoritativeData, next.patches);
|
|
815
820
|
state.version = next.toVersion;
|
|
816
821
|
state.appliedMessageIds.add(next.messageId);
|
|
817
822
|
const awaitingReconciliation = this.hasSettledLayers(state);
|
|
@@ -849,12 +854,7 @@ var ReactiveClient = class {
|
|
|
849
854
|
state.stale = true;
|
|
850
855
|
state.loading = true;
|
|
851
856
|
this.notifySubscription(state);
|
|
852
|
-
|
|
853
|
-
request = this.ensureRegistered(
|
|
854
|
-
state,
|
|
855
|
-
this.wsManager.generation,
|
|
856
|
-
true
|
|
857
|
-
).finally(() => {
|
|
857
|
+
const request = this.ensureRegistered(state, this.wsManager.generation, true).finally(() => {
|
|
858
858
|
if (state.resyncPromise === request) {
|
|
859
859
|
state.resyncPromise = void 0;
|
|
860
860
|
state.resyncGeneration = void 0;
|
|
@@ -922,7 +922,10 @@ var ReactiveClient = class {
|
|
|
922
922
|
if (options?.optimisticData === void 0) return void 0;
|
|
923
923
|
return (data) => {
|
|
924
924
|
if (!Array.isArray(data)) return data;
|
|
925
|
-
const optimistic = settled ?
|
|
925
|
+
const optimistic = settled ? (
|
|
926
|
+
// oxlint-disable-next-line typescript/prefer-nullish-coalescing -- null is a valid mutation result.
|
|
927
|
+
result === void 0 ? typeof options.optimisticData === "function" ? options.optimisticData(input, data) : options.optimisticData : result
|
|
928
|
+
) : typeof options.optimisticData === "function" ? options.optimisticData(input, data) : options.optimisticData;
|
|
926
929
|
if (settled && optimistic !== null && typeof optimistic === "object") {
|
|
927
930
|
const optimisticId = optimistic.id;
|
|
928
931
|
if (optimisticId !== void 0 && data.some(
|
|
@@ -937,12 +940,7 @@ var ReactiveClient = class {
|
|
|
937
940
|
settleMutationLayers(mutationId, input, result, options, states) {
|
|
938
941
|
for (const state of states) {
|
|
939
942
|
const layer = state.optimisticLayers.get(mutationId);
|
|
940
|
-
const updater = this.createOptimisticUpdater(
|
|
941
|
-
input,
|
|
942
|
-
options,
|
|
943
|
-
result,
|
|
944
|
-
true
|
|
945
|
-
);
|
|
943
|
+
const updater = this.createOptimisticUpdater(input, options, result, true);
|
|
946
944
|
if (!layer || !updater) continue;
|
|
947
945
|
layer.updater = updater;
|
|
948
946
|
layer.settledRevision = ++state.settlementRevision;
|