@brass-build/client 0.1.0 → 0.2.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 +72 -63
- package/CHANGELOG.md +86 -0
- package/README.md +317 -231
- package/dist/auth-flow.d.ts +10 -1
- package/dist/auth-flow.d.ts.map +1 -1
- package/dist/auth-flow.js +64 -20
- package/dist/auth-flow.js.map +1 -1
- package/dist/client.d.ts +3 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +217 -75
- package/dist/client.js.map +1 -1
- package/dist/document-engine.d.ts +4 -0
- package/dist/document-engine.d.ts.map +1 -0
- package/dist/document-engine.js +9 -0
- package/dist/document-engine.js.map +1 -0
- package/dist/document-streams.d.ts +8 -1
- package/dist/document-streams.d.ts.map +1 -1
- package/dist/document-streams.js +79 -8
- package/dist/document-streams.js.map +1 -1
- package/dist/documents-namespace.d.ts.map +1 -1
- package/dist/documents-namespace.js +19 -3
- package/dist/documents-namespace.js.map +1 -1
- package/dist/documents.d.ts +43 -14
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +871 -223
- package/dist/documents.js.map +1 -1
- package/dist/embed-protocol.d.ts +3 -0
- package/dist/embed-protocol.d.ts.map +1 -0
- package/dist/embed-protocol.js +26 -0
- package/dist/embed-protocol.js.map +1 -0
- package/dist/emitter.d.ts.map +1 -1
- package/dist/emitter.js +8 -1
- package/dist/emitter.js.map +1 -1
- package/dist/export-guest.d.ts.map +1 -1
- package/dist/export-guest.js +14 -6
- package/dist/export-guest.js.map +1 -1
- package/dist/fetch-deadline.d.ts +22 -0
- package/dist/fetch-deadline.d.ts.map +1 -0
- package/dist/fetch-deadline.js +96 -0
- package/dist/fetch-deadline.js.map +1 -0
- package/dist/fetch-retry.d.ts +1 -0
- package/dist/fetch-retry.d.ts.map +1 -1
- package/dist/fetch-retry.js +12 -5
- package/dist/fetch-retry.js.map +1 -1
- package/dist/handle-types.d.ts +150 -0
- package/dist/handle-types.d.ts.map +1 -0
- package/dist/handle-types.js +8 -0
- package/dist/handle-types.js.map +1 -0
- package/dist/handles.d.ts +48 -48
- package/dist/handles.d.ts.map +1 -1
- package/dist/handles.js +3122 -501
- package/dist/handles.js.map +1 -1
- package/dist/hosted-runtime.d.ts.map +1 -1
- package/dist/hosted-runtime.js +8 -12
- package/dist/hosted-runtime.js.map +1 -1
- package/dist/hosted-ui.d.ts +0 -1
- package/dist/hosted-ui.d.ts.map +1 -1
- package/dist/hosted-ui.js +29 -24
- package/dist/hosted-ui.js.map +1 -1
- package/dist/importer.d.ts.map +1 -1
- package/dist/importer.js +253 -8
- package/dist/importer.js.map +1 -1
- package/dist/index.d.ts +10 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -2
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +1 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js.map +1 -1
- package/dist/join-coverage.d.ts +12 -0
- package/dist/join-coverage.d.ts.map +1 -0
- package/dist/join-coverage.js +37 -0
- package/dist/join-coverage.js.map +1 -0
- package/dist/jwt.d.ts.map +1 -1
- package/dist/jwt.js +5 -1
- package/dist/jwt.js.map +1 -1
- package/dist/launch-context.js +2 -2
- package/dist/launch-context.js.map +1 -1
- package/dist/mutate-batching.d.ts +25 -2
- package/dist/mutate-batching.d.ts.map +1 -1
- package/dist/mutate-batching.js +57 -10
- package/dist/mutate-batching.js.map +1 -1
- package/dist/open-document.d.ts +15 -1
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +1851 -321
- package/dist/open-document.js.map +1 -1
- package/dist/presence.d.ts +28 -0
- package/dist/presence.d.ts.map +1 -1
- package/dist/presence.js +149 -7
- package/dist/presence.js.map +1 -1
- package/dist/record-paging.d.ts +336 -71
- package/dist/record-paging.d.ts.map +1 -1
- package/dist/record-paging.js +591 -127
- package/dist/record-paging.js.map +1 -1
- package/dist/selection-persistence.d.ts.map +1 -1
- package/dist/selection-persistence.js +11 -1
- package/dist/selection-persistence.js.map +1 -1
- package/dist/serializer.d.ts +30 -1
- package/dist/serializer.d.ts.map +1 -1
- package/dist/session-persistence.d.ts.map +1 -1
- package/dist/session-persistence.js +9 -8
- package/dist/session-persistence.js.map +1 -1
- package/dist/session-toolbar.d.ts +0 -1
- package/dist/session-toolbar.d.ts.map +1 -1
- package/dist/session-toolbar.js +226 -75
- package/dist/session-toolbar.js.map +1 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +87 -18
- package/dist/session.js.map +1 -1
- package/dist/tail-ops.d.ts +14 -4
- package/dist/tail-ops.d.ts.map +1 -1
- package/dist/tail-ops.js +75 -15
- package/dist/tail-ops.js.map +1 -1
- package/dist/topic-lifecycle.d.ts +4 -0
- package/dist/topic-lifecycle.d.ts.map +1 -1
- package/dist/topic-lifecycle.js +25 -1
- package/dist/topic-lifecycle.js.map +1 -1
- package/dist/types.d.ts +47 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/undo.d.ts +2 -3
- package/dist/undo.d.ts.map +1 -1
- package/dist/undo.js +11 -0
- package/dist/undo.js.map +1 -1
- package/dist/unload-guard.d.ts +5 -0
- package/dist/unload-guard.d.ts.map +1 -0
- package/dist/unload-guard.js +88 -0
- package/dist/unload-guard.js.map +1 -0
- package/dist/version-bar.d.ts +4 -3
- package/dist/version-bar.d.ts.map +1 -1
- package/dist/version-bar.js +140 -19
- package/dist/version-bar.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/dist/vite.d.ts +18 -2
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +133 -8
- package/dist/vite.js.map +1 -1
- package/dist/ws-client.d.ts +23 -3
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +339 -89
- package/dist/ws-client.js.map +1 -1
- package/dist/ws-frame-limit.d.ts.map +1 -1
- package/dist/ws-frame-limit.js +19 -12
- package/dist/ws-frame-limit.js.map +1 -1
- package/docs/app-manifest.md +7 -8
- package/docs/custom-chrome.md +17 -20
- package/docs/hosted-ui-advanced.md +14 -17
- package/docs/imported-documents.md +53 -197
- package/docs/presence.md +45 -21
- package/docs/schemas.md +12 -17
- package/docs/streams.md +55 -57
- package/package.json +4 -3
- package/src/auth-flow.ts +90 -23
- package/src/client.ts +225 -78
- package/src/document-engine.ts +15 -0
- package/src/document-signals.ts +7 -7
- package/src/document-streams.ts +95 -9
- package/src/documents-namespace.ts +18 -3
- package/src/documents.ts +983 -277
- package/src/embed-protocol.ts +25 -0
- package/src/emitter.ts +8 -1
- package/src/export-guest.ts +15 -9
- package/src/fetch-deadline.ts +119 -0
- package/src/fetch-retry.ts +14 -5
- package/src/handle-types.ts +303 -0
- package/src/handles.ts +3406 -683
- package/src/hosted-runtime.ts +8 -12
- package/src/hosted-ui.ts +28 -32
- package/src/importer.ts +261 -8
- package/src/index.ts +29 -18
- package/src/internal.ts +1 -0
- package/src/join-coverage.ts +42 -0
- package/src/jwt.ts +8 -1
- package/src/launch-context.ts +2 -2
- package/src/mutate-batching.ts +60 -11
- package/src/open-document.ts +2355 -515
- package/src/presence.ts +210 -11
- package/src/record-paging.ts +687 -137
- package/src/selection-persistence.ts +10 -1
- package/src/serializer.ts +33 -1
- package/src/session-persistence.ts +9 -8
- package/src/session-toolbar.ts +249 -80
- package/src/session.ts +91 -17
- package/src/tail-ops.ts +76 -10
- package/src/topic-lifecycle.ts +31 -1
- package/src/types.ts +56 -4
- package/src/undo.ts +13 -5
- package/src/unload-guard.ts +91 -0
- package/src/version-bar.ts +163 -25
- package/src/version.ts +1 -1
- package/src/vite.ts +175 -10
- package/src/ws-client.ts +392 -92
- package/src/ws-frame-limit.ts +19 -12
package/src/hosted-runtime.ts
CHANGED
|
@@ -1,32 +1,28 @@
|
|
|
1
1
|
// Runtime config resolution for Brass-hosted bundles.
|
|
2
2
|
//
|
|
3
|
-
// A bundle served by app hosting
|
|
4
|
-
//
|
|
5
|
-
// the
|
|
6
|
-
// `/.well-known/brass-runtime.json` into the served prefix (see CONTRACT.md
|
|
7
|
-
// "App hosting"), and `Brass.init()` reads it back same-origin and adopts
|
|
3
|
+
// A bundle served by app hosting gets its identity and environment from the
|
|
4
|
+
// serving slot, not from code: the platform stamps a runtime descriptor into
|
|
5
|
+
// the served prefix, and `Brass.init()` reads it back same-origin and adopts
|
|
8
6
|
// it wholesale. It is authoritative on a hosted origin, so the same artifact
|
|
9
7
|
// picks up each environment's config from whichever origin serves it and a
|
|
10
8
|
// bundle that hardcodes one environment can't carry it onto another's slot.
|
|
11
9
|
// `isBrassHostedHost` recognizes the default `*.onbrass.app` slots by
|
|
12
10
|
// hostname; a custom serving domain (a vanity URL) is not recognizable that
|
|
13
11
|
// way, so the SDK also reads the descriptor whenever a bundle relies on the
|
|
14
|
-
// slot for its environment (no
|
|
15
|
-
//
|
|
12
|
+
// slot for its environment (no `*BaseUrl` passed to `Brass.init`), on any
|
|
13
|
+
// host. A present descriptor is authoritative and overrides even a
|
|
16
14
|
// baked app id. A developer on their own hosting passes explicit origins and
|
|
17
15
|
// never triggers the fetch.
|
|
18
16
|
|
|
19
17
|
export const HOSTED_RUNTIME_PATH = '/.well-known/brass-runtime.json';
|
|
20
18
|
|
|
21
19
|
// The window global a Brass serving slot stamps into the served `index.html`
|
|
22
|
-
//
|
|
20
|
+
// as an inline script. This is the primary,
|
|
23
21
|
// fetch-free path: the HOST decides whether a bundle is on a Brass slot by
|
|
24
22
|
// whether the served page carries this global, so the SDK never guesses from
|
|
25
23
|
// the hostname (a custom serving domain is indistinguishable from
|
|
26
|
-
// self-hosting by name) nor makes a request that 404s off a slot.
|
|
27
|
-
//
|
|
28
|
-
// published package cannot import the workspace module, so the name is
|
|
29
|
-
// duplicated by necessity).
|
|
24
|
+
// self-hosting by name) nor makes a request that 404s off a slot. The name is
|
|
25
|
+
// part of the serving contract, so it is fixed rather than configurable.
|
|
30
26
|
const HOSTED_RUNTIME_GLOBAL = '__brassRuntime';
|
|
31
27
|
|
|
32
28
|
// The apex app hosting serves bundles under. Every hosting origin is some
|
package/src/hosted-ui.ts
CHANGED
|
@@ -27,12 +27,8 @@
|
|
|
27
27
|
// page learns the host origin from `init` and targets outbound at it (never
|
|
28
28
|
// `*`).
|
|
29
29
|
|
|
30
|
-
// Both sides ignore any `message` whose `data[ENVELOPE]` is not this exact
|
|
31
|
-
// value, so unrelated postMessage traffic never reaches the handshake.
|
|
32
30
|
import { BrassError } from './errors.js';
|
|
33
|
-
|
|
34
|
-
const ENVELOPE = '__brass_embed';
|
|
35
|
-
const PROTOCOL_VERSION = 1;
|
|
31
|
+
import { EMBED_ENVELOPE, EMBED_PROTOCOL_VERSION } from './embed-protocol.js';
|
|
36
32
|
|
|
37
33
|
// No `ready` within this window means the iframe is unusable (CSP, origin
|
|
38
34
|
// unreachable, bridge threw). What the window is worth is set by what the
|
|
@@ -267,14 +263,6 @@ export interface HostedUiLauncherOptions {
|
|
|
267
263
|
// state instead of parking the remote-eviction error surface. Signalled
|
|
268
264
|
// pre-flight, so the note beats the server's `doc_gone` frame.
|
|
269
265
|
onLocalDelete?: (docId: string, active: boolean) => void;
|
|
270
|
-
// The SDK's fallback platform-session pointer (`sid`), non-null only when
|
|
271
|
-
// this browser blocked the cross-site `brass_auth` cookie and the SDK is
|
|
272
|
-
// running in code / session-token mode. Read at dialog-open time and posted
|
|
273
|
-
// in the bridge `init` (to the pinned dashboard origin only) so the embed
|
|
274
|
-
// page can refresh the dashboard's own tokens when its framed cookie is
|
|
275
|
-
// likewise blocked. Null in a normal browser, where the embed's cookie
|
|
276
|
-
// carries its session and no hand-off is needed.
|
|
277
|
-
getSessionToken?: () => string | null;
|
|
278
266
|
// The `sub` of the user this app is currently acting as, read at
|
|
279
267
|
// dialog-open time and posted in the bridge `init`. The embed page
|
|
280
268
|
// authenticates from the dashboard origin's own platform-session cookie,
|
|
@@ -314,11 +302,6 @@ function redirectToStandalone(
|
|
|
314
302
|
): void {
|
|
315
303
|
const url = new URL(`${baseUrl}/embed/${surface}`);
|
|
316
304
|
for (const [key, value] of Object.entries(params)) {
|
|
317
|
-
// `sessionToken` is an in-iframe-only credential hand-off (posted over the
|
|
318
|
-
// bridge to the pinned dashboard origin); never let it reach a URL. On the
|
|
319
|
-
// redirect fallback the standalone page is top-level and first-party, so
|
|
320
|
-
// its own cookie carries the session and no hand-off is needed.
|
|
321
|
-
if (key === 'sessionToken') continue;
|
|
322
305
|
if (value !== undefined && value !== null) {
|
|
323
306
|
url.searchParams.set(key, String(value));
|
|
324
307
|
}
|
|
@@ -666,13 +649,13 @@ function openEmbed(
|
|
|
666
649
|
if (event.origin !== dashboardOrigin) return;
|
|
667
650
|
if (event.source !== iframe.contentWindow) return;
|
|
668
651
|
const data = event.data as Record<string, unknown> | null;
|
|
669
|
-
if (!data || data[
|
|
652
|
+
if (!data || data[EMBED_ENVELOPE] !== EMBED_PROTOCOL_VERSION) return;
|
|
670
653
|
|
|
671
654
|
switch (data['type']) {
|
|
672
655
|
case 'ready': {
|
|
673
656
|
if (readyTimer !== undefined) clearTimeout(readyTimer);
|
|
674
657
|
iframe.contentWindow?.postMessage(
|
|
675
|
-
{ [
|
|
658
|
+
{ [EMBED_ENVELOPE]: EMBED_PROTOCOL_VERSION, type: 'init', surface, params },
|
|
676
659
|
dashboardOrigin,
|
|
677
660
|
);
|
|
678
661
|
// Embed page mounted; fade the loader out (delayed removal so the
|
|
@@ -710,7 +693,7 @@ function openEmbed(
|
|
|
710
693
|
const docId = typeof params['docId'] === 'string' ? params['docId'] : '';
|
|
711
694
|
const reply = (outcome: ExportSerializeOutcome): void => {
|
|
712
695
|
iframe.contentWindow?.postMessage(
|
|
713
|
-
{ [
|
|
696
|
+
{ [EMBED_ENVELOPE]: EMBED_PROTOCOL_VERSION, type: 'export-result', requestId, ...outcome },
|
|
714
697
|
dashboardOrigin,
|
|
715
698
|
);
|
|
716
699
|
};
|
|
@@ -833,6 +816,23 @@ function openEmbed(
|
|
|
833
816
|
});
|
|
834
817
|
}
|
|
835
818
|
|
|
819
|
+
// Warm the connection to the origin the embed pages come from. An app talks to
|
|
820
|
+
// the api and auth origins, never this one, so the first hosted surface a
|
|
821
|
+
// reader opens otherwise pays DNS, TCP and TLS before the iframe's document
|
|
822
|
+
// request can even start. Idempotent, since an app may build more than one
|
|
823
|
+
// launcher.
|
|
824
|
+
function preconnectToDashboard(origin: string): void {
|
|
825
|
+
if (typeof document === 'undefined') return;
|
|
826
|
+
const links = document.head.querySelectorAll('link[rel="preconnect"]');
|
|
827
|
+
for (const link of links) {
|
|
828
|
+
if (link.getAttribute('href') === origin) return;
|
|
829
|
+
}
|
|
830
|
+
const link = document.createElement('link');
|
|
831
|
+
link.rel = 'preconnect';
|
|
832
|
+
link.href = origin;
|
|
833
|
+
document.head.appendChild(link);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
836
|
export function createHostedUiLauncher(
|
|
837
837
|
opts: HostedUiLauncherOptions,
|
|
838
838
|
): HostedUiLauncher {
|
|
@@ -840,6 +840,7 @@ export function createHostedUiLauncher(
|
|
|
840
840
|
// Parsed once for the postMessage origin checks; a malformed
|
|
841
841
|
// `dashboardBaseUrl` throws here, at construction, not mid-handshake.
|
|
842
842
|
const dashboardOrigin = new URL(baseUrl).origin;
|
|
843
|
+
preconnectToDashboard(dashboardOrigin);
|
|
843
844
|
// Watch for activations from here on, so a surface opened later knows which
|
|
844
845
|
// control the reader pressed to get there.
|
|
845
846
|
trackHostedUiActivations();
|
|
@@ -847,21 +848,16 @@ export function createHostedUiLauncher(
|
|
|
847
848
|
// would put a dark embed inside a light host on a dark-OS user.
|
|
848
849
|
const theme: HostedUiTheme = opts.theme ?? 'light';
|
|
849
850
|
|
|
850
|
-
//
|
|
851
|
-
//
|
|
852
|
-
//
|
|
853
|
-
//
|
|
854
|
-
|
|
855
|
-
// Every embed surface authenticates the same way, so this is cross-cutting
|
|
856
|
-
// rather than per-surface like `appId`.
|
|
857
|
-
const withSession = (
|
|
851
|
+
// Name the user this app is acting as in every surface's bridge params.
|
|
852
|
+
// Read at open time, not launcher-construction time, since the app may
|
|
853
|
+
// authenticate after the launcher is built. Every embed surface checks the
|
|
854
|
+
// same way, so this is cross-cutting rather than per-surface like `appId`.
|
|
855
|
+
const withActingSub = (
|
|
858
856
|
params: Record<string, unknown>,
|
|
859
857
|
): Record<string, unknown> => {
|
|
860
|
-
const sid = opts.getSessionToken?.();
|
|
861
858
|
const actingSub = opts.getActingSub?.();
|
|
862
859
|
return {
|
|
863
860
|
...params,
|
|
864
|
-
...(sid ? { sessionToken: sid } : {}),
|
|
865
861
|
...(actingSub ? { actingSub } : {}),
|
|
866
862
|
};
|
|
867
863
|
};
|
|
@@ -880,7 +876,7 @@ export function createHostedUiLauncher(
|
|
|
880
876
|
baseUrl,
|
|
881
877
|
dashboardOrigin,
|
|
882
878
|
surface,
|
|
883
|
-
|
|
879
|
+
withActingSub(params),
|
|
884
880
|
unavailable,
|
|
885
881
|
theme,
|
|
886
882
|
exportDoc ? opts.serializeForExport : undefined,
|
package/src/importer.ts
CHANGED
|
@@ -38,6 +38,35 @@ export interface ImportRunner {
|
|
|
38
38
|
attempt(doc: Document): Promise<void>;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// The claim holder can close its tab mid-import and let its claim expire, so
|
|
42
|
+
// a client that lost the race asks again instead of spending this document's
|
|
43
|
+
// one attempt on it.
|
|
44
|
+
//
|
|
45
|
+
// A holder EXTENDS its claim while it runs (`startClaimRefresh`), so the
|
|
46
|
+
// expiry a loser is waiting out is not the platform's base TTL but
|
|
47
|
+
// `last-refresh + TTL`, up to `CLAIM_REFRESH_MAX` refreshes away. A schedule
|
|
48
|
+
// sized against the base TTL therefore gives up while the claim is still
|
|
49
|
+
// live, and what it leaves behind is a reader sitting on an empty document
|
|
50
|
+
// with no further attempt coming and nothing said. So the tail keeps asking,
|
|
51
|
+
// at an interval, for longer than a holder can hold.
|
|
52
|
+
const PENDING_RETRY_DELAYS_MS = [15_000, 45_000, 135_000];
|
|
53
|
+
const PENDING_RETRY_INTERVAL_MS = 120_000;
|
|
54
|
+
// Enough tail attempts to outlast a holder refreshing to its own bound, and
|
|
55
|
+
// far inside the per-caller claim budget.
|
|
56
|
+
const PENDING_RETRY_TAIL = 18;
|
|
57
|
+
|
|
58
|
+
// A claim expires while a long import is still parsing or still waiting for
|
|
59
|
+
// its writes to be acked, and the completion needs it, so the runner
|
|
60
|
+
// re-claims on this interval while its own import runs. Bounded so a
|
|
61
|
+
// document whose writes never drain stops re-claiming.
|
|
62
|
+
const CLAIM_REFRESH_MS = 60_000;
|
|
63
|
+
const CLAIM_REFRESH_MAX = 30;
|
|
64
|
+
|
|
65
|
+
// The status the platform refuses an import the app cannot run with: its
|
|
66
|
+
// `imports` do not claim the file, it declares no schema, or the document
|
|
67
|
+
// holds a half-written import.
|
|
68
|
+
const CLAIM_REFUSED_STATUS = 422;
|
|
69
|
+
|
|
41
70
|
// Runs the import protocol for one opened document: claim, and on the
|
|
42
71
|
// win fetch + parse + complete. Separated from the client so the
|
|
43
72
|
// protocol is testable against mocked HTTP deps.
|
|
@@ -46,12 +75,63 @@ export function createImportRunner(deps: ImportRunnerDeps): ImportRunner {
|
|
|
46
75
|
// One attempt per Document instance; a reopen mints a new instance,
|
|
47
76
|
// whose claim resolves `already_imported` as a no-op.
|
|
48
77
|
const attempted = new WeakSet<Document>();
|
|
49
|
-
|
|
78
|
+
// Documents waiting on server-side work, holding the detach for the status
|
|
79
|
+
// listener that is watching it. Kept apart from `attempted` so the wait does
|
|
80
|
+
// not consume the attempt, and holding the DETACH rather than a bare mark so
|
|
81
|
+
// a second `attempt` call replaces the listener instead of stacking one.
|
|
82
|
+
//
|
|
83
|
+
// Replacing rather than returning early is what survives a park. An app's
|
|
84
|
+
// `close()` parks the Document, which drops every status listener, and the
|
|
85
|
+
// park cache hands the SAME instance back on reopen; a mark alone would
|
|
86
|
+
// still say "someone is watching this" when nothing is, and the reopened
|
|
87
|
+
// document would never attempt its import again for the life of the page.
|
|
88
|
+
const waiting = new WeakMap<Document, () => void>();
|
|
89
|
+
// Retries already scheduled against another client's live claim.
|
|
90
|
+
const pendingRetries = new WeakMap<Document, number>();
|
|
91
|
+
const runner: ImportRunner = {
|
|
50
92
|
setImporter(fn: DocumentImporter): void {
|
|
51
93
|
importer = fn;
|
|
52
94
|
},
|
|
53
95
|
async attempt(doc: Document): Promise<void> {
|
|
54
96
|
if (importer === null || doc.id === null || attempted.has(doc)) return;
|
|
97
|
+
// Claiming an import binds the document to THIS client for the claim's
|
|
98
|
+
// lifetime, and only this client can complete it. A document that is
|
|
99
|
+
// closed or parked can no longer read its source or write records, so a
|
|
100
|
+
// claim taken for one is a claim nothing will ever complete: every other
|
|
101
|
+
// open client is refused until the platform's claim expires. The retry
|
|
102
|
+
// timer below and the resumed-open path both reach here with a document
|
|
103
|
+
// that may have gone since, so the gate belongs at the entry rather than
|
|
104
|
+
// at each caller.
|
|
105
|
+
if (!doc._isLive) return;
|
|
106
|
+
// A document with server-side work in flight has no source file to
|
|
107
|
+
// read yet. A drive-linked file is fetched from the drive and stored
|
|
108
|
+
// by a worker AFTER the open returns, so claiming now asks to import
|
|
109
|
+
// bytes that have not arrived: the platform refuses, and this
|
|
110
|
+
// document's one attempt is spent on a document that was never
|
|
111
|
+
// eligible. Wait for the work the server reports, then attempt.
|
|
112
|
+
if (doc.status?.pending === true) {
|
|
113
|
+
// Any listener from an earlier attempt goes first, so the document is
|
|
114
|
+
// watched exactly once whether or not the previous one survived.
|
|
115
|
+
waiting.get(doc)?.();
|
|
116
|
+
waiting.delete(doc);
|
|
117
|
+
try {
|
|
118
|
+
const off = doc.onStatus((status) => {
|
|
119
|
+
if (status.pending) return;
|
|
120
|
+
waiting.get(doc)?.();
|
|
121
|
+
waiting.delete(doc);
|
|
122
|
+
void runner.attempt(doc);
|
|
123
|
+
});
|
|
124
|
+
waiting.set(doc, off);
|
|
125
|
+
} catch {
|
|
126
|
+
// The document closed while attaching; nothing left to import
|
|
127
|
+
// into, and a reopen mints a fresh instance that attempts again.
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
// The work finished, so nothing is left to wait for; drop the listener
|
|
132
|
+
// rather than leave it to fire against a document already past it.
|
|
133
|
+
waiting.get(doc)?.();
|
|
134
|
+
waiting.delete(doc);
|
|
55
135
|
attempted.add(doc);
|
|
56
136
|
const run = importer;
|
|
57
137
|
const docId = doc.id;
|
|
@@ -59,10 +139,19 @@ export function createImportRunner(deps: ImportRunnerDeps): ImportRunner {
|
|
|
59
139
|
try {
|
|
60
140
|
claim = await deps.claimImport(docId);
|
|
61
141
|
} catch (err) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
142
|
+
const status = err instanceof BrassError ? err.status : undefined;
|
|
143
|
+
if (status === 409) {
|
|
144
|
+
schedulePendingRetry(doc);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
// A refusal names what the developer has to change, so it reaches
|
|
148
|
+
// the app's error handler; the rest of the 4xx surface (nothing
|
|
149
|
+
// stored to import, read-only access) is a no-op.
|
|
150
|
+
if (
|
|
151
|
+
status !== undefined &&
|
|
152
|
+
status < 500 &&
|
|
153
|
+
status !== CLAIM_REFUSED_STATUS
|
|
154
|
+
) {
|
|
66
155
|
return;
|
|
67
156
|
}
|
|
68
157
|
deps.reportError(asError(err));
|
|
@@ -71,19 +160,183 @@ export function createImportRunner(deps: ImportRunnerDeps): ImportRunner {
|
|
|
71
160
|
if (claim.claimed !== true) return;
|
|
72
161
|
// This client won the claim: run the importer, then report
|
|
73
162
|
// completion so the platform stamps the document with the app's
|
|
74
|
-
// schema. A throw
|
|
75
|
-
//
|
|
163
|
+
// schema. A throw skips the completion and leaves the claim to
|
|
164
|
+
// expire; the platform decides what a later claim may do with a
|
|
165
|
+
// document the failed run already wrote records into.
|
|
76
166
|
try {
|
|
77
167
|
const source = await doc.sourceBlob();
|
|
78
|
-
|
|
168
|
+
const stopRefresh = startClaimRefresh(docId, doc);
|
|
169
|
+
// Whether the importer put anything on the wire. A commit fires the
|
|
170
|
+
// document's subscribers synchronously and raises `savingCount` as it
|
|
171
|
+
// does, so a run that wrote is seen here even when its ops ack before
|
|
172
|
+
// it returns.
|
|
173
|
+
let wroteRecords = false;
|
|
174
|
+
const stopWatchingWrites = doc.subscribe(() => {
|
|
175
|
+
if (doc.savingCount > 0) wroteRecords = true;
|
|
176
|
+
});
|
|
177
|
+
try {
|
|
178
|
+
await run(source, doc);
|
|
179
|
+
// The importer's ops are still in flight when it returns, and
|
|
180
|
+
// the completion stamps the version the import produced from
|
|
181
|
+
// the head it lands on. Completing before they are acked stamps
|
|
182
|
+
// a version below the records' own, which reads as a document
|
|
183
|
+
// edited since it was uploaded from then on.
|
|
184
|
+
await whenSaved(doc);
|
|
185
|
+
} finally {
|
|
186
|
+
stopRefresh();
|
|
187
|
+
stopWatchingWrites();
|
|
188
|
+
}
|
|
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;
|
|
197
|
+
// An importer that returns without writing anything must not stamp the
|
|
198
|
+
// document imported: the stamp is permanent, a later open reads
|
|
199
|
+
// `already_imported`, and the file's records then have no path into a
|
|
200
|
+
// document that holds none. `try { parse() } catch { return }` is an
|
|
201
|
+
// ordinary shape, so this is reachable from app code that looks
|
|
202
|
+
// correct. Both signals are required before skipping, so an import
|
|
203
|
+
// that DID write is completed either way and never runs twice.
|
|
204
|
+
if (!wroteRecords && isEmptyDocument(doc)) {
|
|
205
|
+
deps.reportError(
|
|
206
|
+
new Error(
|
|
207
|
+
'The importer wrote no records, so this file was not imported. ' +
|
|
208
|
+
'Reload to try again.',
|
|
209
|
+
),
|
|
210
|
+
);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
79
213
|
await deps.completeImport(docId);
|
|
80
214
|
} catch (err) {
|
|
81
215
|
deps.reportError(asError(err));
|
|
82
216
|
}
|
|
83
217
|
},
|
|
84
218
|
};
|
|
219
|
+
|
|
220
|
+
function schedulePendingRetry(doc: Document): void {
|
|
221
|
+
const taken = pendingRetries.get(doc) ?? 0;
|
|
222
|
+
const delay =
|
|
223
|
+
PENDING_RETRY_DELAYS_MS[taken] ??
|
|
224
|
+
(taken < PENDING_RETRY_DELAYS_MS.length + PENDING_RETRY_TAIL
|
|
225
|
+
? PENDING_RETRY_INTERVAL_MS
|
|
226
|
+
: undefined);
|
|
227
|
+
if (delay === undefined) {
|
|
228
|
+
// Out of attempts with the file still unimported. The reader is looking
|
|
229
|
+
// at an empty document, so say so rather than leaving them to conclude
|
|
230
|
+
// the file was empty.
|
|
231
|
+
deps.reportError(
|
|
232
|
+
new Error(
|
|
233
|
+
'Could not import this file: another window has been importing it ' +
|
|
234
|
+
'for longer than expected. Reload to try again.',
|
|
235
|
+
),
|
|
236
|
+
);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
pendingRetries.set(doc, taken + 1);
|
|
240
|
+
attempted.delete(doc);
|
|
241
|
+
setTimeout(() => {
|
|
242
|
+
void runner.attempt(doc);
|
|
243
|
+
}, delay);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function startClaimRefresh(docId: string, doc: Document): () => void {
|
|
247
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
248
|
+
let left = CLAIM_REFRESH_MAX;
|
|
249
|
+
const tick = (): void => {
|
|
250
|
+
if (left <= 0) return;
|
|
251
|
+
// A renewal from a document that has gone tells the platform the holder
|
|
252
|
+
// is still working, when this client can no longer complete the import
|
|
253
|
+
// at all. Stopping here is what lets the claim expire on its own TTL so
|
|
254
|
+
// a client that IS open picks the file up, rather than being refused for
|
|
255
|
+
// the whole refresh budget.
|
|
256
|
+
if (!doc._isLive) return;
|
|
257
|
+
left -= 1;
|
|
258
|
+
// A failed refresh needs no handling: the completion reports
|
|
259
|
+
// whether the claim survived.
|
|
260
|
+
void deps.claimImport(docId).catch(() => undefined);
|
|
261
|
+
timer = setTimeout(tick, CLAIM_REFRESH_MS);
|
|
262
|
+
};
|
|
263
|
+
timer = setTimeout(tick, CLAIM_REFRESH_MS);
|
|
264
|
+
return () => {
|
|
265
|
+
clearTimeout(timer);
|
|
266
|
+
left = 0;
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return runner;
|
|
271
|
+
}
|
|
272
|
+
|
|
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
|
+
});
|
|
85
318
|
}
|
|
86
319
|
|
|
87
320
|
function asError(err: unknown): Error {
|
|
88
321
|
return err instanceof Error ? err : new Error(String(err));
|
|
89
322
|
}
|
|
323
|
+
|
|
324
|
+
// Whether the document holds nothing at all, read only when no write was
|
|
325
|
+
// observed, so the walk is over an empty or near-empty document.
|
|
326
|
+
//
|
|
327
|
+
// A document reports its ROOTS whatever it holds (a fresh one reads
|
|
328
|
+
// `{ entities: {} }`), so emptiness is every root being empty rather than
|
|
329
|
+
// there being no roots.
|
|
330
|
+
function isEmptyDocument(doc: Document): boolean {
|
|
331
|
+
try {
|
|
332
|
+
return Object.values(doc.toJSON()).every(
|
|
333
|
+
(root) =>
|
|
334
|
+
root === null ||
|
|
335
|
+
typeof root !== 'object' ||
|
|
336
|
+
Object.keys(root as object).length === 0,
|
|
337
|
+
);
|
|
338
|
+
} catch {
|
|
339
|
+
// A document that cannot be read is not one to declare empty.
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
// The app-facing entry (`@brass-build/client`). A type reaches this barrel
|
|
2
|
+
// only when a member of `Brass`, `Document`, or `DocumentSession` names it
|
|
3
|
+
// in a signature, so what an app's autocomplete offers is what an app can
|
|
4
|
+
// call. The plumbing under those members (the session's dependency seam,
|
|
5
|
+
// the document factory, the join and at-version wire shapes) stays exported
|
|
6
|
+
// from its own module for this package's own use, and `./internal` carries
|
|
7
|
+
// the primitives Brass's own dashboard and console import.
|
|
8
|
+
// `__tests__/exports-contract.test.ts` pins both sets.
|
|
9
|
+
|
|
1
10
|
import { BrassClient } from './client.js';
|
|
2
11
|
import type {
|
|
3
12
|
CreateDocumentOptions,
|
|
@@ -14,6 +23,7 @@ import type {
|
|
|
14
23
|
ErrorListener,
|
|
15
24
|
BrassInitOptions,
|
|
16
25
|
BrassUser,
|
|
26
|
+
UndoControllerOptions,
|
|
17
27
|
UserChangeListener,
|
|
18
28
|
} from './types.js';
|
|
19
29
|
|
|
@@ -22,13 +32,11 @@ export type {
|
|
|
22
32
|
ErrorListener,
|
|
23
33
|
BrassInitOptions,
|
|
24
34
|
BrassUser,
|
|
35
|
+
UndoControllerOptions,
|
|
25
36
|
UserChangeListener,
|
|
26
37
|
};
|
|
27
38
|
export { BrassError, type BrassErrorCode } from './errors.js';
|
|
28
39
|
export {
|
|
29
|
-
type AcquireOrganizationResult,
|
|
30
|
-
type HostedUiLauncher,
|
|
31
|
-
type HostedUiLauncherOptions,
|
|
32
40
|
type HostedUiTheme,
|
|
33
41
|
type SelectDocumentResult,
|
|
34
42
|
} from './hosted-ui.js';
|
|
@@ -52,7 +60,6 @@ export {
|
|
|
52
60
|
type CommandGroup,
|
|
53
61
|
type CommandSurface,
|
|
54
62
|
type DocumentSession,
|
|
55
|
-
type DocumentSessionDeps,
|
|
56
63
|
type SessionOpenOptions,
|
|
57
64
|
type SessionStatus,
|
|
58
65
|
} from './session.js';
|
|
@@ -96,34 +103,38 @@ export type {
|
|
|
96
103
|
ChangeSource,
|
|
97
104
|
HandleUnsubscribe,
|
|
98
105
|
ListHandle,
|
|
106
|
+
ListChange,
|
|
99
107
|
ListSubscriber,
|
|
108
|
+
Splice,
|
|
100
109
|
MapHandle,
|
|
101
110
|
MapSubscriber,
|
|
102
111
|
OrderedMapHandle,
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
112
|
+
OrderedMapPosition,
|
|
113
|
+
} from './handle-types.js';
|
|
114
|
+
|
|
115
|
+
export type {
|
|
116
|
+
DocumentSerializer,
|
|
117
|
+
StoredDocument,
|
|
118
|
+
StoredList,
|
|
119
|
+
StoredMap,
|
|
120
|
+
} from './serializer.js';
|
|
106
121
|
export type { DocumentImporter } from './importer.js';
|
|
107
|
-
export { DocumentStream
|
|
122
|
+
export { DocumentStream } from './document-streams.js';
|
|
108
123
|
export {
|
|
109
|
-
type CreateDocumentFn,
|
|
110
124
|
type CreateDocumentOptions,
|
|
111
|
-
type DocumentAtVersionResponse,
|
|
112
|
-
type DocumentDriveSummaryWire,
|
|
113
|
-
type DocumentFactory,
|
|
114
|
-
type DocumentSnapshotResponse,
|
|
115
|
-
type OpenDocumentFactoryOptions,
|
|
116
|
-
type OpenDocumentByIdFn,
|
|
117
125
|
type OpenDocumentByIdOptions,
|
|
118
|
-
type OpenDocumentWsClient,
|
|
119
126
|
type OpenedDocument,
|
|
120
127
|
} from './open-document.js';
|
|
128
|
+
export { PRESENCE_LAYER_MAX_BYTES } from './presence.js';
|
|
121
129
|
export type {
|
|
122
130
|
PresenceChangeListener,
|
|
123
131
|
PresenceClient,
|
|
132
|
+
PresenceLayer,
|
|
124
133
|
PresenceParticipant,
|
|
125
134
|
PresenceSetInput,
|
|
126
135
|
PresenceState,
|
|
136
|
+
PresenceStatus,
|
|
137
|
+
PresenceStatusListener,
|
|
127
138
|
} from './presence.js';
|
|
128
139
|
|
|
129
140
|
const client = new BrassClient();
|
|
@@ -169,8 +180,8 @@ export const Brass = {
|
|
|
169
180
|
getAccessToken(): Promise<string | null> {
|
|
170
181
|
return client.getAccessToken();
|
|
171
182
|
},
|
|
172
|
-
// Metadata read for a known id (`get`) plus thumbnails /
|
|
173
|
-
//
|
|
183
|
+
// Metadata read for a known id (`get`) plus thumbnails. Create / move /
|
|
184
|
+
// list / grant access are hosted-only.
|
|
174
185
|
get documents(): DocumentsNamespace {
|
|
175
186
|
return client.documents;
|
|
176
187
|
},
|
package/src/internal.ts
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Which seqs a join response actually delivered.
|
|
2
|
+
//
|
|
3
|
+
// A join carries a snapshot sealed at `snapshot_seq`, the op rows above it,
|
|
4
|
+
// and `current_seq`, the branch's seq counter. The counter is NOT a delivery
|
|
5
|
+
// claim: the server allocates a seq on it before writing that op's row, so
|
|
6
|
+
// while someone is writing in bulk the counter names ops whose rows the
|
|
7
|
+
// tail query cannot see yet. A reader that treats the counter as covered
|
|
8
|
+
// records itself as holding ops it was never sent, and every later prompt
|
|
9
|
+
// for them short-circuits on that record.
|
|
10
|
+
//
|
|
11
|
+
// What a response can vouch for is the snapshot plus the unbroken run of
|
|
12
|
+
// tail seqs above it. Anything past the first gap is applied but not
|
|
13
|
+
// covered, so it parks until the gap fills.
|
|
14
|
+
|
|
15
|
+
/** The contiguous floor a join delivered: `snapshotSeq` plus its tail run. */
|
|
16
|
+
export function joinCoverageFloor(
|
|
17
|
+
snapshotSeq: number,
|
|
18
|
+
tailSeqs: readonly number[],
|
|
19
|
+
): number {
|
|
20
|
+
const present = new Set(tailSeqs);
|
|
21
|
+
let floor = snapshotSeq;
|
|
22
|
+
while (present.has(floor + 1)) floor += 1;
|
|
23
|
+
return floor;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Where the snapshot in a join response ends, for a response that may not
|
|
28
|
+
* say. Our server always reports `snapshot_seq`; a response without it
|
|
29
|
+
* (an older server, a fixture) leaves only the tail's own lower edge, which
|
|
30
|
+
* is the true boundary except when the first op above the snapshot is one of
|
|
31
|
+
* the rows still in flight. With no tail at all there is nothing to infer
|
|
32
|
+
* from, and the counter is all that is on offer.
|
|
33
|
+
*/
|
|
34
|
+
export function snapshotSeqOf(
|
|
35
|
+
reported: number | undefined,
|
|
36
|
+
tailSeqs: readonly number[],
|
|
37
|
+
currentSeq: number,
|
|
38
|
+
): number {
|
|
39
|
+
if (typeof reported === 'number') return reported;
|
|
40
|
+
if (tailSeqs.length === 0) return currentSeq;
|
|
41
|
+
return Math.min(...tailSeqs) - 1;
|
|
42
|
+
}
|
package/src/jwt.ts
CHANGED
|
@@ -6,7 +6,14 @@ export function decodeIdToken(idToken: string): BrassUser | null {
|
|
|
6
6
|
const payloadPart = parts[1];
|
|
7
7
|
if (!payloadPart) return null;
|
|
8
8
|
try {
|
|
9
|
-
|
|
9
|
+
// A JWT payload is UTF-8, and `atob` answers one latin1 character per
|
|
10
|
+
// byte, so a claim with any non-ASCII character (an accented name in
|
|
11
|
+
// `email`) needs the bytes decoded rather than the string used as is.
|
|
12
|
+
const bytes = Uint8Array.from(
|
|
13
|
+
atob(payloadPart.replace(/-/g, '+').replace(/_/g, '/')),
|
|
14
|
+
(ch) => ch.charCodeAt(0),
|
|
15
|
+
);
|
|
16
|
+
const json = new TextDecoder().decode(bytes);
|
|
10
17
|
const claims = JSON.parse(json) as { sub?: unknown; email?: unknown };
|
|
11
18
|
if (typeof claims.sub !== 'string') return null;
|
|
12
19
|
const user: BrassUser = { sub: claims.sub };
|
package/src/launch-context.ts
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
// collide with an app's own query string, and stripped from the address bar
|
|
11
11
|
// on read so it does not linger in history or a referrer.
|
|
12
12
|
//
|
|
13
|
-
// The dashboard writes this parameter
|
|
14
|
-
//
|
|
13
|
+
// The dashboard writes this parameter when it opens an app, and the published
|
|
14
|
+
// SDK cannot import the dashboard's source, so the name is spelled in both
|
|
15
15
|
// places.
|
|
16
16
|
|
|
17
17
|
export const LAUNCH_ORGANIZATION_PARAM = 'brass_org';
|