@brass-build/client 0.2.0 → 0.4.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 (168) hide show
  1. package/AGENTS.md +26 -5
  2. package/CHANGELOG.md +61 -2
  3. package/README.md +247 -87
  4. package/dist/acting-organization.d.ts +131 -0
  5. package/dist/acting-organization.d.ts.map +1 -0
  6. package/dist/acting-organization.js +278 -0
  7. package/dist/acting-organization.js.map +1 -0
  8. package/dist/api-headers.js +7 -7
  9. package/dist/api-headers.js.map +1 -1
  10. package/dist/call-service.d.ts +74 -0
  11. package/dist/call-service.d.ts.map +1 -0
  12. package/dist/call-service.js +253 -0
  13. package/dist/call-service.js.map +1 -0
  14. package/dist/client.d.ts +29 -0
  15. package/dist/client.d.ts.map +1 -1
  16. package/dist/client.js +583 -12
  17. package/dist/client.js.map +1 -1
  18. package/dist/document-checkpoints.d.ts +1 -0
  19. package/dist/document-checkpoints.d.ts.map +1 -1
  20. package/dist/document-checkpoints.js +27 -0
  21. package/dist/document-checkpoints.js.map +1 -1
  22. package/dist/document-contents.d.ts +15 -0
  23. package/dist/document-contents.d.ts.map +1 -0
  24. package/dist/document-contents.js +88 -0
  25. package/dist/document-contents.js.map +1 -0
  26. package/dist/document-signals.d.ts +31 -1
  27. package/dist/document-signals.d.ts.map +1 -1
  28. package/dist/document-signals.js +59 -0
  29. package/dist/document-signals.js.map +1 -1
  30. package/dist/document-streams.d.ts +4 -2
  31. package/dist/document-streams.d.ts.map +1 -1
  32. package/dist/document-streams.js +6 -0
  33. package/dist/document-streams.js.map +1 -1
  34. package/dist/document-thumbnail-capture.d.ts +20 -0
  35. package/dist/document-thumbnail-capture.d.ts.map +1 -1
  36. package/dist/document-thumbnail-capture.js +247 -9
  37. package/dist/document-thumbnail-capture.js.map +1 -1
  38. package/dist/documents-namespace.d.ts.map +1 -1
  39. package/dist/documents-namespace.js +3 -0
  40. package/dist/documents-namespace.js.map +1 -1
  41. package/dist/documents.d.ts +51 -3
  42. package/dist/documents.d.ts.map +1 -1
  43. package/dist/documents.js +415 -36
  44. package/dist/documents.js.map +1 -1
  45. package/dist/export-guest.d.ts.map +1 -1
  46. package/dist/export-guest.js +5 -0
  47. package/dist/export-guest.js.map +1 -1
  48. package/dist/handle-types.d.ts +23 -0
  49. package/dist/handle-types.d.ts.map +1 -1
  50. package/dist/handles.d.ts +1 -1
  51. package/dist/handles.d.ts.map +1 -1
  52. package/dist/handles.js +3 -3
  53. package/dist/handles.js.map +1 -1
  54. package/dist/hosted-ui.d.ts +10 -0
  55. package/dist/hosted-ui.d.ts.map +1 -1
  56. package/dist/hosted-ui.js +115 -30
  57. package/dist/hosted-ui.js.map +1 -1
  58. package/dist/importer.d.ts.map +1 -1
  59. package/dist/importer.js +28 -66
  60. package/dist/importer.js.map +1 -1
  61. package/dist/index.d.ts +8 -3
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +25 -1
  64. package/dist/index.js.map +1 -1
  65. package/dist/internal.d.ts +2 -0
  66. package/dist/internal.d.ts.map +1 -1
  67. package/dist/internal.js +2 -0
  68. package/dist/internal.js.map +1 -1
  69. package/dist/mutate-batching.js +2 -2
  70. package/dist/mutate-batching.js.map +1 -1
  71. package/dist/open-document.d.ts +16 -1
  72. package/dist/open-document.d.ts.map +1 -1
  73. package/dist/open-document.js +123 -35
  74. package/dist/open-document.js.map +1 -1
  75. package/dist/presence.d.ts +10 -0
  76. package/dist/presence.d.ts.map +1 -1
  77. package/dist/presence.js.map +1 -1
  78. package/dist/record-paging.d.ts +13 -14
  79. package/dist/record-paging.d.ts.map +1 -1
  80. package/dist/record-paging.js +13 -14
  81. package/dist/record-paging.js.map +1 -1
  82. package/dist/serializer.d.ts +3 -3
  83. package/dist/session-toolbar.d.ts +9 -0
  84. package/dist/session-toolbar.d.ts.map +1 -1
  85. package/dist/session-toolbar.js +191 -21
  86. package/dist/session-toolbar.js.map +1 -1
  87. package/dist/tail-ops.js +2 -2
  88. package/dist/tail-ops.js.map +1 -1
  89. package/dist/types.d.ts +0 -42
  90. package/dist/types.d.ts.map +1 -1
  91. package/dist/undo.d.ts +1 -1
  92. package/dist/undo.d.ts.map +1 -1
  93. package/dist/undo.js +1 -1
  94. package/dist/undo.js.map +1 -1
  95. package/dist/version-bar.d.ts.map +1 -1
  96. package/dist/version-bar.js +75 -4
  97. package/dist/version-bar.js.map +1 -1
  98. package/dist/version.d.ts +2 -2
  99. package/dist/version.js +1 -1
  100. package/dist/wasm-ready-gate.d.ts +13 -0
  101. package/dist/wasm-ready-gate.d.ts.map +1 -0
  102. package/dist/wasm-ready-gate.js +28 -0
  103. package/dist/wasm-ready-gate.js.map +1 -0
  104. package/dist/wasm-runtime.browser.d.ts +11 -0
  105. package/dist/wasm-runtime.browser.d.ts.map +1 -0
  106. package/dist/wasm-runtime.browser.js +44 -0
  107. package/dist/wasm-runtime.browser.js.map +1 -0
  108. package/dist/wasm-runtime.d.ts +6 -0
  109. package/dist/wasm-runtime.d.ts.map +1 -0
  110. package/dist/wasm-runtime.js +20 -0
  111. package/dist/wasm-runtime.js.map +1 -0
  112. package/dist/when-saved.d.ts +2 -0
  113. package/dist/when-saved.d.ts.map +1 -0
  114. package/dist/when-saved.js +70 -0
  115. package/dist/when-saved.js.map +1 -0
  116. package/dist/ws-client.d.ts +4 -0
  117. package/dist/ws-client.d.ts.map +1 -1
  118. package/dist/ws-client.js +70 -1
  119. package/dist/ws-client.js.map +1 -1
  120. package/dist/ws-frame-limit.d.ts.map +1 -1
  121. package/dist/ws-frame-limit.js +9 -10
  122. package/dist/ws-frame-limit.js.map +1 -1
  123. package/docs/app-manifest.md +335 -5
  124. package/docs/custom-chrome.md +7 -2
  125. package/docs/presence.md +12 -0
  126. package/docs/services.md +265 -0
  127. package/docs/text-fields.md +191 -0
  128. package/package.json +4 -5
  129. package/src/acting-organization.ts +396 -0
  130. package/src/api-headers.ts +7 -7
  131. package/src/call-service.ts +354 -0
  132. package/src/client.ts +655 -15
  133. package/src/document-checkpoints.ts +30 -0
  134. package/src/document-contents.ts +104 -0
  135. package/src/document-signals.ts +113 -1
  136. package/src/document-streams.ts +15 -1
  137. package/src/document-thumbnail-capture.ts +253 -9
  138. package/src/documents-namespace.ts +20 -10
  139. package/src/documents.ts +460 -33
  140. package/src/export-guest.ts +5 -0
  141. package/src/handle-types.ts +35 -0
  142. package/src/handles.ts +7 -6
  143. package/src/hosted-ui.ts +155 -29
  144. package/src/importer.ts +28 -62
  145. package/src/index.ts +43 -4
  146. package/src/internal.ts +10 -0
  147. package/src/mutate-batching.ts +2 -2
  148. package/src/open-document.ts +179 -39
  149. package/src/presence.ts +6 -0
  150. package/src/record-paging.ts +13 -14
  151. package/src/serializer.ts +3 -3
  152. package/src/session-toolbar.ts +219 -22
  153. package/src/tail-ops.ts +2 -2
  154. package/src/types.ts +6 -4
  155. package/src/undo.ts +1 -1
  156. package/src/version-bar.ts +77 -7
  157. package/src/version.ts +1 -1
  158. package/src/wasm-ready-gate.ts +30 -0
  159. package/src/wasm-runtime.browser.ts +48 -0
  160. package/src/wasm-runtime.ts +21 -0
  161. package/src/when-saved.ts +66 -0
  162. package/src/ws-client.ts +70 -1
  163. package/src/ws-frame-limit.ts +9 -10
  164. package/dist/vite.d.ts +0 -31
  165. package/dist/vite.d.ts.map +0 -1
  166. package/dist/vite.js +0 -161
  167. package/dist/vite.js.map +0 -1
  168. package/src/vite.ts +0 -214
