@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.
- package/AGENTS.md +255 -0
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +836 -0
- package/dist/api-fetch.d.ts +16 -0
- package/dist/api-fetch.d.ts.map +1 -0
- package/dist/api-fetch.js +97 -0
- package/dist/api-fetch.js.map +1 -0
- package/dist/api-headers.d.ts +3 -0
- package/dist/api-headers.d.ts.map +1 -0
- package/dist/api-headers.js +25 -0
- package/dist/api-headers.js.map +1 -0
- package/dist/auth-flow.d.ts +23 -0
- package/dist/auth-flow.d.ts.map +1 -0
- package/dist/auth-flow.js +76 -0
- package/dist/auth-flow.js.map +1 -0
- package/dist/client.d.ts +106 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1278 -0
- package/dist/client.js.map +1 -0
- package/dist/doc-url-sync.d.ts +19 -0
- package/dist/doc-url-sync.d.ts.map +1 -0
- package/dist/doc-url-sync.js +181 -0
- package/dist/doc-url-sync.js.map +1 -0
- package/dist/document-branches.d.ts +32 -0
- package/dist/document-branches.d.ts.map +1 -0
- package/dist/document-branches.js +89 -0
- package/dist/document-branches.js.map +1 -0
- package/dist/document-checkpoints.d.ts +24 -0
- package/dist/document-checkpoints.d.ts.map +1 -0
- package/dist/document-checkpoints.js +115 -0
- package/dist/document-checkpoints.js.map +1 -0
- package/dist/document-commands.d.ts +93 -0
- package/dist/document-commands.d.ts.map +1 -0
- package/dist/document-commands.js +423 -0
- package/dist/document-commands.js.map +1 -0
- package/dist/document-signals.d.ts +53 -0
- package/dist/document-signals.d.ts.map +1 -0
- package/dist/document-signals.js +114 -0
- package/dist/document-signals.js.map +1 -0
- package/dist/document-streams.d.ts +22 -0
- package/dist/document-streams.d.ts.map +1 -0
- package/dist/document-streams.js +98 -0
- package/dist/document-streams.js.map +1 -0
- package/dist/document-thumbnail-capture.d.ts +27 -0
- package/dist/document-thumbnail-capture.d.ts.map +1 -0
- package/dist/document-thumbnail-capture.js +160 -0
- package/dist/document-thumbnail-capture.js.map +1 -0
- package/dist/documents-namespace.d.ts +134 -0
- package/dist/documents-namespace.d.ts.map +1 -0
- package/dist/documents-namespace.js +190 -0
- package/dist/documents-namespace.js.map +1 -0
- package/dist/documents.d.ts +146 -0
- package/dist/documents.d.ts.map +1 -0
- package/dist/documents.js +2045 -0
- package/dist/documents.js.map +1 -0
- package/dist/emitter.d.ts +15 -0
- package/dist/emitter.d.ts.map +1 -0
- package/dist/emitter.js +90 -0
- package/dist/emitter.js.map +1 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +22 -0
- package/dist/errors.js.map +1 -0
- package/dist/export-guest.d.ts +9 -0
- package/dist/export-guest.d.ts.map +1 -0
- package/dist/export-guest.js +225 -0
- package/dist/export-guest.js.map +1 -0
- package/dist/fetch-retry.d.ts +16 -0
- package/dist/fetch-retry.d.ts.map +1 -0
- package/dist/fetch-retry.js +90 -0
- package/dist/fetch-retry.js.map +1 -0
- package/dist/guest-link.d.ts +3 -0
- package/dist/guest-link.d.ts.map +1 -0
- package/dist/guest-link.js +13 -0
- package/dist/guest-link.js.map +1 -0
- package/dist/handles.d.ts +57 -0
- package/dist/handles.d.ts.map +1 -0
- package/dist/handles.js +1375 -0
- package/dist/handles.js.map +1 -0
- package/dist/hosted-runtime.d.ts +33 -0
- package/dist/hosted-runtime.d.ts.map +1 -0
- package/dist/hosted-runtime.js +99 -0
- package/dist/hosted-runtime.js.map +1 -0
- package/dist/hosted-ui.d.ts +84 -0
- package/dist/hosted-ui.d.ts.map +1 -0
- package/dist/hosted-ui.js +673 -0
- package/dist/hosted-ui.js.map +1 -0
- package/dist/importer.d.ts +22 -0
- package/dist/importer.d.ts.map +1 -0
- package/dist/importer.js +54 -0
- package/dist/importer.js.map +1 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +132 -0
- package/dist/index.js.map +1 -0
- package/dist/init-keys.d.ts +3 -0
- package/dist/init-keys.d.ts.map +1 -0
- package/dist/init-keys.js +13 -0
- package/dist/init-keys.js.map +1 -0
- package/dist/internal.d.ts +6 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +17 -0
- package/dist/internal.js.map +1 -0
- package/dist/jwt.d.ts +3 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +22 -0
- package/dist/jwt.js.map +1 -0
- package/dist/launch-context.d.ts +4 -0
- package/dist/launch-context.d.ts.map +1 -0
- package/dist/launch-context.js +44 -0
- package/dist/launch-context.js.map +1 -0
- package/dist/login-hint.d.ts +4 -0
- package/dist/login-hint.d.ts.map +1 -0
- package/dist/login-hint.js +63 -0
- package/dist/login-hint.js.map +1 -0
- package/dist/mutate-batching.d.ts +14 -0
- package/dist/mutate-batching.d.ts.map +1 -0
- package/dist/mutate-batching.js +65 -0
- package/dist/mutate-batching.js.map +1 -0
- package/dist/open-document.d.ts +127 -0
- package/dist/open-document.d.ts.map +1 -0
- package/dist/open-document.js +2121 -0
- package/dist/open-document.js.map +1 -0
- package/dist/page-title.d.ts +3 -0
- package/dist/page-title.d.ts.map +1 -0
- package/dist/page-title.js +32 -0
- package/dist/page-title.js.map +1 -0
- package/dist/perf.d.ts +9 -0
- package/dist/perf.d.ts.map +1 -0
- package/dist/perf.js +39 -0
- package/dist/perf.js.map +1 -0
- package/dist/presence.d.ts +62 -0
- package/dist/presence.d.ts.map +1 -0
- package/dist/presence.js +331 -0
- package/dist/presence.js.map +1 -0
- package/dist/record-paging.d.ts +99 -0
- package/dist/record-paging.d.ts.map +1 -0
- package/dist/record-paging.js +220 -0
- package/dist/record-paging.js.map +1 -0
- package/dist/schema-conflict-dialog.d.ts +10 -0
- package/dist/schema-conflict-dialog.d.ts.map +1 -0
- package/dist/schema-conflict-dialog.js +164 -0
- package/dist/schema-conflict-dialog.js.map +1 -0
- package/dist/selection-persistence.d.ts +5 -0
- package/dist/selection-persistence.d.ts.map +1 -0
- package/dist/selection-persistence.js +90 -0
- package/dist/selection-persistence.js.map +1 -0
- package/dist/serializer.d.ts +3 -0
- package/dist/serializer.d.ts.map +1 -0
- package/dist/serializer.js +2 -0
- package/dist/serializer.js.map +1 -0
- package/dist/session-persistence.d.ts +4 -0
- package/dist/session-persistence.d.ts.map +1 -0
- package/dist/session-persistence.js +68 -0
- package/dist/session-persistence.js.map +1 -0
- package/dist/session-toolbar.d.ts +62 -0
- package/dist/session-toolbar.d.ts.map +1 -0
- package/dist/session-toolbar.js +1205 -0
- package/dist/session-toolbar.js.map +1 -0
- package/dist/session.d.ts +69 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +926 -0
- package/dist/session.js.map +1 -0
- package/dist/tail-ops.d.ts +28 -0
- package/dist/tail-ops.d.ts.map +1 -0
- package/dist/tail-ops.js +46 -0
- package/dist/tail-ops.js.map +1 -0
- package/dist/topic-lifecycle.d.ts +41 -0
- package/dist/topic-lifecycle.d.ts.map +1 -0
- package/dist/topic-lifecycle.js +169 -0
- package/dist/topic-lifecycle.js.map +1 -0
- package/dist/types.d.ts +22 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/undo.d.ts +18 -0
- package/dist/undo.d.ts.map +1 -0
- package/dist/undo.js +110 -0
- package/dist/undo.js.map +1 -0
- package/dist/version-bar.d.ts +22 -0
- package/dist/version-bar.d.ts.map +1 -0
- package/dist/version-bar.js +169 -0
- package/dist/version-bar.js.map +1 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +8 -0
- package/dist/version.js.map +1 -0
- package/dist/vite.d.ts +15 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +36 -0
- package/dist/vite.js.map +1 -0
- package/dist/ws-client.d.ts +53 -0
- package/dist/ws-client.d.ts.map +1 -0
- package/dist/ws-client.js +604 -0
- package/dist/ws-client.js.map +1 -0
- package/dist/ws-frame-limit.d.ts +21 -0
- package/dist/ws-frame-limit.d.ts.map +1 -0
- package/dist/ws-frame-limit.js +40 -0
- package/dist/ws-frame-limit.js.map +1 -0
- package/docs/app-manifest.md +65 -0
- package/docs/custom-chrome.md +147 -0
- package/docs/hosted-ui-advanced.md +77 -0
- package/docs/imported-documents.md +261 -0
- package/docs/presence.md +129 -0
- package/docs/schemas.md +135 -0
- package/docs/streams.md +248 -0
- package/package.json +60 -0
- package/scripts/prepare.mjs +50 -0
- package/src/api-fetch.ts +127 -0
- package/src/api-headers.ts +28 -0
- package/src/auth-flow.ts +125 -0
- package/src/client.ts +1457 -0
- package/src/doc-url-sync.ts +183 -0
- package/src/document-branches.ts +124 -0
- package/src/document-checkpoints.ts +135 -0
- package/src/document-commands.ts +651 -0
- package/src/document-signals.ts +234 -0
- package/src/document-streams.ts +132 -0
- package/src/document-thumbnail-capture.ts +191 -0
- package/src/documents-namespace.ts +516 -0
- package/src/documents.ts +2426 -0
- package/src/emitter.ts +129 -0
- package/src/errors.ts +45 -0
- package/src/export-guest.ts +282 -0
- package/src/fetch-retry.ts +119 -0
- package/src/guest-link.ts +14 -0
- package/src/handles.ts +1731 -0
- package/src/hosted-runtime.ts +109 -0
- package/src/hosted-ui.ts +1020 -0
- package/src/importer.ts +89 -0
- package/src/index.ts +267 -0
- package/src/init-keys.ts +15 -0
- package/src/internal.ts +23 -0
- package/src/jwt.ts +18 -0
- package/src/launch-context.ts +43 -0
- package/src/login-hint.ts +60 -0
- package/src/mutate-batching.ts +63 -0
- package/src/open-document.ts +2869 -0
- package/src/page-title.ts +33 -0
- package/src/perf.ts +46 -0
- package/src/presence.ts +471 -0
- package/src/record-paging.ts +270 -0
- package/src/schema-conflict-dialog.ts +176 -0
- package/src/selection-persistence.ts +98 -0
- package/src/serializer.ts +15 -0
- package/src/session-persistence.ts +69 -0
- package/src/session-toolbar.ts +1457 -0
- package/src/session.ts +1262 -0
- package/src/tail-ops.ts +63 -0
- package/src/topic-lifecycle.ts +217 -0
- package/src/types.ts +75 -0
- package/src/undo.ts +125 -0
- package/src/version-bar.ts +221 -0
- package/src/version.ts +8 -0
- package/src/vite.ts +49 -0
- package/src/ws-client.ts +678 -0
- 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
|
+
}
|
package/src/presence.ts
ADDED
|
@@ -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
|
+
}
|