@dabble/patches 0.8.21 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/net/PatchesSync.d.ts +36 -0
- package/dist/net/PatchesSync.js +53 -1
- package/dist/net/error.d.ts +9 -0
- package/dist/net/error.js +10 -1
- package/dist/net/index.d.ts +5 -2
- package/dist/net/index.js +2 -1
- package/dist/net/invite.d.ts +55 -0
- package/dist/net/invite.js +0 -0
- package/dist/net/protocol/types.d.ts +16 -2
- package/dist/net/rest/PatchesREST.d.ts +46 -0
- package/dist/net/rest/PatchesREST.js +92 -0
- package/dist/net/rest/PatchesRESTSignalingTransport.d.ts +42 -0
- package/dist/net/rest/PatchesRESTSignalingTransport.js +34 -0
- package/dist/net/rest/SSEServer.d.ts +15 -0
- package/dist/net/rest/SSEServer.js +25 -0
- package/dist/net/rest/SSESignalingService.d.ts +60 -0
- package/dist/net/rest/SSESignalingService.js +27 -0
- package/dist/net/rest/index.d.ts +4 -0
- package/dist/net/rest/index.js +2 -0
- package/dist/net/signaling/SignalingService.d.ts +74 -0
- package/dist/net/signaling/SignalingService.js +135 -0
- package/dist/net/signaling/index.d.ts +7 -0
- package/dist/net/signaling/index.js +1 -0
- package/dist/net/webrtc/WebRTCAwareness.d.ts +0 -2
- package/dist/net/webrtc/WebRTCTransport.d.ts +9 -8
- package/dist/net/webrtc/WebRTCTransport.js +3 -2
- package/dist/net/webrtc/index.d.ts +0 -2
- package/dist/net/websocket/SignalingService.d.ts +2 -66
- package/dist/net/websocket/SignalingService.js +1 -135
- package/dist/server/LWWBranchManager.d.ts +2 -2
- package/dist/server/LWWServer.d.ts +2 -2
- package/dist/server/types.d.ts +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -254,7 +254,7 @@ When to use which? WebSocket for document sync. WebRTC for presence/cursors to r
|
|
|
254
254
|
|
|
255
255
|
### Awareness (Presence & Cursors)
|
|
256
256
|
|
|
257
|
-
Show who's online, where their cursor is, what they're selecting.
|
|
257
|
+
Show who's online, where their cursor is, what they're selecting. `WebRTCAwareness` carries presence over peer-to-peer WebRTC connections, with the signaling handshake multiplexed over whatever sync channel you already have open — `WebSocketTransport` or `PatchesREST` (SSE+REST). No second connection, same `clientId`.
|
|
258
258
|
|
|
259
259
|
See [Awareness documentation](./docs/awareness.md) for implementation details.
|
|
260
260
|
|
|
@@ -84,6 +84,19 @@ declare class PatchesSync extends ReadonlyStoreClass<PatchesSyncState> {
|
|
|
84
84
|
* Provides the pending changes that were discarded so the application can handle them.
|
|
85
85
|
*/
|
|
86
86
|
readonly onRemoteDocDeleted: easy_signal.Signal<(docId: string, pendingChanges: Change[]) => void>;
|
|
87
|
+
/**
|
|
88
|
+
* Signal emitted when the server reports the caller is no longer authorised
|
|
89
|
+
* to read/write a tracked document (e.g. a co-author was revoked, removed
|
|
90
|
+
* from a shared collection, or never had access in the first place).
|
|
91
|
+
*
|
|
92
|
+
* Local cleanup mirrors `onRemoteDocDeleted` — untrack, drop the local
|
|
93
|
+
* cache, return any pending changes that were lost — but the doc itself
|
|
94
|
+
* still exists server-side. Consumers that maintain a workspace listing
|
|
95
|
+
* (e.g. a "Shared with Me" dashboard) should remove the doc from their
|
|
96
|
+
* own state when this fires; otherwise it would resurface on next start
|
|
97
|
+
* and immediately re-fail with the same 403.
|
|
98
|
+
*/
|
|
99
|
+
readonly onRemoteDocAccessRevoked: easy_signal.Signal<(docId: string, pendingChanges: Change[]) => void>;
|
|
87
100
|
/**
|
|
88
101
|
* Signal emitted after pending branch metas have been synced to the server.
|
|
89
102
|
* Consumers should use this to refresh in-memory branch state (e.g. call `loadCached()`
|
|
@@ -183,6 +196,21 @@ declare class PatchesSync extends ReadonlyStoreClass<PatchesSyncState> {
|
|
|
183
196
|
* Cleans up local state and notifies the application with any pending changes that were lost.
|
|
184
197
|
*/
|
|
185
198
|
protected _handleRemoteDocDeleted(docId: string): Promise<void>;
|
|
199
|
+
/**
|
|
200
|
+
* Sibling of `_handleRemoteDocDeleted` for the access-revoked path.
|
|
201
|
+
* Same local cleanup (close, untrack, drop cache, clear sync state) but
|
|
202
|
+
* the doc is not tombstoned server-side — it just isn't ours anymore.
|
|
203
|
+
* Emitted as a distinct signal so consumers can show different UX
|
|
204
|
+
* ("Your access was revoked" vs. "Project was deleted") without having
|
|
205
|
+
* to inspect error codes themselves.
|
|
206
|
+
*/
|
|
207
|
+
protected _handleRemoteDocAccessRevoked(docId: string): Promise<void>;
|
|
208
|
+
/**
|
|
209
|
+
* Local cleanup shared by `_handleRemoteDocDeleted` and
|
|
210
|
+
* `_handleRemoteDocAccessRevoked`. Returns pending changes that were
|
|
211
|
+
* lost so the caller can include them in the application-facing signal.
|
|
212
|
+
*/
|
|
213
|
+
protected _cleanupAfterAccessLoss(docId: string): Promise<Change[]>;
|
|
186
214
|
/**
|
|
187
215
|
* Adds, updates, or removes a doc state entry immutably and notifies via store.
|
|
188
216
|
* - Pass a full DocSyncState to add a new entry or overwrite an existing one.
|
|
@@ -211,6 +239,14 @@ declare class PatchesSync extends ReadonlyStoreClass<PatchesSyncState> {
|
|
|
211
239
|
* Helper to detect DOC_DELETED (410) errors from the server.
|
|
212
240
|
*/
|
|
213
241
|
protected _isDocDeletedError(err: unknown): boolean;
|
|
242
|
+
/**
|
|
243
|
+
* Helper to detect ACCESS_REVOKED (403) errors from the server. Used by
|
|
244
|
+
* the sync/flush catch blocks to short-circuit straight into the
|
|
245
|
+
* `_handleRemoteDocAccessRevoked` cleanup path rather than latching the
|
|
246
|
+
* error onto `docStates[docId].syncError` (which would surface as a
|
|
247
|
+
* permanent "Unable to Sync" pill in the UI).
|
|
248
|
+
*/
|
|
249
|
+
protected _isAccessRevokedError(err: unknown): boolean;
|
|
214
250
|
}
|
|
215
251
|
|
|
216
252
|
export { PatchesSync, type PatchesSyncOptions, type PatchesSyncState };
|
package/dist/net/PatchesSync.js
CHANGED
|
@@ -52,6 +52,19 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [serialGate],
|
|
|
52
52
|
* Provides the pending changes that were discarded so the application can handle them.
|
|
53
53
|
*/
|
|
54
54
|
__publicField(this, "onRemoteDocDeleted", signal());
|
|
55
|
+
/**
|
|
56
|
+
* Signal emitted when the server reports the caller is no longer authorised
|
|
57
|
+
* to read/write a tracked document (e.g. a co-author was revoked, removed
|
|
58
|
+
* from a shared collection, or never had access in the first place).
|
|
59
|
+
*
|
|
60
|
+
* Local cleanup mirrors `onRemoteDocDeleted` — untrack, drop the local
|
|
61
|
+
* cache, return any pending changes that were lost — but the doc itself
|
|
62
|
+
* still exists server-side. Consumers that maintain a workspace listing
|
|
63
|
+
* (e.g. a "Shared with Me" dashboard) should remove the doc from their
|
|
64
|
+
* own state when this fires; otherwise it would resurface on next start
|
|
65
|
+
* and immediately re-fail with the same 403.
|
|
66
|
+
*/
|
|
67
|
+
__publicField(this, "onRemoteDocAccessRevoked", signal());
|
|
55
68
|
/**
|
|
56
69
|
* Signal emitted after pending branch metas have been synced to the server.
|
|
57
70
|
* Consumers should use this to refresh in-memory branch state (e.g. call `loadCached()`
|
|
@@ -360,6 +373,10 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [serialGate],
|
|
|
360
373
|
await this._handleRemoteDocDeleted(docId);
|
|
361
374
|
return;
|
|
362
375
|
}
|
|
376
|
+
if (this._isAccessRevokedError(err)) {
|
|
377
|
+
await this._handleRemoteDocAccessRevoked(docId);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
363
380
|
const syncError = err instanceof Error ? err : new Error(String(err));
|
|
364
381
|
this._updateDocSyncState(docId, { syncStatus: "error", syncError });
|
|
365
382
|
console.error(`Error syncing doc ${docId}:`, err);
|
|
@@ -421,6 +438,10 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [serialGate],
|
|
|
421
438
|
await this._handleRemoteDocDeleted(docId);
|
|
422
439
|
return;
|
|
423
440
|
}
|
|
441
|
+
if (this._isAccessRevokedError(err)) {
|
|
442
|
+
await this._handleRemoteDocAccessRevoked(docId);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
424
445
|
const flushError = err instanceof Error ? err : new Error(String(err));
|
|
425
446
|
this._updateDocSyncState(docId, { syncStatus: "error", syncError: flushError });
|
|
426
447
|
console.error(`Flush failed for doc ${docId}:`, err);
|
|
@@ -562,6 +583,27 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [serialGate],
|
|
|
562
583
|
* Cleans up local state and notifies the application with any pending changes that were lost.
|
|
563
584
|
*/
|
|
564
585
|
async _handleRemoteDocDeleted(docId) {
|
|
586
|
+
const pendingChanges = await this._cleanupAfterAccessLoss(docId);
|
|
587
|
+
await this.onRemoteDocDeleted.emit(docId, pendingChanges);
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Sibling of `_handleRemoteDocDeleted` for the access-revoked path.
|
|
591
|
+
* Same local cleanup (close, untrack, drop cache, clear sync state) but
|
|
592
|
+
* the doc is not tombstoned server-side — it just isn't ours anymore.
|
|
593
|
+
* Emitted as a distinct signal so consumers can show different UX
|
|
594
|
+
* ("Your access was revoked" vs. "Project was deleted") without having
|
|
595
|
+
* to inspect error codes themselves.
|
|
596
|
+
*/
|
|
597
|
+
async _handleRemoteDocAccessRevoked(docId) {
|
|
598
|
+
const pendingChanges = await this._cleanupAfterAccessLoss(docId);
|
|
599
|
+
await this.onRemoteDocAccessRevoked.emit(docId, pendingChanges);
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Local cleanup shared by `_handleRemoteDocDeleted` and
|
|
603
|
+
* `_handleRemoteDocAccessRevoked`. Returns pending changes that were
|
|
604
|
+
* lost so the caller can include them in the application-facing signal.
|
|
605
|
+
*/
|
|
606
|
+
async _cleanupAfterAccessLoss(docId) {
|
|
565
607
|
const algorithm = this._getAlgorithm(docId);
|
|
566
608
|
const pendingChanges = await algorithm.getPendingToSend(docId) ?? [];
|
|
567
609
|
const doc = this.patches.getOpenDoc(docId);
|
|
@@ -571,7 +613,7 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [serialGate],
|
|
|
571
613
|
this.trackedDocs.delete(docId);
|
|
572
614
|
this._updateDocSyncState(docId, void 0);
|
|
573
615
|
await algorithm.confirmDeleteDoc(docId);
|
|
574
|
-
|
|
616
|
+
return pendingChanges;
|
|
575
617
|
}
|
|
576
618
|
/**
|
|
577
619
|
* Adds, updates, or removes a doc state entry immutably and notifies via store.
|
|
@@ -634,6 +676,16 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [serialGate],
|
|
|
634
676
|
_isDocDeletedError(err) {
|
|
635
677
|
return err instanceof StatusError && err.code === ErrorCodes.DOC_DELETED;
|
|
636
678
|
}
|
|
679
|
+
/**
|
|
680
|
+
* Helper to detect ACCESS_REVOKED (403) errors from the server. Used by
|
|
681
|
+
* the sync/flush catch blocks to short-circuit straight into the
|
|
682
|
+
* `_handleRemoteDocAccessRevoked` cleanup path rather than latching the
|
|
683
|
+
* error onto `docStates[docId].syncError` (which would surface as a
|
|
684
|
+
* permanent "Unable to Sync" pill in the UI).
|
|
685
|
+
*/
|
|
686
|
+
_isAccessRevokedError(err) {
|
|
687
|
+
return err instanceof StatusError && err.code === ErrorCodes.ACCESS_REVOKED;
|
|
688
|
+
}
|
|
637
689
|
}
|
|
638
690
|
_init = __decoratorStart(_a);
|
|
639
691
|
__decorateElement(_init, 1, "syncDoc", _syncDoc_dec, PatchesSync);
|
package/dist/net/error.d.ts
CHANGED
|
@@ -11,6 +11,15 @@ declare const ErrorCodes: {
|
|
|
11
11
|
readonly DOC_DELETED: 410;
|
|
12
12
|
/** Document not found (never existed). */
|
|
13
13
|
readonly DOC_NOT_FOUND: 404;
|
|
14
|
+
/**
|
|
15
|
+
* Caller is no longer authorized to read/write the document.
|
|
16
|
+
* Distinct from DOC_DELETED — the doc still exists, the caller just lost
|
|
17
|
+
* membership (revoked, or removed from a shared collection). The sync
|
|
18
|
+
* loop treats it like a soft delete: untrack, drop local cache, emit
|
|
19
|
+
* `onRemoteDocAccessRevoked` so the application can remove the doc from
|
|
20
|
+
* its own workspace state.
|
|
21
|
+
*/
|
|
22
|
+
readonly ACCESS_REVOKED: 403;
|
|
14
23
|
};
|
|
15
24
|
/**
|
|
16
25
|
* Error thrown when the JSON-RPC client receives a message that cannot be parsed as JSON.
|
package/dist/net/error.js
CHANGED
|
@@ -10,7 +10,16 @@ const ErrorCodes = {
|
|
|
10
10
|
/** Document was deleted (tombstone exists). */
|
|
11
11
|
DOC_DELETED: 410,
|
|
12
12
|
/** Document not found (never existed). */
|
|
13
|
-
DOC_NOT_FOUND: 404
|
|
13
|
+
DOC_NOT_FOUND: 404,
|
|
14
|
+
/**
|
|
15
|
+
* Caller is no longer authorized to read/write the document.
|
|
16
|
+
* Distinct from DOC_DELETED — the doc still exists, the caller just lost
|
|
17
|
+
* membership (revoked, or removed from a shared collection). The sync
|
|
18
|
+
* loop treats it like a soft delete: untrack, drop local cache, emit
|
|
19
|
+
* `onRemoteDocAccessRevoked` so the application can remove the doc from
|
|
20
|
+
* its own workspace state.
|
|
21
|
+
*/
|
|
22
|
+
ACCESS_REVOKED: 403
|
|
14
23
|
};
|
|
15
24
|
class JSONRPCParseError extends Error {
|
|
16
25
|
rawMessage;
|
package/dist/net/index.d.ts
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
|
+
export { Invite, InviteProjectBookMeta, InviteProjectMetaSnapshot, InviteRole, InviteTo } from './invite.js';
|
|
1
2
|
export { FetchTransport } from './http/FetchTransport.js';
|
|
2
3
|
export { PatchesClient } from './PatchesClient.js';
|
|
3
4
|
export { PatchesConnection } from './PatchesConnection.js';
|
|
4
5
|
export { PatchesSync, PatchesSyncOptions, PatchesSyncState } from './PatchesSync.js';
|
|
5
6
|
export { PatchesREST, PatchesRESTOptions } from './rest/PatchesREST.js';
|
|
7
|
+
export { PatchesRESTSignalingTransport } from './rest/PatchesRESTSignalingTransport.js';
|
|
6
8
|
export { BufferedEvent, SSEServer, SSEServerOptions } from './rest/SSEServer.js';
|
|
9
|
+
export { SSESignalingService } from './rest/SSESignalingService.js';
|
|
7
10
|
export { normalizeIds } from './rest/utils.js';
|
|
8
11
|
export { JSONRPCClient } from './protocol/JSONRPCClient.js';
|
|
9
12
|
export { ApiDefinition, ConnectionSignalSubscriber, JSONRPCServer, JSONRPCServerOptions, MessageHandler } from './protocol/JSONRPCServer.js';
|
|
10
13
|
export { getAuthContext, getClientId } from './serverContext.js';
|
|
11
|
-
export { AwarenessUpdateNotificationParams, BranchAPI, ClientTransport, ConnectionState, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, Message, PatchesAPI, PatchesNotificationParams, ServerTransport, SignalNotificationParams } from './protocol/types.js';
|
|
14
|
+
export { AwarenessUpdateNotificationParams, BranchAPI, ClientTransport, ConnectionState, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, Message, PatchesAPI, PatchesNotificationParams, ServerTransport, SignalNotificationParams, SignalingTransport } from './protocol/types.js';
|
|
12
15
|
export { rpcError, rpcNotification, rpcResponse } from './protocol/utils.js';
|
|
13
16
|
export { Access, AuthContext, AuthorizationProvider, allowAll, assertNotDeleted, denyAll } from './websocket/AuthorizationProvider.js';
|
|
14
17
|
export { onlineState } from './websocket/onlineState.js';
|
|
15
18
|
export { PatchesWebSocket } from './websocket/PatchesWebSocket.js';
|
|
16
|
-
export { JsonRpcMessage, SignalingService } from './
|
|
19
|
+
export { JsonRpcMessage, SignalingService } from './signaling/SignalingService.js';
|
|
17
20
|
export { WebSocketServer, WebSocketServerOptions } from './websocket/WebSocketServer.js';
|
|
18
21
|
export { WebSocketOptions, WebSocketTransport } from './websocket/WebSocketTransport.js';
|
|
19
22
|
export { CommitChangesOptions } from '../types.js';
|
package/dist/net/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import "../chunk-IZ2YBCUP.js";
|
|
2
|
+
export * from "./invite.js";
|
|
2
3
|
export * from "./http/FetchTransport.js";
|
|
3
4
|
export * from "./PatchesClient.js";
|
|
4
5
|
export * from "./PatchesConnection.js";
|
|
@@ -11,7 +12,7 @@ export * from "./protocol/utils.js";
|
|
|
11
12
|
export * from "./websocket/AuthorizationProvider.js";
|
|
12
13
|
export * from "./websocket/onlineState.js";
|
|
13
14
|
export * from "./websocket/PatchesWebSocket.js";
|
|
14
|
-
export * from "./
|
|
15
|
+
export * from "./signaling/SignalingService.js";
|
|
15
16
|
export * from "./websocket/WebSocketServer.js";
|
|
16
17
|
export * from "./websocket/WebSocketTransport.js";
|
|
17
18
|
export {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shapes returned by pup's invite endpoints (`GET /docs/:docId/_invites/:inviteId`)
|
|
3
|
+
* and embedded in roles documents. Kept in sync with pup's `Invite` / `UserAccess`
|
|
4
|
+
* surface so clients deserialize with correct field names.
|
|
5
|
+
*/
|
|
6
|
+
type InviteRole = 'owner' | 'write' | 'edit' | 'comment' | 'view';
|
|
7
|
+
/** Invite addressee payload (`invite.to`). */
|
|
8
|
+
interface InviteTo {
|
|
9
|
+
role: InviteRole;
|
|
10
|
+
private?: boolean;
|
|
11
|
+
doc?: string;
|
|
12
|
+
/** Optional client-side id echoed from legacy creation paths; stripped on accept server-side. */
|
|
13
|
+
id?: string;
|
|
14
|
+
name?: string;
|
|
15
|
+
email: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Minimal book-row shape stored on invites for dashboard-style previews.
|
|
19
|
+
* Mirrors writer `NovelBookMeta` / pup-embedded JSON (extra keys are ignored).
|
|
20
|
+
*/
|
|
21
|
+
interface InviteProjectBookMeta {
|
|
22
|
+
id?: string;
|
|
23
|
+
title?: string;
|
|
24
|
+
subtitle?: string;
|
|
25
|
+
author?: string;
|
|
26
|
+
coverArt?: string;
|
|
27
|
+
coverArtRatio?: unknown;
|
|
28
|
+
pattern?: number;
|
|
29
|
+
backgroundColor?: unknown;
|
|
30
|
+
}
|
|
31
|
+
/** Novel project meta snapshot on an invite (writer `InviteProjectMetaSnapshot`). */
|
|
32
|
+
interface InviteProjectMetaSnapshot {
|
|
33
|
+
title?: string;
|
|
34
|
+
modifiedAt?: string;
|
|
35
|
+
books: InviteProjectBookMeta[];
|
|
36
|
+
isTemplate?: boolean;
|
|
37
|
+
templateCount?: number;
|
|
38
|
+
type?: string;
|
|
39
|
+
}
|
|
40
|
+
interface Invite {
|
|
41
|
+
from: string;
|
|
42
|
+
to: InviteTo;
|
|
43
|
+
createdAt: number;
|
|
44
|
+
expiresAt: number;
|
|
45
|
+
/** Cached project title at invite creation (writer/pup roles doc). */
|
|
46
|
+
projectName?: string;
|
|
47
|
+
/** @deprecated Prefer `projectMetaSnapshot.books`. */
|
|
48
|
+
projectBooks?: InviteProjectBookMeta[];
|
|
49
|
+
/** @deprecated Prefer `projectMetaSnapshot.modifiedAt`. */
|
|
50
|
+
projectModifiedAt?: string;
|
|
51
|
+
/** Full project meta snapshot for accept-modal preview. */
|
|
52
|
+
projectMetaSnapshot?: InviteProjectMetaSnapshot;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type { Invite, InviteProjectBookMeta, InviteProjectMetaSnapshot, InviteRole, InviteTo };
|
|
File without changes
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Unsubscriber } from 'easy-signal';
|
|
1
|
+
import { Unsubscriber, Signal } from 'easy-signal';
|
|
2
2
|
import { ListBranchesOptions, Branch, CreateBranchMetadata, EditableBranchMetadata, PatchesState, Change, ChangeInput, CommitChangesOptions, EditableVersionMetadata, ListVersionsOptions, VersionMetadata } from '../../types.js';
|
|
3
3
|
import '../../json-patch/JSONPatch.js';
|
|
4
4
|
import '@dabble/delta';
|
|
@@ -31,6 +31,20 @@ interface ClientTransport {
|
|
|
31
31
|
*/
|
|
32
32
|
onMessage(cb: (raw: string) => void): Unsubscriber;
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Lifecycle-aware transport that {@link WebRTCTransport} can ride on top of as a
|
|
36
|
+
* signaling channel. Anything implementing {@link ClientTransport} plus a
|
|
37
|
+
* `connect()` / `state` / `onStateChange` triplet qualifies — including
|
|
38
|
+
* `WebSocketTransport` and `PatchesRESTSignalingTransport`.
|
|
39
|
+
*/
|
|
40
|
+
interface SignalingTransport extends ClientTransport {
|
|
41
|
+
/** Open the underlying channel. May be a no-op if the channel is already open. */
|
|
42
|
+
connect(): Promise<void> | void;
|
|
43
|
+
/** Current connection state. */
|
|
44
|
+
readonly state: ConnectionState;
|
|
45
|
+
/** Emits whenever {@link state} changes. */
|
|
46
|
+
readonly onStateChange: Signal<(state: ConnectionState) => void>;
|
|
47
|
+
}
|
|
34
48
|
/**
|
|
35
49
|
* Minimal contract for server-side transports that can have **multiple** logical peers.
|
|
36
50
|
* Each message must indicate the **from / to** connection. Any additional lifecycle
|
|
@@ -155,4 +169,4 @@ interface SignalNotificationParams {
|
|
|
155
169
|
data: any;
|
|
156
170
|
}
|
|
157
171
|
|
|
158
|
-
export { type AwarenessUpdateNotificationParams, type BranchAPI, type ClientTransport, CommitChangesOptions, type ConnectionState, type JsonRpcNotification, type JsonRpcRequest, type JsonRpcResponse, type Message, type PatchesAPI, type PatchesNotificationParams, type ServerTransport, type SignalNotificationParams };
|
|
172
|
+
export { type AwarenessUpdateNotificationParams, type BranchAPI, type ClientTransport, CommitChangesOptions, type ConnectionState, type JsonRpcNotification, type JsonRpcRequest, type JsonRpcResponse, type Message, type PatchesAPI, type PatchesNotificationParams, type ServerTransport, type SignalNotificationParams, type SignalingTransport };
|
|
@@ -2,6 +2,7 @@ import * as easy_signal from 'easy-signal';
|
|
|
2
2
|
import { Change, CommitChangesOptions, PatchesState, ChangeInput, DeleteDocOptions, EditableVersionMetadata, ListVersionsOptions, VersionMetadata, PatchesSnapshot, Branch, CreateBranchMetadata, EditableBranchMetadata } from '../../types.js';
|
|
3
3
|
import { PatchesConnection } from '../PatchesConnection.js';
|
|
4
4
|
import { ConnectionState } from '../protocol/types.js';
|
|
5
|
+
import { Invite } from '../invite.js';
|
|
5
6
|
import '../../json-patch/JSONPatch.js';
|
|
6
7
|
import '@dabble/delta';
|
|
7
8
|
import '../../json-patch/types.js';
|
|
@@ -36,6 +37,11 @@ declare class PatchesREST implements PatchesConnection {
|
|
|
36
37
|
readonly onStateChange: easy_signal.Signal<(state: ConnectionState) => void>;
|
|
37
38
|
readonly onChangesCommitted: easy_signal.Signal<(docId: string, changes: Change[], options?: CommitChangesOptions) => void>;
|
|
38
39
|
readonly onDocDeleted: easy_signal.Signal<(docId: string) => void>;
|
|
40
|
+
/**
|
|
41
|
+
* Emits raw JSON-RPC strings received over the multiplexed `signal` SSE channel.
|
|
42
|
+
* Used by `PatchesRESTSignalingTransport` to drive `WebRTCTransport`'s signaling.
|
|
43
|
+
*/
|
|
44
|
+
readonly onSignal: easy_signal.Signal<(raw: string) => void>;
|
|
39
45
|
private _url;
|
|
40
46
|
private _state;
|
|
41
47
|
private eventSource;
|
|
@@ -45,6 +51,8 @@ declare class PatchesREST implements PatchesConnection {
|
|
|
45
51
|
constructor(url: string, options?: PatchesRESTOptions);
|
|
46
52
|
get url(): string;
|
|
47
53
|
set url(url: string);
|
|
54
|
+
/** Current connection state of the underlying SSE stream. */
|
|
55
|
+
get state(): ConnectionState;
|
|
48
56
|
connect(): Promise<void>;
|
|
49
57
|
disconnect(): void;
|
|
50
58
|
subscribe(ids: string | string[]): Promise<string[]>;
|
|
@@ -68,6 +76,44 @@ declare class PatchesREST implements PatchesConnection {
|
|
|
68
76
|
updateBranch(docId: string, branchId: string, metadata: EditableBranchMetadata): Promise<void>;
|
|
69
77
|
deleteBranch(docId: string, branchId: string): Promise<void>;
|
|
70
78
|
mergeBranch(docId: string, branchId: string): Promise<void>;
|
|
79
|
+
/**
|
|
80
|
+
* Fetch a single invite addressed to the authenticated user.
|
|
81
|
+
* `GET /docs/:docId/_invites/:inviteId`
|
|
82
|
+
*/
|
|
83
|
+
getInvite(docId: string, inviteId: string): Promise<Invite>;
|
|
84
|
+
/**
|
|
85
|
+
* Accept or decline an invite. Returns whether the server applied a role
|
|
86
|
+
* change (`ok` from `{ ok: boolean }`).
|
|
87
|
+
* `POST /docs/:docId/_invites/:inviteId` body `{ accept: boolean }`
|
|
88
|
+
*/
|
|
89
|
+
acceptInvite(docId: string, inviteId: string, accept: boolean): Promise<boolean>;
|
|
90
|
+
/**
|
|
91
|
+
* Remove the authenticated user from `roles.users` (non-owners only).
|
|
92
|
+
* `POST /docs/:docId/_self/leave`
|
|
93
|
+
*/
|
|
94
|
+
leaveProject(docId: string): Promise<boolean>;
|
|
95
|
+
/**
|
|
96
|
+
* Owner/co-author revokes another member. `targetUid` is removed from
|
|
97
|
+
* `roles.users` and tombstoned under `roles.formerUsers.revokedAt`.
|
|
98
|
+
* `POST /docs/:docId/_members/:uid/revoke`
|
|
99
|
+
*/
|
|
100
|
+
revokeMember(docId: string, targetUid: string): Promise<boolean>;
|
|
101
|
+
/**
|
|
102
|
+
* POSTs a raw JSON-RPC string to `/signal/:clientId`. Used as the upstream
|
|
103
|
+
* half of the multiplexed signaling channel: receive happens via the `signal`
|
|
104
|
+
* SSE event (see {@link onSignal}).
|
|
105
|
+
*
|
|
106
|
+
* The body is sent verbatim — callers pass an already-stringified JSON-RPC
|
|
107
|
+
* message. The endpoint forwards it to {@link SignalingService.handleClientMessage}.
|
|
108
|
+
*
|
|
109
|
+
* Silently no-ops when the SSE stream is not connected. Signaling is
|
|
110
|
+
* inherently best-effort and the server has no live `signal` channel back
|
|
111
|
+
* to this client until the stream is up, so a frame sent before connect
|
|
112
|
+
* resolves can't be relayed anyway. Throwing here would surface as an
|
|
113
|
+
* unhandled rejection through `JSONRPCClient.call`, which doesn't await
|
|
114
|
+
* `transport.send`.
|
|
115
|
+
*/
|
|
116
|
+
sendSignal(raw: string): Promise<void>;
|
|
71
117
|
private _setState;
|
|
72
118
|
private _getHeaders;
|
|
73
119
|
private _fetch;
|
|
@@ -13,6 +13,11 @@ class PatchesREST {
|
|
|
13
13
|
onStateChange = signal();
|
|
14
14
|
onChangesCommitted = signal();
|
|
15
15
|
onDocDeleted = signal();
|
|
16
|
+
/**
|
|
17
|
+
* Emits raw JSON-RPC strings received over the multiplexed `signal` SSE channel.
|
|
18
|
+
* Used by `PatchesRESTSignalingTransport` to drive `WebRTCTransport`'s signaling.
|
|
19
|
+
*/
|
|
20
|
+
onSignal = signal();
|
|
16
21
|
_url;
|
|
17
22
|
_state = "disconnected";
|
|
18
23
|
eventSource = null;
|
|
@@ -36,6 +41,11 @@ class PatchesREST {
|
|
|
36
41
|
set url(url) {
|
|
37
42
|
this._url = url.replace(/\/$/, "");
|
|
38
43
|
}
|
|
44
|
+
// --- Connection State ---
|
|
45
|
+
/** Current connection state of the underlying SSE stream. */
|
|
46
|
+
get state() {
|
|
47
|
+
return this._state;
|
|
48
|
+
}
|
|
39
49
|
// --- Connection Lifecycle ---
|
|
40
50
|
connect() {
|
|
41
51
|
this.shouldBeConnected = true;
|
|
@@ -94,6 +104,9 @@ class PatchesREST {
|
|
|
94
104
|
} catch {
|
|
95
105
|
}
|
|
96
106
|
});
|
|
107
|
+
es.addEventListener("signal", (e) => {
|
|
108
|
+
this.onSignal.emit(e.data);
|
|
109
|
+
});
|
|
97
110
|
es.addEventListener("resync", () => {
|
|
98
111
|
if (!this.shouldBeConnected) return;
|
|
99
112
|
this._setState("disconnected");
|
|
@@ -189,6 +202,85 @@ class PatchesREST {
|
|
|
189
202
|
async mergeBranch(docId, branchId) {
|
|
190
203
|
await this._fetch(`/docs/${docId}/_branches/${encodeURIComponent(branchId)}/_merge`, { method: "POST" });
|
|
191
204
|
}
|
|
205
|
+
// --- Invites & membership (pup REST) ---
|
|
206
|
+
/**
|
|
207
|
+
* Fetch a single invite addressed to the authenticated user.
|
|
208
|
+
* `GET /docs/:docId/_invites/:inviteId`
|
|
209
|
+
*/
|
|
210
|
+
async getInvite(docId, inviteId) {
|
|
211
|
+
return this._fetch(`/docs/${docId}/_invites/${encodeURIComponent(inviteId)}`);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Accept or decline an invite. Returns whether the server applied a role
|
|
215
|
+
* change (`ok` from `{ ok: boolean }`).
|
|
216
|
+
* `POST /docs/:docId/_invites/:inviteId` body `{ accept: boolean }`
|
|
217
|
+
*/
|
|
218
|
+
async acceptInvite(docId, inviteId, accept) {
|
|
219
|
+
const result = await this._fetch(`/docs/${docId}/_invites/${encodeURIComponent(inviteId)}`, {
|
|
220
|
+
method: "POST",
|
|
221
|
+
body: { accept }
|
|
222
|
+
});
|
|
223
|
+
return Boolean(result?.ok);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Remove the authenticated user from `roles.users` (non-owners only).
|
|
227
|
+
* `POST /docs/:docId/_self/leave`
|
|
228
|
+
*/
|
|
229
|
+
async leaveProject(docId) {
|
|
230
|
+
const result = await this._fetch(`/docs/${docId}/_self/leave`, {
|
|
231
|
+
method: "POST",
|
|
232
|
+
body: {}
|
|
233
|
+
});
|
|
234
|
+
return Boolean(result?.ok);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Owner/co-author revokes another member. `targetUid` is removed from
|
|
238
|
+
* `roles.users` and tombstoned under `roles.formerUsers.revokedAt`.
|
|
239
|
+
* `POST /docs/:docId/_members/:uid/revoke`
|
|
240
|
+
*/
|
|
241
|
+
async revokeMember(docId, targetUid) {
|
|
242
|
+
const result = await this._fetch(
|
|
243
|
+
`/docs/${docId}/_members/${encodeURIComponent(targetUid)}/revoke`,
|
|
244
|
+
{
|
|
245
|
+
method: "POST",
|
|
246
|
+
body: {}
|
|
247
|
+
}
|
|
248
|
+
);
|
|
249
|
+
return Boolean(result?.ok);
|
|
250
|
+
}
|
|
251
|
+
// --- WebRTC Signaling ---
|
|
252
|
+
/**
|
|
253
|
+
* POSTs a raw JSON-RPC string to `/signal/:clientId`. Used as the upstream
|
|
254
|
+
* half of the multiplexed signaling channel: receive happens via the `signal`
|
|
255
|
+
* SSE event (see {@link onSignal}).
|
|
256
|
+
*
|
|
257
|
+
* The body is sent verbatim — callers pass an already-stringified JSON-RPC
|
|
258
|
+
* message. The endpoint forwards it to {@link SignalingService.handleClientMessage}.
|
|
259
|
+
*
|
|
260
|
+
* Silently no-ops when the SSE stream is not connected. Signaling is
|
|
261
|
+
* inherently best-effort and the server has no live `signal` channel back
|
|
262
|
+
* to this client until the stream is up, so a frame sent before connect
|
|
263
|
+
* resolves can't be relayed anyway. Throwing here would surface as an
|
|
264
|
+
* unhandled rejection through `JSONRPCClient.call`, which doesn't await
|
|
265
|
+
* `transport.send`.
|
|
266
|
+
*/
|
|
267
|
+
async sendSignal(raw) {
|
|
268
|
+
if (this._state !== "connected") return;
|
|
269
|
+
const headers = await this._getHeaders();
|
|
270
|
+
const response = await globalThis.fetch(`${this._url}/signal/${this.clientId}`, {
|
|
271
|
+
method: "POST",
|
|
272
|
+
credentials: "include",
|
|
273
|
+
headers: {
|
|
274
|
+
"Content-Type": "application/json",
|
|
275
|
+
...headers
|
|
276
|
+
},
|
|
277
|
+
body: raw,
|
|
278
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
279
|
+
});
|
|
280
|
+
if (!response.ok) {
|
|
281
|
+
throw new StatusError(response.status, response.statusText);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
192
284
|
// --- Private Helpers ---
|
|
193
285
|
_setState(state) {
|
|
194
286
|
if (state === this._state) return;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import * as easy_signal from 'easy-signal';
|
|
2
|
+
import { Unsubscriber } from 'easy-signal';
|
|
3
|
+
import { SignalingTransport, ConnectionState } from '../protocol/types.js';
|
|
4
|
+
import { PatchesREST } from './PatchesREST.js';
|
|
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 '../invite.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Adapter that exposes a {@link PatchesREST} connection as a {@link SignalingTransport}
|
|
14
|
+
* for `WebRTCTransport`. Multiplexes signaling over the existing SSE stream:
|
|
15
|
+
*
|
|
16
|
+
* - **send** → `PatchesREST.sendSignal()` → `POST /signal/:clientId`.
|
|
17
|
+
* - **receive** → subscribes to `PatchesREST.onSignal` (the `signal` SSE event).
|
|
18
|
+
*
|
|
19
|
+
* Does not own the connection. The application calls `patches.connect()`
|
|
20
|
+
* directly; `connect()` here just delegates so callers can still `await` it.
|
|
21
|
+
*/
|
|
22
|
+
declare class PatchesRESTSignalingTransport implements SignalingTransport {
|
|
23
|
+
private patches;
|
|
24
|
+
/**
|
|
25
|
+
* @param patches - The shared `PatchesREST` instance handling document sync.
|
|
26
|
+
* The same `clientId` is used for both, so signaling addressing matches.
|
|
27
|
+
*/
|
|
28
|
+
constructor(patches: PatchesREST);
|
|
29
|
+
get state(): ConnectionState;
|
|
30
|
+
get onStateChange(): easy_signal.Signal<(state: ConnectionState) => void>;
|
|
31
|
+
/**
|
|
32
|
+
* Delegates to {@link PatchesREST.connect}. Safe to call multiple times — if
|
|
33
|
+
* the SSE stream is already open, the underlying call is a no-op.
|
|
34
|
+
*/
|
|
35
|
+
connect(): Promise<void>;
|
|
36
|
+
/** Sends a raw JSON-RPC frame upstream over the signaling REST endpoint. */
|
|
37
|
+
send(raw: string): Promise<void>;
|
|
38
|
+
/** Subscribes to inbound JSON-RPC frames received via the `signal` SSE event. */
|
|
39
|
+
onMessage(cb: (raw: string) => void): Unsubscriber;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export { PatchesRESTSignalingTransport };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import "../../chunk-IZ2YBCUP.js";
|
|
2
|
+
class PatchesRESTSignalingTransport {
|
|
3
|
+
/**
|
|
4
|
+
* @param patches - The shared `PatchesREST` instance handling document sync.
|
|
5
|
+
* The same `clientId` is used for both, so signaling addressing matches.
|
|
6
|
+
*/
|
|
7
|
+
constructor(patches) {
|
|
8
|
+
this.patches = patches;
|
|
9
|
+
}
|
|
10
|
+
get state() {
|
|
11
|
+
return this.patches.state;
|
|
12
|
+
}
|
|
13
|
+
get onStateChange() {
|
|
14
|
+
return this.patches.onStateChange;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Delegates to {@link PatchesREST.connect}. Safe to call multiple times — if
|
|
18
|
+
* the SSE stream is already open, the underlying call is a no-op.
|
|
19
|
+
*/
|
|
20
|
+
async connect() {
|
|
21
|
+
await this.patches.connect();
|
|
22
|
+
}
|
|
23
|
+
/** Sends a raw JSON-RPC frame upstream over the signaling REST endpoint. */
|
|
24
|
+
send(raw) {
|
|
25
|
+
return this.patches.sendSignal(raw);
|
|
26
|
+
}
|
|
27
|
+
/** Subscribes to inbound JSON-RPC frames received via the `signal` SSE event. */
|
|
28
|
+
onMessage(cb) {
|
|
29
|
+
return this.patches.onSignal(cb);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export {
|
|
33
|
+
PatchesRESTSignalingTransport
|
|
34
|
+
};
|
|
@@ -127,6 +127,21 @@ declare class SSEServer {
|
|
|
127
127
|
* @param exceptClientId - Client ID to exclude (typically the one who made the change).
|
|
128
128
|
*/
|
|
129
129
|
notify(docId: string, event: string, params: Record<string, any>, exceptClientId?: string): void;
|
|
130
|
+
/**
|
|
131
|
+
* Send an event directly to a single connected client, bypassing subscription
|
|
132
|
+
* routing. Used for client-targeted traffic like WebRTC signaling.
|
|
133
|
+
*
|
|
134
|
+
* Unlike {@link notify}, this does NOT buffer through expiry: if the client
|
|
135
|
+
* is currently disconnected, the call returns false and the event is dropped.
|
|
136
|
+
* Stale signaling replayed after a buffer expiry is harmful (peers gone, ICE
|
|
137
|
+
* candidates moot), so we deliberately do not preserve it.
|
|
138
|
+
*
|
|
139
|
+
* @param clientId - Target client.
|
|
140
|
+
* @param event - SSE event type (e.g. `'signal'`).
|
|
141
|
+
* @param data - Pre-serialised event payload.
|
|
142
|
+
* @returns true if the client was connected and the event was written.
|
|
143
|
+
*/
|
|
144
|
+
sendToClient(clientId: string, event: string, data: string): boolean;
|
|
130
145
|
/**
|
|
131
146
|
* List all client IDs subscribed to a document.
|
|
132
147
|
*/
|
|
@@ -157,6 +157,31 @@ class SSEServer {
|
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* Send an event directly to a single connected client, bypassing subscription
|
|
162
|
+
* routing. Used for client-targeted traffic like WebRTC signaling.
|
|
163
|
+
*
|
|
164
|
+
* Unlike {@link notify}, this does NOT buffer through expiry: if the client
|
|
165
|
+
* is currently disconnected, the call returns false and the event is dropped.
|
|
166
|
+
* Stale signaling replayed after a buffer expiry is harmful (peers gone, ICE
|
|
167
|
+
* candidates moot), so we deliberately do not preserve it.
|
|
168
|
+
*
|
|
169
|
+
* @param clientId - Target client.
|
|
170
|
+
* @param event - SSE event type (e.g. `'signal'`).
|
|
171
|
+
* @param data - Pre-serialised event payload.
|
|
172
|
+
* @returns true if the client was connected and the event was written.
|
|
173
|
+
*/
|
|
174
|
+
sendToClient(clientId, event, data) {
|
|
175
|
+
const client = this.clients.get(clientId);
|
|
176
|
+
if (!client || !client.writer) return false;
|
|
177
|
+
this._writeEvent(client, {
|
|
178
|
+
id: client.nextEventId++,
|
|
179
|
+
event,
|
|
180
|
+
data,
|
|
181
|
+
timestamp: Date.now()
|
|
182
|
+
});
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
160
185
|
/**
|
|
161
186
|
* List all client IDs subscribed to a document.
|
|
162
187
|
*/
|