@dabble/patches 0.9.1 → 0.9.5

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 CHANGED
@@ -16,6 +16,26 @@ doc.change(state => (state.title = 'New Title'));
16
16
 
17
17
  Changes apply immediately for snappy UIs, then sync to the server in the background. Offline? No problem. Changes queue up and sync when you're back online.
18
18
 
19
+ ## Scope: what Patches is and is not
20
+
21
+ Patches is a **generic primitives library**. It provides the building blocks (OT, LWW, sync transport, doc-store interfaces) for realtime systems. Compose it into your own backend and frontend.
22
+
23
+ Patches knows about:
24
+
25
+ - JSON documents, JSON Patch operations, and snapshots
26
+ - Operational Transformation and Last-Write-Wins conflict resolution
27
+ - A transport surface (WebSocket, SSE+REST, WebRTC) that moves changes between clients and a server
28
+ - HTTP status codes propagated as `StatusError` (401, 403, 404, 410) — so consuming apps can branch on the response
29
+
30
+ Patches does **not** know about:
31
+
32
+ - Users, roles, invites, memberships, or any access-control model
33
+ - Apps, projects, books, documents-as-products, or any app-specific terminology
34
+ - Email sending, name/email lookups, or any identity service
35
+ - UX policy for what to do when access is lost (consuming apps decide whether a 403 means "show a banner", "kick out of the doc", or anything else)
36
+
37
+ If a feature feels like it belongs in Patches but mentions an app concept (e.g. "an invite for a project"), it doesn't belong here — it belongs one level up, in the app's own client SDK or in the consuming app itself. The Patches surface should stay generic enough to back any realtime system.
38
+
19
39
  ## Two Sync Strategies
20
40
 
21
41
  Patches gives you two conflict resolution approaches. Pick the right tool for the job.
@@ -254,7 +274,7 @@ When to use which? WebSocket for document sync. WebRTC for presence/cursors to r
254
274
 
255
275
  ### Awareness (Presence & Cursors)
256
276
 
257
- Show who's online, where their cursor is, what they're selecting. Works over both WebSocket and WebRTC.
277
+ 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
278
 
259
279
  See [Awareness documentation](./docs/awareness.md) for implementation details.
260
280
 
@@ -84,19 +84,6 @@ 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>;
100
87
  /**
101
88
  * Signal emitted after pending branch metas have been synced to the server.
102
89
  * Consumers should use this to refresh in-memory branch state (e.g. call `loadCached()`
@@ -196,21 +183,6 @@ declare class PatchesSync extends ReadonlyStoreClass<PatchesSyncState> {
196
183
  * Cleans up local state and notifies the application with any pending changes that were lost.
197
184
  */
198
185
  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[]>;
214
186
  /**
215
187
  * Adds, updates, or removes a doc state entry immutably and notifies via store.
216
188
  * - Pass a full DocSyncState to add a new entry or overwrite an existing one.
@@ -239,14 +211,6 @@ declare class PatchesSync extends ReadonlyStoreClass<PatchesSyncState> {
239
211
  * Helper to detect DOC_DELETED (410) errors from the server.
240
212
  */
241
213
  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;
250
214
  }
251
215
 
252
216
  export { PatchesSync, type PatchesSyncOptions, type PatchesSyncState };
@@ -52,19 +52,6 @@ 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());
68
55
  /**
69
56
  * Signal emitted after pending branch metas have been synced to the server.
70
57
  * Consumers should use this to refresh in-memory branch state (e.g. call `loadCached()`
@@ -373,10 +360,6 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [serialGate],
373
360
  await this._handleRemoteDocDeleted(docId);
374
361
  return;
375
362
  }
376
- if (this._isAccessRevokedError(err)) {
377
- await this._handleRemoteDocAccessRevoked(docId);
378
- return;
379
- }
380
363
  const syncError = err instanceof Error ? err : new Error(String(err));
381
364
  this._updateDocSyncState(docId, { syncStatus: "error", syncError });
382
365
  console.error(`Error syncing doc ${docId}:`, err);
@@ -438,10 +421,6 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [serialGate],
438
421
  await this._handleRemoteDocDeleted(docId);
439
422
  return;
440
423
  }
441
- if (this._isAccessRevokedError(err)) {
442
- await this._handleRemoteDocAccessRevoked(docId);
443
- return;
444
- }
445
424
  const flushError = err instanceof Error ? err : new Error(String(err));
446
425
  this._updateDocSyncState(docId, { syncStatus: "error", syncError: flushError });
447
426
  console.error(`Flush failed for doc ${docId}:`, err);
@@ -583,27 +562,6 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [serialGate],
583
562
  * Cleans up local state and notifies the application with any pending changes that were lost.
584
563
  */