@@ -13,6 +13,7 @@
13
13
  // (framer -> guest carries the snapshot; guest -> framer the bytes).
14
14
 
15
15
  import { Document } from './documents.js';
16
+ import { ensureWasmReady } from './wasm-runtime.js';
16
17
  import type { DocumentStreamInfo } from './documents-namespace.js';
17
18
  import type { StreamRuntime } from './document-streams.js';
18
19
  import type { DocumentSerializer, StoredDocument } from './serializer.js';
@@ -120,6 +121,7 @@ export function startExportGuest(): ExportGuestController {
120
121
  return res.streams;
121
122
  },
122
123
  openLine: async (lineId: string) => {
124
+ await ensureWasmReady();
123
125
  const res = (await fetchFromFramer(hostOrigin, 'stream-snapshot-request', {
124
126
  lineId,
125
127
  })) as { snapshot: Uint8Array | null; tailOps: Uint8Array[] };
@@ -161,6 +163,9 @@ export function startExportGuest(): ExportGuestController {
161
163
  // partitioned document's other streams (`doc.stream(name)`) resolves
162
164
  // them over the bridge through the runtime below, so a partitioned
163
165
  // document exports in full headless, not just its default stream.
166
+ // The engine runs for the first time here, and `Brass.init` only starts
167
+ // its fetch, so a guest that has just booted waits for it once.
168
+ await ensureWasmReady();
164
169
  const doc = snapshot
165
170
  ? Document.openFromSnapshot(snapshot, identity)
166
171
  : Document.create(identity);
@@ -301,3 +301,38 @@ export interface OrderedMapHandle<T = unknown> {
301
301
  */
302
302
  onSettled(cb: () => void): HandleUnsubscribe;
303
303
  }
304
+
305
+
306
+ /**
307
+ * A handle on a top-level text container. `roots()` reports such a container
308
+ * as kind `'text'`; this is what reads and writes one.
309
+ *
310
+ * Deliberately small. The record surface (`map` / `list` / `orderedMap`) is
311
+ * where an app models its own data, and a text root is what a document
312
+ * authored elsewhere may carry, so this covers reading it and replacing it
313
+ * rather than growing a rich-text editing surface.
314
+ */
315
+ export interface TextHandle {
316
+ /** The container's current contents. */
317
+ read(): string;
318
+ /**
319
+ * Replace the contents. Loro diffs against what is stored and applies the
320
+ * smallest edit that gets there, so a concurrent edit elsewhere in the text
321
+ * survives, where writing the whole string as one replacement would discard
322
+ * it.
323
+ */
324
+ update(next: string): void;
325
+ /** Fires once on registration with the current contents, then on every change. */
326
+ subscribe(cb: TextSubscriber): HandleUnsubscribe;
327
+ }
328
+
329
+ // A text subscriber. Fires once on registration with the current contents,
330
+ // then on every change. `origin` carries who authored the change, the way
331
+ // `MapSubscriber`'s does, and is undefined on a fire that describes no
332
+ // authored transition (registration, and the whole-container catch-up a
333
+ // checkout or a return to head produces).
334
+ //
335
+ // A field bound to a text container needs it: the value arrives on the app's
336
+ // own keystroke as well as on a peer's, and only the two peers' fires ask
337
+ // the app to move a caret that a person is sitting in.
338
+ export type TextSubscriber = (value: string, origin?: ChangeOrigin) => void;
package/src/handles.ts CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  type LoroEventBatch,
12
12
  isContainer,
13
13
  type Delta,
14
- } from 'loro-crdt';
14
+ } from './wasm-runtime.js';
15
15
 
