@dabble/patches 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +632 -0
- package/dist/client/PatchDoc.d.ts +85 -0
- package/dist/client/PatchDoc.js +299 -0
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.js +1 -0
- package/dist/event-signal.d.ts +31 -0
- package/dist/event-signal.js +40 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/json-patch/JSONPatch.d.ts +126 -0
- package/dist/json-patch/JSONPatch.js +221 -0
- package/dist/json-patch/applyPatch.d.ts +11 -0
- package/dist/json-patch/applyPatch.js +37 -0
- package/dist/json-patch/composePatch.d.ts +2 -0
- package/dist/json-patch/composePatch.js +38 -0
- package/dist/json-patch/createJSONPatch.d.ts +35 -0
- package/dist/json-patch/createJSONPatch.js +41 -0
- package/dist/json-patch/index.d.ts +9 -0
- package/dist/json-patch/index.js +8 -0
- package/dist/json-patch/invertPatch.d.ts +2 -0
- package/dist/json-patch/invertPatch.js +31 -0
- package/dist/json-patch/ops/add.d.ts +2 -0
- package/dist/json-patch/ops/add.js +52 -0
- package/dist/json-patch/ops/bitmask.d.ts +14 -0
- package/dist/json-patch/ops/bitmask.js +48 -0
- package/dist/json-patch/ops/copy.d.ts +2 -0
- package/dist/json-patch/ops/copy.js +34 -0
- package/dist/json-patch/ops/increment.d.ts +5 -0
- package/dist/json-patch/ops/increment.js +21 -0
- package/dist/json-patch/ops/index.d.ts +22 -0
- package/dist/json-patch/ops/index.js +25 -0
- package/dist/json-patch/ops/move.d.ts +2 -0
- package/dist/json-patch/ops/move.js +211 -0
- package/dist/json-patch/ops/remove.d.ts +2 -0
- package/dist/json-patch/ops/remove.js +31 -0
- package/dist/json-patch/ops/replace.d.ts +2 -0
- package/dist/json-patch/ops/replace.js +44 -0
- package/dist/json-patch/ops/test.d.ts +2 -0
- package/dist/json-patch/ops/test.js +22 -0
- package/dist/json-patch/ops/text.d.ts +2 -0
- package/dist/json-patch/ops/text.js +57 -0
- package/dist/json-patch/patchProxy.d.ts +41 -0
- package/dist/json-patch/patchProxy.js +125 -0
- package/dist/json-patch/state.d.ts +2 -0
- package/dist/json-patch/state.js +8 -0
- package/dist/json-patch/transformPatch.d.ts +19 -0
- package/dist/json-patch/transformPatch.js +37 -0
- package/dist/json-patch/types.d.ts +52 -0
- package/dist/json-patch/types.js +1 -0
- package/dist/json-patch/utils/deepEqual.d.ts +1 -0
- package/dist/json-patch/utils/deepEqual.js +33 -0
- package/dist/json-patch/utils/exit.d.ts +2 -0
- package/dist/json-patch/utils/exit.js +4 -0
- package/dist/json-patch/utils/get.d.ts +2 -0
- package/dist/json-patch/utils/get.js +6 -0
- package/dist/json-patch/utils/getOpData.d.ts +2 -0
- package/dist/json-patch/utils/getOpData.js +10 -0
- package/dist/json-patch/utils/getType.d.ts +3 -0
- package/dist/json-patch/utils/getType.js +6 -0
- package/dist/json-patch/utils/index.d.ts +14 -0
- package/dist/json-patch/utils/index.js +14 -0
- package/dist/json-patch/utils/log.d.ts +2 -0
- package/dist/json-patch/utils/log.js +7 -0
- package/dist/json-patch/utils/ops.d.ts +14 -0
- package/dist/json-patch/utils/ops.js +103 -0
- package/dist/json-patch/utils/paths.d.ts +9 -0
- package/dist/json-patch/utils/paths.js +53 -0
- package/dist/json-patch/utils/pluck.d.ts +5 -0
- package/dist/json-patch/utils/pluck.js +30 -0
- package/dist/json-patch/utils/shallowCopy.d.ts +1 -0
- package/dist/json-patch/utils/shallowCopy.js +20 -0
- package/dist/json-patch/utils/softWrites.d.ts +7 -0
- package/dist/json-patch/utils/softWrites.js +18 -0
- package/dist/json-patch/utils/toArrayIndex.d.ts +1 -0
- package/dist/json-patch/utils/toArrayIndex.js +12 -0
- package/dist/json-patch/utils/toKeys.d.ts +1 -0
- package/dist/json-patch/utils/toKeys.js +15 -0
- package/dist/json-patch/utils/updateArrayIndexes.d.ts +5 -0
- package/dist/json-patch/utils/updateArrayIndexes.js +38 -0
- package/dist/json-patch/utils/updateArrayPath.d.ts +5 -0
- package/dist/json-patch/utils/updateArrayPath.js +45 -0
- package/dist/net/AbstractTransport.d.ts +47 -0
- package/dist/net/AbstractTransport.js +37 -0
- package/dist/net/PatchesOfflineFirst.d.ts +3 -0
- package/dist/net/PatchesOfflineFirst.js +3 -0
- package/dist/net/PatchesRealtime.d.ts +90 -0
- package/dist/net/PatchesRealtime.js +257 -0
- package/dist/net/index.d.ts +9 -0
- package/dist/net/index.js +8 -0
- package/dist/net/protocol/JSONRPCClient.d.ts +55 -0
- package/dist/net/protocol/JSONRPCClient.js +106 -0
- package/dist/net/protocol/types.d.ts +142 -0
- package/dist/net/protocol/types.js +1 -0
- package/dist/net/webrtc/WebRTCAwareness.d.ts +81 -0
- package/dist/net/webrtc/WebRTCAwareness.js +119 -0
- package/dist/net/webrtc/WebRTCTransport.d.ts +80 -0
- package/dist/net/webrtc/WebRTCTransport.js +157 -0
- package/dist/net/websocket/PatchesWebSocket.d.ts +107 -0
- package/dist/net/websocket/PatchesWebSocket.js +144 -0
- package/dist/net/websocket/SignalingService.d.ts +91 -0
- package/dist/net/websocket/SignalingService.js +140 -0
- package/dist/net/websocket/WebSocketTransport.d.ts +47 -0
- package/dist/net/websocket/WebSocketTransport.js +138 -0
- package/dist/persist/IndexedDBStore.d.ts +72 -0
- package/dist/persist/IndexedDBStore.js +283 -0
- package/dist/persist/index.d.ts +2 -0
- package/dist/persist/index.js +1 -0
- package/dist/server/BranchManager.d.ts +40 -0
- package/dist/server/BranchManager.js +138 -0
- package/dist/server/HistoryManager.d.ts +63 -0
- package/dist/server/HistoryManager.js +92 -0
- package/dist/server/PatchServer.d.ts +129 -0
- package/dist/server/PatchServer.js +358 -0
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.js +3 -0
- package/dist/types.d.ts +158 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +36 -0
- package/dist/utils.js +83 -0
- package/package.json +78 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { signal } from '../event-signal.js';
|
|
2
|
+
/**
|
|
3
|
+
* Abstract base class that implements common functionality for various transport implementations.
|
|
4
|
+
* Provides state management and event signaling for connection state changes and message reception.
|
|
5
|
+
* Concrete transport implementations must extend this class and implement the abstract methods.
|
|
6
|
+
*/
|
|
7
|
+
export class AbstractTransport {
|
|
8
|
+
constructor() {
|
|
9
|
+
this._state = 'disconnected';
|
|
10
|
+
/**
|
|
11
|
+
* Signal that emits when the connection state changes.
|
|
12
|
+
* Subscribers will receive the new connection state as an argument.
|
|
13
|
+
*/
|
|
14
|
+
this.onStateChange = signal();
|
|
15
|
+
/**
|
|
16
|
+
* Signal that emits when a message is received from the transport.
|
|
17
|
+
* Subscribers will receive the message data as a string.
|
|
18
|
+
*/
|
|
19
|
+
this.onMessage = signal();
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Gets the current connection state of the transport.
|
|
23
|
+
* @returns The current connection state ('connecting', 'connected', 'disconnected', or 'error')
|
|
24
|
+
*/
|
|
25
|
+
get state() {
|
|
26
|
+
return this._state;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Sets the connection state and emits a state change event.
|
|
30
|
+
* This method is protected and should only be called by subclasses.
|
|
31
|
+
* @param state - The new connection state
|
|
32
|
+
*/
|
|
33
|
+
set state(state) {
|
|
34
|
+
this._state = state;
|
|
35
|
+
this.onStateChange.emit(state);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { PatchDoc } from '../client/PatchDoc.js';
|
|
2
|
+
import { type Signal } from '../event-signal.js';
|
|
3
|
+
import type { ConnectionState } from './protocol/types.js';
|
|
4
|
+
import type { WebSocketOptions } from './websocket/WebSocketTransport.js';
|
|
5
|
+
export interface PatchesRealtimeOptions {
|
|
6
|
+
/** Initial metadata to attach to changes from this client */
|
|
7
|
+
metadata?: Record<string, any>;
|
|
8
|
+
/** Custom WebSocket configuration (e.g. headers, protocols) */
|
|
9
|
+
wsOptions?: WebSocketOptions;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* High-level client for real-time collaborative editing.
|
|
13
|
+
* Manages WebSocket connection and document synchronization.
|
|
14
|
+
*/
|
|
15
|
+
export declare class PatchesRealtime {
|
|
16
|
+
private ws;
|
|
17
|
+
private docs;
|
|
18
|
+
private options;
|
|
19
|
+
private wsChangesUnsubscriber;
|
|
20
|
+
private connectionState;
|
|
21
|
+
/** Emitted when the WebSocket connection state changes. */
|
|
22
|
+
readonly onStateChange: Signal<(state: ConnectionState) => void>;
|
|
23
|
+
/** Emitted when an error occurs during synchronization or connection. */
|
|
24
|
+
readonly onError: Signal<(details: {
|
|
25
|
+
type: "sendFailed" | "applyFailed" | "syncError" | "connectionError";
|
|
26
|
+
docId?: string;
|
|
27
|
+
error: Error;
|
|
28
|
+
recoveryAttempted?: boolean;
|
|
29
|
+
recoveryError?: Error;
|
|
30
|
+
}) => void>;
|
|
31
|
+
/**
|
|
32
|
+
* Creates an instance of PatchesRealtime.
|
|
33
|
+
* @param url The WebSocket URL of the Patches server.
|
|
34
|
+
* @param opts Configuration options.
|
|
35
|
+
*/
|
|
36
|
+
constructor(url: string, opts?: PatchesRealtimeOptions);
|
|
37
|
+
/**
|
|
38
|
+
* Establishes the WebSocket connection to the server.
|
|
39
|
+
* Automatically called by `openDoc` if not already connected.
|
|
40
|
+
*/
|
|
41
|
+
connect(): Promise<void>;
|
|
42
|
+
/**
|
|
43
|
+
* Opens a document by its ID, fetches its initial state, and sets up
|
|
44
|
+
* real-time synchronization. If the document is already open, returns
|
|
45
|
+
* the existing instance.
|
|
46
|
+
* @param docId The unique identifier for the document.
|
|
47
|
+
* @param opts Options, including optional metadata specific to this client's interaction with the document.
|
|
48
|
+
* @returns A Promise resolving to the synchronized PatchDoc instance.
|
|
49
|
+
* @throws If the connection fails, subscription fails, or fetching the initial document state fails.
|
|
50
|
+
*/
|
|
51
|
+
openDoc<T extends object>(docId: string, opts?: {
|
|
52
|
+
metadata?: Record<string, any>;
|
|
53
|
+
}): Promise<PatchDoc<T>>;
|
|
54
|
+
/**
|
|
55
|
+
* Closes a specific document locally, stops listening for its changes,
|
|
56
|
+
* and unsubscribes from server updates for that document.
|
|
57
|
+
* @param docId The ID of the document to close.
|
|
58
|
+
*/
|
|
59
|
+
closeDoc(docId: string): Promise<void>;
|
|
60
|
+
/**
|
|
61
|
+
* Closes all open documents, unsubscribes from all server updates,
|
|
62
|
+
* cleans up all local listeners, and disconnects the WebSocket.
|
|
63
|
+
*/
|
|
64
|
+
close(): void;
|
|
65
|
+
/**
|
|
66
|
+
* Sets up the synchronization logic for a single document. Listens for local
|
|
67
|
+
* changes on the `PatchDoc` and triggers sending them to the server.
|
|
68
|
+
* @param docId The document ID.
|
|
69
|
+
* @param doc The `PatchDoc` instance.
|
|
70
|
+
* @returns An unsubscriber function to remove the listener.
|
|
71
|
+
* @internal
|
|
72
|
+
*/
|
|
73
|
+
private _setupDocSync;
|
|
74
|
+
/**
|
|
75
|
+
* Checks if a document has pending local changes that haven't been sent
|
|
76
|
+
* to the server and attempts to send them if the document is not already
|
|
77
|
+
* in the process of sending. Handles server confirmation and potential
|
|
78
|
+
* new changes that occurred during the send operation.
|
|
79
|
+
* @param docId The document ID.
|
|
80
|
+
* @param doc The `PatchDoc` instance.
|
|
81
|
+
* @internal
|
|
82
|
+
*/
|
|
83
|
+
private _sendPendingIfNecessary;
|
|
84
|
+
/**
|
|
85
|
+
* Attempts to resynchronize a document by fetching its latest state from the server.
|
|
86
|
+
* @param docId The ID of the document to resynchronize.
|
|
87
|
+
* @internal
|
|
88
|
+
*/
|
|
89
|
+
private _resyncDoc;
|
|
90
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { PatchDoc } from '../client/PatchDoc.js';
|
|
2
|
+
import { signal } from '../event-signal.js';
|
|
3
|
+
import { PatchesWebSocket } from './websocket/PatchesWebSocket.js';
|
|
4
|
+
/**
|
|
5
|
+
* High-level client for real-time collaborative editing.
|
|
6
|
+
* Manages WebSocket connection and document synchronization.
|
|
7
|
+
*/
|
|
8
|
+
export class PatchesRealtime {
|
|
9
|
+
/**
|
|
10
|
+
* Creates an instance of PatchesRealtime.
|
|
11
|
+
* @param url The WebSocket URL of the Patches server.
|
|
12
|
+
* @param opts Configuration options.
|
|
13
|
+
*/
|
|
14
|
+
constructor(url, opts = {}) {
|
|
15
|
+
this.docs = new Map();
|
|
16
|
+
this.wsChangesUnsubscriber = null;
|
|
17
|
+
this.connectionState = 'disconnected'; // Track internal state
|
|
18
|
+
/** Emitted when an error occurs during synchronization or connection. */
|
|
19
|
+
this.onError = signal();
|
|
20
|
+
this.ws = new PatchesWebSocket(url, opts.wsOptions);
|
|
21
|
+
this.options = opts;
|
|
22
|
+
// Re-emit the state change signal and track internally
|
|
23
|
+
this.onStateChange = signal(); // Explicit type
|
|
24
|
+
this.ws.onStateChange((state) => {
|
|
25
|
+
// Call signal directly to subscribe
|
|
26
|
+
this.connectionState = state;
|
|
27
|
+
this.onStateChange.emit(state); // Pass through the signal
|
|
28
|
+
});
|
|
29
|
+
// Register a single listener for all incoming changes from the WebSocket.
|
|
30
|
+
this.wsChangesUnsubscriber = this.ws.onChangesCommitted(({ docId, changes }) => {
|
|
31
|
+
const managedDoc = this.docs.get(docId);
|
|
32
|
+
if (managedDoc) {
|
|
33
|
+
try {
|
|
34
|
+
// Apply changes received from the server to the local document copy.
|
|
35
|
+
managedDoc.doc.applyExternalServerUpdate(changes);
|
|
36
|
+
// After applying, immediately check if there are new local changes to send.
|
|
37
|
+
this._sendPendingIfNecessary(docId, managedDoc.doc);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
console.error(`Error applying external server update for doc ${docId}:`, error);
|
|
41
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
42
|
+
// Emit error and attempt recovery by resyncing
|
|
43
|
+
this.onError.emit({ type: 'applyFailed', docId, error: err, recoveryAttempted: true });
|
|
44
|
+
// Use void to explicitly ignore the promise returned by _resyncDoc
|
|
45
|
+
void this._resyncDoc(docId);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// If the document isn't open locally (e.g., already closed), ignore the incoming change.
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Establishes the WebSocket connection to the server.
|
|
53
|
+
* Automatically called by `openDoc` if not already connected.
|
|
54
|
+
*/
|
|
55
|
+
async connect() {
|
|
56
|
+
// The underlying PatchesWebSocket handles connection state and idempotency.
|
|
57
|
+
await this.ws.connect();
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Opens a document by its ID, fetches its initial state, and sets up
|
|
61
|
+
* real-time synchronization. If the document is already open, returns
|
|
62
|
+
* the existing instance.
|
|
63
|
+
* @param docId The unique identifier for the document.
|
|
64
|
+
* @param opts Options, including optional metadata specific to this client's interaction with the document.
|
|
65
|
+
* @returns A Promise resolving to the synchronized PatchDoc instance.
|
|
66
|
+
* @throws If the connection fails, subscription fails, or fetching the initial document state fails.
|
|
67
|
+
*/
|
|
68
|
+
async openDoc(docId, opts = {}) {
|
|
69
|
+
// Ensure connection is established before proceeding.
|
|
70
|
+
await this.connect();
|
|
71
|
+
// Return existing instance if already managed.
|
|
72
|
+
const existingManagedDoc = this.docs.get(docId);
|
|
73
|
+
if (existingManagedDoc) {
|
|
74
|
+
return existingManagedDoc.doc;
|
|
75
|
+
}
|
|
76
|
+
// Subscribe to server updates for this document ID *before* fetching state
|
|
77
|
+
// to avoid missing updates that might occur between getDoc and subscription completion.
|
|
78
|
+
await this.ws.subscribe(docId);
|
|
79
|
+
let snapshot;
|
|
80
|
+
try {
|
|
81
|
+
// Fetch the initial state (snapshot) of the document from the server.
|
|
82
|
+
snapshot = await this.ws.getDoc(docId);
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
// If fetching the document fails, attempt to clean up by unsubscribing.
|
|
86
|
+
console.error(`Failed to get initial state for doc ${docId}, attempting to unsubscribe:`, err);
|
|
87
|
+
this.ws.unsubscribe(docId).catch(unsubErr => {
|
|
88
|
+
// Log secondary error if unsubscribe also fails, but prioritize original error.
|
|
89
|
+
console.warn(`Failed to unsubscribe ${docId} after getDoc error:`, unsubErr);
|
|
90
|
+
});
|
|
91
|
+
// Re-throw the original error to signal failure to the caller.
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
// Create the local PatchDoc instance with the fetched state and merged metadata.
|
|
95
|
+
const doc = new PatchDoc(snapshot.state, { ...this.options.metadata, ...opts.metadata });
|
|
96
|
+
// Import the snapshot details (like revision number) into the PatchDoc.
|
|
97
|
+
doc.import(snapshot);
|
|
98
|
+
// Set up the listener to send local changes to the server.
|
|
99
|
+
const onChangeUnsubscriber = this._setupDocSync(docId, doc);
|
|
100
|
+
// Store the document instance and its change listener unsubscriber.
|
|
101
|
+
this.docs.set(docId, { doc, onChangeUnsubscriber });
|
|
102
|
+
return doc;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Closes a specific document locally, stops listening for its changes,
|
|
106
|
+
* and unsubscribes from server updates for that document.
|
|
107
|
+
* @param docId The ID of the document to close.
|
|
108
|
+
*/
|
|
109
|
+
async closeDoc(docId) {
|
|
110
|
+
const managedDoc = this.docs.get(docId);
|
|
111
|
+
if (managedDoc) {
|
|
112
|
+
// Stop listening to local changes for this document.
|
|
113
|
+
managedDoc.onChangeUnsubscriber();
|
|
114
|
+
// Remove the document from local management.
|
|
115
|
+
this.docs.delete(docId);
|
|
116
|
+
// Unsubscribe from server updates for this document.
|
|
117
|
+
try {
|
|
118
|
+
await this.ws.unsubscribe(docId);
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
// Log error but continue, as the primary goal (local cleanup) is done.
|
|
122
|
+
console.warn(`Error unsubscribing from doc ${docId} during closeDoc:`, err);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
// Log if closeDoc is called for a document not currently managed.
|
|
127
|
+
// This might indicate a logic error elsewhere or harmless redundant calls.
|
|
128
|
+
console.warn(`closeDoc called for non-existent or already closed doc: ${docId}`);
|
|
129
|
+
// No need to attempt unsubscribe if we don't think we were subscribed.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Closes all open documents, unsubscribes from all server updates,
|
|
134
|
+
* cleans up all local listeners, and disconnects the WebSocket.
|
|
135
|
+
*/
|
|
136
|
+
close() {
|
|
137
|
+
// Attempt to unsubscribe from all currently managed documents on the server.
|
|
138
|
+
const docIds = Array.from(this.docs.keys());
|
|
139
|
+
if (docIds.length > 0) {
|
|
140
|
+
this.ws.unsubscribe(docIds).catch(err => {
|
|
141
|
+
console.warn('Error unsubscribing from documents during close:', err);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
// Clean up local change listeners for all managed documents.
|
|
145
|
+
this.docs.forEach(managedDoc => managedDoc.onChangeUnsubscriber());
|
|
146
|
+
this.docs.clear(); // Remove all documents from local management.
|
|
147
|
+
// Clean up the global listener for incoming changes from the WebSocket.
|
|
148
|
+
if (this.wsChangesUnsubscriber) {
|
|
149
|
+
this.wsChangesUnsubscriber();
|
|
150
|
+
this.wsChangesUnsubscriber = null;
|
|
151
|
+
}
|
|
152
|
+
// Disconnect the WebSocket connection.
|
|
153
|
+
this.ws.disconnect();
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Sets up the synchronization logic for a single document. Listens for local
|
|
157
|
+
* changes on the `PatchDoc` and triggers sending them to the server.
|
|
158
|
+
* @param docId The document ID.
|
|
159
|
+
* @param doc The `PatchDoc` instance.
|
|
160
|
+
* @returns An unsubscriber function to remove the listener.
|
|
161
|
+
* @internal
|
|
162
|
+
*/
|
|
163
|
+
_setupDocSync(docId, doc) {
|
|
164
|
+
// Listen for local changes and attempt to send them.
|
|
165
|
+
return doc.onChange(async () => {
|
|
166
|
+
await this._sendPendingIfNecessary(docId, doc);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Checks if a document has pending local changes that haven't been sent
|
|
171
|
+
* to the server and attempts to send them if the document is not already
|
|
172
|
+
* in the process of sending. Handles server confirmation and potential
|
|
173
|
+
* new changes that occurred during the send operation.
|
|
174
|
+
* @param docId The document ID.
|
|
175
|
+
* @param doc The `PatchDoc` instance.
|
|
176
|
+
* @internal
|
|
177
|
+
*/
|
|
178
|
+
async _sendPendingIfNecessary(docId, doc) {
|
|
179
|
+
// Only proceed if there are pending changes and we are not already sending.
|
|
180
|
+
if (!doc.isSending && doc.hasPending) {
|
|
181
|
+
let changes;
|
|
182
|
+
try {
|
|
183
|
+
// Get the changes formatted for the server.
|
|
184
|
+
changes = doc.getUpdatesForServer();
|
|
185
|
+
// Basic sanity check - should not happen if hasPending is true.
|
|
186
|
+
if (changes.length === 0) {
|
|
187
|
+
console.warn(`_sendPendingIfNecessary called for ${docId} with hasPending=true but getUpdatesForServer returned empty.`);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
// Send the changes to the server via WebSocket.
|
|
191
|
+
const serverCommit = await this.ws.commitChanges(docId, changes);
|
|
192
|
+
// Apply the server's confirmation (e.g., updated revision number) to the local doc.
|
|
193
|
+
doc.applyServerConfirmation(serverCommit);
|
|
194
|
+
// IMPORTANT: After successful confirmation, immediately check again.
|
|
195
|
+
// New local changes might have occurred while the commit request was in flight.
|
|
196
|
+
// This recursive call ensures those are sent promptly.
|
|
197
|
+
await this._sendPendingIfNecessary(docId, doc);
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
console.error(`Error sending changes to server for doc ${docId}:`, error);
|
|
201
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
202
|
+
// Notify PatchDoc to move sending changes back to pending
|
|
203
|
+
doc.handleSendFailure();
|
|
204
|
+
// Emit error and attempt recovery if online
|
|
205
|
+
this.onError.emit({ type: 'sendFailed', docId, error: err, recoveryAttempted: true });
|
|
206
|
+
// Use the internally tracked state
|
|
207
|
+
if (this.connectionState === 'connected' || this.connectionState === 'connecting') {
|
|
208
|
+
console.warn(`Attempting recovery via resync for doc ${docId} after send failure.`);
|
|
209
|
+
// Use void to explicitly ignore the promise returned by _resyncDoc
|
|
210
|
+
void this._resyncDoc(docId);
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
console.warn(`Send failure for doc ${docId} while offline. Recovery deferred until reconnection.`);
|
|
214
|
+
// No immediate recovery action needed, handleSendFailure already reset state.
|
|
215
|
+
// The standard reconnection logic should trigger a send attempt later.
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Attempts to resynchronize a document by fetching its latest state from the server.
|
|
222
|
+
* @param docId The ID of the document to resynchronize.
|
|
223
|
+
* @internal
|
|
224
|
+
*/
|
|
225
|
+
async _resyncDoc(docId) {
|
|
226
|
+
const managedDoc = this.docs.get(docId);
|
|
227
|
+
if (!managedDoc) {
|
|
228
|
+
console.warn(`_resyncDoc called for non-managed doc: ${docId}`);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
console.log(`Attempting resync for doc ${docId}...`);
|
|
232
|
+
try {
|
|
233
|
+
const snapshot = await this.ws.getDoc(docId);
|
|
234
|
+
// Import will trigger recalculateLocalState and onUpdate
|
|
235
|
+
managedDoc.doc.import(snapshot);
|
|
236
|
+
console.log(`Successfully resynced doc ${docId} to revision ${snapshot.rev}.`);
|
|
237
|
+
// After successful resync, try sending any pending changes immediately
|
|
238
|
+
await this._sendPendingIfNecessary(docId, managedDoc.doc);
|
|
239
|
+
}
|
|
240
|
+
catch (recoveryError) {
|
|
241
|
+
console.error(`Failed to resync doc ${docId}:`, recoveryError);
|
|
242
|
+
const recErr = recoveryError instanceof Error ? recoveryError : new Error(String(recoveryError));
|
|
243
|
+
// Emit a specific syncError to indicate recovery failure
|
|
244
|
+
this.onError.emit({
|
|
245
|
+
type: 'syncError',
|
|
246
|
+
docId,
|
|
247
|
+
// TODO: Should we include the original error that triggered the resync attempt?
|
|
248
|
+
// For now, just include the recovery error itself.
|
|
249
|
+
error: recErr, // Error during the recovery attempt
|
|
250
|
+
recoveryAttempted: true,
|
|
251
|
+
recoveryError: recErr,
|
|
252
|
+
});
|
|
253
|
+
// If resync fails, the document might be in an inconsistent state.
|
|
254
|
+
// Further actions might be needed (e.g., closing the doc, user notification).
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { PatchesOfflineFirst } from './PatchesOfflineFirst';
|
|
2
|
+
export { PatchesRealtime } from './PatchesRealtime';
|
|
3
|
+
export { JSONRPCClient } from './protocol/JSONRPCClient';
|
|
4
|
+
export { PatchesWebSocket } from './websocket/PatchesWebSocket';
|
|
5
|
+
export { WebSocketTransport } from './websocket/WebSocketTransport';
|
|
6
|
+
export type { ConnectionState, ListOptions, PatchesAPI, PatchesNotificationParams, SignalNotificationParams, } from './protocol/types';
|
|
7
|
+
export * from './protocol/types.js';
|
|
8
|
+
export * from './webrtc/WebRTCAwareness.js';
|
|
9
|
+
export * from './webrtc/WebRTCTransport.js';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { PatchesOfflineFirst } from './PatchesOfflineFirst';
|
|
2
|
+
export { PatchesRealtime } from './PatchesRealtime';
|
|
3
|
+
export { JSONRPCClient } from './protocol/JSONRPCClient';
|
|
4
|
+
export { PatchesWebSocket } from './websocket/PatchesWebSocket';
|
|
5
|
+
export { WebSocketTransport } from './websocket/WebSocketTransport';
|
|
6
|
+
export * from './protocol/types.js';
|
|
7
|
+
export * from './webrtc/WebRTCAwareness.js';
|
|
8
|
+
export * from './webrtc/WebRTCTransport.js';
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type SignalSubscriber, type Unsubscriber } from '../../event-signal.js';
|
|
2
|
+
import type { Transport } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Implementation of a JSON-RPC 2.0 client that communicates over a provided transport layer.
|
|
5
|
+
* This client handles sending requests, notifications, and processing responses from a server.
|
|
6
|
+
* It also supports subscription to server-sent notifications.
|
|
7
|
+
*/
|
|
8
|
+
export declare class JSONRPCClient {
|
|
9
|
+
private transport;
|
|
10
|
+
private nextId;
|
|
11
|
+
private pending;
|
|
12
|
+
private notificationSignals;
|
|
13
|
+
/**
|
|
14
|
+
* Creates a new JSON-RPC client instance.
|
|
15
|
+
*
|
|
16
|
+
* @param transport - The transport layer implementation that will be used for sending/receiving messages
|
|
17
|
+
*/
|
|
18
|
+
constructor(transport: Transport);
|
|
19
|
+
/**
|
|
20
|
+
* Sends a JSON-RPC request to the server and returns a promise for the response.
|
|
21
|
+
*
|
|
22
|
+
* @param method - The name of the remote procedure to call
|
|
23
|
+
* @param params - The parameters to pass to the remote procedure (optional)
|
|
24
|
+
* @returns A promise that resolves with the result of the procedure call or rejects with an error
|
|
25
|
+
* @template T - The expected return type of the remote procedure
|
|
26
|
+
*/
|
|
27
|
+
request<T = any>(method: string, params?: any): Promise<T>;
|
|
28
|
+
/**
|
|
29
|
+
* Sends a JSON-RPC notification to the server (no response expected).
|
|
30
|
+
*
|
|
31
|
+
* @param method - The name of the remote procedure to call
|
|
32
|
+
* @param params - The parameters to pass to the remote procedure (optional)
|
|
33
|
+
*/
|
|
34
|
+
notify(method: string, params?: any): void;
|
|
35
|
+
/**
|
|
36
|
+
* Subscribes to server-sent notifications for a specific method.
|
|
37
|
+
*
|
|
38
|
+
* @param method - The notification method name to subscribe to
|
|
39
|
+
* @param handler - The callback function that will be invoked when notifications are received
|
|
40
|
+
* @returns A function that can be called to unsubscribe from the notifications
|
|
41
|
+
* @template T - The type of the handler function
|
|
42
|
+
*/
|
|
43
|
+
on<T extends SignalSubscriber = SignalSubscriber>(method: string, handler: T): Unsubscriber;
|
|
44
|
+
/**
|
|
45
|
+
* Processes incoming messages from the transport layer.
|
|
46
|
+
* Handles three types of messages:
|
|
47
|
+
* - Notifications: Emitted to registered subscribers
|
|
48
|
+
* - Responses: Resolved/rejected to the corresponding pending promise
|
|
49
|
+
* - Invalid messages: Logged as warnings
|
|
50
|
+
*
|
|
51
|
+
* @private
|
|
52
|
+
* @param data - The raw message data received from the transport
|
|
53
|
+
*/
|
|
54
|
+
private handleMessage;
|
|
55
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { signal } from '../../event-signal.js';
|
|
2
|
+
/**
|
|
3
|
+
* Implementation of a JSON-RPC 2.0 client that communicates over a provided transport layer.
|
|
4
|
+
* This client handles sending requests, notifications, and processing responses from a server.
|
|
5
|
+
* It also supports subscription to server-sent notifications.
|
|
6
|
+
*/
|
|
7
|
+
export class JSONRPCClient {
|
|
8
|
+
/**
|
|
9
|
+
* Creates a new JSON-RPC client instance.
|
|
10
|
+
*
|
|
11
|
+
* @param transport - The transport layer implementation that will be used for sending/receiving messages
|
|
12
|
+
*/
|
|
13
|
+
constructor(transport) {
|
|
14
|
+
this.transport = transport;
|
|
15
|
+
this.nextId = 1;
|
|
16
|
+
this.pending = new Map();
|
|
17
|
+
this.notificationSignals = new Map();
|
|
18
|
+
transport.onMessage(this.handleMessage.bind(this));
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Sends a JSON-RPC request to the server and returns a promise for the response.
|
|
22
|
+
*
|
|
23
|
+
* @param method - The name of the remote procedure to call
|
|
24
|
+
* @param params - The parameters to pass to the remote procedure (optional)
|
|
25
|
+
* @returns A promise that resolves with the result of the procedure call or rejects with an error
|
|
26
|
+
* @template T - The expected return type of the remote procedure
|
|
27
|
+
*/
|
|
28
|
+
async request(method, params) {
|
|
29
|
+
const id = this.nextId++;
|
|
30
|
+
const message = { jsonrpc: '2.0', id, method, params };
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
this.pending.set(id, { resolve, reject });
|
|
33
|
+
this.transport.send(JSON.stringify(message));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Sends a JSON-RPC notification to the server (no response expected).
|
|
38
|
+
*
|
|
39
|
+
* @param method - The name of the remote procedure to call
|
|
40
|
+
* @param params - The parameters to pass to the remote procedure (optional)
|
|
41
|
+
*/
|
|
42
|
+
notify(method, params) {
|
|
43
|
+
const message = { jsonrpc: '2.0', method, params };
|
|
44
|
+
this.transport.send(JSON.stringify(message));
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Subscribes to server-sent notifications for a specific method.
|
|
48
|
+
*
|
|
49
|
+
* @param method - The notification method name to subscribe to
|
|
50
|
+
* @param handler - The callback function that will be invoked when notifications are received
|
|
51
|
+
* @returns A function that can be called to unsubscribe from the notifications
|
|
52
|
+
* @template T - The type of the handler function
|
|
53
|
+
*/
|
|
54
|
+
on(method, handler) {
|
|
55
|
+
let thisSignal = this.notificationSignals.get(method);
|
|
56
|
+
if (!thisSignal) {
|
|
57
|
+
thisSignal = signal();
|
|
58
|
+
this.notificationSignals.set(method, thisSignal);
|
|
59
|
+
}
|
|
60
|
+
return thisSignal(handler);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Processes incoming messages from the transport layer.
|
|
64
|
+
* Handles three types of messages:
|
|
65
|
+
* - Notifications: Emitted to registered subscribers
|
|
66
|
+
* - Responses: Resolved/rejected to the corresponding pending promise
|
|
67
|
+
* - Invalid messages: Logged as warnings
|
|
68
|
+
*
|
|
69
|
+
* @private
|
|
70
|
+
* @param data - The raw message data received from the transport
|
|
71
|
+
*/
|
|
72
|
+
handleMessage(data) {
|
|
73
|
+
try {
|
|
74
|
+
const message = JSON.parse(data);
|
|
75
|
+
// Check if it's a notification (has method but no id)
|
|
76
|
+
if (typeof message === 'object' && message !== null && 'method' in message && !('id' in message)) {
|
|
77
|
+
const thisSignal = this.notificationSignals.get(message.method);
|
|
78
|
+
if (thisSignal)
|
|
79
|
+
thisSignal.emit(message.params);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
// Must be a response (has id)
|
|
83
|
+
if (typeof message === 'object' && message !== null && 'id' in message) {
|
|
84
|
+
const response = message;
|
|
85
|
+
const pending = this.pending.get(response.id);
|
|
86
|
+
if (pending) {
|
|
87
|
+
this.pending.delete(response.id);
|
|
88
|
+
if ('error' in response) {
|
|
89
|
+
pending.reject(response.error);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
pending.resolve(response.result);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
console.warn(`Received response for unknown id: ${response.id}`);
|
|
97
|
+
}
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
console.warn('Received unexpected message format:', message);
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
console.error('Failed to parse incoming message:', data, error);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|