@brass-build/client 0.1.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 +255 -0
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +836 -0
- package/dist/api-fetch.d.ts +16 -0
- package/dist/api-fetch.d.ts.map +1 -0
- package/dist/api-fetch.js +97 -0
- package/dist/api-fetch.js.map +1 -0
- package/dist/api-headers.d.ts +3 -0
- package/dist/api-headers.d.ts.map +1 -0
- package/dist/api-headers.js +25 -0
- package/dist/api-headers.js.map +1 -0
- package/dist/auth-flow.d.ts +23 -0
- package/dist/auth-flow.d.ts.map +1 -0
- package/dist/auth-flow.js +76 -0
- package/dist/auth-flow.js.map +1 -0
- package/dist/client.d.ts +106 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1278 -0
- package/dist/client.js.map +1 -0
- package/dist/doc-url-sync.d.ts +19 -0
- package/dist/doc-url-sync.d.ts.map +1 -0
- package/dist/doc-url-sync.js +181 -0
- package/dist/doc-url-sync.js.map +1 -0
- package/dist/document-branches.d.ts +32 -0
- package/dist/document-branches.d.ts.map +1 -0
- package/dist/document-branches.js +89 -0
- package/dist/document-branches.js.map +1 -0
- package/dist/document-checkpoints.d.ts +24 -0
- package/dist/document-checkpoints.d.ts.map +1 -0
- package/dist/document-checkpoints.js +115 -0
- package/dist/document-checkpoints.js.map +1 -0
- package/dist/document-commands.d.ts +93 -0
- package/dist/document-commands.d.ts.map +1 -0
- package/dist/document-commands.js +423 -0
- package/dist/document-commands.js.map +1 -0
- package/dist/document-signals.d.ts +53 -0
- package/dist/document-signals.d.ts.map +1 -0
- package/dist/document-signals.js +114 -0
- package/dist/document-signals.js.map +1 -0
- package/dist/document-streams.d.ts +22 -0
- package/dist/document-streams.d.ts.map +1 -0
- package/dist/document-streams.js +98 -0
- package/dist/document-streams.js.map +1 -0
- package/dist/document-thumbnail-capture.d.ts +27 -0
- package/dist/document-thumbnail-capture.d.ts.map +1 -0
- package/dist/document-thumbnail-capture.js +160 -0
- package/dist/document-thumbnail-capture.js.map +1 -0
- package/dist/documents-namespace.d.ts +134 -0
- package/dist/documents-namespace.d.ts.map +1 -0
- package/dist/documents-namespace.js +190 -0
- package/dist/documents-namespace.js.map +1 -0
- package/dist/documents.d.ts +146 -0
- package/dist/documents.d.ts.map +1 -0
- package/dist/documents.js +2045 -0
- package/dist/documents.js.map +1 -0
- package/dist/emitter.d.ts +15 -0
- package/dist/emitter.d.ts.map +1 -0
- package/dist/emitter.js +90 -0
- package/dist/emitter.js.map +1 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +22 -0
- package/dist/errors.js.map +1 -0
- package/dist/export-guest.d.ts +9 -0
- package/dist/export-guest.d.ts.map +1 -0
- package/dist/export-guest.js +225 -0
- package/dist/export-guest.js.map +1 -0
- package/dist/fetch-retry.d.ts +16 -0
- package/dist/fetch-retry.d.ts.map +1 -0
- package/dist/fetch-retry.js +90 -0
- package/dist/fetch-retry.js.map +1 -0
- package/dist/guest-link.d.ts +3 -0
- package/dist/guest-link.d.ts.map +1 -0
- package/dist/guest-link.js +13 -0
- package/dist/guest-link.js.map +1 -0
- package/dist/handles.d.ts +57 -0
- package/dist/handles.d.ts.map +1 -0
- package/dist/handles.js +1375 -0
- package/dist/handles.js.map +1 -0
- package/dist/hosted-runtime.d.ts +33 -0
- package/dist/hosted-runtime.d.ts.map +1 -0
- package/dist/hosted-runtime.js +99 -0
- package/dist/hosted-runtime.js.map +1 -0
- package/dist/hosted-ui.d.ts +84 -0
- package/dist/hosted-ui.d.ts.map +1 -0
- package/dist/hosted-ui.js +673 -0
- package/dist/hosted-ui.js.map +1 -0
- package/dist/importer.d.ts +22 -0
- package/dist/importer.d.ts.map +1 -0
- package/dist/importer.js +54 -0
- package/dist/importer.js.map +1 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +132 -0
- package/dist/index.js.map +1 -0
- package/dist/init-keys.d.ts +3 -0
- package/dist/init-keys.d.ts.map +1 -0
- package/dist/init-keys.js +13 -0
- package/dist/init-keys.js.map +1 -0
- package/dist/internal.d.ts +6 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +17 -0
- package/dist/internal.js.map +1 -0
- package/dist/jwt.d.ts +3 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +22 -0
- package/dist/jwt.js.map +1 -0
- package/dist/launch-context.d.ts +4 -0
- package/dist/launch-context.d.ts.map +1 -0
- package/dist/launch-context.js +44 -0
- package/dist/launch-context.js.map +1 -0
- package/dist/login-hint.d.ts +4 -0
- package/dist/login-hint.d.ts.map +1 -0
- package/dist/login-hint.js +63 -0
- package/dist/login-hint.js.map +1 -0
- package/dist/mutate-batching.d.ts +14 -0
- package/dist/mutate-batching.d.ts.map +1 -0
- package/dist/mutate-batching.js +65 -0
- package/dist/mutate-batching.js.map +1 -0
- package/dist/open-document.d.ts +127 -0
- package/dist/open-document.d.ts.map +1 -0
- package/dist/open-document.js +2121 -0
- package/dist/open-document.js.map +1 -0
- package/dist/page-title.d.ts +3 -0
- package/dist/page-title.d.ts.map +1 -0
- package/dist/page-title.js +32 -0
- package/dist/page-title.js.map +1 -0
- package/dist/perf.d.ts +9 -0
- package/dist/perf.d.ts.map +1 -0
- package/dist/perf.js +39 -0
- package/dist/perf.js.map +1 -0
- package/dist/presence.d.ts +62 -0
- package/dist/presence.d.ts.map +1 -0
- package/dist/presence.js +331 -0
- package/dist/presence.js.map +1 -0
- package/dist/record-paging.d.ts +99 -0
- package/dist/record-paging.d.ts.map +1 -0
- package/dist/record-paging.js +220 -0
- package/dist/record-paging.js.map +1 -0
- package/dist/schema-conflict-dialog.d.ts +10 -0
- package/dist/schema-conflict-dialog.d.ts.map +1 -0
- package/dist/schema-conflict-dialog.js +164 -0
- package/dist/schema-conflict-dialog.js.map +1 -0
- package/dist/selection-persistence.d.ts +5 -0
- package/dist/selection-persistence.d.ts.map +1 -0
- package/dist/selection-persistence.js +90 -0
- package/dist/selection-persistence.js.map +1 -0
- package/dist/serializer.d.ts +3 -0
- package/dist/serializer.d.ts.map +1 -0
- package/dist/serializer.js +2 -0
- package/dist/serializer.js.map +1 -0
- package/dist/session-persistence.d.ts +4 -0
- package/dist/session-persistence.d.ts.map +1 -0
- package/dist/session-persistence.js +68 -0
- package/dist/session-persistence.js.map +1 -0
- package/dist/session-toolbar.d.ts +62 -0
- package/dist/session-toolbar.d.ts.map +1 -0
- package/dist/session-toolbar.js +1205 -0
- package/dist/session-toolbar.js.map +1 -0
- package/dist/session.d.ts +69 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +926 -0
- package/dist/session.js.map +1 -0
- package/dist/tail-ops.d.ts +28 -0
- package/dist/tail-ops.d.ts.map +1 -0
- package/dist/tail-ops.js +46 -0
- package/dist/tail-ops.js.map +1 -0
- package/dist/topic-lifecycle.d.ts +41 -0
- package/dist/topic-lifecycle.d.ts.map +1 -0
- package/dist/topic-lifecycle.js +169 -0
- package/dist/topic-lifecycle.js.map +1 -0
- package/dist/types.d.ts +22 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/undo.d.ts +18 -0
- package/dist/undo.d.ts.map +1 -0
- package/dist/undo.js +110 -0
- package/dist/undo.js.map +1 -0
- package/dist/version-bar.d.ts +22 -0
- package/dist/version-bar.d.ts.map +1 -0
- package/dist/version-bar.js +169 -0
- package/dist/version-bar.js.map +1 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +8 -0
- package/dist/version.js.map +1 -0
- package/dist/vite.d.ts +15 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +36 -0
- package/dist/vite.js.map +1 -0
- package/dist/ws-client.d.ts +53 -0
- package/dist/ws-client.d.ts.map +1 -0
- package/dist/ws-client.js +604 -0
- package/dist/ws-client.js.map +1 -0
- package/dist/ws-frame-limit.d.ts +21 -0
- package/dist/ws-frame-limit.d.ts.map +1 -0
- package/dist/ws-frame-limit.js +40 -0
- package/dist/ws-frame-limit.js.map +1 -0
- package/docs/app-manifest.md +65 -0
- package/docs/custom-chrome.md +147 -0
- package/docs/hosted-ui-advanced.md +77 -0
- package/docs/imported-documents.md +261 -0
- package/docs/presence.md +129 -0
- package/docs/schemas.md +135 -0
- package/docs/streams.md +248 -0
- package/package.json +60 -0
- package/scripts/prepare.mjs +50 -0
- package/src/api-fetch.ts +127 -0
- package/src/api-headers.ts +28 -0
- package/src/auth-flow.ts +125 -0
- package/src/client.ts +1457 -0
- package/src/doc-url-sync.ts +183 -0
- package/src/document-branches.ts +124 -0
- package/src/document-checkpoints.ts +135 -0
- package/src/document-commands.ts +651 -0
- package/src/document-signals.ts +234 -0
- package/src/document-streams.ts +132 -0
- package/src/document-thumbnail-capture.ts +191 -0
- package/src/documents-namespace.ts +516 -0
- package/src/documents.ts +2426 -0
- package/src/emitter.ts +129 -0
- package/src/errors.ts +45 -0
- package/src/export-guest.ts +282 -0
- package/src/fetch-retry.ts +119 -0
- package/src/guest-link.ts +14 -0
- package/src/handles.ts +1731 -0
- package/src/hosted-runtime.ts +109 -0
- package/src/hosted-ui.ts +1020 -0
- package/src/importer.ts +89 -0
- package/src/index.ts +267 -0
- package/src/init-keys.ts +15 -0
- package/src/internal.ts +23 -0
- package/src/jwt.ts +18 -0
- package/src/launch-context.ts +43 -0
- package/src/login-hint.ts +60 -0
- package/src/mutate-batching.ts +63 -0
- package/src/open-document.ts +2869 -0
- package/src/page-title.ts +33 -0
- package/src/perf.ts +46 -0
- package/src/presence.ts +471 -0
- package/src/record-paging.ts +270 -0
- package/src/schema-conflict-dialog.ts +176 -0
- package/src/selection-persistence.ts +98 -0
- package/src/serializer.ts +15 -0
- package/src/session-persistence.ts +69 -0
- package/src/session-toolbar.ts +1457 -0
- package/src/session.ts +1262 -0
- package/src/tail-ops.ts +63 -0
- package/src/topic-lifecycle.ts +217 -0
- package/src/types.ts +75 -0
- package/src/undo.ts +125 -0
- package/src/version-bar.ts +221 -0
- package/src/version.ts +8 -0
- package/src/vite.ts +49 -0
- package/src/ws-client.ts +678 -0
- package/src/ws-frame-limit.ts +52 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// The record-store paging codec: how a large record-graph document packs its
|
|
2
|
+
// `instances` map into page containers, and how to undo that back to a flat
|
|
3
|
+
// `key -> { type, params }` lookup.
|
|
4
|
+
//
|
|
5
|
+
// It lives in the SDK (not in `formats/core`, where the record store lives)
|
|
6
|
+
// because the published `@brass-build/client` ships unbundled and can only
|
|
7
|
+
// import published packages and its own relative files; `formats/core` is
|
|
8
|
+
// neither. The substrate imports these helpers back from here, so the codec is
|
|
9
|
+
// defined ONCE and the SDK's paged read / edit cannot drift from how the store
|
|
10
|
+
// is written. Deliberately loro-free (fflate, a stable hash, JSON), so it
|
|
11
|
+
// stays a pure value codec on both sides.
|
|
12
|
+
//
|
|
13
|
+
// Wire recap (full rationale in `formats/core/src/record-store.ts`): the root
|
|
14
|
+
// `instances` LoroMap is EITHER unpaged (key -> `{ type, params }`, params a
|
|
15
|
+
// plain value) or, above `UNPAGED_MAX_RECORDS` records, PAGED: keyed
|
|
16
|
+
// `p0,p1,...` -> a `{ type: '__page__', params: <string> }` container whose
|
|
17
|
+
// params is a gzipped latin1 string of a JSON array of `[key, tag, body]`
|
|
18
|
+
// triples. `meta.page_count` carries the count; `instance_order` lists record
|
|
19
|
+
// keys in order in both shapes. A record's page is a hash of its key, so it is
|
|
20
|
+
// resolved with no stored index.
|
|
21
|
+
|
|
22
|
+
import { gzipSync, gunzipSync, strToU8, strFromU8 } from 'fflate';
|
|
23
|
+
|
|
24
|
+
// Gzip backend: native `node:zlib` when the runtime provides it, fflate
|
|
25
|
+
// otherwise. The page codec must stay a pure isomorphic value codec (the
|
|
26
|
+
// published SDK runs in browsers, where fflate is the only option), but
|
|
27
|
+
// fflate's JS inflate is several times slower than zlib's native one on a
|
|
28
|
+
// server-grade page walk, and a server's constrained cores amplify that. The
|
|
29
|
+
// module is reached through `process.getBuiltinModule` (Node 22+) via a
|
|
30
|
+
// structural `globalThis` read, so no `node:` import ever appears in the
|
|
31
|
+
// browser bundle and the SDK compiles without Node types. Both backends
|
|
32
|
+
// speak standard gzip, so pages written by one decode under the other.
|
|
33
|
+
interface GzipBackend {
|
|
34
|
+
gzipSync(bytes: Uint8Array): Uint8Array;
|
|
35
|
+
gunzipSync(bytes: Uint8Array): Uint8Array;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let nativeZlib: GzipBackend | null | undefined;
|
|
39
|
+
|
|
40
|
+
function zlibBackend(): GzipBackend | null {
|
|
41
|
+
if (nativeZlib === undefined) {
|
|
42
|
+
try {
|
|
43
|
+
const proc = (
|
|
44
|
+
globalThis as {
|
|
45
|
+
process?: { getBuiltinModule?: (id: string) => unknown };
|
|
46
|
+
}
|
|
47
|
+
).process;
|
|
48
|
+
nativeZlib =
|
|
49
|
+
(proc?.getBuiltinModule?.('node:zlib') as GzipBackend | undefined) ??
|
|
50
|
+
null;
|
|
51
|
+
} catch {
|
|
52
|
+
nativeZlib = null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return nativeZlib;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function gzip(bytes: Uint8Array): Uint8Array {
|
|
59
|
+
const zlib = zlibBackend();
|
|
60
|
+
return zlib ? zlib.gzipSync(bytes) : gzipSync(bytes);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function gunzip(bytes: Uint8Array): Uint8Array {
|
|
64
|
+
const zlib = zlibBackend();
|
|
65
|
+
return zlib ? zlib.gunzipSync(bytes) : gunzipSync(bytes);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Field names inside a stored record container: `type` is the tag, `params`
|
|
69
|
+
* the body. Verbatim so the substrate and SDK name the same fields. */
|
|
70
|
+
export const TAG_FIELD = 'type';
|
|
71
|
+
export const BODY_FIELD = 'params';
|
|
72
|
+
|
|
73
|
+
/** Root container keys the record store occupies on a document. */
|
|
74
|
+
export const META_KEY = 'meta';
|
|
75
|
+
export const RECORDS_KEY = 'instances';
|
|
76
|
+
export const ORDER_KEY = 'instance_order';
|
|
77
|
+
|
|
78
|
+
/** `meta` key holding a paged store's page count; absent (or 0) means
|
|
79
|
+
* unpaged. The read path detects pages by tag; only an in-place edit needs
|
|
80
|
+
* the count, to hash a key to its page. */
|
|
81
|
+
export const PAGE_COUNT_KEY = 'page_count';
|
|
82
|
+
|
|
83
|
+
/** `meta` key holding the paging codec's scheme number, stamped on a paged
|
|
84
|
+
* write. The scheme fixes the page-key hash (`pageKeyFor`), the `p<n>` key
|
|
85
|
+
* format, and the packed-record layout together: a reader that hashes a key
|
|
86
|
+
* with a different scheme than the store was written under targets the wrong
|
|
87
|
+
* page on an in-place edit, silently writing a record into a page it does not
|
|
88
|
+
* belong to. The stamp lets a pinned reader detect a store written under a
|
|
89
|
+
* scheme it does not implement and refuse the edit instead of corrupting it.
|
|
90
|
+
* Absent means the current scheme (paged stores written before the stamp
|
|
91
|
+
* existed used scheme 1's hash and layout). */
|
|
92
|
+
export const PAGING_SCHEME_KEY = 'paging_scheme';
|
|
93
|
+
|
|
94
|
+
/** The paging scheme this build writes and can edit. Bump ONLY alongside a
|
|
95
|
+
* change to `pageKeyFor`'s hash, the `p<n>` key format, or the packed-record
|
|
96
|
+
* layout, so an older pinned reader trips `isPagingSchemeEditable` and fails
|
|
97
|
+
* loud rather than editing a store it would mis-hash. */
|
|
98
|
+
export const CURRENT_PAGING_SCHEME = 1;
|
|
99
|
+
|
|
100
|
+
/** Whether a store stamped with `scheme` can be edited in place by this build.
|
|
101
|
+
* Absent / non-numeric / `0` is a scheme-1 store (the stamp postdates it), so
|
|
102
|
+
* editable; any scheme at or below the current one is editable; a higher
|
|
103
|
+
* scheme is not (this reader would hash into the wrong page). Reads stay
|
|
104
|
+
* best-effort and are not gated here; only an in-place edit can persist the
|
|
105
|
+
* mis-hash, so the edit path is where the guard belongs. */
|
|
106
|
+
export function isPagingSchemeEditable(scheme: unknown): boolean {
|
|
107
|
+
if (typeof scheme !== 'number' || !Number.isFinite(scheme) || scheme <= 0) {
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
return scheme <= CURRENT_PAGING_SCHEME;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Above this many records the store is written paged; at or below it it keeps
|
|
114
|
+
* the one-container-per-record shape. */
|
|
115
|
+
export const UNPAGED_MAX_RECORDS = 50_000;
|
|
116
|
+
|
|
117
|
+
// Target records per page and a page-count ceiling so even the 2M-record cap
|
|
118
|
+
// stays out of the slow zone. Mirrored from the substrate's write path so
|
|
119
|
+
// `pageCountFor` agrees with how the store was written.
|
|
120
|
+
const TARGET_PAGE_SIZE = 64;
|
|
121
|
+
const MAX_PAGES = 50_000;
|
|
122
|
+
|
|
123
|
+
/** The tag a page container carries in place of a record tag, so a reader
|
|
124
|
+
* tells a page (body = record triples) from a single record (body = one
|
|
125
|
+
* plain value). */
|
|
126
|
+
export const PAGE_TAG = '__page__';
|
|
127
|
+
|
|
128
|
+
/** A record packed inside a page body: `[key, tag, body]`. The tuple form
|
|
129
|
+
* keeps a paged snapshot compact. */
|
|
130
|
+
export type PackedRecord<B> = [key: string, tag: string, body: B];
|
|
131
|
+
|
|
132
|
+
/** Encode a page's triples as its stored body: JSON-serialised, gzipped, held
|
|
133
|
+
* as a latin1 string. A STRING body avoids loro's superlinear per-value
|
|
134
|
+
* conversion of a deeply nested triple array; gzipping keeps a heavy file in
|
|
135
|
+
* memory + export budget. (Full rationale in the substrate's `encodePageBody`.) */
|
|
136
|
+
export function encodePageBody<B>(triples: PackedRecord<B>[]): string {
|
|
137
|
+
return encodePageBodyFromEncoded(triples.map((t) => JSON.stringify(t)));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Encode one record's `[key, tag, body]` triple as the JSON string it
|
|
141
|
+
* occupies inside a page body. Split from page assembly so a large write can
|
|
142
|
+
* stringify each record as it buckets and release the parsed body before the
|
|
143
|
+
* gzip loop, rather than holding the whole parsed model live through it (see
|
|
144
|
+
* `writeRecordStoreConsuming` for why that release keeps the encode fast). */
|
|
145
|
+
export function encodeRecordForPage<B>(
|
|
146
|
+
key: string,
|
|
147
|
+
tag: string,
|
|
148
|
+
body: B,
|
|
149
|
+
): string {
|
|
150
|
+
return JSON.stringify([key, tag, body] as PackedRecord<B>);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Assemble a page body from records already JSON-encoded by
|
|
154
|
+
* `encodeRecordForPage`: wrap in a JSON array, gzip, hold as a latin1 string.
|
|
155
|
+
* Byte-identical to `encodePageBody` over the same triples, so the wire shape
|
|
156
|
+
* is unchanged; the split exists only so the writer frees each parsed body
|
|
157
|
+
* first. */
|
|
158
|
+
export function encodePageBodyFromEncoded(encoded: string[]): string {
|
|
159
|
+
return strFromU8(gzip(strToU8('[' + encoded.join(',') + ']')), true);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Decode a page body back to its record triples. Returns [] for anything that
|
|
163
|
+
* does not decompress to a JSON array, so a reader tolerates a corrupt or
|
|
164
|
+
* stray page rather than throwing. */
|
|
165
|
+
export function decodePageBody(body: unknown): PackedRecord<unknown>[] {
|
|
166
|
+
if (typeof body !== 'string') return [];
|
|
167
|
+
try {
|
|
168
|
+
const json = strFromU8(gunzip(strToU8(body, true)));
|
|
169
|
+
const parsed = JSON.parse(json);
|
|
170
|
+
return Array.isArray(parsed) ? (parsed as PackedRecord<unknown>[]) : [];
|
|
171
|
+
} catch {
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The page count for a record population: 0 (unpaged) at or below
|
|
177
|
+
* `UNPAGED_MAX_RECORDS`, else ~`TARGET_PAGE_SIZE` records per page, capped at
|
|
178
|
+
* `MAX_PAGES`. */
|
|
179
|
+
export function pageCountFor(recordCount: number): number {
|
|
180
|
+
if (recordCount <= UNPAGED_MAX_RECORDS) return 0;
|
|
181
|
+
return Math.min(Math.ceil(recordCount / TARGET_PAGE_SIZE), MAX_PAGES);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** FNV-1a (32-bit) assigning a record key to a page. Stable and
|
|
185
|
+
* engine-independent: the same key resolves to the same page on write, read,
|
|
186
|
+
* and edit across processes, which is what lets the page be found with no
|
|
187
|
+
* stored index. */
|
|
188
|
+
function hashKey(key: string): number {
|
|
189
|
+
let h = 0x811c9dc5;
|
|
190
|
+
for (let i = 0; i < key.length; i += 1) {
|
|
191
|
+
h ^= key.charCodeAt(i);
|
|
192
|
+
h = Math.imul(h, 0x01000193);
|
|
193
|
+
}
|
|
194
|
+
return h >>> 0;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The page key a record key lives in, for a store of `pageCount` pages. */
|
|
198
|
+
export function pageKeyFor(key: string, pageCount: number): string {
|
|
199
|
+
return `p${hashKey(key) % pageCount}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** A stored container's tag (`type`), or null for any value not in the
|
|
203
|
+
* `{ type, params }` shape, so a reader skips a corrupt entry. A page carries
|
|
204
|
+
* `PAGE_TAG`. */
|
|
205
|
+
export function peekRecordTag(value: unknown): string | null {
|
|
206
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
207
|
+
const tag = (value as Record<string, unknown>)[TAG_FIELD];
|
|
208
|
+
return typeof tag === 'string' ? tag : null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Decode a stored container (`{ type, params }`) to its tag + raw body,
|
|
212
|
+
* or null when it is not in the record shape. */
|
|
213
|
+
export function decodeRecordValue(
|
|
214
|
+
value: unknown,
|
|
215
|
+
): { tag: string; body: unknown } | null {
|
|
216
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
217
|
+
const o = value as Record<string, unknown>;
|
|
218
|
+
const tag = o[TAG_FIELD];
|
|
219
|
+
if (typeof tag !== 'string' || !(BODY_FIELD in o)) return null;
|
|
220
|
+
return { tag, body: o[BODY_FIELD] };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Whether a `meta.page_count` value denotes a PAGED store. The writer sets
|
|
224
|
+
* it to a positive count only when it pages, and never on an unpaged write,
|
|
225
|
+
* so a positive finite number is the authoritative "is paged" signal. */
|
|
226
|
+
export function isPagedByPageCount(pageCount: unknown): boolean {
|
|
227
|
+
return typeof pageCount === 'number' && Number.isFinite(pageCount) && pageCount > 0;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Flatten a possibly-PAGED `instances` lookup to the flat
|
|
231
|
+
* `key -> { type, params }` lookup a reader consumes. An unpaged lookup is
|
|
232
|
+
* returned unchanged (no allocation). The single place paging is undone on the
|
|
233
|
+
* read side.
|
|
234
|
+
*
|
|
235
|
+
* `paged` is the AUTHORITATIVE page state, read from `meta.page_count` by the
|
|
236
|
+
* caller (`isPagedByPageCount`). Page interpretation MUST be gated on it, not
|
|
237
|
+
* on whether some entry is `PAGE_TAG`-tagged: a genuine record whose codec tag
|
|
238
|
+
* happens to equal `PAGE_TAG` in an UNPAGED store is a real record, not a
|
|
239
|
+
* page. Treating it as a page decodes its plain body as `decodePageBody`
|
|
240
|
+
* (which returns `[]` for a non-page body), silently DROPPING the record, and
|
|
241
|
+
* a crafted page-shaped body could forge records into the lookup. */
|
|
242
|
+
export function flattenPagedLookup(
|
|
243
|
+
lookup: Record<string, unknown>,
|
|
244
|
+
paged: boolean,
|
|
245
|
+
): Record<string, unknown> {
|
|
246
|
+
if (!paged) return lookup;
|
|
247
|
+
|
|
248
|
+
const flat: Record<string, unknown> = {};
|
|
249
|
+
for (const [key, value] of Object.entries(lookup)) {
|
|
250
|
+
if (peekRecordTag(value) !== PAGE_TAG) {
|
|
251
|
+
flat[key] = value; // tolerate a stray non-page entry by its own key
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
const body = decodePageBody(decodeRecordValue(value)?.body);
|
|
255
|
+
for (const packed of body) {
|
|
256
|
+
if (
|
|
257
|
+
Array.isArray(packed) &&
|
|
258
|
+
packed.length >= 3 &&
|
|
259
|
+
typeof packed[0] === 'string' &&
|
|
260
|
+
typeof packed[1] === 'string'
|
|
261
|
+
) {
|
|
262
|
+
flat[packed[0]] = {
|
|
263
|
+
[TAG_FIELD]: packed[1],
|
|
264
|
+
[BODY_FIELD]: packed[2],
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return flat;
|
|
270
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// The prompt shown when the platform refuses to open a document this app's
|
|
2
|
+
// declared schema cannot read.
|
|
3
|
+
//
|
|
4
|
+
// SDK-rendered rather than a hosted iframe: it asks one question about a
|
|
5
|
+
// document already named on this page, needs no session of its own, and has
|
|
6
|
+
// to appear on a cold boot from a bookmark, which is the moment an iframe
|
|
7
|
+
// round-trip is least welcome. The hosted dialogs exist for surfaces that
|
|
8
|
+
// read the person's whole account; this reads nothing.
|
|
9
|
+
//
|
|
10
|
+
// Both strings are the server's own, rendered verbatim. A client compiles
|
|
11
|
+
// into an app's bundle and freezes there, so a word this file holds itself
|
|
12
|
+
// can only be corrected by every app republishing, while a word it is handed
|
|
13
|
+
// reaches every app the moment the platform deploys. The fallback below is
|
|
14
|
+
// for a platform that predates the title, not a second opinion about wording.
|
|
15
|
+
|
|
16
|
+
export interface SchemaConflictChoice {
|
|
17
|
+
openAnyway: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const STYLE = `
|
|
21
|
+
/* A dialog opened with showModal(), so the browser holds the keyboard inside
|
|
22
|
+
the prompt. The UA box styles for a dialog (content-sized, centered,
|
|
23
|
+
bordered, padded, max-width capped) are all wrong for a full-viewport
|
|
24
|
+
backdrop, so each is zeroed, and the UA backdrop is cleared because this
|
|
25
|
+
element paints the dim itself. */
|
|
26
|
+
.brass-sc-backdrop {
|
|
27
|
+
position: fixed;
|
|
28
|
+
inset: 0;
|
|
29
|
+
margin: 0;
|
|
30
|
+
border: 0;
|
|
31
|
+
width: auto;
|
|
32
|
+
height: auto;
|
|
33
|
+
max-width: none;
|
|
34
|
+
max-height: none;
|
|
35
|
+
z-index: 2147483646;
|
|
36
|
+
display: flex;
|
|
37
|
+
align-items: center;
|
|
38
|
+
justify-content: center;
|
|
39
|
+
padding: 1rem;
|
|
40
|
+
background: rgba(20, 18, 13, 0.45);
|
|
41
|
+
font: 15px/1.5 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
|
42
|
+
}
|
|
43
|
+
.brass-sc-backdrop::backdrop {
|
|
44
|
+
background: transparent;
|
|
45
|
+
}
|
|
46
|
+
.brass-sc-panel {
|
|
47
|
+
background: Canvas;
|
|
48
|
+
color: CanvasText;
|
|
49
|
+
color-scheme: light dark;
|
|
50
|
+
max-width: 26rem;
|
|
51
|
+
width: 100%;
|
|
52
|
+
padding: 1.25rem;
|
|
53
|
+
border-radius: 0.75rem;
|
|
54
|
+
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.28);
|
|
55
|
+
}
|
|
56
|
+
.brass-sc-title {
|
|
57
|
+
margin: 0 0 0.5rem;
|
|
58
|
+
font-size: 1.0625rem;
|
|
59
|
+
font-weight: 600;
|
|
60
|
+
}
|
|
61
|
+
.brass-sc-body {
|
|
62
|
+
margin: 0 0 1.25rem;
|
|
63
|
+
}
|
|
64
|
+
.brass-sc-actions {
|
|
65
|
+
display: flex;
|
|
66
|
+
flex-wrap: wrap;
|
|
67
|
+
justify-content: flex-end;
|
|
68
|
+
gap: 0.5rem;
|
|
69
|
+
}
|
|
70
|
+
.brass-sc-actions button {
|
|
71
|
+
font: inherit;
|
|
72
|
+
min-height: 2.5rem;
|
|
73
|
+
padding: 0 0.875rem;
|
|
74
|
+
border-radius: 0.375rem;
|
|
75
|
+
border: 1px solid rgba(128, 128, 128, 0.4);
|
|
76
|
+
background: transparent;
|
|
77
|
+
color: inherit;
|
|
78
|
+
cursor: pointer;
|
|
79
|
+
}
|
|
80
|
+
.brass-sc-actions button.brass-sc-go {
|
|
81
|
+
border-color: transparent;
|
|
82
|
+
background: CanvasText;
|
|
83
|
+
color: Canvas;
|
|
84
|
+
}
|
|
85
|
+
`;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Ask whether to open the document anyway. Resolves `openAnyway: false` on
|
|
89
|
+
* Close, on Escape, and when there is no DOM to ask in: every one of those is
|
|
90
|
+
* an open nobody agreed to, and the refusal stands.
|
|
91
|
+
*/
|
|
92
|
+
export function askSchemaConflict(
|
|
93
|
+
message: string,
|
|
94
|
+
title?: string,
|
|
95
|
+
): Promise<SchemaConflictChoice> {
|
|
96
|
+
if (typeof document === 'undefined') {
|
|
97
|
+
return Promise.resolve({ openAnyway: false });
|
|
98
|
+
}
|
|
99
|
+
return new Promise<SchemaConflictChoice>((resolve) => {
|
|
100
|
+
const host = document.createElement('div');
|
|
101
|
+
const shadow = host.attachShadow({ mode: 'open' });
|
|
102
|
+
const style = document.createElement('style');
|
|
103
|
+
style.textContent = STYLE;
|
|
104
|
+
|
|
105
|
+
const backdrop = document.createElement('dialog');
|
|
106
|
+
backdrop.className = 'brass-sc-backdrop';
|
|
107
|
+
const panel = document.createElement('div');
|
|
108
|
+
panel.className = 'brass-sc-panel';
|
|
109
|
+
panel.setAttribute('role', 'alertdialog');
|
|
110
|
+
panel.setAttribute('aria-modal', 'true');
|
|
111
|
+
panel.setAttribute('aria-labelledby', 'brass-sc-title');
|
|
112
|
+
|
|
113
|
+
const titleEl = document.createElement('h2');
|
|
114
|
+
titleEl.className = 'brass-sc-title';
|
|
115
|
+
titleEl.id = 'brass-sc-title';
|
|
116
|
+
titleEl.textContent = title ?? 'This app may not read this file correctly';
|
|
117
|
+
|
|
118
|
+
const body = document.createElement('p');
|
|
119
|
+
body.className = 'brass-sc-body';
|
|
120
|
+
body.textContent = message;
|
|
121
|
+
|
|
122
|
+
const actions = document.createElement('div');
|
|
123
|
+
actions.className = 'brass-sc-actions';
|
|
124
|
+
const close = document.createElement('button');
|
|
125
|
+
close.type = 'button';
|
|
126
|
+
close.textContent = 'Close';
|
|
127
|
+
const go = document.createElement('button');
|
|
128
|
+
go.type = 'button';
|
|
129
|
+
go.className = 'brass-sc-go';
|
|
130
|
+
go.textContent = 'Open anyway';
|
|
131
|
+
|
|
132
|
+
// Where the reader was when the prompt opened. Answering it detaches the
|
|
133
|
+
// prompt rather than closing it in place, so putting them back is this
|
|
134
|
+
// function's job.
|
|
135
|
+
const opener =
|
|
136
|
+
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
137
|
+
|
|
138
|
+
let settled = false;
|
|
139
|
+
const settle = (openAnyway: boolean): void => {
|
|
140
|
+
if (settled) return;
|
|
141
|
+
settled = true;
|
|
142
|
+
document.removeEventListener('keydown', onKey, true);
|
|
143
|
+
if (backdrop.open) backdrop.close();
|
|
144
|
+
host.remove();
|
|
145
|
+
if (opener?.isConnected) opener.focus();
|
|
146
|
+
resolve({ openAnyway });
|
|
147
|
+
};
|
|
148
|
+
function onKey(event: KeyboardEvent): void {
|
|
149
|
+
if (event.key === 'Escape') settle(false);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
close.addEventListener('click', () => settle(false));
|
|
153
|
+
go.addEventListener('click', () => settle(true));
|
|
154
|
+
document.addEventListener('keydown', onKey, true);
|
|
155
|
+
// An open dialog answers Escape itself, which would close it without
|
|
156
|
+
// resolving. Route it into the one answer path; `onKey` above normally
|
|
157
|
+
// gets there first.
|
|
158
|
+
backdrop.addEventListener('cancel', (event) => {
|
|
159
|
+
event.preventDefault();
|
|
160
|
+
settle(false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
actions.append(close, go);
|
|
164
|
+
panel.append(titleEl, body, actions);
|
|
165
|
+
backdrop.append(panel);
|
|
166
|
+
shadow.append(style, backdrop);
|
|
167
|
+
document.body.append(host);
|
|
168
|
+
// The call that holds the keyboard inside the prompt: without it Tab keeps
|
|
169
|
+
// walking the app behind it. Guarded so a browser without the top layer
|
|
170
|
+
// still gets the prompt, non-modal, rather than none.
|
|
171
|
+
if (typeof backdrop.showModal === 'function') backdrop.showModal();
|
|
172
|
+
// Close is the safe answer, so it takes focus: Enter on a dialog nobody
|
|
173
|
+
// read declines rather than opening a document the platform refused.
|
|
174
|
+
close.focus();
|
|
175
|
+
});
|
|
176
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Per-app sessionStorage layer that stashes the last value returned by
|
|
2
|
+
// `Brass.selectDocument()`; the session's boot restore reads it back as
|
|
3
|
+
// the bare-URL fallback.
|
|
4
|
+
//
|
|
5
|
+
// Keys are namespaced per-app id so integrations on one origin don't
|
|
6
|
+
// collide. Cleared on sign-out (`clearAll`); session storage also drops on
|
|
7
|
+
// tab close.
|
|
8
|
+
|
|
9
|
+
import type { SelectDocumentResult } from './hosted-ui.js';
|
|
10
|
+
|
|
11
|
+
const KEY_PREFIX = '__brass_';
|
|
12
|
+
const DOC_KEY = `${KEY_PREFIX}last_doc_`;
|
|
13
|
+
|
|
14
|
+
// Single per-page-load warn: `storeLastDocument` runs on every successful pick,
|
|
15
|
+
// so without the latch a server-rendered host would flood its logs.
|
|
16
|
+
let warnedUnavailable = false;
|
|
17
|
+
|
|
18
|
+
function getStorage(): Storage | null {
|
|
19
|
+
if (typeof sessionStorage === 'undefined') return null;
|
|
20
|
+
try {
|
|
21
|
+
return sessionStorage;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function warnOnceUnavailable(): void {
|
|
28
|
+
if (warnedUnavailable) return;
|
|
29
|
+
warnedUnavailable = true;
|
|
30
|
+
console.warn(
|
|
31
|
+
'Brass: sessionStorage is unavailable; selection persistence is disabled.',
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function docKey(appId: string): string {
|
|
36
|
+
return `${DOC_KEY}${appId}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isSelectDocumentResult(value: unknown): value is SelectDocumentResult {
|
|
40
|
+
if (!value || typeof value !== 'object') return false;
|
|
41
|
+
const v = value as Record<string, unknown>;
|
|
42
|
+
return (
|
|
43
|
+
typeof v['doc_id'] === 'string' &&
|
|
44
|
+
// `project_id: string | null` (loose docs have no project).
|
|
45
|
+
(typeof v['project_id'] === 'string' || v['project_id'] === null) &&
|
|
46
|
+
typeof v['organization_id'] === 'string' &&
|
|
47
|
+
typeof v['role'] === 'string' &&
|
|
48
|
+
typeof v['name'] === 'string' &&
|
|
49
|
+
typeof v['created_at'] === 'string' &&
|
|
50
|
+
typeof v['updated_at'] === 'string'
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function storeLastDocument(
|
|
55
|
+
appId: string,
|
|
56
|
+
value: SelectDocumentResult,
|
|
57
|
+
): void {
|
|
58
|
+
const storage = getStorage();
|
|
59
|
+
if (!storage) {
|
|
60
|
+
warnOnceUnavailable();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
storage.setItem(docKey(appId), JSON.stringify(value));
|
|
65
|
+
} catch {
|
|
66
|
+
// Quota / disabled storage; drop silently so a recall-on-reload
|
|
67
|
+
// niceness never breaks the launcher path that just succeeded.
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function readLastDocument(
|
|
72
|
+
appId: string,
|
|
73
|
+
): SelectDocumentResult | null {
|
|
74
|
+
const storage = getStorage();
|
|
75
|
+
if (!storage) return null;
|
|
76
|
+
const raw = storage.getItem(docKey(appId));
|
|
77
|
+
if (raw === null) return null;
|
|
78
|
+
let parsed: unknown;
|
|
79
|
+
try {
|
|
80
|
+
parsed = JSON.parse(raw);
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
// A value written by an older SDK version may not match the current
|
|
85
|
+
// shape; ignore it rather than hand the caller a half-typed object.
|
|
86
|
+
if (!isSelectDocumentResult(parsed)) return null;
|
|
87
|
+
return parsed;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function clearAll(appId: string): void {
|
|
91
|
+
const storage = getStorage();
|
|
92
|
+
if (!storage) return;
|
|
93
|
+
try {
|
|
94
|
+
storage.removeItem(docKey(appId));
|
|
95
|
+
} catch {
|
|
96
|
+
// See `storeLastDocument`.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Document } from './documents.js';
|
|
2
|
+
|
|
3
|
+
// A per-app serializer that turns an app-owned (blob) document's records
|
|
4
|
+
// back into the source file's bytes: the inverse of the parse the app ran
|
|
5
|
+
// on `doc.sourceBlob()` when it opened the file. Register one at boot with
|
|
6
|
+
// `Brass.registerSerializer`.
|
|
7
|
+
//
|
|
8
|
+
// The platform invokes it, never the app: on a download from a hosted Brass
|
|
9
|
+
// surface, the platform asks the running app to serialise, then handles
|
|
10
|
+
// staging, naming, and delivery. Producing the bytes is the serializer's
|
|
11
|
+
// whole job. An app with no registered serializer cannot export its blob
|
|
12
|
+
// documents (the platform falls back to the stale-original passthrough).
|
|
13
|
+
export type DocumentSerializer = (
|
|
14
|
+
doc: Document,
|
|
15
|
+
) => Uint8Array | Promise<Uint8Array>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Per-app sessionStorage layer for the incognito / third-party-cookie
|
|
2
|
+
// fallback session token: the opaque `sid` from a `brass_code` exchange, so
|
|
3
|
+
// a hard reload in a cookie-blocked browser (incognito, Safari ITP) keeps
|
|
4
|
+
// the user signed in. In a normal browser the cross-site cookie carries the
|
|
5
|
+
// session and nothing is written here.
|
|
6
|
+
//
|
|
7
|
+
// The value is no more sensitive than the cookie it mirrors: an opaque
|
|
8
|
+
// pointer, useless off an allowlisted Origin, gated by `approved_apps`
|
|
9
|
+
// server-side, same 30-day TTL. `sessionStorage` (per-tab, dropped on tab
|
|
10
|
+
// close) matches the cookie-channel UX. Keyed per-app id.
|
|
11
|
+
|
|
12
|
+
const KEY_PREFIX = '__brass_session_';
|
|
13
|
+
|
|
14
|
+
let warnedUnavailable = false;
|
|
15
|
+
|
|
16
|
+
function getStorage(): Storage | null {
|
|
17
|
+
if (typeof sessionStorage === 'undefined') return null;
|
|
18
|
+
try {
|
|
19
|
+
return sessionStorage;
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function warnOnceUnavailable(): void {
|
|
26
|
+
if (warnedUnavailable) return;
|
|
27
|
+
warnedUnavailable = true;
|
|
28
|
+
console.warn(
|
|
29
|
+
'Brass: sessionStorage is unavailable; the incognito session-token fallback will not survive reloads.',
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function key(appId: string): string {
|
|
34
|
+
return `${KEY_PREFIX}${appId}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function storeSessionToken(appId: string, token: string): void {
|
|
38
|
+
const storage = getStorage();
|
|
39
|
+
if (!storage) {
|
|
40
|
+
warnOnceUnavailable();
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
storage.setItem(key(appId), token);
|
|
45
|
+
} catch {
|
|
46
|
+
// Quota / disabled storage; drop rather than fail a /refresh that
|
|
47
|
+
// already succeeded (reload-survival is a niceness).
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function readSessionToken(appId: string): string | null {
|
|
52
|
+
const storage = getStorage();
|
|
53
|
+
if (!storage) return null;
|
|
54
|
+
try {
|
|
55
|
+
return storage.getItem(key(appId));
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function clearSessionToken(appId: string): void {
|
|
62
|
+
const storage = getStorage();
|
|
63
|
+
if (!storage) return;
|
|
64
|
+
try {
|
|
65
|
+
storage.removeItem(key(appId));
|
|
66
|
+
} catch {
|
|
67
|
+
// See `storeSessionToken`.
|
|
68
|
+
}
|
|
69
|
+
}
|