edytor 0.1.0-next.21 → 0.1.0-next.23

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.
Files changed (69) hide show
  1. package/README.md +1 -1
  2. package/dist/cloudflare/DocumentRoom.d.ts +302 -10
  3. package/dist/cloudflare/DocumentRoom.js +965 -88
  4. package/dist/cloudflare/index.d.ts +1 -1
  5. package/dist/cloudflare/index.js +1 -1
  6. package/dist/cloudflare/routeDocumentSocket.js +30 -6
  7. package/dist/collaboration/awarenessSelection.d.ts +7 -1
  8. package/dist/collaboration/awarenessSelection.js +7 -1
  9. package/dist/collaboration/index.d.ts +2 -2
  10. package/dist/collaboration/index.js +2 -2
  11. package/dist/collaboration/providers.d.ts +4 -0
  12. package/dist/collaboration/providers.js +4 -0
  13. package/dist/components/Edytor.svelte +14 -2
  14. package/dist/components/Edytor.svelte.d.ts +8 -1
  15. package/dist/crdt/admission.d.ts +6 -1
  16. package/dist/crdt/admission.js +9 -2
  17. package/dist/crdt/data.d.ts +3 -1
  18. package/dist/crdt/data.js +42 -32
  19. package/dist/crdt/document.d.ts +31 -8
  20. package/dist/crdt/document.js +53 -18
  21. package/dist/crdt/edytor-doc.d.ts +31 -3
  22. package/dist/crdt/edytor-doc.js +131 -14
  23. package/dist/crdt/engine.d.ts +6 -0
  24. package/dist/crdt/engine.js +7 -0
  25. package/dist/crdt/flow.d.ts +2 -0
  26. package/dist/crdt/flow.js +2 -2
  27. package/dist/crdt/index.d.ts +20 -9
  28. package/dist/crdt/index.js +3 -3
  29. package/dist/crdt/migration/migrate.js +9 -3
  30. package/dist/crdt/placement/model.d.ts +9 -1
  31. package/dist/crdt/placement/model.js +29 -10
  32. package/dist/crdt/placement/rank.d.ts +20 -0
  33. package/dist/crdt/placement/rank.js +79 -0
  34. package/dist/crdt/protocols/envelope.d.ts +20 -1
  35. package/dist/crdt/protocols/envelope.js +14 -2
  36. package/dist/crdt/providers/container.d.ts +15 -0
  37. package/dist/crdt/providers/container.js +17 -3
  38. package/dist/crdt/providers/index.d.ts +21 -1
  39. package/dist/crdt/providers/index.js +86 -3
  40. package/dist/crdt/providers/indexeddb.js +60 -40
  41. package/dist/crdt/providers/room.d.ts +18 -2
  42. package/dist/crdt/providers/room.js +29 -5
  43. package/dist/crdt/providers/websocket.d.ts +9 -1
  44. package/dist/crdt/providers/websocket.js +21 -6
  45. package/dist/crdt/storage.d.ts +21 -0
  46. package/dist/crdt/storage.js +37 -0
  47. package/dist/crdt/structs.d.ts +17 -0
  48. package/dist/crdt/structs.js +41 -0
  49. package/dist/crdt/text/model.d.ts +9 -1
  50. package/dist/crdt/text/model.js +24 -14
  51. package/dist/crdt/text/rows.d.ts +94 -0
  52. package/dist/crdt/text/rows.js +193 -0
  53. package/dist/crdt/text/runs.d.ts +30 -10
  54. package/dist/crdt/text/runs.js +1235 -264
  55. package/dist/edytor.svelte.d.ts +3 -1
  56. package/dist/edytor.svelte.js +9 -6
  57. package/dist/index.d.ts +1 -1
  58. package/dist/index.js +1 -1
  59. package/dist/plugins/image/ImageEmpty.svelte +19 -6
  60. package/dist/plugins/image/ImagePlugin.svelte +9 -3
  61. package/dist/plugins/image/ImagePlugin.svelte.d.ts +2 -2
  62. package/dist/plugins/image/image.d.ts +16 -0
  63. package/dist/plugins/image/image.js +19 -0
  64. package/dist/plugins/index.d.ts +1 -1
  65. package/dist/plugins/index.js +1 -1
  66. package/dist/plugins.d.ts +11 -0
  67. package/dist/session/commands.d.ts +1 -0
  68. package/dist/session/commands.js +4 -1
  69. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  import { bindIndexeddbProvider } from './indexeddb.js';
2
- import { assertRoomId } from './room.js';
2
+ import { assertRoomId, SyncRefusedError } from './room.js';
3
3
  import { bindWebsocketProvider } from './websocket.js';
