@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/emitter.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Internal listener-fanout primitive. One home for the pattern the SDK's
|
|
2
|
+
// subscribe surfaces share: a `Set` of callbacks, an add/delete unsubscribe,
|
|
3
|
+
// an emit loop, and the small per-site knobs (deliver-on-attach, retain the
|
|
4
|
+
// last value, drop duplicate emits, and how a listener's throw is handled).
|
|
5
|
+
// Not exported from `index.ts`: every caller is SDK-internal (the document
|
|
6
|
+
// lifecycle signals, the Document subscriber set, the client's error
|
|
7
|
+
// listeners).
|
|
8
|
+
|
|
9
|
+
export interface EmitterOptions<T> {
|
|
10
|
+
// Deliver the retained value to a newly-attached listener, in a microtask,
|
|
11
|
+
// so a listener wired after the value arrived still sees it once. The
|
|
12
|
+
// microtask keeps the callsite ordering identical whether the value is
|
|
13
|
+
// already present or arrives later (the unsubscribe is returned before the
|
|
14
|
+
// deferred fire runs). Needs a `retain` policy to have a value to deliver.
|
|
15
|
+
fireOnAttach?: boolean;
|
|
16
|
+
// Whether the last emitted value is cached:
|
|
17
|
+
// 'latest' - cache it; a `fireOnAttach` attach delivers it, and it
|
|
18
|
+
// survives `clear()`.
|
|
19
|
+
// 'oneShot' - cache it and stop after the first emit: later emits are
|
|
20
|
+
// dropped, the listener set is cleared once it fires, and a
|
|
21
|
+
// later attach delivers the cached value once without
|
|
22
|
+
// retaining the listener.
|
|
23
|
+
retain?: 'latest' | 'oneShot';
|
|
24
|
+
// Drop an emit whose value equals the retained one (consulted only once a
|
|
25
|
+
// value is retained), so a source that re-emits on unrelated changes does
|
|
26
|
+
// not wake listeners when nothing they read moved.
|
|
27
|
+
equals?: (value: T, retained: T) => boolean;
|
|
28
|
+
// Called with a listener's thrown error instead of letting it propagate.
|
|
29
|
+
// Omit to let a throw propagate and abort the emit loop. The three policies
|
|
30
|
+
// the SDK uses: swallow (`onError: () => {}`), log and continue
|
|
31
|
+
// (`onError: (e) => console.error(...)`), and propagate (omit).
|
|
32
|
+
onError?: (err: unknown) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface Emitter<T> {
|
|
36
|
+
// Register a listener; the returned function detaches it. Idempotent detach.
|
|
37
|
+
on(cb: (value: T) => void): () => void;
|
|
38
|
+
// Fanout `value` to every listener, applying the retain / dedup / one-shot
|
|
39
|
+
// policy and the error handling.
|
|
40
|
+
emit(value: T): void;
|
|
41
|
+
// Drop all listeners. The retained value (if any) is kept, so a later attach
|
|
42
|
+
// still delivers it: this matches the signals' `clearListeners`, where a
|
|
43
|
+
// suspend / close drops app callbacks but a resumed open still reads the
|
|
44
|
+
// last status.
|
|
45
|
+
clear(): void;
|
|
46
|
+
// The last emitted value, or `undefined` if none has been emitted (or the
|
|
47
|
+
// emitter does not retain). None of the SDK's retained types carry
|
|
48
|
+
// `undefined` as a meaningful value, so a caller coalesces with `?? null`.
|
|
49
|
+
readonly retained: T | undefined;
|
|
50
|
+
readonly size: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function createEmitter<T>(opts: EmitterOptions<T> = {}): Emitter<T> {
|
|
54
|
+
const { fireOnAttach = false, retain, equals, onError } = opts;
|
|
55
|
+
const listeners = new Set<(value: T) => void>();
|
|
56
|
+
const oneShot = retain === 'oneShot';
|
|
57
|
+
const retains = retain !== undefined;
|
|
58
|
+
let hasValue = false;
|
|
59
|
+
let retained: T | undefined;
|
|
60
|
+
|
|
61
|
+
const dispatch = (cb: (value: T) => void, value: T): void => {
|
|
62
|
+
if (onError) {
|
|
63
|
+
try {
|
|
64
|
+
cb(value);
|
|
65
|
+
} catch (err) {
|
|
66
|
+
onError(err);
|
|
67
|
+
}
|
|
68
|
+
} else {
|
|
69
|
+
cb(value);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
on(cb) {
|
|
75
|
+
if (fireOnAttach && hasValue) {
|
|
76
|
+
if (oneShot) {
|
|
77
|
+
// A one-shot has already fired for everyone: a late attach gets the
|
|
78
|
+
// cached value once (in a microtask, so the unsubscribe returns
|
|
79
|
+
// first) and is not retained. Terminal, so the returned unsubscribe
|
|
80
|
+
// is a no-op (there is no ongoing subscription to cancel).
|
|
81
|
+
const value = retained as T;
|
|
82
|
+
queueMicrotask(() => {
|
|
83
|
+
dispatch(cb, value);
|
|
84
|
+
});
|
|
85
|
+
return () => {};
|
|
86
|
+
}
|
|
87
|
+
// Deliver the retained value on attach, in a microtask so the
|
|
88
|
+
// unsubscribe returns first. Two guards keep the deferred fire honest:
|
|
89
|
+
// skip it if the listener detached in the meantime (a synchronous
|
|
90
|
+
// unsubscribe, e.g. a framework effect's immediate cleanup, must not
|
|
91
|
+
// still receive a value), and deliver the CURRENT retained value
|
|
92
|
+
// rather than a snapshot taken at attach time, so a value emitted
|
|
93
|
+
// between attach and flush is not clobbered by a stale one.
|
|
94
|
+
listeners.add(cb);
|
|
95
|
+
queueMicrotask(() => {
|
|
96
|
+
if (!listeners.has(cb)) return;
|
|
97
|
+
dispatch(cb, retained as T);
|
|
98
|
+
});
|
|
99
|
+
return () => {
|
|
100
|
+
listeners.delete(cb);
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
listeners.add(cb);
|
|
104
|
+
return () => {
|
|
105
|
+
listeners.delete(cb);
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
emit(value) {
|
|
109
|
+
// A one-shot's first emit is terminal, so drop any later one.
|
|
110
|
+
if (oneShot && hasValue) return;
|
|
111
|
+
if (equals && hasValue && equals(value, retained as T)) return;
|
|
112
|
+
if (retains) {
|
|
113
|
+
retained = value;
|
|
114
|
+
hasValue = true;
|
|
115
|
+
}
|
|
116
|
+
for (const cb of listeners) dispatch(cb, value);
|
|
117
|
+
if (oneShot) listeners.clear();
|
|
118
|
+
},
|
|
119
|
+
clear() {
|
|
120
|
+
listeners.clear();
|
|
121
|
+
},
|
|
122
|
+
get retained() {
|
|
123
|
+
return retained;
|
|
124
|
+
},
|
|
125
|
+
get size() {
|
|
126
|
+
return listeners.size;
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// The single error type every `@brass-build/client` call rejects with.
|
|
2
|
+
//
|
|
3
|
+
// One catch type, two mutually-exclusive discriminators: branch on
|
|
4
|
+
// `.status` for the HTTP surface, `.code` for the hosted-UI launchers (an
|
|
5
|
+
// HTTP rejection has no `code`, a launcher rejection no `status`).
|
|
6
|
+
// - `iframe_unavailable`: this browser / app context can't host the
|
|
7
|
+
// iframe (an app surfaces this as "open in a new tab").
|
|
8
|
+
// - `embed_error`: the embed page cannot proceed (e.g. not signed in).
|
|
9
|
+
// - `not_in_browser`: a non-DOM runtime.
|
|
10
|
+
// - `app_not_installed`: the server rejected the app's action because
|
|
11
|
+
// the target organization has no install of it; `organizationId`
|
|
12
|
+
// names the org so the install-consent flow can be offered.
|
|
13
|
+
export type BrassErrorCode =
|
|
14
|
+
| 'not_in_browser'
|
|
15
|
+
| 'iframe_unavailable'
|
|
16
|
+
| 'embed_error'
|
|
17
|
+
| 'app_not_installed'
|
|
18
|
+
| 'schema_conflict';
|
|
19
|
+
|
|
20
|
+
export class BrassError extends Error {
|
|
21
|
+
override name = 'BrassError';
|
|
22
|
+
readonly status?: number;
|
|
23
|
+
readonly code?: BrassErrorCode;
|
|
24
|
+
readonly organizationId?: string;
|
|
25
|
+
/** Server-authored heading for a prompt this error drives, when the
|
|
26
|
+
* platform sends one. A client freezes into an app's bundle, so a string
|
|
27
|
+
* it holds itself can only be corrected by every app republishing. */
|
|
28
|
+
readonly title?: string;
|
|
29
|
+
|
|
30
|
+
constructor(
|
|
31
|
+
message: string,
|
|
32
|
+
init?: {
|
|
33
|
+
status?: number;
|
|
34
|
+
code?: BrassErrorCode;
|
|
35
|
+
organizationId?: string;
|
|
36
|
+
title?: string;
|
|
37
|
+
},
|
|
38
|
+
) {
|
|
39
|
+
super(message);
|
|
40
|
+
if (init?.status !== undefined) this.status = init.status;
|
|
41
|
+
if (init?.code !== undefined) this.code = init.code;
|
|
42
|
+
if (init?.organizationId !== undefined) this.organizationId = init.organizationId;
|
|
43
|
+
if (init?.title !== undefined) this.title = init.title;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// Export-guest: the app side of platform-initiated export when no app is
|
|
2
|
+
// already framing the document. The standalone dashboard and the
|
|
3
|
+
// drive-push path have no live app, so Brass loads the app's own bundle
|
|
4
|
+
// in a hidden iframe with the export-mode URL flag, hands it the
|
|
5
|
+
// document snapshot, and asks it to serialize. The roles invert from the
|
|
6
|
+
// usual hosted-UI case (see `hosted-ui.ts`): Brass is the framer (host)
|
|
7
|
+
// and the app's SDK is the guest.
|
|
8
|
+
//
|
|
9
|
+
// The guest never authenticates and never calls the API: it serializes
|
|
10
|
+
// the handed-in snapshot, so a partitioned auth cookie in the framed
|
|
11
|
+
// context is irrelevant. Reuses the `__brass_embed` envelope and the
|
|
12
|
+
// `export-request` / `export-result` pair with the direction flipped
|
|
13
|
+
// (framer -> guest carries the snapshot; guest -> framer the bytes).
|
|
14
|
+
|
|
15
|
+
import { Document } from './documents.js';
|
|
16
|
+
import type { DocumentStreamInfo } from './documents-namespace.js';
|
|
17
|
+
import type { StreamRuntime } from './document-streams.js';
|
|
18
|
+
import type { DocumentSerializer } from './serializer.js';
|
|
19
|
+
|
|
20
|
+
const ENVELOPE = '__brass_embed';
|
|
21
|
+
const PROTOCOL_VERSION = 1;
|
|
22
|
+
|
|
23
|
+
// The URL flag Brass appends when it frames an app purely to serialize.
|
|
24
|
+
// A normal app load never carries it, so a real user visit is untouched.
|
|
25
|
+
export const EXPORT_MODE_PARAM = '__brass_export';
|
|
26
|
+
|
|
27
|
+
// How long to wait for a late `registerSerializer` before answering
|
|
28
|
+
// `no_serializer`, so the framer falls back to the server export without
|
|
29
|
+
// waiting out its own longer timeout. Generous enough for an app doing
|
|
30
|
+
// async setup before it registers.
|
|
31
|
+
const SERIALIZER_WAIT_MS = 10_000;
|
|
32
|
+
|
|
33
|
+
// How long a single proxied stream fetch (manifest or line snapshot) waits
|
|
34
|
+
// on the framer before rejecting, so a serializer awaiting `doc.stream()`
|
|
35
|
+
// during a headless export cannot hang past the framer's own export budget.
|
|
36
|
+
const STREAM_REQUEST_TIMEOUT_MS = 20_000;
|
|
37
|
+
|
|
38
|
+
// Search string passed in so this is pure and testable.
|
|
39
|
+
export function isExportMode(search: string): boolean {
|
|
40
|
+
return new URLSearchParams(search).has(EXPORT_MODE_PARAM);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ExportGuestController {
|
|
44
|
+
// `Brass.registerSerializer` forwards here so a serializer registered
|
|
45
|
+
// after the snapshot arrives still completes the export.
|
|
46
|
+
setSerializer(serializer: DocumentSerializer): void;
|
|
47
|
+
dispose(): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface PendingRequest {
|
|
51
|
+
requestId: string;
|
|
52
|
+
// The document being serialized, so `doc.id` reads the same here as it
|
|
53
|
+
// does when the serializer runs against a live open. A serializer that
|
|
54
|
+
// writes the document id into its output is doing an ordinary thing, and
|
|
55
|
+
// it would otherwise get a real id on one export path and null on the
|
|
56
|
+
// other. Absent from an older framer's request, where it stays null.
|
|
57
|
+
docId: string | null;
|
|
58
|
+
// The sealed snapshot bytes, or null for a doc with no snapshot yet.
|
|
59
|
+
snapshot: Uint8Array | null;
|
|
60
|
+
// Tail op blobs since the snapshot, ordered by seq. Applying them over
|
|
61
|
+
// the snapshot matches what a live open sees, so the export reflects
|
|
62
|
+
// the user's latest edits, not the last sealed snapshot.
|
|
63
|
+
tailOps: Uint8Array[];
|
|
64
|
+
hostOrigin: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Start listening as a framed export guest. Called from `Brass.init`
|
|
68
|
+
// only when `isExportMode(location.search)` is true.
|
|
69
|
+
export function startExportGuest(): ExportGuestController {
|
|
70
|
+
let serializer: DocumentSerializer | null = null;
|
|
71
|
+
let pending: PendingRequest | null = null;
|
|
72
|
+
let done = false;
|
|
73
|
+
let waitTimer: ReturnType<typeof setTimeout> | undefined;
|
|
74
|
+
|
|
75
|
+
// In-flight proxied stream fetches, keyed by requestId. A serializer that
|
|
76
|
+
// opens a partitioned document's streams (`doc.stream(name)`) resolves
|
|
77
|
+
// them over the bridge: the framer has the auth to fetch each stream's
|
|
78
|
+
// manifest + line snapshot, the detached guest does not.
|
|
79
|
+
const pendingStreamFetches = new Map<
|
|
80
|
+
string,
|
|
81
|
+
{
|
|
82
|
+
resolve: (value: unknown) => void;
|
|
83
|
+
reject: (error: Error) => void;
|
|
84
|
+
timer: ReturnType<typeof setTimeout>;
|
|
85
|
+
}
|
|
86
|
+
>();
|
|
87
|
+
let streamRequestSeq = 0;
|
|
88
|
+
|
|
89
|
+
// Ask the framer for stream data over the bridge and await its reply.
|
|
90
|
+
// Rejects on timeout so a serializer cannot hang past the framer's export
|
|
91
|
+
// budget (the framer then falls back / reports the failure).
|
|
92
|
+
function fetchFromFramer(
|
|
93
|
+
hostOrigin: string,
|
|
94
|
+
type: 'stream-list-request' | 'stream-snapshot-request',
|
|
95
|
+
extra: Record<string, unknown>,
|
|
96
|
+
): Promise<unknown> {
|
|
97
|
+
const requestId = `str_${(streamRequestSeq += 1)}`;
|
|
98
|
+
return new Promise((resolve, reject) => {
|
|
99
|
+
const timer = setTimeout(() => {
|
|
100
|
+
pendingStreamFetches.delete(requestId);
|
|
101
|
+
reject(new Error('Timed out fetching stream data from the export host.'));
|
|
102
|
+
}, STREAM_REQUEST_TIMEOUT_MS);
|
|
103
|
+
pendingStreamFetches.set(requestId, { resolve, reject, timer });
|
|
104
|
+
window.parent.postMessage(
|
|
105
|
+
{ [ENVELOPE]: PROTOCOL_VERSION, type, requestId, ...extra },
|
|
106
|
+
hostOrigin,
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// A StreamRuntime whose list / openLine are served by the framer, so the
|
|
112
|
+
// detached export Document opens streams without any network of its own.
|
|
113
|
+
// create() is refused: an export is read-only.
|
|
114
|
+
function makeStreamRuntime(hostOrigin: string): StreamRuntime {
|
|
115
|
+
return {
|
|
116
|
+
list: async () => {
|
|
117
|
+
const res = (await fetchFromFramer(
|
|
118
|
+
hostOrigin,
|
|
119
|
+
'stream-list-request',
|
|
120
|
+
{},
|
|
121
|
+
)) as { streams: DocumentStreamInfo[] };
|
|
122
|
+
return res.streams;
|
|
123
|
+
},
|
|
124
|
+
openLine: async (lineId: string) => {
|
|
125
|
+
const res = (await fetchFromFramer(hostOrigin, 'stream-snapshot-request', {
|
|
126
|
+
lineId,
|
|
127
|
+
})) as { snapshot: Uint8Array | null; tailOps: Uint8Array[] };
|
|
128
|
+
const inner = res.snapshot
|
|
129
|
+
? Document.openFromSnapshot(res.snapshot)
|
|
130
|
+
: Document.create();
|
|
131
|
+
for (const op of res.tailOps) inner.applyRemoteOp(op);
|
|
132
|
+
return inner;
|
|
133
|
+
},
|
|
134
|
+
create: () => {
|
|
135
|
+
throw new Error('Cannot create a stream during a headless export.');
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function reply(
|
|
141
|
+
hostOrigin: string,
|
|
142
|
+
requestId: string,
|
|
143
|
+
outcome:
|
|
144
|
+
| { ok: true; bytes: Uint8Array }
|
|
145
|
+
| { ok: false; code: string; message: string },
|
|
146
|
+
): void {
|
|
147
|
+
window.parent.postMessage(
|
|
148
|
+
{ [ENVELOPE]: PROTOCOL_VERSION, type: 'export-result', requestId, ...outcome },
|
|
149
|
+
hostOrigin,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function run(): Promise<void> {
|
|
154
|
+
if (done || !pending || !serializer) return;
|
|
155
|
+
done = true;
|
|
156
|
+
if (waitTimer !== undefined) clearTimeout(waitTimer);
|
|
157
|
+
const { hostOrigin, requestId, docId, snapshot, tailOps } = pending;
|
|
158
|
+
const identity = docId !== null ? { docId } : {};
|
|
159
|
+
try {
|
|
160
|
+
// A detached, in-memory Document over the snapshot + tail: the
|
|
161
|
+
// serializer reads records as it would from a live open, and its
|
|
162
|
+
// default-stream reads touch nothing. A serializer that opens a
|
|
163
|
+
// partitioned document's other streams (`doc.stream(name)`) resolves
|
|
164
|
+
// them over the bridge through the runtime below, so a partitioned
|
|
165
|
+
// document exports in full headless, not just its default stream.
|
|
166
|
+
const doc = snapshot
|
|
167
|
+
? Document.openFromSnapshot(snapshot, identity)
|
|
168
|
+
: Document.create(identity);
|
|
169
|
+
for (const op of tailOps) doc.applyRemoteOp(op);
|
|
170
|
+
doc.setStreamRuntime(makeStreamRuntime(hostOrigin));
|
|
171
|
+
const bytes = await serializer(doc);
|
|
172
|
+
reply(hostOrigin, requestId, { ok: true, bytes });
|
|
173
|
+
} catch (err) {
|
|
174
|
+
reply(hostOrigin, requestId, {
|
|
175
|
+
ok: false,
|
|
176
|
+
code: 'serializer_failed',
|
|
177
|
+
message: err instanceof Error ? err.message : 'Serializer failed.',
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function onMessage(event: MessageEvent): void {
|
|
183
|
+
const data = event.data as Record<string, unknown> | null;
|
|
184
|
+
if (!data || data[ENVELOPE] !== PROTOCOL_VERSION) return;
|
|
185
|
+
|
|
186
|
+
// A reply to a proxied stream fetch. Accept only from the pinned framer
|
|
187
|
+
// origin (set once the export request arrives), so a second frame cannot
|
|
188
|
+
// feed the serializer forged stream bytes.
|
|
189
|
+
if (
|
|
190
|
+
data['type'] === 'stream-list-result' ||
|
|
191
|
+
data['type'] === 'stream-snapshot-result'
|
|
192
|
+
) {
|
|
193
|
+
if (!pending || event.origin !== pending.hostOrigin) return;
|
|
194
|
+
const requestId = data['requestId'];
|
|
195
|
+
if (typeof requestId !== 'string') return;
|
|
196
|
+
const fetch = pendingStreamFetches.get(requestId);
|
|
197
|
+
if (!fetch) return;
|
|
198
|
+
pendingStreamFetches.delete(requestId);
|
|
199
|
+
clearTimeout(fetch.timer);
|
|
200
|
+
if (data['ok'] === true) {
|
|
201
|
+
if (data['type'] === 'stream-list-result') {
|
|
202
|
+
fetch.resolve({
|
|
203
|
+
streams: Array.isArray(data['streams']) ? data['streams'] : [],
|
|
204
|
+
});
|
|
205
|
+
} else {
|
|
206
|
+
fetch.resolve({
|
|
207
|
+
snapshot: data['snapshot'] instanceof Uint8Array ? data['snapshot'] : null,
|
|
208
|
+
tailOps: Array.isArray(data['tailOps'])
|
|
209
|
+
? data['tailOps'].filter((op) => op instanceof Uint8Array)
|
|
210
|
+
: [],
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
fetch.reject(
|
|
215
|
+
new Error(
|
|
216
|
+
typeof data['message'] === 'string'
|
|
217
|
+
? data['message']
|
|
218
|
+
: 'The export host could not provide the stream.',
|
|
219
|
+
),
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (data['type'] !== 'export-request') return;
|
|
226
|
+
// First framer to send a request wins; pin its origin so a second
|
|
227
|
+
// frame cannot answer or observe.
|
|
228
|
+
if (pending !== null) return;
|
|
229
|
+
const requestId = data['requestId'];
|
|
230
|
+
const docId = data['docId'];
|
|
231
|
+
const snapshot = data['snapshot'];
|
|
232
|
+
const tailOps = data['tailOps'];
|
|
233
|
+
if (typeof requestId !== 'string') return;
|
|
234
|
+
if (snapshot !== null && !(snapshot instanceof Uint8Array)) return;
|
|
235
|
+
if (!Array.isArray(tailOps) || !tailOps.every((op) => op instanceof Uint8Array)) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
pending = {
|
|
239
|
+
requestId,
|
|
240
|
+
docId: typeof docId === 'string' && docId !== '' ? docId : null,
|
|
241
|
+
snapshot,
|
|
242
|
+
tailOps,
|
|
243
|
+
hostOrigin: event.origin,
|
|
244
|
+
};
|
|
245
|
+
// If no serializer yet, wait briefly then answer `no_serializer` so
|
|
246
|
+
// the framer falls back to the server export.
|
|
247
|
+
if (serializer) {
|
|
248
|
+
void run();
|
|
249
|
+
} else {
|
|
250
|
+
waitTimer = setTimeout(() => {
|
|
251
|
+
if (done || !pending) return;
|
|
252
|
+
done = true;
|
|
253
|
+
reply(pending.hostOrigin, pending.requestId, {
|
|
254
|
+
ok: false,
|
|
255
|
+
code: 'no_serializer',
|
|
256
|
+
message: 'No serializer registered.',
|
|
257
|
+
});
|
|
258
|
+
}, SERIALIZER_WAIT_MS);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
window.addEventListener('message', onMessage);
|
|
263
|
+
// Announce readiness. Posted with `'*'` because the host origin is not
|
|
264
|
+
// known until its first message arrives; the ping carries no data.
|
|
265
|
+
window.parent.postMessage({ [ENVELOPE]: PROTOCOL_VERSION, type: 'ready' }, '*');
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
setSerializer(next: DocumentSerializer): void {
|
|
269
|
+
serializer = next;
|
|
270
|
+
if (pending) void run();
|
|
271
|
+
},
|
|
272
|
+
dispose(): void {
|
|
273
|
+
window.removeEventListener('message', onMessage);
|
|
274
|
+
if (waitTimer !== undefined) clearTimeout(waitTimer);
|
|
275
|
+
for (const fetch of pendingStreamFetches.values()) {
|
|
276
|
+
clearTimeout(fetch.timer);
|
|
277
|
+
fetch.reject(new Error('Export guest disposed.'));
|
|
278
|
+
}
|
|
279
|
+
pendingStreamFetches.clear();
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Bounded transient-retry for HTTP requests, with the replay policy as an
|
|
2
|
+
// explicit argument so the read path and the one write path we replay share
|
|
3
|
+
// a single implementation instead of hand-rolling a loop per call site.
|
|
4
|
+
//
|
|
5
|
+
// A cold Brass API container can reject a request at the gateway with a
|
|
6
|
+
// transient 5xx (503 while the API scales into a concurrent burst, 502 /
|
|
7
|
+
// 504 on a cold-start blip) or 429 (a short rate spike). These are not
|
|
8
|
+
// the caller's error and clear within a second or two, so a single hard
|
|
9
|
+
// failure on a document open reads as "the app is broken" when a brief
|
|
10
|
+
// wait-and-retry would have succeeded.
|
|
11
|
+
//
|
|
12
|
+
// What is safe to replay depends on whether the request is idempotent,
|
|
13
|
+
// which is why the policy is a parameter rather than a constant:
|
|
14
|
+
//
|
|
15
|
+
// - A GET is idempotent, so replaying it is always safe: retry the whole
|
|
16
|
+
// transient 5xx / 429 set AND a thrown network error (`RETRY_READ`).
|
|
17
|
+
// - A write is NOT idempotent, so only a 503 is safe to replay: the API
|
|
18
|
+
// gateway returns a 503 BEFORE the request reaches the handler (a transient
|
|
19
|
+
// throttle / scale-up / deploy-window blip), so the write never ran and
|
|
20
|
+
// a replay cannot double-apply it. A 502 / 504 / 429 or a network throw
|
|
21
|
+
// might mean the handler ran, so those stay hard failures (`RETRY_WRITE_503`).
|
|
22
|
+
// - A 500 is never retried under either policy: an unhandled server error
|
|
23
|
+
// is usually deterministic, so a replay just doubles the cost without
|
|
24
|
+
// changing the outcome.
|
|
25
|
+
// A 4xx other than 429 (403 app_not_installed, 404) is a definitive answer
|
|
26
|
+
// and is never in a policy's replay set.
|
|
27
|
+
|
|
28
|
+
export interface RetryPolicy {
|
|
29
|
+
// HTTP statuses whose response is replayed rather than returned.
|
|
30
|
+
replayStatuses: ReadonlySet<number>;
|
|
31
|
+
// Whether a thrown network error (DNS, connection reset, TLS) is replayed.
|
|
32
|
+
// Safe only for an idempotent request: a throw on a write might mean the
|
|
33
|
+
// handler ran, so a write policy leaves this false.
|
|
34
|
+
replayNetworkErrors: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Idempotent GET: replay the full transient set and network throws.
|
|
38
|
+
export const RETRY_READ: RetryPolicy = {
|
|
39
|
+
replayStatuses: new Set([429, 502, 503, 504]),
|
|
40
|
+
replayNetworkErrors: true,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Non-idempotent write: replay ONLY a pre-handler 503, never a network throw.
|
|
44
|
+
export const RETRY_WRITE_503: RetryPolicy = {
|
|
45
|
+
replayStatuses: new Set([503]),
|
|
46
|
+
replayNetworkErrors: false,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Idempotent write: replay the full transient set AND a network throw, exactly
|
|
50
|
+
// like a read. Safe only when the request carries a STABLE idempotency key the
|
|
51
|
+
// server dedupes on, so a replay the handler already applied returns the
|
|
52
|
+
// original result instead of applying twice. This is what lets document create
|
|
53
|
+
// stop assuming a 503 is always pre-handler (`RETRY_WRITE_503`'s constraint):
|
|
54
|
+
// with a key, even a post-handler 503 or a network throw is replay-safe.
|
|
55
|
+
export const RETRY_IDEMPOTENT_WRITE: RetryPolicy = {
|
|
56
|
+
replayStatuses: new Set([429, 502, 503, 504]),
|
|
57
|
+
replayNetworkErrors: true,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export interface RetryOptions {
|
|
61
|
+
// Which statuses / throws replay. Defaults to the idempotent read policy.
|
|
62
|
+
policy?: RetryPolicy;
|
|
63
|
+
// Total attempts including the first. Default 3 (so up to 2 replays).
|
|
64
|
+
maxAttempts?: number;
|
|
65
|
+
// Base backoff; attempt N waits ~base * 2^(N-1) with jitter. Default 300.
|
|
66
|
+
baseDelayMs?: number;
|
|
67
|
+
// Invoked before each replay with the 1-based attempt that just failed and
|
|
68
|
+
// the response (absent on a network throw). For diagnostics only.
|
|
69
|
+
onRetry?: (attempt: number, response: Response | undefined) => void;
|
|
70
|
+
// Injected for tests; defaults to the real timer.
|
|
71
|
+
sleep?: (ms: number) => Promise<void>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const defaultSleep = (ms: number): Promise<void> =>
|
|
75
|
+
new Promise((resolve) => setTimeout(resolve, ms));
|
|
76
|
+
|
|
77
|
+
// Run `doFetch` (which performs one request) up to `maxAttempts` times,
|
|
78
|
+
// replaying per `policy` with exponential backoff plus jitter between tries.
|
|
79
|
+
// Returns the last Response (replayable or not) so the caller keeps its own
|
|
80
|
+
// status handling; rethrows the last network error only if the policy replays
|
|
81
|
+
// throws and every attempt threw. A network throw the policy does not replay
|
|
82
|
+
// propagates immediately.
|
|
83
|
+
export async function fetchWithRetry(
|
|
84
|
+
doFetch: () => Promise<Response>,
|
|
85
|
+
options: RetryOptions = {},
|
|
86
|
+
): Promise<Response> {
|
|
87
|
+
const policy = options.policy ?? RETRY_READ;
|
|
88
|
+
const maxAttempts = options.maxAttempts ?? 3;
|
|
89
|
+
const baseDelayMs = options.baseDelayMs ?? 300;
|
|
90
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
91
|
+
|
|
92
|
+
let lastError: unknown;
|
|
93
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
94
|
+
let response: Response | undefined;
|
|
95
|
+
try {
|
|
96
|
+
response = await doFetch();
|
|
97
|
+
} catch (err) {
|
|
98
|
+
// A network-level throw is only replayable for an idempotent request;
|
|
99
|
+
// a write policy rethrows it rather than risk a double-apply.
|
|
100
|
+
if (!policy.replayNetworkErrors) throw err;
|
|
101
|
+
lastError = err;
|
|
102
|
+
}
|
|
103
|
+
if (response && !policy.replayStatuses.has(response.status)) {
|
|
104
|
+
return response;
|
|
105
|
+
}
|
|
106
|
+
if (attempt === maxAttempts) {
|
|
107
|
+
if (response) return response;
|
|
108
|
+
throw lastError;
|
|
109
|
+
}
|
|
110
|
+
options.onRetry?.(attempt, response);
|
|
111
|
+
// Exponential backoff with jitter so a burst of clients that all
|
|
112
|
+
// failed together do not re-collide on the same retry tick.
|
|
113
|
+
const backoff = baseDelayMs * 2 ** (attempt - 1);
|
|
114
|
+
const jitter = Math.random() * baseDelayMs;
|
|
115
|
+
await sleep(backoff + jitter);
|
|
116
|
+
}
|
|
117
|
+
// Unreachable: the loop returns or throws on the final attempt.
|
|
118
|
+
throw lastError ?? new Error('fetchWithRetry: no attempts made');
|
|
119
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// The anonymous "anyone with the link" boot signal. When Brass routes a
|
|
2
|
+
// no-sign-in share link into an app, it appends `?brass_link=<token>` to
|
|
3
|
+
// the app's URL. `Brass.init` reads it here and opens the document
|
|
4
|
+
// read-only through the token (`GET /links/:token/view`), with no sign-in.
|
|
5
|
+
//
|
|
6
|
+
// Pure + search-string-in so it stays testable, mirroring
|
|
7
|
+
// `isExportMode(search)`.
|
|
8
|
+
|
|
9
|
+
export const GUEST_LINK_PARAM = 'brass_link';
|
|
10
|
+
|
|
11
|
+
export function readGuestLinkToken(search: string): string | null {
|
|
12
|
+
const value = new URLSearchParams(search).get(GUEST_LINK_PARAM);
|
|
13
|
+
return value && value.trim() !== '' ? value : null;
|
|
14
|
+
}
|