@brass-build/client 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +50 -7
- package/CHANGELOG.md +63 -2
- package/README.md +233 -77
- package/dist/acting-organization.d.ts +131 -0
- package/dist/acting-organization.d.ts.map +1 -0
- package/dist/acting-organization.js +273 -0
- package/dist/acting-organization.js.map +1 -0
- package/dist/api-headers.js +7 -7
- package/dist/api-headers.js.map +1 -1
- package/dist/call-service.d.ts +74 -0
- package/dist/call-service.d.ts.map +1 -0
- package/dist/call-service.js +253 -0
- package/dist/call-service.js.map +1 -0
- package/dist/client.d.ts +31 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +611 -12
- package/dist/client.js.map +1 -1
- package/dist/document-contents.d.ts +15 -0
- package/dist/document-contents.d.ts.map +1 -0
- package/dist/document-contents.js +88 -0
- package/dist/document-contents.js.map +1 -0
- package/dist/document-streams.d.ts +4 -2
- package/dist/document-streams.d.ts.map +1 -1
- package/dist/document-streams.js +6 -0
- package/dist/document-streams.js.map +1 -1
- package/dist/documents.d.ts +45 -1
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +305 -22
- package/dist/documents.js.map +1 -1
- package/dist/export-guest.d.ts.map +1 -1
- package/dist/export-guest.js +5 -0
- package/dist/export-guest.js.map +1 -1
- package/dist/handle-types.d.ts +23 -0
- package/dist/handle-types.d.ts.map +1 -1
- package/dist/handles.d.ts +1 -1
- package/dist/handles.d.ts.map +1 -1
- package/dist/handles.js +1 -1
- package/dist/handles.js.map +1 -1
- package/dist/hosted-ui.d.ts +10 -0
- package/dist/hosted-ui.d.ts.map +1 -1
- package/dist/hosted-ui.js +115 -30
- package/dist/hosted-ui.js.map +1 -1
- package/dist/importer.d.ts.map +1 -1
- package/dist/importer.js +17 -6
- package/dist/importer.js.map +1 -1
- package/dist/index.d.ts +8 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -1
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +1 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +1 -0
- package/dist/internal.js.map +1 -1
- package/dist/mutate-batching.js +2 -2
- package/dist/mutate-batching.js.map +1 -1
- package/dist/open-document.d.ts +1 -1
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +69 -34
- package/dist/open-document.js.map +1 -1
- package/dist/presence.d.ts +10 -0
- package/dist/presence.d.ts.map +1 -1
- package/dist/presence.js.map +1 -1
- package/dist/session-toolbar.d.ts +9 -0
- package/dist/session-toolbar.d.ts.map +1 -1
- package/dist/session-toolbar.js +60 -12
- package/dist/session-toolbar.js.map +1 -1
- package/dist/tail-ops.js +2 -2
- package/dist/tail-ops.js.map +1 -1
- package/dist/types.d.ts +0 -42
- package/dist/types.d.ts.map +1 -1
- package/dist/undo.d.ts +1 -1
- package/dist/undo.d.ts.map +1 -1
- package/dist/undo.js +1 -1
- package/dist/undo.js.map +1 -1
- package/dist/version-bar.d.ts.map +1 -1
- package/dist/version-bar.js +52 -2
- package/dist/version-bar.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/dist/wasm-ready-gate.d.ts +13 -0
- package/dist/wasm-ready-gate.d.ts.map +1 -0
- package/dist/wasm-ready-gate.js +28 -0
- package/dist/wasm-ready-gate.js.map +1 -0
- package/dist/wasm-runtime.browser.d.ts +11 -0
- package/dist/wasm-runtime.browser.d.ts.map +1 -0
- package/dist/wasm-runtime.browser.js +44 -0
- package/dist/wasm-runtime.browser.js.map +1 -0
- package/dist/wasm-runtime.d.ts +6 -0
- package/dist/wasm-runtime.d.ts.map +1 -0
- package/dist/wasm-runtime.js +20 -0
- package/dist/wasm-runtime.js.map +1 -0
- package/docs/app-manifest.md +335 -5
- package/docs/custom-chrome.md +3 -0
- package/docs/presence.md +12 -0
- package/docs/services.md +265 -0
- package/docs/text-fields.md +191 -0
- package/package.json +4 -5
- package/src/acting-organization.ts +390 -0
- package/src/api-headers.ts +7 -7
- package/src/call-service.ts +354 -0
- package/src/client.ts +680 -14
- package/src/document-contents.ts +104 -0
- package/src/document-streams.ts +15 -1
- package/src/documents.ts +340 -20
- package/src/export-guest.ts +5 -0
- package/src/handle-types.ts +35 -0
- package/src/handles.ts +1 -1
- package/src/hosted-ui.ts +155 -29
- package/src/importer.ts +17 -6
- package/src/index.ts +43 -4
- package/src/internal.ts +4 -0
- package/src/mutate-batching.ts +2 -2
- package/src/open-document.ts +79 -38
- package/src/presence.ts +6 -0
- package/src/session-toolbar.ts +80 -12
- package/src/tail-ops.ts +2 -2
- package/src/types.ts +6 -4
- package/src/undo.ts +1 -1
- package/src/version-bar.ts +53 -2
- package/src/version.ts +1 -1
- package/src/wasm-ready-gate.ts +30 -0
- package/src/wasm-runtime.browser.ts +48 -0
- package/src/wasm-runtime.ts +21 -0
- package/dist/vite.d.ts +0 -31
- package/dist/vite.d.ts.map +0 -1
- package/dist/vite.js +0 -161
- package/dist/vite.js.map +0 -1
- package/src/vite.ts +0 -214
package/src/export-guest.ts
CHANGED
|
@@ -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);
|
package/src/handle-types.ts
CHANGED
|
@@ -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
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} |
|
|
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
|
|
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
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
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
|
|
559
|
-
// the user sees a blank panel
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
//
|
|
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
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
|
769
|
-
|
|
770
|
-
|
|
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
|
@@ -73,9 +73,17 @@ const CLAIM_REFUSED_STATUS = 422;
|
|
|
73
73
|
// protocol is testable against mocked HTTP deps.
|
|
74
74
|
export function createImportRunner(deps: ImportRunnerDeps): ImportRunner {
|
|
75
75
|
let importer: DocumentImporter | null = null;
|
|
76
|
-
// One attempt per
|
|
77
|
-
//
|
|
78
|
-
|
|
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>();
|
|
79
87
|
// Documents waiting on server-side work, holding the detach for the status
|
|
80
88
|
// listener that is watching it. Kept apart from `attempted` so the wait does
|
|
81
89
|
// not consume the attempt, and holding the DETACH rather than a bare mark so
|
|
@@ -94,7 +102,10 @@ export function createImportRunner(deps: ImportRunnerDeps): ImportRunner {
|
|
|
94
102
|
importer = fn;
|
|
95
103
|
},
|
|
96
104
|
async attempt(doc: Document): Promise<void> {
|
|
97
|
-
if (importer === null || doc.id === null
|
|
105
|
+
if (importer === null || doc.id === null) return;
|
|
106
|
+
if (attempted.has(doc) && attempted.get(doc) === doc._openGeneration) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
98
109
|
// Claiming an import binds the document to THIS client for the claim's
|
|
99
110
|
// lifetime, and only this client can complete it. A document that is
|
|
100
111
|
// closed or parked can no longer read its source or write records, so a
|
|
@@ -125,7 +136,7 @@ export function createImportRunner(deps: ImportRunnerDeps): ImportRunner {
|
|
|
125
136
|
waiting.set(doc, off);
|
|
126
137
|
} catch {
|
|
127
138
|
// The document closed while attaching; nothing left to import
|
|
128
|
-
// into, and
|
|
139
|
+
// into, and the attempt is unspent, so the reopen attempts again.
|
|
129
140
|
}
|
|
130
141
|
return;
|
|
131
142
|
}
|
|
@@ -133,7 +144,7 @@ export function createImportRunner(deps: ImportRunnerDeps): ImportRunner {
|
|
|
133
144
|
// rather than leave it to fire against a document already past it.
|
|
134
145
|
waiting.get(doc)?.();
|
|
135
146
|
waiting.delete(doc);
|
|
136
|
-
attempted.
|
|
147
|
+
attempted.set(doc, doc._openGeneration);
|
|
137
148
|
const run = importer;
|
|
138
149
|
const docId = doc.id;
|
|
139
150
|
let claim: ImportClaimResponse;
|
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
|
|
144
|
-
//
|
|
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
|
-
|
|
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 {
|
package/src/internal.ts
CHANGED
package/src/mutate-batching.ts
CHANGED
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
//
|
|
18
18
|
// The budget answers to STORAGE, not to the socket: an op past the frame cap
|
|
19
19
|
// takes the HTTP write, which carries about 190x a frame. What storage meters
|
|
20
|
-
// is op COUNT
|
|
21
|
-
//
|
|
20
|
+
// is op COUNT: each op costs the same bookkeeping whatever its size, and
|
|
21
|
+
// takes a place in the join's tail budget. Measured on a
|
|
22
22
|
// 500,000-element write, 48 KB produces 526 ops and 512 KB produces 158, past
|
|
23
23
|
// which the budget stops binding for that shape, while encode time stays flat
|
|
24
24
|
// throughout: a smaller budget spends op count and buys nothing.
|