@brass-build/client 0.1.0 → 0.3.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 (213) hide show
  1. package/AGENTS.md +72 -63
  2. package/CHANGELOG.md +97 -0
  3. package/README.md +325 -235
  4. package/dist/auth-flow.d.ts +10 -1
  5. package/dist/auth-flow.d.ts.map +1 -1
  6. package/dist/auth-flow.js +64 -20
  7. package/dist/auth-flow.js.map +1 -1
  8. package/dist/client.d.ts +4 -0
  9. package/dist/client.d.ts.map +1 -1
  10. package/dist/client.js +245 -76
  11. package/dist/client.js.map +1 -1
  12. package/dist/document-checkpoints.d.ts +1 -0
  13. package/dist/document-checkpoints.d.ts.map +1 -1
  14. package/dist/document-checkpoints.js +27 -0
  15. package/dist/document-checkpoints.js.map +1 -1
  16. package/dist/document-engine.d.ts +4 -0
  17. package/dist/document-engine.d.ts.map +1 -0
  18. package/dist/document-engine.js +9 -0
  19. package/dist/document-engine.js.map +1 -0
  20. package/dist/document-signals.d.ts +31 -1
  21. package/dist/document-signals.d.ts.map +1 -1
  22. package/dist/document-signals.js +59 -0
  23. package/dist/document-signals.js.map +1 -1
  24. package/dist/document-streams.d.ts +8 -1
  25. package/dist/document-streams.d.ts.map +1 -1
  26. package/dist/document-streams.js +79 -8
  27. package/dist/document-streams.js.map +1 -1
  28. package/dist/document-thumbnail-capture.d.ts +20 -0
  29. package/dist/document-thumbnail-capture.d.ts.map +1 -1
  30. package/dist/document-thumbnail-capture.js +247 -9
  31. package/dist/document-thumbnail-capture.js.map +1 -1
  32. package/dist/documents-namespace.d.ts.map +1 -1
  33. package/dist/documents-namespace.js +22 -3
  34. package/dist/documents-namespace.js.map +1 -1
  35. package/dist/documents.d.ts +49 -16
  36. package/dist/documents.d.ts.map +1 -1
  37. package/dist/documents.js +981 -237
  38. package/dist/documents.js.map +1 -1
  39. package/dist/embed-protocol.d.ts +3 -0
  40. package/dist/embed-protocol.d.ts.map +1 -0
  41. package/dist/embed-protocol.js +26 -0
  42. package/dist/embed-protocol.js.map +1 -0
  43. package/dist/emitter.d.ts.map +1 -1
  44. package/dist/emitter.js +8 -1
  45. package/dist/emitter.js.map +1 -1
  46. package/dist/export-guest.d.ts.map +1 -1
  47. package/dist/export-guest.js +14 -6
  48. package/dist/export-guest.js.map +1 -1
  49. package/dist/fetch-deadline.d.ts +22 -0
  50. package/dist/fetch-deadline.d.ts.map +1 -0
  51. package/dist/fetch-deadline.js +96 -0
  52. package/dist/fetch-deadline.js.map +1 -0
  53. package/dist/fetch-retry.d.ts +1 -0
  54. package/dist/fetch-retry.d.ts.map +1 -1
  55. package/dist/fetch-retry.js +12 -5
  56. package/dist/fetch-retry.js.map +1 -1
  57. package/dist/handle-types.d.ts +150 -0
  58. package/dist/handle-types.d.ts.map +1 -0
  59. package/dist/handle-types.js +8 -0
  60. package/dist/handle-types.js.map +1 -0
  61. package/dist/handles.d.ts +48 -48
  62. package/dist/handles.d.ts.map +1 -1
  63. package/dist/handles.js +3122 -501
  64. package/dist/handles.js.map +1 -1
  65. package/dist/hosted-runtime.d.ts.map +1 -1
  66. package/dist/hosted-runtime.js +8 -12
  67. package/dist/hosted-runtime.js.map +1 -1
  68. package/dist/hosted-ui.d.ts +0 -1
  69. package/dist/hosted-ui.d.ts.map +1 -1
  70. package/dist/hosted-ui.js +29 -24
  71. package/dist/hosted-ui.js.map +1 -1
  72. package/dist/importer.d.ts.map +1 -1
  73. package/dist/importer.js +204 -8
  74. package/dist/importer.js.map +1 -1
  75. package/dist/index.d.ts +10 -10
  76. package/dist/index.d.ts.map +1 -1
  77. package/dist/index.js +11 -2
  78. package/dist/index.js.map +1 -1
  79. package/dist/internal.d.ts +2 -1
  80. package/dist/internal.d.ts.map +1 -1
  81. package/dist/internal.js +1 -0
  82. package/dist/internal.js.map +1 -1
  83. package/dist/join-coverage.d.ts +12 -0
  84. package/dist/join-coverage.d.ts.map +1 -0
  85. package/dist/join-coverage.js +37 -0
  86. package/dist/join-coverage.js.map +1 -0
  87. package/dist/jwt.d.ts.map +1 -1
  88. package/dist/jwt.js +5 -1
  89. package/dist/jwt.js.map +1 -1
  90. package/dist/launch-context.js +2 -2
  91. package/dist/launch-context.js.map +1 -1
  92. package/dist/mutate-batching.d.ts +25 -2
  93. package/dist/mutate-batching.d.ts.map +1 -1
  94. package/dist/mutate-batching.js +57 -10
  95. package/dist/mutate-batching.js.map +1 -1
  96. package/dist/open-document.d.ts +30 -1
  97. package/dist/open-document.d.ts.map +1 -1
  98. package/dist/open-document.js +1905 -322
  99. package/dist/open-document.js.map +1 -1
  100. package/dist/presence.d.ts +28 -0
  101. package/dist/presence.d.ts.map +1 -1
  102. package/dist/presence.js +149 -7
  103. package/dist/presence.js.map +1 -1
  104. package/dist/record-paging.d.ts +335 -71
  105. package/dist/record-paging.d.ts.map +1 -1
  106. package/dist/record-paging.js +590 -127
  107. package/dist/record-paging.js.map +1 -1
  108. package/dist/selection-persistence.d.ts.map +1 -1
  109. package/dist/selection-persistence.js +11 -1
  110. package/dist/selection-persistence.js.map +1 -1
  111. package/dist/serializer.d.ts +30 -1
  112. package/dist/serializer.d.ts.map +1 -1
  113. package/dist/session-persistence.d.ts.map +1 -1
  114. package/dist/session-persistence.js +9 -8
  115. package/dist/session-persistence.js.map +1 -1
  116. package/dist/session-toolbar.d.ts +0 -1
  117. package/dist/session-toolbar.d.ts.map +1 -1
  118. package/dist/session-toolbar.js +356 -83
  119. package/dist/session-toolbar.js.map +1 -1
  120. package/dist/session.d.ts.map +1 -1
  121. package/dist/session.js +87 -18
  122. package/dist/session.js.map +1 -1
  123. package/dist/tail-ops.d.ts +14 -4
  124. package/dist/tail-ops.d.ts.map +1 -1
  125. package/dist/tail-ops.js +75 -15
  126. package/dist/tail-ops.js.map +1 -1
  127. package/dist/topic-lifecycle.d.ts +4 -0
  128. package/dist/topic-lifecycle.d.ts.map +1 -1
  129. package/dist/topic-lifecycle.js +25 -1
  130. package/dist/topic-lifecycle.js.map +1 -1
  131. package/dist/types.d.ts +47 -1
  132. package/dist/types.d.ts.map +1 -1
  133. package/dist/undo.d.ts +2 -3
  134. package/dist/undo.d.ts.map +1 -1
  135. package/dist/undo.js +11 -0
  136. package/dist/undo.js.map +1 -1
  137. package/dist/unload-guard.d.ts +5 -0
  138. package/dist/unload-guard.d.ts.map +1 -0
  139. package/dist/unload-guard.js +88 -0
  140. package/dist/unload-guard.js.map +1 -0
  141. package/dist/version-bar.d.ts +4 -3
  142. package/dist/version-bar.d.ts.map +1 -1
  143. package/dist/version-bar.js +162 -20
  144. package/dist/version-bar.js.map +1 -1
  145. package/dist/version.d.ts +2 -2
  146. package/dist/version.js +1 -1
  147. package/dist/vite.d.ts +18 -2
  148. package/dist/vite.d.ts.map +1 -1
  149. package/dist/vite.js +133 -8
  150. package/dist/vite.js.map +1 -1
  151. package/dist/when-saved.d.ts +2 -0
  152. package/dist/when-saved.d.ts.map +1 -0
  153. package/dist/when-saved.js +70 -0
  154. package/dist/when-saved.js.map +1 -0
  155. package/dist/ws-client.d.ts +27 -3
  156. package/dist/ws-client.d.ts.map +1 -1
  157. package/dist/ws-client.js +408 -89
  158. package/dist/ws-client.js.map +1 -1
  159. package/dist/ws-frame-limit.d.ts.map +1 -1
  160. package/dist/ws-frame-limit.js +23 -17
  161. package/dist/ws-frame-limit.js.map +1 -1
  162. package/docs/app-manifest.md +7 -8
  163. package/docs/custom-chrome.md +21 -22
  164. package/docs/hosted-ui-advanced.md +14 -17
  165. package/docs/imported-documents.md +53 -197
  166. package/docs/presence.md +45 -21
  167. package/docs/schemas.md +12 -17
  168. package/docs/streams.md +55 -57
  169. package/package.json +4 -3
  170. package/src/auth-flow.ts +90 -23
  171. package/src/client.ts +262 -80
  172. package/src/document-checkpoints.ts +30 -0
  173. package/src/document-engine.ts +15 -0
  174. package/src/document-signals.ts +120 -8
  175. package/src/document-streams.ts +95 -9
  176. package/src/document-thumbnail-capture.ts +253 -9
  177. package/src/documents-namespace.ts +38 -13
  178. package/src/documents.ts +1103 -290
  179. package/src/embed-protocol.ts +25 -0
  180. package/src/emitter.ts +8 -1
  181. package/src/export-guest.ts +15 -9
  182. package/src/fetch-deadline.ts +119 -0
  183. package/src/fetch-retry.ts +14 -5
  184. package/src/handle-types.ts +303 -0
  185. package/src/handles.ts +3407 -683
  186. package/src/hosted-runtime.ts +8 -12
  187. package/src/hosted-ui.ts +28 -32
  188. package/src/importer.ts +216 -8
  189. package/src/index.ts +29 -18
  190. package/src/internal.ts +7 -0
  191. package/src/join-coverage.ts +42 -0
  192. package/src/jwt.ts +8 -1
  193. package/src/launch-context.ts +2 -2
  194. package/src/mutate-batching.ts +60 -11
  195. package/src/open-document.ts +2441 -502
  196. package/src/presence.ts +210 -11
  197. package/src/record-paging.ts +686 -137
  198. package/src/selection-persistence.ts +10 -1
  199. package/src/serializer.ts +33 -1
  200. package/src/session-persistence.ts +9 -8
  201. package/src/session-toolbar.ts +387 -89
  202. package/src/session.ts +91 -17
  203. package/src/tail-ops.ts +76 -10
  204. package/src/topic-lifecycle.ts +31 -1
  205. package/src/types.ts +56 -4
  206. package/src/undo.ts +13 -5
  207. package/src/unload-guard.ts +91 -0
  208. package/src/version-bar.ts +183 -26
  209. package/src/version.ts +1 -1
  210. package/src/vite.ts +175 -10
  211. package/src/when-saved.ts +66 -0
  212. package/src/ws-client.ts +461 -92
  213. package/src/ws-frame-limit.ts +23 -17
