@brass-build/client 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +72 -63
- package/CHANGELOG.md +97 -0
- package/README.md +325 -235
- 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 +4 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +245 -76
- package/dist/client.js.map +1 -1
- package/dist/document-checkpoints.d.ts +1 -0
- package/dist/document-checkpoints.d.ts.map +1 -1
- package/dist/document-checkpoints.js +27 -0
- package/dist/document-checkpoints.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-signals.d.ts +31 -1
- package/dist/document-signals.d.ts.map +1 -1
- package/dist/document-signals.js +59 -0
- package/dist/document-signals.js.map +1 -1
- 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/document-thumbnail-capture.d.ts +20 -0
- package/dist/document-thumbnail-capture.d.ts.map +1 -1
- package/dist/document-thumbnail-capture.js +247 -9
- package/dist/document-thumbnail-capture.js.map +1 -1
- package/dist/documents-namespace.d.ts.map +1 -1
- package/dist/documents-namespace.js +22 -3
- package/dist/documents-namespace.js.map +1 -1
- package/dist/documents.d.ts +49 -16
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +981 -237
- 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 +204 -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 +2 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +1 -0
- 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 +30 -1
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +1905 -322
- 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 +335 -71
- package/dist/record-paging.d.ts.map +1 -1
- package/dist/record-paging.js +590 -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 +356 -83
- 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 +162 -20
- 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/when-saved.d.ts +2 -0
- package/dist/when-saved.d.ts.map +1 -0
- package/dist/when-saved.js +70 -0
- package/dist/when-saved.js.map +1 -0
- package/dist/ws-client.d.ts +27 -3
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +408 -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 +23 -17
- package/dist/ws-frame-limit.js.map +1 -1
- package/docs/app-manifest.md +7 -8
- package/docs/custom-chrome.md +21 -22
- 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 +262 -80
- package/src/document-checkpoints.ts +30 -0
- package/src/document-engine.ts +15 -0
- package/src/document-signals.ts +120 -8
- package/src/document-streams.ts +95 -9
- package/src/document-thumbnail-capture.ts +253 -9
- package/src/documents-namespace.ts +38 -13
- package/src/documents.ts +1103 -290
- 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 +3407 -683
- package/src/hosted-runtime.ts +8 -12
- package/src/hosted-ui.ts +28 -32
- package/src/importer.ts +216 -8
- package/src/index.ts +29 -18
- package/src/internal.ts +7 -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 +2441 -502
- package/src/presence.ts +210 -11
- package/src/record-paging.ts +686 -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 +387 -89
- 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 +183 -26
- package/src/version.ts +1 -1
- package/src/vite.ts +175 -10
- package/src/when-saved.ts +66 -0
- package/src/ws-client.ts +461 -92
- package/src/ws-frame-limit.ts +23 -17
package/src/client.ts
CHANGED
|
@@ -2,12 +2,15 @@ import {
|
|
|
2
2
|
buildSignInUrl,
|
|
3
3
|
buildSignedOutOfAppUrl,
|
|
4
4
|
refreshTokens,
|
|
5
|
-
|
|
5
|
+
revokeAppApproval,
|
|
6
|
+
type RefreshCredential,
|
|
7
|
+
type RefreshOutcome,
|
|
6
8
|
type TokenSet,
|
|
7
9
|
} from './auth-flow.js';
|
|
8
10
|
import { decodeIdToken } from './jwt.js';
|
|
9
11
|
import { createEmitter } from './emitter.js';
|
|
10
12
|
import {
|
|
13
|
+
clearParkedDocuments,
|
|
11
14
|
createOpenDocument,
|
|
12
15
|
type CreateDocumentArgs,
|
|
13
16
|
type CreateDocumentOptions,
|
|
@@ -28,7 +31,7 @@ import {
|
|
|
28
31
|
type SelectDocumentResult,
|
|
29
32
|
} from './hosted-ui.js';
|
|
30
33
|
import type { Document } from './documents.js';
|
|
31
|
-
import type { DocumentSerializer } from './serializer.js';
|
|
34
|
+
import type { DocumentSerializer, StoredDocument } from './serializer.js';
|
|
32
35
|
import {
|
|
33
36
|
createImportRunner,
|
|
34
37
|
type DocumentImporter,
|
|
@@ -73,6 +76,8 @@ import {
|
|
|
73
76
|
createDocumentsNamespace,
|
|
74
77
|
type DocumentsNamespace,
|
|
75
78
|
type DocumentsInternalNamespace,
|
|
79
|
+
type PushToDriveInput,
|
|
80
|
+
type PushToDriveResult,
|
|
76
81
|
} from './documents-namespace.js';
|
|
77
82
|
import {
|
|
78
83
|
createDocumentSession,
|
|
@@ -111,6 +116,10 @@ import type {
|
|
|
111
116
|
} from './types.js';
|
|
112
117
|
|
|
113
118
|
const DEFAULT_AUTH_BASE_URL = 'https://auth.brass.build';
|
|
119
|
+
// How long `signOut` waits for the revocation before it navigates. The page
|
|
120
|
+
// it lands on finishes an unrevoked sign-out itself, so waiting longer than
|
|
121
|
+
// this only delays the sign-out.
|
|
122
|
+
const SIGN_OUT_REVOKE_DEADLINE_MS = 4000;
|
|
114
123
|
// The data API has its own host, NOT the dashboard origin: the
|
|
115
124
|
// dashboard (app.brass.build) is a static SPA, the data API is a
|
|
116
125
|
// separate gateway behind api.brass.build.
|
|
@@ -146,11 +155,22 @@ export interface ResolvedBaseUrls {
|
|
|
146
155
|
|
|
147
156
|
// Resolve the four base URLs from init options. Zero config applies all
|
|
148
157
|
// four production origins (including `wsBaseUrl`, so documents and
|
|
149
|
-
// presence work with nothing passed).
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
158
|
+
// presence work with nothing passed).
|
|
159
|
+
//
|
|
160
|
+
// Naming ANY `*BaseUrl` withholds the production `wsBaseUrl`: an app that
|
|
161
|
+
// overrides `apiBaseUrl` and inherited a production socket would connect to
|
|
162
|
+
// the wrong stack and silently drop events, where an unfilled `wsBaseUrl`
|
|
163
|
+
// fails loudly on subscribe.
|
|
164
|
+
//
|
|
165
|
+
// `authBaseUrl`, `apiBaseUrl` and `dashboardBaseUrl` still fall back to their
|
|
166
|
+
// production defaults, so a caller who names some but not all of them reaches
|
|
167
|
+
// production for the rest. `dashboardBaseUrl` is the one that goes wrong
|
|
168
|
+
// quietly: every hosted surface (the picker, share, the version bar) is framed
|
|
169
|
+
// from it, so an app on another stack frames production's, which either
|
|
170
|
+
// refuses to render or hands back ids the configured API cannot open. Name it
|
|
171
|
+
// alongside the others.
|
|
172
|
+
//
|
|
173
|
+
// Pure so the precedence is unit-testable.
|
|
154
174
|
export function resolveBaseUrls(options: BrassInitOptions): ResolvedBaseUrls {
|
|
155
175
|
const anyUrlOverride = Boolean(
|
|
156
176
|
options.authBaseUrl || options.apiBaseUrl || options.wsBaseUrl || options.dashboardBaseUrl,
|
|
@@ -179,6 +199,23 @@ const REFRESH_LEEWAY_MS = 60_000;
|
|
|
179
199
|
// server-side format bumps from stranding SDK consumers.
|
|
180
200
|
const APP_ID_PATTERN = /^brass_app_(?:(?:test|live|internal)_)?[A-Za-z0-9_-]+$/;
|
|
181
201
|
|
|
202
|
+
// What one `/refresh` settled, applied to the client's own state. Carries the
|
|
203
|
+
// `no_session` / `unavailable` split `RefreshOutcome` draws, because the two
|
|
204
|
+
// are opposite answers for a caller: `no_session` is the server saying this
|
|
205
|
+
// app has none, `unavailable` is the absence of an answer.
|
|
206
|
+
type RefreshSettlement =
|
|
207
|
+
| { status: 'ok'; tokens: TokenSet }
|
|
208
|
+
| { status: 'no_session' }
|
|
209
|
+
| { status: 'unavailable' };
|
|
210
|
+
|
|
211
|
+
// The rejection for `unavailable`. A 503 status is what makes the outcome
|
|
212
|
+
// legible to the layers that branch on one: an app's own retry, and the
|
|
213
|
+
// selection self-heal that clears a persisted doc id on a definitive 403 /
|
|
214
|
+
// 404 and leaves a transient alone.
|
|
215
|
+
function refreshUnavailableError(): BrassError {
|
|
216
|
+
return new BrassError('The sign-in service is unavailable', { status: 503 });
|
|
217
|
+
}
|
|
218
|
+
|
|
182
219
|
export class BrassClient {
|
|
183
220
|
private appId: string | null = null;
|
|
184
221
|
private authBaseUrl = DEFAULT_AUTH_BASE_URL;
|
|
@@ -230,7 +267,12 @@ export class BrassClient {
|
|
|
230
267
|
// Init-failure listeners. Swallow a listener's throw so one bad handler
|
|
231
268
|
// cannot block the others or mask the original error (already logged).
|
|
232
269
|
private readonly errorListeners = createEmitter<Error>({ onError: () => {} });
|
|
233
|
-
private inflightRefresh: Promise<
|
|
270
|
+
private inflightRefresh: Promise<RefreshSettlement> | null = null;
|
|
271
|
+
// Bumped by every `signOut`, and captured by a `/refresh` when it starts.
|
|
272
|
+
// A refresh that settles against a moved epoch drops its answer: the
|
|
273
|
+
// credentials it would write were cleared by a sign-out while it was in
|
|
274
|
+
// flight, and re-writing them signs the person back in.
|
|
275
|
+
private authEpoch = 0;
|
|
234
276
|
private initialized = false;
|
|
235
277
|
// Incognito / third-party-cookie fallback state. Both null in a normal
|
|
236
278
|
// browser, where the cross-site `brass_auth` cookie carries the session
|
|
@@ -238,7 +280,7 @@ export class BrassClient {
|
|
|
238
280
|
// - `pendingAuthCode`: the one-time `brass_code` pulled off the
|
|
239
281
|
// landing URL by bootstrap(), consumed at most once by refresh() if
|
|
240
282
|
// the cookie channel is unavailable.
|
|
241
|
-
// - `sessionToken`: the
|
|
283
|
+
// - `sessionToken`: the app-bound session pointer from a code
|
|
242
284
|
// exchange. Once set, every refresh() uses it instead of the
|
|
243
285
|
// cookie. Mirrored into sessionStorage so a reload in a
|
|
244
286
|
// cookie-blocked browser hydrates it; see session-persistence.ts.
|
|
@@ -280,8 +322,9 @@ export class BrassClient {
|
|
|
280
322
|
completeImport: (docId) => this.docsInternal().completeImport(docId),
|
|
281
323
|
reportError: (error) => this.fireError(error),
|
|
282
324
|
});
|
|
283
|
-
//
|
|
284
|
-
//
|
|
325
|
+
// One socket carrying every document topic this client subscribes,
|
|
326
|
+
// built on first open so an app that never opens a document never
|
|
327
|
+
// connects.
|
|
285
328
|
private docWsClient: BrassWsClient | null = null;
|
|
286
329
|
|
|
287
330
|
// init ALWAYS resolves: failures (bad/missing app_id) fire onError +
|
|
@@ -439,7 +482,7 @@ export class BrassClient {
|
|
|
439
482
|
// renders a signed-out UI or calls `signIn()`, which navigates the
|
|
440
483
|
// frame away from the export it was loaded to run.
|
|
441
484
|
if (this.exportGuest === null) {
|
|
442
|
-
for (const listener of this.listeners) {
|
|
485
|
+
for (const listener of Array.from(this.listeners)) {
|
|
443
486
|
listener(this.user);
|
|
444
487
|
}
|
|
445
488
|
}
|
|
@@ -458,23 +501,34 @@ export class BrassClient {
|
|
|
458
501
|
window.location.assign(buildSignInUrl(this.authBaseUrl, appId, window.location.href));
|
|
459
502
|
}
|
|
460
503
|
|
|
461
|
-
// Per-app sign-out.
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
// and offers a one-click "Sign out of Brass too" link.
|
|
504
|
+
// Per-app sign-out. Revokes this app's server-side approval (subsequent
|
|
505
|
+
// /refresh calls for this app return 401 even on cookie reuse), then makes
|
|
506
|
+
// a top-level navigation to a Brass-hosted page that surfaces the email
|
|
507
|
+
// the user is still signed in to Brass as, and offers a one-click "Sign
|
|
508
|
+
// out of Brass too" link.
|
|
467
509
|
//
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
//
|
|
510
|
+
// The navigation is what closes the trust gap of "did sign-out actually
|
|
511
|
+
// happen?"; the revocation rides the credentialed route because the page
|
|
512
|
+
// answers whoever arrives at it and so writes nothing on arrival. The
|
|
513
|
+
// decision to ALSO sign out of the platform belongs to the user, made on
|
|
514
|
+
// the Brass-hosted page; the calling app cannot trigger platform-level
|
|
515
|
+
// sign-out from its own UI.
|
|
473
516
|
signOut(): void {
|
|
474
517
|
const appId = this.requireAppId();
|
|
475
518
|
if (typeof window === 'undefined') {
|
|
476
519
|
throw new Error('Brass.signOut must be called in a browser');
|
|
477
520
|
}
|
|
521
|
+
// Read before the clears below drop it: where the cross-site cookie is
|
|
522
|
+
// blocked, this pointer is the revocation's only credential.
|
|
523
|
+
const credential: RefreshCredential = this.sessionToken
|
|
524
|
+
? { kind: 'sessionToken', sessionToken: this.sessionToken }
|
|
525
|
+
: { kind: 'cookie' };
|
|
526
|
+
// Fence any `/refresh` already on the wire: landing after the clears
|
|
527
|
+
// below, it writes back the token set, the login hint and the session
|
|
528
|
+
// pointer, and re-emits the signed-in user. The hint and the pointer
|
|
529
|
+
// outlive the page, so that resurrects the session on the next load
|
|
530
|
+
// too, and the navigation below can be delayed or cancelled.
|
|
531
|
+
this.authEpoch += 1;
|
|
478
532
|
this.setTokens(null);
|
|
479
533
|
// Drop any fallback-mode credential too, so a stale session token
|
|
480
534
|
// can't resurrect the session on the next getAccessToken(). Clear
|
|
@@ -496,12 +550,22 @@ export class BrassClient {
|
|
|
496
550
|
// on the way to the confirmation page. Harmless when the app is not
|
|
497
551
|
// gated (the cookie simply isn't there). Off a hosted origin there is
|
|
498
552
|
// no such cookie, so go straight to the confirmation page.
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
553
|
+
const target = isBrassHostedHost(window.location.hostname)
|
|
554
|
+
? `${window.location.origin}/__brass/leave?return_to=${encodeURIComponent(signedOutUrl)}`
|
|
555
|
+
: signedOutUrl;
|
|
556
|
+
// Leave once the revocation settles, so the page reports a sign-out that
|
|
557
|
+
// already happened rather than offering the button that finishes it.
|
|
558
|
+
let left = false;
|
|
559
|
+
const leave = (): void => {
|
|
560
|
+
if (left) return;
|
|
561
|
+
left = true;
|
|
562
|
+
window.location.assign(target);
|
|
563
|
+
};
|
|
564
|
+
const deadline = setTimeout(leave, SIGN_OUT_REVOKE_DEADLINE_MS);
|
|
565
|
+
void revokeAppApproval(this.authBaseUrl, appId, credential).then(() => {
|
|
566
|
+
clearTimeout(deadline);
|
|
567
|
+
leave();
|
|
568
|
+
});
|
|
505
569
|
}
|
|
506
570
|
|
|
507
571
|
get currentUser(): BrassUser | null {
|
|
@@ -563,10 +627,11 @@ export class BrassClient {
|
|
|
563
627
|
this.errorListeners.emit(error);
|
|
564
628
|
}
|
|
565
629
|
|
|
566
|
-
// The
|
|
567
|
-
//
|
|
568
|
-
// `Brass.openDocumentById` /
|
|
569
|
-
// Brass.init(appId) first" before
|
|
630
|
+
// The metadata + thumbnail namespace for a document the app already
|
|
631
|
+
// holds an id for (`get` / `setThumbnail` / `getThumbnail`); the
|
|
632
|
+
// editable surface stays on `Brass.openDocumentById` /
|
|
633
|
+
// `Brass.createDocument`. Throws "Call Brass.init(appId) first" before
|
|
634
|
+
// init.
|
|
570
635
|
get documents(): DocumentsNamespace {
|
|
571
636
|
return this.docsInternal();
|
|
572
637
|
}
|
|
@@ -713,9 +778,8 @@ export class BrassClient {
|
|
|
713
778
|
dashboardBaseUrl: this._dashboardBaseUrl,
|
|
714
779
|
getDocument: (docId) => this.documents.get(docId),
|
|
715
780
|
// The version-preview bar is a hosted embed; it takes the same theme
|
|
716
|
-
//
|
|
781
|
+
// as the other hosted surfaces.
|
|
717
782
|
hostedUiTheme: this.hostedUiTheme,
|
|
718
|
-
getSessionToken: () => this.sessionToken,
|
|
719
783
|
};
|
|
720
784
|
}
|
|
721
785
|
|
|
@@ -788,11 +852,6 @@ export class BrassClient {
|
|
|
788
852
|
if (active) this.session._noteLocalDelete(docId);
|
|
789
853
|
else this.session._clearLocalDelete(docId);
|
|
790
854
|
},
|
|
791
|
-
// Hand our fallback platform-session pointer to the embed so it can
|
|
792
|
-
// authenticate the dashboard when its own framed cookie is blocked
|
|
793
|
-
// (incognito / Safari ITP). Null in a normal browser, where the
|
|
794
|
-
// embed's cross-site cookie carries the session on its own.
|
|
795
|
-
getSessionToken: () => this.sessionToken,
|
|
796
855
|
// Name the user this app is acting as, so the embed can refuse to
|
|
797
856
|
// act as anyone else. The embed authenticates from the dashboard
|
|
798
857
|
// origin's platform-session cookie, which moves when a second
|
|
@@ -837,7 +896,13 @@ export class BrassClient {
|
|
|
837
896
|
options?: OpenDocumentByIdOptions,
|
|
838
897
|
): Promise<OpenedDocument> {
|
|
839
898
|
const opened = await this.openDocumentInternal(docId, options);
|
|
840
|
-
|
|
899
|
+
// The URL names the document the reader is looking at, and the session
|
|
900
|
+
// owns that whenever it holds one: this open is detached, so claiming the
|
|
901
|
+
// URL would leave the address bar naming a document the session is not
|
|
902
|
+
// showing, and a reload would swap the reader onto it. An app with no
|
|
903
|
+
// session document still gets the sync, which is the read-mostly caller
|
|
904
|
+
// this method exists for.
|
|
905
|
+
if (this.session.document === null) this.maybeWriteDocIdToUrl(opened.id);
|
|
841
906
|
return opened;
|
|
842
907
|
}
|
|
843
908
|
|
|
@@ -845,7 +910,7 @@ export class BrassClient {
|
|
|
845
910
|
// the link" anonymous path). No sign-in: the token is the credential and
|
|
846
911
|
// the server serves a read-only, member-stripped snapshot with no live
|
|
847
912
|
// subscription. `Brass.session` drives this from the URL on boot when the
|
|
848
|
-
// page carries `?brass_link
|
|
913
|
+
// page carries `?brass_link=`.
|
|
849
914
|
private async openByLinkInternal(
|
|
850
915
|
token: string,
|
|
851
916
|
options?: OpenDocumentByIdOptions,
|
|
@@ -1023,13 +1088,13 @@ export class BrassClient {
|
|
|
1023
1088
|
registerImporter(importer: DocumentImporter): void {
|
|
1024
1089
|
this.importRunner.setImporter(importer);
|
|
1025
1090
|
if (this.guestLinkTokenValue !== null) return;
|
|
1026
|
-
for (const doc of this.
|
|
1091
|
+
for (const doc of this.liveOpenDocuments()) {
|
|
1027
1092
|
void this.importRunner.attempt(doc);
|
|
1028
1093
|
}
|
|
1029
1094
|
}
|
|
1030
1095
|
|
|
1031
1096
|
// Track an opened document so an export request can resolve its id to
|
|
1032
|
-
// the live Document.
|
|
1097
|
+
// the live Document. A re-open overwrites.
|
|
1033
1098
|
private trackOpenDocument(doc: OpenedDocument): void {
|
|
1034
1099
|
this.openDocuments.set(doc.id, doc);
|
|
1035
1100
|
doc.onGone(() => {
|
|
@@ -1045,24 +1110,89 @@ export class BrassClient {
|
|
|
1045
1110
|
}
|
|
1046
1111
|
}
|
|
1047
1112
|
|
|
1113
|
+
// The tracked documents still open, dropping the rest as it goes.
|
|
1114
|
+
// `onGone` covers only a server-side eviction, and two ordinary paths
|
|
1115
|
+
// retire a Document without one: an app's `close()` (which parks it, so
|
|
1116
|
+
// the public surface throws while the instance lives on in the park
|
|
1117
|
+
// cache) and that cache's LRU eviction (a full teardown, no listener
|
|
1118
|
+
// left to fire). Reading through this is what keeps a retired wrapper
|
|
1119
|
+
// out of an answer, and what keeps the set from growing for the life of
|
|
1120
|
+
// the page.
|
|
1121
|
+
private liveOpenDocuments(): Document[] {
|
|
1122
|
+
const live: Document[] = [];
|
|
1123
|
+
for (const [docId, doc] of this.openDocuments) {
|
|
1124
|
+
if (doc._isLive) live.push(doc);
|
|
1125
|
+
else this.openDocuments.delete(docId);
|
|
1126
|
+
}
|
|
1127
|
+
return live;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
private liveOpenDocument(docId: string): Document | null {
|
|
1131
|
+
const doc = this.openDocuments.get(docId);
|
|
1132
|
+
if (!doc) return null;
|
|
1133
|
+
if (doc._isLive) return doc;
|
|
1134
|
+
this.openDocuments.delete(docId);
|
|
1135
|
+
return null;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// Publish a document to a drive, supplying the bytes when the platform has
|
|
1139
|
+
// no writer for it.
|
|
1140
|
+
//
|
|
1141
|
+
// Where the export of this document runs in THIS app, the SDK runs the
|
|
1142
|
+
// registered serializer in process and the push carries its bytes, rather
|
|
1143
|
+
// than sending the reader through the hosted export frame. That is what
|
|
1144
|
+
// makes a publish from inside an app one click, and it is the same on the
|
|
1145
|
+
// publish that creates the drive file and on every push after it: the
|
|
1146
|
+
// caller's own app either writes this document or it does not.
|
|
1147
|
+
//
|
|
1148
|
+
// Everything else is the route's, including refusing bytes from an app the
|
|
1149
|
+
// exporter setting does not name, which it does by name rather than the
|
|
1150
|
+
// client predicting it.
|
|
1151
|
+
private async pushDocumentToDrive(
|
|
1152
|
+
doc: Document,
|
|
1153
|
+
input: PushToDriveInput,
|
|
1154
|
+
): Promise<PushToDriveResult> {
|
|
1155
|
+
const docId = doc.id;
|
|
1156
|
+
if (docId === null || !doc._exportRunsHere) {
|
|
1157
|
+
return await this.docsInternal().pushToDrive(doc, input);
|
|
1158
|
+
}
|
|
1159
|
+
const serialized = await this.serializeForExport(docId);
|
|
1160
|
+
if (!serialized.ok) {
|
|
1161
|
+
throw new BrassError(
|
|
1162
|
+
`This app could not write the document out to publish it: ${serialized.message}`,
|
|
1163
|
+
{ status: 400 },
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
1166
|
+
return await this.docsInternal().pushToDrive(doc, {
|
|
1167
|
+
...input,
|
|
1168
|
+
bytes: serialized.bytes,
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1048
1172
|
// Run the registered serializer for an export request from a hosted
|
|
1049
1173
|
// surface. Resolves the doc from the open set (the active session doc is
|
|
1050
1174
|
// the fallback for an app that drives it through the session), then hands
|
|
1051
1175
|
// it to the serializer. A missing serializer is the ordinary "this app
|
|
1052
|
-
// does not export" outcome, not an error.
|
|
1176
|
+
// does not export" outcome, not an error. A document the app has closed
|
|
1177
|
+
// reports `no_document`: running the serializer against it would throw
|
|
1178
|
+
// out of the app's own record reads and report `serializer_failed`, which
|
|
1179
|
+
// names the app's code for a state the platform is holding.
|
|
1053
1180
|
private async serializeForExport(docId: string): Promise<ExportSerializeOutcome> {
|
|
1054
1181
|
if (!this.serializer) {
|
|
1055
1182
|
return { ok: false, code: 'no_serializer', message: 'No serializer registered.' };
|
|
1056
1183
|
}
|
|
1057
1184
|
const sessionDoc = this.documentSession?.document ?? null;
|
|
1058
1185
|
const doc =
|
|
1059
|
-
this.
|
|
1060
|
-
(sessionDoc && sessionDoc.id === docId ? sessionDoc : null);
|
|
1186
|
+
this.liveOpenDocument(docId) ??
|
|
1187
|
+
(sessionDoc && sessionDoc.id === docId && sessionDoc._isLive ? sessionDoc : null);
|
|
1061
1188
|
if (!doc) {
|
|
1062
1189
|
return { ok: false, code: 'no_document', message: 'Document is not open.' };
|
|
1063
1190
|
}
|
|
1064
1191
|
try {
|
|
1065
|
-
|
|
1192
|
+
// A serializer runs detached, with no subscription to have been fed by,
|
|
1193
|
+
// and it needs the document once, so it is handed the containers whole
|
|
1194
|
+
// (`StoredDocument`) rather than the fed surface an app holds.
|
|
1195
|
+
const bytes = await this.serializer(doc as StoredDocument & Document);
|
|
1066
1196
|
return { ok: true, bytes };
|
|
1067
1197
|
} catch (err) {
|
|
1068
1198
|
return {
|
|
@@ -1107,6 +1237,7 @@ export class BrassClient {
|
|
|
1107
1237
|
apiBaseUrl: this.apiBaseUrl,
|
|
1108
1238
|
getAccessToken: () => this.getAccessToken(),
|
|
1109
1239
|
getAppAssertion: () => this.getAppAssertion(),
|
|
1240
|
+
identity: () => this.user?.sub ?? null,
|
|
1110
1241
|
...(wsClient ? { wsClient } : {}),
|
|
1111
1242
|
branchProvider: {
|
|
1112
1243
|
list: (docId) => docsInternal.listBranches(docId),
|
|
@@ -1123,8 +1254,7 @@ export class BrassClient {
|
|
|
1123
1254
|
thumbnailUploader: (doc, image, width, height) =>
|
|
1124
1255
|
docsNs.setThumbnail(doc, { image, width, height }),
|
|
1125
1256
|
checkpointCreator: (doc) => docsInternal.createCheckpoint(doc),
|
|
1126
|
-
drivePusher: (doc, input) =>
|
|
1127
|
-
docsInternal.pushToDrive(doc, input),
|
|
1257
|
+
drivePusher: (doc, input) => this.pushDocumentToDrive(doc, input),
|
|
1128
1258
|
sourceBlobFetcher: (doc) => docsInternal.sourceBlob(doc),
|
|
1129
1259
|
hostedUiProvider: {
|
|
1130
1260
|
documentHistory: (docId, options) =>
|
|
@@ -1200,29 +1330,50 @@ export class BrassClient {
|
|
|
1200
1330
|
return this.docWsClient;
|
|
1201
1331
|
}
|
|
1202
1332
|
|
|
1333
|
+
// Resolves null only when the server answered that this app has no live
|
|
1334
|
+
// session, and rejects with a 503 `BrassError` when a `/refresh` found no
|
|
1335
|
+
// answer at all. The two outcomes drive opposite handling: null is the
|
|
1336
|
+
// signed-out state a caller renders a sign-in CTA for, where a rejection
|
|
1337
|
+
// is a momentary outage the caller retries. Collapsing them into null
|
|
1338
|
+
// reports a 401-shaped "Not signed in" to a signed-in user whose network
|
|
1339
|
+
// blipped, and hides the transient from every layer that self-heals on it.
|
|
1203
1340
|
async getAccessToken(): Promise<string | null> {
|
|
1204
1341
|
this.requireInit();
|
|
1342
|
+
// On a `?brass_link=` page the link token is the credential and `init`
|
|
1343
|
+
// runs no auth bootstrap, so answer null rather than probing /refresh.
|
|
1344
|
+
// A visitor with no session would draw a 401 the browser logs; one who
|
|
1345
|
+
// holds a session for this app would get a real token, and a caller
|
|
1346
|
+
// that prefers a token over the link token (the socket does) would then
|
|
1347
|
+
// present membership where the page's whole authority is the link.
|
|
1348
|
+
if (this.guestLinkTokenValue !== null) return null;
|
|
1205
1349
|
const current = this.tokens;
|
|
1206
1350
|
if (current && current.expiresAt - REFRESH_LEEWAY_MS > Date.now()) {
|
|
1207
1351
|
return current.accessToken;
|
|
1208
1352
|
}
|
|
1209
|
-
const
|
|
1210
|
-
|
|
1353
|
+
const settled = await this.refresh();
|
|
1354
|
+
if (settled.status === 'unavailable') throw refreshUnavailableError();
|
|
1355
|
+
return settled.status === 'ok' ? settled.tokens.accessToken : null;
|
|
1211
1356
|
}
|
|
1212
1357
|
|
|
1213
1358
|
// The acting-app assertion paired with the current access token, or
|
|
1214
1359
|
// null when the proxy issued none. Mirrors `getAccessToken`'s
|
|
1215
1360
|
// freshness check so the two stay in lockstep: a request builder that
|
|
1216
1361
|
// calls `getAccessToken()` then this reads the same (possibly just
|
|
1217
|
-
// refreshed) token set without forcing a second refresh.
|
|
1362
|
+
// refreshed) token set without forcing a second refresh. Rejects on a
|
|
1363
|
+
// transient outage for the same reason `getAccessToken` does.
|
|
1218
1364
|
async getAppAssertion(): Promise<string | null> {
|
|
1219
1365
|
this.requireInit();
|
|
1366
|
+
// Paired with `getAccessToken`: a link guest holds no token to assert
|
|
1367
|
+
// an acting app against, and reaching /refresh here would authenticate
|
|
1368
|
+
// the page the same way.
|
|
1369
|
+
if (this.guestLinkTokenValue !== null) return null;
|
|
1220
1370
|
const current = this.tokens;
|
|
1221
1371
|
if (current && current.expiresAt - REFRESH_LEEWAY_MS > Date.now()) {
|
|
1222
1372
|
return current.appAssertion ?? null;
|
|
1223
1373
|
}
|
|
1224
|
-
const
|
|
1225
|
-
|
|
1374
|
+
const settled = await this.refresh();
|
|
1375
|
+
if (settled.status === 'unavailable') throw refreshUnavailableError();
|
|
1376
|
+
return settled.status === 'ok' ? (settled.tokens.appAssertion ?? null) : null;
|
|
1226
1377
|
}
|
|
1227
1378
|
|
|
1228
1379
|
private async bootstrap(): Promise<void> {
|
|
@@ -1230,7 +1381,7 @@ export class BrassClient {
|
|
|
1230
1381
|
const appId = this.requireAppId();
|
|
1231
1382
|
// Every successful sign-in 302s back to return_to with the
|
|
1232
1383
|
// platform-session cookie set AND a one-time `brass_code` query
|
|
1233
|
-
// parameter
|
|
1384
|
+
// parameter. The cookie
|
|
1234
1385
|
// is the primary channel; the code is the incognito / Safari ITP /
|
|
1235
1386
|
// post-third-party-cookie fallback for when the browser drops the
|
|
1236
1387
|
// cross-site cookie on our /refresh XHR.
|
|
@@ -1243,8 +1394,7 @@ export class BrassClient {
|
|
|
1243
1394
|
//
|
|
1244
1395
|
// No app-controlled silent-mint path: the SDK never auto-fires
|
|
1245
1396
|
// Brass.signIn() based on a URL hint. New app approvals always
|
|
1246
|
-
// require an explicit click on the auth-hosted picker
|
|
1247
|
-
// adaptive /sign-in branch in infra/lambda/auth/sign-in.ts).
|
|
1397
|
+
// require an explicit click on the auth-hosted picker.
|
|
1248
1398
|
// Same treatment for the launching organization: read it off the URL
|
|
1249
1399
|
// and strip it here, so a client-side navigation later in the session
|
|
1250
1400
|
// cannot lose it and the address bar stays the app's own.
|
|
@@ -1300,19 +1450,27 @@ export class BrassClient {
|
|
|
1300
1450
|
this.pendingAuthCode = null;
|
|
1301
1451
|
}
|
|
1302
1452
|
|
|
1303
|
-
private async refresh(): Promise<
|
|
1453
|
+
private async refresh(): Promise<RefreshSettlement> {
|
|
1304
1454
|
if (this.inflightRefresh) return this.inflightRefresh;
|
|
1305
1455
|
const appId = this.requireAppId();
|
|
1306
|
-
|
|
1307
|
-
|
|
1456
|
+
const epoch = this.authEpoch;
|
|
1457
|
+
this.inflightRefresh = (async (): Promise<RefreshSettlement> => {
|
|
1458
|
+
const outcome = await this.runRefresh(appId);
|
|
1459
|
+
// No answer means no change: the token set, the login hint, and any
|
|
1460
|
+
// fallback session token stay as they are, and a later attempt settles
|
|
1461
|
+
// the session.
|
|
1462
|
+
if (outcome.status === 'unavailable') return { status: 'unavailable' };
|
|
1463
|
+
// A sign-out landed while this round-trip was in flight, so whatever
|
|
1464
|
+
// it resolved belongs to a session the person has left. Report it as
|
|
1465
|
+
// no session and write nothing: the caller waiting on this token gets
|
|
1466
|
+
// the signed-out answer the sign-out established.
|
|
1467
|
+
if (this.authEpoch !== epoch) return { status: 'no_session' };
|
|
1468
|
+
const result = outcome.status === 'ok' ? outcome.result : null;
|
|
1308
1469
|
this.setTokens(result?.tokens ?? null);
|
|
1309
1470
|
// Remember whether a live session exists for this app so the next
|
|
1310
1471
|
// cold load knows whether to probe /refresh (see bootstrap). A
|
|
1311
|
-
// success proves a session;
|
|
1312
|
-
//
|
|
1313
|
-
// silent. A transient network failure also clears it; the cost is
|
|
1314
|
-
// one extra sign-in click on the next load, self-healing on the
|
|
1315
|
-
// next success.
|
|
1472
|
+
// success proves a session; `no_session` is the server saying there
|
|
1473
|
+
// is none, so drop the hint and the next signed-out load stays silent.
|
|
1316
1474
|
if (result) {
|
|
1317
1475
|
storeLoginHint(appId);
|
|
1318
1476
|
} else {
|
|
@@ -1323,12 +1481,11 @@ export class BrassClient {
|
|
|
1323
1481
|
// Mirror into sessionStorage so a reload on this tab can
|
|
1324
1482
|
// hydrate it (the cookie channel is unavailable in this
|
|
1325
1483
|
// browser by construction; we only get here via the code or
|
|
1326
|
-
// sid channels). The
|
|
1327
|
-
//
|
|
1328
|
-
// than the cookie it mirrors.
|
|
1484
|
+
// sid channels). The pointer is bound to this app id, so what
|
|
1485
|
+
// is stored authenticates this app and nothing else.
|
|
1329
1486
|
storeSessionToken(appId, result.sessionToken);
|
|
1330
1487
|
}
|
|
1331
|
-
return result
|
|
1488
|
+
return result ? { status: 'ok', tokens: result.tokens } : { status: 'no_session' };
|
|
1332
1489
|
})().finally(() => {
|
|
1333
1490
|
this.inflightRefresh = null;
|
|
1334
1491
|
});
|
|
@@ -1338,16 +1495,20 @@ export class BrassClient {
|
|
|
1338
1495
|
// Picks the credential channel for a single /refresh round-trip. The
|
|
1339
1496
|
// cookie path is primary and untouched for browsers that send the
|
|
1340
1497
|
// third-party cookie; the one-time code is consumed only when that
|
|
1341
|
-
// cookie path
|
|
1498
|
+
// cookie path resolved no session. Once a fallback session
|
|
1342
1499
|
// token is in hand (the cross-site cookie is proven unavailable for
|
|
1343
1500
|
// this page's life), every later refresh uses it directly.
|
|
1344
|
-
|
|
1501
|
+
//
|
|
1502
|
+
// A rung falls through to the next only on `no_session`. Each one discards
|
|
1503
|
+
// a credential as it passes (the stale sid, the one-time code), and an
|
|
1504
|
+
// `unavailable` says nothing about the credential it just tried.
|
|
1505
|
+
private async runRefresh(appId: string): Promise<RefreshOutcome> {
|
|
1345
1506
|
if (this.sessionToken) {
|
|
1346
1507
|
const viaSession = await refreshTokens(this.authBaseUrl, appId, {
|
|
1347
1508
|
kind: 'sessionToken',
|
|
1348
1509
|
sessionToken: this.sessionToken,
|
|
1349
1510
|
});
|
|
1350
|
-
if (viaSession) return viaSession;
|
|
1511
|
+
if (viaSession.status !== 'no_session') return viaSession;
|
|
1351
1512
|
// Stored sid is stale (server-side revoke, row expiry). Drop both
|
|
1352
1513
|
// the in-memory copy and the sessionStorage mirror, then fall
|
|
1353
1514
|
// through to the cookie / code paths. Without this, a hydrated-
|
|
@@ -1358,19 +1519,32 @@ export class BrassClient {
|
|
|
1358
1519
|
clearSessionToken(appId);
|
|
1359
1520
|
}
|
|
1360
1521
|
const viaCookie = await refreshTokens(this.authBaseUrl, appId);
|
|
1361
|
-
if (viaCookie) return viaCookie;
|
|
1362
|
-
// Cookie channel
|
|
1363
|
-
// we landed with one. Clear it first: it is single-use server-side,
|
|
1364
|
-
//
|
|
1522
|
+
if (viaCookie.status !== 'no_session') return viaCookie;
|
|
1523
|
+
// Cookie channel resolved no session. Fall back to the one-time code if
|
|
1524
|
+
// we landed with one. Clear it first: it is single-use server-side, so a
|
|
1525
|
+
// failed exchange must not be retried.
|
|
1365
1526
|
const code = this.pendingAuthCode;
|
|
1527
|
+
if (!code) return viaCookie;
|
|
1366
1528
|
this.pendingAuthCode = null;
|
|
1367
|
-
if (!code) return null;
|
|
1368
1529
|
return refreshTokens(this.authBaseUrl, appId, { kind: 'code', code });
|
|
1369
1530
|
}
|
|
1370
1531
|
|
|
1371
1532
|
private setTokens(next: TokenSet | null): void {
|
|
1372
|
-
this.tokens = next;
|
|
1373
1533
|
const nextUser = next ? decodeIdToken(next.idToken) : null;
|
|
1534
|
+
// A different person on this page. `/refresh` resolves whichever platform
|
|
1535
|
+
// session the browser holds, so the identity behind a page that never
|
|
1536
|
+
// navigated can become someone else's (another tab signs out and a second
|
|
1537
|
+
// person signs in). Run the retirement first, as its own transition, so
|
|
1538
|
+
// what the previous identity left open is retired before the new one is
|
|
1539
|
+
// announced rather than inherited by it.
|
|
1540
|
+
if (
|
|
1541
|
+
this.user !== null &&
|
|
1542
|
+
nextUser !== null &&
|
|
1543
|
+
this.user.sub !== nextUser.sub
|
|
1544
|
+
) {
|
|
1545
|
+
this.setTokens(null);
|
|
1546
|
+
}
|
|
1547
|
+
this.tokens = next;
|
|
1374
1548
|
if (sameUser(this.user, nextUser)) return;
|
|
1375
1549
|
this.user = nextUser;
|
|
1376
1550
|
// Suppress emits while init is still in flight: init's finally
|
|
@@ -1379,9 +1553,17 @@ export class BrassClient {
|
|
|
1379
1553
|
// fire here (null -> user) AND fire again from init's finally
|
|
1380
1554
|
// (the same user), double-emitting on every signed-in app load.
|
|
1381
1555
|
if (!this.bootstrapped) return;
|
|
1382
|
-
|
|
1556
|
+
// Copied before the walk: a listener that detaches another would otherwise
|
|
1557
|
+
// remove it before its turn, and a `Set` never visits such an entry.
|
|
1558
|
+
for (const listener of Array.from(this.listeners)) {
|
|
1383
1559
|
listener(nextUser);
|
|
1384
1560
|
}
|
|
1561
|
+
// After the listeners, because the session closes the active document as
|
|
1562
|
+
// it hears the sign-out and a close PARKS the Document. A parked Document
|
|
1563
|
+
// answers the next open of its key out of memory, with no token and no
|
|
1564
|
+
// request, so one left behind here is the previous person's document
|
|
1565
|
+
// handed to whoever signs in next.
|
|
1566
|
+
if (nextUser === null) clearParkedDocuments();
|
|
1385
1567
|
}
|
|
1386
1568
|
|
|
1387
1569
|
private async hydrateProfile(): Promise<void> {
|
|
@@ -29,6 +29,9 @@ export interface CheckpointSchedulerHost {
|
|
|
29
29
|
currentSeq(): number | null;
|
|
30
30
|
// Seal the current undo step. Safe to call when undo is absent.
|
|
31
31
|
sealUndoStep(): void;
|
|
32
|
+
// Resolve `true` once every op this client has committed locally has been
|
|
33
|
+
// acked; `false` when the document went, or the acks stopped arriving.
|
|
34
|
+
whenLocalOpsSaved(): Promise<boolean>;
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
export class CheckpointScheduler {
|
|
@@ -65,6 +68,26 @@ export class CheckpointScheduler {
|
|
|
65
68
|
// arriving during the await start a fresh undo step rather than
|
|
66
69
|
// folding into the pre-checkpoint one.
|
|
67
70
|
this.host.sealUndoStep();
|
|
71
|
+
// The anchor is a SERVER seq, and an op the app just committed holds one
|
|
72
|
+
// only once it is acked, so a checkpoint sealed with writes outstanding
|
|
73
|
+
// sits below the work the caller is saving: an app that writes and then
|
|
74
|
+
// saves a version (the file importer's one-undo-step seal is this shape)
|
|
75
|
+
// marks a point its own edits are missing from, and restoring it loses
|
|
76
|
+
// them. Waiting for the acks is what makes the marker describe the state
|
|
77
|
+
// the caller has.
|
|
78
|
+
const saved = await this.host.whenLocalOpsSaved();
|
|
79
|
+
if (!this.host.isAttached()) {
|
|
80
|
+
throw new Error('Document is closed');
|
|
81
|
+
}
|
|
82
|
+
// Sealing anyway is the failure this wait exists to prevent, and it fails
|
|
83
|
+
// quietly: the marker looks like a saved version and restores to a state
|
|
84
|
+
// missing the work the caller asked to save. Refusing hands the app a
|
|
85
|
+
// state it can act on instead.
|
|
86
|
+
if (!saved) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
'createCheckpoint: the document has changes that are not saved yet',
|
|
89
|
+
);
|
|
90
|
+
}
|
|
68
91
|
const result = await this._creator();
|
|
69
92
|
// Record the seq so a pending auto-checkpoint at the same version
|
|
70
93
|
// skips its fire. The server Put is unconditional (last-write-wins),
|
|
@@ -116,6 +139,13 @@ export class CheckpointScheduler {
|
|
|
116
139
|
private async _fireAuto(): Promise<void> {
|
|
117
140
|
if (!this.host.isAutoAllowed()) return;
|
|
118
141
|
if (this._creator === null) return;
|
|
142
|
+
// Same anchor rule as `create`. The debounce usually outlasts the acks,
|
|
143
|
+
// so this ordinarily resolves at once; what it covers is a burst whose
|
|
144
|
+
// last ops are still draining when the timer fires. Best-effort here, so
|
|
145
|
+
// a seal that cannot confirm the writes skips and the next local edit
|
|
146
|
+
// reschedules it.
|
|
147
|
+
if (!(await this.host.whenLocalOpsSaved())) return;
|
|
148
|
+
if (!this.host.isAutoAllowed()) return;
|
|
119
149
|
const seq = this.host.currentSeq();
|
|
120
150
|
if (seq === null) return;
|
|
121
151
|
// Already checkpointed at this exact version (manual seal a moment
|