@brass-build/client 0.1.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 (258) hide show
  1. package/AGENTS.md +255 -0
  2. package/CHANGELOG.md +15 -0
  3. package/LICENSE +21 -0
  4. package/README.md +836 -0
  5. package/dist/api-fetch.d.ts +16 -0
  6. package/dist/api-fetch.d.ts.map +1 -0
  7. package/dist/api-fetch.js +97 -0
  8. package/dist/api-fetch.js.map +1 -0
  9. package/dist/api-headers.d.ts +3 -0
  10. package/dist/api-headers.d.ts.map +1 -0
  11. package/dist/api-headers.js +25 -0
  12. package/dist/api-headers.js.map +1 -0
  13. package/dist/auth-flow.d.ts +23 -0
  14. package/dist/auth-flow.d.ts.map +1 -0
  15. package/dist/auth-flow.js +76 -0
  16. package/dist/auth-flow.js.map +1 -0
  17. package/dist/client.d.ts +106 -0
  18. package/dist/client.d.ts.map +1 -0
  19. package/dist/client.js +1278 -0
  20. package/dist/client.js.map +1 -0
  21. package/dist/doc-url-sync.d.ts +19 -0
  22. package/dist/doc-url-sync.d.ts.map +1 -0
  23. package/dist/doc-url-sync.js +181 -0
  24. package/dist/doc-url-sync.js.map +1 -0
  25. package/dist/document-branches.d.ts +32 -0
  26. package/dist/document-branches.d.ts.map +1 -0
  27. package/dist/document-branches.js +89 -0
  28. package/dist/document-branches.js.map +1 -0
  29. package/dist/document-checkpoints.d.ts +24 -0
  30. package/dist/document-checkpoints.d.ts.map +1 -0
  31. package/dist/document-checkpoints.js +115 -0
  32. package/dist/document-checkpoints.js.map +1 -0
  33. package/dist/document-commands.d.ts +93 -0
  34. package/dist/document-commands.d.ts.map +1 -0
  35. package/dist/document-commands.js +423 -0
  36. package/dist/document-commands.js.map +1 -0
  37. package/dist/document-signals.d.ts +53 -0
  38. package/dist/document-signals.d.ts.map +1 -0
  39. package/dist/document-signals.js +114 -0
  40. package/dist/document-signals.js.map +1 -0
  41. package/dist/document-streams.d.ts +22 -0
  42. package/dist/document-streams.d.ts.map +1 -0
  43. package/dist/document-streams.js +98 -0
  44. package/dist/document-streams.js.map +1 -0
  45. package/dist/document-thumbnail-capture.d.ts +27 -0
  46. package/dist/document-thumbnail-capture.d.ts.map +1 -0
  47. package/dist/document-thumbnail-capture.js +160 -0
  48. package/dist/document-thumbnail-capture.js.map +1 -0
  49. package/dist/documents-namespace.d.ts +134 -0
  50. package/dist/documents-namespace.d.ts.map +1 -0
  51. package/dist/documents-namespace.js +190 -0
  52. package/dist/documents-namespace.js.map +1 -0
  53. package/dist/documents.d.ts +146 -0
  54. package/dist/documents.d.ts.map +1 -0
  55. package/dist/documents.js +2045 -0
  56. package/dist/documents.js.map +1 -0
  57. package/dist/emitter.d.ts +15 -0
  58. package/dist/emitter.d.ts.map +1 -0
  59. package/dist/emitter.js +90 -0
  60. package/dist/emitter.js.map +1 -0
  61. package/dist/errors.d.ts +18 -0
  62. package/dist/errors.d.ts.map +1 -0
  63. package/dist/errors.js +22 -0
  64. package/dist/errors.js.map +1 -0
  65. package/dist/export-guest.d.ts +9 -0
  66. package/dist/export-guest.d.ts.map +1 -0
  67. package/dist/export-guest.js +225 -0
  68. package/dist/export-guest.js.map +1 -0
  69. package/dist/fetch-retry.d.ts +16 -0
  70. package/dist/fetch-retry.d.ts.map +1 -0
  71. package/dist/fetch-retry.js +90 -0
  72. package/dist/fetch-retry.js.map +1 -0
  73. package/dist/guest-link.d.ts +3 -0
  74. package/dist/guest-link.d.ts.map +1 -0
  75. package/dist/guest-link.js +13 -0
  76. package/dist/guest-link.js.map +1 -0
  77. package/dist/handles.d.ts +57 -0
  78. package/dist/handles.d.ts.map +1 -0
  79. package/dist/handles.js +1375 -0
  80. package/dist/handles.js.map +1 -0
  81. package/dist/hosted-runtime.d.ts +33 -0
  82. package/dist/hosted-runtime.d.ts.map +1 -0
  83. package/dist/hosted-runtime.js +99 -0
  84. package/dist/hosted-runtime.js.map +1 -0
  85. package/dist/hosted-ui.d.ts +84 -0
  86. package/dist/hosted-ui.d.ts.map +1 -0
  87. package/dist/hosted-ui.js +673 -0
  88. package/dist/hosted-ui.js.map +1 -0
  89. package/dist/importer.d.ts +22 -0
  90. package/dist/importer.d.ts.map +1 -0
  91. package/dist/importer.js +54 -0
  92. package/dist/importer.js.map +1 -0
  93. package/dist/index.d.ts +54 -0
  94. package/dist/index.d.ts.map +1 -0
  95. package/dist/index.js +132 -0
  96. package/dist/index.js.map +1 -0
  97. package/dist/init-keys.d.ts +3 -0
  98. package/dist/init-keys.d.ts.map +1 -0
  99. package/dist/init-keys.js +13 -0
  100. package/dist/init-keys.js.map +1 -0
  101. package/dist/internal.d.ts +6 -0
  102. package/dist/internal.d.ts.map +1 -0
  103. package/dist/internal.js +17 -0
  104. package/dist/internal.js.map +1 -0
  105. package/dist/jwt.d.ts +3 -0
  106. package/dist/jwt.d.ts.map +1 -0
  107. package/dist/jwt.js +22 -0
  108. package/dist/jwt.js.map +1 -0
  109. package/dist/launch-context.d.ts +4 -0
  110. package/dist/launch-context.d.ts.map +1 -0
  111. package/dist/launch-context.js +44 -0
  112. package/dist/launch-context.js.map +1 -0
  113. package/dist/login-hint.d.ts +4 -0
  114. package/dist/login-hint.d.ts.map +1 -0
  115. package/dist/login-hint.js +63 -0
  116. package/dist/login-hint.js.map +1 -0
  117. package/dist/mutate-batching.d.ts +14 -0
  118. package/dist/mutate-batching.d.ts.map +1 -0
  119. package/dist/mutate-batching.js +65 -0
  120. package/dist/mutate-batching.js.map +1 -0
  121. package/dist/open-document.d.ts +127 -0
  122. package/dist/open-document.d.ts.map +1 -0
  123. package/dist/open-document.js +2121 -0
  124. package/dist/open-document.js.map +1 -0
  125. package/dist/page-title.d.ts +3 -0
  126. package/dist/page-title.d.ts.map +1 -0
  127. package/dist/page-title.js +32 -0
  128. package/dist/page-title.js.map +1 -0
  129. package/dist/perf.d.ts +9 -0
  130. package/dist/perf.d.ts.map +1 -0
  131. package/dist/perf.js +39 -0
  132. package/dist/perf.js.map +1 -0
  133. package/dist/presence.d.ts +62 -0
  134. package/dist/presence.d.ts.map +1 -0
  135. package/dist/presence.js +331 -0
  136. package/dist/presence.js.map +1 -0
  137. package/dist/record-paging.d.ts +99 -0
  138. package/dist/record-paging.d.ts.map +1 -0
  139. package/dist/record-paging.js +220 -0
  140. package/dist/record-paging.js.map +1 -0
  141. package/dist/schema-conflict-dialog.d.ts +10 -0
  142. package/dist/schema-conflict-dialog.d.ts.map +1 -0
  143. package/dist/schema-conflict-dialog.js +164 -0
  144. package/dist/schema-conflict-dialog.js.map +1 -0
  145. package/dist/selection-persistence.d.ts +5 -0
  146. package/dist/selection-persistence.d.ts.map +1 -0
  147. package/dist/selection-persistence.js +90 -0
  148. package/dist/selection-persistence.js.map +1 -0
  149. package/dist/serializer.d.ts +3 -0
  150. package/dist/serializer.d.ts.map +1 -0
  151. package/dist/serializer.js +2 -0
  152. package/dist/serializer.js.map +1 -0
  153. package/dist/session-persistence.d.ts +4 -0
  154. package/dist/session-persistence.d.ts.map +1 -0
  155. package/dist/session-persistence.js +68 -0
  156. package/dist/session-persistence.js.map +1 -0
  157. package/dist/session-toolbar.d.ts +62 -0
  158. package/dist/session-toolbar.d.ts.map +1 -0
  159. package/dist/session-toolbar.js +1205 -0
  160. package/dist/session-toolbar.js.map +1 -0
  161. package/dist/session.d.ts +69 -0
  162. package/dist/session.d.ts.map +1 -0
  163. package/dist/session.js +926 -0
  164. package/dist/session.js.map +1 -0
  165. package/dist/tail-ops.d.ts +28 -0
  166. package/dist/tail-ops.d.ts.map +1 -0
  167. package/dist/tail-ops.js +46 -0
  168. package/dist/tail-ops.js.map +1 -0
  169. package/dist/topic-lifecycle.d.ts +41 -0
  170. package/dist/topic-lifecycle.d.ts.map +1 -0
  171. package/dist/topic-lifecycle.js +169 -0
  172. package/dist/topic-lifecycle.js.map +1 -0
  173. package/dist/types.d.ts +22 -0
  174. package/dist/types.d.ts.map +1 -0
  175. package/dist/types.js +2 -0
  176. package/dist/types.js.map +1 -0
  177. package/dist/undo.d.ts +18 -0
  178. package/dist/undo.d.ts.map +1 -0
  179. package/dist/undo.js +110 -0
  180. package/dist/undo.js.map +1 -0
  181. package/dist/version-bar.d.ts +22 -0
  182. package/dist/version-bar.d.ts.map +1 -0
  183. package/dist/version-bar.js +169 -0
  184. package/dist/version-bar.js.map +1 -0
  185. package/dist/version.d.ts +3 -0
  186. package/dist/version.d.ts.map +1 -0
  187. package/dist/version.js +8 -0
  188. package/dist/version.js.map +1 -0
  189. package/dist/vite.d.ts +15 -0
  190. package/dist/vite.d.ts.map +1 -0
  191. package/dist/vite.js +36 -0
  192. package/dist/vite.js.map +1 -0
  193. package/dist/ws-client.d.ts +53 -0
  194. package/dist/ws-client.d.ts.map +1 -0
  195. package/dist/ws-client.js +604 -0
  196. package/dist/ws-client.js.map +1 -0
  197. package/dist/ws-frame-limit.d.ts +21 -0
  198. package/dist/ws-frame-limit.d.ts.map +1 -0
  199. package/dist/ws-frame-limit.js +40 -0
  200. package/dist/ws-frame-limit.js.map +1 -0
  201. package/docs/app-manifest.md +65 -0
  202. package/docs/custom-chrome.md +147 -0
  203. package/docs/hosted-ui-advanced.md +77 -0
  204. package/docs/imported-documents.md +261 -0
  205. package/docs/presence.md +129 -0
  206. package/docs/schemas.md +135 -0
  207. package/docs/streams.md +248 -0
  208. package/package.json +60 -0
  209. package/scripts/prepare.mjs +50 -0
  210. package/src/api-fetch.ts +127 -0
  211. package/src/api-headers.ts +28 -0
  212. package/src/auth-flow.ts +125 -0
  213. package/src/client.ts +1457 -0
  214. package/src/doc-url-sync.ts +183 -0
  215. package/src/document-branches.ts +124 -0
  216. package/src/document-checkpoints.ts +135 -0
  217. package/src/document-commands.ts +651 -0
  218. package/src/document-signals.ts +234 -0
  219. package/src/document-streams.ts +132 -0
  220. package/src/document-thumbnail-capture.ts +191 -0
  221. package/src/documents-namespace.ts +516 -0
  222. package/src/documents.ts +2426 -0
  223. package/src/emitter.ts +129 -0
  224. package/src/errors.ts +45 -0
  225. package/src/export-guest.ts +282 -0
  226. package/src/fetch-retry.ts +119 -0
  227. package/src/guest-link.ts +14 -0
  228. package/src/handles.ts +1731 -0
  229. package/src/hosted-runtime.ts +109 -0
  230. package/src/hosted-ui.ts +1020 -0
  231. package/src/importer.ts +89 -0
  232. package/src/index.ts +267 -0
  233. package/src/init-keys.ts +15 -0
  234. package/src/internal.ts +23 -0
  235. package/src/jwt.ts +18 -0
  236. package/src/launch-context.ts +43 -0
  237. package/src/login-hint.ts +60 -0
  238. package/src/mutate-batching.ts +63 -0
  239. package/src/open-document.ts +2869 -0
  240. package/src/page-title.ts +33 -0
  241. package/src/perf.ts +46 -0
  242. package/src/presence.ts +471 -0
  243. package/src/record-paging.ts +270 -0
  244. package/src/schema-conflict-dialog.ts +176 -0
  245. package/src/selection-persistence.ts +98 -0
  246. package/src/serializer.ts +15 -0
  247. package/src/session-persistence.ts +69 -0
  248. package/src/session-toolbar.ts +1457 -0
  249. package/src/session.ts +1262 -0
  250. package/src/tail-ops.ts +63 -0
  251. package/src/topic-lifecycle.ts +217 -0
  252. package/src/types.ts +75 -0
  253. package/src/undo.ts +125 -0
  254. package/src/version-bar.ts +221 -0
  255. package/src/version.ts +8 -0
  256. package/src/vite.ts +49 -0
  257. package/src/ws-client.ts +678 -0
  258. package/src/ws-frame-limit.ts +52 -0