@@ -9,13 +9,23 @@
9
9
  // grow with no SDK or app change.
10
10
  //
11
11
  // Bridge protocol (shared envelope `__brass_embed: 1`):
12
- // host -> iframe: init{surface,params} | version-changed{version}
13
- // iframe -> host: ready | resize{height} | preview{version}
14
- // | return-to-head | fork{fromVersion} | browse
12
+ // host -> iframe: init{surface,params}
13
+ // | version-changed{version, requestId?, ok?}
14
+ // iframe -> host: ready | resize{height} | preview{version, requestId?}
15
+ // | return-to-head{requestId?} | fork{fromVersion} | browse
15
16
  // | error{code,message}
17
+ //
18
+ // A navigating intent (`preview` / `return-to-head`) is CONFIRMED: the host
19
+ // awaits the document method it routes to and answers with the version the
20
+ // document actually shows afterwards, plus whether the intent was accepted.
21
+ // The bar moves optimistically on the click, so without that answer a refused
22
+ // checkout (a document pinned to a version, a materialise that failed) leaves
23
+ // it naming a version nothing is showing, and nothing later corrects it: the
24
+ // host's own re-render is driven by the session, which does not notify on a
25
+ // preview -> preview step.
26
+
27
+ import { EMBED_ENVELOPE, EMBED_PROTOCOL_VERSION } from './embed-protocol.js';
16
28
 
