@brass-build/client 0.1.0 → 0.3.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 +72 -63
- package/CHANGELOG.md +97 -0
- package/README.md +325 -235
- package/dist/auth-flow.d.ts +10 -1
- package/dist/auth-flow.d.ts.map +1 -1
- package/dist/auth-flow.js +64 -20
- package/dist/auth-flow.js.map +1 -1
- package/dist/client.d.ts +4 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +245 -76
- package/dist/client.js.map +1 -1
- package/dist/document-checkpoints.d.ts +1 -0
- package/dist/document-checkpoints.d.ts.map +1 -1
- package/dist/document-checkpoints.js +27 -0
- package/dist/document-checkpoints.js.map +1 -1
- package/dist/document-engine.d.ts +4 -0
- package/dist/document-engine.d.ts.map +1 -0
- package/dist/document-engine.js +9 -0
- package/dist/document-engine.js.map +1 -0
- package/dist/document-signals.d.ts +31 -1
- package/dist/document-signals.d.ts.map +1 -1
- package/dist/document-signals.js +59 -0
- package/dist/document-signals.js.map +1 -1
- package/dist/document-streams.d.ts +8 -1
- package/dist/document-streams.d.ts.map +1 -1
- package/dist/document-streams.js +79 -8
- package/dist/document-streams.js.map +1 -1
- package/dist/document-thumbnail-capture.d.ts +20 -0
- package/dist/document-thumbnail-capture.d.ts.map +1 -1
- package/dist/document-thumbnail-capture.js +247 -9
- package/dist/document-thumbnail-capture.js.map +1 -1
- package/dist/documents-namespace.d.ts.map +1 -1
- package/dist/documents-namespace.js +22 -3
- package/dist/documents-namespace.js.map +1 -1
- package/dist/documents.d.ts +49 -16
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +981 -237
- package/dist/documents.js.map +1 -1
- package/dist/embed-protocol.d.ts +3 -0
- package/dist/embed-protocol.d.ts.map +1 -0
- package/dist/embed-protocol.js +26 -0
- package/dist/embed-protocol.js.map +1 -0
- package/dist/emitter.d.ts.map +1 -1
- package/dist/emitter.js +8 -1
- package/dist/emitter.js.map +1 -1
- package/dist/export-guest.d.ts.map +1 -1
- package/dist/export-guest.js +14 -6
- package/dist/export-guest.js.map +1 -1
- package/dist/fetch-deadline.d.ts +22 -0
- package/dist/fetch-deadline.d.ts.map +1 -0
- package/dist/fetch-deadline.js +96 -0
- package/dist/fetch-deadline.js.map +1 -0
- package/dist/fetch-retry.d.ts +1 -0
- package/dist/fetch-retry.d.ts.map +1 -1
- package/dist/fetch-retry.js +12 -5
- package/dist/fetch-retry.js.map +1 -1
- package/dist/handle-types.d.ts +150 -0
- package/dist/handle-types.d.ts.map +1 -0
- package/dist/handle-types.js +8 -0
- package/dist/handle-types.js.map +1 -0
- package/dist/handles.d.ts +48 -48
- package/dist/handles.d.ts.map +1 -1
- package/dist/handles.js +3122 -501
- package/dist/handles.js.map +1 -1
- package/dist/hosted-runtime.d.ts.map +1 -1
- package/dist/hosted-runtime.js +8 -12
- package/dist/hosted-runtime.js.map +1 -1
- package/dist/hosted-ui.d.ts +0 -1
- package/dist/hosted-ui.d.ts.map +1 -1
- package/dist/hosted-ui.js +29 -24
- package/dist/hosted-ui.js.map +1 -1
- package/dist/importer.d.ts.map +1 -1
- package/dist/importer.js +204 -8
- package/dist/importer.js.map +1 -1
- package/dist/index.d.ts +10 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -2
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +2 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +1 -0
- package/dist/internal.js.map +1 -1
- package/dist/join-coverage.d.ts +12 -0
- package/dist/join-coverage.d.ts.map +1 -0
- package/dist/join-coverage.js +37 -0
- package/dist/join-coverage.js.map +1 -0
- package/dist/jwt.d.ts.map +1 -1
- package/dist/jwt.js +5 -1
- package/dist/jwt.js.map +1 -1
- package/dist/launch-context.js +2 -2
- package/dist/launch-context.js.map +1 -1
- package/dist/mutate-batching.d.ts +25 -2
- package/dist/mutate-batching.d.ts.map +1 -1
- package/dist/mutate-batching.js +57 -10
- package/dist/mutate-batching.js.map +1 -1
- package/dist/open-document.d.ts +30 -1
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +1905 -322
- package/dist/open-document.js.map +1 -1
- package/dist/presence.d.ts +28 -0
- package/dist/presence.d.ts.map +1 -1
- package/dist/presence.js +149 -7
- package/dist/presence.js.map +1 -1
- package/dist/record-paging.d.ts +335 -71
- package/dist/record-paging.d.ts.map +1 -1
- package/dist/record-paging.js +590 -127
- package/dist/record-paging.js.map +1 -1
- package/dist/selection-persistence.d.ts.map +1 -1
- package/dist/selection-persistence.js +11 -1
- package/dist/selection-persistence.js.map +1 -1
- package/dist/serializer.d.ts +30 -1
- package/dist/serializer.d.ts.map +1 -1
- package/dist/session-persistence.d.ts.map +1 -1
- package/dist/session-persistence.js +9 -8
- package/dist/session-persistence.js.map +1 -1
- package/dist/session-toolbar.d.ts +0 -1
- package/dist/session-toolbar.d.ts.map +1 -1
- package/dist/session-toolbar.js +356 -83
- package/dist/session-toolbar.js.map +1 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +87 -18
- package/dist/session.js.map +1 -1
- package/dist/tail-ops.d.ts +14 -4
- package/dist/tail-ops.d.ts.map +1 -1
- package/dist/tail-ops.js +75 -15
- package/dist/tail-ops.js.map +1 -1
- package/dist/topic-lifecycle.d.ts +4 -0
- package/dist/topic-lifecycle.d.ts.map +1 -1
- package/dist/topic-lifecycle.js +25 -1
- package/dist/topic-lifecycle.js.map +1 -1
- package/dist/types.d.ts +47 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/undo.d.ts +2 -3
- package/dist/undo.d.ts.map +1 -1
- package/dist/undo.js +11 -0
- package/dist/undo.js.map +1 -1
- package/dist/unload-guard.d.ts +5 -0
- package/dist/unload-guard.d.ts.map +1 -0
- package/dist/unload-guard.js +88 -0
- package/dist/unload-guard.js.map +1 -0
- package/dist/version-bar.d.ts +4 -3
- package/dist/version-bar.d.ts.map +1 -1
- package/dist/version-bar.js +162 -20
- package/dist/version-bar.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/dist/vite.d.ts +18 -2
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +133 -8
- package/dist/vite.js.map +1 -1
- package/dist/when-saved.d.ts +2 -0
- package/dist/when-saved.d.ts.map +1 -0
- package/dist/when-saved.js +70 -0
- package/dist/when-saved.js.map +1 -0
- package/dist/ws-client.d.ts +27 -3
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +408 -89
- package/dist/ws-client.js.map +1 -1
- package/dist/ws-frame-limit.d.ts.map +1 -1
- package/dist/ws-frame-limit.js +23 -17
- package/dist/ws-frame-limit.js.map +1 -1
- package/docs/app-manifest.md +7 -8
- package/docs/custom-chrome.md +21 -22
- package/docs/hosted-ui-advanced.md +14 -17
- package/docs/imported-documents.md +53 -197
- package/docs/presence.md +45 -21
- package/docs/schemas.md +12 -17
- package/docs/streams.md +55 -57
- package/package.json +4 -3
- package/src/auth-flow.ts +90 -23
- package/src/client.ts +262 -80
- package/src/document-checkpoints.ts +30 -0
- package/src/document-engine.ts +15 -0
- package/src/document-signals.ts +120 -8
- package/src/document-streams.ts +95 -9
- package/src/document-thumbnail-capture.ts +253 -9
- package/src/documents-namespace.ts +38 -13
- package/src/documents.ts +1103 -290
- package/src/embed-protocol.ts +25 -0
- package/src/emitter.ts +8 -1
- package/src/export-guest.ts +15 -9
- package/src/fetch-deadline.ts +119 -0
- package/src/fetch-retry.ts +14 -5
- package/src/handle-types.ts +303 -0
- package/src/handles.ts +3407 -683
- package/src/hosted-runtime.ts +8 -12
- package/src/hosted-ui.ts +28 -32
- package/src/importer.ts +216 -8
- package/src/index.ts +29 -18
- package/src/internal.ts +7 -0
- package/src/join-coverage.ts +42 -0
- package/src/jwt.ts +8 -1
- package/src/launch-context.ts +2 -2
- package/src/mutate-batching.ts +60 -11
- package/src/open-document.ts +2441 -502
- package/src/presence.ts +210 -11
- package/src/record-paging.ts +686 -137
- package/src/selection-persistence.ts +10 -1
- package/src/serializer.ts +33 -1
- package/src/session-persistence.ts +9 -8
- package/src/session-toolbar.ts +387 -89
- package/src/session.ts +91 -17
- package/src/tail-ops.ts +76 -10
- package/src/topic-lifecycle.ts +31 -1
- package/src/types.ts +56 -4
- package/src/undo.ts +13 -5
- package/src/unload-guard.ts +91 -0
- package/src/version-bar.ts +183 -26
- package/src/version.ts +1 -1
- package/src/vite.ts +175 -10
- package/src/when-saved.ts +66 -0
- package/src/ws-client.ts +461 -92
- package/src/ws-frame-limit.ts +23 -17
package/src/presence.ts
CHANGED
|
@@ -6,13 +6,52 @@
|
|
|
6
6
|
// local merge, throttled outbound), and notifies `on('change')`. The
|
|
7
7
|
// server stores what it is sent verbatim, so the merge happens here.
|
|
8
8
|
|
|
9
|
+
import { createEmitter } from './emitter.js';
|
|
10
|
+
|
|
9
11
|
// `set(...)` is debounced to at most one `presence` frame per interval
|
|
10
12
|
// (leading edge fires immediately, trailing send carries merges from
|
|
11
13
|
// the window). 100 ms = 10 Hz: low fanout cost, smooth interpolation.
|
|
12
14
|
export const PRESENCE_PUBLISH_INTERVAL_MS = 100;
|
|
13
15
|
|
|
16
|
+
/**
|
|
17
|
+
* The largest a single presence layer (`state` or `ext`) may serialise to.
|
|
18
|
+
*
|
|
19
|
+
* The platform caps each layer at this and drops a frame whose layer is
|
|
20
|
+
* over, so a payload past it reaches no peer. Roughly 130 to 140 entity ids
|
|
21
|
+
* in `state.selection`, which an ordinary marquee select crosses, so measure
|
|
22
|
+
* against it whenever what you publish scales with what the user picked.
|
|
23
|
+
*/
|
|
24
|
+
export const PRESENCE_LAYER_MAX_BYTES = 2048;
|
|
25
|
+
|
|
14
26
|
const PRESENCE_TOPIC_PREFIX = 'presence:doc:';
|
|
15
27
|
|
|
28
|
+
const TEXT_ENCODER = new TextEncoder();
|
|
29
|
+
|
|
30
|
+
// Serialised UTF-8 byte length of one layer, measured the same way the
|
|
31
|
+
// platform measures it. A non-serialisable value (circular, a BigInt) reads
|
|
32
|
+
// as over the cap rather than throwing out of the publish path, since a
|
|
33
|
+
// frame carrying it could not be sent either way.
|
|
34
|
+
function presenceLayerBytes(layer: Record<string, unknown>): number {
|
|
35
|
+
let json: string;
|
|
36
|
+
try {
|
|
37
|
+
json = JSON.stringify(layer);
|
|
38
|
+
} catch {
|
|
39
|
+
return Number.POSITIVE_INFINITY;
|
|
40
|
+
}
|
|
41
|
+
return TEXT_ENCODER.encode(json).length;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The outgoing payload as one comparable string, or null when it cannot be
|
|
45
|
+
// serialised. Null reads as "different" at the call site, so a comparison
|
|
46
|
+
// that cannot be answered sends the frame rather than skipping it.
|
|
47
|
+
function presencePayloadKey(presence: PresenceSetInput): string | null {
|
|
48
|
+
try {
|
|
49
|
+
return JSON.stringify(presence);
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
16
55
|
// Reserved-by-convention sub-keys on the presence `state` block, so
|
|
17
56
|
// two apps on the same document agree on each key's meaning even when
|
|
18
57
|
// one is 2D (cursor only) and another 3D (camera + cursor). All keys
|
|
@@ -72,6 +111,36 @@ export type PresenceChangeListener = (
|
|
|
72
111
|
roster: PresenceParticipant[],
|
|
73
112
|
) => void;
|
|
74
113
|
|
|
114
|
+
/** The two independently capped layers of a presence payload. */
|
|
115
|
+
export type PresenceLayer = 'state' | 'ext';
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Whether this client's presence is reaching its peers.
|
|
119
|
+
*
|
|
120
|
+
* A layer larger than `PRESENCE_LAYER_MAX_BYTES` is withheld from the wire,
|
|
121
|
+
* so the roster everyone else renders keeps the last value that fit. Read
|
|
122
|
+
* `status` for the current answer, or `onStatus` to be told when it moves.
|
|
123
|
+
*/
|
|
124
|
+
export interface PresenceStatus {
|
|
125
|
+
/** True while some layer is too large to publish. */
|
|
126
|
+
oversize: boolean;
|
|
127
|
+
/** The layers being withheld, `state` before `ext`. Empty when
|
|
128
|
+
* `oversize` is false. */
|
|
129
|
+
withheld: PresenceLayer[];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export type PresenceStatusListener = (status: PresenceStatus) => void;
|
|
133
|
+
|
|
134
|
+
const PRESENCE_STATUS_OK: PresenceStatus = { oversize: false, withheld: [] };
|
|
135
|
+
|
|
136
|
+
function statusEqual(a: PresenceStatus, b: PresenceStatus): boolean {
|
|
137
|
+
return (
|
|
138
|
+
a.oversize === b.oversize &&
|
|
139
|
+
a.withheld.length === b.withheld.length &&
|
|
140
|
+
a.withheld.every((layer, i) => layer === b.withheld[i])
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
75
144
|
// The public shape attached as `doc.presence`. An interface (not the
|
|
76
145
|
// concrete class) so `documents.ts` can type the field without
|
|
77
146
|
// importing the implementation.
|
|
@@ -87,6 +156,14 @@ export interface PresenceClient {
|
|
|
87
156
|
// `addEventListener` / `removeEventListener` style where the listener
|
|
88
157
|
// reference is what's kept at teardown.
|
|
89
158
|
off(event: 'change', listener: PresenceChangeListener): void;
|
|
159
|
+
// Subscribe to whether this client's presence is reaching its peers.
|
|
160
|
+
// Fires on attach with the current status, then on every change to it,
|
|
161
|
+
// never per `set(...)`: a publisher whose every update is too large
|
|
162
|
+
// reports the condition once, not at 10 Hz. Returns an unsubscribe
|
|
163
|
+
// function.
|
|
164
|
+
onStatus(listener: PresenceStatusListener): () => void;
|
|
165
|
+
// Whether this client's presence is reaching its peers, right now.
|
|
166
|
+
readonly status: PresenceStatus;
|
|
90
167
|
// The current roster of OTHER live participants (self is not in it; read `self`).
|
|
91
168
|
readonly roster: PresenceParticipant[];
|
|
92
169
|
// This client's own current presence payload, as last set.
|
|
@@ -99,10 +176,10 @@ export interface PresenceClient {
|
|
|
99
176
|
leave(): void;
|
|
100
177
|
/**
|
|
101
178
|
* @internal Called when the owning Document parks (`Document._suspend`).
|
|
102
|
-
* Drops app-registered `change` listeners so a callback
|
|
103
|
-
* parked session does not keep firing on remote presence
|
|
104
|
-
* the app has closed the doc, and gates the outbound publish
|
|
105
|
-
* parked doc stops advertising this client's cursor. Keeps the WS
|
|
179
|
+
* Drops app-registered `change` and `status` listeners so a callback
|
|
180
|
+
* wired in the parked session does not keep firing on remote presence
|
|
181
|
+
* frames after the app has closed the doc, and gates the outbound publish
|
|
182
|
+
* path so a parked doc stops advertising this client's cursor. Keeps the WS
|
|
106
183
|
* subscription and roster alive so a resumed open is seamless. Not
|
|
107
184
|
* `leave()`: parking is reversible and the subscription must survive it.
|
|
108
185
|
*/
|
|
@@ -191,6 +268,34 @@ class PresenceController implements PresenceClient {
|
|
|
191
268
|
private _throttleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
192
269
|
private _lastSentAt = 0;
|
|
193
270
|
private _closed = false;
|
|
271
|
+
// The last value of each layer that reached the wire, or null for a layer
|
|
272
|
+
// that has never been published. A withheld layer re-sends its entry: the
|
|
273
|
+
// server stores the payload verbatim, so omitting the layer would clear it
|
|
274
|
+
// from every peer's roster rather than leaving it at its last good value.
|
|
275
|
+
private _sentState: Record<string, unknown> | null = null;
|
|
276
|
+
private _sentExt: Record<string, unknown> | null = null;
|
|
277
|
+
// The payload of the last frame this connection published, so a tick that
|
|
278
|
+
// resolves to the same one can be skipped. Distinct from the two layers
|
|
279
|
+
// above, which are the last values that FIT and are what a withheld layer
|
|
280
|
+
// falls back to.
|
|
281
|
+
private _sentPayloadKey: string | null = null;
|
|
282
|
+
// Set by the two paths that republish because the server's copy of this
|
|
283
|
+
// client's presence is gone (a re-subscribe rewrites the row empty, and a
|
|
284
|
+
// resumed open re-advertises after a park). The frame they need is exactly
|
|
285
|
+
// the one the comparison above would skip, so it takes precedence for one
|
|
286
|
+
// send.
|
|
287
|
+
private _republishPending = false;
|
|
288
|
+
private readonly _statusEmitter = createEmitter<PresenceStatus>({
|
|
289
|
+
fireOnAttach: true,
|
|
290
|
+
retain: 'latest',
|
|
291
|
+
// Publishing runs at 10 Hz, and an app that overflows a layer usually
|
|
292
|
+
// overflows it on every update, so the gate is what turns a per-update
|
|
293
|
+
// condition into the edge an app can act on.
|
|
294
|
+
equals: statusEqual,
|
|
295
|
+
onError: (err) => {
|
|
296
|
+
console.error('[Brass] presence onStatus listener threw:', err);
|
|
297
|
+
},
|
|
298
|
+
});
|
|
194
299
|
// Set while the owning Document is parked (`_suspendListeners`). Gates the
|
|
195
300
|
// outbound publish path so a parked doc advertises nothing, whether from a
|
|
196
301
|
// late `set(...)` or from the reconnect-snapshot republish (the WS
|
|
@@ -208,6 +313,9 @@ class PresenceController implements PresenceClient {
|
|
|
208
313
|
branchId: string,
|
|
209
314
|
) {
|
|
210
315
|
this._topic = `${PRESENCE_TOPIC_PREFIX}${docId}:${branchId}`;
|
|
316
|
+
// Seed the retained status so `status` reads and a fire-on-attach
|
|
317
|
+
// `onStatus` both answer before the first `set(...)`.
|
|
318
|
+
this._statusEmitter.emit(PRESENCE_STATUS_OK);
|
|
211
319
|
this._unsub = this._wsClient.subscribe(this._topic, (frame) =>
|
|
212
320
|
this._onFrame(frame),
|
|
213
321
|
);
|
|
@@ -217,6 +325,14 @@ class PresenceController implements PresenceClient {
|
|
|
217
325
|
return [...this._roster.values()];
|
|
218
326
|
}
|
|
219
327
|
|
|
328
|
+
get status(): PresenceStatus {
|
|
329
|
+
return this._statusEmitter.retained ?? PRESENCE_STATUS_OK;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
onStatus(listener: PresenceStatusListener): () => void {
|
|
333
|
+
return this._statusEmitter.on(listener);
|
|
334
|
+
}
|
|
335
|
+
|
|
220
336
|
get self(): {
|
|
221
337
|
state: Record<string, unknown>;
|
|
222
338
|
ext: Record<string, unknown>;
|
|
@@ -284,6 +400,7 @@ class PresenceController implements PresenceClient {
|
|
|
284
400
|
}
|
|
285
401
|
this._roster.clear();
|
|
286
402
|
this._listeners.clear();
|
|
403
|
+
this._statusEmitter.clear();
|
|
287
404
|
}
|
|
288
405
|
|
|
289
406
|
_suspendListeners(): void {
|
|
@@ -297,6 +414,7 @@ class PresenceController implements PresenceClient {
|
|
|
297
414
|
// would otherwise send a fresh frame while parked.
|
|
298
415
|
this._suspended = true;
|
|
299
416
|
this._listeners.clear();
|
|
417
|
+
this._statusEmitter.clear();
|
|
300
418
|
if (this._throttleTimer !== null) {
|
|
301
419
|
clearTimeout(this._throttleTimer);
|
|
302
420
|
this._throttleTimer = null;
|
|
@@ -313,6 +431,7 @@ class PresenceController implements PresenceClient {
|
|
|
313
431
|
Object.keys(this._selfState).length > 0 ||
|
|
314
432
|
Object.keys(this._selfExt).length > 0
|
|
315
433
|
) {
|
|
434
|
+
this._republishPending = true;
|
|
316
435
|
this._scheduleSend();
|
|
317
436
|
}
|
|
318
437
|
}
|
|
@@ -337,7 +456,7 @@ class PresenceController implements PresenceClient {
|
|
|
337
456
|
return;
|
|
338
457
|
case 'doc_gone':
|
|
339
458
|
// The server also fans `doc_gone` on `presence:doc:` (paired
|
|
340
|
-
// with the
|
|
459
|
+
// with the subscription eviction). `leave()` stops
|
|
341
460
|
// publishing immediately so a trailing presence frame crossing
|
|
342
461
|
// the eviction window can't be rejected `forbidden` and surface
|
|
343
462
|
// to `onError` as a misleading "subscribe(...): forbidden", and
|
|
@@ -368,6 +487,7 @@ class PresenceController implements PresenceClient {
|
|
|
368
487
|
Object.keys(this._selfState).length > 0 ||
|
|
369
488
|
Object.keys(this._selfExt).length > 0
|
|
370
489
|
) {
|
|
490
|
+
this._republishPending = true;
|
|
371
491
|
this._scheduleSend();
|
|
372
492
|
}
|
|
373
493
|
}
|
|
@@ -409,7 +529,11 @@ class PresenceController implements PresenceClient {
|
|
|
409
529
|
|
|
410
530
|
private _emitChange(): void {
|
|
411
531
|
const roster = this.roster;
|
|
412
|
-
|
|
532
|
+
// Copied before the walk: a listener that closes the document reaches
|
|
533
|
+
// `_suspendListeners`, which clears this set, and a `Set` never visits an
|
|
534
|
+
// entry removed before its turn. Iterating it live drops every listener
|
|
535
|
+
// after that one.
|
|
536
|
+
for (const listener of Array.from(this._listeners)) {
|
|
413
537
|
try {
|
|
414
538
|
listener(roster);
|
|
415
539
|
} catch {
|
|
@@ -444,20 +568,95 @@ class PresenceController implements PresenceClient {
|
|
|
444
568
|
|
|
445
569
|
private _sendNow(): void {
|
|
446
570
|
if (this._closed || this._suspended) return;
|
|
571
|
+
// Stamped even when nothing goes out, so the throttle still paces the
|
|
572
|
+
// measurement rather than re-serialising on every `set(...)`.
|
|
447
573
|
this._lastSentAt = Date.now();
|
|
574
|
+
|
|
575
|
+
const withheld: PresenceLayer[] = [];
|
|
448
576
|
const presence: PresenceSetInput = {};
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
577
|
+
const state = this._publishableLayer(
|
|
578
|
+
'state',
|
|
579
|
+
this._selfState,
|
|
580
|
+
this._sentState,
|
|
581
|
+
withheld,
|
|
582
|
+
);
|
|
583
|
+
if (state) presence.state = state;
|
|
584
|
+
const ext = this._publishableLayer(
|
|
585
|
+
'ext',
|
|
586
|
+
this._selfExt,
|
|
587
|
+
this._sentExt,
|
|
588
|
+
withheld,
|
|
589
|
+
);
|
|
590
|
+
if (ext) presence.ext = ext;
|
|
591
|
+
|
|
592
|
+
this._emitStatus(withheld);
|
|
593
|
+
|
|
594
|
+
// Every layer is either empty or oversized with nothing published behind
|
|
595
|
+
// it, so the frame would carry nothing peers do not already have.
|
|
596
|
+
if (presence.state === undefined && presence.ext === undefined) return;
|
|
597
|
+
|
|
598
|
+
// A frame repeating what this connection already published costs the
|
|
599
|
+
// server a row write and every peer a roster fanout, and says nothing.
|
|
600
|
+
// A withheld layer makes that the steady state rather than an edge case:
|
|
601
|
+
// it resolves to its last good value on every tick, so an app publishing
|
|
602
|
+
// an oversized layer at its input rate would re-send that one value at up
|
|
603
|
+
// to 10 Hz for as long as the layer stays over.
|
|
604
|
+
const payloadKey = presencePayloadKey(presence);
|
|
605
|
+
if (
|
|
606
|
+
!this._republishPending &&
|
|
607
|
+
payloadKey !== null &&
|
|
608
|
+
payloadKey === this._sentPayloadKey
|
|
609
|
+
) {
|
|
610
|
+
return;
|
|
454
611
|
}
|
|
612
|
+
this._republishPending = false;
|
|
613
|
+
this._sentPayloadKey = payloadKey;
|
|
614
|
+
this._sentState = presence.state ?? null;
|
|
615
|
+
this._sentExt = presence.ext ?? null;
|
|
455
616
|
this._wsClient.send({
|
|
456
617
|
type: 'presence',
|
|
457
618
|
topic: this._topic,
|
|
458
619
|
presence,
|
|
459
620
|
});
|
|
460
621
|
}
|
|
622
|
+
|
|
623
|
+
// Resolve what one layer contributes to the outbound frame: the current
|
|
624
|
+
// value when it fits, otherwise the last value that did (recorded in
|
|
625
|
+
// `withheld` either way). Measuring here is what keeps a presence frame
|
|
626
|
+
// orders of magnitude under the size that closes the socket, which matters
|
|
627
|
+
// because the socket carries the document's ops too and the reconnect
|
|
628
|
+
// republishes retained presence, so one oversized frame would otherwise
|
|
629
|
+
// wedge the document in a reconnect loop instead of dropping an update.
|
|
630
|
+
private _publishableLayer(
|
|
631
|
+
layer: PresenceLayer,
|
|
632
|
+
current: Record<string, unknown>,
|
|
633
|
+
lastSent: Record<string, unknown> | null,
|
|
634
|
+
withheld: PresenceLayer[],
|
|
635
|
+
): Record<string, unknown> | undefined {
|
|
636
|
+
if (Object.keys(current).length === 0) return undefined;
|
|
637
|
+
if (presenceLayerBytes(current) <= PRESENCE_LAYER_MAX_BYTES) return current;
|
|
638
|
+
withheld.push(layer);
|
|
639
|
+
return lastSent ?? undefined;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
private _emitStatus(withheld: PresenceLayer[]): void {
|
|
643
|
+
const next: PresenceStatus =
|
|
644
|
+
withheld.length === 0
|
|
645
|
+
? PRESENCE_STATUS_OK
|
|
646
|
+
: { oversize: true, withheld };
|
|
647
|
+
if (statusEqual(next, this.status)) return;
|
|
648
|
+
if (next.oversize) {
|
|
649
|
+
// The rising edge only, so a 10 Hz publisher gets one line rather than
|
|
650
|
+
// ten a second. Without it an app that never wired `onStatus` has no
|
|
651
|
+
// sign that its peers stopped seeing the layer.
|
|
652
|
+
console.warn(
|
|
653
|
+
`[Brass] presence ${next.withheld.join(' and ')} exceeds ` +
|
|
654
|
+
`${PRESENCE_LAYER_MAX_BYTES} bytes and is not being published. ` +
|
|
655
|
+
`Publish less, or watch presence.onStatus to react.`,
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
this._statusEmitter.emit(next);
|
|
659
|
+
}
|
|
461
660
|
}
|
|
462
661
|
|
|
463
662
|
// Factory for the presence controller. Called from `open-document.ts`
|