@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
package/src/tail-ops.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Tail ops arrive in one of two shapes and are used in only one.
|
|
2
|
+
//
|
|
3
|
+
// An op that fit its durable row carries its bytes inline. One that
|
|
4
|
+
// spilled to storage carries a short-lived signed URL instead, because a
|
|
5
|
+
// join response holding several megabytes of base64 is a response the
|
|
6
|
+
// server cannot build. Both mean the same thing to a reader: bytes to
|
|
7
|
+
// apply, in seq order.
|
|
8
|
+
//
|
|
9
|
+
// Resolving at the parse boundary is what keeps that difference from
|
|
10
|
+
// spreading. Every path that folds a tail in (a cold open, a reconnect,
|
|
11
|
+
// a version preview) then works on inline blobs exactly as before, and
|
|
12
|
+
// none of them has to be async to do it.
|
|
13
|
+
|
|
14
|
+
import { BrassError } from './errors.js';
|
|
15
|
+
|
|
16
|
+
/** A tail op as the server sends it. */
|
|
17
|
+
export type WireTailOp =
|
|
18
|
+
| { seq: number; op_blob_b64: string }
|
|
19
|
+
| { seq: number; op_blob_url: string };
|
|
20
|
+
|
|
21
|
+
/** A tail op with its bytes in hand. */
|
|
22
|
+
export interface ResolvedTailOp {
|
|
23
|
+
seq: number;
|
|
24
|
+
op_blob_b64: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isUrlOp(op: WireTailOp): op is { seq: number; op_blob_url: string } {
|
|
28
|
+
return typeof (op as { op_blob_url?: unknown }).op_blob_url === 'string';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Fetch any tail op whose bytes live behind a signed URL, leaving inline
|
|
33
|
+
* ops untouched, and return the tail in its original order.
|
|
34
|
+
*
|
|
35
|
+
* Ordering matters and concurrency does not disturb it: the fetches run
|
|
36
|
+
* together but each result lands back at its own index, so the caller
|
|
37
|
+
* still applies ops in ascending seq.
|
|
38
|
+
*
|
|
39
|
+
* A failed fetch throws rather than dropping the op. Skipping one would
|
|
40
|
+
* leave the reader a document missing an edit it will never be sent
|
|
41
|
+
* again, which reads as data loss; failing the join sends it back through
|
|
42
|
+
* a path that can succeed.
|
|
43
|
+
*/
|
|
44
|
+
export async function resolveTailOps(
|
|
45
|
+
tailOps: readonly WireTailOp[],
|
|
46
|
+
fetchImpl: typeof fetch = fetch,
|
|
47
|
+
): Promise<ResolvedTailOp[]> {
|
|
48
|
+
if (!tailOps.some(isUrlOp)) {
|
|
49
|
+
return tailOps as ResolvedTailOp[];
|
|
50
|
+
}
|
|
51
|
+
return Promise.all(
|
|
52
|
+
tailOps.map(async (op) => {
|
|
53
|
+
if (!isUrlOp(op)) return op;
|
|
54
|
+
// The URL is pre-signed, so it carries no Brass headers: adding one
|
|
55
|
+
// would fall outside the signature and be refused.
|
|
56
|
+
const response = await fetchImpl(op.op_blob_url, { method: 'GET' });
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
throw new BrassError('Tail op fetch failed', { status: response.status });
|
|
59
|
+
}
|
|
60
|
+
return { seq: op.seq, op_blob_b64: await response.text() };
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// Pure per-topic subscription lifecycle for `BrassWsClient`. The client
|
|
2
|
+
// owns the socket, timers, and frame queues; every decision about a
|
|
3
|
+
// topic's subscription (what to send, when to hold or drop queued frames,
|
|
4
|
+
// when to surface an error) comes from `step`'s transition table.
|
|
5
|
+
//
|
|
6
|
+
// Phases:
|
|
7
|
+
// - `idle`: not wanted. Errors and acks for it are ignored.
|
|
8
|
+
// - `subscribing`: wanted, first subscribe on this connection unacked
|
|
9
|
+
// (or not yet sendable). Topic-scoped frames are held until the ack.
|
|
10
|
+
// - `established`: acked; frames flow.
|
|
11
|
+
// - `retrying`: the one silent re-subscribe this connection grants is in
|
|
12
|
+
// flight after a mid-stream `not_subscribed` eviction. Further
|
|
13
|
+
// `not_subscribed` errors describe frames that predate the retry and
|
|
14
|
+
// are swallowed; any other reason is the retry's own rejection.
|
|
15
|
+
// - `tornDown`: rejected or evicted with the retry spent. Terminal until
|
|
16
|
+
// a fresh `user_subscribe` or the next socket transition prunes it.
|
|
17
|
+
// `retrySpent` survives into a same-connection re-subscribe so the
|
|
18
|
+
// retry stays one-per-topic-per-connection.
|
|
19
|
+
//
|
|
20
|
+
// Invariant: every transition into `tornDown` emits `send_unsubscribe`,
|
|
21
|
+
// so no server-side subscription row can outlive the client-side state
|
|
22
|
+
// (an orphan row would have its `last_seen` kept fresh by the heartbeat,
|
|
23
|
+
// making it immune to the server's stale reaper).
|
|
24
|
+
|
|
25
|
+
export type TopicPhase =
|
|
26
|
+
| 'idle'
|
|
27
|
+
| 'subscribing'
|
|
28
|
+
| 'established'
|
|
29
|
+
| 'retrying'
|
|
30
|
+
| 'tornDown';
|
|
31
|
+
|
|
32
|
+
export interface TopicState {
|
|
33
|
+
phase: TopicPhase;
|
|
34
|
+
// True once this connection's one silent re-subscribe is consumed.
|
|
35
|
+
// Reset by `socket_open`.
|
|
36
|
+
retrySpent: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const IDLE_TOPIC: TopicState = { phase: 'idle', retrySpent: false };
|
|
40
|
+
|
|
41
|
+
export type TopicEvent =
|
|
42
|
+
| { type: 'user_subscribe' }
|
|
43
|
+
| { type: 'user_unsubscribe' }
|
|
44
|
+
| { type: 'socket_open' }
|
|
45
|
+
| { type: 'socket_closed' }
|
|
46
|
+
| { type: 'subscribed_ack' }
|
|
47
|
+
| { type: 'error'; reason: string };
|
|
48
|
+
|
|
49
|
+
export type TopicEffect =
|
|
50
|
+
| { kind: 'send_subscribe' }
|
|
51
|
+
| { kind: 'send_unsubscribe' }
|
|
52
|
+
// Flush the frames held for the unacked subscribe, in order.
|
|
53
|
+
| { kind: 'flush_held' }
|
|
54
|
+
| { kind: 'drop_held' }
|
|
55
|
+
// Detach the topic's callbacks: further server frames must not reach
|
|
56
|
+
// them and the user's unsubscribe becomes a no-op.
|
|
57
|
+
| { kind: 'drop_listeners' }
|
|
58
|
+
| { kind: 'fire_error'; reason: string };
|
|
59
|
+
|
|
60
|
+
export interface StepResult {
|
|
61
|
+
state: TopicState;
|
|
62
|
+
effects: TopicEffect[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// True while topic-scoped frames must be held back: sending before the
|
|
66
|
+
// `subscribed` ack races the server's SUB# row write and draws a spurious
|
|
67
|
+
// `forbidden`.
|
|
68
|
+
export function holdsFrames(state: TopicState): boolean {
|
|
69
|
+
return state.phase === 'subscribing' || state.phase === 'retrying';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const SEND_SUBSCRIBE: TopicEffect = { kind: 'send_subscribe' };
|
|
73
|
+
const FLUSH_HELD: TopicEffect = { kind: 'flush_held' };
|
|
74
|
+
const DROP_HELD: TopicEffect = { kind: 'drop_held' };
|
|
75
|
+
|
|
76
|
+
// Rejection / spent-retry teardown. `send_unsubscribe` releases any
|
|
77
|
+
// server-side row a completed retry may have re-created (the invariant in
|
|
78
|
+
// the header); `unsubscribe` is idempotent server-side, so sending it when
|
|
79
|
+
// no row exists is safe.
|
|
80
|
+
function tearDown(state: TopicState, reason: string): StepResult {
|
|
81
|
+
return {
|
|
82
|
+
state: { phase: 'tornDown', retrySpent: state.retrySpent },
|
|
83
|
+
effects: [
|
|
84
|
+
DROP_HELD,
|
|
85
|
+
{ kind: 'drop_listeners' },
|
|
86
|
+
{ kind: 'send_unsubscribe' },
|
|
87
|
+
{ kind: 'fire_error', reason },
|
|
88
|
+
],
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// A (re)opened socket grants a fresh retry and a fresh subscribe.
|
|
93
|
+
function reopened(): StepResult {
|
|
94
|
+
return {
|
|
95
|
+
state: { phase: 'subscribing', retrySpent: false },
|
|
96
|
+
effects: [SEND_SUBSCRIBE],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function unsubscribed(): StepResult {
|
|
101
|
+
return {
|
|
102
|
+
state: IDLE_TOPIC,
|
|
103
|
+
effects: [DROP_HELD, { kind: 'send_unsubscribe' }],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function step(state: TopicState, event: TopicEvent): StepResult {
|
|
108
|
+
switch (state.phase) {
|
|
109
|
+
case 'idle':
|
|
110
|
+
if (event.type === 'user_subscribe') {
|
|
111
|
+
return {
|
|
112
|
+
state: { phase: 'subscribing', retrySpent: false },
|
|
113
|
+
effects: [SEND_SUBSCRIBE],
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return { state, effects: [] };
|
|
117
|
+
|
|
118
|
+
case 'subscribing':
|
|
119
|
+
switch (event.type) {
|
|
120
|
+
case 'subscribed_ack':
|
|
121
|
+
return {
|
|
122
|
+
state: { ...state, phase: 'established' },
|
|
123
|
+
effects: [FLUSH_HELD],
|
|
124
|
+
};
|
|
125
|
+
case 'error':
|
|
126
|
+
// Never established on this connection: the rejection is final.
|
|
127
|
+
return tearDown(state, event.reason);
|
|
128
|
+
case 'socket_open':
|
|
129
|
+
return reopened();
|
|
130
|
+
case 'socket_closed':
|
|
131
|
+
return { state, effects: [DROP_HELD] };
|
|
132
|
+
case 'user_unsubscribe':
|
|
133
|
+
return unsubscribed();
|
|
134
|
+
case 'user_subscribe':
|
|
135
|
+
return { state, effects: [] };
|
|
136
|
+
}
|
|
137
|
+
break;
|
|
138
|
+
|
|
139
|
+
case 'established':
|
|
140
|
+
switch (event.type) {
|
|
141
|
+
case 'error':
|
|
142
|
+
// A mid-stream `not_subscribed` is an eviction (the server
|
|
143
|
+
// dropped the subscription row while this client stayed alive,
|
|
144
|
+
// e.g. a presence reap that raced a live-but-quiet connection),
|
|
145
|
+
// not a rejection: recover silently with one re-subscribe per
|
|
146
|
+
// connection, keeping the listeners and held-frame queue. Any
|
|
147
|
+
// other reason is a genuine refusal (role, scope, membership)
|
|
148
|
+
// that a re-subscribe would just repeat.
|
|
149
|
+
if (event.reason === 'not_subscribed' && !state.retrySpent) {
|
|
150
|
+
return {
|
|
151
|
+
state: { phase: 'retrying', retrySpent: true },
|
|
152
|
+
effects: [SEND_SUBSCRIBE],
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return tearDown(state, event.reason);
|
|
156
|
+
case 'subscribed_ack':
|
|
157
|
+
return { state, effects: [FLUSH_HELD] };
|
|
158
|
+
case 'socket_open':
|
|
159
|
+
return reopened();
|
|
160
|
+
case 'socket_closed':
|
|
161
|
+
return { state: { ...state, phase: 'subscribing' }, effects: [DROP_HELD] };
|
|
162
|
+
case 'user_unsubscribe':
|
|
163
|
+
return unsubscribed();
|
|
164
|
+
case 'user_subscribe':
|
|
165
|
+
return { state, effects: [] };
|
|
166
|
+
}
|
|
167
|
+
break;
|
|
168
|
+
|
|
169
|
+
case 'retrying':
|
|
170
|
+
switch (event.type) {
|
|
171
|
+
case 'error':
|
|
172
|
+
// An eviction draws errors for EVERY frame in flight against the
|
|
173
|
+
// reaped row; the trailing ones land while the retry's subscribe
|
|
174
|
+
// is unacked and describe frames that predate it, so the retry
|
|
175
|
+
// supersedes them. A non-`not_subscribed` reason in this window
|
|
176
|
+
// is the retry's own rejection.
|
|
177
|
+
if (event.reason === 'not_subscribed') {
|
|
178
|
+
return { state, effects: [] };
|
|
179
|
+
}
|
|
180
|
+
return tearDown(state, event.reason);
|
|
181
|
+
case 'subscribed_ack':
|
|
182
|
+
return {
|
|
183
|
+
state: { ...state, phase: 'established' },
|
|
184
|
+
effects: [FLUSH_HELD],
|
|
185
|
+
};
|
|
186
|
+
case 'socket_open':
|
|
187
|
+
return reopened();
|
|
188
|
+
case 'socket_closed':
|
|
189
|
+
return { state: { ...state, phase: 'subscribing' }, effects: [DROP_HELD] };
|
|
190
|
+
case 'user_unsubscribe':
|
|
191
|
+
return unsubscribed();
|
|
192
|
+
case 'user_subscribe':
|
|
193
|
+
return { state, effects: [] };
|
|
194
|
+
}
|
|
195
|
+
break;
|
|
196
|
+
|
|
197
|
+
case 'tornDown':
|
|
198
|
+
switch (event.type) {
|
|
199
|
+
case 'user_subscribe':
|
|
200
|
+
// Same-connection re-subscribe: a spent retry stays spent, so
|
|
201
|
+
// the fresh subscribe rides the retrying phase's error handling.
|
|
202
|
+
return {
|
|
203
|
+
state: {
|
|
204
|
+
phase: state.retrySpent ? 'retrying' : 'subscribing',
|
|
205
|
+
retrySpent: state.retrySpent,
|
|
206
|
+
},
|
|
207
|
+
effects: [SEND_SUBSCRIBE],
|
|
208
|
+
};
|
|
209
|
+
case 'socket_open':
|
|
210
|
+
case 'socket_closed':
|
|
211
|
+
return { state: IDLE_TOPIC, effects: [] };
|
|
212
|
+
default:
|
|
213
|
+
return { state, effects: [] };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return { state, effects: [] };
|
|
217
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export interface BrassUser {
|
|
2
|
+
sub: string;
|
|
3
|
+
email?: string;
|
|
4
|
+
display_name?: string;
|
|
5
|
+
avatar_url?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface BrassInitOptions {
|
|
9
|
+
// With nothing passed, the SDK resolves all four production origins.
|
|
10
|
+
// Naming any one of the four below opts out of that entirely, so a
|
|
11
|
+
// stack is addressed by its own origins rather than by a mix.
|
|
12
|
+
authBaseUrl?: string;
|
|
13
|
+
// Origin for the Brass data API (documents, organizations, users). The
|
|
14
|
+
// auth surface uses `authBaseUrl`.
|
|
15
|
+
apiBaseUrl?: string;
|
|
16
|
+
// WebSocket endpoint for document op and presence subscriptions. On
|
|
17
|
+
// its own hostname, so it cannot be derived from `apiBaseUrl`. Passing
|
|
18
|
+
// any other `*BaseUrl` leaves it unset on purpose: a production ws
|
|
19
|
+
// origin beside a hand-overridden HTTP origin would connect to a
|
|
20
|
+
// different stack and silently drop events, whereas unset fails loudly
|
|
21
|
+
// on `subscribe`.
|
|
22
|
+
wsBaseUrl?: string;
|
|
23
|
+
// Origin the Brass-hosted UI pages are served from. The hosted-UI
|
|
24
|
+
// launchers load `${dashboardBaseUrl}/embed/*` in a modal iframe.
|
|
25
|
+
dashboardBaseUrl?: string;
|
|
26
|
+
// Pin the hosted-UI modal + embed page to a theme. Defaults to
|
|
27
|
+
// `'light'`; `'auto'` follows the OS preference. An app that forces
|
|
28
|
+
// its own theme should pass the matching value so the modal doesn't
|
|
29
|
+
// mismatch the page that opened it.
|
|
30
|
+
hostedUiTheme?: 'light' | 'dark' | 'auto';
|
|
31
|
+
// Open a document even when this app's declared schema cannot read it
|
|
32
|
+
// (default off). Off, the platform refuses such an open and the SDK asks
|
|
33
|
+
// the person whether to go ahead, so a bookmark or a reload cannot open
|
|
34
|
+
// one silently. On, every open carries the answer already, which is what
|
|
35
|
+
// an app driving opens with nobody watching needs.
|
|
36
|
+
openIncompatibleDocuments?: boolean;
|
|
37
|
+
// Mirror the active document selection to the page URL (default on):
|
|
38
|
+
// writes the doc id (and non-default branch) using `pushState` for a
|
|
39
|
+
// genuine open / branch switch and `replaceState` to pin the boot
|
|
40
|
+
// selection, and restores from the URL on boot and `popstate`. Read
|
|
41
|
+
// the id directly via `Brass.docIdFromUrl()`. Opt out with `false`
|
|
42
|
+
// for an app that owns its URL state (e.g. its own router).
|
|
43
|
+
syncDocToUrl?: boolean;
|
|
44
|
+
// Query-parameter name for the active doc id (default `'doc'`). Set to
|
|
45
|
+
// disambiguate when the app's URL already uses `doc`.
|
|
46
|
+
docUrlParam?: string;
|
|
47
|
+
// Query-parameter name for a non-default branch (default `'branch'`).
|
|
48
|
+
// Present only when the active document is on a non-default branch.
|
|
49
|
+
branchUrlParam?: string;
|
|
50
|
+
// Set `document.title` to the active document's name (default on), so
|
|
51
|
+
// history entries are distinguishable, restoring the original title
|
|
52
|
+
// when no document is open. While a document is open the title is
|
|
53
|
+
// suffixed with the app name (`Doc name · App name`), taken from the
|
|
54
|
+
// page's own `<title>` at the first sync. Opt out with `false` for an
|
|
55
|
+
// app that manages its own title.
|
|
56
|
+
syncDocToTitle?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// `'unknown'` is the bootstrap window before `Brass.init` settles,
|
|
60
|
+
// distinct from `'signed-out'` (init settled, no user) so an app can
|
|
61
|
+
// render a neutral state instead of flashing its sign-in CTA at a user
|
|
62
|
+
// who turns out to be signed in. Then tracks every sign-in / sign-out.
|
|
63
|
+
// `'guest'` is the anonymous-link case: the page was opened via a
|
|
64
|
+
// public-link token (`?brass_link=`), so there is no signed-in user but
|
|
65
|
+
// the session still opens a read-only document. An app renders its
|
|
66
|
+
// working surface for `'signed-in'` OR `'guest'` (read-only), and its
|
|
67
|
+
// sign-in CTA only for `'signed-out'`.
|
|
68
|
+
export type AuthStatus = 'unknown' | 'signed-in' | 'signed-out' | 'guest';
|
|
69
|
+
|
|
70
|
+
export type UserChangeListener = (user: BrassUser | null) => void;
|
|
71
|
+
|
|
72
|
+
// Fired when Brass.init fails (bad / missing app_id, etc.). Register
|
|
73
|
+
// before init to get visibility into failures that an init wrapped
|
|
74
|
+
// without a `.catch()` would otherwise swallow silently.
|
|
75
|
+
export type ErrorListener = (error: Error) => void;
|
package/src/undo.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Per-Document undo/redo, backed by Loro's UndoManager: undo is
|
|
2
|
+
// local-peer-scoped (reverts only this peer's ops), group bracketing collapses
|
|
3
|
+
// same-stroke edits into one entry, and remote-import bookkeeping keeps the
|
|
4
|
+
// redo stack honest across other peers' edits.
|
|
5
|
+
import { UndoManager, type LoroDoc } from 'loro-crdt';
|
|
6
|
+
|
|
7
|
+
// Loro's time-based merge is permanently disabled. The undo boundary here is
|
|
8
|
+
// the checkpoint group bracket (`sealStep`), and the two are incompatible: a
|
|
9
|
+
// nonzero merge interval merges adjacent changes by *timestamp*, and that
|
|
10
|
+
// merge reaches ACROSS a `groupEnd()` / `groupStart()` seal, collapsing two
|
|
11
|
+
// separately-sealed steps into one. That silently violates the "every
|
|
12
|
+
// checkpoint is an undo boundary" contract (one Ctrl-Z reverts more than the
|
|
13
|
+
// last operation and the rendered state jumps past where the user expected).
|
|
14
|
+
// Same-stroke collapsing is still handled, by the open group not the clock:
|
|
15
|
+
// every commit between two seals brackets into one entry. Apps tune
|
|
16
|
+
// granularity via `setAutoCheckpointIntervalMs` and `createCheckpoint`.
|
|
17
|
+
const UNDO_MERGE_INTERVAL_MS = 0;
|
|
18
|
+
|
|
19
|
+
// Cap on retained undo steps (Loro's default is 100). 200 at typical AEC
|
|
20
|
+
// entity sizes is a few hundred KB, well below anything that matters.
|
|
21
|
+
const DEFAULT_MAX_UNDO_STEPS = 200;
|
|
22
|
+
|
|
23
|
+
export interface UndoControllerOptions {
|
|
24
|
+
// Cap on retained undo steps. Older steps drop off the bottom of the
|
|
25
|
+
// stack as new ones land.
|
|
26
|
+
maxUndoSteps?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Thin wrapper around Loro's UndoManager exposing only what the Document
|
|
30
|
+
// needs. Owned by the Document; not part of the public SDK surface.
|
|
31
|
+
export class UndoController {
|
|
32
|
+
private readonly _mgr: UndoManager;
|
|
33
|
+
// Whether an implicit group is open. The Document keeps one open between
|
|
34
|
+
// checkpoints so `createCheckpoint` is THE undo boundary: every commit
|
|
35
|
+
// between two checkpoints collapses into one step. `sealStep` closes and
|
|
36
|
+
// reopens the group, the only way to force a boundary into Loro's stack
|
|
37
|
+
// (Loro has no "force boundary here" primitive). This is a hard boundary
|
|
38
|
+
// only because the merge interval is disabled (see above).
|
|
39
|
+
private _groupOpen = false;
|
|
40
|
+
|
|
41
|
+
constructor(loroDoc: LoroDoc, options?: UndoControllerOptions) {
|
|
42
|
+
this._mgr = new UndoManager(loroDoc, {
|
|
43
|
+
mergeInterval: UNDO_MERGE_INTERVAL_MS,
|
|
44
|
+
maxUndoSteps: options?.maxUndoSteps ?? DEFAULT_MAX_UNDO_STEPS,
|
|
45
|
+
});
|
|
46
|
+
// Open the implicit group so commits before the first `createCheckpoint`
|
|
47
|
+
// still bracket as one undo step.
|
|
48
|
+
this._mgr.groupStart();
|
|
49
|
+
this._groupOpen = true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// undo/redo run OUTSIDE the implicit group: Loro groups every commit while a
|
|
53
|
+
// group is open, INCLUDING the one a `redo()` / `undo()` itself produces, so
|
|
54
|
+
// running them inside would fold the reapplied step into the group and the
|
|
55
|
+
// next sealed edit would merge with it (one Ctrl-Z reverts both). Closing
|
|
56
|
+
// first also makes unsealed pending commits their own boundary.
|
|
57
|
+
undo(): boolean {
|
|
58
|
+
return this.outsideGroup(() => this._mgr.undo());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
redo(): boolean {
|
|
62
|
+
return this.outsideGroup(() => this._mgr.redo());
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private outsideGroup(op: () => boolean): boolean {
|
|
66
|
+
if (this._groupOpen) {
|
|
67
|
+
this._mgr.groupEnd();
|
|
68
|
+
this._groupOpen = false;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
return op();
|
|
72
|
+
} finally {
|
|
73
|
+
this._mgr.groupStart();
|
|
74
|
+
this._groupOpen = true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
canUndo(): boolean {
|
|
79
|
+
return this._mgr.canUndo();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
canRedo(): boolean {
|
|
83
|
+
return this._mgr.canRedo();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Seal the current undo step: close the implicit group (commits since the
|
|
87
|
+
// previous seal collapse into one entry) and open a fresh one.
|
|
88
|
+
// `createCheckpoint` and the auto-checkpoint scheduler call this so every
|
|
89
|
+
// checkpoint is a Ctrl-Z boundary. Idempotent: calling twice with no
|
|
90
|
+
// intervening commit is safe (Loro collapses the empty group).
|
|
91
|
+
sealStep(): void {
|
|
92
|
+
if (this._groupOpen) {
|
|
93
|
+
this._mgr.groupEnd();
|
|
94
|
+
this._groupOpen = false;
|
|
95
|
+
}
|
|
96
|
+
this._mgr.groupStart();
|
|
97
|
+
this._groupOpen = true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Drop both stacks. Called by `_applySnapshot`: the prior entries reference a
|
|
101
|
+
// state the new snapshot superseded.
|
|
102
|
+
clear(): void {
|
|
103
|
+
// Close the open group first so `clear()` sees a clean state (defensive;
|
|
104
|
+
// Loro's `clear` drops both stacks regardless).
|
|
105
|
+
if (this._groupOpen) {
|
|
106
|
+
this._mgr.groupEnd();
|
|
107
|
+
this._groupOpen = false;
|
|
108
|
+
}
|
|
109
|
+
this._mgr.clear();
|
|
110
|
+
this._mgr.groupStart();
|
|
111
|
+
this._groupOpen = true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
free(): void {
|
|
115
|
+
if (this._groupOpen) {
|
|
116
|
+
try {
|
|
117
|
+
this._mgr.groupEnd();
|
|
118
|
+
} catch {
|
|
119
|
+
// Best-effort; the underlying manager is about to free.
|
|
120
|
+
}
|
|
121
|
+
this._groupOpen = false;
|
|
122
|
+
}
|
|
123
|
+
this._mgr.free();
|
|
124
|
+
}
|
|
125
|
+
}
|