@brass-build/client 0.3.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.
- package/AGENTS.md +26 -5
- package/CHANGELOG.md +50 -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 +278 -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 +28 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +555 -11
- 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 +396 -0
- package/src/api-headers.ts +7 -7
- package/src/call-service.ts +354 -0
- package/src/client.ts +618 -13
- 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/open-document.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type ReadOnlyReason,
|
|
19
19
|
type ResyncPushOutcome,
|
|
20
20
|
} from './documents.js';
|
|
21
|
+
import { ensureWasmReady } from './wasm-runtime.js';
|
|
21
22
|
import { createPresenceController } from './presence.js';
|
|
22
23
|
import type { WsTopicCallback } from './ws-client.js';
|
|
23
24
|
import { applyAppAssertionHeader, applyClientVersionHeader } from './api-headers.js';
|
|
@@ -372,10 +373,10 @@ export interface OpenDocumentFactoryOptions {
|
|
|
372
373
|
// Backs the Document's built-in 'history' / 'manage' / 'share'
|
|
373
374
|
// commands. When absent (tests), those commands render disabled.
|
|
374
375
|
hostedUiProvider?: HostedUiProvider;
|
|
375
|
-
// The organization this
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
|
|
376
|
+
// The organization this app is already acting in, read through a getter
|
|
377
|
+
// because `init` resolves it after the factory's options are written and
|
|
378
|
+
// because it moves as the reader works. See `createDocumentId`.
|
|
379
|
+
preferredOrganizationId?: () => string | null;
|
|
379
380
|
// Who is signed in, as the park cache's ownership stamp (see
|
|
380
381
|
// `ParkedDocEntry.identity`). Read at park and at take, so a Document only
|
|
381
382
|
// ever comes back to the person it was opened for. Absent (tests, the
|
|
@@ -1192,16 +1193,16 @@ export function createOpenDocument(
|
|
|
1192
1193
|
// open on something that clears on its own.
|
|
1193
1194
|
//
|
|
1194
1195
|
// Longer than the default handful of attempts because of what a 503 means
|
|
1195
|
-
// on this route: the document is intact and its storage is busy.
|
|
1196
|
-
//
|
|
1197
|
-
// answer 503 is one that was just written hard, which is exactly the
|
|
1196
|
+
// on this route: the document is intact and its storage is busy. A
|
|
1197
|
+
// document's own writes contend with each other, so the one most likely
|
|
1198
|
+
// to answer 503 is one that was just written hard, which is exactly the
|
|
1198
1199
|
// moment a person reloads to see their work. That pressure clears over
|
|
1199
1200
|
// tens of seconds, not the one second the default budget spans, and the
|
|
1200
1201
|
// cost of giving up early is telling someone their document will not open
|
|
1201
1202
|
// when waiting would have opened it.
|
|
1202
1203
|
//
|
|
1203
1204
|
// A thrown request gets a short budget of its own: it never reached the
|
|
1204
|
-
// API, so
|
|
1205
|
+
// API, so busy storage is not what is wrong, and a blocked origin
|
|
1205
1206
|
// or a host that does not resolve answers the seventh attempt as it
|
|
1206
1207
|
// answered the first, with the reader watching a loading surface
|
|
1207
1208
|
// throughout.
|
|
@@ -1344,7 +1345,7 @@ export function createOpenDocument(
|
|
|
1344
1345
|
// piped through `open` to materialise the editable Document.
|
|
1345
1346
|
// What the app hears when an op the socket could not carry also failed
|
|
1346
1347
|
// over HTTP. The server's message is preferred where it has one, since it
|
|
1347
|
-
// names the actual refusal (too large
|
|
1348
|
+
// names the actual refusal (too large to store inline, a revoked
|
|
1348
1349
|
// role); the status-shaped fallbacks cover a response that carries none.
|
|
1349
1350
|
function opWriteFailureMessage(status: number, parsed: unknown): string {
|
|
1350
1351
|
const serverMessage = (parsed as { error?: unknown } | undefined)?.error;
|
|
@@ -1356,12 +1357,12 @@ export function createOpenDocument(
|
|
|
1356
1357
|
return `This change could not be saved (${status}).`;
|
|
1357
1358
|
}
|
|
1358
1359
|
|
|
1359
|
-
// Write one op over HTTP. The socket carries an op only up to
|
|
1360
|
-
//
|
|
1361
|
-
//
|
|
1360
|
+
// Write one op over HTTP. The socket carries an op only up to its own
|
|
1361
|
+
// message cap; past it the message is not delivered and the connection is
|
|
1362
|
+
// closed, so `wireDocTransport` sends the large ones here.
|
|
1362
1363
|
// Not retried on a transient failure: the caller holds the op in its
|
|
1363
1364
|
// outstanding tracker and re-asserts it, and a blind replay would write a
|
|
1364
|
-
// second
|
|
1365
|
+
// second stored copy of the same edit.
|
|
1365
1366
|
// Mint a presigned PUT and upload the blob to it, returning the staging
|
|
1366
1367
|
// key the op write names. The PUT carries no Brass headers: the URL is
|
|
1367
1368
|
// signed, and an extra header falls outside that signature.
|
|
@@ -1433,9 +1434,9 @@ export function createOpenDocument(
|
|
|
1433
1434
|
};
|
|
1434
1435
|
applyClientVersionHeader(headers);
|
|
1435
1436
|
await applyAppAssertionHeader(headers, opts.getAppAssertion);
|
|
1436
|
-
// Past
|
|
1437
|
-
//
|
|
1438
|
-
//
|
|
1437
|
+
// Past the size an op can carry inline, the blob is uploaded first and
|
|
1438
|
+
// the write names what was staged, so a multi-megabyte op never travels
|
|
1439
|
+
// through a request body.
|
|
1439
1440
|
let stagedKey: string | undefined;
|
|
1440
1441
|
if (opBlobNeedsStaging(opBlobB64)) {
|
|
1441
1442
|
stagedKey = await stageOpBlob(docId, opBlobB64, token);
|
|
@@ -1525,32 +1526,36 @@ export function createOpenDocument(
|
|
|
1525
1526
|
// a needed destination / organization before showing a loading state; any
|
|
1526
1527
|
// other non-2xx throws.
|
|
1527
1528
|
//
|
|
1528
|
-
// A create that names no placement of its own
|
|
1529
|
-
//
|
|
1530
|
-
//
|
|
1531
|
-
//
|
|
1532
|
-
//
|
|
1529
|
+
// A create that names no placement of its own lands in the organization the
|
|
1530
|
+
// app is already acting in: the one a launch named, the one a document the
|
|
1531
|
+
// reader just closed lives in, or the one they chose in the account menu's
|
|
1532
|
+
// switch. Without it a member of several organizations is asked to name a
|
|
1533
|
+
// destination they have already answered, once per create. An explicit
|
|
1534
|
+
// `siblingOf` / `destination` outranks it: placing a document beside the
|
|
1535
|
+
// open one is the more specific answer.
|
|
1533
1536
|
const createDocumentId = async (
|
|
1534
1537
|
args: CreateDocumentArgs,
|
|
1535
1538
|
): Promise<CreateDocumentResult> => {
|
|
1536
|
-
const
|
|
1539
|
+
const preferred =
|
|
1537
1540
|
args.siblingOf === undefined && args.destination === undefined
|
|
1538
|
-
? (opts.
|
|
1541
|
+
? (opts.preferredOrganizationId?.() ?? null)
|
|
1539
1542
|
: null;
|
|
1540
|
-
if (
|
|
1543
|
+
if (preferred === null) return postAndClassify(args);
|
|
1541
1544
|
try {
|
|
1542
1545
|
return await postAndClassify({
|
|
1543
1546
|
...args,
|
|
1544
|
-
destination: { organization_id:
|
|
1547
|
+
destination: { organization_id: preferred },
|
|
1545
1548
|
});
|
|
1546
1549
|
} catch (err) {
|
|
1547
|
-
//
|
|
1548
|
-
//
|
|
1549
|
-
//
|
|
1550
|
-
//
|
|
1551
|
-
//
|
|
1552
|
-
//
|
|
1553
|
-
//
|
|
1550
|
+
// Every source of this value is a preference rather than an answer: a
|
|
1551
|
+
// URL parameter, a document open in an organization reached through a
|
|
1552
|
+
// shared project, a choice made before the person left. Each can name
|
|
1553
|
+
// an organization the caller no longer has, which the server answers
|
|
1554
|
+
// with a plain 404. Drop it and let the server resolve the destination
|
|
1555
|
+
// on its own, which routes a multi-organization caller to the picker. A
|
|
1556
|
+
// coded rejection (`app_not_installed`) belongs to the caller's own
|
|
1557
|
+
// consent flow and passes through untouched, as does the 404 for an
|
|
1558
|
+
// organization that exists but denies the app.
|
|
1554
1559
|
if (err instanceof BrassError && err.status === 404 && !err.code) {
|
|
1555
1560
|
return postAndClassify(args);
|
|
1556
1561
|
}
|
|
@@ -1792,6 +1797,7 @@ export function createOpenDocument(
|
|
|
1792
1797
|
resyncFromSnapshot,
|
|
1793
1798
|
skipPresence: true,
|
|
1794
1799
|
parkable: false,
|
|
1800
|
+
capacityReloadsDocument: false,
|
|
1795
1801
|
});
|
|
1796
1802
|
}
|
|
1797
1803
|
return doc;
|
|
@@ -2307,6 +2313,11 @@ export function createOpenDocument(
|
|
|
2307
2313
|
}
|
|
2308
2314
|
}
|
|
2309
2315
|
if (opts.reportError) doc.setErrorReporter(opts.reportError);
|
|
2316
|
+
// No socket on a document the caller can write to: the write path reaches
|
|
2317
|
+
// no server, so the document reports the first attempt rather than
|
|
2318
|
+
// discarding it. Outside tests this is `wsBaseUrl` left unset, which
|
|
2319
|
+
// naming any other base URL does.
|
|
2320
|
+
if (!opts.wsClient && !doc.readOnly) doc.markTransportUnavailable();
|
|
2310
2321
|
return doc;
|
|
2311
2322
|
}
|
|
2312
2323
|
|
|
@@ -2594,7 +2605,12 @@ export function createOpenDocument(
|
|
|
2594
2605
|
canEdit: boolean,
|
|
2595
2606
|
): void {
|
|
2596
2607
|
const wsClient = opts.wsClient;
|
|
2597
|
-
if (!wsClient)
|
|
2608
|
+
if (!wsClient) {
|
|
2609
|
+
// No socket: a write would change local state and reach no server, so
|
|
2610
|
+
// the document reports the first one instead of discarding it quietly.
|
|
2611
|
+
if (canEdit) doc.markTransportUnavailable();
|
|
2612
|
+
return;
|
|
2613
|
+
}
|
|
2598
2614
|
const topic = `doc:${docId}:${lineId}`;
|
|
2599
2615
|
|
|
2600
2616
|
// Ops on the socket awaiting an answer, keyed by `client_op_id`. An
|
|
@@ -2932,11 +2948,12 @@ export function createOpenDocument(
|
|
|
2932
2948
|
// create + obtain the doc_id, then `open(docId)`); they aren't fused
|
|
2933
2949
|
// because the create response is metadata-only and the open path already
|
|
2934
2950
|
// handles the empty-snapshot case via `Document.create()`. This is the
|
|
2935
|
-
// low-level path (`Brass.createDocument`): the
|
|
2936
|
-
// the
|
|
2937
|
-
//
|
|
2938
|
-
//
|
|
2939
|
-
//
|
|
2951
|
+
// low-level path (`Brass.createDocument`): the doc lands in the
|
|
2952
|
+
// organization the app is acting in, and otherwise in the caller's single
|
|
2953
|
+
// organization; a caller with none is routed to the hosted
|
|
2954
|
+
// acquire-organization surface and the create retries; a multi-org caller
|
|
2955
|
+
// whose app is acting nowhere rejects with a 409 (no hosted picker here,
|
|
2956
|
+
// that is the session's `create()`).
|
|
2940
2957
|
const create: CreateDocumentFn = async (options) => {
|
|
2941
2958
|
const args: CreateDocumentArgs = { name: options?.name ?? 'Untitled' };
|
|
2942
2959
|
if (options?.app_metadata !== undefined) args.app_metadata = options.app_metadata;
|
|
@@ -3031,6 +3048,13 @@ interface WireDocTransportArgs {
|
|
|
3031
3048
|
// by the parent Document (closed on parent close), so it bypasses the
|
|
3032
3049
|
// park cache and `close()` tears down immediately.
|
|
3033
3050
|
parkable?: boolean;
|
|
3051
|
+
// True for the session-owned top-level document: an unhealable capacity
|
|
3052
|
+
// refusal tells the user and reloads, because the session reopens the id
|
|
3053
|
+
// and rebinds the app to the platform's copy. False for a stream line,
|
|
3054
|
+
// which has no such owner and is only one part of a document: it marks
|
|
3055
|
+
// itself read-only at capacity and surfaces `stream.atCapacity` instead
|
|
3056
|
+
// of alerting the reader and reloading a document the stream is part of.
|
|
3057
|
+
capacityReloadsDocument?: boolean;
|
|
3034
3058
|
// The branch the caller REQUESTED (`options.branch`), undefined for a
|
|
3035
3059
|
// default-branch open. Distinct from `branchId`, which is the branch the
|
|
3036
3060
|
// server RESOLVED. The park cache keys on this (symmetric with
|
|
@@ -3809,6 +3833,18 @@ function wireDocTransport(args: WireDocTransportArgs): void {
|
|
|
3809
3833
|
clearTimeout(gapTimer);
|
|
3810
3834
|
gapTimer = null;
|
|
3811
3835
|
}
|
|
3836
|
+
// A stream line reaches this only at capacity, and it has no owner to
|
|
3837
|
+
// reopen it: the session reopens the top-level id, but a stream is one
|
|
3838
|
+
// part of a document nothing reloads on its behalf. So it stops the
|
|
3839
|
+
// divergence (above) and marks itself read-only at capacity, which
|
|
3840
|
+
// `stream.readOnly` / `stream.atCapacity` already surface to the app,
|
|
3841
|
+
// rather than alerting the reader and reloading a document it is part
|
|
3842
|
+
// of. The diverged edit stays visible until the app reopens the stream;
|
|
3843
|
+
// it reached no other reader, so nothing is lost by leaving it.
|
|
3844
|
+
if (args.capacityReloadsDocument === false) {
|
|
3845
|
+
args.doc._markReadOnly('capacity');
|
|
3846
|
+
return;
|
|
3847
|
+
}
|
|
3812
3848
|
args.doc._markReadOnly('unsaved');
|
|
3813
3849
|
// The user is told by the SDK, not by each app, because an app that
|
|
3814
3850
|
// renders nothing is exactly the case this has to cover: a document that
|
|
@@ -4762,6 +4798,11 @@ async function materialise(
|
|
|
4762
4798
|
documentOptions?: DocumentOptions,
|
|
4763
4799
|
onSnapshotProgress?: (loaded: number, total: number | null) => void,
|
|
4764
4800
|
): Promise<OpenedDocument> {
|
|
4801
|
+
// Constructing the document is the first thing in this package that runs the
|
|
4802
|
+
// WASM, and `Brass.init` only starts the fetch, so this is where it is
|
|
4803
|
+
// waited on. Usually it resolved during the auth round-trip and this costs
|
|
4804
|
+
// nothing.
|
|
4805
|
+
await ensureWasmReady();
|
|
4765
4806
|
// Stamp the server-minted doc_id onto the Document (via DocumentOptions, so
|
|
4766
4807
|
// it threads through the LoroDoc constructor without changing the static
|
|
4767
4808
|
// factory signatures) so callers can read `doc.id` to correlate.
|
package/src/presence.ts
CHANGED
|
@@ -78,6 +78,12 @@ export interface PresenceState {
|
|
|
78
78
|
/** Entity ids the user has selected. Use to highlight remote
|
|
79
79
|
* selections so collaborators see what each other is working on. */
|
|
80
80
|
selection?: string[];
|
|
81
|
+
/** Where the user's caret sits in a text container. `key` names the
|
|
82
|
+
* container (`doc.text(key)`), and both offsets count UTF-16 code
|
|
83
|
+
* units into its value: `anchor` where the selection began, `head`
|
|
84
|
+
* where it is now, equal for a plain caret. Use it to draw remote
|
|
85
|
+
* carets in prose, the way `selection` highlights picked entities. */
|
|
86
|
+
textCaret?: { key: string; anchor: number; head: number };
|
|
81
87
|
}
|
|
82
88
|
|
|
83
89
|
// A live participant on a presence topic. Keyed on `connectionId` (one
|
package/src/session-toolbar.ts
CHANGED
|
@@ -73,6 +73,17 @@ export interface AccountMenuDeps {
|
|
|
73
73
|
// dashboard" link. The client's resolved dashboard origin (picks the
|
|
74
74
|
// environment), with no trailing slash.
|
|
75
75
|
dashboardBaseUrl: string;
|
|
76
|
+
// The organization this app is currently acting in, or null while none has
|
|
77
|
+
// been resolved or the one resolved has no name yet.
|
|
78
|
+
getActingOrganization?: () => { id: string; name: string } | null;
|
|
79
|
+
// Push-based, because the value is resolved lazily at the first act that
|
|
80
|
+
// needs it and can change when the reader switches.
|
|
81
|
+
subscribeActingOrganization?: (
|
|
82
|
+
listener: (organization: { id: string; name: string } | null) => void,
|
|
83
|
+
) => () => void;
|
|
84
|
+
// Opens the hosted surface that picks the organization to act in, which is
|
|
85
|
+
// also where one gets created.
|
|
86
|
+
switchOrganization?: () => void;
|
|
76
87
|
}
|
|
77
88
|
|
|
78
89
|
export type SessionToolbarDeps = FileMenuDeps &
|
|
@@ -200,7 +211,8 @@ const CSS = `
|
|
|
200
211
|
margin:4px 2px; }
|
|
201
212
|
/* [hidden] must beat the block rule above (the account menu swaps its body
|
|
202
213
|
between signed-in actions and the guest's "Sign in"). */
|
|
203
|
-
.b-pop button[hidden], .b-pop a[hidden], .b-pop .b-email[hidden]
|
|
214
|
+
.b-pop button[hidden], .b-pop a[hidden], .b-pop .b-email[hidden],
|
|
215
|
+
.b-pop .b-account-org[hidden] { display:none; }
|
|
204
216
|
.b-identity { display:flex; flex-direction:column; align-items:flex-start;
|
|
205
217
|
gap:1px; min-width:0; }
|
|
206
218
|
/* Cancels the horizontal padding the title and location carry for their focus
|
|
@@ -267,9 +279,12 @@ const CSS = `
|
|
|
267
279
|
/* minmax(0,1fr) so a long name or email ellipsizes instead of widening the
|
|
268
280
|
menu: a grid item's default min-width is its content's intrinsic size. */
|
|
269
281
|
.b-account-id { display:grid; grid-template-columns:minmax(0,1fr); gap:1px; min-width:0; }
|
|
270
|
-
.b-account-name, .b-email
|
|
282
|
+
.b-account-name, .b-email, .b-account-org {
|
|
283
|
+
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
271
284
|
.b-account-name { font-size:13px; font-weight:600; line-height:1.3; }
|
|
272
|
-
|
|
285
|
+
/* The organization line qualifies the identity rather than naming it, so it
|
|
286
|
+
sits at the address's quiet tier and never competes with the name above. */
|
|
287
|
+
.b-email, .b-account-org { color:var(--brass-fg-muted); font-size:12px; line-height:1.3; }
|
|
273
288
|
.b-account .b-pop button, .b-account .b-pop a {
|
|
274
289
|
display:flex; align-items:center; gap:10px; font-size:13px; }
|
|
275
290
|
.b-account .b-pop svg { flex:0 0 auto; color:var(--brass-fg-muted); }
|
|
@@ -1272,22 +1287,25 @@ function buildAccountMenu(deps: AccountMenuDeps): PieceInternal {
|
|
|
1272
1287
|
align: 'end',
|
|
1273
1288
|
});
|
|
1274
1289
|
// The menu opens on the identity it acts for: the avatar at reading size,
|
|
1275
|
-
// the display name,
|
|
1276
|
-
//
|
|
1290
|
+
// the display name, the email under it, and the organization the app acts in
|
|
1291
|
+
// under that. The name line drops out when the user has none, leaving the
|
|
1292
|
+
// email as the single identifying line.
|
|
1277
1293
|
const head = el('div', 'b-account-head');
|
|
1278
1294
|
const headFace = buildAvatarFace();
|
|
1279
1295
|
const faceEl = el('span', 'b-account-face');
|
|
1280
1296
|
faceEl.append(...headFace.nodes);
|
|
1281
1297
|
const nameEl = el('div', 'b-account-name');
|
|
1282
1298
|
const emailEl = el('div', 'b-email');
|
|
1299
|
+
const orgEl = el('div', 'b-account-org');
|
|
1283
1300
|
const identity = el('div', 'b-account-id');
|
|
1284
|
-
identity.append(nameEl, emailEl);
|
|
1301
|
+
identity.append(nameEl, emailEl, orgEl);
|
|
1285
1302
|
head.append(faceEl, identity);
|
|
1286
1303
|
const headSep = el('hr', 'b-sep');
|
|
1287
1304
|
headSep.setAttribute('role', 'separator');
|
|
1288
1305
|
// Opens the dashboard in a new tab, so the user keeps the app open in the
|
|
1289
|
-
// current one
|
|
1290
|
-
//
|
|
1306
|
+
// current one, and lands in the organization the app is working in rather
|
|
1307
|
+
// than wherever the dashboard was left. `/home` is the authenticated
|
|
1308
|
+
// landing and stands in when the app is working in none.
|
|
1291
1309
|
const dashboardLink = el('a');
|
|
1292
1310
|
dashboardLink.append(
|
|
1293
1311
|
strokeIcon(
|
|
@@ -1301,6 +1319,23 @@ function buildAccountMenu(deps: AccountMenuDeps): PieceInternal {
|
|
|
1301
1319
|
dashboardLink.target = '_blank';
|
|
1302
1320
|
dashboardLink.rel = 'noopener noreferrer';
|
|
1303
1321
|
dashboardLink.onclick = () => menu.close();
|
|
1322
|
+
// Sits directly above the dashboard link, so the organization the header
|
|
1323
|
+
// names and the action that changes it read as one block.
|
|
1324
|
+
const switchOrgBtn = el('button');
|
|
1325
|
+
switchOrgBtn.type = 'button';
|
|
1326
|
+
switchOrgBtn.append(
|
|
1327
|
+
strokeIcon(
|
|
1328
|
+
'm17 2 4 4-4 4',
|
|
1329
|
+
'M3 11v-1a4 4 0 0 1 4-4h14',
|
|
1330
|
+
'm7 22-4-4 4-4',
|
|
1331
|
+
'M21 13v1a4 4 0 0 1-4 4H3',
|
|
1332
|
+
),
|
|
1333
|
+
'Switch organization...',
|
|
1334
|
+
);
|
|
1335
|
+
switchOrgBtn.onclick = () => {
|
|
1336
|
+
menu.close();
|
|
1337
|
+
deps.switchOrganization?.();
|
|
1338
|
+
};
|
|
1304
1339
|
const signOutBtn = el('button');
|
|
1305
1340
|
signOutBtn.append(
|
|
1306
1341
|
strokeIcon('M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4', 'm16 17 5-5-5-5', 'M21 12H9'),
|
|
@@ -1323,12 +1358,18 @@ function buildAccountMenu(deps: AccountMenuDeps): PieceInternal {
|
|
|
1323
1358
|
menu.close();
|
|
1324
1359
|
deps.signIn();
|
|
1325
1360
|
};
|
|
1326
|
-
menu.pop.append(head, headSep, dashboardLink, signOutBtn, signInBtn);
|
|
1361
|
+
menu.pop.append(head, headSep, switchOrgBtn, dashboardLink, signOutBtn, signInBtn);
|
|
1327
1362
|
|
|
1328
1363
|
const triggerFace = buildAvatarFace();
|
|
1329
1364
|
menu.trigger.append(...triggerFace.nodes);
|
|
1330
1365
|
|
|
1331
|
-
|
|
1366
|
+
// The menu acts for a (user, organization) pair, and each half arrives on
|
|
1367
|
+
// its own listener, so both are held here and one render draws them.
|
|
1368
|
+
let currentUser = deps.getUser();
|
|
1369
|
+
let organization = deps.getActingOrganization?.() ?? null;
|
|
1370
|
+
|
|
1371
|
+
const render = (): void => {
|
|
1372
|
+
const user = currentUser;
|
|
1332
1373
|
triggerFace.render(user);
|
|
1333
1374
|
headFace.render(user);
|
|
1334
1375
|
nameEl.textContent = user?.display_name ?? '';
|
|
@@ -1346,13 +1387,40 @@ function buildAccountMenu(deps: AccountMenuDeps): PieceInternal {
|
|
|
1346
1387
|
dashboardLink.hidden = !signedIn;
|
|
1347
1388
|
signOutBtn.hidden = !signedIn;
|
|
1348
1389
|
signInBtn.hidden = signedIn;
|
|
1390
|
+
// The organization line names what the app is acting in, so it is drawn
|
|
1391
|
+
// only when there is something to name: an app that has not needed an
|
|
1392
|
+
// organization yet has not resolved one, and resolving one to fill this
|
|
1393
|
+
// line is the boot-time question the platform refuses.
|
|
1394
|
+
const organizationName = signedIn ? (organization?.name ?? '') : '';
|
|
1395
|
+
orgEl.textContent = organizationName;
|
|
1396
|
+
orgEl.title = organizationName;
|
|
1397
|
+
orgEl.hidden = organizationName === '';
|
|
1398
|
+
dashboardLink.href =
|
|
1399
|
+
signedIn && organization
|
|
1400
|
+
? `${deps.dashboardBaseUrl}/organizations/${encodeURIComponent(organization.id)}`
|
|
1401
|
+
: `${deps.dashboardBaseUrl}/home`;
|
|
1402
|
+
// Offered to every signed-in reader, including one the line has nothing
|
|
1403
|
+
// to name and one who belongs to a single organization. The surface
|
|
1404
|
+
// behind it both moves the app and creates an organization, so gating it
|
|
1405
|
+
// on already having somewhere else to go would withhold it from exactly
|
|
1406
|
+
// the person it is for; and an app reached by its own URL has resolved
|
|
1407
|
+
// nothing to name, which is the state a deliberate choice settles.
|
|
1408
|
+
switchOrgBtn.hidden = !signedIn || deps.switchOrganization === undefined;
|
|
1349
1409
|
};
|
|
1350
|
-
|
|
1351
|
-
const unsub = deps.onUserChange(
|
|
1410
|
+
render();
|
|
1411
|
+
const unsub = deps.onUserChange((user) => {
|
|
1412
|
+
currentUser = user;
|
|
1413
|
+
render();
|
|
1414
|
+
});
|
|
1415
|
+
const unsubOrganization = deps.subscribeActingOrganization?.((next) => {
|
|
1416
|
+
organization = next;
|
|
1417
|
+
render();
|
|
1418
|
+
});
|
|
1352
1419
|
return {
|
|
1353
1420
|
root: menu.root,
|
|
1354
1421
|
destroy(): void {
|
|
1355
1422
|
unsub();
|
|
1423
|
+
unsubOrganization?.();
|
|
1356
1424
|
menu.destroy();
|
|
1357
1425
|
},
|
|
1358
1426
|
};
|
package/src/tail-ops.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Tail ops arrive in one of two shapes and are used in only one.
|
|
2
2
|
//
|
|
3
|
-
// An op
|
|
4
|
-
//
|
|
3
|
+
// An op small enough to store inline carries its bytes. A larger one
|
|
4
|
+
// carries a short-lived signed URL instead, because a
|
|
5
5
|
// join response holding several megabytes of base64 is a response the
|
|
6
6
|
// server cannot build. Both mean the same thing to a reader: bytes to
|
|
7
7
|
// apply, in seq order.
|
package/src/types.ts
CHANGED
|
@@ -87,10 +87,12 @@ export interface UndoControllerOptions {
|
|
|
87
87
|
* A document's own view of how its sync is going, as the client sees it.
|
|
88
88
|
*
|
|
89
89
|
* The server records what reached it, so the facts that separate a write
|
|
90
|
-
* that was lost from a client that is merely slow live only here.
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
90
|
+
* that was lost from a client that is merely slow live only here.
|
|
91
|
+
*
|
|
92
|
+
* @internal Read on demand by Brass's own surfaces, through the `@internal`
|
|
93
|
+
* `Document.diagnostics()`. An app reads `savingCount` to know a save is
|
|
94
|
+
* outstanding and `Brass.onError` to hear a failure; the SDK resolves a
|
|
95
|
+
* stuck save itself.
|
|
94
96
|
*/
|
|
95
97
|
export interface DocSyncDiagnostics {
|
|
96
98
|
/** Ops committed locally that the server has not acknowledged. */
|
package/src/undo.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// local-peer-scoped (reverts only this peer's ops), group bracketing collapses
|
|
3
3
|
// same-stroke edits into one entry, and remote-import bookkeeping keeps the
|
|
4
4
|
// redo stack honest across other peers' edits.
|
|
5
|
-
import { UndoManager, type LoroDoc } from '
|
|
5
|
+
import { UndoManager, type LoroDoc } from './wasm-runtime.js';
|
|
6
6
|
|
|
7
7
|
import type { UndoControllerOptions } from './types.js';
|
|
8
8
|
|
package/src/version-bar.ts
CHANGED
|
@@ -88,6 +88,10 @@ function clampHeight(h: number): number {
|
|
|
88
88
|
// takes the strip. Matches the hosted-UI launcher's own long window.
|
|
89
89
|
const VERSION_BAR_READY_TIMEOUT_MS = 10_000;
|
|
90
90
|
|
|
91
|
+
// How long a frame that answered gets to draw its strip before the
|
|
92
|
+
// placeholder comes off anyway. See `armRevealTimer`.
|
|
93
|
+
const VERSION_BAR_REVEAL_TIMEOUT_MS = 10_000;
|
|
94
|
+
|
|
91
95
|
export function createVersionBarFrame(
|
|
92
96
|
root: HTMLElement,
|
|
93
97
|
deps: VersionBarDeps,
|
|
@@ -164,6 +168,10 @@ export function createVersionBarFrame(
|
|
|
164
168
|
// one that never answers, which is why this is a timer rather than a
|
|
165
169
|
// handler.
|
|
166
170
|
let readyTimer: ReturnType<typeof setTimeout> | undefined;
|
|
171
|
+
// The placeholder holds the strip's height until the embed has drawn its
|
|
172
|
+
// own, which `booted` reports and this timer backstops.
|
|
173
|
+
let revealed = false;
|
|
174
|
+
let revealTimer: ReturnType<typeof setTimeout> | undefined;
|
|
167
175
|
|
|
168
176
|
// Hand the strip to the SDK-drawn fallback, which carries the two controls
|
|
169
177
|
// that need no timeline (return to latest, close).
|
|
@@ -174,12 +182,37 @@ export function createVersionBarFrame(
|
|
|
174
182
|
clearTimeout(readyTimer);
|
|
175
183
|
readyTimer = undefined;
|
|
176
184
|
}
|
|
185
|
+
if (revealTimer !== undefined) {
|
|
186
|
+
clearTimeout(revealTimer);
|
|
187
|
+
revealTimer = undefined;
|
|
188
|
+
}
|
|
177
189
|
placeholder.style.display = 'none';
|
|
178
190
|
iframe.style.height = '0';
|
|
179
191
|
fallback.hidden = false;
|
|
180
192
|
renderFallback();
|
|
181
193
|
}
|
|
182
194
|
|
|
195
|
+
// Give the embed's strip the height the placeholder was holding.
|
|
196
|
+
function revealFrame(): void {
|
|
197
|
+
if (destroyed || failed || revealed) return;
|
|
198
|
+
revealed = true;
|
|
199
|
+
if (revealTimer !== undefined) {
|
|
200
|
+
clearTimeout(revealTimer);
|
|
201
|
+
revealTimer = undefined;
|
|
202
|
+
}
|
|
203
|
+
placeholder.style.display = 'none';
|
|
204
|
+
iframe.style.height = `${READY_HEIGHT}px`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// A frame that answers and then draws nothing (a boot that dies past its
|
|
208
|
+
// first script, an embed page older than `booted`) holds the placeholder
|
|
209
|
+
// otherwise. The swap is what the reader's controls arrive with, so take
|
|
210
|
+
// it on the timer rather than leaving the strip inert.
|
|
211
|
+
function armRevealTimer(): void {
|
|
212
|
+
if (revealed || revealTimer !== undefined) return;
|
|
213
|
+
revealTimer = setTimeout(revealFrame, VERSION_BAR_REVEAL_TIMEOUT_MS);
|
|
214
|
+
}
|
|
215
|
+
|
|
183
216
|
function armReadyTimer(): void {
|
|
184
217
|
if (readyTimer !== undefined) clearTimeout(readyTimer);
|
|
185
218
|
readyTimer = setTimeout(() => {
|
|
@@ -267,16 +300,23 @@ export function createVersionBarFrame(
|
|
|
267
300
|
if (!data || data[EMBED_ENVELOPE] !== EMBED_PROTOCOL_VERSION) return;
|
|
268
301
|
switch (data['type']) {
|
|
269
302
|
case 'ready': {
|
|
303
|
+
// The frame is alive. It says nothing about the embed having drawn
|
|
304
|
+
// its strip yet, so the placeholder stays until `booted`: the embed
|
|
305
|
+
// posts this from a script that runs before its application, and
|
|
306
|
+
// swapping here would trade the themed placeholder for an empty one.
|
|
270
307
|
ready = true;
|
|
271
308
|
if (readyTimer !== undefined) {
|
|
272
309
|
clearTimeout(readyTimer);
|
|
273
310
|
readyTimer = undefined;
|
|
274
311
|
}
|
|
275
|
-
|
|
276
|
-
iframe.style.height = `${READY_HEIGHT}px`;
|
|
312
|
+
armRevealTimer();
|
|
277
313
|
postInit();
|
|
278
314
|
break;
|
|
279
315
|
}
|
|
316
|
+
case 'booted': {
|
|
317
|
+
revealFrame();
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
280
320
|
case 'resize': {
|
|
281
321
|
const h = data['height'];
|
|
282
322
|
if (typeof h === 'number') iframe.style.height = `${clampHeight(h)}px`;
|
|
@@ -336,6 +376,13 @@ export function createVersionBarFrame(
|
|
|
336
376
|
// page gets a fresh verdict, so the failed state resets with it.
|
|
337
377
|
ready = false;
|
|
338
378
|
failed = false;
|
|
379
|
+
// The placeholder is back up, so the swap the new page's `booted`
|
|
380
|
+
// asks for is one this bar has not made yet.
|
|
381
|
+
revealed = false;
|
|
382
|
+
if (revealTimer !== undefined) {
|
|
383
|
+
clearTimeout(revealTimer);
|
|
384
|
+
revealTimer = undefined;
|
|
385
|
+
}
|
|
339
386
|
deliveredVersion = undefined;
|
|
340
387
|
fallback.hidden = true;
|
|
341
388
|
placeholder.style.display = '';
|
|
@@ -369,6 +416,10 @@ export function createVersionBarFrame(
|
|
|
369
416
|
clearTimeout(readyTimer);
|
|
370
417
|
readyTimer = undefined;
|
|
371
418
|
}
|
|
419
|
+
if (revealTimer !== undefined) {
|
|
420
|
+
clearTimeout(revealTimer);
|
|
421
|
+
revealTimer = undefined;
|
|
422
|
+
}
|
|
372
423
|
window.removeEventListener('message', onMessage);
|
|
373
424
|
iframe.remove();
|
|
374
425
|
placeholder.remove();
|
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.
|
|
6
|
+
export const SDK_VERSION = '0.4.0';
|
|
7
7
|
|
|
8
8
|
export const SDK_CLIENT_ID = `sdk/${SDK_VERSION}`;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The once-only gate the browser runtime half readies its WASM behind, kept
|
|
2
|
+
// separate from it so it can be exercised without a real engine: the half
|
|
3
|
+
// itself names a WASM-shipping dependency, which pins it to an environment
|
|
4
|
+
// that can load one.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Build a function that runs `inits` once and hands every caller the same
|
|
8
|
+
* result.
|
|
9
|
+
*
|
|
10
|
+
* A rejection is NOT retained. Each init is a multi-megabyte fetch over
|
|
11
|
+
* whatever connection the reader has, and a page navigating away mid-download
|
|
12
|
+
* aborts it, so a retained rejection would answer every later call with a
|
|
13
|
+
* failure the network has long since recovered from. Dropping it makes the
|
|
14
|
+
* next call start over, which is what a reader who opens a document a minute
|
|
15
|
+
* later needs.
|
|
16
|
+
*/
|
|
17
|
+
export function createReadyGate(
|
|
18
|
+
inits: readonly (() => Promise<unknown>)[],
|
|
19
|
+
): () => Promise<void> {
|
|
20
|
+
let readying: Promise<void> | null = null;
|
|
21
|
+
return () => {
|
|
22
|
+
readying ??= Promise.all(inits.map((init) => init()))
|
|
23
|
+
.then(() => undefined)
|
|
24
|
+
.catch((err: unknown) => {
|
|
25
|
+
readying = null;
|
|
26
|
+
throw err;
|
|
27
|
+
});
|
|
28
|
+
return readying;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// The browser half of the WASM runtime seam: the ONE module in this package
|
|
2
|
+
// that names a WASM-shipping dependency, and the only place a new one is
|
|
3
|
+
// added. Everything else imports `./wasm-runtime.js`, whose node half this
|
|
4
|
+
// one replaces in a browser build, so no other file in the SDK (and no line of
|
|
5
|
+
// an app's own config) names a WASM dep or its loader.
|
|
6
|
+
//
|
|
7
|
+
// Each entry is loaded through its package's WEB build, which readies its
|
|
8
|
+
// module asynchronously behind an explicit init and locates its bytes with
|
|
9
|
+
// `new URL(..., import.meta.url)`. That combination is what makes the whole
|
|
10
|
+
// package build with no bundler plugins: every major bundler rewrites that URL
|
|
11
|
+
// into an emitted asset on its own, and nothing here emits a top-level await,
|
|
12
|
+
// so no build target has to accept one.
|
|
13
|
+
//
|
|
14
|
+
// The alternative each package offers by default is worse in a way nothing
|
|
15
|
+
// reports. A default browser entry fetches its module with a SYNCHRONOUS
|
|
16
|
+
// XMLHttpRequest while it evaluates, so an app freezes its main thread for the
|
|
17
|
+
// whole download before it paints, whether or not anyone opens a document, and
|
|
18
|
+
// the freeze grows with the reader's latency rather than with the app. That is
|
|
19
|
+
// what a browser build resolves to unless something selects otherwise, it
|
|
20
|
+
// builds clean, and the only tell is the chunk count.
|
|
21
|
+
|
|
22
|
+
// The init comes from the deeper path because the package's web entry
|
|
23
|
+
// re-exports its module with `export *`, which does not carry a default, so
|
|
24
|
+
// the entry's types do not declare the init its JavaScript exports. The two
|
|
25
|
+
// specifiers resolve to the same module instance (the entry imports this
|
|
26
|
+
// file), so readying through one readies the classes taken from the other.
|
|
27
|
+
import initEngine from 'loro-crdt/web/loro_wasm.js';
|
|
28
|
+
|
|
29
|
+
import { createReadyGate } from './wasm-ready-gate.js';
|
|
30
|
+
|
|
31
|
+
export * from 'loro-crdt/web';
|
|
32
|
+
|
|
33
|
+
// Every WASM module this package needs ready before it runs, as the init each
|
|
34
|
+
// one exports. Add an entry rather than replacing the list: a consumer with
|
|
35
|
+
// two installed during a migration window needs both readied, and the gate
|
|
36
|
+
// below is what every caller already awaits, so a new entry costs no new call
|
|
37
|
+
// anywhere.
|
|
38
|
+
const INITS: readonly (() => Promise<unknown>)[] = [initEngine];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve once every WASM module this package needs is ready to run.
|
|
42
|
+
*
|
|
43
|
+
* `Brass.init()` starts it and every path that constructs a document awaits
|
|
44
|
+
* it, so an app that follows the documented boot never calls it. It is
|
|
45
|
+
* idempotent and concurrent-safe for the paths that construct documents
|
|
46
|
+
* without one (a test, an in-repo tool).
|
|
47
|
+
*/
|
|
48
|
+
export const ensureWasmReady: () => Promise<void> = createReadyGate(INITS);
|