@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 +15 -0
- package/dist/transports/types.d.ts +13 -0
- package/dist/transports/websocket.d.ts +2 -0
- package/package.json +1 -1
- package/src/index.ts +42 -0
- package/src/transports/types.ts +11 -0
- package/src/transports/websocket.test.ts +41 -0
- package/src/transports/websocket.ts +35 -3
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
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,
|
package/src/transports/types.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|