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.
- package/README.md +1 -1
- package/dist/cloudflare/DocumentRoom.d.ts +302 -10
- package/dist/cloudflare/DocumentRoom.js +965 -88
- package/dist/cloudflare/index.d.ts +1 -1
- package/dist/cloudflare/index.js +1 -1
- package/dist/cloudflare/routeDocumentSocket.js +30 -6
- package/dist/collaboration/awarenessSelection.d.ts +7 -1
- package/dist/collaboration/awarenessSelection.js +7 -1
- package/dist/collaboration/index.d.ts +2 -2
- package/dist/collaboration/index.js +2 -2
- package/dist/collaboration/providers.d.ts +4 -0
- package/dist/collaboration/providers.js +4 -0
- package/dist/components/Edytor.svelte +14 -2
- package/dist/components/Edytor.svelte.d.ts +8 -1
- package/dist/crdt/admission.d.ts +6 -1
- package/dist/crdt/admission.js +9 -2
- package/dist/crdt/data.d.ts +3 -1
- package/dist/crdt/data.js +42 -32
- package/dist/crdt/document.d.ts +31 -8
- package/dist/crdt/document.js +53 -18
- package/dist/crdt/edytor-doc.d.ts +31 -3
- package/dist/crdt/edytor-doc.js +131 -14
- package/dist/crdt/engine.d.ts +6 -0
- package/dist/crdt/engine.js +7 -0
- package/dist/crdt/flow.d.ts +2 -0
- package/dist/crdt/flow.js +2 -2
- package/dist/crdt/index.d.ts +20 -9
- package/dist/crdt/index.js +3 -3
- package/dist/crdt/migration/migrate.js +9 -3
- package/dist/crdt/placement/model.d.ts +9 -1
- package/dist/crdt/placement/model.js +29 -10
- package/dist/crdt/placement/rank.d.ts +20 -0
- package/dist/crdt/placement/rank.js +79 -0
- package/dist/crdt/protocols/envelope.d.ts +20 -1
- package/dist/crdt/protocols/envelope.js +14 -2
- package/dist/crdt/providers/container.d.ts +15 -0
- package/dist/crdt/providers/container.js +17 -3
- package/dist/crdt/providers/index.d.ts +21 -1
- package/dist/crdt/providers/index.js +86 -3
- package/dist/crdt/providers/indexeddb.js +60 -40
- package/dist/crdt/providers/room.d.ts +18 -2
- package/dist/crdt/providers/room.js +29 -5
- package/dist/crdt/providers/websocket.d.ts +9 -1
- package/dist/crdt/providers/websocket.js +21 -6
- package/dist/crdt/storage.d.ts +21 -0
- package/dist/crdt/storage.js +37 -0
- package/dist/crdt/structs.d.ts +17 -0
- package/dist/crdt/structs.js +41 -0
- package/dist/crdt/text/model.d.ts +9 -1
- package/dist/crdt/text/model.js +24 -14
- package/dist/crdt/text/rows.d.ts +94 -0
- package/dist/crdt/text/rows.js +193 -0
- package/dist/crdt/text/runs.d.ts +30 -10
- package/dist/crdt/text/runs.js +1235 -264
- package/dist/edytor.svelte.d.ts +3 -1
- package/dist/edytor.svelte.js +9 -6
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/plugins/image/ImageEmpty.svelte +19 -6
- package/dist/plugins/image/ImagePlugin.svelte +9 -3
- package/dist/plugins/image/ImagePlugin.svelte.d.ts +2 -2
- package/dist/plugins/image/image.d.ts +16 -0
- package/dist/plugins/image/image.js +19 -0
- package/dist/plugins/index.d.ts +1 -1
- package/dist/plugins/index.js +1 -1
- package/dist/plugins.d.ts +11 -0
- package/dist/session/commands.d.ts +1 -0
- package/dist/session/commands.js +4 -1
- 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,
|
|
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((
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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(() =>
|
|
84
|
-
idbPersistence.
|
|
85
|
-
|
|
86
|
-
.
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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`
|
|
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((
|
|
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
|
-
|
|
118
|
-
|
|
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
|
|
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`,
|
|
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
|
|
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
|
|
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
|
|
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
|
+
};
|
package/dist/crdt/structs.d.ts
CHANGED
|
@@ -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[];
|
package/dist/crdt/structs.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
};
|