@dabble/patches 0.7.24 → 0.8.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/dist/json-patch/utils/getType.d.ts +1 -1
- package/dist/net/PatchesConnection.d.ts +27 -0
- package/dist/net/PatchesConnection.js +0 -0
- package/dist/net/PatchesSync.d.ts +23 -18
- package/dist/net/PatchesSync.js +38 -29
- package/dist/net/index.d.ts +4 -0
- package/dist/net/index.js +2 -0
- package/dist/net/rest/PatchesREST.d.ts +75 -0
- package/dist/net/rest/PatchesREST.js +234 -0
- package/dist/net/rest/SSEServer.d.ts +151 -0
- package/dist/net/rest/SSEServer.js +248 -0
- package/dist/net/rest/index.d.ts +12 -0
- package/dist/net/rest/index.js +8 -0
- package/dist/net/rest/utils.d.ts +14 -0
- package/dist/net/rest/utils.js +11 -0
- package/dist/net/websocket/PatchesWebSocket.d.ts +5 -1
- package/dist/net/websocket/PatchesWebSocket.js +8 -0
- package/dist/solid/context.d.ts +2 -3
- package/dist/solid/index.d.ts +2 -3
- package/dist/vue/index.d.ts +2 -3
- package/dist/vue/provider.d.ts +2 -3
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { State, JSONPatchOp, JSONPatchOpHandler } from '../types.js';
|
|
2
2
|
|
|
3
3
|
declare function getType(state: State, patch: JSONPatchOp): JSONPatchOpHandler;
|
|
4
|
-
declare function getTypeLike(state: State, patch: JSONPatchOp): "
|
|
4
|
+
declare function getTypeLike(state: State, patch: JSONPatchOp): "test" | "add" | "remove" | "replace" | "copy" | "move";
|
|
5
5
|
|
|
6
6
|
export { getType, getTypeLike };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Signal } from 'easy-signal';
|
|
2
|
+
import { Change, CommitChangesOptions } from '../types.js';
|
|
3
|
+
import { PatchesAPI, ConnectionState } from './protocol/types.js';
|
|
4
|
+
import '../json-patch/JSONPatch.js';
|
|
5
|
+
import '@dabble/delta';
|
|
6
|
+
import '../json-patch/types.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Common interface for transport connections that PatchesSync can use.
|
|
10
|
+
* Implemented by PatchesWebSocket (WebSocket transport) and PatchesREST (SSE + fetch transport).
|
|
11
|
+
*/
|
|
12
|
+
interface PatchesConnection extends PatchesAPI {
|
|
13
|
+
/** The server URL. Readable and writable — setting while connected triggers reconnection. */
|
|
14
|
+
url: string;
|
|
15
|
+
/** Establish the connection to the server. */
|
|
16
|
+
connect(): Promise<void>;
|
|
17
|
+
/** Tear down the connection. */
|
|
18
|
+
disconnect(): void;
|
|
19
|
+
/** Signal emitted when the connection state changes. */
|
|
20
|
+
readonly onStateChange: Signal<(state: ConnectionState) => void>;
|
|
21
|
+
/** Signal emitted when the server pushes committed changes for a subscribed document. */
|
|
22
|
+
readonly onChangesCommitted: Signal<(docId: string, changes: Change[], options?: CommitChangesOptions) => void>;
|
|
23
|
+
/** Signal emitted when a subscribed document is deleted remotely. */
|
|
24
|
+
readonly onDocDeleted: Signal<(docId: string) => void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type { PatchesConnection };
|
|
File without changes
|
|
@@ -1,20 +1,19 @@
|
|
|
1
1
|
import * as easy_signal from 'easy-signal';
|
|
2
2
|
import { ReadonlyStoreClass, Store } from 'easy-signal';
|
|
3
|
-
import { ConnectionState } from './protocol/types.js';
|
|
4
|
-
import { DocSyncStatus, DocSyncState, Change } from '../types.js';
|
|
5
|
-
import { JSONRPCClient } from './protocol/JSONRPCClient.js';
|
|
6
|
-
import { PatchesWebSocket } from './websocket/PatchesWebSocket.js';
|
|
7
|
-
import { WebSocketOptions } from './websocket/WebSocketTransport.js';
|
|
8
3
|
import { SizeCalculator } from '../algorithms/ot/shared/changeBatching.js';
|
|
9
4
|
import { ClientAlgorithm } from '../client/ClientAlgorithm.js';
|
|
10
5
|
import { Patches } from '../client/Patches.js';
|
|
11
6
|
import { AlgorithmName } from '../client/PatchesStore.js';
|
|
7
|
+
import { DocSyncStatus, DocSyncState, Change } from '../types.js';
|
|
8
|
+
import { PatchesConnection } from './PatchesConnection.js';
|
|
9
|
+
import { JSONRPCClient } from './protocol/JSONRPCClient.js';
|
|
10
|
+
import { ConnectionState } from './protocol/types.js';
|
|
11
|
+
import { WebSocketOptions } from './websocket/WebSocketTransport.js';
|
|
12
|
+
import '../json-patch/types.js';
|
|
13
|
+
import '../BaseDoc-BT18xPxU.js';
|
|
12
14
|
import '../json-patch/JSONPatch.js';
|
|
13
15
|
import '@dabble/delta';
|
|
14
|
-
import '../json-patch/types.js';
|
|
15
|
-
import './PatchesClient.js';
|
|
16
16
|
import '../utils/deferred.js';
|
|
17
|
-
import '../BaseDoc-BT18xPxU.js';
|
|
18
17
|
|
|
19
18
|
interface PatchesSyncState {
|
|
20
19
|
online: boolean;
|
|
@@ -24,6 +23,7 @@ interface PatchesSyncState {
|
|
|
24
23
|
}
|
|
25
24
|
interface PatchesSyncOptions {
|
|
26
25
|
subscribeFilter?: (docIds: string[]) => string[];
|
|
26
|
+
/** WebSocket options. Only used when a URL string is passed to the constructor. */
|
|
27
27
|
websocket?: WebSocketOptions;
|
|
28
28
|
/** Wire batch limit for network transmission. Defaults to 1MB. */
|
|
29
29
|
maxPayloadBytes?: number;
|
|
@@ -33,9 +33,12 @@ interface PatchesSyncOptions {
|
|
|
33
33
|
sizeCalculator?: SizeCalculator;
|
|
34
34
|
}
|
|
35
35
|
/**
|
|
36
|
-
* Handles
|
|
36
|
+
* Handles server connection, document subscriptions, and syncing logic between
|
|
37
37
|
* the Patches instance and the server.
|
|
38
38
|
*
|
|
39
|
+
* Accepts either a URL string (creates a WebSocket connection) or a PatchesConnection
|
|
40
|
+
* instance (e.g. PatchesREST for SSE + fetch).
|
|
41
|
+
*
|
|
39
42
|
* PatchesSync is algorithm-agnostic. It delegates to algorithm methods for:
|
|
40
43
|
* - Getting pending changes to send
|
|
41
44
|
* - Applying server changes
|
|
@@ -43,7 +46,7 @@ interface PatchesSyncOptions {
|
|
|
43
46
|
*/
|
|
44
47
|
declare class PatchesSync extends ReadonlyStoreClass<PatchesSyncState> {
|
|
45
48
|
protected options?: PatchesSyncOptions | undefined;
|
|
46
|
-
protected
|
|
49
|
+
protected connection: PatchesConnection;
|
|
47
50
|
protected patches: Patches;
|
|
48
51
|
protected maxPayloadBytes?: number;
|
|
49
52
|
protected maxStorageBytes?: number;
|
|
@@ -66,36 +69,38 @@ declare class PatchesSync extends ReadonlyStoreClass<PatchesSyncState> {
|
|
|
66
69
|
* Provides the pending changes that were discarded so the application can handle them.
|
|
67
70
|
*/
|
|
68
71
|
readonly onRemoteDocDeleted: easy_signal.Signal<(docId: string, pendingChanges: Change[]) => void>;
|
|
69
|
-
constructor(patches: Patches, url: string, options?: PatchesSyncOptions
|
|
72
|
+
constructor(patches: Patches, url: string, options?: PatchesSyncOptions);
|
|
73
|
+
constructor(patches: Patches, connection: PatchesConnection, options?: PatchesSyncOptions);
|
|
70
74
|
/**
|
|
71
75
|
* Gets the algorithm for a document. Uses the open doc's algorithm if available,
|
|
72
76
|
* otherwise falls back to the default algorithm.
|
|
73
77
|
*/
|
|
74
78
|
protected _getAlgorithm(docId: string): ClientAlgorithm;
|
|
75
79
|
/**
|
|
76
|
-
* Gets the URL
|
|
80
|
+
* Gets the server URL.
|
|
77
81
|
*/
|
|
78
82
|
get url(): string;
|
|
79
83
|
/**
|
|
80
|
-
* Sets the URL
|
|
84
|
+
* Sets the server URL. Reconnects if currently connected.
|
|
81
85
|
*/
|
|
82
86
|
set url(url: string);
|
|
83
87
|
/**
|
|
84
88
|
* Gets the JSON-RPC client for making custom RPC calls.
|
|
85
|
-
*
|
|
89
|
+
* Only available when using a WebSocket connection (PatchesWebSocket or PatchesClient).
|
|
90
|
+
* Returns undefined when using REST transport.
|
|
86
91
|
*/
|
|
87
|
-
get rpc(): JSONRPCClient;
|
|
92
|
+
get rpc(): JSONRPCClient | undefined;
|
|
88
93
|
/**
|
|
89
94
|
* Updates the sync state.
|
|
90
95
|
* @param update - The partial state to update.
|
|
91
96
|
*/
|
|
92
97
|
protected updateState(update: Partial<PatchesSyncState>): void;
|
|
93
98
|
/**
|
|
94
|
-
* Connects to the
|
|
99
|
+
* Connects to the server and starts syncing if online. If not online, it will wait for online state.
|
|
95
100
|
*/
|
|
96
101
|
connect(): Promise<void>;
|
|
97
102
|
/**
|
|
98
|
-
* Disconnects from the
|
|
103
|
+
* Disconnects from the server and stops syncing.
|
|
99
104
|
*/
|
|
100
105
|
disconnect(): void;
|
|
101
106
|
/**
|
|
@@ -155,7 +160,7 @@ declare class PatchesSync extends ReadonlyStoreClass<PatchesSyncState> {
|
|
|
155
160
|
protected _resetSyncingStatuses(): void;
|
|
156
161
|
/**
|
|
157
162
|
* Applies the subscribeFilter option to a list of doc IDs, returning the subset
|
|
158
|
-
* that should be sent to
|
|
163
|
+
* that should be sent to subscribe/unsubscribe. Returns the full list if no filter is set.
|
|
159
164
|
*/
|
|
160
165
|
protected _filterSubscribeIds(docIds: string[]): string[];
|
|
161
166
|
/**
|
package/dist/net/PatchesSync.js
CHANGED
|
@@ -13,6 +13,7 @@ import { BaseDoc } from "../client/BaseDoc.js";
|
|
|
13
13
|
import { Patches } from "../client/Patches.js";
|
|
14
14
|
import { isDocLoaded } from "../shared/utils.js";
|
|
15
15
|
import { blockable } from "../utils/concurrency.js";
|
|
16
|
+
import { ErrorCodes, StatusError } from "./error.js";
|
|
16
17
|
import { PatchesWebSocket } from "./websocket/PatchesWebSocket.js";
|
|
17
18
|
import { onlineState } from "./websocket/onlineState.js";
|
|
18
19
|
const EMPTY_DOC_STATE = {
|
|
@@ -22,7 +23,7 @@ const EMPTY_DOC_STATE = {
|
|
|
22
23
|
isLoaded: false
|
|
23
24
|
};
|
|
24
25
|
class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [blockable], __receiveCommittedChanges_dec = [blockable], _a) {
|
|
25
|
-
constructor(patches,
|
|
26
|
+
constructor(patches, urlOrConnection, options) {
|
|
26
27
|
super({
|
|
27
28
|
online: onlineState.isOnline,
|
|
28
29
|
connected: false,
|
|
@@ -30,7 +31,7 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [blockable],
|
|
|
30
31
|
});
|
|
31
32
|
this.options = options;
|
|
32
33
|
__runInitializers(_init, 5, this);
|
|
33
|
-
__publicField(this, "
|
|
34
|
+
__publicField(this, "connection");
|
|
34
35
|
__publicField(this, "patches");
|
|
35
36
|
__publicField(this, "maxPayloadBytes");
|
|
36
37
|
__publicField(this, "maxStorageBytes");
|
|
@@ -55,13 +56,17 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [blockable],
|
|
|
55
56
|
this.maxPayloadBytes = options?.maxPayloadBytes;
|
|
56
57
|
this.maxStorageBytes = options?.maxStorageBytes ?? patches.docOptions?.maxStorageBytes;
|
|
57
58
|
this.sizeCalculator = options?.sizeCalculator ?? patches.docOptions?.sizeCalculator;
|
|
58
|
-
|
|
59
|
+
if (typeof urlOrConnection === "string") {
|
|
60
|
+
this.connection = new PatchesWebSocket(urlOrConnection, options?.websocket);
|
|
61
|
+
} else {
|
|
62
|
+
this.connection = urlOrConnection;
|
|
63
|
+
}
|
|
59
64
|
this.docStates = store({});
|
|
60
65
|
this.trackedDocs = new Set(patches.trackedDocs);
|
|
61
66
|
onlineState.onOnlineChange((online) => this.updateState({ online }));
|
|
62
|
-
this.
|
|
63
|
-
this.
|
|
64
|
-
this.
|
|
67
|
+
this.connection.onStateChange(this._handleConnectionChange.bind(this));
|
|
68
|
+
this.connection.onChangesCommitted(this._receiveCommittedChanges.bind(this));
|
|
69
|
+
this.connection.onDocDeleted((docId) => this._handleRemoteDocDeleted(docId));
|
|
65
70
|
patches.onTrackDocs(this._handleDocsTracked.bind(this));
|
|
66
71
|
patches.onUntrackDocs(this._handleDocsUntracked.bind(this));
|
|
67
72
|
patches.onDeleteDoc(this._handleDocDeleted.bind(this));
|
|
@@ -82,27 +87,31 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [blockable],
|
|
|
82
87
|
return algorithm;
|
|
83
88
|
}
|
|
84
89
|
/**
|
|
85
|
-
* Gets the URL
|
|
90
|
+
* Gets the server URL.
|
|
86
91
|
*/
|
|
87
92
|
get url() {
|
|
88
|
-
return this.
|
|
93
|
+
return this.connection.url;
|
|
89
94
|
}
|
|
90
95
|
/**
|
|
91
|
-
* Sets the URL
|
|
96
|
+
* Sets the server URL. Reconnects if currently connected.
|
|
92
97
|
*/
|
|
93
98
|
set url(url) {
|
|
94
|
-
this.
|
|
99
|
+
this.connection.url = url;
|
|
95
100
|
if (this.state.connected) {
|
|
96
|
-
this.
|
|
97
|
-
this.
|
|
101
|
+
this.connection.disconnect();
|
|
102
|
+
this.connection.connect();
|
|
98
103
|
}
|
|
99
104
|
}
|
|
100
105
|
/**
|
|
101
106
|
* Gets the JSON-RPC client for making custom RPC calls.
|
|
102
|
-
*
|
|
107
|
+
* Only available when using a WebSocket connection (PatchesWebSocket or PatchesClient).
|
|
108
|
+
* Returns undefined when using REST transport.
|
|
103
109
|
*/
|
|
104
110
|
get rpc() {
|
|
105
|
-
|
|
111
|
+
if ("rpc" in this.connection) {
|
|
112
|
+
return this.connection.rpc;
|
|
113
|
+
}
|
|
114
|
+
return void 0;
|
|
106
115
|
}
|
|
107
116
|
/**
|
|
108
117
|
* Updates the sync state.
|
|
@@ -118,11 +127,11 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [blockable],
|
|
|
118
127
|
}
|
|
119
128
|
}
|
|
120
129
|
/**
|
|
121
|
-
* Connects to the
|
|
130
|
+
* Connects to the server and starts syncing if online. If not online, it will wait for online state.
|
|
122
131
|
*/
|
|
123
132
|
async connect() {
|
|
124
133
|
try {
|
|
125
|
-
await this.
|
|
134
|
+
await this.connection.connect();
|
|
126
135
|
} catch (err) {
|
|
127
136
|
console.error("PatchesSync connection failed:", err);
|
|
128
137
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
@@ -132,10 +141,10 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [blockable],
|
|
|
132
141
|
}
|
|
133
142
|
}
|
|
134
143
|
/**
|
|
135
|
-
* Disconnects from the
|
|
144
|
+
* Disconnects from the server and stops syncing.
|
|
136
145
|
*/
|
|
137
146
|
disconnect() {
|
|
138
|
-
this.
|
|
147
|
+
this.connection.disconnect();
|
|
139
148
|
this.updateState({ connected: false, syncStatus: "unsynced" });
|
|
140
149
|
this._resetSyncingStatuses();
|
|
141
150
|
}
|
|
@@ -181,7 +190,7 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [blockable],
|
|
|
181
190
|
try {
|
|
182
191
|
const subscribeIds = this._filterSubscribeIds(activeDocIds);
|
|
183
192
|
if (subscribeIds.length) {
|
|
184
|
-
await this.
|
|
193
|
+
await this.connection.subscribe(subscribeIds);
|
|
185
194
|
}
|
|
186
195
|
} catch (err) {
|
|
187
196
|
console.warn("Error subscribing to active docs during sync:", err);
|
|
@@ -192,7 +201,7 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [blockable],
|
|
|
192
201
|
const deletePromises = deletedDocs.map(async ({ docId }) => {
|
|
193
202
|
try {
|
|
194
203
|
console.info(`Attempting server delete for tombstoned doc: ${docId}`);
|
|
195
|
-
await this.
|
|
204
|
+
await this.connection.deleteDoc(docId);
|
|
196
205
|
const algorithm = this._getAlgorithm(docId);
|
|
197
206
|
await algorithm.confirmDeleteDoc(docId);
|
|
198
207
|
console.info(`Successfully deleted and untracked doc: ${docId}`);
|
|
@@ -226,12 +235,12 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [blockable],
|
|
|
226
235
|
} else {
|
|
227
236
|
const committedRev = await algorithm.getCommittedRev(docId);
|
|
228
237
|
if (committedRev) {
|
|
229
|
-
const serverChanges = await this.
|
|
238
|
+
const serverChanges = await this.connection.getChangesSince(docId, committedRev);
|
|
230
239
|
if (serverChanges.length > 0) {
|
|
231
240
|
await this._applyServerChangesToDoc(docId, serverChanges);
|
|
232
241
|
}
|
|
233
242
|
} else {
|
|
234
|
-
const snapshot = await this.
|
|
243
|
+
const snapshot = await this.connection.getDoc(docId);
|
|
235
244
|
await algorithm.store.saveDoc(docId, snapshot);
|
|
236
245
|
this._updateDocSyncState(docId, { committedRev: snapshot.rev });
|
|
237
246
|
if (baseDoc) {
|
|
@@ -286,10 +295,10 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [blockable],
|
|
|
286
295
|
if (!this.state.connected) {
|
|
287
296
|
throw new Error("Disconnected during flush");
|
|
288
297
|
}
|
|
289
|
-
const { changes: committed, docReloadRequired } = await this.
|
|
298
|
+
const { changes: committed, docReloadRequired } = await this.connection.commitChanges(docId, changeBatch);
|
|
290
299
|
if (docReloadRequired) {
|
|
291
300
|
await algorithm.confirmSent(docId, changeBatch);
|
|
292
|
-
const snapshot = await this.
|
|
301
|
+
const snapshot = await this.connection.getDoc(docId);
|
|
293
302
|
await algorithm.store.saveDoc(docId, snapshot);
|
|
294
303
|
this._updateDocSyncState(docId, { committedRev: snapshot.rev });
|
|
295
304
|
const openDoc = this.patches.getOpenDoc(docId);
|
|
@@ -344,7 +353,7 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [blockable],
|
|
|
344
353
|
if (this.state.connected) {
|
|
345
354
|
try {
|
|
346
355
|
const algorithm = this._getAlgorithm(docId);
|
|
347
|
-
await this.
|
|
356
|
+
await this.connection.deleteDoc(docId);
|
|
348
357
|
await algorithm.confirmDeleteDoc(docId);
|
|
349
358
|
} catch (err) {
|
|
350
359
|
console.error(`Server delete failed for doc ${docId}, will retry on reconnect/resync.`, err);
|
|
@@ -412,7 +421,7 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [blockable],
|
|
|
412
421
|
try {
|
|
413
422
|
const subscribeIds = this._filterSubscribeIds(newIds).filter((id) => !alreadySubscribed.has(id));
|
|
414
423
|
if (subscribeIds.length) {
|
|
415
|
-
await this.
|
|
424
|
+
await this.connection.subscribe(subscribeIds);
|
|
416
425
|
}
|
|
417
426
|
await Promise.all(newIds.map((id) => this.syncDoc(id)));
|
|
418
427
|
} catch (err) {
|
|
@@ -433,7 +442,7 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [blockable],
|
|
|
433
442
|
const unsubscribeIds = [...subscribedBefore].filter((id) => !subscribedAfter.has(id));
|
|
434
443
|
if (this.state.connected && unsubscribeIds.length) {
|
|
435
444
|
try {
|
|
436
|
-
await this.
|
|
445
|
+
await this.connection.unsubscribe(unsubscribeIds);
|
|
437
446
|
} catch (err) {
|
|
438
447
|
console.warn(`Failed to unsubscribe docs: ${unsubscribeIds.join(", ")}`, err);
|
|
439
448
|
}
|
|
@@ -504,7 +513,7 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [blockable],
|
|
|
504
513
|
}
|
|
505
514
|
/**
|
|
506
515
|
* Applies the subscribeFilter option to a list of doc IDs, returning the subset
|
|
507
|
-
* that should be sent to
|
|
516
|
+
* that should be sent to subscribe/unsubscribe. Returns the full list if no filter is set.
|
|
508
517
|
*/
|
|
509
518
|
_filterSubscribeIds(docIds) {
|
|
510
519
|
return this.options?.subscribeFilter?.(docIds) || docIds;
|
|
@@ -520,7 +529,7 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [blockable],
|
|
|
520
529
|
* Helper to detect DOC_DELETED (410) errors from the server.
|
|
521
530
|
*/
|
|
522
531
|
_isDocDeletedError(err) {
|
|
523
|
-
return
|
|
532
|
+
return err instanceof StatusError && err.code === ErrorCodes.DOC_DELETED;
|
|
524
533
|
}
|
|
525
534
|
}
|
|
526
535
|
_init = __decoratorStart(_a);
|
package/dist/net/index.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
export { FetchTransport } from './http/FetchTransport.js';
|
|
2
2
|
export { PatchesClient } from './PatchesClient.js';
|
|
3
|
+
export { PatchesConnection } from './PatchesConnection.js';
|
|
3
4
|
export { PatchesSync, PatchesSyncOptions, PatchesSyncState } from './PatchesSync.js';
|
|
5
|
+
export { PatchesREST, PatchesRESTOptions } from './rest/PatchesREST.js';
|
|
6
|
+
export { BufferedEvent, SSEServer, SSEServerOptions } from './rest/SSEServer.js';
|
|
7
|
+
export { encodeDocId, normalizeIds } from './rest/utils.js';
|
|
4
8
|
export { JSONRPCClient } from './protocol/JSONRPCClient.js';
|
|
5
9
|
export { AccessLevel, ApiDefinition, ConnectionSignalSubscriber, JSONRPCServer, JSONRPCServerOptions, MessageHandler } from './protocol/JSONRPCServer.js';
|
|
6
10
|
export { getAuthContext, getClientId } from './serverContext.js';
|
package/dist/net/index.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import "../chunk-IZ2YBCUP.js";
|
|
2
2
|
export * from "./http/FetchTransport.js";
|
|
3
3
|
export * from "./PatchesClient.js";
|
|
4
|
+
export * from "./PatchesConnection.js";
|
|
4
5
|
export * from "./PatchesSync.js";
|
|
6
|
+
export * from "./rest/index.js";
|
|
5
7
|
export * from "./protocol/JSONRPCClient.js";
|
|
6
8
|
export * from "./protocol/JSONRPCServer.js";
|
|
7
9
|
import { getAuthContext, getClientId } from "./serverContext.js";
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import * as easy_signal from 'easy-signal';
|
|
2
|
+
import { Change, CommitChangesOptions, PatchesState, ChangeInput, DeleteDocOptions, EditableVersionMetadata, ListVersionsOptions, VersionMetadata, PatchesSnapshot } from '../../types.js';
|
|
3
|
+
import { PatchesConnection } from '../PatchesConnection.js';
|
|
4
|
+
import { ConnectionState } from '../protocol/types.js';
|
|
5
|
+
import '../../json-patch/JSONPatch.js';
|
|
6
|
+
import '@dabble/delta';
|
|
7
|
+
import '../../json-patch/types.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Options for creating a PatchesREST instance.
|
|
11
|
+
*/
|
|
12
|
+
interface PatchesRESTOptions {
|
|
13
|
+
/**
|
|
14
|
+
* Explicit client ID. If not provided, restored from sessionStorage (when available)
|
|
15
|
+
* or generated via crypto.randomUUID(). Persisted to sessionStorage automatically.
|
|
16
|
+
*/
|
|
17
|
+
clientId?: string;
|
|
18
|
+
/** Static headers added to every fetch request (e.g. Authorization). */
|
|
19
|
+
headers?: Record<string, string>;
|
|
20
|
+
/**
|
|
21
|
+
* Dynamic header provider called before every fetch request. Useful for token refresh.
|
|
22
|
+
* Merged on top of static headers (same-name keys override).
|
|
23
|
+
*/
|
|
24
|
+
getHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Client for the Patches real-time collaboration service using SSE + REST.
|
|
28
|
+
*
|
|
29
|
+
* Uses Server-Sent Events for receiving other clients' changes and HTTP/fetch
|
|
30
|
+
* for sending changes. Drop-in replacement for PatchesWebSocket when paired
|
|
31
|
+
* with PatchesSync.
|
|
32
|
+
*/
|
|
33
|
+
declare class PatchesREST implements PatchesConnection {
|
|
34
|
+
/** The client ID used for SSE connection and subscription management. */
|
|
35
|
+
readonly clientId: string;
|
|
36
|
+
readonly onStateChange: easy_signal.Signal<(state: ConnectionState) => void>;
|
|
37
|
+
readonly onChangesCommitted: easy_signal.Signal<(docId: string, changes: Change[], options?: CommitChangesOptions) => void>;
|
|
38
|
+
readonly onDocDeleted: easy_signal.Signal<(docId: string) => void>;
|
|
39
|
+
private _url;
|
|
40
|
+
private _state;
|
|
41
|
+
private eventSource;
|
|
42
|
+
private options;
|
|
43
|
+
private shouldBeConnected;
|
|
44
|
+
private onlineUnsubscriber;
|
|
45
|
+
constructor(url: string, options?: PatchesRESTOptions);
|
|
46
|
+
get url(): string;
|
|
47
|
+
set url(url: string);
|
|
48
|
+
connect(): Promise<void>;
|
|
49
|
+
disconnect(): void;
|
|
50
|
+
subscribe(ids: string | string[]): Promise<string[]>;
|
|
51
|
+
unsubscribe(ids: string | string[]): Promise<void>;
|
|
52
|
+
getDoc<T = any>(docId: string): Promise<PatchesState<T>>;
|
|
53
|
+
getChangesSince(docId: string, rev: number): Promise<Change[]>;
|
|
54
|
+
commitChanges(docId: string, changes: ChangeInput[], options?: CommitChangesOptions): Promise<{
|
|
55
|
+
changes: Change[];
|
|
56
|
+
docReloadRequired?: true;
|
|
57
|
+
}>;
|
|
58
|
+
deleteDoc(docId: string, _options?: DeleteDocOptions): Promise<void>;
|
|
59
|
+
createVersion(docId: string, metadata: EditableVersionMetadata): Promise<string>;
|
|
60
|
+
listVersions(docId: string, options?: ListVersionsOptions): Promise<VersionMetadata[]>;
|
|
61
|
+
getVersionState(docId: string, versionId: string): Promise<PatchesSnapshot>;
|
|
62
|
+
getVersionChanges(docId: string, versionId: string): Promise<Change[]>;
|
|
63
|
+
updateVersion(docId: string, versionId: string, metadata: EditableVersionMetadata): Promise<void>;
|
|
64
|
+
listBranches(docId: string): Promise<VersionMetadata[]>;
|
|
65
|
+
createBranch(docId: string, rev: number, metadata?: EditableVersionMetadata): Promise<string>;
|
|
66
|
+
closeBranch(branchId: string): Promise<void>;
|
|
67
|
+
mergeBranch(branchId: string): Promise<void>;
|
|
68
|
+
private _setState;
|
|
69
|
+
private _getHeaders;
|
|
70
|
+
private _fetch;
|
|
71
|
+
private _ensureOnlineOfflineListeners;
|
|
72
|
+
private _removeOnlineOfflineListeners;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export { PatchesREST, type PatchesRESTOptions };
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import "../../chunk-IZ2YBCUP.js";
|
|
2
|
+
import { signal } from "easy-signal";
|
|
3
|
+
import { StatusError } from "../error.js";
|
|
4
|
+
import { onlineState } from "../websocket/onlineState.js";
|
|
5
|
+
import { encodeDocId, normalizeIds } from "./utils.js";
|
|
6
|
+
const SESSION_STORAGE_KEY = "patches-clientId";
|
|
7
|
+
class PatchesREST {
|
|
8
|
+
/** The client ID used for SSE connection and subscription management. */
|
|
9
|
+
clientId;
|
|
10
|
+
// --- Public Signals ---
|
|
11
|
+
onStateChange = signal();
|
|
12
|
+
onChangesCommitted = signal();
|
|
13
|
+
onDocDeleted = signal();
|
|
14
|
+
_url;
|
|
15
|
+
_state = "disconnected";
|
|
16
|
+
eventSource = null;
|
|
17
|
+
options;
|
|
18
|
+
shouldBeConnected = false;
|
|
19
|
+
onlineUnsubscriber = null;
|
|
20
|
+
constructor(url, options) {
|
|
21
|
+
this._url = url.replace(/\/$/, "");
|
|
22
|
+
this.options = options ?? {};
|
|
23
|
+
const storage = typeof globalThis.sessionStorage !== "undefined" ? globalThis.sessionStorage : void 0;
|
|
24
|
+
this.clientId = this.options.clientId ?? storage?.getItem(SESSION_STORAGE_KEY) ?? globalThis.crypto.randomUUID();
|
|
25
|
+
try {
|
|
26
|
+
storage?.setItem(SESSION_STORAGE_KEY, this.clientId);
|
|
27
|
+
} catch {
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// --- URL ---
|
|
31
|
+
get url() {
|
|
32
|
+
return this._url;
|
|
33
|
+
}
|
|
34
|
+
set url(url) {
|
|
35
|
+
this._url = url.replace(/\/$/, "");
|
|
36
|
+
}
|
|
37
|
+
// --- Connection Lifecycle ---
|
|
38
|
+
connect() {
|
|
39
|
+
this.shouldBeConnected = true;
|
|
40
|
+
this._ensureOnlineOfflineListeners();
|
|
41
|
+
if (onlineState.isOffline) {
|
|
42
|
+
return Promise.resolve();
|
|
43
|
+
}
|
|
44
|
+
if (this.eventSource) {
|
|
45
|
+
return Promise.resolve();
|
|
46
|
+
}
|
|
47
|
+
this._setState("connecting");
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const es = new EventSource(`${this._url}/events/${this.clientId}`);
|
|
50
|
+
this.eventSource = es;
|
|
51
|
+
let settled = false;
|
|
52
|
+
es.onopen = () => {
|
|
53
|
+
this._setState("connected");
|
|
54
|
+
if (!settled) {
|
|
55
|
+
settled = true;
|
|
56
|
+
resolve();
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
es.onerror = () => {
|
|
60
|
+
if (!settled) {
|
|
61
|
+
settled = true;
|
|
62
|
+
this._setState("error");
|
|
63
|
+
reject(new Error("SSE connection failed"));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (this._state === "connected") {
|
|
67
|
+
this._setState("disconnected");
|
|
68
|
+
this._setState("connecting");
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
es.addEventListener("changesCommitted", (e) => {
|
|
72
|
+
try {
|
|
73
|
+
const { docId, changes, options } = JSON.parse(e.data);
|
|
74
|
+
this.onChangesCommitted.emit(docId, changes, options);
|
|
75
|
+
} catch {
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
es.addEventListener("docDeleted", (e) => {
|
|
79
|
+
try {
|
|
80
|
+
const { docId } = JSON.parse(e.data);
|
|
81
|
+
this.onDocDeleted.emit(docId);
|
|
82
|
+
} catch {
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
es.addEventListener("resync", () => {
|
|
86
|
+
if (!this.shouldBeConnected) return;
|
|
87
|
+
this._setState("disconnected");
|
|
88
|
+
this._setState("connected");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
disconnect() {
|
|
93
|
+
this.shouldBeConnected = false;
|
|
94
|
+
this._removeOnlineOfflineListeners();
|
|
95
|
+
if (this.eventSource) {
|
|
96
|
+
this.eventSource.close();
|
|
97
|
+
this.eventSource = null;
|
|
98
|
+
}
|
|
99
|
+
this._setState("disconnected");
|
|
100
|
+
}
|
|
101
|
+
// --- PatchesAPI: Subscriptions ---
|
|
102
|
+
async subscribe(ids) {
|
|
103
|
+
const result = await this._fetch(`/subscriptions/${this.clientId}`, {
|
|
104
|
+
method: "POST",
|
|
105
|
+
body: { docIds: normalizeIds(ids) }
|
|
106
|
+
});
|
|
107
|
+
return result.docIds;
|
|
108
|
+
}
|
|
109
|
+
async unsubscribe(ids) {
|
|
110
|
+
await this._fetch(`/subscriptions/${this.clientId}`, {
|
|
111
|
+
method: "DELETE",
|
|
112
|
+
body: { docIds: normalizeIds(ids) }
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
// --- PatchesAPI: Documents ---
|
|
116
|
+
async getDoc(docId) {
|
|
117
|
+
return this._fetch(`/docs/${encodeDocId(docId)}`);
|
|
118
|
+
}
|
|
119
|
+
async getChangesSince(docId, rev) {
|
|
120
|
+
return this._fetch(`/docs/${encodeDocId(docId)}/_changes?since=${rev}`);
|
|
121
|
+
}
|
|
122
|
+
async commitChanges(docId, changes, options) {
|
|
123
|
+
return this._fetch(`/docs/${encodeDocId(docId)}/_changes`, {
|
|
124
|
+
method: "POST",
|
|
125
|
+
body: { changes, options }
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
async deleteDoc(docId, _options) {
|
|
129
|
+
await this._fetch(`/docs/${encodeDocId(docId)}`, { method: "DELETE" });
|
|
130
|
+
}
|
|
131
|
+
// --- PatchesAPI: Versions ---
|
|
132
|
+
async createVersion(docId, metadata) {
|
|
133
|
+
return this._fetch(`/docs/${encodeDocId(docId)}/_versions`, {
|
|
134
|
+
method: "POST",
|
|
135
|
+
body: metadata
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
async listVersions(docId, options) {
|
|
139
|
+
const params = options ? `?${new URLSearchParams(options)}` : "";
|
|
140
|
+
return this._fetch(`/docs/${encodeDocId(docId)}/_versions${params}`);
|
|
141
|
+
}
|
|
142
|
+
async getVersionState(docId, versionId) {
|
|
143
|
+
return this._fetch(`/docs/${encodeDocId(docId)}/_versions/${encodeURIComponent(versionId)}`);
|
|
144
|
+
}
|
|
145
|
+
async getVersionChanges(docId, versionId) {
|
|
146
|
+
return this._fetch(`/docs/${encodeDocId(docId)}/_versions/${encodeURIComponent(versionId)}/_changes`);
|
|
147
|
+
}
|
|
148
|
+
async updateVersion(docId, versionId, metadata) {
|
|
149
|
+
await this._fetch(`/docs/${encodeDocId(docId)}/_versions/${encodeURIComponent(versionId)}`, {
|
|
150
|
+
method: "PUT",
|
|
151
|
+
body: metadata
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
// --- Branch Operations (not in PatchesAPI but matches PatchesClient feature parity) ---
|
|
155
|
+
async listBranches(docId) {
|
|
156
|
+
return this._fetch(`/docs/${encodeDocId(docId)}/_branches`);
|
|
157
|
+
}
|
|
158
|
+
async createBranch(docId, rev, metadata) {
|
|
159
|
+
return this._fetch(`/docs/${encodeDocId(docId)}/_branches`, {
|
|
160
|
+
method: "POST",
|
|
161
|
+
body: { rev, ...metadata }
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
async closeBranch(branchId) {
|
|
165
|
+
await this._fetch(`/docs/${encodeDocId(branchId)}`, { method: "DELETE" });
|
|
166
|
+
}
|
|
167
|
+
async mergeBranch(branchId) {
|
|
168
|
+
await this._fetch(`/docs/${encodeDocId(branchId)}/_merge`, { method: "POST" });
|
|
169
|
+
}
|
|
170
|
+
// --- Private Helpers ---
|
|
171
|
+
_setState(state) {
|
|
172
|
+
if (state === this._state) return;
|
|
173
|
+
this._state = state;
|
|
174
|
+
this.onStateChange.emit(state);
|
|
175
|
+
}
|
|
176
|
+
async _getHeaders() {
|
|
177
|
+
const staticHeaders = this.options.headers ?? {};
|
|
178
|
+
if (this.options.getHeaders) {
|
|
179
|
+
const dynamic = await this.options.getHeaders();
|
|
180
|
+
return { ...staticHeaders, ...dynamic };
|
|
181
|
+
}
|
|
182
|
+
return staticHeaders;
|
|
183
|
+
}
|
|
184
|
+
async _fetch(path, init) {
|
|
185
|
+
const headers = await this._getHeaders();
|
|
186
|
+
const method = init?.method ?? "GET";
|
|
187
|
+
const hasBody = init?.body !== void 0;
|
|
188
|
+
const response = await globalThis.fetch(`${this._url}${path}`, {
|
|
189
|
+
method,
|
|
190
|
+
headers: {
|
|
191
|
+
...hasBody ? { "Content-Type": "application/json" } : {},
|
|
192
|
+
...headers
|
|
193
|
+
},
|
|
194
|
+
body: hasBody ? JSON.stringify(init.body) : void 0
|
|
195
|
+
});
|
|
196
|
+
if (!response.ok) {
|
|
197
|
+
let message = response.statusText;
|
|
198
|
+
let data;
|
|
199
|
+
try {
|
|
200
|
+
const json = await response.json();
|
|
201
|
+
message = json.message ?? json.error ?? message;
|
|
202
|
+
data = json.data;
|
|
203
|
+
} catch {
|
|
204
|
+
}
|
|
205
|
+
throw new StatusError(response.status, message, data);
|
|
206
|
+
}
|
|
207
|
+
if (response.status === 204) {
|
|
208
|
+
return void 0;
|
|
209
|
+
}
|
|
210
|
+
return response.json();
|
|
211
|
+
}
|
|
212
|
+
_ensureOnlineOfflineListeners() {
|
|
213
|
+
if (!this.onlineUnsubscriber) {
|
|
214
|
+
this.onlineUnsubscriber = onlineState.onOnlineChange((isOnline) => {
|
|
215
|
+
if (isOnline && this.shouldBeConnected && !this.eventSource) {
|
|
216
|
+
this.connect();
|
|
217
|
+
} else if (!isOnline && this.eventSource) {
|
|
218
|
+
this.eventSource.close();
|
|
219
|
+
this.eventSource = null;
|
|
220
|
+
this._setState("disconnected");
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
_removeOnlineOfflineListeners() {
|
|
226
|
+
if (this.onlineUnsubscriber) {
|
|
227
|
+
this.onlineUnsubscriber();
|
|
228
|
+
this.onlineUnsubscriber = null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
export {
|
|
233
|
+
PatchesREST
|
|
234
|
+
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { AuthorizationProvider, AuthContext } from '../websocket/AuthorizationProvider.js';
|
|
2
|
+
import '../../server/types.js';
|
|
3
|
+
import '../../json-patch/types.js';
|
|
4
|
+
import '../../types.js';
|
|
5
|
+
import '../../json-patch/JSONPatch.js';
|
|
6
|
+
import '@dabble/delta';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A buffered SSE event waiting to be sent or replayed.
|
|
10
|
+
*/
|
|
11
|
+
interface BufferedEvent {
|
|
12
|
+
id: number;
|
|
13
|
+
event: string;
|
|
14
|
+
data: string;
|
|
15
|
+
timestamp: number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Options for creating an SSEServer instance.
|
|
19
|
+
*/
|
|
20
|
+
interface SSEServerOptions {
|
|
21
|
+
/** Heartbeat interval in milliseconds. Default: 30000 (30 seconds). */
|
|
22
|
+
heartbeatIntervalMs?: number;
|
|
23
|
+
/** How long to keep client state after disconnect. Default: 300000 (5 minutes). */
|
|
24
|
+
bufferTTLMs?: number;
|
|
25
|
+
/**
|
|
26
|
+
* Sliding window for the event buffer while connected, in milliseconds.
|
|
27
|
+
* Must cover the worst-case gap between a silent network failure and
|
|
28
|
+
* TCP detecting it (heartbeat interval + TCP retransmission timeout).
|
|
29
|
+
* Default: bufferTTLMs (matches the disconnect buffer lifetime).
|
|
30
|
+
*/
|
|
31
|
+
bufferWindowMs?: number;
|
|
32
|
+
/** Authorization provider for subscription access control. */
|
|
33
|
+
auth?: AuthorizationProvider;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Framework-agnostic SSE server for the Patches real-time collaboration service.
|
|
37
|
+
*
|
|
38
|
+
* Manages SSE connections, document subscriptions, per-client event buffering,
|
|
39
|
+
* and heartbeats. Does NOT create HTTP routes — the framework calls these methods
|
|
40
|
+
* from its route handlers.
|
|
41
|
+
*
|
|
42
|
+
* Returns standard ReadableStream from connect(), compatible with any Web Standard
|
|
43
|
+
* framework (Hono, Express + node:stream, Cloudflare Workers, Deno, etc.).
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```typescript
|
|
47
|
+
* const sse = new SSEServer();
|
|
48
|
+
*
|
|
49
|
+
* // GET /events/:clientId
|
|
50
|
+
* app.get('/events/:clientId', (c) => {
|
|
51
|
+
* const stream = sse.connect(
|
|
52
|
+
* c.req.param('clientId'),
|
|
53
|
+
* c.req.header('Last-Event-ID')
|
|
54
|
+
* );
|
|
55
|
+
* return new Response(stream, {
|
|
56
|
+
* headers: {
|
|
57
|
+
* 'Content-Type': 'text/event-stream',
|
|
58
|
+
* 'Cache-Control': 'no-cache',
|
|
59
|
+
* 'Connection': 'keep-alive',
|
|
60
|
+
* 'X-Accel-Buffering': 'no',
|
|
61
|
+
* },
|
|
62
|
+
* });
|
|
63
|
+
* });
|
|
64
|
+
*
|
|
65
|
+
* // POST /subscriptions/:clientId
|
|
66
|
+
* app.post('/subscriptions/:clientId', async (c) => {
|
|
67
|
+
* const { docIds } = await c.req.json();
|
|
68
|
+
* const ctx = { clientId: c.req.param('clientId'), ...authData };
|
|
69
|
+
* const subscribed = await sse.subscribe(c.req.param('clientId'), docIds, ctx);
|
|
70
|
+
* return c.json({ docIds: subscribed });
|
|
71
|
+
* });
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
declare class SSEServer {
|
|
75
|
+
private clients;
|
|
76
|
+
private heartbeatInterval;
|
|
77
|
+
private heartbeatIntervalMs;
|
|
78
|
+
private bufferTTLMs;
|
|
79
|
+
private bufferWindowMs;
|
|
80
|
+
private auth?;
|
|
81
|
+
constructor(options?: SSEServerOptions);
|
|
82
|
+
private static noop;
|
|
83
|
+
/**
|
|
84
|
+
* Opens an SSE connection for a client. Returns a ReadableStream that the
|
|
85
|
+
* framework pipes to the HTTP response.
|
|
86
|
+
*
|
|
87
|
+
* If `lastEventId` is provided (from the Last-Event-ID header on reconnect),
|
|
88
|
+
* buffered events are replayed. If the buffer has expired, a `resync` event
|
|
89
|
+
* is sent so the client knows to do a full re-sync.
|
|
90
|
+
*
|
|
91
|
+
* The framework MUST call `disconnect(clientId)` when the response closes.
|
|
92
|
+
*/
|
|
93
|
+
connect(clientId: string, lastEventId?: string): ReadableStream<Uint8Array>;
|
|
94
|
+
/**
|
|
95
|
+
* Called when the SSE response stream closes. The framework MUST call this
|
|
96
|
+
* when the HTTP response ends (e.g., on the response 'close' event).
|
|
97
|
+
*
|
|
98
|
+
* Starts the buffer expiry timer. If the client reconnects before it expires,
|
|
99
|
+
* buffered events are replayed.
|
|
100
|
+
*/
|
|
101
|
+
disconnect(clientId: string): void;
|
|
102
|
+
/**
|
|
103
|
+
* Subscribe a client to documents.
|
|
104
|
+
*
|
|
105
|
+
* @param clientId - The client to subscribe.
|
|
106
|
+
* @param docIds - Document IDs to subscribe to.
|
|
107
|
+
* @param ctx - Auth context for authorization checks.
|
|
108
|
+
* @returns The list of document IDs successfully subscribed.
|
|
109
|
+
*/
|
|
110
|
+
subscribe(clientId: string, docIds: string[], ctx?: AuthContext): Promise<string[]>;
|
|
111
|
+
/**
|
|
112
|
+
* Unsubscribe a client from documents.
|
|
113
|
+
*/
|
|
114
|
+
unsubscribe(clientId: string, docIds: string[]): void;
|
|
115
|
+
/**
|
|
116
|
+
* Send a notification event to all clients subscribed to the document.
|
|
117
|
+
*
|
|
118
|
+
* Connected clients receive the event immediately via their SSE stream.
|
|
119
|
+
* Disconnected clients (within buffer TTL) get the event buffered for replay.
|
|
120
|
+
*
|
|
121
|
+
* @param event - The SSE event type (e.g. 'changesCommitted', 'docDeleted').
|
|
122
|
+
* @param params - Event data. Must include `docId` for subscription routing.
|
|
123
|
+
* @param exceptClientId - Client ID to exclude (typically the one who made the change).
|
|
124
|
+
*/
|
|
125
|
+
notify(event: string, params: {
|
|
126
|
+
docId: string;
|
|
127
|
+
[k: string]: any;
|
|
128
|
+
}, exceptClientId?: string): void;
|
|
129
|
+
/**
|
|
130
|
+
* List all client IDs subscribed to a document.
|
|
131
|
+
*/
|
|
132
|
+
listSubscriptions(docId: string): string[];
|
|
133
|
+
/**
|
|
134
|
+
* Get all active (connected) client IDs.
|
|
135
|
+
*/
|
|
136
|
+
getConnectionIds(): string[];
|
|
137
|
+
/**
|
|
138
|
+
* Clean up all resources (heartbeat interval, client buffers, timers).
|
|
139
|
+
*/
|
|
140
|
+
destroy(): void;
|
|
141
|
+
/**
|
|
142
|
+
* Trim buffer to keep only events within the sliding window (while connected)
|
|
143
|
+
* or all events (while disconnected — those are managed by the TTL timer).
|
|
144
|
+
*/
|
|
145
|
+
private _trimBuffer;
|
|
146
|
+
private _writeEvent;
|
|
147
|
+
private _startHeartbeat;
|
|
148
|
+
private _cleanupClient;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export { type BufferedEvent, SSEServer, type SSEServerOptions };
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import "../../chunk-IZ2YBCUP.js";
|
|
2
|
+
class SSEServer {
|
|
3
|
+
clients = /* @__PURE__ */ new Map();
|
|
4
|
+
heartbeatInterval = null;
|
|
5
|
+
heartbeatIntervalMs;
|
|
6
|
+
bufferTTLMs;
|
|
7
|
+
bufferWindowMs;
|
|
8
|
+
auth;
|
|
9
|
+
constructor(options) {
|
|
10
|
+
this.heartbeatIntervalMs = options?.heartbeatIntervalMs ?? 3e4;
|
|
11
|
+
this.bufferTTLMs = options?.bufferTTLMs ?? 3e5;
|
|
12
|
+
this.bufferWindowMs = options?.bufferWindowMs ?? this.bufferTTLMs;
|
|
13
|
+
this.auth = options?.auth;
|
|
14
|
+
this._startHeartbeat();
|
|
15
|
+
}
|
|
16
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
|
17
|
+
static noop() {
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Opens an SSE connection for a client. Returns a ReadableStream that the
|
|
21
|
+
* framework pipes to the HTTP response.
|
|
22
|
+
*
|
|
23
|
+
* If `lastEventId` is provided (from the Last-Event-ID header on reconnect),
|
|
24
|
+
* buffered events are replayed. If the buffer has expired, a `resync` event
|
|
25
|
+
* is sent so the client knows to do a full re-sync.
|
|
26
|
+
*
|
|
27
|
+
* The framework MUST call `disconnect(clientId)` when the response closes.
|
|
28
|
+
*/
|
|
29
|
+
connect(clientId, lastEventId) {
|
|
30
|
+
let client = this.clients.get(clientId);
|
|
31
|
+
if (client) {
|
|
32
|
+
if (client.expiryTimer) {
|
|
33
|
+
globalThis.clearTimeout(client.expiryTimer);
|
|
34
|
+
client.expiryTimer = null;
|
|
35
|
+
}
|
|
36
|
+
if (client.writer) {
|
|
37
|
+
client.writer.close().catch(SSEServer.noop);
|
|
38
|
+
client.writer = null;
|
|
39
|
+
}
|
|
40
|
+
client.disconnectedAt = null;
|
|
41
|
+
} else {
|
|
42
|
+
client = {
|
|
43
|
+
writer: null,
|
|
44
|
+
encoder: new TextEncoder(),
|
|
45
|
+
subscriptions: /* @__PURE__ */ new Set(),
|
|
46
|
+
buffer: [],
|
|
47
|
+
nextEventId: 1,
|
|
48
|
+
disconnectedAt: null,
|
|
49
|
+
expiryTimer: null
|
|
50
|
+
};
|
|
51
|
+
this.clients.set(clientId, client);
|
|
52
|
+
}
|
|
53
|
+
const { readable, writable } = new TransformStream();
|
|
54
|
+
client.writer = writable.getWriter();
|
|
55
|
+
if (lastEventId) {
|
|
56
|
+
const lastId = parseInt(lastEventId, 10);
|
|
57
|
+
if (isNaN(lastId)) {
|
|
58
|
+
this._writeEvent(client, { id: client.nextEventId++, event: "resync", data: "{}", timestamp: Date.now() });
|
|
59
|
+
client.buffer = [];
|
|
60
|
+
} else {
|
|
61
|
+
const knownClient = client.nextEventId > 1;
|
|
62
|
+
client.buffer = client.buffer.filter((e) => e.id > lastId);
|
|
63
|
+
if (!knownClient && lastId > 0) {
|
|
64
|
+
this._writeEvent(client, { id: client.nextEventId++, event: "resync", data: "{}", timestamp: Date.now() });
|
|
65
|
+
} else {
|
|
66
|
+
for (const event of client.buffer) {
|
|
67
|
+
this._writeEvent(client, event);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return readable;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Called when the SSE response stream closes. The framework MUST call this
|
|
76
|
+
* when the HTTP response ends (e.g., on the response 'close' event).
|
|
77
|
+
*
|
|
78
|
+
* Starts the buffer expiry timer. If the client reconnects before it expires,
|
|
79
|
+
* buffered events are replayed.
|
|
80
|
+
*/
|
|
81
|
+
disconnect(clientId) {
|
|
82
|
+
const client = this.clients.get(clientId);
|
|
83
|
+
if (!client) return;
|
|
84
|
+
client.writer = null;
|
|
85
|
+
client.disconnectedAt = Date.now();
|
|
86
|
+
client.expiryTimer = globalThis.setTimeout(() => {
|
|
87
|
+
this._cleanupClient(clientId);
|
|
88
|
+
}, this.bufferTTLMs);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Subscribe a client to documents.
|
|
92
|
+
*
|
|
93
|
+
* @param clientId - The client to subscribe.
|
|
94
|
+
* @param docIds - Document IDs to subscribe to.
|
|
95
|
+
* @param ctx - Auth context for authorization checks.
|
|
96
|
+
* @returns The list of document IDs successfully subscribed.
|
|
97
|
+
*/
|
|
98
|
+
async subscribe(clientId, docIds, ctx) {
|
|
99
|
+
const client = this.clients.get(clientId);
|
|
100
|
+
if (!client) return [];
|
|
101
|
+
const allowed = [];
|
|
102
|
+
for (const docId of docIds) {
|
|
103
|
+
if (this.auth) {
|
|
104
|
+
try {
|
|
105
|
+
const ok = await this.auth.canAccess(ctx, docId, "read", "subscribe");
|
|
106
|
+
if (!ok) continue;
|
|
107
|
+
} catch {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
allowed.push(docId);
|
|
112
|
+
client.subscriptions.add(docId);
|
|
113
|
+
}
|
|
114
|
+
return allowed;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Unsubscribe a client from documents.
|
|
118
|
+
*/
|
|
119
|
+
unsubscribe(clientId, docIds) {
|
|
120
|
+
const client = this.clients.get(clientId);
|
|
121
|
+
if (!client) return;
|
|
122
|
+
for (const docId of docIds) {
|
|
123
|
+
client.subscriptions.delete(docId);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Send a notification event to all clients subscribed to the document.
|
|
128
|
+
*
|
|
129
|
+
* Connected clients receive the event immediately via their SSE stream.
|
|
130
|
+
* Disconnected clients (within buffer TTL) get the event buffered for replay.
|
|
131
|
+
*
|
|
132
|
+
* @param event - The SSE event type (e.g. 'changesCommitted', 'docDeleted').
|
|
133
|
+
* @param params - Event data. Must include `docId` for subscription routing.
|
|
134
|
+
* @param exceptClientId - Client ID to exclude (typically the one who made the change).
|
|
135
|
+
*/
|
|
136
|
+
notify(event, params, exceptClientId) {
|
|
137
|
+
const { docId } = params;
|
|
138
|
+
const data = JSON.stringify(params);
|
|
139
|
+
for (const [clientId, client] of this.clients) {
|
|
140
|
+
if (clientId === exceptClientId) continue;
|
|
141
|
+
if (!client.subscriptions.has(docId)) continue;
|
|
142
|
+
const now = Date.now();
|
|
143
|
+
const buffered = {
|
|
144
|
+
id: client.nextEventId++,
|
|
145
|
+
event,
|
|
146
|
+
data,
|
|
147
|
+
timestamp: now
|
|
148
|
+
};
|
|
149
|
+
client.buffer.push(buffered);
|
|
150
|
+
this._trimBuffer(client, now);
|
|
151
|
+
if (client.writer) {
|
|
152
|
+
this._writeEvent(client, buffered);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* List all client IDs subscribed to a document.
|
|
158
|
+
*/
|
|
159
|
+
listSubscriptions(docId) {
|
|
160
|
+
const result = [];
|
|
161
|
+
for (const [clientId, client] of this.clients) {
|
|
162
|
+
if (client.subscriptions.has(docId)) {
|
|
163
|
+
result.push(clientId);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Get all active (connected) client IDs.
|
|
170
|
+
*/
|
|
171
|
+
getConnectionIds() {
|
|
172
|
+
const result = [];
|
|
173
|
+
for (const [clientId, client] of this.clients) {
|
|
174
|
+
if (client.writer) {
|
|
175
|
+
result.push(clientId);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return result;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Clean up all resources (heartbeat interval, client buffers, timers).
|
|
182
|
+
*/
|
|
183
|
+
destroy() {
|
|
184
|
+
if (this.heartbeatInterval) {
|
|
185
|
+
globalThis.clearInterval(this.heartbeatInterval);
|
|
186
|
+
this.heartbeatInterval = null;
|
|
187
|
+
}
|
|
188
|
+
for (const [, client] of this.clients) {
|
|
189
|
+
if (client.expiryTimer) {
|
|
190
|
+
globalThis.clearTimeout(client.expiryTimer);
|
|
191
|
+
}
|
|
192
|
+
if (client.writer) {
|
|
193
|
+
client.writer.close().catch(SSEServer.noop);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
this.clients.clear();
|
|
197
|
+
}
|
|
198
|
+
// --- Private Helpers ---
|
|
199
|
+
/**
|
|
200
|
+
* Trim buffer to keep only events within the sliding window (while connected)
|
|
201
|
+
* or all events (while disconnected — those are managed by the TTL timer).
|
|
202
|
+
*/
|
|
203
|
+
_trimBuffer(client, now) {
|
|
204
|
+
if (client.disconnectedAt !== null) return;
|
|
205
|
+
const cutoff = now - this.bufferWindowMs;
|
|
206
|
+
const idx = client.buffer.findIndex((e) => e.timestamp >= cutoff);
|
|
207
|
+
if (idx > 0) {
|
|
208
|
+
client.buffer = client.buffer.slice(idx);
|
|
209
|
+
} else if (idx === -1) {
|
|
210
|
+
client.buffer = [];
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
_writeEvent(client, event) {
|
|
214
|
+
if (!client.writer) return;
|
|
215
|
+
const msg = `id: ${event.id}
|
|
216
|
+
event: ${event.event}
|
|
217
|
+
data: ${event.data}
|
|
218
|
+
|
|
219
|
+
`;
|
|
220
|
+
client.writer.write(client.encoder.encode(msg)).catch(() => {
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
_startHeartbeat() {
|
|
224
|
+
this.heartbeatInterval = globalThis.setInterval(() => {
|
|
225
|
+
const heartbeat = ": heartbeat\n\n";
|
|
226
|
+
for (const client of this.clients.values()) {
|
|
227
|
+
if (client.writer) {
|
|
228
|
+
client.writer.write(client.encoder.encode(heartbeat)).catch(() => {
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}, this.heartbeatIntervalMs);
|
|
233
|
+
}
|
|
234
|
+
_cleanupClient(clientId) {
|
|
235
|
+
const client = this.clients.get(clientId);
|
|
236
|
+
if (!client) return;
|
|
237
|
+
if (client.expiryTimer) {
|
|
238
|
+
globalThis.clearTimeout(client.expiryTimer);
|
|
239
|
+
}
|
|
240
|
+
if (client.writer) {
|
|
241
|
+
client.writer.close().catch(SSEServer.noop);
|
|
242
|
+
}
|
|
243
|
+
this.clients.delete(clientId);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
export {
|
|
247
|
+
SSEServer
|
|
248
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { PatchesREST, PatchesRESTOptions } from './PatchesREST.js';
|
|
2
|
+
export { BufferedEvent, SSEServer, SSEServerOptions } from './SSEServer.js';
|
|
3
|
+
export { encodeDocId, normalizeIds } from './utils.js';
|
|
4
|
+
import 'easy-signal';
|
|
5
|
+
import '../../types.js';
|
|
6
|
+
import '../../json-patch/JSONPatch.js';
|
|
7
|
+
import '@dabble/delta';
|
|
8
|
+
import '../../json-patch/types.js';
|
|
9
|
+
import '../PatchesConnection.js';
|
|
10
|
+
import '../protocol/types.js';
|
|
11
|
+
import '../websocket/AuthorizationProvider.js';
|
|
12
|
+
import '../../server/types.js';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encode a hierarchical doc ID for use in URL path segments.
|
|
3
|
+
* Each segment is individually encoded so slashes are preserved as path separators.
|
|
4
|
+
*
|
|
5
|
+
* @example encodeDocId('users/abc/stats/2026-01') => 'users/abc/stats/2026-01'
|
|
6
|
+
* @example encodeDocId('docs/hello world') => 'docs/hello%20world'
|
|
7
|
+
*/
|
|
8
|
+
declare function encodeDocId(docId: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* Normalize a string or string array to a string array.
|
|
11
|
+
*/
|
|
12
|
+
declare function normalizeIds(ids: string | string[]): string[];
|
|
13
|
+
|
|
14
|
+
export { encodeDocId, normalizeIds };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Signal } from 'easy-signal';
|
|
2
2
|
import { PatchesClient } from '../PatchesClient.js';
|
|
3
|
+
import { PatchesConnection } from '../PatchesConnection.js';
|
|
3
4
|
import { ConnectionState } from '../protocol/types.js';
|
|
4
5
|
import { WebSocketTransport, WebSocketOptions } from './WebSocketTransport.js';
|
|
5
6
|
import '../../types.js';
|
|
@@ -15,7 +16,7 @@ import '../../utils/deferred.js';
|
|
|
15
16
|
* versioning, and other OT-specific functionality
|
|
16
17
|
* over a WebSocket connection.
|
|
17
18
|
*/
|
|
18
|
-
declare class PatchesWebSocket extends PatchesClient {
|
|
19
|
+
declare class PatchesWebSocket extends PatchesClient implements PatchesConnection {
|
|
19
20
|
transport: WebSocketTransport;
|
|
20
21
|
/** Signal emitted when the underlying WebSocket connection state changes. */
|
|
21
22
|
readonly onStateChange: Signal<(state: ConnectionState) => void>;
|
|
@@ -25,6 +26,9 @@ declare class PatchesWebSocket extends PatchesClient {
|
|
|
25
26
|
* @param wsOptions - Optional configuration for the underlying WebSocket connection
|
|
26
27
|
*/
|
|
27
28
|
constructor(url: string, wsOptions?: WebSocketOptions);
|
|
29
|
+
/** The WebSocket server URL. */
|
|
30
|
+
get url(): string;
|
|
31
|
+
set url(url: string);
|
|
28
32
|
/**
|
|
29
33
|
* Establishes a connection to the Patches server.
|
|
30
34
|
* @returns A promise that resolves when the connection is established
|
|
@@ -18,6 +18,14 @@ class PatchesWebSocket extends PatchesClient {
|
|
|
18
18
|
this.transport = transport;
|
|
19
19
|
this.onStateChange = this.transport.onStateChange;
|
|
20
20
|
}
|
|
21
|
+
// --- URL ---
|
|
22
|
+
/** The WebSocket server URL. */
|
|
23
|
+
get url() {
|
|
24
|
+
return this.transport.url;
|
|
25
|
+
}
|
|
26
|
+
set url(url) {
|
|
27
|
+
this.transport.url = url;
|
|
28
|
+
}
|
|
21
29
|
// --- Connection Management ---
|
|
22
30
|
/**
|
|
23
31
|
* Establishes a connection to the Patches server.
|
package/dist/solid/context.d.ts
CHANGED
|
@@ -9,13 +9,12 @@ import '@dabble/delta';
|
|
|
9
9
|
import '../client/ClientAlgorithm.js';
|
|
10
10
|
import '../BaseDoc-BT18xPxU.js';
|
|
11
11
|
import '../client/PatchesStore.js';
|
|
12
|
+
import '../algorithms/ot/shared/changeBatching.js';
|
|
13
|
+
import '../net/PatchesConnection.js';
|
|
12
14
|
import '../net/protocol/types.js';
|
|
13
15
|
import '../net/protocol/JSONRPCClient.js';
|
|
14
|
-
import '../net/websocket/PatchesWebSocket.js';
|
|
15
|
-
import '../net/PatchesClient.js';
|
|
16
16
|
import '../net/websocket/WebSocketTransport.js';
|
|
17
17
|
import '../utils/deferred.js';
|
|
18
|
-
import '../algorithms/ot/shared/changeBatching.js';
|
|
19
18
|
|
|
20
19
|
/**
|
|
21
20
|
* Context value containing Patches and optional PatchesSync instances.
|
package/dist/solid/index.d.ts
CHANGED
|
@@ -14,10 +14,9 @@ import '../client/ClientAlgorithm.js';
|
|
|
14
14
|
import '../BaseDoc-BT18xPxU.js';
|
|
15
15
|
import '../client/PatchesStore.js';
|
|
16
16
|
import '../net/PatchesSync.js';
|
|
17
|
+
import '../algorithms/ot/shared/changeBatching.js';
|
|
18
|
+
import '../net/PatchesConnection.js';
|
|
17
19
|
import '../net/protocol/types.js';
|
|
18
20
|
import '../net/protocol/JSONRPCClient.js';
|
|
19
|
-
import '../net/websocket/PatchesWebSocket.js';
|
|
20
|
-
import '../net/PatchesClient.js';
|
|
21
21
|
import '../net/websocket/WebSocketTransport.js';
|
|
22
22
|
import '../utils/deferred.js';
|
|
23
|
-
import '../algorithms/ot/shared/changeBatching.js';
|
package/dist/vue/index.d.ts
CHANGED
|
@@ -14,10 +14,9 @@ import '../client/ClientAlgorithm.js';
|
|
|
14
14
|
import '../BaseDoc-BT18xPxU.js';
|
|
15
15
|
import '../client/PatchesStore.js';
|
|
16
16
|
import '../net/PatchesSync.js';
|
|
17
|
+
import '../algorithms/ot/shared/changeBatching.js';
|
|
18
|
+
import '../net/PatchesConnection.js';
|
|
17
19
|
import '../net/protocol/types.js';
|
|
18
20
|
import '../net/protocol/JSONRPCClient.js';
|
|
19
|
-
import '../net/websocket/PatchesWebSocket.js';
|
|
20
|
-
import '../net/PatchesClient.js';
|
|
21
21
|
import '../net/websocket/WebSocketTransport.js';
|
|
22
22
|
import '../utils/deferred.js';
|
|
23
|
-
import '../algorithms/ot/shared/changeBatching.js';
|
package/dist/vue/provider.d.ts
CHANGED
|
@@ -9,13 +9,12 @@ import '@dabble/delta';
|
|
|
9
9
|
import '../client/ClientAlgorithm.js';
|
|
10
10
|
import '../BaseDoc-BT18xPxU.js';
|
|
11
11
|
import '../client/PatchesStore.js';
|
|
12
|
+
import '../algorithms/ot/shared/changeBatching.js';
|
|
13
|
+
import '../net/PatchesConnection.js';
|
|
12
14
|
import '../net/protocol/types.js';
|
|
13
15
|
import '../net/protocol/JSONRPCClient.js';
|
|
14
|
-
import '../net/websocket/PatchesWebSocket.js';
|
|
15
|
-
import '../net/PatchesClient.js';
|
|
16
16
|
import '../net/websocket/WebSocketTransport.js';
|
|
17
17
|
import '../utils/deferred.js';
|
|
18
|
-
import '../algorithms/ot/shared/changeBatching.js';
|
|
19
18
|
|
|
20
19
|
/**
|
|
21
20
|
* Injection key for Patches instance.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dabble/patches",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Immutable JSON Patch implementation based on RFC 6902 supporting operational transformation and last-writer-wins",
|
|
5
5
|
"author": "Jacob Wright <jacwright@gmail.com>",
|
|
6
6
|
"bugs": {
|