@@ -0,0 +1,33 @@
1
+ // SDK-managed page title. The session sets the tab / history title to the
2
+ // active document's name so history entries and tabs are distinguishable
3
+ // (without it every entry the doc-URL sync records reads identically). On by
4
+ // default; opt out with `Brass.init(appId, { syncDocToTitle: false })` for
5
+ // an app that owns its own `document.title`.
6
+ //
7
+ // SSR-safe: a no-op when there is no `document`. The write is skipped when
8
+ // the title already matches, so calling it on every doc mutation (to follow
9
+ // a rename) costs only a string compare.
10
+
11
+ export function setPageTitle(title: string): void {
12
+ if (typeof document === 'undefined') return;
13
+ if (document.title === title) return;
14
+ document.title = title;
15
+ }
16
+
17
+ // Compose the tab title from the doc-scoped part (already branch-qualified
18
+ // by the session, e.g. `Doc` or `Doc (branch)`) and the app name, so a tab
19
+ // reads `Doc · App`. A falsy `docTitle` means no document is open, so the
20
+ // page's own title stands alone.
21
+ //
22
+ // `appName` is the page's own `<title>` captured at the first sync, which is
23
+ // typically the app name already (it is what the tab and every doc-less
24
+ // history entry show), so the qualified title needs no configuration. A
25
+ // blank `appName` (a page with an empty `<title>`) yields the bare doc
26
+ // title, never a dangling separator.
27
+ export function composePageTitle(
28
+ docTitle: string | null,
29
+ appName: string,
30
+ ): string {
31
+ if (!docTitle) return appName;
32
+ return appName ? `${docTitle} · ${appName}` : docTitle;
33
+ }
package/src/perf.ts ADDED
@@ -0,0 +1,46 @@
1
+ // Optional load-phase timing for diagnosing document-open cost. Off by
2
+ // default and zero-cost when off, so it is safe to leave wrapped around hot
3
+ // paths in shipped code.
4
+ //
5
+ // Enable at runtime (no rebuild) from a browser console:
6
+ // globalThis.__BRASS_PERF__ = true
7
+ // then reopen the document. Each phase prints as
8
+ // [brass perf] <label>: <ms> ms { ...fields }
9
+ // the fields carrying the numbers that explain the cost (snapshot bytes,
10
+ // response size, op counts).
11
+
12
+ interface PerfGlobal {
13
+ __BRASS_PERF__?: unknown;
14
+ }
15
+
16
+ function perfEnabled(): boolean {
17
+ try {
18
+ return Boolean((globalThis as PerfGlobal).__BRASS_PERF__);
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ function perfNow(): number {
25
+ return typeof performance !== 'undefined' &&
26
+ typeof performance.now === 'function'
27
+ ? performance.now()
28
+ : Date.now();
29
+ }
30
+
31
+ /** Called to close a timing span; logs elapsed ms plus any fields. */
32
+ export type PerfEnd = (fields?: Record<string, unknown>) => void;
33
+
34
+ /**
35
+ * Start a timing span. Returns an `end(fields?)` to call when the phase
36
+ * completes. A no-op (returns a no-op `end`) when timing is disabled, so
37
+ * callers wrap unconditionally without branching.
38
+ */
39
+ export function perfStart(label: string): PerfEnd {
40
+ if (!perfEnabled()) return () => {};
41
+ const t0 = perfNow();
42
+ return (fields) => {
43
+ const ms = Math.round((perfNow() - t0) * 10) / 10;
44
+ console.info(`[brass perf] ${label}: ${ms} ms`, fields ?? {});
45
+ };
46
+ }
@@ -0,0 +1,471 @@
1
+ // Document-scoped presence ("who is here right now"), reached as
2
+ // `doc.presence`. Owns one WS subscription on `presence:doc:<doc_id>`
3
+ // multiplexed onto the shared `BrassWsClient`: tracks the roster of
4
+ // other participants (from `presence_snapshot` + join/update/leave
5
+ // frames), publishes this client's presence via `set(...)` (shallow
6
+ // local merge, throttled outbound), and notifies `on('change')`. The
7
+ // server stores what it is sent verbatim, so the merge happens here.
8
+
9
+ // `set(...)` is debounced to at most one `presence` frame per interval
10
+ // (leading edge fires immediately, trailing send carries merges from
11
+ // the window). 100 ms = 10 Hz: low fanout cost, smooth interpolation.
12
+ export const PRESENCE_PUBLISH_INTERVAL_MS = 100;
13
+
14
+ const PRESENCE_TOPIC_PREFIX = 'presence:doc:';
15
+
16
+ // Reserved-by-convention sub-keys on the presence `state` block, so
17
+ // two apps on the same document agree on each key's meaning even when
18
+ // one is 2D (cursor only) and another 3D (camera + cursor). All keys
19
+ // optional; an app sets only what it has.
20
+ //
21
+ // Coordinates are the platform standard: meters, right-handed, Z-up,
22
+ // document world space. Map your engine's coordinates in before `set`
23
+ // and back out when rendering remotes.
24
+ //
25
+ // App-specific state (tool, chat bubble, typing indicator) goes under
26
+ // `ext` instead, namespaced out of the cross-app conventions.
27
+ export interface PresenceState {
28
+ /** Primary pointer position. Use for "this is where my cursor
29
+ * is right now" in a 2D or 3D editor; omit if the app has no
30
+ * cursor concept. */
31
+ cursor?: { x: number; y: number; z: number };
32
+ /** Where the user is looking from in 3D. Use to render a
33
+ * "Bob is viewing the south facade" gizmo at the camera's
34
+ * position. Omit in 2D apps; use `cursor` instead. */
35
+ camera?: {
36
+ position: { x: number; y: number; z: number };
37
+ target?: { x: number; y: number; z: number };
38
+ };
39
+ /** Entity ids the user has selected. Use to highlight remote
40
+ * selections so collaborators see what each other is working on. */
41
+ selection?: string[];
42
+ }
43
+
44
+ // A live participant on a presence topic. Keyed on `connectionId` (one
45
+ // per tab / device); identity fields (`sub`, `displayName`, `color`)
46
+ // are server-stamped. The `Partial<PresenceState> & Record<...>`
47
+ // intersection on `state` types `state.cursor?.x` as `number |
48
+ // undefined` while still allowing arbitrary `state[key]` (typed
49
+ // `unknown`). `ext` is the app-namespaced blob. Both default to `{}`
50
+ // before the participant's first `set(...)`.
51
+ export interface PresenceParticipant {
52
+ connectionId: string;
53
+ sub: string;
54
+ displayName: string;
55
+ avatarUrl: string;
56
+ color: string;
57
+ joinedAt: string;
58
+ lastSeen: string;
59
+ state: Partial<PresenceState> & Record<string, unknown>;
60
+ ext: Record<string, unknown>;
61
+ }
62
+
63
+ // The partial payload passed to `presence.set(...)`. Either layer may
64
+ // be present; each is shallow-merged into the client's current self
65
+ // payload, so `set({ cursor })` then `set({ camera })` keeps both.
66
+ export interface PresenceSetInput {
67
+ state?: Record<string, unknown>;
68
+ ext?: Record<string, unknown>;
69
+ }
70
+
71
+ export type PresenceChangeListener = (
72
+ roster: PresenceParticipant[],
73
+ ) => void;
74
+
75
+ // The public shape attached as `doc.presence`. An interface (not the
76
+ // concrete class) so `documents.ts` can type the field without
77
+ // importing the implementation.
78
+ export interface PresenceClient {
79
+ // Publish a partial update; shallow-merged locally, throttled onto the wire.
80
+ set(input: PresenceSetInput): void;
81
+ // Subscribe to roster changes (snapshot, join, update, leave). Fires
82
+ // once on subscribe with the current roster when peers are already
83
+ // present, then on every change. Returns an unsubscribe function;
84
+ // equivalent to `.off` with the same listener.
85
+ on(event: 'change', listener: PresenceChangeListener): () => void;
86
+ // Detach a listener registered with `.on`. Idempotent. For the
87
+ // `addEventListener` / `removeEventListener` style where the listener
88
+ // reference is what's kept at teardown.
89
+ off(event: 'change', listener: PresenceChangeListener): void;
90
+ // The current roster of OTHER live participants (self is not in it; read `self`).
91
+ readonly roster: PresenceParticipant[];
92
+ // This client's own current presence payload, as last set.
93
+ readonly self: {
94
+ state: Record<string, unknown>;
95
+ ext: Record<string, unknown>;
96
+ };
97
+ // Unsubscribe the topic and drop all roster state + listeners. Also
98
+ // called automatically when the owning Document is closed.
99
+ leave(): void;
100
+ /**
101
+ * @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
106
+ * subscription and roster alive so a resumed open is seamless. Not
107
+ * `leave()`: parking is reversible and the subscription must survive it.
108
+ */
109
+ _suspendListeners(): void;
110
+ /**
111
+ * @internal Called when the owning Document resumes (`Document._resume`).
112
+ * Reopens the outbound gate `_suspendListeners` closed and re-advertises
113
+ * this client's retained presence so peers restore its cursor on the
114
+ * resumed open, mirroring the reconnect-snapshot republish.
115
+ */
116
+ _resumeListeners(): void;
117
+ }
118
+
119
+ // Structural subset of `BrassWsClient` the controller needs. Matches
120
+ // `OpenDocumentWsClient` in `open-document.ts` so the same shared
121
+ // transport instance is passed straight through.
122
+ export interface PresenceWsClient {
123
+ subscribe(topic: string, callback: (frame: unknown) => void): () => void;
124
+ send(frame: unknown): void;
125
+ }
126
+
127
+ function isObject(value: unknown): value is Record<string, unknown> {
128
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
129
+ }
130
+
131
+ // Map a server-stamped (snake_case) participant from the wire onto
132
+ // the camelCase `PresenceParticipant` SDK consumers see. Returns null
133
+ // for a structurally invalid entry so one bad row in a snapshot does
134
+ // not poison the whole roster.
135
+ function wireToParticipant(raw: unknown): PresenceParticipant | null {
136
+ if (!isObject(raw)) return null;
137
+ const connectionId = raw['connection_id'];
138
+ const sub = raw['sub'];
139
+ const displayName = raw['display_name'];
140
+ const avatarUrl = raw['avatar_url'];
141
+ const color = raw['color'];
142
+ const joinedAt = raw['joined_at'];
143
+ const lastSeen = raw['last_seen'];
144
+ if (
145
+ typeof connectionId !== 'string' ||
146
+ typeof sub !== 'string' ||
147
+ typeof displayName !== 'string' ||
148
+ typeof color !== 'string' ||
149
+ typeof joinedAt !== 'string' ||
150
+ typeof lastSeen !== 'string'
151
+ ) {
152
+ return null;
153
+ }
154
+ const presence = raw['presence'];
155
+ const { state, ext } = splitPresencePayload(presence);
156
+ return {
157
+ connectionId,
158
+ sub,
159
+ displayName,
160
+ avatarUrl: typeof avatarUrl === 'string' ? avatarUrl : '',
161
+ color,
162
+ joinedAt,
163
+ lastSeen,
164
+ state,
165
+ ext,
166
+ };
167
+ }
168
+
169
+ // Pull `state` / `ext` out of a wire `presence` payload, defaulting
170
+ // each to an empty object. Tolerates a missing or malformed payload.
171
+ function splitPresencePayload(payload: unknown): {
172
+ state: Record<string, unknown>;
173
+ ext: Record<string, unknown>;
174
+ } {
175
+ if (!isObject(payload)) return { state: {}, ext: {} };
176
+ const rawState = payload['state'];
177
+ const rawExt = payload['ext'];
178
+ return {
179
+ state: isObject(rawState) ? rawState : {},
180
+ ext: isObject(rawExt) ? rawExt : {},
181
+ };
182
+ }
183
+
184
+ class PresenceController implements PresenceClient {
185
+ private readonly _topic: string;
186
+ private readonly _roster = new Map<string, PresenceParticipant>();
187
+ private readonly _listeners = new Set<PresenceChangeListener>();
188
+ private _selfState: Record<string, unknown> = {};
189
+ private _selfExt: Record<string, unknown> = {};
190
+ private _unsub: (() => void) | null = null;
191
+ private _throttleTimer: ReturnType<typeof setTimeout> | null = null;
192
+ private _lastSentAt = 0;
193
+ private _closed = false;
194
+ // Set while the owning Document is parked (`_suspendListeners`). Gates the
195
+ // outbound publish path so a parked doc advertises nothing, whether from a
196
+ // late `set(...)` or from the reconnect-snapshot republish (the WS
197
+ // subscription stays attached across a park, so those frames keep arriving).
198
+ // `_resumeListeners` clears it. Distinct from `_closed`: parking is
199
+ // reversible, so inbound roster tracking and `_selfState` are kept.
200
+ private _suspended = false;
201
+
202
+ constructor(
203
+ private readonly _wsClient: PresenceWsClient,
204
+ docId: string,
205
+ // The branch this presence channel scopes to. Presence is per
206
+ // (document, branch): two users on different branches of the
207
+ // same doc do not see each other's cursors.
208
+ branchId: string,
209
+ ) {
210
+ this._topic = `${PRESENCE_TOPIC_PREFIX}${docId}:${branchId}`;
211
+ this._unsub = this._wsClient.subscribe(this._topic, (frame) =>
212
+ this._onFrame(frame),
213
+ );
214
+ }
215
+
216
+ get roster(): PresenceParticipant[] {
217
+ return [...this._roster.values()];
218
+ }
219
+
220
+ get self(): {
221
+ state: Record<string, unknown>;
222
+ ext: Record<string, unknown>;
223
+ } {
224
+ return { state: this._selfState, ext: this._selfExt };
225
+ }
226
+
227
+ set(input: PresenceSetInput): void {
228
+ if (this._closed) return;
229
+ if (input.state !== undefined) {
230
+ this._selfState = { ...this._selfState, ...input.state };
231
+ }
232
+ if (input.ext !== undefined) {
233
+ this._selfExt = { ...this._selfExt, ...input.ext };
234
+ }
235
+ this._scheduleSend();
236
+ }
237
+
238
+ on(event: 'change', listener: PresenceChangeListener): () => void {
239
+ if (event !== 'change') {
240
+ // Only `change` exists today. Returning a no-op unsubscribe
241
+ // (rather than throwing) keeps a forward-compatible call site
242
+ // from breaking against an older SDK build.
243
+ return () => {};
244
+ }
245
+ this._listeners.add(listener);
246
+ // Deliver the current roster on attach when peers are already here, so
247
+ // a listener wired after the `presence_snapshot` landed (the common
248
+ // case: opening a document others are already in) paints them instead
249
+ // of waiting for their next move. Matches the fire-on-attach contract
250
+ // the SDK's other subscribe surfaces hold when they have state to
251
+ // deliver (`onGone`, `onStatus`). An empty roster is the consumer's
252
+ // default paint, so it needs no initial fire.
253
+ if (this._roster.size > 0) {
254
+ try {
255
+ listener(this.roster);
256
+ } catch {
257
+ // A throwing initial callback must not strand the registration.
258
+ }
259
+ }
260
+ return () => {
261
+ this._listeners.delete(listener);
262
+ };
263
+ }
264
+
265
+ off(event: 'change', listener: PresenceChangeListener): void {
266
+ if (event !== 'change') return;
267
+ this._listeners.delete(listener);
268
+ }
269
+
270
+ leave(): void {
271
+ if (this._closed) return;
272
+ this._closed = true;
273
+ if (this._throttleTimer !== null) {
274
+ clearTimeout(this._throttleTimer);
275
+ this._throttleTimer = null;
276
+ }
277
+ if (this._unsub) {
278
+ try {
279
+ this._unsub();
280
+ } catch {
281
+ // Detaching an already-detached subscription is fine; swallow.
282
+ }
283
+ this._unsub = null;
284
+ }
285
+ this._roster.clear();
286
+ this._listeners.clear();
287
+ }
288
+
289
+ _suspendListeners(): void {
290
+ // Drop the app-registered change listeners and gate the outbound publish
291
+ // path; keep the WS subscription and roster so a resumed open (which does
292
+ // not re-create the controller) is seamless and a re-registered
293
+ // `on('change')` fires immediately with the still-current roster. The gate
294
+ // is what stops a parked doc advertising this client's cursor: cancelling
295
+ // the pending trailing send alone is not enough, because a `set(...)` after
296
+ // park or the republish `_applySnapshot` fires on a reconnect snapshot
297
+ // would otherwise send a fresh frame while parked.
298
+ this._suspended = true;
299
+ this._listeners.clear();
300
+ if (this._throttleTimer !== null) {
301
+ clearTimeout(this._throttleTimer);
302
+ this._throttleTimer = null;
303
+ }
304
+ }
305
+
306
+ _resumeListeners(): void {
307
+ // Reopen the outbound gate and re-advertise this client's retained
308
+ // presence so peers restore its cursor on the resumed open without waiting
309
+ // for the next input, the same republish `_applySnapshot` does after a
310
+ // reconnect. No-op when nothing has been set yet.
311
+ this._suspended = false;
312
+ if (
313
+ Object.keys(this._selfState).length > 0 ||
314
+ Object.keys(this._selfExt).length > 0
315
+ ) {
316
+ this._scheduleSend();
317
+ }
318
+ }
319
+
320
+ // --- wire handling ----------------------------------------------
321
+
322
+ private _onFrame(frame: unknown): void {
323
+ if (this._closed || !isObject(frame)) return;
324
+ if (frame['topic'] !== this._topic) return;
325
+ switch (frame['type']) {
326
+ case 'presence_snapshot':
327
+ this._applySnapshot(frame['participants']);
328
+ return;
329
+ case 'presence_join':
330
+ this._applyJoin(frame['participant']);
331
+ return;
332
+ case 'presence_update':
333
+ this._applyUpdate(frame['connection_id'], frame['presence']);
334
+ return;
335
+ case 'presence_leave':
336
+ this._applyLeave(frame['connection_id']);
337
+ return;
338
+ case 'doc_gone':
339
+ // The server also fans `doc_gone` on `presence:doc:` (paired
340
+ // with the SUB# eviction in `fanoutDocGone`). `leave()` stops
341
+ // publishing immediately so a trailing presence frame crossing
342
+ // the eviction window can't be rejected `forbidden` and surface
343
+ // to `onError` as a misleading "subscribe(...): forbidden", and
344
+ // drops the WS subscription so a late error finds no listeners.
345
+ this.leave();
346
+ return;
347
+ default:
348
+ // `subscribed` / `error` are handled by the shared
349
+ // `BrassWsClient`; ignore anything unrecognised so a server
350
+ // schema addition doesn't break an older SDK.
351
+ return;
352
+ }
353
+ }
354
+
355
+ private _applySnapshot(rawParticipants: unknown): void {
356
+ if (!Array.isArray(rawParticipants)) return;
357
+ this._roster.clear();
358
+ for (const raw of rawParticipants) {
359
+ const participant = wireToParticipant(raw);
360
+ if (participant) this._roster.set(participant.connectionId, participant);
361
+ }
362
+ this._emitChange();
363
+ // A snapshot marks a (re)subscribe settling. When this client already
364
+ // has presence to show (a cursor set before a reconnect or a silent
365
+ // server-side re-subscribe), republish it through the throttle so
366
+ // peers restore it without waiting for the next user input.
367
+ if (
368
+ Object.keys(this._selfState).length > 0 ||
369
+ Object.keys(this._selfExt).length > 0
370
+ ) {
371
+ this._scheduleSend();
372
+ }
373
+ }
374
+
375
+ private _applyJoin(rawParticipant: unknown): void {
376
+ const participant = wireToParticipant(rawParticipant);
377
+ if (!participant) return;
378
+ this._roster.set(participant.connectionId, participant);
379
+ this._emitChange();
380
+ }
381
+
382
+ private _applyUpdate(rawConnectionId: unknown, rawPresence: unknown): void {
383
+ if (typeof rawConnectionId !== 'string') return;
384
+ const existing = this._roster.get(rawConnectionId);
385
+ // An update for a participant not in the roster means we missed
386
+ // their join (e.g. a snapshot that raced the join frame). Ignore
387
+ // it rather than synthesising a participant with no identity
388
+ // fields; their next join / our next snapshot reconciles.
389
+ if (!existing) return;
390
+ const { state, ext } = splitPresencePayload(rawPresence);
391
+ this._roster.set(rawConnectionId, {
392
+ ...existing,
393
+ state,
394
+ ext,
395
+ // `presence_update` carries no server timestamp; stamp the
396
+ // local receive time so a consumer's idle / drop heuristic has
397
+ // a monotonic-ish signal to age participants off.
398
+ lastSeen: new Date().toISOString(),
399
+ });
400
+ this._emitChange();
401
+ }
402
+
403
+ private _applyLeave(rawConnectionId: unknown): void {
404
+ if (typeof rawConnectionId !== 'string') return;
405
+ if (this._roster.delete(rawConnectionId)) {
406
+ this._emitChange();
407
+ }
408
+ }
409
+
410
+ private _emitChange(): void {
411
+ const roster = this.roster;
412
+ for (const listener of this._listeners) {
413
+ try {
414
+ listener(roster);
415
+ } catch {
416
+ // A throwing listener must not break the others or the read
417
+ // loop; matches the swallow contract on `Document` subscribers.
418
+ }
419
+ }
420
+ }
421
+
422
+ // --- outbound throttle ------------------------------------------
423
+
424
+ private _scheduleSend(): void {
425
+ if (this._closed || this._suspended) return;
426
+ const elapsed = Date.now() - this._lastSentAt;
427
+ if (elapsed >= PRESENCE_PUBLISH_INTERVAL_MS) {
428
+ this._sendNow();
429
+ return;
430
+ }
431
+ if (this._throttleTimer === null) {
432
+ // Trailing send: schedule one frame at the end of the current
433
+ // window. Further `set(...)` calls before it fires just merge
434
+ // into `_selfState` / `_selfExt` and ride the same trailing send.
435
+ this._throttleTimer = setTimeout(
436
+ () => {
437
+ this._throttleTimer = null;
438
+ this._sendNow();
439
+ },
440
+ PRESENCE_PUBLISH_INTERVAL_MS - elapsed,
441
+ );
442
+ }
443
+ }
444
+
445
+ private _sendNow(): void {
446
+ if (this._closed || this._suspended) return;
447
+ this._lastSentAt = Date.now();
448
+ 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;
454
+ }
455
+ this._wsClient.send({
456
+ type: 'presence',
457
+ topic: this._topic,
458
+ presence,
459
+ });
460
+ }
461
+ }
462
+
463
+ // Factory for the presence controller. Called from `open-document.ts`
464
+ // when a WS client is wired onto a freshly materialised Document.
465
+ export function createPresenceController(
466
+ wsClient: PresenceWsClient,
467
+ docId: string,
468
+ branchId: string,
469
+ ): PresenceClient {
470
+ return new PresenceController(wsClient, docId, branchId);
471
+ }