17
- const ENVELOPE = '__brass_embed';
18
- const PROTOCOL_VERSION = 1;
19
29
  // Collapsed until the embed reports `ready`, so the load moment shows a
20
30
  // themed placeholder strip rather than an empty white iframe (no flash, no
21
31
  // layout jump: the placeholder reserves the strip's height).
@@ -29,14 +39,17 @@ export interface VersionBarDeps {
29
39
  dashboardBaseUrl: string;
30
40
  // Pins the embed's light/dark theme (matches the other hosted surfaces).
31
41
  theme?: 'light' | 'dark' | 'auto';
32
- // The fallback platform-session pointer, handed to the embed so it can
33
- // authenticate when its framed cookie is blocked (incognito / Safari ITP).
34
- getSessionToken?: () => string | null | undefined;
35
- // Intents from the embed, routed to the live active document.
36
- checkoutVersion: (version: number) => void;
37
- returnToHead: () => void;
42
+ // Intents from the embed, routed to the live active document. The two
43
+ // navigating ones settle (a rejection is a refused intent, which the frame
44
+ // reports back rather than swallowing).
45
+ checkoutVersion: (version: number) => Promise<void>;
46
+ returnToHead: () => Promise<void>;
38
47
  fork: (fromVersion: number, name?: string) => void;
39
48
  browse: () => void;
49
+ // The version the active document shows right now (`null` at the live
50
+ // head). Read after an intent settles, so the answer describes the
51
+ // document rather than the request.
52
+ currentVersion: () => number | null;
40
53
  // Dismiss the bar. The bar stays open through the whole browsing session
41
54
  // (including once returned to the live head), so it is closed only by the
42
55
  // user's explicit "Close", never as a side effect of navigation.
@@ -52,6 +65,10 @@ export interface VersionBarContext {
52
65
  // The active branch's display name when on a non-default branch, else
53
66
  // undefined. The bar composes a branch-from-version's initial name from it.
54
67
  branchName?: string | undefined;
68
+ // True when the document is pinned to this version and has no live channel
69
+ // (a cold open at a version). Stepping and returning to the head are both
70
+ // refused on that shape, so the bar offers neither.
71
+ pinned?: boolean;
55
72
  }
56
73
 
57
74
  export interface VersionBarFrame {
@@ -67,6 +84,10 @@ function clampHeight(h: number): number {
67
84
  return Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, Math.round(h)));
68
85
  }
69
86
 
