@brass-build/client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (258) hide show
  1. package/AGENTS.md +255 -0
  2. package/CHANGELOG.md +15 -0
  3. package/LICENSE +21 -0
  4. package/README.md +836 -0
  5. package/dist/api-fetch.d.ts +16 -0
  6. package/dist/api-fetch.d.ts.map +1 -0
  7. package/dist/api-fetch.js +97 -0
  8. package/dist/api-fetch.js.map +1 -0
  9. package/dist/api-headers.d.ts +3 -0
  10. package/dist/api-headers.d.ts.map +1 -0
  11. package/dist/api-headers.js +25 -0
  12. package/dist/api-headers.js.map +1 -0
  13. package/dist/auth-flow.d.ts +23 -0
  14. package/dist/auth-flow.d.ts.map +1 -0
  15. package/dist/auth-flow.js +76 -0
  16. package/dist/auth-flow.js.map +1 -0
  17. package/dist/client.d.ts +106 -0
  18. package/dist/client.d.ts.map +1 -0
  19. package/dist/client.js +1278 -0
  20. package/dist/client.js.map +1 -0
  21. package/dist/doc-url-sync.d.ts +19 -0
  22. package/dist/doc-url-sync.d.ts.map +1 -0
  23. package/dist/doc-url-sync.js +181 -0
  24. package/dist/doc-url-sync.js.map +1 -0
  25. package/dist/document-branches.d.ts +32 -0
  26. package/dist/document-branches.d.ts.map +1 -0
  27. package/dist/document-branches.js +89 -0
  28. package/dist/document-branches.js.map +1 -0
  29. package/dist/document-checkpoints.d.ts +24 -0
  30. package/dist/document-checkpoints.d.ts.map +1 -0
  31. package/dist/document-checkpoints.js +115 -0
  32. package/dist/document-checkpoints.js.map +1 -0
  33. package/dist/document-commands.d.ts +93 -0
  34. package/dist/document-commands.d.ts.map +1 -0
  35. package/dist/document-commands.js +423 -0
  36. package/dist/document-commands.js.map +1 -0
  37. package/dist/document-signals.d.ts +53 -0
  38. package/dist/document-signals.d.ts.map +1 -0
  39. package/dist/document-signals.js +114 -0
  40. package/dist/document-signals.js.map +1 -0
  41. package/dist/document-streams.d.ts +22 -0
  42. package/dist/document-streams.d.ts.map +1 -0
  43. package/dist/document-streams.js +98 -0
  44. package/dist/document-streams.js.map +1 -0
  45. package/dist/document-thumbnail-capture.d.ts +27 -0
  46. package/dist/document-thumbnail-capture.d.ts.map +1 -0
  47. package/dist/document-thumbnail-capture.js +160 -0
  48. package/dist/document-thumbnail-capture.js.map +1 -0
  49. package/dist/documents-namespace.d.ts +134 -0
  50. package/dist/documents-namespace.d.ts.map +1 -0
  51. package/dist/documents-namespace.js +190 -0
  52. package/dist/documents-namespace.js.map +1 -0
  53. package/dist/documents.d.ts +146 -0
  54. package/dist/documents.d.ts.map +1 -0
  55. package/dist/documents.js +2045 -0
  56. package/dist/documents.js.map +1 -0
  57. package/dist/emitter.d.ts +15 -0
  58. package/dist/emitter.d.ts.map +1 -0
  59. package/dist/emitter.js +90 -0
  60. package/dist/emitter.js.map +1 -0
  61. package/dist/errors.d.ts +18 -0
  62. package/dist/errors.d.ts.map +1 -0
  63. package/dist/errors.js +22 -0
  64. package/dist/errors.js.map +1 -0
  65. package/dist/export-guest.d.ts +9 -0
  66. package/dist/export-guest.d.ts.map +1 -0
  67. package/dist/export-guest.js +225 -0
  68. package/dist/export-guest.js.map +1 -0
  69. package/dist/fetch-retry.d.ts +16 -0
  70. package/dist/fetch-retry.d.ts.map +1 -0
  71. package/dist/fetch-retry.js +90 -0
  72. package/dist/fetch-retry.js.map +1 -0
  73. package/dist/guest-link.d.ts +3 -0
  74. package/dist/guest-link.d.ts.map +1 -0
  75. package/dist/guest-link.js +13 -0
  76. package/dist/guest-link.js.map +1 -0
  77. package/dist/handles.d.ts +57 -0
  78. package/dist/handles.d.ts.map +1 -0
  79. package/dist/handles.js +1375 -0
  80. package/dist/handles.js.map +1 -0
  81. package/dist/hosted-runtime.d.ts +33 -0
  82. package/dist/hosted-runtime.d.ts.map +1 -0
  83. package/dist/hosted-runtime.js +99 -0
  84. package/dist/hosted-runtime.js.map +1 -0
  85. package/dist/hosted-ui.d.ts +84 -0
  86. package/dist/hosted-ui.d.ts.map +1 -0
  87. package/dist/hosted-ui.js +673 -0
  88. package/dist/hosted-ui.js.map +1 -0
  89. package/dist/importer.d.ts +22 -0
  90. package/dist/importer.d.ts.map +1 -0
  91. package/dist/importer.js +54 -0
  92. package/dist/importer.js.map +1 -0
  93. package/dist/index.d.ts +54 -0
  94. package/dist/index.d.ts.map +1 -0
  95. package/dist/index.js +132 -0
  96. package/dist/index.js.map +1 -0
  97. package/dist/init-keys.d.ts +3 -0
  98. package/dist/init-keys.d.ts.map +1 -0
  99. package/dist/init-keys.js +13 -0
  100. package/dist/init-keys.js.map +1 -0
  101. package/dist/internal.d.ts +6 -0
  102. package/dist/internal.d.ts.map +1 -0
  103. package/dist/internal.js +17 -0
  104. package/dist/internal.js.map +1 -0
  105. package/dist/jwt.d.ts +3 -0
  106. package/dist/jwt.d.ts.map +1 -0
  107. package/dist/jwt.js +22 -0
  108. package/dist/jwt.js.map +1 -0
  109. package/dist/launch-context.d.ts +4 -0
  110. package/dist/launch-context.d.ts.map +1 -0
  111. package/dist/launch-context.js +44 -0
  112. package/dist/launch-context.js.map +1 -0
  113. package/dist/login-hint.d.ts +4 -0
  114. package/dist/login-hint.d.ts.map +1 -0
  115. package/dist/login-hint.js +63 -0
  116. package/dist/login-hint.js.map +1 -0
  117. package/dist/mutate-batching.d.ts +14 -0
  118. package/dist/mutate-batching.d.ts.map +1 -0
  119. package/dist/mutate-batching.js +65 -0
  120. package/dist/mutate-batching.js.map +1 -0
  121. package/dist/open-document.d.ts +127 -0
  122. package/dist/open-document.d.ts.map +1 -0
  123. package/dist/open-document.js +2121 -0
  124. package/dist/open-document.js.map +1 -0
  125. package/dist/page-title.d.ts +3 -0
  126. package/dist/page-title.d.ts.map +1 -0
  127. package/dist/page-title.js +32 -0
  128. package/dist/page-title.js.map +1 -0
  129. package/dist/perf.d.ts +9 -0
  130. package/dist/perf.d.ts.map +1 -0
  131. package/dist/perf.js +39 -0
  132. package/dist/perf.js.map +1 -0
  133. package/dist/presence.d.ts +62 -0
  134. package/dist/presence.d.ts.map +1 -0
  135. package/dist/presence.js +331 -0
  136. package/dist/presence.js.map +1 -0
  137. package/dist/record-paging.d.ts +99 -0
  138. package/dist/record-paging.d.ts.map +1 -0
  139. package/dist/record-paging.js +220 -0
  140. package/dist/record-paging.js.map +1 -0
  141. package/dist/schema-conflict-dialog.d.ts +10 -0
  142. package/dist/schema-conflict-dialog.d.ts.map +1 -0
  143. package/dist/schema-conflict-dialog.js +164 -0
  144. package/dist/schema-conflict-dialog.js.map +1 -0
  145. package/dist/selection-persistence.d.ts +5 -0
  146. package/dist/selection-persistence.d.ts.map +1 -0
  147. package/dist/selection-persistence.js +90 -0
  148. package/dist/selection-persistence.js.map +1 -0
  149. package/dist/serializer.d.ts +3 -0
  150. package/dist/serializer.d.ts.map +1 -0
  151. package/dist/serializer.js +2 -0
  152. package/dist/serializer.js.map +1 -0
  153. package/dist/session-persistence.d.ts +4 -0
  154. package/dist/session-persistence.d.ts.map +1 -0
  155. package/dist/session-persistence.js +68 -0
  156. package/dist/session-persistence.js.map +1 -0
  157. package/dist/session-toolbar.d.ts +62 -0
  158. package/dist/session-toolbar.d.ts.map +1 -0
  159. package/dist/session-toolbar.js +1205 -0
  160. package/dist/session-toolbar.js.map +1 -0
  161. package/dist/session.d.ts +69 -0
  162. package/dist/session.d.ts.map +1 -0
  163. package/dist/session.js +926 -0
  164. package/dist/session.js.map +1 -0
  165. package/dist/tail-ops.d.ts +28 -0
  166. package/dist/tail-ops.d.ts.map +1 -0
  167. package/dist/tail-ops.js +46 -0
  168. package/dist/tail-ops.js.map +1 -0
  169. package/dist/topic-lifecycle.d.ts +41 -0
  170. package/dist/topic-lifecycle.d.ts.map +1 -0
  171. package/dist/topic-lifecycle.js +169 -0
  172. package/dist/topic-lifecycle.js.map +1 -0
  173. package/dist/types.d.ts +22 -0
  174. package/dist/types.d.ts.map +1 -0
  175. package/dist/types.js +2 -0
  176. package/dist/types.js.map +1 -0
  177. package/dist/undo.d.ts +18 -0
  178. package/dist/undo.d.ts.map +1 -0
  179. package/dist/undo.js +110 -0
  180. package/dist/undo.js.map +1 -0
  181. package/dist/version-bar.d.ts +22 -0
  182. package/dist/version-bar.d.ts.map +1 -0
  183. package/dist/version-bar.js +169 -0
  184. package/dist/version-bar.js.map +1 -0
  185. package/dist/version.d.ts +3 -0
  186. package/dist/version.d.ts.map +1 -0
  187. package/dist/version.js +8 -0
  188. package/dist/version.js.map +1 -0
  189. package/dist/vite.d.ts +15 -0
  190. package/dist/vite.d.ts.map +1 -0
  191. package/dist/vite.js +36 -0
  192. package/dist/vite.js.map +1 -0
  193. package/dist/ws-client.d.ts +53 -0
  194. package/dist/ws-client.d.ts.map +1 -0
  195. package/dist/ws-client.js +604 -0
  196. package/dist/ws-client.js.map +1 -0
  197. package/dist/ws-frame-limit.d.ts +21 -0
  198. package/dist/ws-frame-limit.d.ts.map +1 -0
  199. package/dist/ws-frame-limit.js +40 -0
  200. package/dist/ws-frame-limit.js.map +1 -0
  201. package/docs/app-manifest.md +65 -0
  202. package/docs/custom-chrome.md +147 -0
  203. package/docs/hosted-ui-advanced.md +77 -0
  204. package/docs/imported-documents.md +261 -0
  205. package/docs/presence.md +129 -0
  206. package/docs/schemas.md +135 -0
  207. package/docs/streams.md +248 -0
  208. package/package.json +60 -0
  209. package/scripts/prepare.mjs +50 -0
  210. package/src/api-fetch.ts +127 -0
  211. package/src/api-headers.ts +28 -0
  212. package/src/auth-flow.ts +125 -0
  213. package/src/client.ts +1457 -0
  214. package/src/doc-url-sync.ts +183 -0
  215. package/src/document-branches.ts +124 -0
  216. package/src/document-checkpoints.ts +135 -0
  217. package/src/document-commands.ts +651 -0
  218. package/src/document-signals.ts +234 -0
  219. package/src/document-streams.ts +132 -0
  220. package/src/document-thumbnail-capture.ts +191 -0
  221. package/src/documents-namespace.ts +516 -0
  222. package/src/documents.ts +2426 -0
  223. package/src/emitter.ts +129 -0
  224. package/src/errors.ts +45 -0
  225. package/src/export-guest.ts +282 -0
  226. package/src/fetch-retry.ts +119 -0
  227. package/src/guest-link.ts +14 -0
  228. package/src/handles.ts +1731 -0
  229. package/src/hosted-runtime.ts +109 -0
  230. package/src/hosted-ui.ts +1020 -0
  231. package/src/importer.ts +89 -0
  232. package/src/index.ts +267 -0
  233. package/src/init-keys.ts +15 -0
  234. package/src/internal.ts +23 -0
  235. package/src/jwt.ts +18 -0
  236. package/src/launch-context.ts +43 -0
  237. package/src/login-hint.ts +60 -0
  238. package/src/mutate-batching.ts +63 -0
  239. package/src/open-document.ts +2869 -0
  240. package/src/page-title.ts +33 -0
  241. package/src/perf.ts +46 -0
  242. package/src/presence.ts +471 -0
  243. package/src/record-paging.ts +270 -0
  244. package/src/schema-conflict-dialog.ts +176 -0
  245. package/src/selection-persistence.ts +98 -0
  246. package/src/serializer.ts +15 -0
  247. package/src/session-persistence.ts +69 -0
  248. package/src/session-toolbar.ts +1457 -0
  249. package/src/session.ts +1262 -0
  250. package/src/tail-ops.ts +63 -0
  251. package/src/topic-lifecycle.ts +217 -0
  252. package/src/types.ts +75 -0
  253. package/src/undo.ts +125 -0
  254. package/src/version-bar.ts +221 -0
  255. package/src/version.ts +8 -0
  256. package/src/vite.ts +49 -0
  257. package/src/ws-client.ts +678 -0
  258. package/src/ws-frame-limit.ts +52 -0
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
+ }