4
4
  export const bindProviders = (Y) => {
5
5
  const idb = bindIndexeddbProvider(Y);
@@ -39,7 +39,7 @@ export const bindProviders = (Y) => {
39
39
  const persistName = options.persist === false ? undefined : (options.persistName ?? `edytor:${room}`);
40
40
  const sync = (payload) => {
41
41
  const { doc, awareness, synced, failed, attach, armBound, holdBound } = payload;
42
- const { params, WebSocketPolyfill, maxBackoffTime, connectTimeout, disableBc, onExpired } = options;
42
+ const { params, WebSocketPolyfill, maxBackoffTime, connectTimeout, disableBc, onExpired, maxFrameBytes } = options;
43
43
  const local = persistName !== undefined && typeof indexedDB !== 'undefined'
44
44
  ? localSync(persistName, { disableBc })
45
45
  : undefined;
@@ -49,7 +49,8 @@ export const bindProviders = (Y) => {
49
49
  WebSocketPolyfill,
50
50
  maxBackoffTime,
51
51
  connectTimeout,
52
- disableBc: disableBc || local !== undefined
52
+ disableBc: disableBc || local !== undefined,
53
+ maxFrameBytes
53
54
  });
54
55
  if (onExpired)
55
56
  provider.on('expired', (state) => onExpired(state));
@@ -106,7 +107,89 @@ export const bindProviders = (Y) => {
106
107
  };
107
108
  return Object.assign(sync, { bound: Infinity, target: `websocket:${room}`, persistName });
108
109
  };
110
+ /** `[serverUrl (no trailing slash), room]` of a target, the room id checked. */
111
+ const targetOf = (options) => {
112
+ const [server, room] = 'server' in options ? [options.server, options.room] : [options.serverUrl, options.roomName];
113
+ assertRoomId(room);
114
+ return [server.replace(/\/+$/, ''), room];
115
+ };
116
+ /**
117
+ * Keep a document fresh without opening it (H12): load its local store
118
+ * (the one `createWebsocketSync` keeps, `edytor:<server>/<room>` unless
119
+ * `persistName` names another), dial the room once, take what it lacks
120
+ * and hand it what the store holds that the room lacks, wait until the
121
+ * room stored it, then close. Resolves with whether the room sent
122
+ * anything new; rejects with the room's refusal (`SyncRefusedError`),
123
+ * after `timeout`, or where there is no IndexedDB.
124
+ */
125
+ const prefetch = (options) => {
126
+ const [serverUrl, room] = targetOf(options);
127
+ if (typeof indexedDB === 'undefined') {
128
+ return Promise.reject(new TypeError('prefetch needs IndexedDB to keep the document'));
129
+ }
130
+ const doc = new Y.Doc();
131
+ const local = new idb.IndexeddbPersistence(options.persistName ?? `edytor:${serverUrl}/${room}`, doc, { disableBc: true });
132
+ let socket = null;
133
+ /** Prefetch's one timer: its deadline (`timeout`), cleared when it closes. */
134
+ let deadline;
135
+ const close = async () => {
136
+ clearTimeout(deadline);
137
+ socket?.destroy();
138
+ await local.destroy();
139
+ doc.destroy();
140
+ };
141
+ return new Promise((resolve, reject) => {
142
+ const fail = (error) => void close().then(() => reject(error));
143
+ deadline = setTimeout(() => fail(new Error(`prefetch of ${room} timed out`)), options.timeout ?? 30_000);
144
+ local.whenSynced.then(() => {
145
+ const before = Y.encodeStateVector(doc);
146
+ const provider = (socket = new ws.WebsocketProvider(serverUrl, room, doc, {
147
+ params: options.params,
148
+ WebSocketPolyfill: options.WebSocketPolyfill,
149
+ maxBackoffTime: options.maxBackoffTime,
150
+ connectTimeout: options.connectTimeout,
151
+ maxFrameBytes: options.maxFrameBytes,
152
+ disableBc: true
153
+ }));
154
+ // A prefetch is nobody's presence.
155
+ provider.awareness.setLocalState(null);
156
+ const done = () => {
157
+ if (!provider.synced || !provider.saved)
158
+ return;
159
+ const after = Y.encodeStateVector(doc);
160
+ const updated = after.length !== before.length || after.some((b, i) => b !== before[i]);
161
+ void close().then(() => resolve({ updated }));
162
+ };
163
+ provider.on('synced', done);
164
+ provider.on('saved', done);
165
+ provider.on('refused', (refusal) => fail(refusal));
166
+ done();
167
+ }, fail);
168
+ });
169
+ };
170
+ /**
171
+ * When the room last stored a change (ms since the epoch), or `null`
172
+ * (H12): one authorized HTTP `GET <server>/<room>?lastUpdated`, which
173
+ * `routeDocumentSocket` answers without opening the document. Compare
174
+ * it with when a document was last fetched to decide whether to
175
+ * `prefetch` it. Throws on an HTTP error (`403` refused, `401` expired).
176
+ */
177
+ const lastUpdated = async (options) => {
178
+ const [serverUrl, room] = targetOf(options);
179
+ const url = new URL(`${serverUrl.replace(/^ws/, 'http')}/${encodeURIComponent(room)}`);
180
+ for (const [key, value] of Object.entries(options.params ?? {}))
181
+ url.searchParams.set(key, value);
182
+ url.searchParams.set('lastUpdated', '1');
183
+ const response = await (options.fetch ?? fetch)(url.toString());
184
+ if (!response.ok) {
185
+ throw new Error(`lastUpdated of ${room}: ${response.status} ${await response.text()}`);
186
+ }
187
+ const { lastUpdated: at } = (await response.json());
188
+ return typeof at === 'number' ? at : null;
189
+ };
109
190
  return {
191
+ prefetch,
192
+ lastUpdated,
110
193
  IndexeddbPersistence: idb.IndexeddbPersistence,
111
194
  WebsocketProvider: ws.WebsocketProvider,
112
195
  storeState: idb.storeState,
@@ -41,8 +41,8 @@ import * as encoding from 'lib0-v14/encoding';
41
41
  import { Awareness } from '../protocols/awareness.js';
42
42
  import { IsolatedObservable } from '../protocols/observable.js';
43
43
  import { bindSync } from '../protocols/sync.js';
44
- import { generationDbName } from '../protocols/envelope.js';
45
- import { CUSTOM as customStoreName, decodeRow, encodeRow, openContainer, UPDATES as updatesStoreName, verifyOrStamp } from './container.js';
44
+ import { GENERATION_KEY, generationDbName, STORED_GENERATION_RECORD } from '../protocols/envelope.js';
45
+ import { CUSTOM as customStoreName, encodeRow, openContainer, readRow, snapshotRow, UPDATES as updatesStoreName, verifyOrStamp } from './container.js';
46
46
  import { beginDestroy, bindRoomProtocol, emitFailed, initLifecycle, markSynced, quarantined } from './room.js';
47
47
  export const PREFERRED_TRIM_SIZE = 500;
48
48
  /**
@@ -64,37 +64,75 @@ export const bindIndexeddbProvider = (Y) => {
64
64
  /**
65
65
  * Apply all stored update rows (from `_dbref` on) to the doc. Verifies the
66
66
  * generation record before touching the doc — rows from a foreign (v13 or
67
- * otherwise-mismatched) generation are never applied.
67
+ * otherwise-mismatched) generation are never applied. The storage
68
+ * transaction reads everything it needs first (rows, last key, count):
69
+ * a snapshot row inflates asynchronously (P5), after it ends.
68
70
  */
69
71
  const fetchUpdates = (idbPersistence, beforeApplyUpdatesCallback = () => { }, afterApplyUpdatesCallback = () => { }) => {
70
72
  const db = idbPersistence.db;
71
73
  const [updatesStore, customStore] = idb.transact(db, [updatesStoreName, customStoreName]);
72
74
  return verifyOrStamp(idbPersistence.name, updatesStore, customStore)
73
75
  .then(() => idb.getAll(updatesStore, idb.createIDBKeyRangeLowerBound(idbPersistence._dbref, false)))
74
- .then((updates) => {
75
- if (!idbPersistence._destroyed) {
76
+ .then((rows) => {
77
+ // The pre-hydration state row is written before the last key is read.
78
+ if (!idbPersistence._destroyed)
76
79
  beforeApplyUpdatesCallback(updatesStore);
77
- Y.transact(idbPersistence.doc, () => {
78
- updates.forEach((update) => Y.applyUpdate(idbPersistence.doc, decodeRow(update)));
79
- }, idbPersistence, false);
80
- afterApplyUpdatesCallback(updatesStore);
81
- }
80
+ return promise
81
+ .all([idb.getLastKey(updatesStore), idb.count(updatesStore)])
82
+ .then(([lastKey, cnt]) => {
83
+ idbPersistence._dbref = lastKey + 1;
84
+ idbPersistence._dbsize = cnt;
85
+ return promise.all(rows.map(readRow));
86
+ });
82
87
  })
83
- .then(() => idb.getLastKey(updatesStore).then((lastKey) => {
84
- idbPersistence._dbref = lastKey + 1;
85
- }))
86
- .then(() => idb.count(updatesStore).then((cnt) => {
87
- idbPersistence._dbsize = cnt;
88
- }))
89
- .then(() => updatesStore);
88
+ .then((updates) => {
89
+ if (idbPersistence._destroyed)
90
+ return;
91
+ Y.transact(idbPersistence.doc, () => {
92
+ for (const update of updates) {
93
+ if (update instanceof Uint8Array)
94
+ Y.applyUpdate(idbPersistence.doc, update);
95
+ else
96
+ Y.applyUpdateV2(idbPersistence.doc, update.v2);
97
+ }
98
+ }, idbPersistence, false);
99
+ afterApplyUpdatesCallback();
100
+ });
101
+ };
102
+ /** One transaction: the snapshot row, the rows below `below` deleted, the record stamped `'v2'`. */
103
+ const writeSnapshot = (idbPersistence, row, below) => {
104
+ const db = idbPersistence.db;
105
+ const [updatesStore, customStore] = idb.transact(db, [updatesStoreName, customStoreName]);
106
+ // Track transaction commit: listeners attach while it is still active.
107
+ const tx = updatesStore.transaction;
108
+ const transactionDone = promise.create((resolve, reject) => {
109
+ tx.addEventListener('complete', () => resolve(undefined));
110
+ tx.addEventListener('abort', () => reject(new Error(`IndexedDB transaction aborted${tx.error ? `: ${String(tx.error)}` : ''}`)));
111
+ });
112
+ return promise
113
+ .all([
114
+ idb
115
+ // A structured-clone object row (lib0 types rows as binary).
116
+ .addAutoKey(updatesStore, row)
117
+ .then(() => idb.del(updatesStore, idb.createIDBKeyRangeUpperBound(below, true)))
118
+ .then(() => idb.rtop(customStore.put({ ...STORED_GENERATION_RECORD }, GENERATION_KEY)))
119
+ .then(() => idb.count(updatesStore).then((cnt) => {
120
+ idbPersistence._dbsize = cnt;
121
+ })),
122
+ transactionDone
123
+ ])
124
+ .then(() => undefined);
90
125
  };
91
126
  /**
92
127
  * Persist the current state: fetch pending rows, then (when `forceStore`
93
128
  * or past `PREFERRED_TRIM_SIZE`) append a compacted snapshot and delete
94
- * the rows it subsumes.
129
+ * the rows it subsumes. The snapshot is v2, gzip-compressed where the
130
+ * platform can (P5), and the container's generation record is stamped
131
+ * with its storage format in the same transaction.
95
132
  *
96
133
  * Every applied row is represented in the snapshot, so deleting
97
- * `key < _dbref` is always safe; a read-only document (outbound
134
+ * `key < _dbref` (read before the snapshot is compressed: rows stored
135
+ * meanwhile stay) is always safe; a read-only document (outbound
98
136
  * quarantine) never compacts.
99
137
  *
100
138
  * The returned promise settles only after the storage transaction
@@ -108,32 +146,14 @@ export const bindIndexeddbProvider = (Y) => {
108
146
  // Deferred: a synchronous transact/fetch failure (closed or
109
147
  // missing handle) must surface as a rejection, not a throw.
110
148
  .then(() => fetchUpdates(idbPersistence))
111
- .then((updatesStore) => {
149
+ .then(() => {
112
150
  if (quarantined(idbPersistence.doc))
113
151
  return undefined;
114
152
  if (!forceStore && idbPersistence._dbsize < PREFERRED_TRIM_SIZE) {
115
153
  return undefined;
116
154
  }
117
- // Track transaction commit: listeners attach while the
118
- // transaction is still active — this callback runs inside
119
- // the last fetch request's success microtask, before the
120
- // transaction can finish.
121
- const tx = updatesStore.transaction;
122
- const transactionDone = promise.create((resolve, reject) => {
123
- tx.addEventListener('complete', () => resolve(undefined));
124
- tx.addEventListener('abort', () => reject(new Error(`IndexedDB transaction aborted${tx.error ? `: ${String(tx.error)}` : ''}`)));
125
- });
126
- return promise
127
- .all([
128
- idb
129
- .addAutoKey(updatesStore, encodeRow(Y.encodeStateAsUpdate(idbPersistence.doc)))
130
- .then(() => idb.del(updatesStore, idb.createIDBKeyRangeUpperBound(idbPersistence._dbref, true)))
131
- .then(() => idb.count(updatesStore).then((cnt) => {
132
- idbPersistence._dbsize = cnt;
133
- })),
134
- transactionDone
135
- ])
136
- .then(() => undefined);
155
+ const below = idbPersistence._dbref;
156
+ return snapshotRow(Y.encodeStateAsUpdateV2(idbPersistence.doc)).then((row) => idbPersistence._destroyed ? undefined : writeSnapshot(idbPersistence, row, below));
137
157
  });
138
158
  /**
139
159
  * Delete a v14 generation database by LOGICAL name (the `edytor-v14:`
@@ -20,13 +20,23 @@ export declare const MAX_FRAME_BYTES: number;
20
20
  * receiver applies the reassembled frame only once `end` arrives.
21
21
  */
22
22
  export declare const chunkFrame: (whole: Uint8Array, maxFrameBytes?: number) => Uint8Array[];
23
+ /** A chunk sequence announces a frame larger than its reader takes ({@link createChunkReader}). */
24
+ export declare class ChunkLimitError extends Error {
25
+ readonly total: number;
26
+ readonly limit: number;
27
+ constructor(total: number, limit: number);
28
+ }
29
+ /** A chunk part or end with no sequence started (one lost, or never sent). */
30
+ export declare class ChunkSequenceError extends Error {
31
+ }
23
32
  /**
24
33
  * The receiving half of {@link chunkFrame}, one per connection: feed it
25
34
  * each chunk frame's body (after the message type); it returns the whole
26
35
  * frame on `end`, `null` before. An out-of-order or oversized sequence
27
- * throws and resets.
36
+ * throws and resets; a sequence announcing more than `maxBytes` throws
37
+ * {@link ChunkLimitError} at its start, before anything is buffered.
28
38
  */
29
- export declare const createChunkReader: () => (decoder: decoding.Decoder) => Uint8Array | null;
39
+ export declare const createChunkReader: (maxBytes?: number) => (decoder: decoding.Decoder) => Uint8Array | null;
30
40
  export type ProtocolMismatch = {
31
41
  expected: number;
32
42
  found: number | null;
@@ -93,6 +103,12 @@ export declare const CLOSE: {
93
103
  readonly denied: 4403;
94
104
  /** The dialed replica is bound to another user. */
95
105
  readonly replicaTaken: 4409;
106
+ /**
107
+ * A room quota refused the socket's write (`quota: document`,
108
+ * `quota: rate`, `quota: frame`): the frame was not applied, and a
109
+ * redial would resend it and meet the same quota.
110
+ */
111
+ readonly quota: 4413;
96
112
  };
97
113
  /**
98
114
  * Whether a room (document) id can reach its own room: 1–256 characters,
@@ -87,28 +87,46 @@ export const chunkFrame = (whole, maxFrameBytes = MAX_FRAME_BYTES) => {
87
87
  frames.push(frame(messageChunk, (e) => encoding.writeVarUint(e, chunkEnd)));
88
88
  return frames;
89
89
  };
90
+ /** A chunk sequence announces a frame larger than its reader takes ({@link createChunkReader}). */
91
+ export class ChunkLimitError extends Error {
92
+ total;
93
+ limit;
94
+ constructor(total, limit) {
95
+ super(`chunked frame of ${total} bytes exceeds ${limit}`);
96
+ this.total = total;
97
+ this.limit = limit;
98
+ this.name = 'ChunkLimitError';
99
+ }
100
+ }
101
+ /** A chunk part or end with no sequence started (one lost, or never sent). */
102
+ export class ChunkSequenceError extends Error {
103
+ }
90
104
  /**
91
105
  * The receiving half of {@link chunkFrame}, one per connection: feed it
92
106
  * each chunk frame's body (after the message type); it returns the whole
93
107
  * frame on `end`, `null` before. An out-of-order or oversized sequence
94
- * throws and resets.
108
+ * throws and resets; a sequence announcing more than `maxBytes` throws
109
+ * {@link ChunkLimitError} at its start, before anything is buffered.
95
110
  */
96
- export const createChunkReader = () => {
111
+ export const createChunkReader = (maxBytes = Infinity) => {
97
112
  let parts = null;
98
113
  let total = 0;
99
114
  let received = 0;
100
115
  return (decoder) => {
101
116
  const kind = decoding.readVarUint(decoder);
102
117
  if (kind === chunkStart) {
103
- parts = [];
118
+ parts = null;
104
119
  total = decoding.readVarUint(decoder);
120
+ if (total > maxBytes)
121
+ throw new ChunkLimitError(total, maxBytes);
122
+ parts = [];
105
123
  received = 0;
106
124
  return null;
107
125
  }
108
126
  const current = parts;
109
127
  parts = null;
110
128
  if (current === null)
111
- throw new Error(`chunk ${kind} without a start`);
129
+ throw new ChunkSequenceError(`chunk ${kind} without a start`);
112
130
  if (kind === chunkPart) {
113
131
  const bytes = decoding.readVarUint8Array(decoder);
114
132
  received += bytes.length;
@@ -205,7 +223,13 @@ export const CLOSE = {
205
223
  /** The host's authorization denied the dial. */
206
224
  denied: 4403,
207
225
  /** The dialed replica is bound to another user. */
208
- replicaTaken: 4409
226
+ replicaTaken: 4409,
227
+ /**
228
+ * A room quota refused the socket's write (`quota: document`,
229
+ * `quota: rate`, `quota: frame`): the frame was not applied, and a
230
+ * redial would resend it and meet the same quota.
231
+ */
232
+ quota: 4413
209
233
  };
210
234
  /**
211
235
  * Whether a room (document) id can reach its own room: 1–256 characters,
@@ -90,6 +90,12 @@ export type WebsocketProviderOptions = {
90
90
  connectTimeout?: number;
91
91
  /** Opt out of cross-tab sync over the BroadcastChannel (on by default). */
92
92
  disableBc?: boolean;
93
+ /**
94
+ * Largest frame sent whole (default 32 MiB, Cloudflare's WebSocket
95
+ * message limit): a larger one — a reconnect's backlog, a big paste —
96
+ * goes out as a chunk sequence the room reassembles (H6).
97
+ */
98
+ maxFrameBytes?: number;
93
99
  };
94
100
  export type WebsocketProviderApi = InstanceType<ReturnType<typeof bindWebsocketProvider>['WebsocketProvider']>;
95
101
  /**
@@ -97,7 +103,7 @@ export type WebsocketProviderApi = InstanceType<ReturnType<typeof bindWebsocketP
97
103
  */
98
104
  export declare const bindWebsocketProvider: (Y: EngineApi) => {
99
105
  WebsocketProvider: {
100
- new (serverUrl: string, roomname: string, doc: YDoc, { connect, awareness, params, WebSocketPolyfill, resyncInterval, maxBackoffTime, connectTimeout, disableBc }?: WebsocketProviderOptions): {
106
+ new (serverUrl: string, roomname: string, doc: YDoc, { connect, awareness, params, WebSocketPolyfill, resyncInterval, maxBackoffTime, connectTimeout, disableBc, maxFrameBytes }?: WebsocketProviderOptions): {
101
107
  serverUrl: string;
102
108
  roomname: string;
103
109
  doc: YDoc;
@@ -106,6 +112,8 @@ export declare const bindWebsocketProvider: (Y: EngineApi) => {
106
112
  maxBackoffTime: number;
107
113
  /** ms a dial may take to open (`Infinity`: no limit). */
108
114
  connectTimeout: number;
115
+ /** Largest frame sent whole; larger ones are chunked. */
116
+ maxFrameBytes: number;
109
117
  _WS: WebsocketPolyfill;
110
118
  shouldConnect: boolean;
111
119
  ws: WebSocket | null;
@@ -6,7 +6,8 @@
6
6
  * Retained surface (D-24 G-e): `status`/`synced`/`connection-*` events,
7
7
  * `connect()`/`disconnect()`, awareness injection, auth `params` (read at
8
8
  * every dial, so a refreshed token reaches the next connection),
9
- * `WebSocketPolyfill`, exponential-backoff reconnect (`maxBackoffTime`,
9
+ * `WebSocketPolyfill`, outbound chunking of frames over `maxFrameBytes` (H6),
10
+ * exponential-backoff reconnect (`maxBackoffTime`,
10
11
  * growing to 30 s for a room that stays unreachable), liveness (a text
11
12
  * `ping` after 15 s of silence; a text `pong` counts as heard),
12
13
  * `resyncInterval`, and the BroadcastChannel leg (cross-tab sync, on by
@@ -54,7 +55,7 @@ import { Awareness, removeAwarenessStates } from '../protocols/awareness.js';
54
55
  import { IsolatedObservable } from '../protocols/observable.js';
55
56
  import { messagePermissionDenied, messageReadOnly, READ_ONLY_DENIAL, readAuthMessage } from '../protocols/auth.js';
56
57
  import { bindSync } from '../protocols/sync.js';
57
- import { assertRoomId, beginDestroy, bindRoomProtocol, CLOSE, createChunkReader, emitFailed, initLifecycle, isRefusal, LOCAL_PRESENCE_LOSS, markSynced, messageAuth, messageChunk, messageSaved, messageSync, SyncRefusedError } from './room.js';
58
+ import { assertRoomId, beginDestroy, bindRoomProtocol, chunkFrame, CLOSE, createChunkReader, emitFailed, initLifecycle, isRefusal, LOCAL_PRESENCE_LOSS, markSynced, messageAuth, messageChunk, messageSaved, messageSync, MAX_FRAME_BYTES, SyncRefusedError } from './room.js';
58
59
  import { ATTRIBUTION_ROOT } from '../schema.js';
59
60
  import { ATTRIBUTION_ORIGIN } from '../attribution/attribution.js';
60
61
  // @todo - this should depend on awareness.outdatedTime
@@ -72,11 +73,21 @@ const defaultConnectTimeout = 10000;
72
73
  */
73
74
  export const bindWebsocketProvider = (Y) => {
74
75
  const syncProtocol = bindSync(Y);
76
+ /**
77
+ * One frame on `ws`: whole, or as a chunk sequence when it is larger
78
+ * than `maxFrameBytes` (H6). A text frame (the keepalive) goes as it is.
79
+ */
80
+ const sendOn = (provider, ws, buf) => {
81
+ if (typeof buf === 'string')
82
+ return ws.send(buf);
83
+ for (const piece of chunkFrame(buf, provider.maxFrameBytes))
84
+ ws.send(piece);
85
+ };
75
86
  /** Traffic for the server goes out on the socket while it is open. */
76
87
  const send = (provider, buf) => {
77
88
  const ws = provider.ws;
78
89
  if (provider.wsconnected && ws && ws.readyState === ws.OPEN)
79
- ws.send(buf);
90
+ sendOn(provider, ws, buf);
80
91
  };
81
92
  /** Traffic for the other tabs goes out on the channel. */
82
93
  const toTabs = (provider, buf) => {
@@ -390,7 +401,7 @@ export const bindWebsocketProvider = (Y) => {
390
401
  if (event.data === 'pong')
391
402
  return;
392
403
  try {
393
- room.readMessage(provider, new Uint8Array(event.data), true, (buf) => websocket.send(buf));
404
+ room.readMessage(provider, new Uint8Array(event.data), true, (buf) => sendOn(provider, websocket, buf));
394
405
  }
395
406
  catch (error) {
396
407
  provider.emit('message-error', [error, provider]);
@@ -409,7 +420,7 @@ export const bindWebsocketProvider = (Y) => {
409
420
  provider.emit('status', [{ status: 'connected' }]);
410
421
  // The join rule: say hello; the members answer what we lack.
411
422
  for (const buf of room.hello(provider))
412
- websocket.send(buf);
423
+ sendOn(provider, websocket, buf);
413
424
  };
414
425
  provider.emit('status', [{ status: 'connecting' }]);
415
426
  }
@@ -423,6 +434,8 @@ export const bindWebsocketProvider = (Y) => {
423
434
  maxBackoffTime;
424
435
  /** ms a dial may take to open (`Infinity`: no limit). */
425
436
  connectTimeout;
437
+ /** Largest frame sent whole; larger ones are chunked. */
438
+ maxFrameBytes;
426
439
  _WS;
427
440
  shouldConnect;
428
441
  ws = null;
@@ -457,7 +470,7 @@ export const bindWebsocketProvider = (Y) => {
457
470
  _checkInterval;
458
471
  _updateHandler;
459
472
  _awarenessUpdateHandler;
460
- constructor(serverUrl, roomname, doc, { connect = true, awareness = new Awareness(doc), params = {}, WebSocketPolyfill = WebSocket, resyncInterval = 0, maxBackoffTime = 2500, connectTimeout = defaultConnectTimeout, disableBc = false } = {}) {
473
+ constructor(serverUrl, roomname, doc, { connect = true, awareness = new Awareness(doc), params = {}, WebSocketPolyfill = WebSocket, resyncInterval = 0, maxBackoffTime = 2500, connectTimeout = defaultConnectTimeout, disableBc = false, maxFrameBytes = MAX_FRAME_BYTES } = {}) {
461
474
  assertRoomId(roomname);
462
475
  super();
463
476
  this.serverUrl = serverUrl.replace(/\/+$/, '');
@@ -469,6 +482,8 @@ export const bindWebsocketProvider = (Y) => {
469
482
  this.params = params;
470
483
  this.maxBackoffTime = maxBackoffTime;
471
484
  this.connectTimeout = connectTimeout;
485
+ this.maxFrameBytes =
486
+ Number.isInteger(maxFrameBytes) && maxFrameBytes > 64 ? maxFrameBytes : MAX_FRAME_BYTES;
472
487
  this._WS = WebSocketPolyfill;
473
488
  this.shouldConnect = connect;
474
489
  initLifecycle(this, () => this.destroy(), () => room.depart(this));
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The storage codec (P5) — what the room's rows and the IndexedDB store
3
+ * hold, apart from the wire (which stays v1 until the schema generation
4
+ * that changes it). Snapshots are written in the v2 (columnar) encoding
5
+ * and gzip-compressed where the platform has `CompressionStream`; update
6
+ * rows stay v1 (smaller for one keystroke). A compressed record starts
7
+ * with gzip's magic `1f 8b`, which no v2 update does (its first byte is
8
+ * the encoder's feature flag, `00`), so a v2 container's snapshot says
9
+ * itself whether it is compressed. Worker-safe: no timers, every
10
+ * platform API feature-detected.
11
+ */
12
+ /** Whether `bytes` are gzip (its magic `1f 8b`). */
13
+ export declare const isGzip: (bytes: Uint8Array) => boolean;
14
+ /** Whether this platform compresses (`CompressionStream`). */
15
+ export declare const canCompress: () => boolean;
16
+ /** `bytes` gzip-compressed, or `null` where the platform has no `CompressionStream`. */
17
+ export declare const gzip: (bytes: Uint8Array) => Promise<Uint8Array | null>;
18
+ /** `bytes` as stored: inflated when gzip, else as they are. */
19
+ export declare const gunzip: (bytes: Uint8Array) => Promise<Uint8Array>;
20
+ /** `bytes` compressed when that makes them smaller (and the platform can), else as they are. */
21
+ export declare const packed: (bytes: Uint8Array) => Promise<Uint8Array>;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The storage codec (P5) — what the room's rows and the IndexedDB store
3
+ * hold, apart from the wire (which stays v1 until the schema generation
4
+ * that changes it). Snapshots are written in the v2 (columnar) encoding
5
+ * and gzip-compressed where the platform has `CompressionStream`; update
6
+ * rows stay v1 (smaller for one keystroke). A compressed record starts
7
+ * with gzip's magic `1f 8b`, which no v2 update does (its first byte is
8
+ * the encoder's feature flag, `00`), so a v2 container's snapshot says
9
+ * itself whether it is compressed. Worker-safe: no timers, every
10
+ * platform API feature-detected.
11
+ */
12
+ /** Whether `bytes` are gzip (its magic `1f 8b`). */
13
+ export const isGzip = (bytes) => bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b;
14
+ const platform = globalThis;
15
+ /** Run `bytes` through a (de)compression stream. */
16
+ const through = async (bytes, transform) => {
17
+ const stream = new Response(bytes).body.pipeThrough(transform);
18
+ return new Uint8Array(await new Response(stream).arrayBuffer());
19
+ };
20
+ /** Whether this platform compresses (`CompressionStream`). */
21
+ export const canCompress = () => typeof platform.CompressionStream === 'function';
22
+ /** `bytes` gzip-compressed, or `null` where the platform has no `CompressionStream`. */
23
+ export const gzip = async (bytes) => platform.CompressionStream ? through(bytes, new platform.CompressionStream('gzip')) : null;
24
+ /** `bytes` as stored: inflated when gzip, else as they are. */
25
+ export const gunzip = async (bytes) => {
26
+ if (!isGzip(bytes))
27
+ return bytes;
28
+ if (!platform.DecompressionStream) {
29
+ throw new Error('a compressed record needs DecompressionStream, which this platform lacks');
30
+ }
31
+ return through(bytes, new platform.DecompressionStream('gzip'));
32
+ };
33
+ /** `bytes` compressed when that makes them smaller (and the platform can), else as they are. */
34
+ export const packed = async (bytes) => {
35
+ const out = await gzip(bytes);
36
+ return out !== null && out.length < bytes.length ? out : bytes;
37
+ };
@@ -25,7 +25,11 @@ export type StoreStruct = {
25
25
  /** `null` for sequence items; an attr name for map-entry items. */
26
26
  parentSub: string | null;
27
27
  parent: unknown;
28
+ left?: StoreStruct | null;
28
29
  right?: StoreStruct | null;
30
+ content?: {
31
+ arr?: unknown[];
32
+ };
29
33
  countable?: boolean;
30
34
  keep?: boolean;
31
35
  redone?: {
@@ -75,6 +79,12 @@ export declare const structAt: (Y: EngineApi, structs: readonly StoreStruct[], c
75
79
  * partial walk. `clientsOf` throws first when the store itself is absent.
76
80
  */
77
81
  export declare const walkIdSetStructs: (Y: EngineApi, doc: EngineDoc, idSet: IdSetLike, fn: (struct: StoreStruct) => boolean | void) => boolean;
82
+ /**
83
+ * {@link walkIdSetStructs}, clipped: `fn(struct, from, to)` gets the clock
84
+ * range `[from, to)` of the struct the id set covers (a struct can reach past
85
+ * a range a diff of two id sets cut).
86
+ */
87
+ export declare const walkIdSetRanges: (Y: EngineApi, doc: EngineDoc, idSet: IdSetLike, fn: (struct: StoreStruct, from: number, to: number) => void) => boolean;
78
88
  /**
79
89
  * The id the local `redone` chain leads `id` to: the copy an undo or redo
80
90
  * on THIS replica re-created of a deleted item (redo copies chain), or `id`
@@ -104,3 +114,10 @@ export declare const nextClock: (doc: EngineDoc, client: number) => number;
104
114
  * undefined until the missing update arrives.
105
115
  */
106
116
  export declare const holdsPending: (doc: EngineDoc) => boolean;
117
+ /**
118
+ * The transactions the engine has opened and not finished cleaning up
119
+ * (vendor-internal `doc._transactionCleanups`), in order: the open one, and
120
+ * those started during an earlier one's cleanup, whose bodies already ran
121
+ * and whose observers have not.
122
+ */
123
+ export declare const queuedTransactions: (doc: EngineDoc) => readonly unknown[];
@@ -75,6 +75,40 @@ export const walkIdSetStructs = (Y, doc, idSet, fn) => {
75
75
  });
76
76
  return complete;
77
77
  };
78
+ /**
79
+ * {@link walkIdSetStructs}, clipped: `fn(struct, from, to)` gets the clock
80
+ * range `[from, to)` of the struct the id set covers (a struct can reach past
81
+ * a range a diff of two id sets cut).
82
+ */
83
+ export const walkIdSetRanges = (Y, doc, idSet, fn) => {
84
+ const clients = clientsOf(doc);
85
+ let complete = true;
86
+ idSet.clients.forEach((ranges, client) => {
87
+ const structs = clients.get(client);
88
+ if (structs === undefined) {
89
+ complete = false;
90
+ return;
91
+ }
92
+ for (const r of ranges.getIds()) {
93
+ const end = r.clock + r.len;
94
+ let i;
95
+ try {
96
+ i = Y.findIndexSS(structs, r.clock);
97
+ }
98
+ catch {
99
+ complete = false;
100
+ continue;
101
+ }
102
+ for (; i < structs.length; i++) {
103
+ const s = structs[i];
104
+ if (s.id.clock >= end)
105
+ break;
106
+ fn(s, Math.max(r.clock, s.id.clock), Math.min(end, s.id.clock + s.length));
107
+ }
108
+ }
109
+ });
110
+ return complete;
111
+ };
78
112
  /**
79
113
  * The id the local `redone` chain leads `id` to: the copy an undo or redo
80
114
  * on THIS replica re-created of a deleted item (redo copies chain), or `id`
@@ -117,3 +151,10 @@ export const holdsPending = (doc) => {
117
151
  const store = doc.store;
118
152
  return (store?.pendingStructs ?? null) !== null || (store?.pendingDs ?? null) !== null;
119
153
  };
154
+ /**
155
+ * The transactions the engine has opened and not finished cleaning up
156
+ * (vendor-internal `doc._transactionCleanups`), in order: the open one, and
157
+ * those started during an earlier one's cleanup, whose bodies already ran
158
+ * and whose observers have not.
159
+ */
160
+ export const queuedTransactions = (doc) => doc._transactionCleanups ?? [];
@@ -195,9 +195,15 @@ export type Stream = {
195
195
  };
196
196
  /** Scan `text` (live space, renderer-free) for its boundary items. */
197
197
  export declare const scanText: (home: BlockId, text: EngineNode) => TextRow;
198
+ export declare const byId: (a: string, b: string) => number;
198
199
  /** Each block's delimiting boundary: the lowest-id live boundary whose nonce matches its record. */
199
200
  export declare const delimiters: (blocks: ReadonlyMap<BlockId, Pick<TextBlockRec, "n">>, rows: Iterable<TextRow>) => Map<BlockId, string>;
200
- /** The streams of one text, in text order: the home's head (unless delimited elsewhere), then one per delimiting boundary. */
201
+ /**
202
+ * The streams of one text, in text order: the home's head (unless delimited
203
+ * elsewhere), then one per delimiting boundary — one sweep over the
204
+ * boundaries (each non-delimiting one is an inert boundary of the stream it
205
+ * sits in).
206
+ */
201
207
  export declare const placeText: (row: TextRow, delim: ReadonlyMap<BlockId, string>) => Stream[];
202
208
  /** A displayed piece: `[i0, i1)` of `text` (home `t`) holds content only; `block` owns its stream. */
203
209
  export type Seg = {
@@ -221,6 +227,8 @@ export type Ownership = {
221
227
  streamOf: (b: BlockId) => Stream | undefined;
222
228
  /** The streams of `home`'s text, in text order. */
223
229
  streamsIn: (home: BlockId) => readonly Stream[];
230
+ /** The stream of `home`'s text whose `[start, end]` holds live index `i` (the first, at a seam). */
231
+ streamAt: (home: BlockId, i: number) => Stream | undefined;
224
232
  /** `display(b)` as boundary-free pieces in reading order; `null` when hidden or dead. */
225
233
  display: (b: BlockId) => Seg[] | null;
226
234
  };