@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.
@@ -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): "add" | "remove" | "replace" | "move" | "copy" | "test";
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 WebSocket connection, document subscriptions, and syncing logic between
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 ws: PatchesWebSocket;
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 | undefined);
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 of the WebSocket connection.
80
+ * Gets the server URL.
77
81
  */
78
82
  get url(): string;
79
83
  /**
80
- * Sets the URL of the WebSocket connection.
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
- * Useful for application-specific methods not part of the Patches protocol.
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 WebSocket server and starts syncing if online. If not online, it will wait for online state.
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 WebSocket server and stops syncing.
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 ws.subscribe/unsubscribe. Returns the full list if no filter is set.
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
  /**
@@ -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, url, options) {
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, "ws");
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
- this.ws = new PatchesWebSocket(url, options?.websocket);
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.ws.onStateChange(this._handleConnectionChange.bind(this));
63
- this.ws.onChangesCommitted(this._receiveCommittedChanges.bind(this));
64
- this.ws.onDocDeleted((docId) => this._handleRemoteDocDeleted(docId));
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 of the WebSocket connection.
90
+ * Gets the server URL.
86
91
  */
87
92
  get url() {
88
- return this.ws.transport.url;
93
+ return this.connection.url;
89
94
  }
90
95
  /**
91
- * Sets the URL of the WebSocket connection.
96
+ * Sets the server URL. Reconnects if currently connected.
92
97
  */
93
98
  set url(url) {
94
- this.ws.transport.url = url;
99
+ this.connection.url = url;
95
100
  if (this.state.connected) {
96
- this.ws.disconnect();
97
- this.ws.connect();
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
- * Useful for application-specific methods not part of the Patches protocol.
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
- return this.ws.rpc;
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 WebSocket server and starts syncing if online. If not online, it will wait for online state.
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.ws.connect();
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 WebSocket server and stops syncing.
144
+ * Disconnects from the server and stops syncing.
136
145
  */
137
146
  disconnect() {
138
- this.ws.disconnect();
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.ws.subscribe(subscribeIds);
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.ws.deleteDoc(docId);
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.ws.getChangesSince(docId, committedRev);
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.ws.getDoc(docId);
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.ws.commitChanges(docId, changeBatch);
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.ws.getDoc(docId);
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.ws.deleteDoc(docId);
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.ws.subscribe(subscribeIds);
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.ws.unsubscribe(unsubscribeIds);
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 ws.subscribe/unsubscribe. Returns the full list if no filter is set.
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 typeof err === "object" && err !== null && "code" in err && err.code === 410;
532
+ return err instanceof StatusError && err.code === ErrorCodes.DOC_DELETED;
524
533
  }
525
534
  }
526
535
  _init = __decoratorStart(_a);
@@ -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,8 @@
1
+ import "../../chunk-IZ2YBCUP.js";
2
+ export * from "./PatchesREST.js";
3
+ export * from "./SSEServer.js";
4
+ import { encodeDocId, normalizeIds } from "./utils.js";
5
+ export {
6
+ encodeDocId,
7
+ normalizeIds
8
+ };
@@ -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 };
@@ -0,0 +1,11 @@
1
+ import "../../chunk-IZ2YBCUP.js";
2
+ function encodeDocId(docId) {
3
+ return docId.split("/").map(encodeURIComponent).join("/");
4
+ }
5
+ function normalizeIds(ids) {
6
+ return Array.isArray(ids) ? ids : [ids];
7
+ }
8
+ export {
9
+ encodeDocId,
10
+ normalizeIds
11
+ };
@@ -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.
@@ -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.
@@ -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';
@@ -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';
@@ -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.7.24",
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": {