585
564
  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) {
607
565
  const algorithm = this._getAlgorithm(docId);
608
566
  const pendingChanges = await algorithm.getPendingToSend(docId) ?? [];
609
567
  const doc = this.patches.getOpenDoc(docId);
@@ -613,7 +571,7 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [serialGate],
613
571
  this.trackedDocs.delete(docId);
614
572
  this._updateDocSyncState(docId, void 0);
615
573
  await algorithm.confirmDeleteDoc(docId);
616
- return pendingChanges;
574
+ await this.onRemoteDocDeleted.emit(docId, pendingChanges);
617
575
  }
618
576
  /**
619
577
  * Adds, updates, or removes a doc state entry immutably and notifies via store.
@@ -676,16 +634,6 @@ class PatchesSync extends (_a = ReadonlyStoreClass, _syncDoc_dec = [serialGate],
676
634
  _isDocDeletedError(err) {
677
635
  return err instanceof StatusError && err.code === ErrorCodes.DOC_DELETED;
678
636
  }
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
- }
689
637
  }
690
638
  _init = __decoratorStart(_a);
691
639
  __decorateElement(_init, 1, "syncDoc", _syncDoc_dec, PatchesSync);
@@ -5,21 +5,21 @@ declare class StatusError extends Error {
5
5
  }
6
6
  /**
7
7
  * Standard error codes for Patches operations.
8
+ *
9
+ * Patches is permission-agnostic; these codes are surfaced verbatim from
10
+ * `StatusError.code` so consuming apps can branch on the HTTP status without
11
+ * reaching for string matching. Permission *policy* (what to do with a 403)
12
+ * lives in the consuming app.
8
13
  */
9
14
  declare const ErrorCodes: {
10
15
  /** Document was deleted (tombstone exists). */
11
16
  readonly DOC_DELETED: 410;
12
17
  /** Document not found (never existed). */
13
18
  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;
19
+ /** Caller is not authenticated (no/invalid credentials). */
20
+ readonly DOC_UNAUTHORIZED: 401;
21
+ /** Caller is authenticated but not authorized for this doc. */
22
+ readonly DOC_FORBIDDEN: 403;
23
23
  };
24
24
  /**
25
25
  * Error thrown when the JSON-RPC client receives a message that cannot be parsed as JSON.
package/dist/net/error.js CHANGED
@@ -11,15 +11,10 @@ const ErrorCodes = {
11
11
  DOC_DELETED: 410,
12
12
  /** Document not found (never existed). */
13
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
+ /** Caller is not authenticated (no/invalid credentials). */
15
+ DOC_UNAUTHORIZED: 401,
16
+ /** Caller is authenticated but not authorized for this doc. */
17
+ DOC_FORBIDDEN: 403
23
18
  };