87
+ // How long the embed has to say it is ready before the SDK-drawn fallback
88
+ // takes the strip. Matches the hosted-UI launcher's own long window.
89
+ const VERSION_BAR_READY_TIMEOUT_MS = 10_000;
90
+
70
91
  export function createVersionBarFrame(
71
92
  root: HTMLElement,
72
93
  deps: VersionBarDeps,
@@ -81,6 +102,37 @@ export function createVersionBarFrame(
81
102
  placeholder.className = 'b-version-loading';
82
103
  placeholder.textContent = 'Loading version controls...';
83
104
 
105
+ // Shown when the embed reports it cannot render (not signed in in this
106
+ // framing context, an account mismatch). The bar is the only surface that
107
+ // returns a document to its live head, so collapsing the strip on that
108
+ // failure would leave the reader in a read-only preview with nothing to
109
+ // leave it by; this carries the two controls that need no timeline.
110
+ const fallback = document.createElement('div');
111
+ fallback.className = 'b-version-fallback';
112
+ fallback.hidden = true;
113
+ const fallbackText = document.createElement('span');
114
+ fallbackText.className = 'b-version-fallback-msg';
115
+ const fallbackHead = document.createElement('button');
116
+ fallbackHead.type = 'button';
117
+ fallbackHead.textContent = 'Return to latest';
118
+ fallbackHead.onclick = () => {
119
+ void deps.returnToHead().catch(() => {});
120
+ };
121
+ const fallbackClose = document.createElement('button');
122
+ fallbackClose.type = 'button';
123
+ fallbackClose.textContent = 'Close';
124
+ fallbackClose.onclick = () => deps.close();
125
+ fallback.append(fallbackText, fallbackHead, fallbackClose);
126
+
127
+ function renderFallback(): void {
128
+ const pinned = ctx?.pinned === true;
129
+ const previewing = deps.currentVersion() !== null;
130
+ fallbackText.textContent = previewing
131
+ ? 'Viewing an earlier version'
132
+ : 'Latest version';
133
+ fallbackHead.hidden = !previewing || pinned;
134
+ }
135
+
84
136
  const iframe = document.createElement('iframe');
85
137
  const url = new URL(`${base}/embed/version-bar`);
86
138
  url.searchParams.set('theme', theme);
@@ -95,10 +147,46 @@ export function createVersionBarFrame(
95
147
  background: 'transparent',
96
148
  } satisfies Partial<CSSStyleDeclaration>);
97
149
 
98
- root.append(placeholder, iframe);
150
+ root.append(placeholder, fallback, iframe);
99
151
 
100
152
  let ctx: VersionBarContext | null = null;
101
153
  let ready = false;
154
+ // Set once the embed reports it cannot render, so the fallback strip owns
155
+ // the surface and a later `update` does not restore the dead iframe.
156
+ let failed = false;
157
+ let destroyed = false;
158
+ // Armed whenever the iframe is pointed at the embed. Returning a document to
159
+ // its live head is this bar's exclusive capability, so an embed that never
160
+ // loads at all (a host CSP that refuses the frame, an offline reader, a
161
+ // blocking extension) would otherwise leave the placeholder up forever and
162
+ // the reader in a read-only preview with no control to leave it by. The
163
+ // `error` message covers an embed that loaded and refused; nothing covers
164
+ // one that never answers, which is why this is a timer rather than a
165
+ // handler.
166
+ let readyTimer: ReturnType<typeof setTimeout> | undefined;
167
+
168
+ // Hand the strip to the SDK-drawn fallback, which carries the two controls
169
+ // that need no timeline (return to latest, close).
170
+ function enterFallback(): void {
171
+ if (destroyed || failed) return;
172
+ failed = true;
173
+ if (readyTimer !== undefined) {
174
+ clearTimeout(readyTimer);
175
+ readyTimer = undefined;
176
+ }
177
+ placeholder.style.display = 'none';
178
+ iframe.style.height = '0';
179
+ fallback.hidden = false;
180
+ renderFallback();
181
+ }
182
+
183
+ function armReadyTimer(): void {
184
+ if (readyTimer !== undefined) clearTimeout(readyTimer);
185
+ readyTimer = setTimeout(() => {
186
+ readyTimer = undefined;
187
+ if (!ready) enterFallback();
188
+ }, VERSION_BAR_READY_TIMEOUT_MS);
189
+ }
102
190
  // The version last delivered to the embed (via `init` or `version-changed`).
103
191
  // The host re-renders on many events (a commit, a saving-count bump, ...),
104
192
  // each calling `update`, but only an ACTUAL version change should reach the
@@ -107,11 +195,10 @@ export function createVersionBarFrame(
107
195
  let deliveredVersion: number | null | undefined = undefined;
108
196
 
109
197
  function postInit(): void {
110
- const sid = deps.getSessionToken?.();
111
198
  if (!ctx) return;
112
199
  iframe.contentWindow?.postMessage(
113
200
  {
114
- [ENVELOPE]: PROTOCOL_VERSION,
201
+ [EMBED_ENVELOPE]: EMBED_PROTOCOL_VERSION,
115
202
  type: 'init',
116
203
  surface: 'version-bar',
117
204
  params: {
@@ -119,7 +206,7 @@ export function createVersionBarFrame(
119
206
  ...(ctx.branch !== undefined ? { branch: ctx.branch } : {}),
120
207
  version: ctx.version,
121
208
  ...(ctx.branchName !== undefined ? { branchName: ctx.branchName } : {}),
122
- ...(sid ? { sessionToken: sid } : {}),
209
+ ...(ctx.pinned ? { pinned: true } : {}),
123
210
  },
124
211
  },
125
212
  origin,
@@ -127,14 +214,64 @@ export function createVersionBarFrame(
127
214
  deliveredVersion = ctx.version;
128
215
  }
129
216
 
217
+ // Answer a navigating intent with what the document shows now. `ok` is
218
+ // whether the intent itself was accepted, which the version alone cannot
219
+ // say: a checkout the server rounded down and one that was refused both
220
+ // report a version other than the requested one, and the bar keeps the
221
+ // reader's step position for the first and reverts it for the second.
222
+ function confirmIntent(requestId: unknown, ok: boolean): void {
223
+ // An intent settles a turn after it was routed, by which time the bar may
224
+ // have been torn down (the reader closed it, the document changed). There
225
+ // is nothing listening then, and posting into a removed frame is an error
226
+ // that would surface as an unhandled rejection in the app.
227
+ if (!ready || destroyed) return;
228
+ const version = deps.currentVersion();
229
+ deliveredVersion = version;
230
+ iframe.contentWindow?.postMessage(
231
+ {
232
+ [EMBED_ENVELOPE]: EMBED_PROTOCOL_VERSION,
233
+ type: 'version-changed',
234
+ version,
235
+ ok,
236
+ ...(typeof requestId === 'string' ? { requestId } : {}),
237
+ },
238
+ origin,
239
+ );
240
+ }
241
+
242
+ // Intents still awaiting their answer. While one is in flight, `update()`
243
+ // stays quiet: the checkout it runs fires the host's subscribers before its
244
+ // promise resolves, so a render lands here ahead of `confirmIntent`, and an
245
+ // uncorrelated `version-changed` posted then reaches the embed as a phantom
246
+ // external move one message before the answer to its own step. The answer
247
+ // reads the live version at settle time, so nothing the quiet window saw is
248
+ // lost.
249
+ let intentsInFlight = 0;
250
+
251
+ function runIntent(requestId: unknown, intent: () => Promise<void>): void {
252
+ intentsInFlight += 1;
253
+ const settle = (ok: boolean) => (): void => {
254
+ // Decremented in every outcome. A count this leaves above zero silences
255
+ // `update()` for the life of the bar, so an external version change
256
+ // would never reach the embed again.
257
+ intentsInFlight -= 1;
258
+ confirmIntent(requestId, ok);
259
+ };
260
+ void intent().then(settle(true), settle(false));
261
+ }
262
+
130
263
  function onMessage(event: MessageEvent): void {
131
264
  if (event.origin !== origin) return;
132
265
  if (event.source !== iframe.contentWindow) return;
133
266
  const data = event.data as Record<string, unknown> | null;
134
- if (!data || data[ENVELOPE] !== PROTOCOL_VERSION) return;
267
+ if (!data || data[EMBED_ENVELOPE] !== EMBED_PROTOCOL_VERSION) return;
135
268
  switch (data['type']) {
136
269
  case 'ready': {
137
270
  ready = true;
271
+ if (readyTimer !== undefined) {
272
+ clearTimeout(readyTimer);
273
+ readyTimer = undefined;
274
+ }
138
275
  placeholder.style.display = 'none';
139
276
  iframe.style.height = `${READY_HEIGHT}px`;
140
277
  postInit();
@@ -148,12 +285,12 @@ export function createVersionBarFrame(
148
285
  case 'preview': {
149
286
  const v = data['version'];
150
287
  if (typeof v === 'number' && Number.isInteger(v) && v >= 0) {
151
- deps.checkoutVersion(v);
288
+ runIntent(data['requestId'], () => deps.checkoutVersion(v));
152
289
  }
153
290
  break;
154
291
  }
155
292
  case 'return-to-head': {
156
- deps.returnToHead();
293
+ runIntent(data['requestId'], () => deps.returnToHead());
157
294
  break;
158
295
  }
159
296
  case 'fork': {
@@ -173,16 +310,17 @@ export function createVersionBarFrame(
173
310
  break;
174
311
  }
175
312
  case 'error': {
176
- // The embed could not render (e.g. not signed in in this context).
177
- // Collapse rather than leave a dead strip; the app still gates its
178
- // own editing on `doc.readOnly`.
179
- placeholder.style.display = 'none';
180
- iframe.style.height = '0';
313
+ // The embed loaded and reported it cannot render (not signed in in
314
+ // this context, an account mismatch).
315
+ enterFallback();
181
316
  break;
182
317
  }
183
318
  }
184
319
  }
185
320
 
321
+ // Armed here rather than beside the `src` assignment above, which runs
322
+ // before `readyTimer` is initialised.
323
+ armReadyTimer();
186
324
  window.addEventListener('message', onMessage);
187
325
 
188
326
  return {
@@ -194,28 +332,47 @@ export function createVersionBarFrame(
194
332
  // just pushes the new version down.
195
333
  if (prev && (prev.docId !== next.docId || prev.branch !== next.branch)) {
196
334
  // Reload the iframe: reset ready, restore the placeholder, and the
197
- // new page will `ready` -> we re-`init` with the new context.
335
+ // new page will `ready` -> we re-`init` with the new context. A fresh
336
+ // page gets a fresh verdict, so the failed state resets with it.
198
337
  ready = false;
338
+ failed = false;
199
339
  deliveredVersion = undefined;
340
+ fallback.hidden = true;
200
341
  placeholder.style.display = '';
201
342
  iframe.style.height = '0';
202
343
  iframe.src = url.toString();
344
+ armReadyTimer();
345
+ return;
346
+ }
347
+ if (failed) {
348
+ renderFallback();
203
349
  return;
204
350
  }
205
351
  // Only an actual version change reaches the embed (dedup), so a
206
352
  // re-render that didn't move the version can't clobber the bar's state.
207
- if (ready && next.version !== deliveredVersion) {
353
+ // A change while an intent is in flight is that intent's own doing and
354
+ // is delivered by its answer instead.
355
+ if (ready && intentsInFlight === 0 && next.version !== deliveredVersion) {
208
356
  deliveredVersion = next.version;
209
357
  iframe.contentWindow?.postMessage(
210
- { [ENVELOPE]: PROTOCOL_VERSION, type: 'version-changed', version: next.version },
358
+ { [EMBED_ENVELOPE]: EMBED_PROTOCOL_VERSION, type: 'version-changed', version: next.version },
211
359
  origin,
212
360
  );
213
361
  }
214
362
  },
215
363
  destroy(): void {
364
+ destroyed = true;
365
+ // A bar torn down mid-intent leaves the count standing; a rebuilt bar
366
+ // gets a fresh closure, but this keeps the teardown total.
367
+ intentsInFlight = 0;
368
+ if (readyTimer !== undefined) {
369
+ clearTimeout(readyTimer);
370
+ readyTimer = undefined;
371
+ }
216
372
  window.removeEventListener('message', onMessage);
217
373
  iframe.remove();
218
374
  placeholder.remove();
375
+ fallback.remove();
219
376
  },
220
377
  };
221
378
  }
package/src/version.ts CHANGED
@@ -3,6 +3,6 @@
3
3
  // can measure the version distribution of deployed apps and time
4
4
  // compatibility windows against it. A unit test pins this to
5
5
  // package.json's version; the release bump touches both together.
6
- export const SDK_VERSION = '0.1.0';
6
+ export const SDK_VERSION = '0.3.0';
7
7
 
8
8
  export const SDK_CLIENT_ID = `sdk/${SDK_VERSION}`;
package/src/vite.ts CHANGED
@@ -8,37 +8,202 @@
8
8
  // plugin excludes the dep from prebundling (leaving it for the build-time
9
9
  // WASM loaders) and bumps the prebundle target to es2022.
10
10
  //
11
- // `vite-plugin-wasm` and `vite-plugin-top-level-await` remain the caller's
12
- // responsibility: they must land before `brassVite()` in the plugin list.
11
+ // `vite-plugin-wasm` remains the caller's responsibility: it must land before
12
+ // `brassVite()` in the plugin list.
13
13
 
14
- // Names of the WASM-shipping deps the SDK pulls in. Append rather
15
- // than replace when adding a new WASM dep: a consumer who has both
16
- // installed during a migration window needs both excluded.
17
- const WASM_DEPS: readonly string[] = ['loro-crdt'];
14
+ // Every WASM-shipping dep the SDK pulls in, and the entry that loads it
15
+ // asynchronously. Add an entry rather than replacing the list: a consumer who
16
+ // has two installed during a migration window needs both handled, and this is
17
+ // the one place any of them is named, so an app's own config never is.
18
+ //
19
+ // A default browser entry fetches its module with a SYNCHRONOUS
20
+ // XMLHttpRequest while it evaluates, so an app freezes its main thread for the
21
+ // whole download before it paints, whether or not anyone opens a document, and
22
+ // the freeze grows with the reader's latency rather than with the app. An
23
+ // `asyncEntry` hands the same module to the bundler's own asset pipeline,
24
+ // which loads it asynchronously and leaves the main thread free. Selecting one
25
+ // emits a top-level await, so it is reachable only on a build target that
26
+ // accepts one, and the synchronous entry is what keeps every other target
27
+ // building.
28
+ const WASM_DEPS: readonly { specifier: string; asyncEntry: string }[] = [
29
+ { specifier: 'loro-crdt', asyncEntry: 'loro-crdt/bundler' },
30
+ ];
31
+
32
+ // A specifier matched whole. A string `find` matches by prefix, which would
33
+ // also catch the async entry the alias points AT and resolve it to itself.
34
+ const wholeSpecifier = (specifier: string): RegExp =>
35
+ new RegExp(`^${specifier.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`);
36
+
37
+ // The shape this plugin reads and returns, declared here rather than imported
38
+ // so the SDK does not depend on Vite to describe its own plugin.
39
+ //
40
+ // Each field has to be at least as WIDE as Vite's own, because Vite passes its
41
+ // `UserConfig` into the `config` hook and a hook declared over a narrower
42
+ // config is not assignable to Vite's `Plugin`. `target` is the one that bites:
43
+ // esbuild types it `string | string[]`, so declaring it `string` makes
44
+ // `plugins: [brassVite()]` fail to type-check in a consumer's own
45
+ // `vite.config.ts` (verified against Vite 5, 6 and 7; Vite 8 narrowed its own
46
+ // type and hid it). The plugin only ever passes the value through, so the
47
+ // wider type costs nothing here.
48
+ interface ViteAliasEntry {
49
+ find: string | RegExp;
50
+ replacement: string;
51
+ }
52
+
53
+ interface ViteBuildOptions {
54
+ target?: string | string[] | false;
55
+ }
18
56
 
19
57
  interface ViteUserConfig {
58
+ build?: ViteBuildOptions;
59
+ // Vite 8 lets a consumer set the browser build's target per environment, and
60
+ // that value, not the top-level one, is what the client bundle is built to.
61
+ environments?: { client?: { build?: ViteBuildOptions } };
62
+ resolve?: {
63
+ alias?: Record<string, string> | readonly ViteAliasEntry[];
64
+ };
20
65
  optimizeDeps?: {
21
66
  exclude?: string[];
22
- esbuildOptions?: { target?: string };
67
+ esbuildOptions?: { target?: string | string[] };
23
68
  };
24
69
  }
25
70
 
26
71
  interface VitePlugin {
27
72
  name: string;
28
- config(config: ViteUserConfig): ViteUserConfig;
73
+ config(config: ViteUserConfig): Promise<ViteUserConfig>;
74
+ }
75
+
76
+ // Vite 8 prebundles with rolldown, which handles top-level await on its own
77
+ // and deprecates `optimizeDeps.esbuildOptions`. Setting it there does nothing
78
+ // except print a deprecation warning naming this plugin, on every `vite dev`
79
+ // and every `vite build` a consumer runs. Read from the running Vite rather
80
+ // than from the consumer's manifest, and treat an unreadable version as an
81
+ // older Vite, where the target bump is what makes the WASM dep load at all.
82
+ //
83
+ // The specifier is held in a variable so `tsc` does not resolve it. Vite is
84
+ // neither a dependency nor a peer dependency here (the config shapes above are
85
+ // declared for the same reason), so a literal specifier fails the build
86
+ // wherever Vite is absent, which includes `npm install` in this package
87
+ // itself. At runtime the import reaches the Vite running the build.
88
+ const VITE_PACKAGE = 'vite';
89
+
90
+ async function viteMajor(): Promise<number | null> {
91
+ try {
92
+ const { version } = (await import(VITE_PACKAGE)) as { version?: string };
93
+ const major = Number.parseInt(version ?? '', 10);
94
+ return Number.isFinite(major) ? major : null;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ // Browser versions that first shipped top-level await, by the engine names
101
+ // esbuild accepts in a target string. A target naming an engine absent here is
102
+ // read as unsupported.
103
+ const TOP_LEVEL_AWAIT_SINCE: Readonly<Record<string, number>> = {
104
+ chrome: 89,
105
+ edge: 89,
106
+ firefox: 89,
107
+ safari: 15,
108
+ ios: 15,
109
+ opera: 75,
110
+ node: 14.8,
111
+ es: 2022,
112
+ hermes: Number.POSITIVE_INFINITY,
113
+ ie: Number.POSITIVE_INFINITY,
114
+ };
115
+
116
+ // Vite's own named targets. `modules` (the Vite 5 / 6 default) resolves to
117
+ // browsers predating top-level await; `baseline-widely-available` (the Vite 7 /
118
+ // 8 default) resolves to ones that carry it.
119
+ const NAMED_TARGET_SUPPORT: Readonly<Record<string, boolean>> = {
120
+ esnext: true,
121
+ 'baseline-widely-available': true,
122
+ modules: false,
123
+ };
124
+
125
+ function targetSupportsTopLevelAwait(target: string): boolean {
126
+ const t = target.trim().toLowerCase();
127
+ const named = NAMED_TARGET_SUPPORT[t];
128
+ if (named !== undefined) return named;
129
+ const parsed = /^([a-z]+)([\d.]+)$/.exec(t);
130
+ if (!parsed) return false;
131
+ const since = TOP_LEVEL_AWAIT_SINCE[parsed[1] as string];
132
+ if (since === undefined) return false;
133
+ const version = Number.parseFloat(parsed[2] as string);
134
+ return Number.isFinite(version) && version >= since;
135
+ }
136
+
137
+ // Whether the build this plugin is configuring can carry a top-level await.
138
+ // An unreadable or unrecognised target answers `false`, so the worst a wrong
139
+ // reading costs is the synchronous entry a consumer already has.
140
+ function buildAcceptsTopLevelAwait(
141
+ config: ViteUserConfig,
142
+ major: number | null,
143
+ ): boolean {
144
+ const target = config.environments?.client?.build?.target ?? config.build?.target;
145
+ // No explicit target: the answer is Vite's own default, which carries
146
+ // top-level await from Vite 7 (`baseline-widely-available`) and not before
147
+ // (`modules`).
148
+ if (target === undefined) return major !== null && major >= 7;
149
+ if (target === false) return false;
150
+ const targets = Array.isArray(target) ? target : [target];
151
+ return targets.length > 0 && targets.every(targetSupportsTopLevelAwait);
152
+ }
153
+
154
+ // A consumer who already resolves the WASM dep themselves owns that decision,
155
+ // so the plugin adds nothing that would take it over.
156
+ function aliasesWasmDep(config: ViteUserConfig): boolean {
157
+ const alias = config.resolve?.alias;
158
+ if (!alias) return false;
159
+ const specifiers = WASM_DEPS.map((dep) => dep.specifier);
160
+ if (Array.isArray(alias)) {
161
+ return alias.some((entry) =>
162
+ specifiers.some((specifier) =>
163
+ typeof entry.find === 'string'
164
+ ? entry.find === specifier
165
+ : entry.find.test(specifier),
166
+ ),
167
+ );
168
+ }
169
+ return Object.keys(alias as Record<string, string>).some((key) =>
170
+ specifiers.includes(key),
171
+ );
29
172
  }
30
173
 
31
174
  export function brassVite(): VitePlugin {
32
175
  return {
33
176
  name: 'brass-build:vite',
34
- config(config) {
177
+ async config(config) {
178
+ const major = await viteMajor();
179
+ const useAsyncEntry = buildAcceptsTopLevelAwait(config, major) && !aliasesWasmDep(config);
35
180
  const existingExclude = config.optimizeDeps?.exclude ?? [];
36
- const exclude = Array.from(new Set([...existingExclude, ...WASM_DEPS]));
181
+ const exclude = Array.from(
182
+ new Set([
183
+ ...existingExclude,
184
+ ...WASM_DEPS.map((dep) => dep.specifier),
185
+ ...(useAsyncEntry ? WASM_DEPS.map((dep) => dep.asyncEntry) : []),
186
+ ]),
187
+ );
188
+ const resolve = useAsyncEntry
189
+ ? {
190
+ resolve: {
191
+ alias: WASM_DEPS.map((dep) => ({
192
+ find: wholeSpecifier(dep.specifier),
193
+ replacement: dep.asyncEntry,
194
+ })) as ViteAliasEntry[],
195
+ },
196
+ }
197
+ : {};
198
+ if (major !== null && major >= 8) {
199
+ return { ...resolve, optimizeDeps: { exclude } };
200
+ }
37
201
  const existingTarget = config.optimizeDeps?.esbuildOptions?.target;
38
202
  // Don't downgrade if a consumer has already pinned a newer
39
203
  // target; only raise from the Vite default (which lacks TLA).
40
204
  const target = existingTarget ?? 'es2022';
41
205
  return {
206
+ ...resolve,
42
207
  optimizeDeps: {
43
208
  exclude,
44
209
  esbuildOptions: { target },
@@ -0,0 +1,66 @@
1
+ // Waiting out a document's own unsaved ops.
2
+ //
3
+ // `savingCount` counts the ops this client has committed locally and the
4
+ // server has not acked, so a caller that needs the server to hold what the
5
+ // app just wrote waits on it: the import completion, and the checkpoint
6
+ // anchor, both of which describe a state rather than an intention.
7
+
8
+ import type { Document } from './documents.js';
9
+
10
+ // How often the wait re-checks. A closing document drops its subscribers
11
+ // without firing them, so this is the only thing that ends the wait for one
12
+ // that goes while writes are draining.
13
+ const SAVED_POLL_MS = 1_000;
14
+
15
+ // How long the OLDEST outstanding op may go unacked before the wait gives
16
+ // up. An op the server never received is never acked and never re-sent
17
+ // beyond the transport's own attempts, and `savingCount` then stays above
18
+ // zero for the document's life, so a wait with no bound hangs its caller
19
+ // forever. Read off the oldest op rather than off elapsed time, so a bulk
20
+ // write that takes minutes never trips it: there, every op acks promptly
21
+ // and the oldest is always young. Three forced-resend cycles wide (the
22
+ // transport forces one at 60s), so the wait outlasts the recovery it is
23
+ // waiting on.
24
+ const SAVED_STALL_MS = 180_000;
25
+
26
+ /**
27
+ * Resolve `true` once the document has no locally-issued op waiting for its
28
+ * server ack; `false` when the document is no longer live, or the acks
29
+ * stopped arriving.
30
+ *
31
+ * `false` does not say the writes were lost, only that this wait cannot say
32
+ * they landed. A caller about to record something permanent against them (an
33
+ * import completion, a checkpoint anchor) has to stop on it.
34
+ *
35
+ * @internal
36
+ */
37
+ export function whenSaved(doc: Document): Promise<boolean> {
38
+ if (doc.savingCount === 0) return Promise.resolve(true);
39
+ return new Promise<boolean>((resolve) => {
40
+ let off: (() => void) | undefined;
41
+ let poll: ReturnType<typeof setInterval> | undefined;
42
+ const settle = (saved: boolean): void => {
43
+ off?.();
44
+ if (poll !== undefined) clearInterval(poll);
45
+ resolve(saved);
46
+ };
47
+ try {
48
+ off = doc.subscribe(() => {
49
+ if (doc.savingCount === 0) settle(true);
50
+ });
51
+ } catch {
52
+ // The document closed, so nothing more is going to be acked.
53
+ resolve(false);
54
+ return;
55
+ }
56
+ poll = setInterval(() => {
57
+ if (doc.savingCount === 0) return settle(true);
58
+ if (!doc._isLive) return settle(false);
59
+ if (doc.diagnostics().oldestUnackedMs >= SAVED_STALL_MS) settle(false);
60
+ }, SAVED_POLL_MS);
61
+ // Node keeps the process alive for a pending interval; this one is
62
+ // bookkeeping for a wait that ends on its own, so it must not.
63
+ (poll as { unref?: () => void }).unref?.();
64
+ if (doc.savingCount === 0) settle(true);
65
+ });
66
+ }