dynamodb-reactive 0.4.0 → 0.6.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 +93 -51
- package/dist/{client-Zb67giI5.d.ts → client-BPAct-Dz.d.ts} +19 -0
- package/dist/client.cjs +68 -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 +68 -55
- package/dist/client.js.map +1 -1
- package/dist/cloudflare.cjs +223 -36
- package/dist/cloudflare.cjs.map +1 -1
- package/dist/cloudflare.d.cts +78 -6
- package/dist/cloudflare.d.ts +78 -6
- package/dist/cloudflare.js +223 -36
- package/dist/cloudflare.js.map +1 -1
- package/dist/cluster.cjs +81 -66
- package/dist/cluster.cjs.map +1 -1
- package/dist/cluster.js +81 -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 +35 -10
- package/dist/hub.cjs.map +1 -1
- package/dist/hub.d.cts +18 -3
- package/dist/hub.d.ts +18 -3
- package/dist/hub.js +35 -10
- package/dist/hub.js.map +1 -1
- 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/react.cjs +71 -65
- package/dist/react.cjs.map +1 -1
- package/dist/react.js +71 -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 +22 -24
package/README.md
CHANGED
|
@@ -24,17 +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`
|
|
36
|
-
| `dynamodb-reactive/hub`
|
|
37
|
-
| `dynamodb-reactive/cloudflare` | Durable Object hub shell and ticket, socket, and reactive HTTP routes
|
|
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 |
|
|
38
38
|
|
|
39
39
|
React code must import the provider and hooks from `dynamodb-reactive/react`. Importing `dynamodb-reactive/client` does not load React.
|
|
40
40
|
|
|
@@ -86,20 +86,18 @@ const t = initReactive<AppContext>();
|
|
|
86
86
|
|
|
87
87
|
export const appRouter = t.router({
|
|
88
88
|
todos: {
|
|
89
|
-
list: t.procedure
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
.execute();
|
|
102
|
-
}),
|
|
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
|
+
}),
|
|
103
101
|
|
|
104
102
|
create: t.procedure
|
|
105
103
|
.input(z.object({ text: z.string().min(1) }))
|
|
@@ -128,15 +126,13 @@ export const appRouter = t.router({
|
|
|
128
126
|
),
|
|
129
127
|
),
|
|
130
128
|
|
|
131
|
-
delete: t.procedure
|
|
132
|
-
.
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
return { id: input.id };
|
|
139
|
-
}),
|
|
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
|
+
}),
|
|
140
136
|
},
|
|
141
137
|
});
|
|
142
138
|
|
|
@@ -299,10 +295,7 @@ The HTTP principal must own the WebSocket connection named in the request. The c
|
|
|
299
295
|
|
|
300
296
|
```ts
|
|
301
297
|
// reactive-api.ts
|
|
302
|
-
import {
|
|
303
|
-
createReactiveHandler,
|
|
304
|
-
type ReactiveRequest,
|
|
305
|
-
} from 'dynamodb-reactive/server';
|
|
298
|
+
import { createReactiveHandler, type ReactiveRequest } from 'dynamodb-reactive/server';
|
|
306
299
|
import { z } from 'zod';
|
|
307
300
|
|
|
308
301
|
import { authenticateBearer } from './auth.js';
|
|
@@ -430,9 +423,7 @@ export function Todos({ ownerId }: { ownerId: string }) {
|
|
|
430
423
|
return (
|
|
431
424
|
<section aria-busy={todos.loading || todos.stale}>
|
|
432
425
|
<p>Connection: {connection}</p>
|
|
433
|
-
<button onClick={() => void createTodo.mutate({ text: 'New todo' })}>
|
|
434
|
-
Add
|
|
435
|
-
</button>
|
|
426
|
+
<button onClick={() => void createTodo.mutate({ text: 'New todo' })}>Add</button>
|
|
436
427
|
{todos.data?.map((todo) => (
|
|
437
428
|
<div key={todo.id}>
|
|
438
429
|
<button
|
|
@@ -446,9 +437,7 @@ export function Todos({ ownerId }: { ownerId: string }) {
|
|
|
446
437
|
{todo.completed ? 'Undo' : 'Complete'}
|
|
447
438
|
</button>
|
|
448
439
|
<span>{todo.text}</span>
|
|
449
|
-
<button onClick={() => void deleteTodo.mutate({ id: todo.id })}>
|
|
450
|
-
Delete
|
|
451
|
-
</button>
|
|
440
|
+
<button onClick={() => void deleteTodo.mutate({ id: todo.id })}>Delete</button>
|
|
452
441
|
</div>
|
|
453
442
|
))}
|
|
454
443
|
</section>
|
|
@@ -567,14 +556,26 @@ const paths = (path: string) =>
|
|
|
567
556
|
|
|
568
557
|
```ts
|
|
569
558
|
import { DurableObject } from 'cloudflare:workers';
|
|
570
|
-
import {
|
|
559
|
+
import {
|
|
560
|
+
createDurableObjectHub,
|
|
561
|
+
handleReactiveRoutes,
|
|
562
|
+
hubClient,
|
|
563
|
+
} from 'dynamodb-reactive/cloudflare';
|
|
571
564
|
|
|
572
565
|
export class ReactiveHubObject extends DurableObject<Env> {
|
|
573
566
|
private readonly hub = createDurableObjectHub(this.ctx, { paths });
|
|
574
|
-
override fetch(request: Request) {
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
override
|
|
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
|
+
}
|
|
578
579
|
}
|
|
579
580
|
|
|
580
581
|
const hub = (env: Env) => env.HUB.get(env.HUB.idFromName('hub'));
|
|
@@ -582,7 +583,11 @@ const hub = (env: Env) => env.HUB.get(env.HUB.idFromName('hub'));
|
|
|
582
583
|
export default {
|
|
583
584
|
async fetch(request: Request, env: Env) {
|
|
584
585
|
const response = await handleReactiveRoutes(request, {
|
|
585
|
-
routes: {
|
|
586
|
+
routes: {
|
|
587
|
+
ticket: '/api/reactive/token',
|
|
588
|
+
socket: '/api/reactive/socket',
|
|
589
|
+
reactive: '/api/reactive',
|
|
590
|
+
},
|
|
586
591
|
secret: env.REACTIVE_TICKET_SECRET,
|
|
587
592
|
hub: () => hub(env),
|
|
588
593
|
authenticate: (request) => principalFor(request), // undefined answers 401
|
|
@@ -597,6 +602,43 @@ await hubClient(hub(env)).notify('drafts.123');
|
|
|
597
602
|
|
|
598
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`.
|
|
599
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. `principals` may also be a predicate, such as `(principal) => principal.startsWith('listener:')`, for open-ended principals. The hub keeps an earlier alarm when it schedules its sweep, so an object with its own alarm work should schedule the same way and call `hub.alarm()` from its `alarm`.
|
|
624
|
+
|
|
625
|
+
### Application frames and socket data
|
|
626
|
+
|
|
627
|
+
`onOpen(socket, principal)` and `onClose(socket, principal)` run as sockets come and go, and `onMessage(socket, principal, frame)` receives any client frame with a `type` the protocol does not handle. Clients send those with `client.send({ type: 'listening', track })`, which returns false while disconnected; resend from `onConnectionStateChange` once connected again. `hub.setSocketData(socket, data)` keeps small per-socket state that survives hibernation, read back with `hub.socketData(socket)` while iterating `hub.sockets()`. It shares the 2,048-byte attachment limit with subscriptions. During `onClose` the closing socket may still be listed, so exclude it when counting.
|
|
628
|
+
|
|
629
|
+
### Hubs for apps hosted elsewhere
|
|
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:
|
|
632
|
+
|
|
633
|
+
```ts
|
|
634
|
+
import { remoteHub } from 'dynamodb-reactive/cloudflare';
|
|
635
|
+
|
|
636
|
+
const hub = remoteHub({ url: 'https://hub.example.com/api/reactive/notify', secret });
|
|
637
|
+
await hub.notify('scraper.status');
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
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.
|
|
641
|
+
|
|
600
642
|
## Delivery model
|
|
601
643
|
|
|
602
644
|
1. The client establishes a WebSocket and receives its API Gateway connection ID.
|
|
@@ -606,7 +648,7 @@ await hubClient(hub(env)).notify('drafts.123');
|
|
|
606
648
|
|
|
607
649
|
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.
|
|
608
650
|
|
|
609
|
-
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.
|
|
651
|
+
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.
|
|
610
652
|
|
|
611
653
|
## Security
|
|
612
654
|
|
|
@@ -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
|
*/
|
|
@@ -158,6 +169,14 @@ declare class ReactiveClient {
|
|
|
158
169
|
disconnect(): void;
|
|
159
170
|
forceReconnect(): void;
|
|
160
171
|
getConnectionState(): ConnectionState;
|
|
172
|
+
/**
|
|
173
|
+
* Sends an application frame over the socket, such as a presence update a
|
|
174
|
+
* hub's `onMessage` handles. Returns false while disconnected; resend from
|
|
175
|
+
* `onConnectionStateChange` once connected again.
|
|
176
|
+
*/
|
|
177
|
+
send(frame: Record<string, unknown> & {
|
|
178
|
+
type: string;
|
|
179
|
+
}): boolean;
|
|
161
180
|
onConnectionStateChange(listener: (state: ConnectionState) => void): () => void;
|
|
162
181
|
subscribe<TData, TInput = unknown>(path: string, options?: SubscriptionOptions<TInput, TData>): Subscription<TData>;
|
|
163
182
|
call<TInput, TOutput>(path: string, input: TInput, options?: MutationOptions<TInput, TOutput>): Promise<TOutput>;
|
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;
|
|
@@ -280,6 +273,13 @@ var WebSocketManager = class {
|
|
|
280
273
|
this.setState("disconnected");
|
|
281
274
|
void this.connect().catch(() => void 0);
|
|
282
275
|
}
|
|
276
|
+
/** Sends an application frame on the open socket; false when not connected. */
|
|
277
|
+
send(frame) {
|
|
278
|
+
const socket = this.ws;
|
|
279
|
+
if (!socket || this.state !== "connected" || socket.readyState !== socket.OPEN) return false;
|
|
280
|
+
socket.send(JSON.stringify(frame));
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
283
|
onMessage(handler) {
|
|
284
284
|
this.messageHandlers.add(handler);
|
|
285
285
|
return () => this.messageHandlers.delete(handler);
|
|
@@ -310,6 +310,7 @@ var WebSocketManager = class {
|
|
|
310
310
|
return;
|
|
311
311
|
}
|
|
312
312
|
if (message.type === "pong") {
|
|
313
|
+
this.clearPongTimer();
|
|
313
314
|
if (!this._connectionId || this.state === "connected") return;
|
|
314
315
|
this.clearHandshakeTimer();
|
|
315
316
|
this.lastConnectionError = void 0;
|
|
@@ -338,6 +339,18 @@ var WebSocketManager = class {
|
|
|
338
339
|
socket.close(4e3, "Handshake timeout");
|
|
339
340
|
}, this.config.handshakeTimeout);
|
|
340
341
|
}
|
|
342
|
+
handleClose() {
|
|
343
|
+
this.clearConnectionTimers();
|
|
344
|
+
this.ws = null;
|
|
345
|
+
this._connectionId = null;
|
|
346
|
+
this.setState("disconnected");
|
|
347
|
+
this.invokeLifecycleCallback("onDisconnect", this.config.onDisconnect);
|
|
348
|
+
if (this.config.autoReconnect && !this.intentionalDisconnect) {
|
|
349
|
+
this.scheduleReconnect();
|
|
350
|
+
} else {
|
|
351
|
+
this.rejectWaiters(new Error("WebSocket disconnected"));
|
|
352
|
+
}
|
|
353
|
+
}
|
|
341
354
|
startHeartbeat(socket, generation) {
|
|
342
355
|
this.clearHeartbeatTimer();
|
|
343
356
|
this.heartbeatTimer = setInterval(() => {
|
|
@@ -345,7 +358,17 @@ var WebSocketManager = class {
|
|
|
345
358
|
return;
|
|
346
359
|
}
|
|
347
360
|
socket.send(JSON.stringify({ type: "ping" }));
|
|
348
|
-
|
|
361
|
+
this.pongTimer ??= setTimeout(() => {
|
|
362
|
+
this.pongTimer = null;
|
|
363
|
+
if (!this.isCurrent(socket, generation)) return;
|
|
364
|
+
const failure = new Error("WebSocket heartbeat timed out");
|
|
365
|
+
this.lastConnectionError = failure;
|
|
366
|
+
this.invokeLifecycleCallback("onError", this.config.onError, failure);
|
|
367
|
+
socket.onclose = null;
|
|
368
|
+
socket.close(4001, "Heartbeat timeout");
|
|
369
|
+
this.handleClose();
|
|
370
|
+
}, this.config.heartbeatTimeout);
|
|
371
|
+
}, this.config.heartbeatInterval);
|
|
349
372
|
}
|
|
350
373
|
isCurrent(socket, generation) {
|
|
351
374
|
return this.ws === socket && this.socketGeneration === generation;
|
|
@@ -412,9 +435,15 @@ var WebSocketManager = class {
|
|
|
412
435
|
clearInterval(this.heartbeatTimer);
|
|
413
436
|
this.heartbeatTimer = null;
|
|
414
437
|
}
|
|
438
|
+
clearPongTimer() {
|
|
439
|
+
if (!this.pongTimer) return;
|
|
440
|
+
clearTimeout(this.pongTimer);
|
|
441
|
+
this.pongTimer = null;
|
|
442
|
+
}
|
|
415
443
|
clearConnectionTimers() {
|
|
416
444
|
this.clearHandshakeTimer();
|
|
417
445
|
this.clearHeartbeatTimer();
|
|
446
|
+
this.clearPongTimer();
|
|
418
447
|
}
|
|
419
448
|
clearTimers() {
|
|
420
449
|
this.clearConnectionTimers();
|
|
@@ -474,6 +503,14 @@ var ReactiveClient = class {
|
|
|
474
503
|
getConnectionState() {
|
|
475
504
|
return this.connectionState;
|
|
476
505
|
}
|
|
506
|
+
/**
|
|
507
|
+
* Sends an application frame over the socket, such as a presence update a
|
|
508
|
+
* hub's `onMessage` handles. Returns false while disconnected; resend from
|
|
509
|
+
* `onConnectionStateChange` once connected again.
|
|
510
|
+
*/
|
|
511
|
+
send(frame) {
|
|
512
|
+
return this.wsManager.send(frame);
|
|
513
|
+
}
|
|
477
514
|
onConnectionStateChange(listener) {
|
|
478
515
|
this.stateListeners.add(listener);
|
|
479
516
|
return () => this.stateListeners.delete(listener);
|
|
@@ -514,10 +551,7 @@ var ReactiveClient = class {
|
|
|
514
551
|
this.subscriptionsByKey.set(key, shared);
|
|
515
552
|
this.subscriptionsById.set(shared.id, shared);
|
|
516
553
|
if (this.connectionState === "connected") {
|
|
517
|
-
void this.ensureRegistered(
|
|
518
|
-
shared,
|
|
519
|
-
this.wsManager.generation
|
|
520
|
-
).catch(() => void 0);
|
|
554
|
+
void this.ensureRegistered(shared, this.wsManager.generation).catch(() => void 0);
|
|
521
555
|
}
|
|
522
556
|
}
|
|
523
557
|
const leaseId = generateId();
|
|
@@ -526,9 +560,7 @@ var ReactiveClient = class {
|
|
|
526
560
|
onError: options.onError
|
|
527
561
|
});
|
|
528
562
|
if (this.connectionState === "connected" && (!shared.hasRegistered || shared.stale) && !shared.registrationPromise) {
|
|
529
|
-
void this.ensureRegistered(shared, this.wsManager.generation).catch(
|
|
530
|
-
() => void 0
|
|
531
|
-
);
|
|
563
|
+
void this.ensureRegistered(shared, this.wsManager.generation).catch(() => void 0);
|
|
532
564
|
}
|
|
533
565
|
const state = shared;
|
|
534
566
|
let released = false;
|
|
@@ -617,9 +649,7 @@ var ReactiveClient = class {
|
|
|
617
649
|
registerActiveSubscriptions(socketGeneration) {
|
|
618
650
|
for (const state of this.subscriptionsByKey.values()) {
|
|
619
651
|
if (state.active) {
|
|
620
|
-
void this.ensureRegistered(state, socketGeneration).catch(
|
|
621
|
-
() => void 0
|
|
622
|
-
);
|
|
652
|
+
void this.ensureRegistered(state, socketGeneration).catch(() => void 0);
|
|
623
653
|
}
|
|
624
654
|
}
|
|
625
655
|
}
|
|
@@ -672,9 +702,7 @@ var ReactiveClient = class {
|
|
|
672
702
|
})
|
|
673
703
|
});
|
|
674
704
|
if (!response.ok) {
|
|
675
|
-
throw new Error(
|
|
676
|
-
`Subscribe failed: ${response.status} ${response.statusText}`
|
|
677
|
-
);
|
|
705
|
+
throw new Error(`Subscribe failed: ${response.status} ${response.statusText}`);
|
|
678
706
|
}
|
|
679
707
|
const result = await response.json();
|
|
680
708
|
if (result.type === "error") {
|
|
@@ -693,12 +721,7 @@ var ReactiveClient = class {
|
|
|
693
721
|
throw cause;
|
|
694
722
|
} finally {
|
|
695
723
|
if ((!state.active || !isCurrent()) && requestIssued) {
|
|
696
|
-
void this.sendUnsubscribe(
|
|
697
|
-
registrationConnectionId,
|
|
698
|
-
state.id,
|
|
699
|
-
generation,
|
|
700
|
-
registrationId
|
|
701
|
-
);
|
|
724
|
+
void this.sendUnsubscribe(registrationConnectionId, state.id, generation, registrationId);
|
|
702
725
|
}
|
|
703
726
|
if (state.registrationPromise === request) {
|
|
704
727
|
state.registrationPromise = void 0;
|
|
@@ -803,15 +826,12 @@ var ReactiveClient = class {
|
|
|
803
826
|
return;
|
|
804
827
|
}
|
|
805
828
|
this.discardStalePatches(state);
|
|
806
|
-
|
|
807
|
-
const next = [...state.patchBuffer.values()].filter((patch) => patch.fromVersion === state.version).sort((left, right) => left.toVersion - right.toVersion)
|
|
829
|
+
for (; ; ) {
|
|
830
|
+
const next = [...state.patchBuffer.values()].filter((patch) => patch.fromVersion === state.version).sort((left, right) => left.toVersion - right.toVersion).at(0);
|
|
808
831
|
if (!next) break;
|
|
809
832
|
state.patchBuffer.delete(next.messageId);
|
|
810
833
|
try {
|
|
811
|
-
state.authoritativeData = applyPatches(
|
|
812
|
-
state.authoritativeData,
|
|
813
|
-
next.patches
|
|
814
|
-
);
|
|
834
|
+
state.authoritativeData = applyPatches(state.authoritativeData, next.patches);
|
|
815
835
|
state.version = next.toVersion;
|
|
816
836
|
state.appliedMessageIds.add(next.messageId);
|
|
817
837
|
const awaitingReconciliation = this.hasSettledLayers(state);
|
|
@@ -849,12 +869,7 @@ var ReactiveClient = class {
|
|
|
849
869
|
state.stale = true;
|
|
850
870
|
state.loading = true;
|
|
851
871
|
this.notifySubscription(state);
|
|
852
|
-
|
|
853
|
-
request = this.ensureRegistered(
|
|
854
|
-
state,
|
|
855
|
-
this.wsManager.generation,
|
|
856
|
-
true
|
|
857
|
-
).finally(() => {
|
|
872
|
+
const request = this.ensureRegistered(state, this.wsManager.generation, true).finally(() => {
|
|
858
873
|
if (state.resyncPromise === request) {
|
|
859
874
|
state.resyncPromise = void 0;
|
|
860
875
|
state.resyncGeneration = void 0;
|
|
@@ -922,7 +937,10 @@ var ReactiveClient = class {
|
|
|
922
937
|
if (options?.optimisticData === void 0) return void 0;
|
|
923
938
|
return (data) => {
|
|
924
939
|
if (!Array.isArray(data)) return data;
|
|
925
|
-
const optimistic = settled ?
|
|
940
|
+
const optimistic = settled ? (
|
|
941
|
+
// oxlint-disable-next-line typescript/prefer-nullish-coalescing -- null is a valid mutation result.
|
|
942
|
+
result === void 0 ? typeof options.optimisticData === "function" ? options.optimisticData(input, data) : options.optimisticData : result
|
|
943
|
+
) : typeof options.optimisticData === "function" ? options.optimisticData(input, data) : options.optimisticData;
|
|
926
944
|
if (settled && optimistic !== null && typeof optimistic === "object") {
|
|
927
945
|
const optimisticId = optimistic.id;
|
|
928
946
|
if (optimisticId !== void 0 && data.some(
|
|
@@ -937,12 +955,7 @@ var ReactiveClient = class {
|
|
|
937
955
|
settleMutationLayers(mutationId, input, result, options, states) {
|
|
938
956
|
for (const state of states) {
|
|
939
957
|
const layer = state.optimisticLayers.get(mutationId);
|
|
940
|
-
const updater = this.createOptimisticUpdater(
|
|
941
|
-
input,
|
|
942
|
-
options,
|
|
943
|
-
result,
|
|
944
|
-
true
|
|
945
|
-
);
|
|
958
|
+
const updater = this.createOptimisticUpdater(input, options, result, true);
|
|
946
959
|
if (!layer || !updater) continue;
|
|
947
960
|
layer.updater = updater;
|
|
948
961
|
layer.settledRevision = ++state.settlementRevision;
|