@pylonsync/sync 0.4.22 → 0.4.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -19,6 +19,14 @@ export interface SyncEngineConfig {
19
19
  transport?: TransportType;
20
20
  /** WebSocket URL. Default: derived from baseUrl (ws://). */
21
21
  wsUrl?: string;
22
+ /** Connect the live-event socket through the Durable Object sync
23
+ * relay instead of the machine's own WS. The engine fetches a
24
+ * signed connect target from `GET /api/sync/relay-token` before
25
+ * each connect (fresh token every reconnect), so revoked sessions
26
+ * converge on the token TTL. Pull/push/mutations still go to
27
+ * `baseUrl` — the relay carries change events only; room, CRDT,
28
+ * and reactive subscriptions require the direct machine WS. */
29
+ relay?: boolean;
22
30
  /** Poll interval in ms (only used when transport is "poll"). Default 1000. */
23
31
  pollInterval?: number;
24
32
  /** Reconnect delay in ms. Default 1000. */
@@ -798,6 +806,13 @@ export declare class SyncEngine {
798
806
  * subsequent sign-in as the same user is instant.
799
807
  */
800
808
  signOut(): Promise<void>;
809
+ /** Relay mode: fetch a fresh signed connect target from the machine
810
+ * before each socket attempt. The machine authenticates + enriches
811
+ * this caller and mints the blob the relay's policy filter runs
812
+ * against — fetching per-connect is what bounds revoked-role
813
+ * staleness to the token TTL. Returns null on any failure; the
814
+ * transport treats that as a failed connect and backs off. */
815
+ private fetchRelayTarget;
801
816
  /** Shared transport for the auth helpers above. Same bearer/cookie
802
817
  * policy as `request()` — keeps the auth flows on the same
803
818
  * authentication footing as data sync. */
@@ -54,6 +54,19 @@ export interface TransportHost {
54
54
  * must call this fresh on each connect / reconnect so token rotation
55
55
  * is picked up automatically. */
56
56
  getToken(): string | undefined;
57
+ /** Sync-relay mode (engine sets this only when `config.relay` is
58
+ * on): resolve the relay socket target freshly for THIS connect
59
+ * attempt — the ws `url` (with the `since` cursor) plus the signed
60
+ * auth `blob`. The transport sends the blob in the `bearer.<blob>`
61
+ * subprotocol, exactly like the machine token, so the credential
62
+ * stays out of the URL (and out of proxy logs). `null` = the token
63
+ * fetch failed; the transport backs off and retries like any failed
64
+ * connect. When present, this replaces `wsUrl`/derivation and the
65
+ * machine bearer token. */
66
+ getRelayTarget?(): Promise<{
67
+ url: string;
68
+ blob: string;
69
+ } | null>;
57
70
  /** Is this tab the multi-tab leader. Followers don't open their own
58
71
  * transport — they mirror the leader's broadcasts. Transports check
59
72
  * this defensively at start() to avoid wasting socket budget. */
@@ -5,9 +5,11 @@ export declare class WebSocketTransport implements Transport {
5
5
  private reconnectTimer;
6
6
  private stableTimer;
7
7
  private pingTimer;
8
+ private resolvingRelay;
8
9
  private readonly backoff;
9
10
  constructor(host: TransportHost);
10
11
  start(): void;
12
+ private connectTo;
11
13
  stop(): void;
12
14
  send(msg: unknown): void;
13
15
  isOpen(): boolean;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.4.22",
6
+ "version": "0.4.24",
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
9
9
  "types": "./dist/index.d.ts",
package/src/index.ts CHANGED
@@ -110,6 +110,14 @@ export interface SyncEngineConfig {
110
110
  transport?: TransportType;
111
111
  /** WebSocket URL. Default: derived from baseUrl (ws://). */
112
112
  wsUrl?: string;
113
+ /** Connect the live-event socket through the Durable Object sync
114
+ * relay instead of the machine's own WS. The engine fetches a
115
+ * signed connect target from `GET /api/sync/relay-token` before
116
+ * each connect (fresh token every reconnect), so revoked sessions
117
+ * converge on the token TTL. Pull/push/mutations still go to
118
+ * `baseUrl` — the relay carries change events only; room, CRDT,
119
+ * and reactive subscriptions require the direct machine WS. */
120
+ relay?: boolean;
113
121
  /** Poll interval in ms (only used when transport is "poll"). Default 1000. */
114
122
  pollInterval?: number;
115
123
  /** Reconnect delay in ms. Default 1000. */
@@ -2566,6 +2574,39 @@ export class SyncEngine {
2566
2574
  await this.refreshResolvedSession();
2567
2575
  }
2568
2576
 
2577
+ /** Relay mode: fetch a fresh signed connect target from the machine
2578
+ * before each socket attempt. The machine authenticates + enriches
2579
+ * this caller and mints the blob the relay's policy filter runs
2580
+ * against — fetching per-connect is what bounds revoked-role
2581
+ * staleness to the token TTL. Returns null on any failure; the
2582
+ * transport treats that as a failed connect and backs off. */
2583
+ private async fetchRelayTarget(): Promise<{ url: string; blob: string } | null> {
2584
+ try {
2585
+ const headers: Record<string, string> = {};
2586
+ const token =
2587
+ this.config.token ??
2588
+ this.storage.get(this.tokenStorageKey()) ??
2589
+ undefined;
2590
+ if (token) headers["Authorization"] = `Bearer ${token}`;
2591
+ const res = await fetch(`${this.config.baseUrl}/api/sync/relay-token`, {
2592
+ headers,
2593
+ credentials: "include",
2594
+ });
2595
+ if (!res.ok) return null;
2596
+ const body = (await res.json()) as { token?: string; url?: string };
2597
+ if (!body.token || !body.url) return null;
2598
+ const sep = body.url.includes("?") ? "&" : "?";
2599
+ // `since` lets the relay replay its ring tail ahead of live
2600
+ // frames; the engine's reconnect pull against the machine is
2601
+ // still the correctness backstop for anything older. The token
2602
+ // stays OUT of the URL — the transport sends it as a subprotocol.
2603
+ const since = this.cursor.last_seq ?? 0;
2604
+ return { url: `${body.url}${sep}since=${since}`, blob: body.token };
2605
+ } catch {
2606
+ return null;
2607
+ }
2608
+ }
2609
+
2569
2610
  /** Shared transport for the auth helpers above. Same bearer/cookie
2570
2611
  * policy as `request()` — keeps the auth flows on the same
2571
2612
  * authentication footing as data sync. */
@@ -3277,6 +3318,7 @@ export class SyncEngine {
3277
3318
  return {
3278
3319
  baseUrl: this.config.baseUrl,
3279
3320
  wsUrl: this.config.wsUrl,
3321
+ ...(this.config.relay ? { getRelayTarget: () => this.fetchRelayTarget() } : {}),
3280
3322
  pingIntervalMs: this.config.pingIntervalMs,
3281
3323
  reconnectDelayMs: this.config.reconnectDelay,
3282
3324
  pollIntervalMs: this.config.pollInterval,
@@ -70,6 +70,17 @@ export interface TransportHost {
70
70
  * is picked up automatically. */
71
71
  getToken(): string | undefined;
72
72
 
73
+ /** Sync-relay mode (engine sets this only when `config.relay` is
74
+ * on): resolve the relay socket target freshly for THIS connect
75
+ * attempt — the ws `url` (with the `since` cursor) plus the signed
76
+ * auth `blob`. The transport sends the blob in the `bearer.<blob>`
77
+ * subprotocol, exactly like the machine token, so the credential
78
+ * stays out of the URL (and out of proxy logs). `null` = the token
79
+ * fetch failed; the transport backs off and retries like any failed
80
+ * connect. When present, this replaces `wsUrl`/derivation and the
81
+ * machine bearer token. */
82
+ getRelayTarget?(): Promise<{ url: string; blob: string } | null>;
83
+
73
84
  /** Is this tab the multi-tab leader. Followers don't open their own
74
85
  * transport — they mirror the leader's broadcasts. Transports check
75
86
  * this defensively at start() to avoid wasting socket budget. */
@@ -194,6 +194,47 @@ describe("WebSocketTransport", () => {
194
194
  expect(fakeSockets[0].protocols).toBe("bearer.tok_abc");
195
195
  });
196
196
 
197
+ test("relay mode dials the minted URL with the blob as a subprotocol", async () => {
198
+ let mints = 0;
199
+ const { host } = makeHost({
200
+ getToken: () => "tok_abc", // machine token must NOT reach the wire
201
+ getRelayTarget: async () => {
202
+ mints += 1;
203
+ return {
204
+ url: `wss://relay.invalid/sync/ws?app=demo&since=42`,
205
+ blob: `blob${mints}`,
206
+ };
207
+ },
208
+ });
209
+ t = new WebSocketTransport(host);
210
+ t.start();
211
+ // Resolution is async; nothing dialed synchronously, and a second
212
+ // start() during resolution must not stack a second mint.
213
+ expect(fakeSockets.length).toBe(0);
214
+ t.start();
215
+ await Promise.resolve();
216
+ await Promise.resolve();
217
+ expect(mints).toBe(1);
218
+ expect(fakeSockets.length).toBe(1);
219
+ // The blob is NOT in the URL (stays out of proxy logs) …
220
+ expect(fakeSockets[0].url).toBe("wss://relay.invalid/sync/ws?app=demo&since=42");
221
+ // … it rides the bearer subprotocol, and the machine token doesn't.
222
+ expect(fakeSockets[0].protocols).toBe("bearer.blob1");
223
+ });
224
+
225
+ test("relay mint failure backs off instead of dialing", async () => {
226
+ const { host } = makeHost({ getRelayTarget: async () => null });
227
+ t = new WebSocketTransport(host);
228
+ t.start();
229
+ await Promise.resolve();
230
+ await Promise.resolve();
231
+ expect(fakeSockets.length).toBe(0);
232
+ // Backoff timer armed: a later fire re-attempts via reconnect pull.
233
+ await new Promise((r) => setTimeout(r, 30));
234
+ // Each retry re-mints (and fails) — no socket ever opens.
235
+ expect(fakeSockets.length).toBe(0);
236
+ });
237
+
197
238
  test("onopen flips status to connected and fires host.onConnected", () => {
198
239
  const { host, state } = makeHost();
199
240
  t = new WebSocketTransport(host);
@@ -24,6 +24,7 @@ export class WebSocketTransport implements Transport {
24
24
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
25
25
  private stableTimer: ReturnType<typeof setTimeout> | null = null;
26
26
  private pingTimer: ReturnType<typeof setInterval> | null = null;
27
+ private resolvingRelay = false;
27
28
  private readonly backoff: ReconnectBackoff;
28
29
 
29
30
  constructor(host: TransportHost) {
@@ -37,10 +38,39 @@ export class WebSocketTransport implements Transport {
37
38
  // the BroadcastChannel.
38
39
  if (!this.host.isLeader()) return;
39
40
  // Already connected / connecting? Don't stack a second socket.
40
- if (this.ws) return;
41
+ if (this.ws || this.resolvingRelay) return;
42
+
43
+ // Relay mode: the connect target is minted per-attempt (signed
44
+ // blob + since cursor). The blob travels in the `bearer.<blob>`
45
+ // subprotocol via connectTo, NOT the URL — keeping the live
46
+ // credential out of proxy/CDN access logs.
47
+ if (this.host.getRelayTarget) {
48
+ this.resolvingRelay = true;
49
+ this.host
50
+ .getRelayTarget()
51
+ .then((target) => {
52
+ this.resolvingRelay = false;
53
+ if (!this.host.isRunning() || this.ws) return;
54
+ if (!target) {
55
+ this.scheduleReconnect();
56
+ return;
57
+ }
58
+ this.connectTo(target.url, target.blob);
59
+ })
60
+ .catch(() => {
61
+ // getRelayTarget shouldn't reject (the engine's impl catches
62
+ // to null), but a custom host might — never wedge on a stuck
63
+ // resolvingRelay flag.
64
+ this.resolvingRelay = false;
65
+ this.scheduleReconnect();
66
+ });
67
+ return;
68
+ }
69
+
70
+ this.connectTo(this.host.wsUrl ?? this.deriveWsUrl(), this.host.getToken());
71
+ }
41
72
 
42
- const wsUrl = this.host.wsUrl ?? this.deriveWsUrl();
43
- const token = this.host.getToken();
73
+ private connectTo(wsUrl: string, token: string | undefined): void {
44
74
  try {
45
75
  if (token) {
46
76
  const proto = `bearer.${encodeURIComponent(token)}`;
@@ -158,6 +188,8 @@ export class WebSocketTransport implements Transport {
158
188
  }
159
189
  this.ws = null;
160
190
  }
191
+ // A relay-target resolution in flight must not re-open after stop.
192
+ this.resolvingRelay = false;
161
193
  if (this.reconnectTimer) {
162
194
  clearTimeout(this.reconnectTimer);
163
195
  this.reconnectTimer = null;