@brass-build/client 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +26 -5
- package/CHANGELOG.md +61 -2
- package/README.md +247 -87
- package/dist/acting-organization.d.ts +131 -0
- package/dist/acting-organization.d.ts.map +1 -0
- package/dist/acting-organization.js +278 -0
- package/dist/acting-organization.js.map +1 -0
- package/dist/api-headers.js +7 -7
- package/dist/api-headers.js.map +1 -1
- package/dist/call-service.d.ts +74 -0
- package/dist/call-service.d.ts.map +1 -0
- package/dist/call-service.js +253 -0
- package/dist/call-service.js.map +1 -0
- package/dist/client.d.ts +29 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +583 -12
- 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-contents.d.ts +15 -0
- package/dist/document-contents.d.ts.map +1 -0
- package/dist/document-contents.js +88 -0
- package/dist/document-contents.js.map +1 -0
- package/dist/document-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 +4 -2
- package/dist/document-streams.d.ts.map +1 -1
- package/dist/document-streams.js +6 -0
- package/dist/document-streams.js.map +1 -1
- package/dist/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 +3 -0
- package/dist/documents-namespace.js.map +1 -1
- package/dist/documents.d.ts +51 -3
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +415 -36
- package/dist/documents.js.map +1 -1
- package/dist/export-guest.d.ts.map +1 -1
- package/dist/export-guest.js +5 -0
- package/dist/export-guest.js.map +1 -1
- package/dist/handle-types.d.ts +23 -0
- package/dist/handle-types.d.ts.map +1 -1
- package/dist/handles.d.ts +1 -1
- package/dist/handles.d.ts.map +1 -1
- package/dist/handles.js +3 -3
- package/dist/handles.js.map +1 -1
- package/dist/hosted-ui.d.ts +10 -0
- package/dist/hosted-ui.d.ts.map +1 -1
- package/dist/hosted-ui.js +115 -30
- package/dist/hosted-ui.js.map +1 -1
- package/dist/importer.d.ts.map +1 -1
- package/dist/importer.js +28 -66
- package/dist/importer.js.map +1 -1
- package/dist/index.d.ts +8 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -1
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +2 -0
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +2 -0
- package/dist/internal.js.map +1 -1
- package/dist/mutate-batching.js +2 -2
- package/dist/mutate-batching.js.map +1 -1
- package/dist/open-document.d.ts +16 -1
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +123 -35
- package/dist/open-document.js.map +1 -1
- package/dist/presence.d.ts +10 -0
- package/dist/presence.d.ts.map +1 -1
- package/dist/presence.js.map +1 -1
- package/dist/record-paging.d.ts +13 -14
- package/dist/record-paging.d.ts.map +1 -1
- package/dist/record-paging.js +13 -14
- package/dist/record-paging.js.map +1 -1
- package/dist/serializer.d.ts +3 -3
- package/dist/session-toolbar.d.ts +9 -0
- package/dist/session-toolbar.d.ts.map +1 -1
- package/dist/session-toolbar.js +191 -21
- package/dist/session-toolbar.js.map +1 -1
- package/dist/tail-ops.js +2 -2
- package/dist/tail-ops.js.map +1 -1
- package/dist/types.d.ts +0 -42
- package/dist/types.d.ts.map +1 -1
- package/dist/undo.d.ts +1 -1
- package/dist/undo.d.ts.map +1 -1
- package/dist/undo.js +1 -1
- package/dist/undo.js.map +1 -1
- package/dist/version-bar.d.ts.map +1 -1
- package/dist/version-bar.js +75 -4
- package/dist/version-bar.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/dist/wasm-ready-gate.d.ts +13 -0
- package/dist/wasm-ready-gate.d.ts.map +1 -0
- package/dist/wasm-ready-gate.js +28 -0
- package/dist/wasm-ready-gate.js.map +1 -0
- package/dist/wasm-runtime.browser.d.ts +11 -0
- package/dist/wasm-runtime.browser.d.ts.map +1 -0
- package/dist/wasm-runtime.browser.js +44 -0
- package/dist/wasm-runtime.browser.js.map +1 -0
- package/dist/wasm-runtime.d.ts +6 -0
- package/dist/wasm-runtime.d.ts.map +1 -0
- package/dist/wasm-runtime.js +20 -0
- package/dist/wasm-runtime.js.map +1 -0
- package/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 +4 -0
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +70 -1
- package/dist/ws-client.js.map +1 -1
- package/dist/ws-frame-limit.d.ts.map +1 -1
- package/dist/ws-frame-limit.js +9 -10
- package/dist/ws-frame-limit.js.map +1 -1
- package/docs/app-manifest.md +335 -5
- package/docs/custom-chrome.md +7 -2
- package/docs/presence.md +12 -0
- package/docs/services.md +265 -0
- package/docs/text-fields.md +191 -0
- package/package.json +4 -5
- package/src/acting-organization.ts +396 -0
- package/src/api-headers.ts +7 -7
- package/src/call-service.ts +354 -0
- package/src/client.ts +655 -15
- package/src/document-checkpoints.ts +30 -0
- package/src/document-contents.ts +104 -0
- package/src/document-signals.ts +113 -1
- package/src/document-streams.ts +15 -1
- package/src/document-thumbnail-capture.ts +253 -9
- package/src/documents-namespace.ts +20 -10
- package/src/documents.ts +460 -33
- package/src/export-guest.ts +5 -0
- package/src/handle-types.ts +35 -0
- package/src/handles.ts +7 -6
- package/src/hosted-ui.ts +155 -29
- package/src/importer.ts +28 -62
- package/src/index.ts +43 -4
- package/src/internal.ts +10 -0
- package/src/mutate-batching.ts +2 -2
- package/src/open-document.ts +179 -39
- package/src/presence.ts +6 -0
- package/src/record-paging.ts +13 -14
- package/src/serializer.ts +3 -3
- package/src/session-toolbar.ts +219 -22
- package/src/tail-ops.ts +2 -2
- package/src/types.ts +6 -4
- package/src/undo.ts +1 -1
- package/src/version-bar.ts +77 -7
- package/src/version.ts +1 -1
- package/src/wasm-ready-gate.ts +30 -0
- package/src/wasm-runtime.browser.ts +48 -0
- package/src/wasm-runtime.ts +21 -0
- package/src/when-saved.ts +66 -0
- package/src/ws-client.ts +70 -1
- package/src/ws-frame-limit.ts +9 -10
- package/dist/vite.d.ts +0 -31
- package/dist/vite.d.ts.map +0 -1
- package/dist/vite.js +0 -161
- package/dist/vite.js.map +0 -1
- package/src/vite.ts +0 -214
|
@@ -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
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// A whole document read out as plain JSON, from the bytes a join delivers.
|
|
2
|
+
//
|
|
3
|
+
// The caller is a detached reader: `@brass-build/cli`, which fetches a
|
|
4
|
+
// document's snapshot and tail and wants what it holds, with no session, no
|
|
5
|
+
// socket, and no app around it. That read is the store's own business, so it
|
|
6
|
+
// lives here beside `flattenPagedLookup` rather than being reassembled by
|
|
7
|
+
// whoever needs it. A second implementation of the paging precedence is the
|
|
8
|
+
// bug this module exists to prevent.
|
|
9
|
+
//
|
|
10
|
+
// The whole document, not a window over it. A reader building against an
|
|
11
|
+
// example file needs the record TYPES the file carries, and a prefix of a
|
|
12
|
+
// store does not carry them: an import arrives grouped by entity type, so the
|
|
13
|
+
// first records of a large file are a couple of types out of hundreds. The
|
|
14
|
+
// caller holding the document locally can answer any question about it; a
|
|
15
|
+
// caller holding a slice can only answer the ones the slice happens to cover.
|
|
16
|
+
//
|
|
17
|
+
// Nothing here is bounded, and that is deliberate: this runs on a developer's
|
|
18
|
+
// machine, which is the only place a large document fits. The platform's own
|
|
19
|
+
// join refuses rather than serving a partial document, so the bytes handed in
|
|
20
|
+
// are complete or they are not served at all.
|
|
21
|
+
|
|
22
|
+
// Through the runtime seam, not from `loro-crdt` directly: which engine build
|
|
23
|
+
// this package carries is decided by the two `wasm-runtime` halves, and a
|
|
24
|
+
// third module naming the dependency is an import the browser substitution
|
|
25
|
+
// does not reach. The reader is synchronous by design, so it takes the engine
|
|
26
|
+
// and not `ensureWasmReady`: its caller holds the bytes already, which means
|
|
27
|
+
// something upstream has opened a document and readied the runtime.
|
|
28
|
+
import { LoroDoc } from './wasm-runtime.js';
|
|
29
|
+
import {
|
|
30
|
+
META_KEY,
|
|
31
|
+
flattenPagedLookup,
|
|
32
|
+
isPagedMarker,
|
|
33
|
+
pagedRootKey,
|
|
34
|
+
} from './record-paging.js';
|
|
35
|
+
|
|
36
|
+
/** A document's root containers, each read whole, keyed as the document
|
|
37
|
+
* keys them. A map root reads back as the object an app sees through
|
|
38
|
+
* `doc.map(key)`, a list as its items, a text root as its string. The JSON
|
|
39
|
+
* types tell them apart, so nothing here restates the container kind. */
|
|
40
|
+
export type DocumentContents = Record<string, unknown>;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Materialise a document from a join's bytes and read every root.
|
|
44
|
+
*
|
|
45
|
+
* `snapshot` is the compacted state (null for a document that has never been
|
|
46
|
+
* compacted) and `ops` the tail that follows it, in seq order. Both are the
|
|
47
|
+
* shapes `GET /documents/{docId}?include=snapshot` delivers, once its inline
|
|
48
|
+
* and presigned entries are resolved to bytes.
|
|
49
|
+
*/
|
|
50
|
+
export function readDocumentContents(
|
|
51
|
+
snapshot: Uint8Array | null,
|
|
52
|
+
ops: readonly Uint8Array[],
|
|
53
|
+
): DocumentContents {
|
|
54
|
+
const doc = new LoroDoc();
|
|
55
|
+
if (snapshot !== null) doc.import(snapshot);
|
|
56
|
+
for (const op of ops) doc.import(op);
|
|
57
|
+
return readRoots(doc);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A root container id is `cid:root-<name>:<Type>`; the name arrives as the
|
|
61
|
+
// key, so only the type suffix is read.
|
|
62
|
+
function containerType(containerId: string): string {
|
|
63
|
+
return containerId.slice(containerId.lastIndexOf(':') + 1);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readRoots(doc: LoroDoc): DocumentContents {
|
|
67
|
+
const contents: DocumentContents = {};
|
|
68
|
+
// Root ids only, so the enumeration converts nothing: each root below
|
|
69
|
+
// decides its own read.
|
|
70
|
+
for (const [key, containerId] of Object.entries(doc.getShallowValue())) {
|
|
71
|
+
switch (containerType(String(containerId))) {
|
|
72
|
+
case 'Map':
|
|
73
|
+
contents[key] = readMapRoot(doc, key);
|
|
74
|
+
break;
|
|
75
|
+
case 'List':
|
|
76
|
+
contents[key] = doc.getList(key).toJSON();
|
|
77
|
+
break;
|
|
78
|
+
case 'MovableList':
|
|
79
|
+
contents[key] = doc.getMovableList(key).toJSON();
|
|
80
|
+
break;
|
|
81
|
+
case 'Text':
|
|
82
|
+
contents[key] = doc.getText(key).toString();
|
|
83
|
+
break;
|
|
84
|
+
default:
|
|
85
|
+
// A container type this build does not read. Named with a null so
|
|
86
|
+
// the root's existence survives the read rather than the document
|
|
87
|
+
// appearing not to have it.
|
|
88
|
+
contents[key] = null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return contents;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function readMapRoot(doc: LoroDoc, key: string): Record<string, unknown> {
|
|
95
|
+
// Whether the store is paged is read from the root's own `meta` key, which
|
|
96
|
+
// is authoritative: in an unpaged store every key is a record key, a
|
|
97
|
+
// reserved-looking one included, because nothing refused it before the
|
|
98
|
+
// store was written.
|
|
99
|
+
const paged = isPagedMarker(doc.getMap(META_KEY).get(pagedRootKey(key)));
|
|
100
|
+
// One bulk conversion, then the flatten: a per-container walk of a large
|
|
101
|
+
// map costs work that grows with the map's own container count.
|
|
102
|
+
const lookup = doc.getMap(key).toJSON() as Record<string, unknown>;
|
|
103
|
+
return flattenPagedLookup(lookup, paged);
|
|
104
|
+
}
|
package/src/document-signals.ts
CHANGED
|
@@ -45,8 +45,8 @@ export type DocumentStatusUnsubscribe = () => void;
|
|
|
45
45
|
export type DocumentDriveTrackingState =
|
|
46
46
|
| 'unhydrated'
|
|
47
47
|
| 'synced'
|
|
48
|
-
| 'ahead'
|
|
49
48
|
| 'behind'
|
|
49
|
+
| 'ahead'
|
|
50
50
|
| 'diverged';
|
|
51
51
|
|
|
52
52
|
// Which way the mirror flows. `two_way` links round-trip; `import_only` links
|
|
@@ -99,6 +99,11 @@ export interface DocumentDriveStatus {
|
|
|
99
99
|
// the Publish affordance rather than re-deriving it. `detail` carries the
|
|
100
100
|
// server-authored progress line while it is `true`.
|
|
101
101
|
publishing: boolean;
|
|
102
|
+
/** The drive's own words about the file the last publish delivered, set
|
|
103
|
+
* only where the drive answered that it could not read it. The publish
|
|
104
|
+
* succeeded and the artifact exists; the drive's reader rejects it, which
|
|
105
|
+
* a person otherwise meets as an error inside the drive. */
|
|
106
|
+
sourceUnreadable?: string;
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
export type DocumentDriveStatusListener = (
|
|
@@ -106,6 +111,32 @@ export type DocumentDriveStatusListener = (
|
|
|
106
111
|
) => void;
|
|
107
112
|
export type DocumentDriveStatusUnsubscribe = () => void;
|
|
108
113
|
|
|
114
|
+
// Where a document that mirrors nothing can be published: the drive its
|
|
115
|
+
// project is linked to, which takes it as a NEW file. A drive-linked
|
|
116
|
+
// document has `driveStatus` and no targets; this is the state before the
|
|
117
|
+
// first publish, which is what a document added to a linked project (an
|
|
118
|
+
// upload, or one an app created there) sits in.
|
|
119
|
+
//
|
|
120
|
+
// Words are server-authored (`detail`, rendered verbatim), as everywhere
|
|
121
|
+
// else a drive is described.
|
|
122
|
+
export interface DrivePublishTarget {
|
|
123
|
+
// Adapter type token; an app may key an icon off it.
|
|
124
|
+
driveType: string;
|
|
125
|
+
// The drive's user-facing name.
|
|
126
|
+
driveName: string;
|
|
127
|
+
// What the linked container is called on the drive, so a surface can say
|
|
128
|
+
// where the file lands.
|
|
129
|
+
containerName: string;
|
|
130
|
+
// The sentence a publish affordance renders, verbatim.
|
|
131
|
+
detail: string;
|
|
132
|
+
// True while a publish is in flight, so the affordance holds rather than
|
|
133
|
+
// inviting a second artifact.
|
|
134
|
+
publishing: boolean;
|
|
135
|
+
// The last publish attempt failed, in which case `detail` carries the
|
|
136
|
+
// server's account of why rather than the resting sentence.
|
|
137
|
+
publishFailed: boolean;
|
|
138
|
+
}
|
|
139
|
+
|
|
109
140
|
// Each signal is a `createEmitter` composed with the retain / attach / dedup
|
|
110
141
|
// policy its wire event needs, wrapped in a small class only to keep the
|
|
111
142
|
// domain-named methods (`mark`, `emit`, `status`, `clearListeners`) the
|
|
@@ -208,6 +239,86 @@ export class DriveStatusSignal {
|
|
|
208
239
|
}
|
|
209
240
|
}
|
|
210
241
|
|
|
242
|
+
// How the publish of a document that mirrors nothing is going. Server-
|
|
243
|
+
// authored: `detail` replaces the target's resting sentence while present,
|
|
244
|
+
// and is rendered verbatim.
|
|
245
|
+
export interface DrivePublishState {
|
|
246
|
+
// A publish is running on the server.
|
|
247
|
+
publishing: boolean;
|
|
248
|
+
// The last publish attempt failed; `detail` says what the server made of
|
|
249
|
+
// it.
|
|
250
|
+
failed: boolean;
|
|
251
|
+
// The sentence to render, or null to fall back to the target's resting
|
|
252
|
+
// copy.
|
|
253
|
+
detail: string | null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const RESTING_PUBLISH: DrivePublishState = {
|
|
257
|
+
publishing: false,
|
|
258
|
+
failed: false,
|
|
259
|
+
detail: null,
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// Where an unlinked document can be published, and how its publish is going.
|
|
263
|
+
//
|
|
264
|
+
// The TARGETS are seeded from the join alone, because the set only changes
|
|
265
|
+
// when the document becomes a mirror and the drive status arriving is that
|
|
266
|
+
// event, so the open factory clears them when one does. The STATE moves
|
|
267
|
+
// while the document is open: the first publish creates the artifact, so
|
|
268
|
+
// until it lands there is no drive status to report progress or failure
|
|
269
|
+
// through, and a `doc_drive_publish` frame carries it instead.
|
|
270
|
+
export class DrivePublishTargetSignal {
|
|
271
|
+
private _target: DrivePublishTarget | null = null;
|
|
272
|
+
private _state: DrivePublishState = RESTING_PUBLISH;
|
|
273
|
+
private readonly _listeners = new Set<() => void>();
|
|
274
|
+
|
|
275
|
+
get target(): DrivePublishTarget | null {
|
|
276
|
+
return this._target;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
get state(): DrivePublishState {
|
|
280
|
+
return this._state;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
set(target: DrivePublishTarget | null): void {
|
|
284
|
+
this._target = target;
|
|
285
|
+
// The join states the same thing the frames do, so a reopen starts from
|
|
286
|
+
// the server's current answer rather than from the last frame's.
|
|
287
|
+
this._state = {
|
|
288
|
+
publishing: target?.publishing === true,
|
|
289
|
+
failed: target?.publishFailed === true,
|
|
290
|
+
detail: null,
|
|
291
|
+
};
|
|
292
|
+
this._notify();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
setState(state: DrivePublishState): void {
|
|
296
|
+
this._state = state;
|
|
297
|
+
this._notify();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
onState(cb: () => void): () => void {
|
|
301
|
+
this._listeners.add(cb);
|
|
302
|
+
return () => {
|
|
303
|
+
this._listeners.delete(cb);
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Paired with the other signals' method of the same name: a parked or
|
|
308
|
+
// closed Document must not reach an app callback registered by a session
|
|
309
|
+
// that is over.
|
|
310
|
+
clearListeners(): void {
|
|
311
|
+
this._listeners.clear();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Both mutators notify. A reader renders the targets and the state
|
|
315
|
+
// together, so a change to either is a change to what it draws, and one
|
|
316
|
+
// silent mutator would leave that reader showing the other half.
|
|
317
|
+
private _notify(): void {
|
|
318
|
+
for (const cb of this._listeners) cb();
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
211
322
|
// Field-by-field equality for the drive status. The shape is flat (all
|
|
212
323
|
// scalars), so a key compare suffices; explicit rather than JSON.stringify so
|
|
213
324
|
// a future non-scalar field forces a conscious update here.
|
|
@@ -228,6 +339,7 @@ function driveStatusEqual(
|
|
|
228
339
|
a.ref === b.ref &&
|
|
229
340
|
a.refUpdatedAt === b.refUpdatedAt &&
|
|
230
341
|
a.publishing === b.publishing &&
|
|
342
|
+
a.sourceUnreadable === b.sourceUnreadable &&
|
|
231
343
|
a.itemName === b.itemName &&
|
|
232
344
|
a.itemUrl === b.itemUrl
|
|
233
345
|
);
|
package/src/document-streams.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
DocumentSubscriber,
|
|
17
17
|
DocumentUnsubscribe,
|
|
18
18
|
EntityProxy,
|
|
19
|
+
RootKind,
|
|
19
20
|
} from './documents.js';
|
|
20
21
|
import type { DocumentStreamInfo } from './documents-namespace.js';
|
|
21
22
|
import type {
|
|
@@ -25,7 +26,12 @@ import type {
|
|
|
25
26
|
DocumentStatusUnsubscribe,
|
|
26
27
|
DocumentWorkStatus,
|
|
27
28
|
} from './document-signals.js';
|
|
28
|
-
import type {
|
|
29
|
+
import type {
|
|
30
|
+
ListHandle,
|
|
31
|
+
MapHandle,
|
|
32
|
+
OrderedMapHandle,
|
|
33
|
+
TextHandle,
|
|
34
|
+
} from './handle-types.js';
|
|
29
35
|
|
|
30
36
|
// The wiring `Document.stream()` runs on: list/create manifests over
|
|
31
37
|
// HTTP, and open a stream's line (join + WS subscribe) returning the
|
|
@@ -78,6 +84,14 @@ export class DocumentStream {
|
|
|
78
84
|
this._inner.mutate<E>(fn);
|
|
79
85
|
}
|
|
80
86
|
|
|
87
|
+
roots(): Record<string, RootKind> {
|
|
88
|
+
return this._inner.roots();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
text(key: string): TextHandle {
|
|
92
|
+
return this._inner.text(key);
|
|
93
|
+
}
|
|
94
|
+
|
|
81
95
|
toJSON(): Record<string, unknown> {
|
|
82
96
|
return this._inner.toJSON();
|
|
83
97
|
}
|
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
// Document thumbnail auto-capture: screenshots a registered canvas
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Document thumbnail auto-capture: screenshots a registered canvas and uploads
|
|
2
|
+
// it as the thumbnail.
|
|
3
|
+
//
|
|
4
|
+
// The edit trigger debounces a capture after each local commit, so a
|
|
5
|
+
// continuous drag uploads once when it settles. The seed trigger covers a
|
|
6
|
+
// document whose picture is older than its content: an import writes no local
|
|
7
|
+
// commit on the reader's client, and a document nobody has edited yet has none
|
|
8
|
+
// at all. It samples the canvas and uploads the frame it settles on, because
|
|
9
|
+
// the drawn-but-wrong empty editor is on screen from the first frame (measured
|
|
10
|
+
// on the deployed DXF editor: grid at ~0.4s, drawing 0.8s to 4.2s after the
|
|
11
|
+
// records land), so neither blankness nor a fixed delay separates the two.
|
|
4
12
|
|
|
5
13
|
// A canvas the SDK screenshots for the thumbnail. A getter form is accepted
|
|
6
14
|
// for apps that build or rebuild the canvas lazily.
|
|
@@ -18,6 +26,22 @@ export type ThumbnailUploader = (
|
|
|
18
26
|
// so a continuous drag uploads once when it settles, not once per frame.
|
|
19
27
|
const THUMBNAIL_CAPTURE_DEBOUNCE_MS = 1500;
|
|
20
28
|
|
|
29
|
+
// Seed watch: sample interval, equal samples that count as settled, give-up
|
|
30
|
+
// bound, and captures per watch. The window is a bound rather than a wait,
|
|
31
|
+
// since the watch ends as soon as it has what it needs; what it sizes is how
|
|
32
|
+
// long a document nothing has drawn yet stays worth sampling. A cold
|
|
33
|
+
// environment (first load of the bundle, first parse, a slow machine) has been
|
|
34
|
+
// measured taking over twenty seconds to draw a 150 KB DXF.
|
|
35
|
+
const SEED_SAMPLE_MS = 500;
|
|
36
|
+
const SEED_WINDOW_MS = 120_000;
|
|
37
|
+
const SEED_SETTLED_SAMPLES = 2;
|
|
38
|
+
const SEED_MAX_CAPTURES = 2;
|
|
39
|
+
|
|
40
|
+
// Edge of the square the fingerprint downsamples to. Small enough that a
|
|
41
|
+
// sample costs nothing on the render thread, large enough that a drawing does
|
|
42
|
+
// not average away into the background it sits on.
|
|
43
|
+
const FINGERPRINT_EDGE = 64;
|
|
44
|
+
|
|
21
45
|
// Longest-edge cap (device pixels) for an uploaded thumbnail: a generous
|
|
22
46
|
// product ceiling, an upper bound on the size any Brass surface sensibly
|
|
23
47
|
// paints a thumbnail at with retina headroom. The largest surface (the
|
|
@@ -57,6 +81,9 @@ export interface ThumbnailCaptureHost {
|
|
|
57
81
|
// True when the doc is still attached, re-checked after the async toBlob +
|
|
58
82
|
// upload boundary so a capture in flight when the doc closes does not upload.
|
|
59
83
|
isAttached(): boolean;
|
|
84
|
+
// Called once an upload lands, so the host can record which version of the
|
|
85
|
+
// document now has a picture.
|
|
86
|
+
onCaptured?(): void;
|
|
60
87
|
}
|
|
61
88
|
|
|
62
89
|
export class ThumbnailCapture {
|
|
@@ -70,6 +97,26 @@ export class ThumbnailCapture {
|
|
|
70
97
|
// offline / test path, where the canvas is tracked but never captured, so
|
|
71
98
|
// apps can register one early without hitting the server in tests.
|
|
72
99
|
private _uploader: ThumbnailUploader | null = null;
|
|
100
|
+
// Seed-watch state: the interval, the fingerprint the watch will not upload,
|
|
101
|
+
// the last sample and how many times it has repeated, the sample budget, and
|
|
102
|
+
// how many frames the watch has captured.
|
|
103
|
+
private _seedTimer: ReturnType<typeof setInterval> | null = null;
|
|
104
|
+
private _seedHeld: string | null = null;
|
|
105
|
+
private _seedLast: string | null = null;
|
|
106
|
+
private _seedRepeats = 0;
|
|
107
|
+
private _seedSamplesLeft = 0;
|
|
108
|
+
private _seedCaptures = 0;
|
|
109
|
+
// A seed was owed while no canvas was registered, so an app that builds its
|
|
110
|
+
// renderer after it has the content still gets one.
|
|
111
|
+
private _seedOwed = false;
|
|
112
|
+
// Whether this instance has landed an upload. Decides whether an edit may
|
|
113
|
+
// supersede a seed watch, and nothing else.
|
|
114
|
+
private _uploaded = false;
|
|
115
|
+
// A failed upload has been reported, so a document the server keeps
|
|
116
|
+
// refusing warns once rather than once per edit.
|
|
117
|
+
private _warnedCaptureFailure = false;
|
|
118
|
+
// Detaches the page-lifecycle listeners while a canvas is registered.
|
|
119
|
+
private _detachLifecycle: (() => void) | null = null;
|
|
73
120
|
|
|
74
121
|
constructor(private readonly host: ThumbnailCaptureHost) {}
|
|
75
122
|
|
|
@@ -80,9 +127,16 @@ export class ThumbnailCapture {
|
|
|
80
127
|
this._canvas = source;
|
|
81
128
|
// A re-register gets one more chance to surface the missing-buffer warn.
|
|
82
129
|
this._warnedFor = null;
|
|
130
|
+
this._attachLifecycle();
|
|
131
|
+
if (this._seedOwed) {
|
|
132
|
+
this._seedOwed = false;
|
|
133
|
+
this.armSeed();
|
|
134
|
+
}
|
|
83
135
|
return () => {
|
|
84
136
|
if (this._canvas === source) {
|
|
85
137
|
this._canvas = null;
|
|
138
|
+
this._detachLifecycle?.();
|
|
139
|
+
this._detachLifecycle = null;
|
|
86
140
|
this.cancel();
|
|
87
141
|
}
|
|
88
142
|
};
|
|
@@ -96,6 +150,16 @@ export class ThumbnailCapture {
|
|
|
96
150
|
// edits produces ONE upload when it settles.
|
|
97
151
|
schedule(): void {
|
|
98
152
|
if (this._canvas === null) return;
|
|
153
|
+
// An edit supersedes a seed watch once the document HAS a picture: the
|
|
154
|
+
// person is drawing, so the debounce owns it from here and a second upload
|
|
155
|
+
// of the same moment costs a second snapshot seal. Until then the watch
|
|
156
|
+
// runs on, because an edit is often the reader's last act before they
|
|
157
|
+
// leave, and cancelling on it hands the document's only chance at a
|
|
158
|
+
// picture to a debounce that has to outlive the tab.
|
|
159
|
+
if (this._uploaded) {
|
|
160
|
+
this._stopSeed();
|
|
161
|
+
this._seedOwed = false;
|
|
162
|
+
}
|
|
99
163
|
if (this._timer !== null) clearTimeout(this._timer);
|
|
100
164
|
this._timer = setTimeout(() => {
|
|
101
165
|
this._timer = null;
|
|
@@ -103,20 +167,188 @@ export class ThumbnailCapture {
|
|
|
103
167
|
}, THUMBNAIL_CAPTURE_DEBOUNCE_MS);
|
|
104
168
|
}
|
|
105
169
|
|
|
170
|
+
// Called when the document's picture is older than its content: at open, and
|
|
171
|
+
// again whenever content arrives with no local commit behind it. Arming a
|
|
172
|
+
// running watch refreshes its budget, so a burst of arriving ops is one
|
|
173
|
+
// watch that outlives the burst.
|
|
174
|
+
armSeed(): void {
|
|
175
|
+
const el = this._resolveCanvas();
|
|
176
|
+
if (el === null) {
|
|
177
|
+
this._seedOwed = true;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
this._seedSamplesLeft = Math.ceil(SEED_WINDOW_MS / SEED_SAMPLE_MS);
|
|
181
|
+
if (this._seedTimer !== null) return;
|
|
182
|
+
this._seedHeld = null;
|
|
183
|
+
this._seedLast = null;
|
|
184
|
+
this._seedRepeats = 0;
|
|
185
|
+
this._seedCaptures = 0;
|
|
186
|
+
this._seedTimer = setInterval(() => this._sampleSeed(), SEED_SAMPLE_MS);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// A page going away is a deadline: take the frame now rather than waiting
|
|
190
|
+
// out a debounce the tab will not survive. `visibilitychange` covers the
|
|
191
|
+
// tab switch as well, which is the case with room for the upload to finish.
|
|
192
|
+
private _attachLifecycle(): void {
|
|
193
|
+
if (this._detachLifecycle !== null) return;
|
|
194
|
+
if (typeof document === 'undefined' || typeof window === 'undefined') {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const onHidden = (): void => {
|
|
198
|
+
if (document.visibilityState === 'visible') return;
|
|
199
|
+
this._flushPending();
|
|
200
|
+
};
|
|
201
|
+
const onPageHide = (): void => this._flushPending();
|
|
202
|
+
document.addEventListener('visibilitychange', onHidden);
|
|
203
|
+
window.addEventListener('pagehide', onPageHide);
|
|
204
|
+
this._detachLifecycle = () => {
|
|
205
|
+
document.removeEventListener('visibilitychange', onHidden);
|
|
206
|
+
window.removeEventListener('pagehide', onPageHide);
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private _flushPending(): void {
|
|
211
|
+
if (this._timer !== null) {
|
|
212
|
+
clearTimeout(this._timer);
|
|
213
|
+
this._timer = null;
|
|
214
|
+
void this._capture();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
// A watch holding a frame it has not uploaded takes it, for the same
|
|
218
|
+
// reason its own deadline does: this frame beats no frame.
|
|
219
|
+
if (
|
|
220
|
+
this._seedTimer !== null &&
|
|
221
|
+
this._seedLast !== null &&
|
|
222
|
+
this._seedLast !== this._seedHeld
|
|
223
|
+
) {
|
|
224
|
+
this._takeSeed(this._seedLast);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
106
228
|
// Cancel a pending capture (canvas unregistered / suspend / close).
|
|
107
229
|
cancel(): void {
|
|
108
230
|
if (this._timer !== null) {
|
|
109
231
|
clearTimeout(this._timer);
|
|
110
232
|
this._timer = null;
|
|
111
233
|
}
|
|
234
|
+
this._stopSeed();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Release for good. The page-lifecycle listeners are held on `document` and
|
|
238
|
+
// `window` rather than on anything the Document owns, so cancelling the
|
|
239
|
+
// timers does not reach them: without this they outlive every closed
|
|
240
|
+
// document, each retaining its uploader closure and firing on every tab
|
|
241
|
+
// switch for the life of the page.
|
|
242
|
+
destroy(): void {
|
|
243
|
+
this.cancel();
|
|
244
|
+
this._detachLifecycle?.();
|
|
245
|
+
this._detachLifecycle = null;
|
|
246
|
+
this._canvas = null;
|
|
247
|
+
this._seedOwed = false;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private _stopSeed(): void {
|
|
251
|
+
if (this._seedTimer !== null) {
|
|
252
|
+
clearInterval(this._seedTimer);
|
|
253
|
+
this._seedTimer = null;
|
|
254
|
+
}
|
|
255
|
+
this._seedHeld = null;
|
|
256
|
+
this._seedLast = null;
|
|
257
|
+
this._seedRepeats = 0;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private _sampleSeed(): void {
|
|
261
|
+
if (!this.host.isCaptureAllowed()) {
|
|
262
|
+
this._stopSeed();
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const el = this._resolveCanvas();
|
|
266
|
+
if (el === null) {
|
|
267
|
+
this._stopSeed();
|
|
268
|
+
this._seedOwed = true;
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const now = this._fingerprint(el);
|
|
272
|
+
this._seedSamplesLeft -= 1;
|
|
273
|
+
// A canvas with no layout yet is not a frame, so it neither settles nor
|
|
274
|
+
// counts against the budget's meaning; the watch just waits for one.
|
|
275
|
+
if (now === null) {
|
|
276
|
+
if (this._seedSamplesLeft <= 0) this._stopSeed();
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (now !== this._seedHeld) {
|
|
280
|
+
if (now === this._seedLast) this._seedRepeats += 1;
|
|
281
|
+
else this._seedRepeats = 0;
|
|
282
|
+
this._seedLast = now;
|
|
283
|
+
if (this._seedRepeats >= SEED_SETTLED_SAMPLES - 1) {
|
|
284
|
+
this._takeSeed(now);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (this._seedSamplesLeft <= 0) {
|
|
289
|
+
// Still redrawing at the deadline: the latest frame is a truer picture
|
|
290
|
+
// of the document than no picture, and a further wait has no end.
|
|
291
|
+
const moved = this._seedLast !== null && this._seedLast !== this._seedHeld;
|
|
292
|
+
if (moved) this._takeSeed(this._seedLast!);
|
|
293
|
+
else this._stopSeed();
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Upload a settled frame. The first one is a picture of the document, or of
|
|
298
|
+
// the editor about to draw it: an app that mounts its canvas before it has
|
|
299
|
+
// drawn holds an empty grid for as long as the drawing takes. So the watch
|
|
300
|
+
// stays on for a second settle, which lands at the same version and
|
|
301
|
+
// overwrites the first. A third is an app redrawing on its own.
|
|
302
|
+
private _takeSeed(frame: string): void {
|
|
303
|
+
this._seedHeld = frame;
|
|
304
|
+
this._seedRepeats = 0;
|
|
305
|
+
this._seedCaptures += 1;
|
|
306
|
+
if (this._seedCaptures >= SEED_MAX_CAPTURES || this._seedSamplesLeft <= 0) {
|
|
307
|
+
this._stopSeed();
|
|
308
|
+
}
|
|
309
|
+
void this._capture();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// A cheap perceptual fingerprint: the canvas downsampled into a small square
|
|
313
|
+
// and folded into a string. It reads through the same front buffer `toBlob`
|
|
314
|
+
// does, so a canvas the capture would read blank fingerprints blank too and
|
|
315
|
+
// the watch expires instead of uploading it.
|
|
316
|
+
private _fingerprint(el: HTMLCanvasElement): string | null {
|
|
317
|
+
// A canvas the page has not laid out yet (the browser's 1x1 default until
|
|
318
|
+
// the app sizes it) carries no frame to compare.
|
|
319
|
+
if (el.width <= 1 || el.height <= 1) return null;
|
|
320
|
+
try {
|
|
321
|
+
const off = document.createElement('canvas');
|
|
322
|
+
off.width = FINGERPRINT_EDGE;
|
|
323
|
+
off.height = FINGERPRINT_EDGE;
|
|
324
|
+
const ctx = off.getContext('2d', { willReadFrequently: true });
|
|
325
|
+
if (!ctx) return 'unreadable';
|
|
326
|
+
ctx.drawImage(el, 0, 0, FINGERPRINT_EDGE, FINGERPRINT_EDGE);
|
|
327
|
+
const { data } = ctx.getImageData(0, 0, FINGERPRINT_EDGE, FINGERPRINT_EDGE);
|
|
328
|
+
let h1 = 0x811c9dc5;
|
|
329
|
+
let h2 = 0x01000193;
|
|
330
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
331
|
+
const px = (data[i]! << 24) | (data[i + 1]! << 16) | (data[i + 2]! << 8) | data[i + 3]!;
|
|
332
|
+
h1 = Math.imul(h1 ^ px, 0x01000193);
|
|
333
|
+
h2 = Math.imul(h2 + px, 0x85ebca6b) ^ (h2 >>> 13);
|
|
334
|
+
}
|
|
335
|
+
return `${h1 >>> 0}:${h2 >>> 0}`;
|
|
336
|
+
} catch {
|
|
337
|
+
// A tainted canvas throws here exactly as it would in the capture, so
|
|
338
|
+
// treating every read as the same value expires the watch.
|
|
339
|
+
return 'unreadable';
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private _resolveCanvas(): HTMLCanvasElement | null {
|
|
344
|
+
if (this._canvas === null) return null;
|
|
345
|
+
return typeof this._canvas === 'function' ? this._canvas() : this._canvas;
|
|
112
346
|
}
|
|
113
347
|
|
|
114
348
|
private async _capture(): Promise<void> {
|
|
115
349
|
if (!this.host.isCaptureAllowed()) return;
|
|
116
|
-
if (this._canvas === null) return;
|
|
117
350
|
if (this._uploader === null) return;
|
|
118
|
-
const el =
|
|
119
|
-
typeof this._canvas === 'function' ? this._canvas() : this._canvas;
|
|
351
|
+
const el = this._resolveCanvas();
|
|
120
352
|
if (!el) return;
|
|
121
353
|
this._warnIfWebglContextWillProduceBlankCapture(el);
|
|
122
354
|
try {
|
|
@@ -134,9 +366,21 @@ export class ThumbnailCapture {
|
|
|
134
366
|
});
|
|
135
367
|
if (!this.host.isAttached()) return;
|
|
136
368
|
await this._uploader(image, target.width, target.height);
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
369
|
+
this._uploaded = true;
|
|
370
|
+
this.host.onCaptured?.();
|
|
371
|
+
} catch (err) {
|
|
372
|
+
// Best-effort: a rate-limit rejection, a lack of write access, or a
|
|
373
|
+
// transient failure leaves the document on its last picture, and the
|
|
374
|
+
// next edit or arrival reschedules. Reported once, because the refusals
|
|
375
|
+
// that persist (no write access, an oversized image) are otherwise
|
|
376
|
+
// indistinguishable from a capture that never ran.
|
|
377
|
+
if (!this._warnedCaptureFailure) {
|
|
378
|
+
this._warnedCaptureFailure = true;
|
|
379
|
+
console.warn(
|
|
380
|
+
'@brass-build/client: uploading the document thumbnail failed: ' +
|
|
381
|
+
(err instanceof Error ? err.message : String(err)),
|
|
382
|
+
);
|
|
383
|
+
}
|
|
140
384
|
}
|
|
141
385
|
}
|
|
142
386
|
|