24
19
  class JSONRPCParseError extends Error {
25
20
  rawMessage;
@@ -1,20 +1,21 @@
1
- export { Invite, InviteProjectBookMeta, InviteProjectMetaSnapshot, InviteRole, InviteTo } from './invite.js';
2
1
  export { FetchTransport } from './http/FetchTransport.js';
3
2
  export { PatchesClient } from './PatchesClient.js';
4
3
  export { PatchesConnection } from './PatchesConnection.js';
5
4
  export { PatchesSync, PatchesSyncOptions, PatchesSyncState } from './PatchesSync.js';
6
5
  export { PatchesREST, PatchesRESTOptions } from './rest/PatchesREST.js';
6
+ export { PatchesRESTSignalingTransport } from './rest/PatchesRESTSignalingTransport.js';
7
7
  export { BufferedEvent, SSEServer, SSEServerOptions } from './rest/SSEServer.js';
8
+ export { SSESignalingService } from './rest/SSESignalingService.js';
8
9
  export { normalizeIds } from './rest/utils.js';
9
10
  export { JSONRPCClient } from './protocol/JSONRPCClient.js';
10
11
  export { ApiDefinition, ConnectionSignalSubscriber, JSONRPCServer, JSONRPCServerOptions, MessageHandler } from './protocol/JSONRPCServer.js';
11
12
  export { getAuthContext, getClientId } from './serverContext.js';
12
- export { AwarenessUpdateNotificationParams, BranchAPI, ClientTransport, ConnectionState, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, Message, PatchesAPI, PatchesNotificationParams, ServerTransport, SignalNotificationParams } from './protocol/types.js';
13
+ export { AwarenessUpdateNotificationParams, BranchAPI, ClientTransport, ConnectionState, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, Message, PatchesAPI, PatchesNotificationParams, ServerTransport, SignalNotificationParams, SignalingTransport } from './protocol/types.js';
13
14
  export { rpcError, rpcNotification, rpcResponse } from './protocol/utils.js';
14
15
  export { Access, AuthContext, AuthorizationProvider, allowAll, assertNotDeleted, denyAll } from './websocket/AuthorizationProvider.js';
15
16
  export { onlineState } from './websocket/onlineState.js';
16
17
  export { PatchesWebSocket } from './websocket/PatchesWebSocket.js';
17
- export { JsonRpcMessage, SignalingService } from './websocket/SignalingService.js';
18
+ export { JsonRpcMessage, SignalingService } from './signaling/SignalingService.js';
18
19
  export { WebSocketServer, WebSocketServerOptions } from './websocket/WebSocketServer.js';
19
20
  export { WebSocketOptions, WebSocketTransport } from './websocket/WebSocketTransport.js';
20
21
  export { CommitChangesOptions } from '../types.js';
package/dist/net/index.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import "../chunk-IZ2YBCUP.js";
2
- export * from "./invite.js";
3
2
  export * from "./http/FetchTransport.js";
4
3
  export * from "./PatchesClient.js";
5
4
  export * from "./PatchesConnection.js";
@@ -12,7 +11,7 @@ export * from "./protocol/utils.js";
12
11
  export * from "./websocket/AuthorizationProvider.js";
13
12
  export * from "./websocket/onlineState.js";
14
13
  export * from "./websocket/PatchesWebSocket.js";
15
- export * from "./websocket/SignalingService.js";
14
+ export * from "./signaling/SignalingService.js";
16
15
  export * from "./websocket/WebSocketServer.js";
17
16
  export * from "./websocket/WebSocketTransport.js";
18
17
  export {
@@ -162,7 +162,7 @@ class JSONRPCServer {
162
162
  }
163
163
  const ok = await this.auth.canAccess(ctx, docId, access, method);
164
164
  if (!ok) {
165
- throw new StatusError(401, `${access.toUpperCase()}_FORBIDDEN:${docId}`);
165
+ throw new StatusError(403, `${access.toUpperCase()}_FORBIDDEN:${docId}`);
166
166
  }
167
167
  }
168
168
  /**
@@ -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,7 +2,6 @@ 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';
6
5
  import '../../json-patch/JSONPatch.js';
7
6
  import '@dabble/delta';
8
7
  import '../../json-patch/types.js';
@@ -37,6 +36,11 @@ declare class PatchesREST implements PatchesConnection {
37
36
  readonly onStateChange: easy_signal.Signal<(state: ConnectionState) => void>;
38
37
  readonly onChangesCommitted: easy_signal.Signal<(docId: string, changes: Change[], options?: CommitChangesOptions) => void>;
39
38
  readonly onDocDeleted: easy_signal.Signal<(docId: string) => void>;
39
+ /**
40
+ * Emits raw JSON-RPC strings received over the multiplexed `signal` SSE channel.
41
+ * Used by `PatchesRESTSignalingTransport` to drive `WebRTCTransport`'s signaling.
42
+ */
43
+ readonly onSignal: easy_signal.Signal<(raw: string) => void>;
40
44
  private _url;
41
45
  private _state;
42
46
  private eventSource;
@@ -46,6 +50,8 @@ declare class PatchesREST implements PatchesConnection {
46
50
  constructor(url: string, options?: PatchesRESTOptions);
47
51
  get url(): string;
48
52
  set url(url: string);
53
+ /** Current connection state of the underlying SSE stream. */
54
+ get state(): ConnectionState;
49
55
  connect(): Promise<void>;
50
56
  disconnect(): void;
51
57
  subscribe(ids: string | string[]): Promise<string[]>;
@@ -70,27 +76,21 @@ declare class PatchesREST implements PatchesConnection {
70
76
  deleteBranch(docId: string, branchId: string): Promise<void>;
71
77
  mergeBranch(docId: string, branchId: string): Promise<void>;
72
78
  /**
73
- * Fetch a single invite addressed to the authenticated user.
74
- * `GET /docs/:docId/_invites/:inviteId`
75
- */
76
- getInvite(docId: string, inviteId: string): Promise<Invite>;
77
- /**
78
- * Accept or decline an invite. Returns whether the server applied a role
79
- * change (`ok` from `{ ok: boolean }`).
80
- * `POST /docs/:docId/_invites/:inviteId` body `{ accept: boolean }`
81
- */
82
- acceptInvite(docId: string, inviteId: string, accept: boolean): Promise<boolean>;
83
- /**
84
- * Remove the authenticated user from `roles.users` (non-owners only).
85
- * `POST /docs/:docId/_self/leave`
86
- */
87
- leaveProject(docId: string): Promise<boolean>;
88
- /**
89
- * Owner/co-author revokes another member. `targetUid` is removed from
90
- * `roles.users` and tombstoned under `roles.formerUsers.revokedAt`.
91
- * `POST /docs/:docId/_members/:uid/revoke`
79
+ * POSTs a raw JSON-RPC string to `/signal/:clientId`. Used as the upstream
80
+ * half of the multiplexed signaling channel: receive happens via the `signal`
81
+ * SSE event (see {@link onSignal}).
82
+ *
83
+ * The body is sent verbatim — callers pass an already-stringified JSON-RPC
84
+ * message. The endpoint forwards it to {@link SignalingService.handleClientMessage}.
85
+ *
86
+ * Silently no-ops when the SSE stream is not connected. Signaling is
87
+ * inherently best-effort and the server has no live `signal` channel back
88
+ * to this client until the stream is up, so a frame sent before connect
89
+ * resolves can't be relayed anyway. Throwing here would surface as an
90
+ * unhandled rejection through `JSONRPCClient.call`, which doesn't await
91
+ * `transport.send`.
92
92
  */
93
- revokeMember(docId: string, targetUid: string): Promise<boolean>;
93
+ sendSignal(raw: string): Promise<void>;
94
94
  private _setState;
95
95
  private _getHeaders;
96
96
  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,51 +202,38 @@ class PatchesREST {
189
202
  async mergeBranch(docId, branchId) {
190
203
  await this._fetch(`/docs/${docId}/_branches/${encodeURIComponent(branchId)}/_merge`, { method: "POST" });
191
204
  }
192
- // --- Invites & membership (pup REST) ---
193
- /**
194
- * Fetch a single invite addressed to the authenticated user.
195
- * `GET /docs/:docId/_invites/:inviteId`
196
- */
197
- async getInvite(docId, inviteId) {
198
- return this._fetch(`/docs/${docId}/_invites/${encodeURIComponent(inviteId)}`);
199
- }
200
- /**
201
- * Accept or decline an invite. Returns whether the server applied a role
202
- * change (`ok` from `{ ok: boolean }`).
203
- * `POST /docs/:docId/_invites/:inviteId` body `{ accept: boolean }`
204
- */
205
- async acceptInvite(docId, inviteId, accept) {
206
- const result = await this._fetch(`/docs/${docId}/_invites/${encodeURIComponent(inviteId)}`, {
207
- method: "POST",
208
- body: { accept }
209
- });
210
- return Boolean(result?.ok);
211
- }
205
+ // --- WebRTC Signaling ---
212
206
  /**
213
- * Remove the authenticated user from `roles.users` (non-owners only).
214
- * `POST /docs/:docId/_self/leave`
207
+ * POSTs a raw JSON-RPC string to `/signal/:clientId`. Used as the upstream
208
+ * half of the multiplexed signaling channel: receive happens via the `signal`
209
+ * SSE event (see {@link onSignal}).
210
+ *
211
+ * The body is sent verbatim — callers pass an already-stringified JSON-RPC
212
+ * message. The endpoint forwards it to {@link SignalingService.handleClientMessage}.
213
+ *
214
+ * Silently no-ops when the SSE stream is not connected. Signaling is
215
+ * inherently best-effort and the server has no live `signal` channel back
216
+ * to this client until the stream is up, so a frame sent before connect
217
+ * resolves can't be relayed anyway. Throwing here would surface as an
218
+ * unhandled rejection through `JSONRPCClient.call`, which doesn't await
219
+ * `transport.send`.
215
220
  */
216
- async leaveProject(docId) {
217
- const result = await this._fetch(`/docs/${docId}/_self/leave`, {
221
+ async sendSignal(raw) {
222
+ if (this._state !== "connected") return;
223
+ const headers = await this._getHeaders();
224
+ const response = await globalThis.fetch(`${this._url}/signal/${this.clientId}`, {
218
225
  method: "POST",
219
- body: {}
226
+ credentials: "include",
227
+ headers: {
228
+ "Content-Type": "application/json",
229
+ ...headers
230
+ },
231
+ body: raw,
232
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
220
233
  });
221
- return Boolean(result?.ok);
222
- }
223
- /**
224
- * Owner/co-author revokes another member. `targetUid` is removed from
225
- * `roles.users` and tombstoned under `roles.formerUsers.revokedAt`.
226
- * `POST /docs/:docId/_members/:uid/revoke`
227
- */
228
- async revokeMember(docId, targetUid) {
229
- const result = await this._fetch(
230
- `/docs/${docId}/_members/${encodeURIComponent(targetUid)}/revoke`,
231
- {
232
- method: "POST",
233
- body: {}
234
- }
235
- );
236
- return Boolean(result?.ok);
234
+ if (!response.ok) {
235
+ throw new StatusError(response.status, response.statusText);
236
+ }
237
237
  }
238
238
  // --- Private Helpers ---
239
239
  _setState(state) {
@@ -0,0 +1,41 @@
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
+
11
+ /**
12
+ * Adapter that exposes a {@link PatchesREST} connection as a {@link SignalingTransport}
13
+ * for `WebRTCTransport`. Multiplexes signaling over the existing SSE stream:
14
+ *
15
+ * - **send** → `PatchesREST.sendSignal()` → `POST /signal/:clientId`.
16
+ * - **receive** → subscribes to `PatchesREST.onSignal` (the `signal` SSE event).
17
+ *
18
+ * Does not own the connection. The application calls `patches.connect()`
19
+ * directly; `connect()` here just delegates so callers can still `await` it.
20
+ */
21
+ declare class PatchesRESTSignalingTransport implements SignalingTransport {
22
+ private patches;
23
+ /**
24
+ * @param patches - The shared `PatchesREST` instance handling document sync.
25
+ * The same `clientId` is used for both, so signaling addressing matches.
26
+ */
27
+ constructor(patches: PatchesREST);
28
+ get state(): ConnectionState;
29
+ get onStateChange(): easy_signal.Signal<(state: ConnectionState) => void>;
30
+ /**
31
+ * Delegates to {@link PatchesREST.connect}. Safe to call multiple times — if
32
+ * the SSE stream is already open, the underlying call is a no-op.
33
+ */
34
+ connect(): Promise<void>;
35
+ /** Sends a raw JSON-RPC frame upstream over the signaling REST endpoint. */
36
+ send(raw: string): Promise<void>;
37
+ /** Subscribes to inbound JSON-RPC frames received via the `signal` SSE event. */
38
+ onMessage(cb: (raw: string) => void): Unsubscriber;
39
+ }
40
+
41
+ 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
  */