@brass-build/client 0.4.1 → 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/AGENTS.md +124 -45
- package/CHANGELOG.md +80 -4
- package/README.md +666 -179
- package/dist/api-fetch.d.ts +8 -0
- package/dist/api-fetch.d.ts.map +1 -1
- package/dist/api-fetch.js +35 -8
- package/dist/api-fetch.js.map +1 -1
- package/dist/batched-get.d.ts +11 -0
- package/dist/batched-get.d.ts.map +1 -0
- package/dist/batched-get.js +92 -0
- package/dist/batched-get.js.map +1 -0
- package/dist/call-service.d.ts +4 -0
- package/dist/call-service.d.ts.map +1 -1
- package/dist/call-service.js +66 -3
- package/dist/call-service.js.map +1 -1
- package/dist/client.d.ts +48 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +918 -115
- package/dist/client.js.map +1 -1
- package/dist/document-checkpoints.d.ts +25 -2
- package/dist/document-checkpoints.d.ts.map +1 -1
- package/dist/document-checkpoints.js +258 -61
- package/dist/document-checkpoints.js.map +1 -1
- package/dist/document-commands.d.ts.map +1 -1
- package/dist/document-commands.js +5 -3
- package/dist/document-commands.js.map +1 -1
- package/dist/document-contents.d.ts +10 -0
- package/dist/document-contents.d.ts.map +1 -1
- package/dist/document-contents.js +23 -1
- package/dist/document-contents.js.map +1 -1
- package/dist/document-set-subscription.d.ts +56 -0
- package/dist/document-set-subscription.d.ts.map +1 -0
- package/dist/document-set-subscription.js +240 -0
- package/dist/document-set-subscription.js.map +1 -0
- package/dist/document-signals.d.ts +6 -0
- package/dist/document-signals.d.ts.map +1 -1
- package/dist/document-signals.js +31 -1
- package/dist/document-signals.js.map +1 -1
- package/dist/document-streams.d.ts +36 -2
- package/dist/document-streams.d.ts.map +1 -1
- package/dist/document-streams.js +69 -18
- package/dist/document-streams.js.map +1 -1
- package/dist/document-thumbnail-capture.d.ts +12 -2
- package/dist/document-thumbnail-capture.d.ts.map +1 -1
- package/dist/document-thumbnail-capture.js +103 -27
- package/dist/document-thumbnail-capture.js.map +1 -1
- package/dist/documents-namespace.d.ts +92 -10
- package/dist/documents-namespace.d.ts.map +1 -1
- package/dist/documents-namespace.js +285 -39
- package/dist/documents-namespace.js.map +1 -1
- package/dist/documents.d.ts +173 -8
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +1778 -310
- package/dist/documents.js.map +1 -1
- package/dist/errors.d.ts +3 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/fetch-retry.d.ts +1 -0
- package/dist/fetch-retry.d.ts.map +1 -1
- package/dist/fetch-retry.js +2 -0
- package/dist/fetch-retry.js.map +1 -1
- package/dist/file-choice.d.ts +2 -0
- package/dist/file-choice.d.ts.map +1 -0
- package/dist/file-choice.js +27 -0
- package/dist/file-choice.js.map +1 -0
- package/dist/guest-link.d.ts +18 -1
- package/dist/guest-link.d.ts.map +1 -1
- package/dist/guest-link.js +329 -1
- package/dist/guest-link.js.map +1 -1
- package/dist/handle-types.d.ts +67 -5
- package/dist/handle-types.d.ts.map +1 -1
- package/dist/handles.d.ts +15 -19
- package/dist/handles.d.ts.map +1 -1
- package/dist/handles.js +231 -76
- package/dist/handles.js.map +1 -1
- package/dist/hosted-ui.d.ts +61 -0
- package/dist/hosted-ui.d.ts.map +1 -1
- package/dist/hosted-ui.js +94 -42
- package/dist/hosted-ui.js.map +1 -1
- package/dist/import-contract.d.ts +74 -0
- package/dist/import-contract.d.ts.map +1 -0
- package/dist/import-contract.js +163 -0
- package/dist/import-contract.js.map +1 -0
- package/dist/import-guest.d.ts +9 -0
- package/dist/import-guest.d.ts.map +1 -0
- package/dist/import-guest.js +155 -0
- package/dist/import-guest.js.map +1 -0
- package/dist/import-report.d.ts +17 -0
- package/dist/import-report.d.ts.map +1 -0
- package/dist/import-report.js +7 -0
- package/dist/import-report.js.map +1 -0
- package/dist/importer-wait.d.ts +2 -0
- package/dist/importer-wait.d.ts.map +1 -0
- package/dist/importer-wait.js +5 -0
- package/dist/importer-wait.js.map +1 -0
- package/dist/importer.d.ts +18 -4
- package/dist/importer.d.ts.map +1 -1
- package/dist/importer.js +322 -105
- package/dist/importer.js.map +1 -1
- package/dist/in-development.d.ts +2 -0
- package/dist/in-development.d.ts.map +1 -0
- package/dist/in-development.js +10 -0
- package/dist/in-development.js.map +1 -0
- package/dist/index.d.ts +16 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -8
- package/dist/index.js.map +1 -1
- package/dist/init-keys.d.ts +1 -1
- package/dist/init-keys.d.ts.map +1 -1
- package/dist/init-keys.js +6 -1
- package/dist/init-keys.js.map +1 -1
- package/dist/input-documents.d.ts +67 -0
- package/dist/input-documents.d.ts.map +1 -0
- package/dist/input-documents.js +693 -0
- package/dist/input-documents.js.map +1 -0
- package/dist/inputs-status.d.ts +80 -0
- package/dist/inputs-status.d.ts.map +1 -0
- package/dist/inputs-status.js +460 -0
- package/dist/inputs-status.js.map +1 -0
- package/dist/inputs.d.ts +2 -0
- package/dist/inputs.d.ts.map +1 -0
- package/dist/inputs.js +793 -0
- package/dist/inputs.js.map +1 -0
- package/dist/internal.d.ts +6 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +4 -1
- package/dist/internal.js.map +1 -1
- package/dist/join-content.d.ts +33 -0
- package/dist/join-content.d.ts.map +1 -0
- package/dist/join-content.js +114 -0
- package/dist/join-content.js.map +1 -0
- package/dist/join-stamp.d.ts +3 -0
- package/dist/join-stamp.d.ts.map +1 -0
- package/dist/join-stamp.js +12 -0
- package/dist/join-stamp.js.map +1 -0
- package/dist/manifest-drift.d.ts +9 -0
- package/dist/manifest-drift.d.ts.map +1 -0
- package/dist/manifest-drift.js +46 -0
- package/dist/manifest-drift.js.map +1 -0
- package/dist/open-document.d.ts +109 -2
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +1822 -811
- package/dist/open-document.js.map +1 -1
- package/dist/page-restore.d.ts +12 -0
- package/dist/page-restore.d.ts.map +1 -0
- package/dist/page-restore.js +23 -0
- package/dist/page-restore.js.map +1 -0
- package/dist/placement.d.ts +13 -0
- package/dist/placement.d.ts.map +1 -0
- package/dist/placement.js +34 -0
- package/dist/placement.js.map +1 -0
- package/dist/presence.d.ts +1 -0
- package/dist/presence.d.ts.map +1 -1
- package/dist/presence.js +16 -4
- package/dist/presence.js.map +1 -1
- package/dist/read-only-document.d.ts +87 -0
- package/dist/read-only-document.d.ts.map +1 -0
- package/dist/read-only-document.js +4 -0
- package/dist/read-only-document.js.map +1 -0
- package/dist/record-paging.d.ts +63 -6
- package/dist/record-paging.d.ts.map +1 -1
- package/dist/record-paging.js +122 -29
- package/dist/record-paging.js.map +1 -1
- package/dist/references.d.ts +40 -0
- package/dist/references.d.ts.map +1 -0
- package/dist/references.js +135 -0
- package/dist/references.js.map +1 -0
- package/dist/request-lane.d.ts +5 -0
- package/dist/request-lane.d.ts.map +1 -0
- package/dist/request-lane.js +48 -0
- package/dist/request-lane.js.map +1 -0
- package/dist/sealed-arrow.d.ts +170 -0
- package/dist/sealed-arrow.d.ts.map +1 -0
- package/dist/sealed-arrow.js +769 -0
- package/dist/sealed-arrow.js.map +1 -0
- package/dist/sealed-cache.d.ts +21 -0
- package/dist/sealed-cache.d.ts.map +1 -0
- package/dist/sealed-cache.js +247 -0
- package/dist/sealed-cache.js.map +1 -0
- package/dist/sealed-encode.d.ts +87 -0
- package/dist/sealed-encode.d.ts.map +1 -0
- package/dist/sealed-encode.js +876 -0
- package/dist/sealed-encode.js.map +1 -0
- package/dist/sealed-handles.d.ts +45 -0
- package/dist/sealed-handles.d.ts.map +1 -0
- package/dist/sealed-handles.js +408 -0
- package/dist/sealed-handles.js.map +1 -0
- package/dist/sealed-store.d.ts +242 -0
- package/dist/sealed-store.d.ts.map +1 -0
- package/dist/sealed-store.js +1155 -0
- package/dist/sealed-store.js.map +1 -0
- package/dist/serializer.d.ts +6 -15
- package/dist/serializer.d.ts.map +1 -1
- package/dist/session-toolbar.d.ts +20 -5
- package/dist/session-toolbar.d.ts.map +1 -1
- package/dist/session-toolbar.js +419 -67
- package/dist/session-toolbar.js.map +1 -1
- package/dist/session.d.ts +38 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +562 -79
- package/dist/session.js.map +1 -1
- package/dist/sha256.d.ts +3 -0
- package/dist/sha256.d.ts.map +1 -0
- package/dist/sha256.js +74 -0
- package/dist/sha256.js.map +1 -0
- package/dist/stable-handles.d.ts +19 -0
- package/dist/stable-handles.d.ts.map +1 -0
- package/dist/stable-handles.js +193 -0
- package/dist/stable-handles.js.map +1 -0
- package/dist/staged-seal.d.ts +2 -0
- package/dist/staged-seal.d.ts.map +1 -0
- package/dist/staged-seal.js +76 -0
- package/dist/staged-seal.js.map +1 -0
- package/dist/subscriber-delivery.d.ts +8 -0
- package/dist/subscriber-delivery.d.ts.map +1 -0
- package/dist/subscriber-delivery.js +31 -0
- package/dist/subscriber-delivery.js.map +1 -0
- package/dist/tail-op-import.d.ts +10 -0
- package/dist/tail-op-import.d.ts.map +1 -0
- package/dist/tail-op-import.js +33 -0
- package/dist/tail-op-import.js.map +1 -0
- package/dist/topic-lifecycle.d.ts +6 -0
- package/dist/topic-lifecycle.d.ts.map +1 -1
- package/dist/topic-lifecycle.js +32 -2
- package/dist/topic-lifecycle.js.map +1 -1
- package/dist/trial-replace-dialog.d.ts +10 -0
- package/dist/trial-replace-dialog.d.ts.map +1 -0
- package/dist/trial-replace-dialog.js +153 -0
- package/dist/trial-replace-dialog.js.map +1 -0
- package/dist/types.d.ts +8 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/undo.d.ts +60 -5
- package/dist/undo.d.ts.map +1 -1
- package/dist/undo.js +344 -75
- package/dist/undo.js.map +1 -1
- package/dist/version-bar.d.ts +1 -0
- package/dist/version-bar.d.ts.map +1 -1
- package/dist/version-bar.js +2 -1
- package/dist/version-bar.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/dist/ws-client.d.ts +21 -0
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +192 -8
- package/dist/ws-client.js.map +1 -1
- package/dist/ws-frame-limit.d.ts +1 -0
- package/dist/ws-frame-limit.d.ts.map +1 -1
- package/dist/ws-frame-limit.js.map +1 -1
- package/docs/app-manifest.md +121 -17
- package/docs/custom-chrome.md +24 -16
- package/docs/hosted-ui-advanced.md +38 -6
- package/docs/imported-documents.md +104 -51
- package/docs/presence.md +16 -4
- package/docs/schemas.md +8 -5
- package/docs/services.md +6 -6
- package/docs/streams.md +139 -150
- package/docs/text-fields.md +26 -19
- package/package.json +5 -2
- package/src/api-fetch.ts +53 -3
- package/src/auth-flow.ts +1 -1
- package/src/batched-get.ts +117 -0
- package/src/call-service.ts +78 -3
- package/src/client.ts +1022 -112
- package/src/document-checkpoints.ts +307 -65
- package/src/document-commands.ts +5 -5
- package/src/document-contents.ts +27 -1
- package/src/document-set-subscription.ts +274 -0
- package/src/document-signals.ts +47 -5
- package/src/document-streams.ts +85 -16
- package/src/document-thumbnail-capture.ts +118 -32
- package/src/documents-namespace.ts +539 -84
- package/src/documents.ts +2017 -348
- package/src/errors.ts +17 -6
- package/src/fetch-retry.ts +5 -0
- package/src/file-choice.ts +26 -0
- package/src/guest-link.ts +362 -1
- package/src/handle-types.ts +73 -47
- package/src/handles.ts +253 -83
- package/src/hosted-ui.ts +211 -50
- package/src/import-contract.ts +227 -0
- package/src/import-guest.ts +193 -0
- package/src/import-report.ts +24 -0
- package/src/importer-wait.ts +4 -0
- package/src/importer.ts +386 -131
- package/src/in-development.ts +10 -0
- package/src/index.ts +76 -16
- package/src/init-keys.ts +6 -1
- package/src/input-documents.ts +819 -0
- package/src/inputs-status.ts +598 -0
- package/src/inputs.ts +1090 -0
- package/src/internal.ts +7 -0
- package/src/join-content.ts +135 -0
- package/src/join-stamp.ts +12 -0
- package/src/manifest-drift.ts +54 -0
- package/src/open-document.ts +2346 -930
- package/src/page-restore.ts +38 -0
- package/src/placement.ts +54 -0
- package/src/presence.ts +21 -5
- package/src/read-only-document.ts +133 -0
- package/src/record-paging.ts +147 -25
- package/src/references.ts +207 -0
- package/src/request-lane.ts +59 -0
- package/src/sealed-arrow.ts +843 -0
- package/src/sealed-cache.ts +267 -0
- package/src/sealed-encode.ts +1015 -0
- package/src/sealed-handles.ts +486 -0
- package/src/sealed-store.ts +1330 -0
- package/src/serializer.ts +8 -20
- package/src/session-toolbar.ts +463 -72
- package/src/session.ts +740 -99
- package/src/sha256.ts +65 -0
- package/src/stable-handles.ts +235 -0
- package/src/staged-seal.ts +90 -0
- package/src/subscriber-delivery.ts +37 -0
- package/src/tail-op-import.ts +30 -0
- package/src/topic-lifecycle.ts +38 -2
- package/src/trial-replace-dialog.ts +163 -0
- package/src/types.ts +6 -6
- package/src/undo.ts +386 -73
- package/src/version-bar.ts +5 -1
- package/src/version.ts +1 -1
- package/src/ws-client.ts +208 -16
- package/src/ws-frame-limit.ts +1 -0
package/src/ws-client.ts
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
type TopicEvent,
|
|
35
35
|
type TopicState,
|
|
36
36
|
} from './topic-lifecycle.js';
|
|
37
|
+
import { PRESENCE_TOPIC_PREFIX } from './presence.js';
|
|
37
38
|
import { SDK_CLIENT_ID } from './version.js';
|
|
38
39
|
|
|
39
40
|
interface WsClientOptions {
|
|
@@ -41,12 +42,8 @@ interface WsClientOptions {
|
|
|
41
42
|
appId: string;
|
|
42
43
|
getAccessToken: () => Promise<string | null>;
|
|
43
44
|
// Resolves the current app assertion (the auth proxy mints it alongside
|
|
44
|
-
// the access token). Sent as the `app_assertion` connect param
|
|
45
|
-
// server
|
|
46
|
-
// absent / null just means the connection is not app-attested (the
|
|
47
|
-
// server treats it as first-party, un-gated). Optional so a client
|
|
48
|
-
// constructed without it (or on the first-party dashboard path) connects
|
|
49
|
-
// unchanged.
|
|
45
|
+
// the access token). Sent as the `app_assertion` connect param, which the
|
|
46
|
+
// server requires wherever it requires the HTTP header.
|
|
50
47
|
getAppAssertion?: () => Promise<string | null>;
|
|
51
48
|
// The anonymous link-guest token (`?brass_link=`), or null on the
|
|
52
49
|
// ordinary signed-in path. When `getAccessToken` returns null but this
|
|
@@ -116,6 +113,16 @@ const HEARTBEAT_FIRST_MS = 10_000;
|
|
|
116
113
|
// credits: the question is whether the path still carries bytes. Two missed
|
|
117
114
|
// replies plus slack, so one dropped `pong` is not enough.
|
|
118
115
|
const SOCKET_SILENCE_LIMIT_MS = 150_000;
|
|
116
|
+
const SUBSCRIBE_RETRY_BASE_MS = 250;
|
|
117
|
+
const SUBSCRIBE_RETRY_MAX_MS = 15_000;
|
|
118
|
+
// How long a sent subscribe may go unacked before it is sent again. The
|
|
119
|
+
// `subscribed` ack can be lost while the frames after it arrive, and frames
|
|
120
|
+
// for an unacked topic are held, so without a second subscribe everything the
|
|
121
|
+
// topic's owner sends waits for the next socket. Well above an ordinary ack's
|
|
122
|
+
// round trip, since each resend repeats the server's open work; a duplicate
|
|
123
|
+
// subscribe is idempotent there.
|
|
124
|
+
const SUBSCRIBE_ACK_TIMEOUT_MS = 5_000;
|
|
125
|
+
const SUBSCRIBE_ACK_TIMEOUT_MAX_MS = 30_000;
|
|
119
126
|
// Spread added to that limit, because the condition is SHARED: the server
|
|
120
127
|
// answers a `ping` after an awaited presence refresh and reap sweep, so its
|
|
121
128
|
// replies slow under exactly the load that makes sockets fragile, and every
|
|
@@ -142,7 +149,7 @@ const MAX_QUEUED_FRAMES = 1024;
|
|
|
142
149
|
// live (`established`) subscription behind it, so any LATER ack means the
|
|
143
150
|
// socket dropped in between and frames may have been missed.
|
|
144
151
|
interface ListenerMeta {
|
|
145
|
-
onSubscribed?: (info: { resubscribed: boolean }) => void;
|
|
152
|
+
onSubscribed?: (info: { resubscribed: boolean; currentSeq?: number }) => void;
|
|
146
153
|
onEnded?: (info: { reason: string }) => void;
|
|
147
154
|
expectGap: boolean;
|
|
148
155
|
}
|
|
@@ -156,7 +163,12 @@ export interface SubscribeOptions {
|
|
|
156
163
|
// drop, so frames fanned out in the gap were lost and the subscriber
|
|
157
164
|
// should reconcile by refetching (org-topic events carry no catch-up;
|
|
158
165
|
// only the `doc:` topics replay via `doc_init`).
|
|
159
|
-
onSubscribed?: (info: { resubscribed: boolean }) => void;
|
|
166
|
+
onSubscribed?: (info: { resubscribed: boolean; currentSeq?: number }) => void;
|
|
167
|
+
// Asks the server only to report that a `doc:` topic's document moved: no
|
|
168
|
+
// `doc_init` follows the ack, and the ack names the branch head as
|
|
169
|
+
// `currentSeq`. Honoured while every callback on the topic asks for it; a
|
|
170
|
+
// callback that does not re-subscribes the topic in full.
|
|
171
|
+
notice?: boolean;
|
|
160
172
|
// Fired once when the server ends the subscription under a caller that
|
|
161
173
|
// still wants it: it refused the subscribe (a revoked grant), or evicted
|
|
162
174
|
// it again after the one silent re-subscribe a connection grants. The
|
|
@@ -254,6 +266,19 @@ export class BrassWsClient {
|
|
|
254
266
|
// one socket cannot move its own deadline every time the check looks at it.
|
|
255
267
|
private silenceLimitMs = SOCKET_SILENCE_LIMIT_MS;
|
|
256
268
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
269
|
+
// A subscribe the server could not finish is sent again on a doubling,
|
|
270
|
+
// jittered delay, so a burst that collided does not collide again in step.
|
|
271
|
+
// Cleared once the topic leaves its unacked phase or the socket changes.
|
|
272
|
+
private readonly subscribeRetries = new Map<
|
|
273
|
+
string,
|
|
274
|
+
{ attempt: number; timer: ReturnType<typeof setTimeout> | null }
|
|
275
|
+
>();
|
|
276
|
+
// One per sent subscribe still awaiting its ack (`SUBSCRIBE_ACK_TIMEOUT_MS`).
|
|
277
|
+
// Cleared with `subscribeRetries`.
|
|
278
|
+
private readonly subscribeAckWatches = new Map<
|
|
279
|
+
string,
|
|
280
|
+
{ attempt: number; timer: ReturnType<typeof setTimeout> | null }
|
|
281
|
+
>();
|
|
257
282
|
// Sockets this client has opened. Every topic and every open document
|
|
258
283
|
// rides this one socket, so the count is a fact about the client rather
|
|
259
284
|
// than about any one of them. Reported in a client's own sync-health
|
|
@@ -265,9 +290,20 @@ export class BrassWsClient {
|
|
|
265
290
|
// once.
|
|
266
291
|
private outboundOverflowReported = false;
|
|
267
292
|
private heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
|
|
293
|
+
// When the last heartbeat went out, and when each topic was last acked: a
|
|
294
|
+
// pong describes the rows the server held when it answered that ping, so it
|
|
295
|
+
// says nothing about a topic acked after the ping was sent.
|
|
296
|
+
private lastPingSentAt = 0;
|
|
297
|
+
private readonly ackedAt = new Map<string, number>();
|
|
268
298
|
private closedByUser = false;
|
|
269
299
|
// Page-lifecycle listeners, installed lazily on first connect and removed on
|
|
270
300
|
// user close. Guarded for non-DOM environments (Node, SSR).
|
|
301
|
+
// Topics every callback subscribed to as a notice (see `notice`).
|
|
302
|
+
private readonly noticeTopics = new Set<string>();
|
|
303
|
+
// Topics upgraded from a notice while the notice subscribe was unacked. The
|
|
304
|
+
// full subscribe waits for that ack: sent alongside, the notice one can
|
|
305
|
+
// land second and leave the connection a notice subscriber.
|
|
306
|
+
private readonly upgradeOnAck = new Set<string>();
|
|
271
307
|
private visibilityListener: (() => void) | null = null;
|
|
272
308
|
private onlineListener: (() => void) | null = null;
|
|
273
309
|
|
|
@@ -287,6 +323,18 @@ export class BrassWsClient {
|
|
|
287
323
|
set = new Map();
|
|
288
324
|
this.listeners.set(topic, set);
|
|
289
325
|
}
|
|
326
|
+
if (firstForTopic && options.notice === true) {
|
|
327
|
+
this.noticeTopics.add(topic);
|
|
328
|
+
} else if (options.notice !== true && this.noticeTopics.delete(topic)) {
|
|
329
|
+
// The topic was subscribed as a notice, which sends no `doc_init`, and
|
|
330
|
+
// this callback needs the full subscription.
|
|
331
|
+
const prior = this.topics.get(topic);
|
|
332
|
+
if (prior?.phase === 'established') {
|
|
333
|
+
this.sendIfOpen({ type: 'subscribe', topic });
|
|
334
|
+
} else if (prior !== undefined && holdsFrames(prior)) {
|
|
335
|
+
this.upgradeOnAck.add(topic);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
290
338
|
set.set(callback, {
|
|
291
339
|
...(options.onSubscribed ? { onSubscribed: options.onSubscribed } : {}),
|
|
292
340
|
...(options.onEnded ? { onEnded: options.onEnded } : {}),
|
|
@@ -321,6 +369,7 @@ export class BrassWsClient {
|
|
|
321
369
|
if (current.size > 0) return;
|
|
322
370
|
this.listeners.delete(topic);
|
|
323
371
|
this.ownFrameRetryTopics.delete(topic);
|
|
372
|
+
this.noticeTopics.delete(topic);
|
|
324
373
|
this.dispatch(topic, { type: 'user_unsubscribe' });
|
|
325
374
|
// Drop the connection once no topic is wanted, so no idle socket lingers.
|
|
326
375
|
if (this.listeners.size === 0) {
|
|
@@ -336,8 +385,25 @@ export class BrassWsClient {
|
|
|
336
385
|
private dispatch(topic: string, event: TopicEvent): void {
|
|
337
386
|
const prior = this.topics.get(topic) ?? IDLE_TOPIC;
|
|
338
387
|
const { state, effects } = step(prior, event);
|
|
388
|
+
if (
|
|
389
|
+
!holdsFrames(state) ||
|
|
390
|
+
event.type === 'socket_open' ||
|
|
391
|
+
event.type === 'socket_closed'
|
|
392
|
+
) {
|
|
393
|
+
this.clearSubscribeRetry(topic);
|
|
394
|
+
}
|
|
395
|
+
// A socket transition re-sends every wanted topic in full, and a topic
|
|
396
|
+
// that left its live phases has no subscription left to upgrade.
|
|
397
|
+
if (
|
|
398
|
+
event.type === 'socket_open' ||
|
|
399
|
+
event.type === 'socket_closed' ||
|
|
400
|
+
(!holdsFrames(state) && state.phase !== 'established')
|
|
401
|
+
) {
|
|
402
|
+
this.upgradeOnAck.delete(topic);
|
|
403
|
+
}
|
|
339
404
|
if (state.phase === 'idle') {
|
|
340
405
|
this.topics.delete(topic);
|
|
406
|
+
this.ackedAt.delete(topic);
|
|
341
407
|
} else if (event.type === 'user_subscribe') {
|
|
342
408
|
// Re-insert so the map's order (the reconnect replay order) tracks
|
|
343
409
|
// subscription order even for a topic re-subscribed after teardown.
|
|
@@ -378,7 +444,16 @@ export class BrassWsClient {
|
|
|
378
444
|
case 'send_subscribe':
|
|
379
445
|
// Dropped when the socket is not open; `socket_open` re-derives a
|
|
380
446
|
// `send_subscribe` for every wanted topic, so nothing is lost.
|
|
381
|
-
this.
|
|
447
|
+
if (!this.ws || this.ws.readyState !== this.ws.OPEN) return;
|
|
448
|
+
this.sendFrame(
|
|
449
|
+
this.noticeTopics.has(topic)
|
|
450
|
+
? { type: 'subscribe', topic, notice: true }
|
|
451
|
+
: { type: 'subscribe', topic },
|
|
452
|
+
);
|
|
453
|
+
this.watchSubscribeAck(topic);
|
|
454
|
+
return;
|
|
455
|
+
case 'resend_subscribe_later':
|
|
456
|
+
this.scheduleSubscribeRetry(topic);
|
|
382
457
|
return;
|
|
383
458
|
case 'send_unsubscribe':
|
|
384
459
|
this.sendIfOpen({ type: 'unsubscribe', topic });
|
|
@@ -412,6 +487,54 @@ export class BrassWsClient {
|
|
|
412
487
|
}
|
|
413
488
|
}
|
|
414
489
|
|
|
490
|
+
private scheduleSubscribeRetry(topic: string): void {
|
|
491
|
+
const retry = this.subscribeRetries.get(topic) ?? { attempt: 0, timer: null };
|
|
492
|
+
if (retry.timer !== null) return;
|
|
493
|
+
const base = Math.min(
|
|
494
|
+
SUBSCRIBE_RETRY_MAX_MS,
|
|
495
|
+
SUBSCRIBE_RETRY_BASE_MS * 2 ** retry.attempt,
|
|
496
|
+
);
|
|
497
|
+
retry.attempt += 1;
|
|
498
|
+
retry.timer = setTimeout(() => {
|
|
499
|
+
retry.timer = null;
|
|
500
|
+
const state = this.topics.get(topic);
|
|
501
|
+
if (!state || !holdsFrames(state)) return;
|
|
502
|
+
this.runEffect(topic, { kind: 'send_subscribe' });
|
|
503
|
+
}, base / 2 + Math.random() * (base / 2));
|
|
504
|
+
this.subscribeRetries.set(topic, retry);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
private clearSubscribeRetry(topic: string): void {
|
|
508
|
+
const watch = this.subscribeAckWatches.get(topic);
|
|
509
|
+
if (watch) {
|
|
510
|
+
if (watch.timer !== null) clearTimeout(watch.timer);
|
|
511
|
+
this.subscribeAckWatches.delete(topic);
|
|
512
|
+
}
|
|
513
|
+
const retry = this.subscribeRetries.get(topic);
|
|
514
|
+
if (!retry) return;
|
|
515
|
+
if (retry.timer !== null) clearTimeout(retry.timer);
|
|
516
|
+
this.subscribeRetries.delete(topic);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Send the subscribe again if its ack has not arrived in time, on a
|
|
520
|
+
// doubling delay so a server that is merely slow is not asked repeatedly.
|
|
521
|
+
private watchSubscribeAck(topic: string): void {
|
|
522
|
+
const watch = this.subscribeAckWatches.get(topic) ?? { attempt: 0, timer: null };
|
|
523
|
+
if (watch.timer !== null) clearTimeout(watch.timer);
|
|
524
|
+
const delay = Math.min(
|
|
525
|
+
SUBSCRIBE_ACK_TIMEOUT_MAX_MS,
|
|
526
|
+
SUBSCRIBE_ACK_TIMEOUT_MS * 2 ** watch.attempt,
|
|
527
|
+
);
|
|
528
|
+
watch.attempt += 1;
|
|
529
|
+
watch.timer = setTimeout(() => {
|
|
530
|
+
watch.timer = null;
|
|
531
|
+
const state = this.topics.get(topic);
|
|
532
|
+
if (!state || !holdsFrames(state)) return;
|
|
533
|
+
this.runEffect(topic, { kind: 'send_subscribe' });
|
|
534
|
+
}, delay);
|
|
535
|
+
this.subscribeAckWatches.set(topic, watch);
|
|
536
|
+
}
|
|
537
|
+
|
|
415
538
|
// Send an arbitrary frame. Queued when the socket is not open and drained on
|
|
416
539
|
// the next `open` (covering a mutate during the subscribe handshake gap and
|
|
417
540
|
// ops that must survive a mid-session reconnect). Failures (overflow, closed
|
|
@@ -524,6 +647,35 @@ export class BrassWsClient {
|
|
|
524
647
|
}
|
|
525
648
|
}
|
|
526
649
|
|
|
650
|
+
/**
|
|
651
|
+
* Replace the socket because the credential it was opened with no longer
|
|
652
|
+
* applies (a guest page moved onto another document's link). Wanted topics
|
|
653
|
+
* re-subscribe on the new socket and queued frames wait for it.
|
|
654
|
+
*/
|
|
655
|
+
reconnect(): void {
|
|
656
|
+
if (this.closedByUser) return;
|
|
657
|
+
if (this.ws === null && !this.connecting) return;
|
|
658
|
+
this.connectEpoch++;
|
|
659
|
+
if (this.reconnectTimer) {
|
|
660
|
+
clearTimeout(this.reconnectTimer);
|
|
661
|
+
this.reconnectTimer = null;
|
|
662
|
+
}
|
|
663
|
+
this.stopHeartbeat();
|
|
664
|
+
const old = this.ws;
|
|
665
|
+
this.ws = null;
|
|
666
|
+
this.lastInboundAt = 0;
|
|
667
|
+
this.connecting = false;
|
|
668
|
+
for (const topic of this.topics.keys()) {
|
|
669
|
+
this.dispatch(topic, { type: 'socket_closed' });
|
|
670
|
+
}
|
|
671
|
+
try {
|
|
672
|
+
old?.close();
|
|
673
|
+
} catch {
|
|
674
|
+
// Closing an already-closing socket is fine.
|
|
675
|
+
}
|
|
676
|
+
if (this.listeners.size > 0) void this.ensureConnected();
|
|
677
|
+
}
|
|
678
|
+
|
|
527
679
|
private async ensureConnected(): Promise<void> {
|
|
528
680
|
// The reconnect bound is deliberately not consulted here. A caller's own
|
|
529
681
|
// connect always attempts, so a client parked on the bound still reaches
|
|
@@ -536,11 +688,12 @@ export class BrassWsClient {
|
|
|
536
688
|
this.closedByUser = false;
|
|
537
689
|
const epoch = ++this.connectEpoch;
|
|
538
690
|
try {
|
|
539
|
-
|
|
691
|
+
// A page on a link connects with the link, whoever is signed in: the
|
|
692
|
+
// socket carries one credential, and the link is the one that admits
|
|
693
|
+
// its document.
|
|
694
|
+
const guestToken = this.opts.getGuestLinkToken?.() ?? null;
|
|
695
|
+
const token = guestToken ? null : await this.opts.getAccessToken();
|
|
540
696
|
if (epoch !== this.connectEpoch) return;
|
|
541
|
-
// Anonymous link guest: no access token, but a link token authorises a
|
|
542
|
-
// read-only connection.
|
|
543
|
-
const guestToken = token ? null : this.opts.getGuestLinkToken?.() ?? null;
|
|
544
697
|
if (!token && !guestToken) {
|
|
545
698
|
this.connecting = false;
|
|
546
699
|
this.opts.fireError(
|
|
@@ -726,15 +879,22 @@ export class BrassWsClient {
|
|
|
726
879
|
}
|
|
727
880
|
|
|
728
881
|
if (type === 'subscribed' && typeof frame.topic === 'string') {
|
|
882
|
+
this.ackedAt.set(frame.topic, Date.now());
|
|
729
883
|
// `flush_held` drains the frames held back for the unacked subscribe.
|
|
730
884
|
this.dispatch(frame.topic, { type: 'subscribed_ack' });
|
|
885
|
+
if (this.upgradeOnAck.delete(frame.topic)) {
|
|
886
|
+
this.sendIfOpen({ type: 'subscribe', topic: frame.topic });
|
|
887
|
+
}
|
|
731
888
|
const acked = this.listeners.get(frame.topic);
|
|
889
|
+
const currentSeq = (frame as { current_seq?: unknown }).current_seq;
|
|
732
890
|
if (acked) {
|
|
733
891
|
for (const meta of acked.values()) {
|
|
734
892
|
const resubscribed = meta.expectGap;
|
|
735
893
|
meta.expectGap = true;
|
|
736
894
|
try {
|
|
737
|
-
meta.onSubscribed?.(
|
|
895
|
+
meta.onSubscribed?.(
|
|
896
|
+
typeof currentSeq === 'number' ? { resubscribed, currentSeq } : { resubscribed },
|
|
897
|
+
);
|
|
738
898
|
} catch {
|
|
739
899
|
// A bad listener must not break the others or kill the read loop.
|
|
740
900
|
}
|
|
@@ -747,8 +907,14 @@ export class BrassWsClient {
|
|
|
747
907
|
return;
|
|
748
908
|
}
|
|
749
909
|
if (type === 'pong') {
|
|
750
|
-
// Heartbeat reply
|
|
751
|
-
//
|
|
910
|
+
// Heartbeat reply (the round-trip already reset API Gateway's
|
|
911
|
+
// idle-timeout). It names the presence topics the server still holds a
|
|
912
|
+
// row on for this socket. A row reaped while this page's timers were
|
|
913
|
+
// suspended leaves the page out of every roster, and a page that
|
|
914
|
+
// publishes nothing draws no `not_subscribed` to rejoin, so a missing
|
|
915
|
+
// topic is subscribed again here.
|
|
916
|
+
const held = (frame as { presence_topics?: unknown }).presence_topics;
|
|
917
|
+
if (Array.isArray(held)) this.rejoinDroppedPresence(new Set(held));
|
|
752
918
|
return;
|
|
753
919
|
}
|
|
754
920
|
if (type === 'error') {
|
|
@@ -780,9 +946,20 @@ export class BrassWsClient {
|
|
|
780
946
|
// Topic-scoped server frames: fan out to every callback for the topic.
|
|
781
947
|
if (typeof frame.topic === 'string') {
|
|
782
948
|
this.fanOut(frame.topic, frame);
|
|
949
|
+
// `doc_gone` evicts every subscriber on the topic server-side. A
|
|
950
|
+
// subscriber reopening the same topic (a moved document reopens under
|
|
951
|
+
// the same id) must send its own subscribe, which it only does as the
|
|
952
|
+
// topic's first listener.
|
|
953
|
+
if (type === 'doc_gone') this.endTopic(frame.topic);
|
|
783
954
|
}
|
|
784
955
|
}
|
|
785
956
|
|
|
957
|
+
private endTopic(topic: string): void {
|
|
958
|
+
this.ownFrameRetryTopics.delete(topic);
|
|
959
|
+
this.dispatch(topic, { type: 'server_ended' });
|
|
960
|
+
if (this.listeners.size === 0) this.close();
|
|
961
|
+
}
|
|
962
|
+
|
|
786
963
|
private fanOut(topic: string, frame: unknown): void {
|
|
787
964
|
const set = this.listeners.get(topic);
|
|
788
965
|
if (!set) return;
|
|
@@ -894,6 +1071,11 @@ export class BrassWsClient {
|
|
|
894
1071
|
return this.isHidden();
|
|
895
1072
|
}
|
|
896
1073
|
|
|
1074
|
+
/** Whether any socket has opened since this client was made. */
|
|
1075
|
+
get everOpened(): boolean {
|
|
1076
|
+
return this.socketsOpened > 0;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
897
1079
|
/** Whether a socket is open right now. */
|
|
898
1080
|
get connected(): boolean {
|
|
899
1081
|
return this.ws !== null && this.ws.readyState === this.ws.OPEN;
|
|
@@ -965,6 +1147,7 @@ export class BrassWsClient {
|
|
|
965
1147
|
this.dropSilentSocket();
|
|
966
1148
|
return;
|
|
967
1149
|
}
|
|
1150
|
+
this.lastPingSentAt = Date.now();
|
|
968
1151
|
this.sendIfOpen({ type: 'ping' });
|
|
969
1152
|
arm(HEARTBEAT_INTERVAL_MS);
|
|
970
1153
|
}, delay);
|
|
@@ -972,6 +1155,15 @@ export class BrassWsClient {
|
|
|
972
1155
|
arm(HEARTBEAT_FIRST_MS);
|
|
973
1156
|
}
|
|
974
1157
|
|
|
1158
|
+
private rejoinDroppedPresence(held: Set<unknown>): void {
|
|
1159
|
+
for (const [topic, state] of this.topics) {
|
|
1160
|
+
if (!topic.startsWith(PRESENCE_TOPIC_PREFIX) || state.phase !== 'established') continue;
|
|
1161
|
+
if (held.has(topic)) continue;
|
|
1162
|
+
if ((this.ackedAt.get(topic) ?? Infinity) >= this.lastPingSentAt) continue;
|
|
1163
|
+
this.dispatch(topic, { type: 'server_dropped' });
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
975
1167
|
private socketHasGoneSilent(): boolean {
|
|
976
1168
|
if (!this.ws || this.lastInboundAt === 0) return false;
|
|
977
1169
|
return Date.now() - this.lastInboundAt >= this.silenceLimitMs;
|