@brass-build/client 0.1.0 → 0.2.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.
Files changed (193) hide show
  1. package/AGENTS.md +72 -63
  2. package/CHANGELOG.md +86 -0
  3. package/README.md +317 -231
  4. package/dist/auth-flow.d.ts +10 -1
  5. package/dist/auth-flow.d.ts.map +1 -1
  6. package/dist/auth-flow.js +64 -20
  7. package/dist/auth-flow.js.map +1 -1
  8. package/dist/client.d.ts +3 -0
  9. package/dist/client.d.ts.map +1 -1
  10. package/dist/client.js +217 -75
  11. package/dist/client.js.map +1 -1
  12. package/dist/document-engine.d.ts +4 -0
  13. package/dist/document-engine.d.ts.map +1 -0
  14. package/dist/document-engine.js +9 -0
  15. package/dist/document-engine.js.map +1 -0
  16. package/dist/document-streams.d.ts +8 -1
  17. package/dist/document-streams.d.ts.map +1 -1
  18. package/dist/document-streams.js +79 -8
  19. package/dist/document-streams.js.map +1 -1
  20. package/dist/documents-namespace.d.ts.map +1 -1
  21. package/dist/documents-namespace.js +19 -3
  22. package/dist/documents-namespace.js.map +1 -1
  23. package/dist/documents.d.ts +43 -14
  24. package/dist/documents.d.ts.map +1 -1
  25. package/dist/documents.js +871 -223
  26. package/dist/documents.js.map +1 -1
  27. package/dist/embed-protocol.d.ts +3 -0
  28. package/dist/embed-protocol.d.ts.map +1 -0
  29. package/dist/embed-protocol.js +26 -0
  30. package/dist/embed-protocol.js.map +1 -0
  31. package/dist/emitter.d.ts.map +1 -1
  32. package/dist/emitter.js +8 -1
  33. package/dist/emitter.js.map +1 -1
  34. package/dist/export-guest.d.ts.map +1 -1
  35. package/dist/export-guest.js +14 -6
  36. package/dist/export-guest.js.map +1 -1
  37. package/dist/fetch-deadline.d.ts +22 -0
  38. package/dist/fetch-deadline.d.ts.map +1 -0
  39. package/dist/fetch-deadline.js +96 -0
  40. package/dist/fetch-deadline.js.map +1 -0
  41. package/dist/fetch-retry.d.ts +1 -0
  42. package/dist/fetch-retry.d.ts.map +1 -1
  43. package/dist/fetch-retry.js +12 -5
  44. package/dist/fetch-retry.js.map +1 -1
  45. package/dist/handle-types.d.ts +150 -0
  46. package/dist/handle-types.d.ts.map +1 -0
  47. package/dist/handle-types.js +8 -0
  48. package/dist/handle-types.js.map +1 -0
  49. package/dist/handles.d.ts +48 -48
  50. package/dist/handles.d.ts.map +1 -1
  51. package/dist/handles.js +3122 -501
  52. package/dist/handles.js.map +1 -1
  53. package/dist/hosted-runtime.d.ts.map +1 -1
  54. package/dist/hosted-runtime.js +8 -12
  55. package/dist/hosted-runtime.js.map +1 -1
  56. package/dist/hosted-ui.d.ts +0 -1
  57. package/dist/hosted-ui.d.ts.map +1 -1
  58. package/dist/hosted-ui.js +29 -24
  59. package/dist/hosted-ui.js.map +1 -1
  60. package/dist/importer.d.ts.map +1 -1
  61. package/dist/importer.js +253 -8
  62. package/dist/importer.js.map +1 -1
  63. package/dist/index.d.ts +10 -10
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +11 -2
  66. package/dist/index.js.map +1 -1
  67. package/dist/internal.d.ts +1 -1
  68. package/dist/internal.d.ts.map +1 -1
  69. package/dist/internal.js.map +1 -1
  70. package/dist/join-coverage.d.ts +12 -0
  71. package/dist/join-coverage.d.ts.map +1 -0
  72. package/dist/join-coverage.js +37 -0
  73. package/dist/join-coverage.js.map +1 -0
  74. package/dist/jwt.d.ts.map +1 -1
  75. package/dist/jwt.js +5 -1
  76. package/dist/jwt.js.map +1 -1
  77. package/dist/launch-context.js +2 -2
  78. package/dist/launch-context.js.map +1 -1
  79. package/dist/mutate-batching.d.ts +25 -2
  80. package/dist/mutate-batching.d.ts.map +1 -1
  81. package/dist/mutate-batching.js +57 -10
  82. package/dist/mutate-batching.js.map +1 -1
  83. package/dist/open-document.d.ts +15 -1
  84. package/dist/open-document.d.ts.map +1 -1
  85. package/dist/open-document.js +1851 -321
  86. package/dist/open-document.js.map +1 -1
  87. package/dist/presence.d.ts +28 -0
  88. package/dist/presence.d.ts.map +1 -1
  89. package/dist/presence.js +149 -7
  90. package/dist/presence.js.map +1 -1
  91. package/dist/record-paging.d.ts +336 -71
  92. package/dist/record-paging.d.ts.map +1 -1
  93. package/dist/record-paging.js +591 -127
  94. package/dist/record-paging.js.map +1 -1
  95. package/dist/selection-persistence.d.ts.map +1 -1
  96. package/dist/selection-persistence.js +11 -1
  97. package/dist/selection-persistence.js.map +1 -1
  98. package/dist/serializer.d.ts +30 -1
  99. package/dist/serializer.d.ts.map +1 -1
  100. package/dist/session-persistence.d.ts.map +1 -1
  101. package/dist/session-persistence.js +9 -8
  102. package/dist/session-persistence.js.map +1 -1
  103. package/dist/session-toolbar.d.ts +0 -1
  104. package/dist/session-toolbar.d.ts.map +1 -1
  105. package/dist/session-toolbar.js +226 -75
  106. package/dist/session-toolbar.js.map +1 -1
  107. package/dist/session.d.ts.map +1 -1
  108. package/dist/session.js +87 -18
  109. package/dist/session.js.map +1 -1
  110. package/dist/tail-ops.d.ts +14 -4
  111. package/dist/tail-ops.d.ts.map +1 -1
  112. package/dist/tail-ops.js +75 -15
  113. package/dist/tail-ops.js.map +1 -1
  114. package/dist/topic-lifecycle.d.ts +4 -0
  115. package/dist/topic-lifecycle.d.ts.map +1 -1
  116. package/dist/topic-lifecycle.js +25 -1
  117. package/dist/topic-lifecycle.js.map +1 -1
  118. package/dist/types.d.ts +47 -1
  119. package/dist/types.d.ts.map +1 -1
  120. package/dist/undo.d.ts +2 -3
  121. package/dist/undo.d.ts.map +1 -1
  122. package/dist/undo.js +11 -0
  123. package/dist/undo.js.map +1 -1
  124. package/dist/unload-guard.d.ts +5 -0
  125. package/dist/unload-guard.d.ts.map +1 -0
  126. package/dist/unload-guard.js +88 -0
  127. package/dist/unload-guard.js.map +1 -0
  128. package/dist/version-bar.d.ts +4 -3
  129. package/dist/version-bar.d.ts.map +1 -1
  130. package/dist/version-bar.js +140 -19
  131. package/dist/version-bar.js.map +1 -1
  132. package/dist/version.d.ts +2 -2
  133. package/dist/version.js +1 -1
  134. package/dist/vite.d.ts +18 -2
  135. package/dist/vite.d.ts.map +1 -1
  136. package/dist/vite.js +133 -8
  137. package/dist/vite.js.map +1 -1
  138. package/dist/ws-client.d.ts +23 -3
  139. package/dist/ws-client.d.ts.map +1 -1
  140. package/dist/ws-client.js +339 -89
  141. package/dist/ws-client.js.map +1 -1
  142. package/dist/ws-frame-limit.d.ts.map +1 -1
  143. package/dist/ws-frame-limit.js +19 -12
  144. package/dist/ws-frame-limit.js.map +1 -1
  145. package/docs/app-manifest.md +7 -8
  146. package/docs/custom-chrome.md +17 -20
  147. package/docs/hosted-ui-advanced.md +14 -17
  148. package/docs/imported-documents.md +53 -197
  149. package/docs/presence.md +45 -21
  150. package/docs/schemas.md +12 -17
  151. package/docs/streams.md +55 -57
  152. package/package.json +4 -3
  153. package/src/auth-flow.ts +90 -23
  154. package/src/client.ts +225 -78
  155. package/src/document-engine.ts +15 -0
  156. package/src/document-signals.ts +7 -7
  157. package/src/document-streams.ts +95 -9
  158. package/src/documents-namespace.ts +18 -3
  159. package/src/documents.ts +983 -277
  160. package/src/embed-protocol.ts +25 -0
  161. package/src/emitter.ts +8 -1
  162. package/src/export-guest.ts +15 -9
  163. package/src/fetch-deadline.ts +119 -0
  164. package/src/fetch-retry.ts +14 -5
  165. package/src/handle-types.ts +303 -0
  166. package/src/handles.ts +3406 -683
  167. package/src/hosted-runtime.ts +8 -12
  168. package/src/hosted-ui.ts +28 -32
  169. package/src/importer.ts +261 -8
  170. package/src/index.ts +29 -18
  171. package/src/internal.ts +1 -0
  172. package/src/join-coverage.ts +42 -0
  173. package/src/jwt.ts +8 -1
  174. package/src/launch-context.ts +2 -2
  175. package/src/mutate-batching.ts +60 -11
  176. package/src/open-document.ts +2355 -515
  177. package/src/presence.ts +210 -11
  178. package/src/record-paging.ts +687 -137
  179. package/src/selection-persistence.ts +10 -1
  180. package/src/serializer.ts +33 -1
  181. package/src/session-persistence.ts +9 -8
  182. package/src/session-toolbar.ts +249 -80
  183. package/src/session.ts +91 -17
  184. package/src/tail-ops.ts +76 -10
  185. package/src/topic-lifecycle.ts +31 -1
  186. package/src/types.ts +56 -4
  187. package/src/undo.ts +13 -5
  188. package/src/unload-guard.ts +91 -0
  189. package/src/version-bar.ts +163 -25
  190. package/src/version.ts +1 -1
  191. package/src/vite.ts +175 -10
  192. package/src/ws-client.ts +392 -92
  193. package/src/ws-frame-limit.ts +19 -12
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 wired in the
103
- * parked session does not keep firing on remote presence frames after
104
- * the app has closed the doc, and gates the outbound publish path so a
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 SUB# eviction in `fanoutDocGone`). `leave()` stops
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
- for (const listener of this._listeners) {
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
- if (Object.keys(this._selfState).length > 0) {
450
- presence.state = this._selfState;
451
- }
452
- if (Object.keys(this._selfExt).length > 0) {
453
- presence.ext = this._selfExt;
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`