16
16
  import {
17
17
  PAGE_TAG,
@@ -816,9 +816,10 @@ function wrapLoroListProxy(
816
816
  // reading / editing `doc.map('instances')` works unchanged. Only a root
817
817
  // the `meta` markers name reaches here.
818
818
 
819
- // A flattened entry: whatever the map stores under one key. A codec's record
820
- // store holds `{ type, params }`; an app's map holds whatever the app assigns,
821
- // which is any JSON value and which the paged path carries through unchanged.
819
+ // A flattened entry: whatever the map stores under one key. A store of typed
820
+ // records holds `{ type, params }`; an app's map holds whatever the app
821
+ // assigns, which is any JSON value and which the paged path carries through
822
+ // unchanged.
822
823
  // Deliberately not narrowed to an object: a write path that assumes fields is
823
824
  // what turns `5` into an empty container and `'hello'` into one entry per
824
825
  // character.
@@ -2034,8 +2035,8 @@ function rewritePage(
2034
2035
  const page = plainEntry(m, pageKey);
2035
2036
  if (page === undefined) return false;
2036
2037
  // The RAW triples, not the memo's unpacked records: a triple carries either
2037
- // a codec's tag beside its body or one whole value, and `repackRecord` needs
2038
- // the original to keep each one in the shape its readers expect.
2038
+ // a tag beside a body or one whole value, and `repackRecord` needs the
2039
+ // original to keep each one in the shape its readers expect.
2039
2040
  const held = decodePageRecords(page);
2040
2041
  if (held.length === 0) return false;
2041
2042
  const next = new Map(group);
package/src/hosted-ui.ts CHANGED
@@ -19,8 +19,10 @@
19
19
  // document picker).
20
20
  //
21
21
  // Bridge protocol (envelope `__brass_embed: 1`):
22
- // iframe -> host: ready | resize{height} | result{payload} | cancel |
23
- // error{code,message}
22
+ // iframe -> host: ready | booted | resize{height} | result{payload} |
23
+ // cancel | error{code,message}
24
+ // `ready` says the frame is alive and answers the timeout below; `booted`
25
+ // says the embed page has drawn something and is what takes the loader off.
24
26
  // host -> iframe: init{surface,params}
25
27
  // The host validates `event.origin === dashboardOrigin` and
26
28
  // `event.source === iframe.contentWindow` on every inbound message; the embed
@@ -36,17 +38,28 @@ import { EMBED_ENVELOPE, EMBED_PROTOCOL_VERSION } from './embed-protocol.js';
36
38
  //
37
39
  // Only a plain `redirect` can afford the short window: the surface returns
38
40
  // nothing, the standalone page finishes the same job, and the user keeps
39
- // their place, so eating ten seconds of blank modal is the bigger cost.
41
+ // their place, so eating four seconds of blank modal is the bigger cost.
40
42
  //
41
43
  // `reject` and `redirect-with-value` both end with the caller worse off (a
42
44
  // thrown error, or the user navigated out of the host app with the value
43
- // routed home through a URL round trip), so they wait long enough that only
44
- // a genuinely unusable iframe times out. The embed page is a full
45
- // application boot behind a cross-origin request, so a merely slow link
46
- // clears the short window while the iframe is perfectly capable.
45
+ // routed home through a URL round trip), so they wait longer.
46
+ //
47
+ // The embed page answers from a script that runs before its application, so
48
+ // what either window measures is a round trip to the dashboard origin rather
49
+ // than a boot. That is what keeps a slow link on the iframe path: the reader
50
+ // waits out the load inside the app they are in, which is the point of the
51
+ // iframe, instead of being handed a fallback on the strength of how long the
52
+ // embed took to render.
47
53
  const READY_TIMEOUT_LONG_MS = 10_000;
48
54
  const READY_TIMEOUT_SHORT_MS = 4_000;
49
55
 
56
+ // How long a frame that answered `ready` gets to draw something before the
57
+ // spinner comes off anyway. `booted` is what normally takes it off; this
58
+ // covers a boot that dies after the script that pings, and an embed page
59
+ // older than that message. Long, because the cost of being early here is the
60
+ // blank panel the spinner exists to prevent, and the reader can dismiss.
61
+ const REVEAL_TIMEOUT_MS = 10_000;
62
+
50
63
  // What a launcher does when the handshake never completes. See the timeout
51
64
  // constants above for the window each one gets.
52
65
  type UnavailableMode = 'reject' | 'redirect' | 'redirect-with-value';
@@ -106,6 +119,15 @@ export interface AcquireOrganizationResult {
106
119
  organization_id: string;
107
120
  }
108
121
 
122
+ // Result of the internal select-organization launcher: the organization the
123
+ // person chose, and the name to show for it. The surface answers the question
124
+ // on its own, so a caller that needs the app installed there raises the
125
+ // install dialog after this one settles.
126
+ export interface SelectOrganizationResult {
127
+ organization_id: string;
128
+ name: string;
129
+ }
130
+
109
131
  // Result of the internal document-history launcher (opened by the
110
132
  // session's `history` file-menu command). Three outcomes:
111
133
  // - `{ branch_id }`: forked a NEW branch; open editable with
@@ -143,6 +165,14 @@ export interface InstallAppResult {
143
165
  app_name: string | null;
144
166
  }
145
167
 
168
+ // Result of the internal service-credential launcher: the hosts that became
169
+ // usable, so a caller retries the call the refusal interrupted only when the
170
+ // host it named is among them. A reader may settle one host of two and leave
171
+ // the other, so a plain "the dialog closed" says less than this.
172
+ export interface ServiceCredentialResult {
173
+ ready: string[];
174
+ }
175
+
146
176
  // Result of the `drive-resolve` surface (from `Document.resolveDrive()`):
147
177
  // how a `behind` / `diverged` drive-linked doc was resolved. `null` when
148
178
  // dismissed.
@@ -223,6 +253,16 @@ export interface HostedUiLauncher {
223
253
  docId: string,
224
254
  options?: { branch?: string },
225
255
  ): Promise<{ branch_id: string } | null>;
256
+ // Hosted organization picker: one row per organization the caller belongs
257
+ // to, plus an entry that creates or joins one. It asks which organization
258
+ // and nothing else, so a caller whose act then needs an install raises the
259
+ // install dialog against the answer. `currentOrganizationId` marks the one
260
+ // the app is acting in now, where there is one. Resolves with the choice or
261
+ // `null` on cancel. Iframe-only with a `BrassError` reject (the caller needs
262
+ // the id back).
263
+ selectOrganization(
264
+ currentOrganizationId?: string,
265
+ ): Promise<SelectOrganizationResult | null>;
226
266
  // Hosted install-consent UI: the org admin reviews the requested scopes in
227
267
  // plain language and confirms / rejects; on confirm the backend writes the
228
268
  // `ORGANIZATION#/INSTALL#` row. Resolves with the row or `null` on cancel.
@@ -236,12 +276,26 @@ export interface HostedUiLauncher {
236
276
  // switch onto); publish-over resolves in place (`newDocId: null`). Resolves
237
277
  // with the outcome or `null`. Iframe-only with a `BrassError` reject.
238
278
  driveResolve(docId: string): Promise<DriveResolveResult | null>;
279
+ // Hosted surface for a service credential this app is waiting on. Whoever
280
+ // can supply it connects the account and admits this app to it; anyone else
281
+ // is told who to ask. Resolves with the hosts that became usable, or `null`
282
+ // on cancel.
283
+ // Iframe-only with a `BrassError` reject (the caller needs the hosts back).
284
+ serviceCredential(
285
+ organizationId: string,
286
+ host: string,
287
+ ): Promise<ServiceCredentialResult | null>;
239
288
  }
240
289
 
241
290
  export interface HostedUiLauncherOptions {
242
291
  // Origin the embed pages are served from. `Brass.init`'s `dashboardBaseUrl`,
243
292
  // defaulted by the client.
244
293
  dashboardBaseUrl: string;
294
+ // The organization the app is working in, read at launch time rather than
295
+ // captured, since a document opening moves it. Scopes the picker's opening
296
+ // list; absent leaves it on everything the caller can reach, which is what
297
+ // an app that has not resolved one yet should show.
298
+ actingOrganizationId?: () => string | null;
245
299
  // Threaded into param payloads for surfaces that need it (`installApp`).
246
300
  // Every Brass app shares one sign-in client, so the JWT can't
247
301
  // disambiguate the asking app; the embed page reads `appId` off the bridge.
@@ -407,6 +461,21 @@ export function trackHostedUiActivations(): void {
407
461
  document.addEventListener('keydown', trackActivation, true);
408
462
  }
409
463
 
464
+ // Whether this surface is the one on top. An app can open a second surface
465
+ // over the first (a toolbar command while a picker is up, a trigger pressed
466
+ // twice), and each one listens for Escape on `window`, where
467
+ // `stopPropagation` does not reach a sibling listener on the same target, so
468
+ // one Escape would dismiss every surface at once and resolve `null` to
469
+ // callers whose dialog the reader never touched. Read off the document rather
470
+ // than a registry the launcher maintains: the surfaces are appended in the
471
+ // order they open, so the last one in document order is the last one in the
472
+ // top layer, and an overlay removed by anything at all stops counting the
473
+ // moment it leaves.
474
+ function isTopmostSurface(overlay: HTMLElement): boolean {
475
+ const open = document.querySelectorAll('[data-brass-hosted-ui]');
476
+ return open[open.length - 1] === overlay;
477
+ }
478
+
410
479
  // Who a dismissal should put the keyboard back on: whatever holds focus when
411
480
  // the surface opens, or the last control the reader pressed when nothing does.
412
481
  function resolveOpener(): HTMLElement | null {
@@ -555,11 +624,11 @@ function openEmbed(
555
624
  card.appendChild(closeButton);
556
625
  card.appendChild(iframe);
557
626
 
558
- // Loading spinner over the iframe until the `ready` handshake. Without it
559
- // the user sees a blank panel for ~300-1000ms while the iframe boots,
560
- // reading as "the click did nothing". It can't sit BEHIND the iframe
561
- // because the iframe paints its own background synchronously (the
562
- // OS-color-flash defense above), so it goes on top and hides on `ready`.
627
+ // Loading spinner over the iframe until the embed page reports `booted`.
628
+ // Without it the user sees a blank panel while the iframe boots, reading
629
+ // as "the click did nothing". It can't sit BEHIND the iframe because the
630
+ // iframe paints its own background synchronously (the OS-color-flash
631
+ // defense above), so it goes on top.
563
632
  const loader = document.createElement('div');
564
633
  Object.assign(loader.style, {
565
634
  position: 'absolute',
@@ -596,6 +665,8 @@ function openEmbed(
596
665
 
597
666
  let settled = false;
598
667
  let readyTimer: ReturnType<typeof setTimeout> | undefined;
668
+ let revealTimer: ReturnType<typeof setTimeout> | undefined;
669
+ let revealed = false;
599
670
 
600
671
  // Where to put the reader back. Dismissal detaches the dialog rather than
601
672
  // closing it in place, so this is the launcher's job; a reader left on
@@ -624,11 +695,31 @@ function openEmbed(
624
695
  window.removeEventListener('keydown', onKeydown, true);
625
696
  overlay.removeEventListener('cancel', onCancel);
626
697
  if (readyTimer !== undefined) clearTimeout(readyTimer);
698
+ if (revealTimer !== undefined) clearTimeout(revealTimer);
627
699
  if (overlay.open) overlay.close();
628
700
  overlay.remove();
629
701
  restoreFocus();
630
702
  }
631
703
 
704
+ // Hand the frame to the reader: the embed page has drawn something, so
705
+ // the spinner comes off and the keyboard goes in.
706
+ function revealFrame(): void {
707
+ if (revealed) return;
708
+ revealed = true;
709
+ if (revealTimer !== undefined) clearTimeout(revealTimer);
710
+ // Delayed removal so the last 120ms is a fade, not a snap.
711
+ loader.style.opacity = '0';
712
+ window.setTimeout(() => {
713
+ loader.style.display = 'none';
714
+ }, 150);
715
+ // Opening the dialog leaves focus on the close button, which is the
716
+ // right place to wait out the load and the wrong place to stay once
717
+ // there is something inside to receive the keyboard. Only when the
718
+ // reader has not moved in the meantime, since taking focus back off a
719
+ // control they tabbed to during the load is worse than leaving it.
720
+ if (document.activeElement === closeButton) iframe.focus();
721
+ }
722
+
632
723
  function settleResolve(value: EmbedOutcome | null): void {
633
724
  if (settled) return;
634
725
  settled = true;
@@ -653,24 +744,26 @@ function openEmbed(
653
744
 
654
745
  switch (data['type']) {
655
746
  case 'ready': {
747
+ // The frame is alive, which is the whole of what the window above
748
+ // asks. The embed page posts this from a pre-boot script, so it
749
+ // arrives while the application behind it is still loading and
750
+ // there is nothing to look at yet.
656
751
  if (readyTimer !== undefined) clearTimeout(readyTimer);
657
752
  iframe.contentWindow?.postMessage(
658
753
  { [EMBED_ENVELOPE]: EMBED_PROTOCOL_VERSION, type: 'init', surface, params },
659
754
  dashboardOrigin,
660
755
  );
661
- // Embed page mounted; fade the loader out (delayed removal so the
662
- // last 120ms is a fade, not a snap).
663
- loader.style.opacity = '0';
664
- window.setTimeout(() => {
665
- loader.style.display = 'none';
666
- }, 150);
667
- // Hand the keyboard to the embed now that there is something inside
668
- // to receive it: opening the dialog leaves focus on the close
669
- // button, which is the right place to wait while the frame boots and
670
- // the wrong place to stay once it has. Only when the reader has not
671
- // moved in the meantime, since taking focus back off a control they
672
- // tabbed to during the load is worse than leaving it where it is.
673
- if (document.activeElement === closeButton) iframe.focus();
756
+ if (revealTimer === undefined && !revealed) {
757
+ // A frame that comes alive and then draws nothing (a boot that
758
+ // dies past its first script) would otherwise sit under the
759
+ // spinner for as long as the reader waits. Show them what is
760
+ // there instead, and let them close it.
761
+ revealTimer = setTimeout(revealFrame, REVEAL_TIMEOUT_MS);
762
+ }
763
+ break;
764
+ }
765
+ case 'booted': {
766
+ revealFrame();
674
767
  break;
675
768
  }
676
769
  case 'resize': {
@@ -765,10 +858,9 @@ function openEmbed(
765
858
  // page relays it as a `cancel` over the bridge. Capture phase so the
766
859
  // foreground modal wins over any Escape handler the host app registered.
767
860
  function onKeydown(event: KeyboardEvent): void {
768
- if (event.key === 'Escape' && !settled) {
769
- event.stopPropagation();
770
- settleResolve(null);
771
- }
861
+ if (event.key !== 'Escape' || settled || !isTopmostSurface(overlay)) return;
862
+ event.stopPropagation();
863
+ settleResolve(null);
772
864
  }
773
865
  window.addEventListener('keydown', onKeydown, true);
774
866
 
@@ -778,6 +870,7 @@ function openEmbed(
778
870
  // the backstop for an Escape it does not see.
779
871
  function onCancel(event: Event): void {
780
872
  event.preventDefault();
873
+ if (!isTopmostSurface(overlay)) return;
781
874
  settleResolve(null);
782
875
  }
783
876
  overlay.addEventListener('cancel', onCancel);
@@ -904,6 +997,14 @@ export function createHostedUiLauncher(
904
997
  // this app can open" filter.
905
998
  const params: Record<string, unknown> = {};
906
999
  if (opts.callingAppId !== undefined) params['appId'] = opts.callingAppId;
1000
+ // Where the app is working, so the picker opens there rather than on
1001
+ // every document the person can reach. Without it the first list an app
1002
+ // shows spans organizations the app is not installed on, and picking
1003
+ // one of those moves the app somewhere the person did not choose to be.
1004
+ // The picker's own Go-to still reaches everywhere, including work
1005
+ // shared directly from an organization they do not belong to.
1006
+ const actingOrganizationId = opts.actingOrganizationId?.();
1007
+ if (actingOrganizationId) params['organizationId'] = actingOrganizationId;
907
1008
  // Where the iframe can't be hosted (cookie-partitioning browsers), the
908
1009
  // picker falls back to a full-page redirect: the standalone page returns
909
1010
  // the pick on these one-shot params, which the SDK consumes on boot to
@@ -999,6 +1100,20 @@ export function createHostedUiLauncher(
999
1100
  if (options?.branch !== undefined) params['branch'] = options.branch;
1000
1101
  return launch<{ branch_id: string }>('select-branch', params, 'reject');
1001
1102
  },
1103
+ async selectOrganization(
1104
+ currentOrganizationId?: string,
1105
+ ): Promise<SelectOrganizationResult | null> {
1106
+ // The list is read on the dashboard origin and never crosses the
1107
+ // bridge; only the chosen organization comes back. `appId` rides it for
1108
+ // the same reason the install dialog needs it: one sign-in client
1109
+ // serves every app, so the JWT cannot say which app is asking.
1110
+ const params: Record<string, unknown> = {};
1111
+ if (opts.callingAppId !== undefined) params['appId'] = opts.callingAppId;
1112
+ if (currentOrganizationId !== undefined) {
1113
+ params['organizationId'] = currentOrganizationId;
1114
+ }
1115
+ return launch<SelectOrganizationResult>('select-organization', params, 'reject');
1116
+ },
1002
1117
  async installApp(organizationId?: string): Promise<InstallAppResult | null> {
1003
1118
  // Thread the caller's `appId` over the bridge so the embed page renders
1004
1119
  // the right app's name + scopes (every Brass app shares one sign-in
@@ -1012,5 +1127,16 @@ export function createHostedUiLauncher(
1012
1127
  async driveResolve(docId: string): Promise<DriveResolveResult | null> {
1013
1128
  return launch<DriveResolveResult>('drive-resolve', { docId }, 'reject');
1014
1129
  },
1130
+ async serviceCredential(
1131
+ organizationId: string,
1132
+ host: string,
1133
+ ): Promise<ServiceCredentialResult | null> {
1134
+ // `appId` rides the bridge for the same reason the install dialog needs
1135
+ // it: one sign-in client serves every app, so the JWT cannot say which
1136
+ // app is asking, and this screen grants that app in particular.
1137
+ const params: Record<string, unknown> = { organizationId, host };
1138
+ if (opts.callingAppId !== undefined) params['appId'] = opts.callingAppId;
1139
+ return launch<ServiceCredentialResult>('service-credential', params, 'reject');
1140
+ },
1015
1141
  };
1016
1142
  }
package/src/importer.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Document } from './documents.js';
2
2
  import type { DocumentSourceBlob } from './documents-namespace.js';
3
3
  import { BrassError } from './errors.js';
4
+ import { whenSaved } from './when-saved.js';
4
5
 
5
6
  // The app's import half for a file Brass does not parse: given the stored
6
7
  // source file and the open document, parse the bytes and write the records
@@ -72,9 +73,17 @@ const CLAIM_REFUSED_STATUS = 422;
72
73
  // protocol is testable against mocked HTTP deps.
73
74
  export function createImportRunner(deps: ImportRunnerDeps): ImportRunner {
74
75
  let importer: DocumentImporter | null = null;
75
- // One attempt per Document instance; a reopen mints a new instance,
76
- // whose claim resolves `already_imported` as a no-op.
77
- const attempted = new WeakSet<Document>();
76
+ // One attempt per OPEN, recorded as the open generation the attempt was
77
+ // made in. The mark cannot be per-instance: an app's `close()` parks the
78
+ // Document and the park cache hands the SAME instance back on reopen, so a
79
+ // bare mark says "this document already had its attempt" for the life of the
80
+ // page. Every path that leaves the file unimported (the claim failing, the
81
+ // importer throwing, the writes never draining) is then unrecoverable
82
+ // without a reload, and the reopen the reader reaches for does nothing and
83
+ // says nothing. Keyed on the open, a reopen attempts again; a document whose
84
+ // import already finished spends one claim that resolves `already_imported`,
85
+ // which is what a reopen has always cost on a fresh instance.
86
+ const attempted = new WeakMap<Document, number>();
78
87
  // Documents waiting on server-side work, holding the detach for the status
79
88
  // listener that is watching it. Kept apart from `attempted` so the wait does
80
89
  // not consume the attempt, and holding the DETACH rather than a bare mark so
@@ -93,7 +102,10 @@ export function createImportRunner(deps: ImportRunnerDeps): ImportRunner {
93
102
  importer = fn;
94
103
  },
95
104
  async attempt(doc: Document): Promise<void> {
96
- if (importer === null || doc.id === null || attempted.has(doc)) return;
105
+ if (importer === null || doc.id === null) return;
106
+ if (attempted.has(doc) && attempted.get(doc) === doc._openGeneration) {
107
+ return;
108
+ }
97
109
  // Claiming an import binds the document to THIS client for the claim's
98
110
  // lifetime, and only this client can complete it. A document that is
99
111
  // closed or parked can no longer read its source or write records, so a
@@ -124,7 +136,7 @@ export function createImportRunner(deps: ImportRunnerDeps): ImportRunner {
124
136
  waiting.set(doc, off);
125
137
  } catch {
126
138
  // The document closed while attaching; nothing left to import
127
- // into, and a reopen mints a fresh instance that attempts again.
139
+ // into, and the attempt is unspent, so the reopen attempts again.
128
140
  }
129
141
  return;
130
142
  }
@@ -132,7 +144,7 @@ export function createImportRunner(deps: ImportRunnerDeps): ImportRunner {
132
144
  // rather than leave it to fire against a document already past it.
133
145
  waiting.get(doc)?.();
134
146
  waiting.delete(doc);
135
- attempted.add(doc);
147
+ attempted.set(doc, doc._openGeneration);
136
148
  const run = importer;
137
149
  const docId = doc.id;
138
150
  let claim: ImportClaimResponse;
@@ -174,6 +186,7 @@ export function createImportRunner(deps: ImportRunnerDeps): ImportRunner {
174
186
  const stopWatchingWrites = doc.subscribe(() => {
175
187
  if (doc.savingCount > 0) wroteRecords = true;
176
188
  });
189
+ let saved = false;
177
190
  try {
178
191
  await run(source, doc);
179
192
  // The importer's ops are still in flight when it returns, and
@@ -181,19 +194,19 @@ export function createImportRunner(deps: ImportRunnerDeps): ImportRunner {
181
194
  // the head it lands on. Completing before they are acked stamps
182
195
  // a version below the records' own, which reads as a document
183
196
  // edited since it was uploaded from then on.
184
- await whenSaved(doc);
197
+ saved = await whenSaved(doc);
185
198
  } finally {
186
199
  stopRefresh();
187
200
  stopWatchingWrites();
188
201
  }
189
- // The wait above ends on the document going as well as on its writes
190
- // draining, and only one of those means the records are stored. The
191
- // completion stamps the document with the app's schema at the version
192
- // it lands on, so running it over writes that never reached the server
193
- // records an import of records the document does not hold. Abandoning
194
- // instead leaves the claim to expire, and a client that is still open
195
- // imports the file properly.
196
- if (!doc._isLive) return;
202
+ // The wait ends on the document going, and on the acks stopping, as
203
+ // well as on the writes draining, and only the last of those means
204
+ // the records are stored. The completion stamps the document with the
205
+ // app's schema at the version it lands on, so running it over writes
206
+ // that never reached the server records an import of records the
207
+ // document does not hold. Abandoning instead leaves the claim to
208
+ // expire, and a client that is still open imports the file properly.
209
+ if (!saved) return;
197
210
  // An importer that returns without writing anything must not stamp the
198
211
  // document imported: the stamp is permanent, a later open reads
199
212
  // `already_imported`, and the file's records then have no path into a
@@ -270,53 +283,6 @@ export function createImportRunner(deps: ImportRunnerDeps): ImportRunner {
270
283
  return runner;
271
284
  }
272
285
 
273
- // How often the wait below re-checks that the document is still live. A
274
- // closing document drops its subscribers without firing them, so this is the
275
- // only thing that ends the wait for one that goes mid-import; it runs solely
276
- // while an import's writes are draining.
277
- const SAVED_LIVENESS_POLL_MS = 1_000;
278
-
279
- // Resolves once the document has no locally-issued op waiting for its
280
- // server ack, or once the document is no longer live.
281
- //
282
- // The liveness half is not belt-and-braces. Closing a Document CLEARS its
283
- // subscribers rather than firing them, so a wait that watched the ack count
284
- // alone would never settle for a document closed with writes outstanding, and
285
- // the caller's `finally` (which stops renewing the platform's import claim)
286
- // would never run. Resolving here does not claim the writes landed: the
287
- // completion that follows is what asserts that, and it fails against a
288
- // document this client can no longer write.
289
- function whenSaved(doc: Document): Promise<void> {
290
- if (doc.savingCount === 0) return Promise.resolve();
291
- return new Promise<void>((resolve) => {
292
- let off: (() => void) | undefined;
293
- let poll: ReturnType<typeof setInterval> | undefined;
294
- const settle = (): void => {
295
- off?.();
296
- if (poll !== undefined) clearInterval(poll);
297
- resolve();
298
- };
299
- try {
300
- off = doc.subscribe(() => {
301
- if (doc.savingCount > 0) return;
302
- settle();
303
- });
304
- } catch {
305
- // The document closed, so nothing more is going to be acked.
306
- resolve();
307
- return;
308
- }
309
- poll = setInterval(() => {
310
- if (doc._isLive && doc.savingCount > 0) return;
311
- settle();
312
- }, SAVED_LIVENESS_POLL_MS);
313
- // Node keeps the process alive for a pending interval; this one is
314
- // bookkeeping for a wait that ends on its own, so it must not.
315
- (poll as { unref?: () => void }).unref?.();
316
- if (doc.savingCount === 0) settle();
317
- });
318
- }
319
-
320
286
  function asError(err: unknown): Error {
321
287
  return err instanceof Error ? err : new Error(String(err));
322
288
  }
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ import type {
14
14
  OpenedDocument,
15
15
  } from './open-document.js';
16
16
  import type { SelectDocumentResult } from './hosted-ui.js';
17
+ import type { AppServiceState, CallServiceOptions } from './call-service.js';
17
18
  import type { DocumentsNamespace } from './documents-namespace.js';
18
19
  import type { DocumentImporter } from './importer.js';
19
20
  import type { DocumentSerializer } from './serializer.js';
@@ -36,6 +37,11 @@ export type {
36
37
  UserChangeListener,
37
38
  };
38
39
  export { BrassError, type BrassErrorCode } from './errors.js';
40
+ export type {
41
+ AppServiceState,
42
+ CallServiceBody,
43
+ CallServiceOptions,
44
+ } from './call-service.js';
39
45
  export {
40
46
  type HostedUiTheme,
41
47
  type SelectDocumentResult,
@@ -94,6 +100,7 @@ export {
94
100
  type DocumentDriveTrackingState,
95
101
  type DriveMirrorDirection,
96
102
  type EntityProxy,
103
+ type RootKind,
97
104
  type UndoState,
98
105
  type UndoStateSubscriber,
99
106
  } from './documents.js';
@@ -110,6 +117,8 @@ export type {
110
117
  MapSubscriber,
111
118
  OrderedMapHandle,
112
119
  OrderedMapPosition,
120
+ TextHandle,
121
+ TextSubscriber,
113
122
  } from './handle-types.js';
114
123
 
115
124
  export type {
@@ -140,16 +149,21 @@ export type {
140
149
  const client = new BrassClient();
141
150
 
142
151
  // A Brass-hosted bundle (`*.onbrass.app`) resolves its app id and
143
- // environment from the serving slot, so it uses the zero-argument form;
144
- // everywhere else the app id is required.
152
+ // environment from the serving slot, so it takes no id; everywhere else the
153
+ // id is supplied. One bundle does both, so the id argument accepts
154
+ // `undefined` and an app can pass whatever its build resolved to without
155
+ // branching on it.
145
156
  function init(options?: BrassInitOptions): Promise<void>;
146
- function init(appId: string, options?: BrassInitOptions): Promise<void>;
157
+ function init(appId: string | undefined, options?: BrassInitOptions): Promise<void>;
147
158
  function init(
148
159
  appIdOrOptions?: string | BrassInitOptions,
149
160
  options?: BrassInitOptions,
150
161
  ): Promise<void> {
151
162
  if (typeof appIdOrOptions === 'string') return client.init(appIdOrOptions, options);
152
- return client.init(undefined, appIdOrOptions);
163
+ // With no id in the first position the options are in whichever argument
164
+ // the caller used: `init(options)` on a hosted bundle, `init(undefined,
165
+ // options)` from an app that resolves its id at build time.
166
+ return client.init(undefined, appIdOrOptions ?? options);
153
167
  }
154
168
 
155
169
  export const Brass = {
@@ -180,6 +194,31 @@ export const Brass = {
180
194
  getAccessToken(): Promise<string | null> {
181
195
  return client.getAccessToken();
182
196
  },
197
+ // One call to an outside service the app's manifest declares, under a
198
+ // credential Brass holds. Returns the service's own response.
199
+ callService(
200
+ host: string,
201
+ path: string,
202
+ options?: CallServiceOptions,
203
+ ): Promise<Response> {
204
+ return client.callService(host, path, options);
205
+ },
206
+ // Which of this app's declared services would serve a call right now, so a
207
+ // feature that works better with an account connected renders as available
208
+ // or as an upgrade before the reader presses anything.
209
+ //
210
+ // Null while the app is acting in no organization yet, or in one that has
211
+ // not installed it, which is a state to render the feature as available on.
212
+ // `connectService` asks when the reader reaches for it.
213
+ listServices(): Promise<AppServiceState[] | null> {
214
+ return client.listServices();
215
+ },
216
+ // Open the hosted dialog for one service from the app's own affordance.
217
+ // Whoever can supply the credential does it there; anyone else reads who to
218
+ // ask. Resolves true when that host became usable.
219
+ connectService(host: string): Promise<boolean> {
220
+ return client.connectService(host);
221
+ },
183
222
  // Metadata read for a known id (`get`) plus thumbnails. Create / move /
184
223
  // list / grant access are hosted-only.
185
224
  get documents(): DocumentsNamespace {