wordinweb 0.1.22 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -2
- package/dist/chunk-XGTWJMDG.js +38208 -0
- package/dist/chunk-XGTWJMDG.js.map +1 -0
- package/dist/collab.d.ts +1281 -0
- package/dist/collab.js +4909 -0
- package/dist/collab.js.map +1 -0
- package/dist/index-C2J9egkh.d.ts +4314 -0
- package/dist/index.d.ts +2 -2401
- package/dist/index.js +13 -35778
- package/dist/index.js.map +1 -1
- package/package.json +13 -3
package/dist/collab.d.ts
ADDED
|
@@ -0,0 +1,1281 @@
|
|
|
1
|
+
import { ReactNode } from 'react';
|
|
2
|
+
import { X as XmlElement, D as DocxDocument, C as ClientMessage, S as ServerMessage, P as PresencePosition, R as RosterEntry, a as ParticipantProfile, I as Intent, b as IdSidecar, L as LineageHead, T as ToolbarFeature, c as ToolbarMode, d as DocxViewApi, W as WriteStatus, U as UndoOutcome } from './index-C2J9egkh.js';
|
|
3
|
+
export { e as bytesToB64, f as deriveEpochKeys, g as docKeyFromFragment, m as mintDocKey, s as sealCheckpoint, h as stretchShareCode } from './index-C2J9egkh.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* How much of the document an applied intent disturbed — the input to
|
|
7
|
+
* dirty-scoped reconciliation (perf B9/B10). Reconciling an edit used to cost
|
|
8
|
+
* O(document) on every path (full model reparse + full id walk), so per-op
|
|
9
|
+
* cost grew with the document; the scope lets the caller pay for THE EDIT.
|
|
10
|
+
*
|
|
11
|
+
* `doc` is the conservative default and means exactly today's behavior: full
|
|
12
|
+
* refresh() + assignFromRoots(). Only intents whose blast radius is provably
|
|
13
|
+
* one paragraph (or the two sides of a split) report a narrower scope; every
|
|
14
|
+
* structural, document-level, or exotic intent keeps the document scope.
|
|
15
|
+
*/
|
|
16
|
+
type Scope = {
|
|
17
|
+
kind: "doc";
|
|
18
|
+
}
|
|
19
|
+
/** Only these paragraphs' contents changed. */
|
|
20
|
+
| {
|
|
21
|
+
kind: "block";
|
|
22
|
+
blocks: XmlElement[];
|
|
23
|
+
}
|
|
24
|
+
/** `before` was split; `after` is the new sibling that follows it. */
|
|
25
|
+
| {
|
|
26
|
+
kind: "split";
|
|
27
|
+
before: XmlElement;
|
|
28
|
+
after: XmlElement;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Media transfer client (plan doc 16 §5) — the half that was specced and
|
|
33
|
+
* left unbuilt while the server relay shipped.
|
|
34
|
+
*
|
|
35
|
+
* Bytes NEVER ride the WebSocket sequencer: the intent carries only an
|
|
36
|
+
* address (`blobSha`) and the blob travels over HTTP. Everything here is
|
|
37
|
+
* organised around one rule from doc 16 §1.1 — nobody is ever TOLD a hash.
|
|
38
|
+
* The placer commits `blobSha` inside the sequenced, authenticated intent;
|
|
39
|
+
* from then on every participant re-derives sha256 itself and compares
|
|
40
|
+
* against that commitment. A deliverer's claim about what it is delivering
|
|
41
|
+
* is never consulted, so a hostile relay or peer can at worst withhold
|
|
42
|
+
* bytes, never substitute them.
|
|
43
|
+
*
|
|
44
|
+
* ENVELOPE (as SHIPPED, which differs from doc 16 §1's prose): the blob is
|
|
45
|
+
* the BARE AES-GCM ciphertext and the 12-byte IV rides in the intent's `iv`
|
|
46
|
+
* field — the shape doc 16 §2 specifies and `sealMediaBlob` implements.
|
|
47
|
+
* §1/§5.1's "prepend IV to the blob" is stale text. The sha is over the
|
|
48
|
+
* ciphertext either way, which is what lets the blind relay verify it.
|
|
49
|
+
*/
|
|
50
|
+
/** One registered media part's address, as it travels to a late joiner:
|
|
51
|
+
* plaintext rooms in `welcome.media`, encrypted rooms inside the SEALED
|
|
52
|
+
* checkpoint body (the server must not learn part structure). */
|
|
53
|
+
interface MediaAddress {
|
|
54
|
+
part: string;
|
|
55
|
+
sha: string;
|
|
56
|
+
iv?: string;
|
|
57
|
+
}
|
|
58
|
+
/** The late-join address map for a document: every registered part whose sha
|
|
59
|
+
* is actually KNOWN. Parse-derived holes (sha === "") are omitted — an empty
|
|
60
|
+
* address is not an address, and passing one on would only teach the joiner
|
|
61
|
+
* the same nothing. Mirrors the hub's filter for plaintext welcomes. */
|
|
62
|
+
declare function mediaAddressesOf(doc: DocxDocument): MediaAddress[];
|
|
63
|
+
/** Injectable fetch so tests drive the relay without a socket or a server. */
|
|
64
|
+
type FetchLike = (url: string, init?: {
|
|
65
|
+
method?: string;
|
|
66
|
+
body?: Uint8Array;
|
|
67
|
+
}) => Promise<{
|
|
68
|
+
ok: boolean;
|
|
69
|
+
status: number;
|
|
70
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
71
|
+
}>;
|
|
72
|
+
interface MediaTransportOptions {
|
|
73
|
+
/** Origin serving the doc-16 §3 routes, e.g. "http://localhost:1234". */
|
|
74
|
+
httpBase: string;
|
|
75
|
+
/** Defaults to global fetch. */
|
|
76
|
+
fetchImpl?: FetchLike;
|
|
77
|
+
}
|
|
78
|
+
/** How a pending part is faring, for the placeholder UI (doc 16 §5.2 step 4). */
|
|
79
|
+
type MediaState = "fetching" | "waiting" | "unavailable";
|
|
80
|
+
/** Crypto seam: plaintext mode supplies neither, E2EE mode supplies both. */
|
|
81
|
+
interface MediaCrypto {
|
|
82
|
+
/** Seal plaintext for upload; reusing `iv` reproduces a blob byte-identically. */
|
|
83
|
+
seal(plaintext: Uint8Array, iv?: string): Promise<{
|
|
84
|
+
blob: Uint8Array;
|
|
85
|
+
iv: string;
|
|
86
|
+
}>;
|
|
87
|
+
/** Open a downloaded blob; MUST throw on a GCM tag failure. */
|
|
88
|
+
open(blob: Uint8Array, iv: string): Promise<Uint8Array>;
|
|
89
|
+
}
|
|
90
|
+
interface MediaClientCallbacks {
|
|
91
|
+
/** Bytes landed (or a state changed) — repaint. */
|
|
92
|
+
onChange?: () => void;
|
|
93
|
+
/** Placeholder state per part, for the "unavailable" affordance. */
|
|
94
|
+
onState?: (part: string, state: MediaState) => void;
|
|
95
|
+
/** Ask the room for a sha (the connection sends the WS frame). */
|
|
96
|
+
need: (sha: string) => void;
|
|
97
|
+
/** Volunteer holdings (the connection sends the WS frame). */
|
|
98
|
+
have: (shas: string[]) => void;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Per-replica media duties: eager fetch for holes, re-supply for holdings.
|
|
102
|
+
*
|
|
103
|
+
* Deliberately owns no transport and no keys of its own — the connection
|
|
104
|
+
* hands it a doc getter, a crypto seam (absent in plaintext mode), and the
|
|
105
|
+
* two WS senders. That keeps ONE implementation of the state machine behind
|
|
106
|
+
* both the plaintext and encrypted connections, which had drifted to "fully
|
|
107
|
+
* built" and "entirely absent" respectively.
|
|
108
|
+
*/
|
|
109
|
+
declare class MediaClient {
|
|
110
|
+
private opts;
|
|
111
|
+
private getDoc;
|
|
112
|
+
private cb;
|
|
113
|
+
private crypto?;
|
|
114
|
+
private inFlight;
|
|
115
|
+
private states;
|
|
116
|
+
/** Shas we asked the room for, awaiting a media-ready. */
|
|
117
|
+
private waitingFor;
|
|
118
|
+
constructor(opts: MediaTransportOptions, getDoc: () => DocxDocument | null, cb: MediaClientCallbacks, crypto?: MediaCrypto | undefined);
|
|
119
|
+
/** Current placeholder state of a pending part (undefined once ready). */
|
|
120
|
+
stateOf(part: string): MediaState | undefined;
|
|
121
|
+
/**
|
|
122
|
+
* PLACER (doc 16 §5.1). Seal (E2EE) → address → upload. Returns the intent
|
|
123
|
+
* fields on success, or null when the relay refused — in which case the
|
|
124
|
+
* caller MUST NOT emit an insertImage, or the room gets a reservation
|
|
125
|
+
* pointing at a blob that does not exist.
|
|
126
|
+
*/
|
|
127
|
+
upload(docId: string, plaintext: Uint8Array): Promise<{
|
|
128
|
+
blobSha: string;
|
|
129
|
+
bytesLen: number;
|
|
130
|
+
iv?: string;
|
|
131
|
+
} | null>;
|
|
132
|
+
/** Plaintext of blobs THIS client uploaded, so its own images never take a
|
|
133
|
+
* network round trip. Bounded: an entry is dropped once installed. */
|
|
134
|
+
private ownBlobs;
|
|
135
|
+
/**
|
|
136
|
+
* RECEIVER (doc 16 §5.2). Eager-fetch every pending part the doc knows
|
|
137
|
+
* about. Idempotent and safe to call after every applied broadcast: parts
|
|
138
|
+
* already ready or already in flight are skipped.
|
|
139
|
+
*/
|
|
140
|
+
fetchPending(docId: string): Promise<void>;
|
|
141
|
+
private setState;
|
|
142
|
+
private fetchOne;
|
|
143
|
+
/**
|
|
144
|
+
* Verify then install. The sha check is against the value COMMITTED IN THE
|
|
145
|
+
* INTENT, never against anything the deliverer said — defense in depth
|
|
146
|
+
* behind the relay's own check (doc 16 §5.2 step 3), and the only thing
|
|
147
|
+
* standing between a compromised relay and arbitrary bytes in the document.
|
|
148
|
+
*/
|
|
149
|
+
private installVerified;
|
|
150
|
+
/** Server says the blob is fetchable now — retry the parts waiting on it. */
|
|
151
|
+
onReady(docId: string, sha: string): Promise<void>;
|
|
152
|
+
/** No holder is online. The registration stays; a later media-ready (or a
|
|
153
|
+
* holder rejoining, §5.4) recovers it with no action from the user. */
|
|
154
|
+
onUnavailable(sha: string): void;
|
|
155
|
+
/** Shas this replica can serve: parts whose bytes are PRESENT. */
|
|
156
|
+
heldShas(): string[];
|
|
157
|
+
/** §5.4: intersect the room's outstanding needs with local holdings and
|
|
158
|
+
* volunteer — this is what makes evicted media reappear when a holder
|
|
159
|
+
* comes back, with no polling anywhere. */
|
|
160
|
+
volunteer(mediaNeeded: string[] | undefined): void;
|
|
161
|
+
/** Someone in the room needs a sha: answer if we hold it (§5.3). */
|
|
162
|
+
answerRequest(sha: string): void;
|
|
163
|
+
/**
|
|
164
|
+
* HOLDER DUTY (doc 16 §5.3): chosen to re-supply — upload our copy.
|
|
165
|
+
*
|
|
166
|
+
* In an encrypted room this RE-SEALS with the IV recorded on the part, not
|
|
167
|
+
* a fresh one: same key + same IV + same plaintext reproduces the exact
|
|
168
|
+
* ciphertext, which is the only thing that still hashes to the address the
|
|
169
|
+
* intent committed to. A fresh IV would produce a perfectly valid blob at
|
|
170
|
+
* the WRONG address, and the upload would be rejected — do not "fix" it
|
|
171
|
+
* that way (doc 16 §5.3 / doc 13 §4 both carry this warning).
|
|
172
|
+
*
|
|
173
|
+
* The local sha assertion before PUT is deliberate: failing it means THIS
|
|
174
|
+
* replica's pixels are corrupt, and uploading them would just burn the
|
|
175
|
+
* re-supply rotation on bytes the relay will reject anyway.
|
|
176
|
+
*/
|
|
177
|
+
resupply(docId: string, sha: string): Promise<boolean>;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* A transport the connection drives: send a client message, and register a
|
|
182
|
+
* handler for inbound server messages. A WebSocket adapter (or a fake, in
|
|
183
|
+
* tests) implements it. The connection contains no socket code — the client
|
|
184
|
+
* counterpart to the transport-free server hub (plan doc 07).
|
|
185
|
+
*/
|
|
186
|
+
interface ClientTransport {
|
|
187
|
+
send(msg: ClientMessage): void;
|
|
188
|
+
onMessage(cb: (msg: ServerMessage) => void): void;
|
|
189
|
+
}
|
|
190
|
+
interface ConnectionCallbacks {
|
|
191
|
+
/** The document changed (welcome loaded, a broadcast applied). */
|
|
192
|
+
onChange?: () => void;
|
|
193
|
+
/** A remote participant's cursor/selection moved (or cleared: null). */
|
|
194
|
+
onPresence?: (participant: string, position: PresencePosition | null) => void;
|
|
195
|
+
/** The server refused the connection (e.g. version mismatch). */
|
|
196
|
+
onRefused?: (reason: string) => void;
|
|
197
|
+
/**
|
|
198
|
+
* Resume landed in a DIFFERENT epoch than the bundle's (doc 12 §5 case 2):
|
|
199
|
+
* someone re-seeded while this client was away. The connection has taken
|
|
200
|
+
* the server's state and did NOT replay the bundle's pending intents
|
|
201
|
+
* (they belong to the old epoch — replaying them would be a silent
|
|
202
|
+
* cross-epoch merge, which the fork rule forbids). The consumer decides
|
|
203
|
+
* what to do with the old bundle: doc-15 lineage fast-forward/draft, or
|
|
204
|
+
* pre-lineage, "your offline copy is saved as a draft".
|
|
205
|
+
*/
|
|
206
|
+
onEpochChange?: (storedGenesisId: string, currentGenesisId: string) => void;
|
|
207
|
+
/** The session roster changed (join/leave/rename) — full snapshot. */
|
|
208
|
+
onRoster?: (roster: RosterEntry[]) => void;
|
|
209
|
+
/**
|
|
210
|
+
* The connection detected that the local OPTIMISTIC replica drifted from
|
|
211
|
+
* the canonical document at quiescence and rebuilt it in place (encrypted
|
|
212
|
+
* mode: the mirror is local ground truth, so the check costs no network).
|
|
213
|
+
* The doc object was swapped and docEpoch bumped — the renderer remounts
|
|
214
|
+
* exactly like any true-conflict reload. Informational: the heal already
|
|
215
|
+
* happened; consumers surface/telemeter it (the B6a typist-drift class).
|
|
216
|
+
*/
|
|
217
|
+
onSelfHeal?: (info: {
|
|
218
|
+
seq: number;
|
|
219
|
+
liveHash: string;
|
|
220
|
+
canonicalHash: string;
|
|
221
|
+
}) => void;
|
|
222
|
+
/**
|
|
223
|
+
* A submit was DROPPED instead of sent, and the caller needs to know.
|
|
224
|
+
*
|
|
225
|
+
* Today the only reason is "not-ready": a submit arriving before the
|
|
226
|
+
* welcome has nothing to apply against, no confirmed seq to base itself on,
|
|
227
|
+
* and (encrypted) no key to seal with, so it cannot be honoured or queued.
|
|
228
|
+
* It used to return silently — no error, no callback, no counter, no
|
|
229
|
+
* replay — which made lost edits indistinguishable from edits that were
|
|
230
|
+
* never made. B13 measured 403 submits, 172 envelopes on the wire and 172
|
|
231
|
+
* in the document, with a completely clean server log; a channel that
|
|
232
|
+
* consumes intents without a trace is what that requires.
|
|
233
|
+
*
|
|
234
|
+
* NOT `onRefused`: that one means the SERVER refused the connection and the
|
|
235
|
+
* demo renders a "please refresh" screen on it. This is non-fatal and
|
|
236
|
+
* per-intent — the connection stays perfectly usable.
|
|
237
|
+
*/
|
|
238
|
+
onSubmitDropped?: (reason: "not-ready") => void;
|
|
239
|
+
/**
|
|
240
|
+
* Something threw on an async path that has nowhere to return an error —
|
|
241
|
+
* a seal, a transport send, a persistence write. These used to die in a
|
|
242
|
+
* serial chain's `.catch(() => {})`, which is how B13's lost edits and a
|
|
243
|
+
* silently-failing durable copy both stayed invisible.
|
|
244
|
+
*
|
|
245
|
+
* DELIBERATELY SEPARATE from onRefused ("the server refused the
|
|
246
|
+
* connection", which tears the session down) and onSubmitDropped ("an edit
|
|
247
|
+
* was lost before it could be sent"). Each of the three now means exactly
|
|
248
|
+
* one thing; conflating failure channels is precisely how the bugs this
|
|
249
|
+
* arc is cleaning up managed to hide.
|
|
250
|
+
*
|
|
251
|
+
* `where` is a fixed code-site label, never interpolated with session data.
|
|
252
|
+
*/
|
|
253
|
+
onError?: (info: {
|
|
254
|
+
where: string;
|
|
255
|
+
error: unknown;
|
|
256
|
+
}) => void;
|
|
257
|
+
/**
|
|
258
|
+
* Resume landed in a different epoch that is a STRICT DESCENDANT of this
|
|
259
|
+
* client's stored head (doc 15 §1 fast-forward): the seed's lineage
|
|
260
|
+
* contains our (genesisId, hash) at ≥ our seq, so adopting the server's
|
|
261
|
+
* state loses nothing — no draft, no banner noise. The consumer should
|
|
262
|
+
* still bank the superseded bundle recoverable-not-gone (version ring /
|
|
263
|
+
* a superseded slot) per doc 15's fabricated-lineage mitigation.
|
|
264
|
+
*/
|
|
265
|
+
onFastForward?: (fromGenesisId: string, toGenesisId: string) => void;
|
|
266
|
+
/**
|
|
267
|
+
* The SERVER is going to end this session, and this is the grace period
|
|
268
|
+
* before it does (server lifecycle arc). Render a countdown from `inMs`.
|
|
269
|
+
*
|
|
270
|
+
* - `idle` nobody has done anything for the idle window. Cancellable:
|
|
271
|
+
* any qualifying activity — an accepted edit, an admin
|
|
272
|
+
* action, a media transfer, someone joining, but NEVER
|
|
273
|
+
* presence — resets the clock and fires
|
|
274
|
+
* {@link onSessionWarningCleared}. Typing genuinely saves the
|
|
275
|
+
* session, so the countdown is worth showing prominently.
|
|
276
|
+
* - `lifetime` the room hit its absolute age cap. NOT cancellable by
|
|
277
|
+
* anything: the deadline was fixed when the room was created,
|
|
278
|
+
* which is the point (an activity-based clock is exactly what
|
|
279
|
+
* a keepalive script defeats). The document is not lost — the
|
|
280
|
+
* local bundle re-seeds it into a new epoch.
|
|
281
|
+
*
|
|
282
|
+
* `inMs` is the measured remainder at send time, not a configured constant,
|
|
283
|
+
* so counting down from it lands on the real ending.
|
|
284
|
+
*
|
|
285
|
+
* NOT `onRefused`: nothing has been refused yet and the session is fully
|
|
286
|
+
* usable during the grace. The kick that eventually follows arrives as
|
|
287
|
+
* `onRefused("idle-timeout")` / `onRefused("session-expired")`.
|
|
288
|
+
*/
|
|
289
|
+
onSessionWarning?: (info: {
|
|
290
|
+
reason: "idle" | "lifetime";
|
|
291
|
+
inMs: number;
|
|
292
|
+
}) => void;
|
|
293
|
+
/**
|
|
294
|
+
* A previously announced deadline is no longer approaching — take the
|
|
295
|
+
* countdown down. Only ever `idle`, because the lifetime cap cannot be
|
|
296
|
+
* cancelled; the narrow type means a consumer cannot write a branch the
|
|
297
|
+
* server is incapable of producing.
|
|
298
|
+
*/
|
|
299
|
+
onSessionWarningCleared?: (info: {
|
|
300
|
+
reason: "idle";
|
|
301
|
+
}) => void;
|
|
302
|
+
/**
|
|
303
|
+
* ONE OF THIS CLIENT'S OWN INTENTS WAS REJECTED by canonical validation —
|
|
304
|
+
* it will never apply, anywhere, and the edit is gone.
|
|
305
|
+
*
|
|
306
|
+
* This channel exists because of a bug that cost a user their photos. The
|
|
307
|
+
* validator carried a stale image-size bound, so every large image was
|
|
308
|
+
* rejected as "bad size" — and the failure was completely invisible. The
|
|
309
|
+
* reason is worth stating exactly, because it inverts the usual assumption:
|
|
310
|
+
* a rejection is sequenced, so EVERY REPLICA AGREES the intent is dead.
|
|
311
|
+
* Nothing diverges, nothing throws, no upload fails. Our entire invariant
|
|
312
|
+
* arsenal is built to detect disagreement, and this bug's signature was
|
|
313
|
+
* agreement. The image simply never appeared.
|
|
314
|
+
*
|
|
315
|
+
* A wrong bound can happen again; next time somebody should SEE it. Fired
|
|
316
|
+
* only for intents THIS client authored — another participant's rejection is
|
|
317
|
+
* their business and not actionable here.
|
|
318
|
+
*
|
|
319
|
+
* `reason` is the validator's fixed vocabulary (`insertImage: bad size`,
|
|
320
|
+
* `invalid base`, …) — never session content, so it is safe to surface.
|
|
321
|
+
*/
|
|
322
|
+
onIntentRejected?: (info: {
|
|
323
|
+
reason: string;
|
|
324
|
+
clientSeq: number;
|
|
325
|
+
}) => void;
|
|
326
|
+
/** Media re-supply control (doc 16 §5): the room asks who holds `sha`;
|
|
327
|
+
* a holder answers via `mediaHave` and uploads over HTTP when chosen. */
|
|
328
|
+
onMediaRequest?: (sha: string) => void;
|
|
329
|
+
/** A needed blob became fetchable / definitively unavailable. */
|
|
330
|
+
onMediaReady?: (sha: string) => void;
|
|
331
|
+
onMediaUnavailable?: (sha: string) => void;
|
|
332
|
+
/** A pending part's placeholder state changed (fetching/waiting/
|
|
333
|
+
* unavailable) — the renderer shows the "unavailable" affordance off this. */
|
|
334
|
+
onMediaState?: (part: string, state: MediaState) => void;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Client-side session: joins a document, applies canonical broadcasts to a
|
|
338
|
+
* ClientReplica (optimistic apply + reconciliation), and relays presence.
|
|
339
|
+
* The editor layer submits local intents and reads `replica.doc`.
|
|
340
|
+
*/
|
|
341
|
+
declare class CollabConnection {
|
|
342
|
+
private transport;
|
|
343
|
+
private clientId;
|
|
344
|
+
private cb;
|
|
345
|
+
private replica;
|
|
346
|
+
private clientSeq;
|
|
347
|
+
/** Bumps only when reconciliation RELOADED the document (a true conflict) —
|
|
348
|
+
* the renderer re-mounts on this, but updates in place otherwise (no flash
|
|
349
|
+
* for the common non-conflicting edits). */
|
|
350
|
+
docEpoch: number;
|
|
351
|
+
/** Per-client id allocator for carried node ids (split/format-range/insert):
|
|
352
|
+
* a client-specific base block keeps concurrently-allocated ids disjoint
|
|
353
|
+
* across clients so they never collide. */
|
|
354
|
+
private idCounter;
|
|
355
|
+
private idBase;
|
|
356
|
+
/** The epoch id of the session this connection joined (from the welcome).
|
|
357
|
+
* The resume layer compares it against the bundle's stored genesisId:
|
|
358
|
+
* same ⇒ seamless rejoin; different ⇒ someone re-seeded while away
|
|
359
|
+
* (doc 12 §5 case 2 — take server state, keep the local copy; the doc-15
|
|
360
|
+
* lineage check decides fast-forward vs draft). Null until welcomed. */
|
|
361
|
+
genesisId: string | null;
|
|
362
|
+
/** Session encryption mode from the welcome (doc 13 §6). The E2EE layer
|
|
363
|
+
* hard-refuses a value contradicting the link's `#k` fragment. */
|
|
364
|
+
mode: "plaintext" | "encrypted" | null;
|
|
365
|
+
/**
|
|
366
|
+
* The relay's per-blob upload limit in bytes, from the welcome — so a file
|
|
367
|
+
* can be checked BEFORE it is uploaded rather than after a 413.
|
|
368
|
+
*
|
|
369
|
+
* `null` means the server did not publish one (an older host). Callers must
|
|
370
|
+
* treat that as "skip the pre-check", NOT as "no limit" and not as a
|
|
371
|
+
* default they invent: the server still enforces its real limit either way,
|
|
372
|
+
* and a client guessing a larger number would promise the user an upload
|
|
373
|
+
* that cannot succeed.
|
|
374
|
+
*/
|
|
375
|
+
mediaMaxBlobBytes: number | null;
|
|
376
|
+
/** Out-of-band media transfer (doc 16 §5). Null until `httpBase` is
|
|
377
|
+
* supplied — without a relay origin a client simply has no media duties
|
|
378
|
+
* (every existing caller keeps working unchanged). */
|
|
379
|
+
media: MediaClient | null;
|
|
380
|
+
/** The room this connection joined; media URLs are per-doc. */
|
|
381
|
+
private docId;
|
|
382
|
+
constructor(transport: ClientTransport, clientId: string, cb?: ConnectionCallbacks,
|
|
383
|
+
/** Origin serving the doc-16 §3 media routes; enables media transfer. */
|
|
384
|
+
mediaOpts?: MediaTransportOptions);
|
|
385
|
+
/** Upload bytes and get the intent's media fields, or null if the relay
|
|
386
|
+
* refused — the caller must not emit an insertImage in that case. */
|
|
387
|
+
uploadMedia(plaintext: Uint8Array): Promise<{
|
|
388
|
+
blobSha: string;
|
|
389
|
+
bytesLen: number;
|
|
390
|
+
iv?: string;
|
|
391
|
+
} | null>;
|
|
392
|
+
/** Replace the callbacks (used by bindEditor to attach after construction). */
|
|
393
|
+
setCallbacks(cb: ConnectionCallbacks): void;
|
|
394
|
+
/** Allocate `n` fresh carried node ids in this client's disjoint block. */
|
|
395
|
+
allocIds(n: number): number[];
|
|
396
|
+
/**
|
|
397
|
+
* Join a document. The server replies with a welcome (snapshot + sidecar +
|
|
398
|
+
* tail). The hello carries this connection's clientId — the hub binds it to
|
|
399
|
+
* the socket and refuses submits under any other id (doc 11 decision 8).
|
|
400
|
+
* `takeover: true` claims the identity from an existing live connection
|
|
401
|
+
* (the doc-12 §7 "use here instead" path for a second same-profile tab);
|
|
402
|
+
* without it, a duplicate join is refused `already-open`.
|
|
403
|
+
*/
|
|
404
|
+
join(docId: string, token?: string, opts?: {
|
|
405
|
+
takeover?: boolean;
|
|
406
|
+
profile?: ParticipantProfile;
|
|
407
|
+
codeProof?: string;
|
|
408
|
+
ownerToken?: string;
|
|
409
|
+
}): void;
|
|
410
|
+
/** Owner admin op (doc 14 §2.5): honored only if this connection proved
|
|
411
|
+
* the owner token at hello — otherwise the server refuses `not-owner`. */
|
|
412
|
+
admin(action: {
|
|
413
|
+
op: "kick";
|
|
414
|
+
clientId: string;
|
|
415
|
+
} | {
|
|
416
|
+
op: "readOnly";
|
|
417
|
+
on: boolean;
|
|
418
|
+
} | {
|
|
419
|
+
op: "setRole";
|
|
420
|
+
clientId: string;
|
|
421
|
+
role: "editor" | "viewer";
|
|
422
|
+
}): void;
|
|
423
|
+
/** The latest roster snapshot (doc 14 §2); empty until the first fan-out. */
|
|
424
|
+
roster: RosterEntry[];
|
|
425
|
+
/**
|
|
426
|
+
* Attribution layer 1 (doc 14 §3): the canonical log IS the attribution
|
|
427
|
+
* record — every applied entry carries its author's bound clientId. This
|
|
428
|
+
* is the bounded client-side derivation an activity panel renders
|
|
429
|
+
* (clientId → roster name/color). Newest last, capped, derived state:
|
|
430
|
+
* never persisted, dies with the connection (zero custody).
|
|
431
|
+
*/
|
|
432
|
+
activity: {
|
|
433
|
+
seq: number;
|
|
434
|
+
clientId: string;
|
|
435
|
+
kind: string;
|
|
436
|
+
}[];
|
|
437
|
+
private static ACTIVITY_CAP;
|
|
438
|
+
private recordActivity;
|
|
439
|
+
/** Ask the room for a blob this replica needs (doc 16 §5.2). */
|
|
440
|
+
mediaNeed(sha: string): void;
|
|
441
|
+
/** Volunteer holdings (reply to a request, or after a welcome whose
|
|
442
|
+
* mediaNeeded intersects local media — §5.4). */
|
|
443
|
+
mediaHave(shas: string[]): void;
|
|
444
|
+
/** Shas this replica can re-supply: metadata of parts whose bytes are
|
|
445
|
+
* PRESENT (pending parts are exactly what we can't serve). */
|
|
446
|
+
heldMediaShas(): string[];
|
|
447
|
+
/** Rename/recolor this participant mid-session. The server sanitizes and
|
|
448
|
+
* fans out the updated roster; the local copy updates on that echo (no
|
|
449
|
+
* optimistic roster — a 1-RTT lag on your own rename is imperceptible and
|
|
450
|
+
* keeps one code path). */
|
|
451
|
+
setProfile(profile: ParticipantProfile): void;
|
|
452
|
+
/** Bundle being resumed from (set by resume(), consumed at welcome). */
|
|
453
|
+
private resuming;
|
|
454
|
+
/**
|
|
455
|
+
* Rejoin a document from a persisted bundle (doc 12 §5). Restores the
|
|
456
|
+
* clientSeq watermark FIRST — a fresh counter would reuse already-
|
|
457
|
+
* sequenced (clientId, clientSeq) keys and the server would dedup this
|
|
458
|
+
* client's NEW edits as re-sends (silent edit loss). The welcome decides
|
|
459
|
+
* the case: same epoch ⇒ replay pending (below); different ⇒
|
|
460
|
+
* onEpochChange, pending withheld.
|
|
461
|
+
*/
|
|
462
|
+
resume(bundle: DocBundle, token?: string, opts?: {
|
|
463
|
+
profile?: ParticipantProfile;
|
|
464
|
+
codeProof?: string;
|
|
465
|
+
ownerToken?: string;
|
|
466
|
+
}): void;
|
|
467
|
+
/**
|
|
468
|
+
* The current durable state as a doc-12 bundle, or null before welcome.
|
|
469
|
+
* `savedAt` is stamped by the persister at write time (clock injection —
|
|
470
|
+
* this module never reads Date.now itself).
|
|
471
|
+
*/
|
|
472
|
+
exportBundle(docId: string): DocBundle | null;
|
|
473
|
+
exportBundleAsync(docId: string): Promise<DocBundle | null>;
|
|
474
|
+
/** The live document (null until welcome). The editor renders this. */
|
|
475
|
+
get doc(): DocxDocument | null;
|
|
476
|
+
/** Accumulates doc changes across replica rebuilds (welcome), so the getter
|
|
477
|
+
* below stays monotonic per connection. */
|
|
478
|
+
private docVersionBase;
|
|
479
|
+
/**
|
|
480
|
+
* Counts changes TO THE RENDERED DOCUMENT — the repaint signal, as opposed
|
|
481
|
+
* to onChange (which also fires for pure bookkeeping such as a tracked own
|
|
482
|
+
* echo). The editor-driven typing path mutates and paints the doc itself,
|
|
483
|
+
* so its submit + echo leave this untouched; remote applies, optimistic
|
|
484
|
+
* canonical applies (toolbar ops), reloads, and media installs advance it.
|
|
485
|
+
* The react layer repaints on THIS, not on onChange — repainting on
|
|
486
|
+
* onChange queued a whole-document relayout per keystroke, catastrophic
|
|
487
|
+
* past the background-layout page threshold.
|
|
488
|
+
*/
|
|
489
|
+
get docVersion(): number;
|
|
490
|
+
/** docVersionBase at the last takeRenderScope — a base bump between takes
|
|
491
|
+
* (welcome doc replacement, media install) has no narrow scope. */
|
|
492
|
+
private takenDocVersionBase;
|
|
493
|
+
/**
|
|
494
|
+
* Drain the dirty scope behind the docVersion movement since the last take
|
|
495
|
+
* — what the repaint answering `docVersion` must relayout. Replica applies
|
|
496
|
+
* carry their per-intent scope; connection-level bumps (a replaced doc
|
|
497
|
+
* object, media pixels landing) report document scope. Null means NOTHING
|
|
498
|
+
* was recorded since the last take — the caller already painted everything
|
|
499
|
+
* and may skip the repaint.
|
|
500
|
+
*/
|
|
501
|
+
takeRenderScope(): Scope | null;
|
|
502
|
+
get ready(): boolean;
|
|
503
|
+
/** Un-confirmed local intents in flight — the drained-replay discipline
|
|
504
|
+
* (doc 15 §2 / rebase.ts) polls this to submit one intent at a time. */
|
|
505
|
+
get pendingCount(): number;
|
|
506
|
+
/**
|
|
507
|
+
* Submit a local edit. The caller supplies the intent minus its wire
|
|
508
|
+
* bookkeeping (clientId/clientSeq/base) — the connection fills those from
|
|
509
|
+
* the current confirmed seq and applies it optimistically before sending.
|
|
510
|
+
*/
|
|
511
|
+
submit(intent: Omit<Intent, "clientId" | "clientSeq" | "base">): void;
|
|
512
|
+
/** Submit an intent whose mutation the caller ALREADY performed on this
|
|
513
|
+
* connection's live doc (the editor-driven path: DocxEditor applies the
|
|
514
|
+
* command to `conn.doc` and then emits the intent). Skips the optimistic
|
|
515
|
+
* re-apply — applying twice doubled every keystroke — but tracks pending and
|
|
516
|
+
* sends identically, so echoes and reconciliation work unchanged. */
|
|
517
|
+
submitPreApplied(intent: Omit<Intent, "clientId" | "clientSeq" | "base">): void;
|
|
518
|
+
/** Submits dropped because the connection was not ready (see
|
|
519
|
+
* onSubmitDropped). Exposed like `selfHeals` so a harness or a UI can read
|
|
520
|
+
* it: when a run loses intents, this number says how many died HERE rather
|
|
521
|
+
* than anywhere else on the path. */
|
|
522
|
+
droppedPreReady: number;
|
|
523
|
+
private submitFull;
|
|
524
|
+
/** Broadcast this client's cursor/selection (ephemeral). Selection ranges
|
|
525
|
+
* are clamped on the way OUT too, so an over-long selection costs the room
|
|
526
|
+
* a bounded payload rather than every receiver a discard. */
|
|
527
|
+
setPresence(position: PresencePosition | null): void;
|
|
528
|
+
/**
|
|
529
|
+
* Surface any of OUR OWN intents that canonical validation rejected.
|
|
530
|
+
*
|
|
531
|
+
* A rejection is sequenced and agreed by every replica, so it produces no
|
|
532
|
+
* divergence to notice — which is exactly how a stale image-size bound
|
|
533
|
+
* silently ate a user's photos. See ConnectionCallbacks.onIntentRejected.
|
|
534
|
+
*/
|
|
535
|
+
private reportRejections;
|
|
536
|
+
private onServer;
|
|
537
|
+
private rlRedriveTimer;
|
|
538
|
+
private rlRedriveBackoffMs;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* The client-side document bundle (plan doc 12 §4) — under zero custody this
|
|
543
|
+
* is THE durable copy of a document anywhere: the server deletes everything
|
|
544
|
+
* at session end, so each participating browser persists its own bundle and
|
|
545
|
+
* any holder can re-seed the same link later.
|
|
546
|
+
*
|
|
547
|
+
* Contents are the replica's CONFIRMED state plus the pending queue:
|
|
548
|
+
* `confirmedBytes` is a complete docx at `confirmedSeq`; `confirmedSidecar`
|
|
549
|
+
* rides with it always (round-4 F10 — bytes alone cannot reproduce the id
|
|
550
|
+
* table across split-created ids); `pending` is idempotency-keyed and safe
|
|
551
|
+
* to replay after a crash (the server dedups by (clientId, clientSeq)).
|
|
552
|
+
*/
|
|
553
|
+
interface DocBundle {
|
|
554
|
+
docId: string;
|
|
555
|
+
/** Epoch of the session this state came from (doc 12 §5): on rejoin,
|
|
556
|
+
* same ⇒ seamless resume; different ⇒ someone re-seeded while away. */
|
|
557
|
+
genesisId: string;
|
|
558
|
+
confirmedSeq: number;
|
|
559
|
+
confirmedBytes: Uint8Array;
|
|
560
|
+
confirmedSidecar: IdSidecar;
|
|
561
|
+
/** Local intents not yet confirmed when the bundle was written — replayed
|
|
562
|
+
* verbatim on resume; idempotent by (clientId, clientSeq). */
|
|
563
|
+
pending: Intent[];
|
|
564
|
+
/**
|
|
565
|
+
* The clientSeq watermark at write time. Resume MUST restore this before
|
|
566
|
+
* submitting anything new: a fresh connection restarting from 1 would
|
|
567
|
+
* reuse already-sequenced (clientId, clientSeq) keys and the server would
|
|
568
|
+
* dedup its NEW edits as re-sends — self-inflicted silent edit loss (the
|
|
569
|
+
* same failure shape doc 12 §7's single-tab rule guards against).
|
|
570
|
+
*/
|
|
571
|
+
clientSeq: number;
|
|
572
|
+
savedAt: number;
|
|
573
|
+
/**
|
|
574
|
+
* The lineage chain (doc 15 §1): every epoch this copy has passed
|
|
575
|
+
* through, newest last — `[..., {this bundle's genesisId, seq, hash}]`.
|
|
576
|
+
* Rides with re-seeds so returning holders can prove ancestry and
|
|
577
|
+
* fast-forward instead of forking. Capped; ancestry checks are O(chain).
|
|
578
|
+
*/
|
|
579
|
+
lineage: LineageHead[];
|
|
580
|
+
/**
|
|
581
|
+
* Local intents recorded while the session was UNREACHABLE (doc 15 §2 /
|
|
582
|
+
* doc 12 §5 "offline mode is first-class"): the user kept editing after
|
|
583
|
+
* the socket died. On rejoin these are the offline tail the arrival
|
|
584
|
+
* ladder reconciles — replayed as ordinary intents (same epoch), as
|
|
585
|
+
* suggestions (diverged, small), or parked in a draft (diverged, large).
|
|
586
|
+
* Distinct from `pending` (in-flight when a LIVE session was
|
|
587
|
+
* interrupted): tail entries carry NO wire bookkeeping — clientId /
|
|
588
|
+
* clientSeq / base are assigned at replay time by the ordinary submit
|
|
589
|
+
* path, so the stored objects are the payload half of an Intent only.
|
|
590
|
+
*/
|
|
591
|
+
offlineTail?: Intent[];
|
|
592
|
+
/**
|
|
593
|
+
* The genesisId of the epoch the offline tail was recorded AGAINST. This
|
|
594
|
+
* is what lets a resume decide the arrival rung without guessing: same
|
|
595
|
+
* epoch ⇒ the tail is morally a large pending queue and replays silently;
|
|
596
|
+
* different (or absent, for a tail written by an older build) ⇒ true
|
|
597
|
+
* divergence, offer suggest/draft — never silently bulldoze.
|
|
598
|
+
*/
|
|
599
|
+
offlineTailEpoch?: string;
|
|
600
|
+
/** Per-part media metadata (doc 16 §5.3): the sha (and E2EE iv/epoch)
|
|
601
|
+
* of every media part this copy knows. The docx bytes carry the PIXELS;
|
|
602
|
+
* this carries the ADDRESSES — without it a resumed holder couldn't
|
|
603
|
+
* answer re-supply requests or verify fetches (metadata is in-memory on
|
|
604
|
+
* the doc and would otherwise die with the session). */
|
|
605
|
+
mediaMeta?: [string, {
|
|
606
|
+
sha: string;
|
|
607
|
+
iv?: string;
|
|
608
|
+
genesisId?: string;
|
|
609
|
+
}][];
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* THE KEY CONVENTION, in one place. Everything a browser stores rides the
|
|
613
|
+
* bundle store under a key derived from the base docId:
|
|
614
|
+
*
|
|
615
|
+
* - `<docId>` — the live bundle
|
|
616
|
+
* - `<docId>#version-<ts>[-<label>]` — a frozen restore point
|
|
617
|
+
* - `<docId>#draft-<genesis>` — an offline copy parked on divergence
|
|
618
|
+
* - `<docId>#superseded-<genesis>` — a banked copy after a fast-forward
|
|
619
|
+
* - `local:…` — a document that never went live
|
|
620
|
+
*
|
|
621
|
+
* The builders and the parser below are the only code that knows these
|
|
622
|
+
* shapes; UIs render from `parseBundleKey` instead of re-deriving them.
|
|
623
|
+
*/
|
|
624
|
+
type StoredDocKind = "live" | "version" | "draft" | "superseded" | "local" | "unknown";
|
|
625
|
+
interface ParsedBundleKey {
|
|
626
|
+
/** The base document id (the part before any `#` suffix). */
|
|
627
|
+
docId: string;
|
|
628
|
+
kind: StoredDocKind;
|
|
629
|
+
/** A version's human label, when the key carries one. */
|
|
630
|
+
label?: string;
|
|
631
|
+
/** A version's freeze time (ms), parsed from the key. */
|
|
632
|
+
versionSavedAt?: number;
|
|
633
|
+
}
|
|
634
|
+
declare function versionKey(docId: string, savedAt: number, label?: string): string;
|
|
635
|
+
declare function draftKey(docId: string, genesisId: string): string;
|
|
636
|
+
declare function supersededKey(docId: string, genesisId: string): string;
|
|
637
|
+
/** Parse any store key. An unrecognised `#` suffix degrades to `unknown`
|
|
638
|
+
* rather than throwing — a future key shape must not break today's listing. */
|
|
639
|
+
declare function parseBundleKey(key: string): ParsedBundleKey;
|
|
640
|
+
/**
|
|
641
|
+
* One stored entry, as METADATA ONLY — everything a list needs to render
|
|
642
|
+
* (what it is, when, how big) without the document bytes. A 500-page doc is
|
|
643
|
+
* megabytes; listing ten of them must not deserialise all ten, so `list()`
|
|
644
|
+
* returns these and `get(key)` fetches bytes only when one is opened.
|
|
645
|
+
*/
|
|
646
|
+
interface StoredDocSummary extends ParsedBundleKey {
|
|
647
|
+
/** The full store key — what `get()`/`delete()` take. */
|
|
648
|
+
key: string;
|
|
649
|
+
/** When this entry was written (bundle.savedAt, ms). */
|
|
650
|
+
savedAt: number;
|
|
651
|
+
/** Size of the stored document bytes. */
|
|
652
|
+
byteLength: number;
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Storage seam for bundles. The browser implementation is IndexedDB (the
|
|
656
|
+
* only storage that fits multi-MB binary docs); tests and Node use the
|
|
657
|
+
* in-memory implementation — the doc-12 test plan's "in-memory IndexedDB
|
|
658
|
+
* stub", which keeps every bundle test deterministic and dependency-free.
|
|
659
|
+
*/
|
|
660
|
+
interface BundleStore {
|
|
661
|
+
get(docId: string): Promise<DocBundle | null>;
|
|
662
|
+
put(bundle: DocBundle): Promise<void>;
|
|
663
|
+
delete(docId: string): Promise<void>;
|
|
664
|
+
/** Enumerate everything stored, metadata only (see StoredDocSummary). */
|
|
665
|
+
list(): Promise<StoredDocSummary[]>;
|
|
666
|
+
}
|
|
667
|
+
declare class InMemoryBundleStore implements BundleStore {
|
|
668
|
+
private bundles;
|
|
669
|
+
/** Write count — lets tests assert the throttle's coalescing precisely. */
|
|
670
|
+
writes: number;
|
|
671
|
+
get(docId: string): Promise<DocBundle | null>;
|
|
672
|
+
put(bundle: DocBundle): Promise<void>;
|
|
673
|
+
delete(docId: string): Promise<void>;
|
|
674
|
+
list(): Promise<StoredDocSummary[]>;
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Persists a connection's state to a BundleStore on a THROTTLE — not a
|
|
678
|
+
* debounce (round-4 F8): a debounce resets per event, so sustained typing
|
|
679
|
+
* defers the write for the whole burst and an OS kill loses the burst; a
|
|
680
|
+
* throttle bounds data loss to the throttle window (default 1s), which is
|
|
681
|
+
* the actual durability guarantee doc 12 §4 states ("RPO ≈ your last bundle
|
|
682
|
+
* write ≈ the throttle window").
|
|
683
|
+
*
|
|
684
|
+
* Wiring: call `notify()` from the connection's onChange (every local edit,
|
|
685
|
+
* echo, and remote broadcast); call `flush()` from pagehide/visibilitychange
|
|
686
|
+
* — best-effort (IndexedDB has no synchronous API; the throttle is the real
|
|
687
|
+
* mechanism, the flush just narrows the tail).
|
|
688
|
+
*
|
|
689
|
+
* The scheduler (setTimeout/clearTimeout) and clock are injectable so tests
|
|
690
|
+
* drive time deterministically.
|
|
691
|
+
*/
|
|
692
|
+
declare class BundlePersister {
|
|
693
|
+
private conn;
|
|
694
|
+
private store;
|
|
695
|
+
private docId;
|
|
696
|
+
private opts;
|
|
697
|
+
private lastWrite;
|
|
698
|
+
private trailing;
|
|
699
|
+
private stopped;
|
|
700
|
+
/** Writes are async (digest + store I/O) and must not overlap on one
|
|
701
|
+
* store; a serial chain guarantees ordering and lets flush() await
|
|
702
|
+
* every in-flight write (round-4 F8: the flush is only meaningful if it
|
|
703
|
+
* durably lands the latest state before pagehide). */
|
|
704
|
+
private chain;
|
|
705
|
+
constructor(conn: CollabConnection, store: BundleStore, docId: string, opts?: {
|
|
706
|
+
throttleMs?: number;
|
|
707
|
+
now?: () => number;
|
|
708
|
+
setTimer?: (fn: () => void, ms: number) => unknown;
|
|
709
|
+
clearTimer?: (t: unknown) => void;
|
|
710
|
+
/**
|
|
711
|
+
* A persistence write FAILED. Wire this — in a zero-custody design the
|
|
712
|
+
* browser's stored bundle IS the durable copy, so a swallowed quota or
|
|
713
|
+
* blocked-storage error means the user's only durable copy silently
|
|
714
|
+
* stops updating while the editor looks perfectly healthy. That is the
|
|
715
|
+
* most dangerous silence in this codebase, which is why it is now a
|
|
716
|
+
* callback instead of an empty catch.
|
|
717
|
+
*/
|
|
718
|
+
onError?: (err: unknown) => void;
|
|
719
|
+
/**
|
|
720
|
+
* The current offline tail (doc 15 §2), read at write time. The
|
|
721
|
+
* connection's exportBundle cannot know it (the tail is recorded by
|
|
722
|
+
* the layer ABOVE the connection, across connection rebuilds), so the
|
|
723
|
+
* owner of the tail injects a getter. Returning an empty tail (or
|
|
724
|
+
* omitting the getter) ERASES any stored tail — which is exactly
|
|
725
|
+
* right once a replay has drained it.
|
|
726
|
+
*/
|
|
727
|
+
offlineTail?: () => {
|
|
728
|
+
tail: Intent[];
|
|
729
|
+
epoch?: string;
|
|
730
|
+
} | null;
|
|
731
|
+
});
|
|
732
|
+
private get throttleMs();
|
|
733
|
+
private now;
|
|
734
|
+
/** State changed — write now if the window allows, else arm ONE trailing
|
|
735
|
+
* write for the window's end (never more than one timer in flight; N
|
|
736
|
+
* notifies inside a window coalesce into a single trailing write). */
|
|
737
|
+
notify(): void;
|
|
738
|
+
/** Immediate best-effort write (pagehide/visibilitychange/session-end).
|
|
739
|
+
* Awaits ALL in-flight writes plus this one so the latest state is
|
|
740
|
+
* durably landed before the tab goes away. */
|
|
741
|
+
flush(): Promise<void>;
|
|
742
|
+
/** Append a write to the serial chain (never overlapping). */
|
|
743
|
+
private enqueueWrite;
|
|
744
|
+
/** Detach (unmount): cancel the trailing timer; no further writes. */
|
|
745
|
+
stop(): void;
|
|
746
|
+
private write;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Whether this client's edits can currently reach the sequencer.
|
|
751
|
+
*
|
|
752
|
+
* - `live` the round trip completed recently; submits will land.
|
|
753
|
+
* - `reconnecting` the socket is gone and a retry is scheduled. TRANSIENT and
|
|
754
|
+
* usually invisible — a laptop lid closed for ten seconds
|
|
755
|
+
* passes through here and back to `live` without the user
|
|
756
|
+
* noticing.
|
|
757
|
+
* - `lost` the retries are exhausted. Needs a human: reconnect or
|
|
758
|
+
* reload.
|
|
759
|
+
*
|
|
760
|
+
* A NON-LIVE STATE IS NOT A WRITE GATE (doc 12 §5: offline editing is
|
|
761
|
+
* first-class). What consumers MUST guarantee on anything but `live` is that
|
|
762
|
+
* nothing typed can be LOST: edits apply locally and are recorded to the
|
|
763
|
+
* durable offline tail (doc 15 §2) for replay on reconnect — the react
|
|
764
|
+
* layer's capture path does exactly this. The write gate (`writesBlocked`)
|
|
765
|
+
* stays reserved for server REFUSALS, where accepting a keystroke really is
|
|
766
|
+
* accepting-then-losing it. An editable surface whose disconnected edits are
|
|
767
|
+
* neither kept nor gated remains the silent-loss bug this type was created
|
|
768
|
+
* to surface — keep them either captured or blocked, never dropped.
|
|
769
|
+
*/
|
|
770
|
+
type ConnectionState = "live" | "reconnecting" | "lost";
|
|
771
|
+
interface LivenessOptions {
|
|
772
|
+
/** Gap between probes once the previous one was answered. */
|
|
773
|
+
intervalMs?: number;
|
|
774
|
+
/** How long a probe may go unanswered before the socket is declared dead. */
|
|
775
|
+
timeoutMs?: number;
|
|
776
|
+
setTimer?: (fn: () => void, ms: number) => unknown;
|
|
777
|
+
clearTimer?: (t: unknown) => void;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* The canonical document hash (plan doc 05): sha256 over the
|
|
782
|
+
* deterministically-serialized editable roots in fixed part order.
|
|
783
|
+
* `serializeXml` is a pure function of the tree (attribute order preserved
|
|
784
|
+
* from parse, fixed escaping, no environment reads), so replicas that hold
|
|
785
|
+
* the same document produce the same hash on any engine — which is what
|
|
786
|
+
* makes the hash a convergence VERIFIER rather than a hope.
|
|
787
|
+
*
|
|
788
|
+
* Doc-05 scope note: the full recipe also folds in relationship roots,
|
|
789
|
+
* content-types, and declared media shas (package state is
|
|
790
|
+
* divergence-capable). Those parts are immutable in the demo's enabled
|
|
791
|
+
* intent surface (media is gated off; no intent edits rels/content-types
|
|
792
|
+
* outside media), so the editable roots are the complete divergence surface
|
|
793
|
+
* today — the fold-in lands with the media gate (doc 16).
|
|
794
|
+
*/
|
|
795
|
+
declare function docHash(doc: DocxDocument): Promise<string>;
|
|
796
|
+
|
|
797
|
+
declare class IndexedDbBundleStore implements BundleStore {
|
|
798
|
+
private dbName;
|
|
799
|
+
private db;
|
|
800
|
+
private persistRequested;
|
|
801
|
+
constructor(dbName?: string);
|
|
802
|
+
private open;
|
|
803
|
+
/** One request in one transaction, as a promise. */
|
|
804
|
+
private tx;
|
|
805
|
+
get(docId: string): Promise<DocBundle | null>;
|
|
806
|
+
put(bundle: DocBundle): Promise<void>;
|
|
807
|
+
delete(docId: string): Promise<void>;
|
|
808
|
+
list(): Promise<StoredDocSummary[]>;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* React binding for wordinweb collaboration. Imported from the SEPARATE
|
|
813
|
+
* `wordinweb/collab` entry so that non-collab apps never pull the collab
|
|
814
|
+
* engine into their bundle (plan doc 07: unreachable beats shakeable — a
|
|
815
|
+
* local-only `import { DocxView } from "wordinweb"` has no path to this file).
|
|
816
|
+
*/
|
|
817
|
+
interface UseCollabOptions {
|
|
818
|
+
/** WebSocket URL of the collab server (ws://… / wss://…). */
|
|
819
|
+
url: string;
|
|
820
|
+
/** Document id to join (the magic-link id, plan doc 11). */
|
|
821
|
+
docId: string;
|
|
822
|
+
/** This client's stable id (persist per browser/tab). */
|
|
823
|
+
clientId: string;
|
|
824
|
+
/** Optional auth token (JWT minted by the app's backend, plan doc 07). */
|
|
825
|
+
token?: string;
|
|
826
|
+
/** Construct the socket. Defaults to `new WebSocket(url)`; injectable for
|
|
827
|
+
* tests / custom transports. */
|
|
828
|
+
createSocket?: (url: string) => WebSocket;
|
|
829
|
+
/**
|
|
830
|
+
* Bundle persistence + resume (plan doc 12 §4/§5). When set, the hook:
|
|
831
|
+
* resumes from a stored bundle if one exists (replaying pending intents,
|
|
832
|
+
* idempotently) instead of joining cold; persists the confirmed state on a
|
|
833
|
+
* ~1s throttle after every change, plus a best-effort flush on pagehide/
|
|
834
|
+
* hidden; and on an epoch change (someone re-seeded while away) preserves
|
|
835
|
+
* the superseded bundle as a draft (`<docId>#draft-<oldGenesis>`) before
|
|
836
|
+
* adopting the server's state — the fork rule made visible, never a silent
|
|
837
|
+
* merge. Browser apps pass an `IndexedDbBundleStore` (from `wordinweb`);
|
|
838
|
+
* tests inject the in-memory store. Without it, behavior is unchanged
|
|
839
|
+
* (join cold, keep nothing).
|
|
840
|
+
*/
|
|
841
|
+
store?: BundleStore;
|
|
842
|
+
/** Claim the identity from an existing live connection (doc 12 §7 "use
|
|
843
|
+
* here instead"): set after an `already-open` refusal and remount. */
|
|
844
|
+
takeover?: boolean;
|
|
845
|
+
/**
|
|
846
|
+
* E2EE mode (doc 13): the document master key from the link's `#k=`
|
|
847
|
+
* fragment. Its PRESENCE selects the encrypted connection — mode is
|
|
848
|
+
* derived from the link, never the wire; a plaintext welcome is
|
|
849
|
+
* hard-refused (`mode-downgrade`). */
|
|
850
|
+
docKey?: string;
|
|
851
|
+
/** Share code (doc 13 §7) when the doc has one — stretched client-side
|
|
852
|
+
* and mixed into key derivation + sent as the hello proof. */
|
|
853
|
+
shareCode?: string;
|
|
854
|
+
/** Owner capability token (doc 14 §2.5): held by the seeder (returned by
|
|
855
|
+
* go-live, kept in their bundle), never in the shared link. Presence of
|
|
856
|
+
* a valid token unlocks the admin controls. */
|
|
857
|
+
ownerToken?: string;
|
|
858
|
+
/**
|
|
859
|
+
* HTTP origin of the media relay (plan doc 16 §3), e.g.
|
|
860
|
+
* "http://localhost:1234" — the same server the WebSocket points at.
|
|
861
|
+
* Image bytes travel over these routes and NEVER over the sequencer.
|
|
862
|
+
* Without it the room has no media duties and the image toolbar stays
|
|
863
|
+
* inert, so an app that doesn't want media simply omits it.
|
|
864
|
+
*/
|
|
865
|
+
httpBase?: string;
|
|
866
|
+
/** Display profile sent at join (doc 14 §2) — self-asserted; persist it in
|
|
867
|
+
* localStorage next to the clientId so identity is stable per browser. */
|
|
868
|
+
profile?: ParticipantProfile;
|
|
869
|
+
/**
|
|
870
|
+
* Heartbeat + reconnect tuning. Defaults suit a real network (15s probe,
|
|
871
|
+
* 10s answer deadline, 6 retries); tests inject short values and a fake
|
|
872
|
+
* clock. `maxRetries` is how many backoff attempts run before the session
|
|
873
|
+
* gives up and reports `lost` — the point at which a human has to act.
|
|
874
|
+
*/
|
|
875
|
+
liveness?: LivenessOptions & {
|
|
876
|
+
maxRetries?: number;
|
|
877
|
+
};
|
|
878
|
+
/**
|
|
879
|
+
* Bound on the offline tail (doc 15 §4.3) — how many intents may be
|
|
880
|
+
* recorded while disconnected before the editor gates read-only with an
|
|
881
|
+
* explicit "offline limit" state. Defaults to OFFLINE_TAIL_CAP; injectable
|
|
882
|
+
* so tests can reach the cap without recording thousands of edits.
|
|
883
|
+
*/
|
|
884
|
+
offlineTailCap?: number;
|
|
885
|
+
}
|
|
886
|
+
interface CollabSession {
|
|
887
|
+
/** The live document to render (null until the welcome arrives). */
|
|
888
|
+
doc: DocxDocument | null;
|
|
889
|
+
/** Monotonically increases on every reconciled change — a cheap re-render
|
|
890
|
+
* signal (the doc object may be reloaded by reconciliation). */
|
|
891
|
+
version: number;
|
|
892
|
+
/**
|
|
893
|
+
* Increases only when the RENDERED DOCUMENT changed outside the editor —
|
|
894
|
+
* remote applies, optimistic toolbar ops, reloads, self-heals, media
|
|
895
|
+
* installs. The editor-driven typing path (submit + own echo) leaves it
|
|
896
|
+
* untouched: the editor already mutated AND painted the doc, so repainting
|
|
897
|
+
* again on `version` queued a redundant whole-document relayout per
|
|
898
|
+
* keystroke — invisible on 5 pages, catastrophic past DocxView's
|
|
899
|
+
* background-layout threshold (a 500-page doc relaid out asynchronously
|
|
900
|
+
* behind an inert container, per keystroke). DocxView's `renderSignal`
|
|
901
|
+
* rides THIS; `version` remains the observer/bookkeeping signal.
|
|
902
|
+
*/
|
|
903
|
+
renderVersion: number;
|
|
904
|
+
/**
|
|
905
|
+
* Drain the union of the dirty scopes behind `renderVersion` since the last
|
|
906
|
+
* take — what the repaint answering it must relayout. A remote text edit
|
|
907
|
+
* reports its one paragraph, so DocxView relayouts that paragraph
|
|
908
|
+
* incrementally instead of the whole document (the far-page repaint stall);
|
|
909
|
+
* structural or unverifiable intents, doc reloads, and media installs
|
|
910
|
+
* report `doc`, which keeps today's whole-document path. Null means nothing
|
|
911
|
+
* dirty was recorded since the last take (an earlier paint covered it) and
|
|
912
|
+
* the repaint may be skipped. Consumed on the repaint itself so a coalesced
|
|
913
|
+
* repaint sees every batched intent's scope.
|
|
914
|
+
*/
|
|
915
|
+
takeRenderScope: () => Scope | null;
|
|
916
|
+
/** Increases only when reconciliation RELOADED the document (a true
|
|
917
|
+
* conflict). The editor re-mounts on this; between reloads it updates in
|
|
918
|
+
* place (no flash for the common non-conflicting edits). */
|
|
919
|
+
docEpoch: number;
|
|
920
|
+
/** True once joined and the snapshot is loaded. */
|
|
921
|
+
ready: boolean;
|
|
922
|
+
/** Submit a local edit the editor ALREADY applied to the live doc
|
|
923
|
+
* (bookkeeping filled by the connection; no local re-apply). */
|
|
924
|
+
submit: (intent: Omit<Intent, "clientId" | "clientSeq" | "base">) => void;
|
|
925
|
+
/** Submit an operation NOT yet applied locally: it is applied optimistically
|
|
926
|
+
* through the same canonical applyIntent code the server runs, so the local
|
|
927
|
+
* result is byte-identical to every replica by construction. Used for
|
|
928
|
+
* toolbar/API commands (insert chart, set link, page layout, ...). */
|
|
929
|
+
submitOp: (intent: Omit<Intent, "clientId" | "clientSeq" | "base">) => void;
|
|
930
|
+
/** Broadcast this client's cursor/selection. */
|
|
931
|
+
setPresence: (pos: PresencePosition | null) => void;
|
|
932
|
+
/** Allocate carried node ids (sub-range format / split / insert). */
|
|
933
|
+
allocIds: (n: number) => number[];
|
|
934
|
+
/** Remote participants' latest cursor/selection positions. */
|
|
935
|
+
presence: Record<string, PresencePosition | null>;
|
|
936
|
+
/** Set if the server refused the connection (e.g. version mismatch). */
|
|
937
|
+
refused: string | null;
|
|
938
|
+
/**
|
|
939
|
+
* WHETHER THIS CLIENT CAN REACH THE SEQUENCER AT ALL.
|
|
940
|
+
*
|
|
941
|
+
* Distinct from `refused`, which means the server answered and said no —
|
|
942
|
+
* this means nobody answered. A refusal ends the session; this is a
|
|
943
|
+
* transport fault that usually heals by itself.
|
|
944
|
+
*
|
|
945
|
+
* - `live` the last heartbeat round trip completed.
|
|
946
|
+
* - `reconnecting` the socket dropped; a backoff retry is scheduled. Most
|
|
947
|
+
* drops pass through here and back to `live` unnoticed,
|
|
948
|
+
* so UI should stay quiet or subtle here.
|
|
949
|
+
* - `lost` retries exhausted. Show something modal: the document is
|
|
950
|
+
* safe but nothing further will sync until a human acts.
|
|
951
|
+
*
|
|
952
|
+
* A NON-LIVE CONNECTION DOES NOT BLOCK WRITES (doc 12 §5: offline editing
|
|
953
|
+
* is first-class). "I cannot reach the server" is a different fact from
|
|
954
|
+
* "the server said no": nothing typed offline is doomed — it applies
|
|
955
|
+
* locally, is recorded to the offline tail, persists with the bundle, and
|
|
956
|
+
* replays on reconnect (doc 15's arrival ladder). {@link offline} is the
|
|
957
|
+
* state a consumer renders for it; {@link writesBlocked} stays reserved
|
|
958
|
+
* for genuine refusals, where accepting keystrokes would be a lie.
|
|
959
|
+
*/
|
|
960
|
+
connection: ConnectionState;
|
|
961
|
+
/**
|
|
962
|
+
* OFFLINE EDITING IS ACTIVE (doc 15 §2 / doc 12 §5): the sequencer is
|
|
963
|
+
* unreachable, a document is in hand, and nothing has refused this
|
|
964
|
+
* client's writes — so edits apply locally and accumulate in the durable
|
|
965
|
+
* offline tail instead of being dropped.
|
|
966
|
+
*
|
|
967
|
+
* - `editsHeld` how many intents the tail currently holds. Show it: the
|
|
968
|
+
* difference between "editing is paused" and "your changes are being
|
|
969
|
+
* kept (N so far)" is the whole point of the state.
|
|
970
|
+
* - `capped` the tail hit its bound (doc 15 §4.3). Recording has STOPPED
|
|
971
|
+
* and {@link writesBlocked} is raised — loudly, with this flag saying
|
|
972
|
+
* why, never by silently discarding keystrokes. The escape hatches are
|
|
973
|
+
* reconnecting or downloading a copy.
|
|
974
|
+
*
|
|
975
|
+
* Null while the connection is live, and null when a refusal already
|
|
976
|
+
* blocks writes (a viewer's link does not become writable by going
|
|
977
|
+
* through a tunnel). On reconnect the tail replays: silently as ordinary
|
|
978
|
+
* edits when the session is the same epoch, else via {@link arrival}.
|
|
979
|
+
*/
|
|
980
|
+
offline: {
|
|
981
|
+
editsHeld: number;
|
|
982
|
+
capped: boolean;
|
|
983
|
+
} | null;
|
|
984
|
+
/**
|
|
985
|
+
* Retry the connection immediately, resetting the backoff and the attempt
|
|
986
|
+
* budget. Wired to a "Try again" control on the `lost` UI — from `lost`
|
|
987
|
+
* nothing else will ever retry, because exhausting the budget is precisely
|
|
988
|
+
* the statement that automatic recovery has stopped.
|
|
989
|
+
*/
|
|
990
|
+
reconnect: () => void;
|
|
991
|
+
/**
|
|
992
|
+
* The server announced it will END this session, and this is the grace
|
|
993
|
+
* period before it does. `inMs` is the measured remainder at the moment the
|
|
994
|
+
* warning arrived — tick a countdown from it locally rather than expecting
|
|
995
|
+
* refreshed messages.
|
|
996
|
+
*
|
|
997
|
+
* - `idle` cancellable: any qualifying activity (an accepted edit, an
|
|
998
|
+
* admin action, a media transfer, a join — never presence)
|
|
999
|
+
* resets the clock and this goes back to null on its own.
|
|
1000
|
+
* - `lifetime` NOT cancellable by anything; it counts down to the ending.
|
|
1001
|
+
*
|
|
1002
|
+
* Null whenever no deadline is approaching — including after ANY session
|
|
1003
|
+
* end (kick, refusal), because a countdown that outlived its session would
|
|
1004
|
+
* keep promising an ending that already happened.
|
|
1005
|
+
*/
|
|
1006
|
+
sessionWarning: {
|
|
1007
|
+
reason: "idle" | "lifetime";
|
|
1008
|
+
inMs: number;
|
|
1009
|
+
} | null;
|
|
1010
|
+
/** True after an edit was refused under the owner's read-only lock
|
|
1011
|
+
* (doc 14 §2.5). NON-FATAL: the live view keeps working.
|
|
1012
|
+
*
|
|
1013
|
+
* LEGACY/FALLBACK now: sticky until reload or `retryWrites`, because a
|
|
1014
|
+
* refusal can only ever say "blocked". Prefer {@link writesBlocked}, which
|
|
1015
|
+
* reads the server's positive roster status when one is published and only
|
|
1016
|
+
* falls back to this against an older server. */
|
|
1017
|
+
readOnlyBlocked: boolean;
|
|
1018
|
+
/**
|
|
1019
|
+
* The server has told this client it is NOT the owner of this document.
|
|
1020
|
+
*
|
|
1021
|
+
* Holding an `ownerToken` string is not the same as it being valid — a room
|
|
1022
|
+
* re-seeded into a new epoch mints a fresh one, leaving the old token
|
|
1023
|
+
* truthy in a browser that now has no admin rights. The welcome carries no
|
|
1024
|
+
* owner flag, so until an admin op is attempted this is genuinely unknown;
|
|
1025
|
+
* a `not-owner` refusal is the authoritative answer.
|
|
1026
|
+
*
|
|
1027
|
+
* Consumers should HIDE admin controls when this is true rather than
|
|
1028
|
+
* disable them: an offered control that cannot work is worse than an absent
|
|
1029
|
+
* one, and the refusal it produces is not a session failure.
|
|
1030
|
+
*/
|
|
1031
|
+
notOwner: boolean;
|
|
1032
|
+
/**
|
|
1033
|
+
* THE SERVER WILL NOT ACCEPT THIS CLIENT'S WRITES. Consumers must render the
|
|
1034
|
+
* editor READ-ONLY on this, never merely annotate it: an editable surface
|
|
1035
|
+
* over a server that refuses the writes applies every keystroke locally and
|
|
1036
|
+
* then loses it, which is silent data loss that looks like it worked.
|
|
1037
|
+
*
|
|
1038
|
+
* One predicate rather than a per-reason check, so a new blocking state
|
|
1039
|
+
* inherits the gate instead of needing to remember it.
|
|
1040
|
+
*
|
|
1041
|
+
* NOW READS THE SERVER'S ROSTER STATUS (`RosterEntry.write`), which closed
|
|
1042
|
+
* the seam this comment used to describe. That matters in both directions:
|
|
1043
|
+
* the status is present AT JOIN, so edit 1 is gated rather than applied and
|
|
1044
|
+
* then healed away; and it is POSITIVE, so a lift arrives on its own instead
|
|
1045
|
+
* of leaving the client in viewer mode until reload. Against a server that
|
|
1046
|
+
* publishes no status it falls back to the refusal-derived flag — never to
|
|
1047
|
+
* "allowed", which would restore both bugs at once.
|
|
1048
|
+
*
|
|
1049
|
+
* The status also distinguishes the three server conditions the single
|
|
1050
|
+
* `read-only` refusal could not (owner lock, per-client demotion, viewer
|
|
1051
|
+
* token), so a UI can finally say which one applies rather than inventing a
|
|
1052
|
+
* distinction it could not see.
|
|
1053
|
+
*
|
|
1054
|
+
* A DEAD CONNECTION IS DELIBERATELY NOT A BLOCK — that is the refusal /
|
|
1055
|
+
* unreachable split (doc 12 §5). This predicate means "nothing you type
|
|
1056
|
+
* can ever land"; a dropped socket means "nothing you type can land YET",
|
|
1057
|
+
* and the offline tail is what keeps the two honest: disconnected edits
|
|
1058
|
+
* apply locally, persist durably, and replay on reconnect, so accepting
|
|
1059
|
+
* them is not a lie. The one offline condition that does fold in is the
|
|
1060
|
+
* tail CAP ({@link offline}.capped): past it an edit genuinely cannot be
|
|
1061
|
+
* kept, so the gate closes loudly rather than recording silently stopping.
|
|
1062
|
+
* An earlier revision folded every non-live connection in here; that
|
|
1063
|
+
* over-reached — it was written for server refusals and extended to
|
|
1064
|
+
* transport faults, which made first-class offline editing impossible.
|
|
1065
|
+
*
|
|
1066
|
+
* A REFUSED SESSION FOLDS IN TOO, by the same rule. A refusal ends the
|
|
1067
|
+
* session — no socket, nothing to sequence against — so any document still
|
|
1068
|
+
* on screen (e.g. behind a "session ended" dialog) must be read-only, and
|
|
1069
|
+
* it inherits that from this predicate rather than from a refusal-specific
|
|
1070
|
+
* gate someone could forget to compose.
|
|
1071
|
+
*/
|
|
1072
|
+
writesBlocked: boolean;
|
|
1073
|
+
/**
|
|
1074
|
+
* WHY the server refuses this client's writes, straight from the roster.
|
|
1075
|
+
*
|
|
1076
|
+
* UNDEFINED means the server published nothing (an older build), NOT that
|
|
1077
|
+
* writing is allowed — the same contract as the media limit's null. A
|
|
1078
|
+
* consumer must keep its copy generic in that case rather than naming a
|
|
1079
|
+
* cause it was never told.
|
|
1080
|
+
*
|
|
1081
|
+
* The three causes are genuinely different to the person reading the banner:
|
|
1082
|
+
* `owner-lock` may lift at any moment, `demoted` is about them specifically,
|
|
1083
|
+
* and `viewer-role` is a property of their link that nothing the owner does
|
|
1084
|
+
* in-session will change. Telling that last group to wait for the owner is
|
|
1085
|
+
* telling them to wait for something that will never happen.
|
|
1086
|
+
*/
|
|
1087
|
+
writeStatus?: WriteStatus;
|
|
1088
|
+
/**
|
|
1089
|
+
* Optimistically clear the FALLBACK block and let the user try again.
|
|
1090
|
+
*
|
|
1091
|
+
* BELT AND BRACES now, not the primary escape: where the server publishes a
|
|
1092
|
+
* write status, a lift arrives on its own and the editor becomes writable
|
|
1093
|
+
* without anyone clicking anything. This remains for the older-server
|
|
1094
|
+
* fallback path — where the block is sticky, no lift is announced, and
|
|
1095
|
+
* attempting a write is the only way to discover the lock is gone — and as a
|
|
1096
|
+
* manual override if a status update is ever missed.
|
|
1097
|
+
*/
|
|
1098
|
+
retryWrites: () => void;
|
|
1099
|
+
/**
|
|
1100
|
+
* Set when a resume landed in a different epoch than the stored bundle
|
|
1101
|
+
* (doc 12 §5 case 2): the session took the server's state; the old copy
|
|
1102
|
+
* was saved as a draft. UI copy: "restored by another participant — your
|
|
1103
|
+
* offline copy is saved as a draft." Null otherwise.
|
|
1104
|
+
*/
|
|
1105
|
+
epochChanged: {
|
|
1106
|
+
from: string;
|
|
1107
|
+
to: string;
|
|
1108
|
+
} | null;
|
|
1109
|
+
/** Session roster (doc 14 §2): everyone who joined this session, with
|
|
1110
|
+
* connection state — the identity keyspace presence/attribution share. */
|
|
1111
|
+
roster: RosterEntry[];
|
|
1112
|
+
/** Rename/recolor this participant (server sanitizes + fans out). */
|
|
1113
|
+
setProfile: (profile: ParticipantProfile) => void;
|
|
1114
|
+
/** Attribution layer 1 (doc 14 §3): recent applied entries
|
|
1115
|
+
* {seq, clientId, kind} — join clientId to `roster` for names/colors. */
|
|
1116
|
+
activity: {
|
|
1117
|
+
seq: number;
|
|
1118
|
+
clientId: string;
|
|
1119
|
+
kind: string;
|
|
1120
|
+
}[];
|
|
1121
|
+
/** Owner admin ops (doc 14 §2.5) — no-op unless this connection proved
|
|
1122
|
+
* the owner token; the server refuses `not-owner` otherwise. */
|
|
1123
|
+
admin: (action: {
|
|
1124
|
+
op: "kick";
|
|
1125
|
+
clientId: string;
|
|
1126
|
+
} | {
|
|
1127
|
+
op: "readOnly";
|
|
1128
|
+
on: boolean;
|
|
1129
|
+
} | {
|
|
1130
|
+
op: "setRole";
|
|
1131
|
+
clientId: string;
|
|
1132
|
+
role: "editor" | "viewer";
|
|
1133
|
+
}) => void;
|
|
1134
|
+
/**
|
|
1135
|
+
* Offline reconciliation (doc 15 arrival ladder). When a rejoin lands in
|
|
1136
|
+
* a DIFFERENT epoch than the tail was recorded against, the recommended
|
|
1137
|
+
* mode is `suggest` (replay as tracked changes; ≤ the suggest threshold)
|
|
1138
|
+
* or `draft` (large tails). `structural` counts the tail intents that
|
|
1139
|
+
* have no suggestion form (splits/merges/tables/format — rebase.ts):
|
|
1140
|
+
* they are NOT replayed by `suggest` and survive in the banked draft
|
|
1141
|
+
* slot instead — say so, or their absence reads as silent loss. A
|
|
1142
|
+
* same-epoch rejoin never surfaces here: its tail replays silently
|
|
1143
|
+
* through the ordinary submit path, like a large pending queue.
|
|
1144
|
+
* Null when there's nothing to reconcile. `reconcile()` runs the choice.
|
|
1145
|
+
*/
|
|
1146
|
+
arrival: {
|
|
1147
|
+
mode: "suggest" | "draft";
|
|
1148
|
+
tailLength: number;
|
|
1149
|
+
structural: number;
|
|
1150
|
+
} | null;
|
|
1151
|
+
reconcile: (mode: "suggest" | "draft") => void;
|
|
1152
|
+
/** How many times the connection self-healed a drifted optimistic replica
|
|
1153
|
+
* (encrypted mode's quiescent mirror check — the B6a class). Telemetry;
|
|
1154
|
+
* the heal itself is automatic and already reflected in doc/docEpoch. */
|
|
1155
|
+
selfHeals: number;
|
|
1156
|
+
/**
|
|
1157
|
+
* Submits the connection DROPPED because it was not ready yet (no replica
|
|
1158
|
+
* to apply against, no confirmed seq, no key to seal with). Should stay 0:
|
|
1159
|
+
* the editor is gated on `ready`, so nothing user-driven can reach that
|
|
1160
|
+
* guard. A non-zero value means some caller is submitting before the
|
|
1161
|
+
* welcome — which is a silent edit loss, and was the shape of B13's
|
|
1162
|
+
* 231-intent gap. Telemetry, deliberately visible rather than swallowed.
|
|
1163
|
+
*/
|
|
1164
|
+
droppedPreReady: number;
|
|
1165
|
+
/**
|
|
1166
|
+
* Persistence writes that FAILED (quota exceeded, blocked storage, private
|
|
1167
|
+
* mode). Non-zero means this browser's stored bundle is stale — and in a
|
|
1168
|
+
* zero-custody design that bundle IS the durable copy, so "saved" is
|
|
1169
|
+
* currently a lie. Consumers should SHOW this, not just log it: the user
|
|
1170
|
+
* can still rescue the document with the download button, but only if they
|
|
1171
|
+
* are told. Sticky (never decremented): the doubt persists until reload.
|
|
1172
|
+
*/
|
|
1173
|
+
persistErrors: number;
|
|
1174
|
+
/**
|
|
1175
|
+
* The connect-path storage read exceeded its deadline, so this session
|
|
1176
|
+
* joined COLD instead of resuming. Says nothing about whether storage is
|
|
1177
|
+
* full — a slow or blocked store looks identical from here, and
|
|
1178
|
+
* conflating the two with {@link persistErrors} told one user their
|
|
1179
|
+
* storage was full when it held a few megabytes.
|
|
1180
|
+
*/
|
|
1181
|
+
storeSlow: boolean;
|
|
1182
|
+
/**
|
|
1183
|
+
* Submits whose seal or transport send THREW after the edit was already
|
|
1184
|
+
* applied optimistically — i.e. lost edits, counted at the far end of the
|
|
1185
|
+
* path from droppedPreReady.
|
|
1186
|
+
*
|
|
1187
|
+
* The two together are a PARTITION, which is the point: droppedPreReady is
|
|
1188
|
+
* "refused before it was ever applied", this is "accepted locally then lost
|
|
1189
|
+
* on the way out". A swarm run that loses N intents can now subtract both
|
|
1190
|
+
* from N and see what remains unexplained, instead of a night of guessing —
|
|
1191
|
+
* see B13, where exactly that arithmetic was missing.
|
|
1192
|
+
*/
|
|
1193
|
+
sendFailures: number;
|
|
1194
|
+
/** Upload image bytes to the relay and get the address fields an
|
|
1195
|
+
* insertImage intent must carry (doc 16 §5.1). Null = the relay refused,
|
|
1196
|
+
* and the caller must not reserve anything. Always null when the app
|
|
1197
|
+
* supplied no `httpBase`. */
|
|
1198
|
+
uploadMedia: (bytes: Uint8Array) => Promise<{
|
|
1199
|
+
blobSha: string;
|
|
1200
|
+
bytesLen: number;
|
|
1201
|
+
iv?: string;
|
|
1202
|
+
} | null>;
|
|
1203
|
+
/**
|
|
1204
|
+
* Largest single upload the relay accepts, in bytes, as published in the
|
|
1205
|
+
* welcome — so an oversized file can be refused locally instead of after a
|
|
1206
|
+
* full seal-hash-upload round trip.
|
|
1207
|
+
*
|
|
1208
|
+
* NULL MEANS "NO PUBLISHED LIMIT", and the only correct response to it is to
|
|
1209
|
+
* skip the check: a server that publishes nothing still enforces its real
|
|
1210
|
+
* limit, so a client that invents a default either blocks uploads that
|
|
1211
|
+
* would have succeeded or promises a size that will be refused.
|
|
1212
|
+
*/
|
|
1213
|
+
mediaMaxBlobBytes: number | null;
|
|
1214
|
+
/**
|
|
1215
|
+
* Reverse this participant's last SEQUENCED action (plan doc 03 Phase 8).
|
|
1216
|
+
* The inverse is computed locally and submitted as an ORDINARY intent, so
|
|
1217
|
+
* it converges, broadcasts, and — if the target has since been deleted or
|
|
1218
|
+
* changed — is rejected cleanly on every replica alike.
|
|
1219
|
+
*
|
|
1220
|
+
* The outcome is reported rather than thrown so the UI can say the right
|
|
1221
|
+
* thing: `cannot-undo` means the last action has no inverse yet (a
|
|
1222
|
+
* different message from having nothing to undo), and `unavailable` means
|
|
1223
|
+
* this connection has no collaborative undo at all — today the plaintext
|
|
1224
|
+
* connection, whose authority lives on the server.
|
|
1225
|
+
*/
|
|
1226
|
+
undoLast: () => UndoOutcome;
|
|
1227
|
+
}
|
|
1228
|
+
declare function useCollab(opts: UseCollabOptions): CollabSession;
|
|
1229
|
+
/**
|
|
1230
|
+
* A complete collaborative editor: joins a document over the collab server and
|
|
1231
|
+
* renders its live, reconciled state, forwarding local edits as intents. This
|
|
1232
|
+
* is the inbound + outbound integration composed for you — the app supplies
|
|
1233
|
+
* only the connection params.
|
|
1234
|
+
*
|
|
1235
|
+
* Rendering strategy: DocxView renders the replica's LIVE document object
|
|
1236
|
+
* directly (`collab.doc`). Broadcasts mutate that same instance in place and
|
|
1237
|
+
* bump `version`, which DocxView receives as `renderSignal` and turns into a
|
|
1238
|
+
* single in-place repaint — no per-broadcast serialize/parse round-trip. The
|
|
1239
|
+
* `source` bytes are computed only on a true-conflict reload (keyed on
|
|
1240
|
+
* `docEpoch`) as the placeholder/fallback. The visual rendering runs in the
|
|
1241
|
+
* browser (as all of DocxView does); the protocol/convergence/binding it rides
|
|
1242
|
+
* on are covered by the headless test suites.
|
|
1243
|
+
*/
|
|
1244
|
+
/** Toolbar groups OFF by default in the collab editor. Uploads (image/icon/
|
|
1245
|
+
* screenshot/3D/media/object) are excluded per the demo threat model (no
|
|
1246
|
+
* object upload → no stored-payload surface); the rest are commands not yet
|
|
1247
|
+
* routed through intents — showing them would make local-only edits that
|
|
1248
|
+
* silently diverge from other participants. Flip any of these on explicitly
|
|
1249
|
+
* via `toolbarFeatures` once wired. */
|
|
1250
|
+
declare const COLLAB_TOOLBAR_DEFAULTS: Partial<Record<ToolbarFeature, boolean>>;
|
|
1251
|
+
declare function CollabEditor(opts: UseCollabOptions & {
|
|
1252
|
+
editable?: boolean;
|
|
1253
|
+
/** Render the Word-style ribbon toolbar above the page (default true). */
|
|
1254
|
+
toolbar?: boolean;
|
|
1255
|
+
/** Ribbon mode: "simple" (Home only) or "advanced" (default). */
|
|
1256
|
+
toolbarMode?: ToolbarMode;
|
|
1257
|
+
/** Per-group overrides merged over the collab-safe defaults. */
|
|
1258
|
+
toolbarFeatures?: Partial<Record<ToolbarFeature, boolean>>;
|
|
1259
|
+
/** Observe the imperative document API (find/replace, inserts, ...). */
|
|
1260
|
+
onReady?: (api: DocxViewApi) => void;
|
|
1261
|
+
/** Observe the live CollabSession (roster, activity, epochChanged,
|
|
1262
|
+
* doc-for-download) — how an app shell renders chips/banners/buttons
|
|
1263
|
+
* around the editor without re-implementing its composition. */
|
|
1264
|
+
onSession?: (session: CollabSession) => void;
|
|
1265
|
+
/** Custom refusal UI (e.g. already-open -> "use here instead",
|
|
1266
|
+
* no-session -> "bring it back live"). Default: a refresh notice.
|
|
1267
|
+
*
|
|
1268
|
+
* `ctx.docVisible` says which surface the content lands on. False (the
|
|
1269
|
+
* pre-live refusals: already-open, room-full, version mismatch, a code
|
|
1270
|
+
* gate): the content IS the page, exactly as before — render a full
|
|
1271
|
+
* screen. True (post-live refusals: idle-timeout, session-expired): the
|
|
1272
|
+
* document is still in hand and stays on screen READ-ONLY behind the
|
|
1273
|
+
* content, which is rendered in an overlay covering it — render a
|
|
1274
|
+
* dialog/scrim, not a full page. Existing one-argument hosts keep their
|
|
1275
|
+
* old markup untouched; the second argument only ADDS the information. */
|
|
1276
|
+
refusedContent?: (reason: string, ctx?: {
|
|
1277
|
+
docVisible: boolean;
|
|
1278
|
+
}) => ReactNode;
|
|
1279
|
+
}): ReactNode;
|
|
1280
|
+
|
|
1281
|
+
export { BundlePersister, type BundleStore, COLLAB_TOOLBAR_DEFAULTS, CollabEditor, type CollabSession, type DocBundle, InMemoryBundleStore, IndexedDbBundleStore, type CollabSession as InjectedCollabSession, type ParsedBundleKey, type StoredDocKind, type StoredDocSummary, UndoOutcome, type UseCollabOptions, docHash, draftKey, mediaAddressesOf, parseBundleKey, supersededKey, useCollab, versionKey };
|