@parlor/sync 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +10 -2
- package/dist/channel.d.ts +21 -0
- package/dist/channel.d.ts.map +1 -0
- package/dist/channel.js +2 -0
- package/dist/channel.js.map +1 -0
- package/dist/client/index.d.ts +3 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +3 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/sse.d.ts +10 -0
- package/dist/client/sse.d.ts.map +1 -0
- package/dist/client/sse.js +48 -0
- package/dist/client/sse.js.map +1 -0
- package/dist/client/sync-client.d.ts +70 -0
- package/dist/client/sync-client.d.ts.map +1 -0
- package/dist/client/sync-client.js +447 -0
- package/dist/client/sync-client.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol.d.ts +32 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +30 -0
- package/dist/protocol.js.map +1 -0
- package/dist/server/hub.d.ts +15 -0
- package/dist/server/hub.d.ts.map +1 -0
- package/dist/server/hub.js +75 -0
- package/dist/server/hub.js.map +1 -0
- package/dist/server/index.d.ts +5 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +5 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/pruner.d.ts +11 -0
- package/dist/server/pruner.d.ts.map +1 -0
- package/dist/server/pruner.js +23 -0
- package/dist/server/pruner.js.map +1 -0
- package/dist/server/routes.d.ts +36 -0
- package/dist/server/routes.d.ts.map +1 -0
- package/dist/server/routes.js +119 -0
- package/dist/server/routes.js.map +1 -0
- package/dist/server/store.d.ts +24 -0
- package/dist/server/store.d.ts.map +1 -0
- package/dist/server/store.js +110 -0
- package/dist/server/store.js.map +1 -0
- package/package.json +43 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 cychien
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @parlor/sync
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The live-update protocol: the cursor and event types the server and the page exchange so queries refresh when data changes. Installed by `@parlor/server` and `@parlor/client`; you do not depend on it directly.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { INITIAL_CURSOR, SYNC_PROTOCOL_VERSION } from "@parlor/sync";
|
|
7
|
+
|
|
8
|
+
console.log(SYNC_PROTOCOL_VERSION, INITIAL_CURSOR);
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Docs: https://cychien.github.io/parlor/guides/deploy/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Principal } from "@parlor/core";
|
|
2
|
+
export interface ServerChannel {
|
|
3
|
+
readonly event: string;
|
|
4
|
+
connect(connection: ChannelConnection): () => void;
|
|
5
|
+
}
|
|
6
|
+
export interface ChannelConnection {
|
|
7
|
+
readonly clientId: string | undefined;
|
|
8
|
+
readonly scope: string;
|
|
9
|
+
/** Who holds the stream; absent when the server resolves no identity. */
|
|
10
|
+
readonly principal?: Principal | undefined;
|
|
11
|
+
/** Aborts when the stream ends, including when its holder's credential is revoked. */
|
|
12
|
+
readonly closed?: AbortSignal | undefined;
|
|
13
|
+
send(data: string): void;
|
|
14
|
+
}
|
|
15
|
+
export interface ClientChannel {
|
|
16
|
+
readonly event: string;
|
|
17
|
+
opened(): void;
|
|
18
|
+
receive(data: string): void;
|
|
19
|
+
closed(): void;
|
|
20
|
+
}
|
|
21
|
+
//# sourceMappingURL=channel.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../src/channel.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAE9C,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,OAAO,CAAC,UAAU,EAAE,iBAAiB,GAAG,MAAM,IAAI,CAAC;CACpD;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,CAAC;IACtC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,yEAAyE;IACzE,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,GAAG,SAAS,CAAC;IAC3C,sFAAsF;IACtF,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,SAAS,CAAC;IAC1C,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,MAAM,IAAI,IAAI,CAAC;IACf,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,MAAM,IAAI,IAAI,CAAC;CAChB"}
|
package/dist/channel.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"channel.js","sourceRoot":"","sources":["../src/channel.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/client/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,OAAO,EAAE,KAAK,QAAQ,EAAE,MAAM,UAAU,CAAC;AACjE,OAAO,EACL,gBAAgB,EAChB,KAAK,UAAU,EACf,KAAK,iBAAiB,EACtB,KAAK,WAAW,EAChB,kBAAkB,EAClB,gBAAgB,GACjB,MAAM,kBAAkB,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/client/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,OAAO,EAAiB,MAAM,UAAU,CAAC;AACjE,OAAO,EACL,gBAAgB,EAIhB,kBAAkB,EAClB,gBAAgB,GACjB,MAAM,kBAAkB,CAAC"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface SseFrame {
|
|
2
|
+
readonly event: string;
|
|
3
|
+
readonly data: string;
|
|
4
|
+
}
|
|
5
|
+
export declare function parseSseChunk(buffer: string): {
|
|
6
|
+
frames: SseFrame[];
|
|
7
|
+
rest: string;
|
|
8
|
+
};
|
|
9
|
+
export declare function readSse(body: ReadableStream<Uint8Array>, onFrame: (frame: SseFrame) => void, signal?: AbortSignal, onChunk?: () => void): Promise<void>;
|
|
10
|
+
//# sourceMappingURL=sse.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sse.d.ts","sourceRoot":"","sources":["../../src/client/sse.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG;IAAE,MAAM,EAAE,QAAQ,EAAE,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAkBlF;AAED,wBAAsB,OAAO,CAC3B,IAAI,EAAE,cAAc,CAAC,UAAU,CAAC,EAChC,OAAO,EAAE,CAAC,KAAK,EAAE,QAAQ,KAAK,IAAI,EAClC,MAAM,CAAC,EAAE,WAAW,EACpB,OAAO,CAAC,EAAE,MAAM,IAAI,GACnB,OAAO,CAAC,IAAI,CAAC,CAmBf"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export function parseSseChunk(buffer) {
|
|
2
|
+
const frames = [];
|
|
3
|
+
let rest = buffer;
|
|
4
|
+
for (;;) {
|
|
5
|
+
const end = rest.indexOf("\n\n");
|
|
6
|
+
if (end === -1)
|
|
7
|
+
break;
|
|
8
|
+
const block = rest.slice(0, end);
|
|
9
|
+
rest = rest.slice(end + 2);
|
|
10
|
+
let event = "message";
|
|
11
|
+
const data = [];
|
|
12
|
+
for (const line of block.split("\n")) {
|
|
13
|
+
if (line.startsWith(":"))
|
|
14
|
+
continue;
|
|
15
|
+
if (line.startsWith("event:"))
|
|
16
|
+
event = line.slice(6).trim();
|
|
17
|
+
else if (line.startsWith("data:"))
|
|
18
|
+
data.push(line.slice(5).trimStart());
|
|
19
|
+
}
|
|
20
|
+
if (data.length > 0)
|
|
21
|
+
frames.push({ event, data: data.join("\n") });
|
|
22
|
+
}
|
|
23
|
+
return { frames, rest };
|
|
24
|
+
}
|
|
25
|
+
export async function readSse(body, onFrame, signal, onChunk) {
|
|
26
|
+
const reader = body.getReader();
|
|
27
|
+
const decoder = new TextDecoder();
|
|
28
|
+
let buffer = "";
|
|
29
|
+
const abort = () => void reader.cancel().catch(() => { });
|
|
30
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
31
|
+
try {
|
|
32
|
+
for (;;) {
|
|
33
|
+
const { value, done } = await reader.read();
|
|
34
|
+
if (done)
|
|
35
|
+
break;
|
|
36
|
+
onChunk?.();
|
|
37
|
+
buffer += decoder.decode(value, { stream: true });
|
|
38
|
+
const { frames, rest } = parseSseChunk(buffer);
|
|
39
|
+
buffer = rest;
|
|
40
|
+
for (const frame of frames)
|
|
41
|
+
onFrame(frame);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
signal?.removeEventListener("abort", abort);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=sse.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sse.js","sourceRoot":"","sources":["../../src/client/sse.ts"],"names":[],"mappings":"AAKA,MAAM,UAAU,aAAa,CAAC,MAAc;IAC1C,MAAM,MAAM,GAAe,EAAE,CAAC;IAC9B,IAAI,IAAI,GAAG,MAAM,CAAC;IAClB,SAAS,CAAC;QACR,MAAM,GAAG,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;QACjC,IAAI,GAAG,KAAK,CAAC,CAAC;YAAE,MAAM;QACtB,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;QACjC,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC;QAC3B,IAAI,KAAK,GAAG,SAAS,CAAC;QACtB,MAAM,IAAI,GAAa,EAAE,CAAC;QAC1B,KAAK,MAAM,IAAI,IAAI,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;YACrC,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC;gBAAE,SAAS;YACnC,IAAI,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC;gBAAE,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;iBACvD,IAAI,IAAI,CAAC,UAAU,CAAC,OAAO,CAAC;gBAAE,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC;QAC1E,CAAC;QACD,IAAI,IAAI,CAAC,MAAM,GAAG,CAAC;YAAE,MAAM,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;IACrE,CAAC;IACD,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC;AAC1B,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,OAAO,CAC3B,IAAgC,EAChC,OAAkC,EAClC,MAAoB,EACpB,OAAoB;IAEpB,MAAM,MAAM,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC;IAChC,MAAM,OAAO,GAAG,IAAI,WAAW,EAAE,CAAC;IAClC,IAAI,MAAM,GAAG,EAAE,CAAC;IAChB,MAAM,KAAK,GAAG,GAAG,EAAE,CAAC,KAAK,MAAM,CAAC,MAAM,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;IACzD,MAAM,EAAE,gBAAgB,CAAC,OAAO,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;IACzD,IAAI,CAAC;QACH,SAAS,CAAC;YACR,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;YAC5C,IAAI,IAAI;gBAAE,MAAM;YAChB,OAAO,EAAE,EAAE,CAAC;YACZ,MAAM,IAAI,OAAO,CAAC,MAAM,CAAC,KAAK,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC;YAClD,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,aAAa,CAAC,MAAM,CAAC,CAAC;YAC/C,MAAM,GAAG,IAAI,CAAC;YACd,KAAK,MAAM,KAAK,IAAI,MAAM;gBAAE,OAAO,CAAC,KAAK,CAAC,CAAC;QAC7C,CAAC;IACH,CAAC;YAAS,CAAC;QACT,MAAM,EAAE,mBAAmB,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;IAC9C,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { ClientChannel } from "../channel.js";
|
|
2
|
+
import { type SyncEvent } from "../protocol.js";
|
|
3
|
+
export type SyncRequest = (path: string, init?: RequestInit) => Promise<Response>;
|
|
4
|
+
export interface SyncClientOptions {
|
|
5
|
+
readonly request: SyncRequest;
|
|
6
|
+
readonly basePath?: string | undefined;
|
|
7
|
+
readonly clientId?: string | undefined;
|
|
8
|
+
readonly onEvents: (events: SyncEvent[]) => void;
|
|
9
|
+
readonly onError?: ((error: unknown) => void) | undefined;
|
|
10
|
+
readonly coalesceMs?: number | undefined;
|
|
11
|
+
readonly safetyPollMs?: number | undefined;
|
|
12
|
+
readonly hiddenPollMs?: number | undefined;
|
|
13
|
+
readonly reconnectBaseMs?: number | undefined;
|
|
14
|
+
readonly reconnectMaxMs?: number | undefined;
|
|
15
|
+
readonly isHidden?: (() => boolean) | undefined;
|
|
16
|
+
readonly setTimeout?: typeof globalThis.setTimeout | undefined;
|
|
17
|
+
readonly clearTimeout?: typeof globalThis.clearTimeout | undefined;
|
|
18
|
+
readonly channels?: readonly ClientChannel[] | undefined;
|
|
19
|
+
/**
|
|
20
|
+
* Read each time the next poll is scheduled; the interval is the larger of this and the
|
|
21
|
+
* negotiated one, so a caller can only slow polling down.
|
|
22
|
+
*/
|
|
23
|
+
readonly pollEveryMs?: (() => number) | undefined;
|
|
24
|
+
/** Bounds a negotiation or a poll, not the SSE stream. Defaults to 10 s. */
|
|
25
|
+
readonly requestTimeoutMs?: number | undefined;
|
|
26
|
+
/**
|
|
27
|
+
* What the server negotiated, each time it does; `SyncClient.transport` reads `poll`
|
|
28
|
+
* during the fallback poll of a failed negotiation, this never does.
|
|
29
|
+
*/
|
|
30
|
+
readonly onNegotiated?: ((transport: "sse" | "poll") => void) | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* The negotiated transport's connection came back after a failure: a stream reopened
|
|
33
|
+
* after it dropped, or a poll succeeded after a failed one. A fallback poll on an SSE
|
|
34
|
+
* deployment does not count.
|
|
35
|
+
*/
|
|
36
|
+
readonly onResume?: (() => void) | undefined;
|
|
37
|
+
/**
|
|
38
|
+
* The server refused the credential (401 or 403). The client never degrades to polling
|
|
39
|
+
* on it; it keeps retrying the negotiation with backoff. Defaults to `onError`.
|
|
40
|
+
*/
|
|
41
|
+
readonly onUnauthorized?: ((error: SyncUnauthorized) => void) | undefined;
|
|
42
|
+
}
|
|
43
|
+
export declare class SyncUnauthorized extends Error {
|
|
44
|
+
readonly status: number;
|
|
45
|
+
readonly name = "SyncUnauthorized";
|
|
46
|
+
constructor(status: number);
|
|
47
|
+
}
|
|
48
|
+
export declare class SyncRequestTimeout extends Error {
|
|
49
|
+
readonly ms: number;
|
|
50
|
+
readonly name = "SyncRequestTimeout";
|
|
51
|
+
constructor(ms: number);
|
|
52
|
+
}
|
|
53
|
+
export interface SyncClient {
|
|
54
|
+
start(): void;
|
|
55
|
+
stop(): void;
|
|
56
|
+
/**
|
|
57
|
+
* Registers a channel after `start`, for features loaded lazily. It opens at once when
|
|
58
|
+
* the live stream's handshake lists it, else on the next stream. Returns the detach.
|
|
59
|
+
*/
|
|
60
|
+
addChannel(channel: ClientChannel): () => void;
|
|
61
|
+
/**
|
|
62
|
+
* Skips the remaining reconnect backoff, for `online` and visibility events. While
|
|
63
|
+
* polling it polls at once; with a stream open or a request in flight it does nothing.
|
|
64
|
+
*/
|
|
65
|
+
reconnectNow(): void;
|
|
66
|
+
readonly cursor: string;
|
|
67
|
+
readonly transport: "sse" | "poll" | undefined;
|
|
68
|
+
}
|
|
69
|
+
export declare function createSyncClient(options: SyncClientOptions): SyncClient;
|
|
70
|
+
//# sourceMappingURL=sync-client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sync-client.d.ts","sourceRoot":"","sources":["../../src/client/sync-client.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import { compareCursors, cursorOf, decodeCursor, encodeCursor, INITIAL_CURSOR, LEGACY_KEEP_ALIVE_MS, streamLivenessMs, } from "../protocol.js";
|
|
2
|
+
import { readSse } from "./sse.js";
|
|
3
|
+
export class SyncUnauthorized extends Error {
|
|
4
|
+
status;
|
|
5
|
+
name = "SyncUnauthorized";
|
|
6
|
+
constructor(status) {
|
|
7
|
+
super(`sync was refused with HTTP ${status}`);
|
|
8
|
+
this.status = status;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export class SyncRequestTimeout extends Error {
|
|
12
|
+
ms;
|
|
13
|
+
name = "SyncRequestTimeout";
|
|
14
|
+
constructor(ms) {
|
|
15
|
+
super(`sync request timed out after ${ms} ms`);
|
|
16
|
+
this.ms = ms;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
class StaleRequest extends Error {
|
|
20
|
+
name = "StaleRequest";
|
|
21
|
+
}
|
|
22
|
+
const refused = (response) => response.status === 401 || response.status === 403;
|
|
23
|
+
const DEFAULTS = {
|
|
24
|
+
coalesceMs: 250,
|
|
25
|
+
safetyPollMs: 60_000,
|
|
26
|
+
hiddenPollMs: 10_000,
|
|
27
|
+
reconnectBaseMs: 1000,
|
|
28
|
+
reconnectMaxMs: 30_000,
|
|
29
|
+
requestTimeoutMs: 10_000,
|
|
30
|
+
};
|
|
31
|
+
function parseHandshake(handshake) {
|
|
32
|
+
try {
|
|
33
|
+
const { capabilities, keepAliveMs } = JSON.parse(handshake);
|
|
34
|
+
return {
|
|
35
|
+
capabilities: Array.isArray(capabilities)
|
|
36
|
+
? capabilities.filter((c) => typeof c === "string")
|
|
37
|
+
: [],
|
|
38
|
+
keepAliveMs: typeof keepAliveMs === "number" && keepAliveMs > 0 ? keepAliveMs : undefined,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return { capabilities: [], keepAliveMs: undefined };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function createSyncClient(options) {
|
|
46
|
+
const basePath = options.basePath ?? "/_parlor";
|
|
47
|
+
const settings = {
|
|
48
|
+
coalesceMs: options.coalesceMs ?? DEFAULTS.coalesceMs,
|
|
49
|
+
safetyPollMs: options.safetyPollMs ?? DEFAULTS.safetyPollMs,
|
|
50
|
+
hiddenPollMs: options.hiddenPollMs ?? DEFAULTS.hiddenPollMs,
|
|
51
|
+
reconnectBaseMs: options.reconnectBaseMs ?? DEFAULTS.reconnectBaseMs,
|
|
52
|
+
reconnectMaxMs: options.reconnectMaxMs ?? DEFAULTS.reconnectMaxMs,
|
|
53
|
+
requestTimeoutMs: options.requestTimeoutMs ?? DEFAULTS.requestTimeoutMs,
|
|
54
|
+
clientId: options.clientId,
|
|
55
|
+
onError: options.onError,
|
|
56
|
+
};
|
|
57
|
+
const schedule = options.setTimeout ?? ((fn, ms) => globalThis.setTimeout(fn, ms));
|
|
58
|
+
const cancel = options.clearTimeout ?? ((id) => globalThis.clearTimeout(id));
|
|
59
|
+
const isHidden = options.isHidden ?? (() => false);
|
|
60
|
+
const channels = new Map((options.channels ?? []).map((c) => [c.event, c]));
|
|
61
|
+
const guarded = (run) => {
|
|
62
|
+
try {
|
|
63
|
+
run();
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
settings.onError?.(error);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
let cursor = INITIAL_CURSOR;
|
|
70
|
+
let transport;
|
|
71
|
+
let negotiated;
|
|
72
|
+
let interrupted = false;
|
|
73
|
+
let running = false;
|
|
74
|
+
let controller;
|
|
75
|
+
let pollTimer;
|
|
76
|
+
let flushTimer;
|
|
77
|
+
let pending = [];
|
|
78
|
+
let reconnectDelay = settings.reconnectBaseMs;
|
|
79
|
+
let endReconnectWait;
|
|
80
|
+
let connectTimer;
|
|
81
|
+
let epoch = 0;
|
|
82
|
+
const inflight = new Set();
|
|
83
|
+
const withTimeout = async (run) => {
|
|
84
|
+
const mine = epoch;
|
|
85
|
+
const own = new AbortController();
|
|
86
|
+
inflight.add(own);
|
|
87
|
+
let timer;
|
|
88
|
+
const expired = new Promise((_, reject) => {
|
|
89
|
+
timer = schedule(() => {
|
|
90
|
+
own.abort();
|
|
91
|
+
reject(new SyncRequestTimeout(settings.requestTimeoutMs));
|
|
92
|
+
}, settings.requestTimeoutMs);
|
|
93
|
+
});
|
|
94
|
+
try {
|
|
95
|
+
const result = await Promise.race([run(own.signal), expired]);
|
|
96
|
+
if (mine !== epoch)
|
|
97
|
+
throw new StaleRequest();
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (mine !== epoch)
|
|
102
|
+
throw new StaleRequest();
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
inflight.delete(own);
|
|
107
|
+
if (timer !== undefined)
|
|
108
|
+
cancel(timer);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
const resumed = () => {
|
|
112
|
+
if (!interrupted)
|
|
113
|
+
return;
|
|
114
|
+
interrupted = false;
|
|
115
|
+
guarded(() => options.onResume?.());
|
|
116
|
+
};
|
|
117
|
+
const accept = (events) => {
|
|
118
|
+
for (const event of events) {
|
|
119
|
+
const at = cursorOf(event);
|
|
120
|
+
if (compareCursors(at, cursor) <= 0)
|
|
121
|
+
continue;
|
|
122
|
+
cursor = at;
|
|
123
|
+
if (event.source && event.source === settings.clientId)
|
|
124
|
+
continue;
|
|
125
|
+
pending.push(event);
|
|
126
|
+
}
|
|
127
|
+
if (pending.length > 0 && flushTimer === undefined) {
|
|
128
|
+
flushTimer = schedule(() => {
|
|
129
|
+
flushTimer = undefined;
|
|
130
|
+
const batch = pending;
|
|
131
|
+
pending = [];
|
|
132
|
+
options.onEvents(batch);
|
|
133
|
+
}, settings.coalesceMs);
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
const since = () => (cursor === INITIAL_CURSOR ? "" : encodeCursor(cursor));
|
|
137
|
+
const poll = async (mine) => {
|
|
138
|
+
const batch = await withTimeout(async (signal) => {
|
|
139
|
+
const response = await options.request(`${basePath}/sync?since=${encodeURIComponent(since())}`, { signal });
|
|
140
|
+
if (refused(response))
|
|
141
|
+
throw new SyncUnauthorized(response.status);
|
|
142
|
+
if (!response.ok)
|
|
143
|
+
throw new Error(`sync poll failed with HTTP ${response.status}`);
|
|
144
|
+
return (await response.json());
|
|
145
|
+
});
|
|
146
|
+
if (mine !== epoch)
|
|
147
|
+
throw new StaleRequest();
|
|
148
|
+
accept(batch.events);
|
|
149
|
+
if (running && negotiated === "poll")
|
|
150
|
+
resumed();
|
|
151
|
+
};
|
|
152
|
+
const schedulePoll = (ms) => {
|
|
153
|
+
if (!running)
|
|
154
|
+
return;
|
|
155
|
+
if (pollTimer !== undefined)
|
|
156
|
+
cancel(pollTimer);
|
|
157
|
+
pollTimer = schedule(() => {
|
|
158
|
+
pollTimer = undefined;
|
|
159
|
+
let halted = false;
|
|
160
|
+
const mine = epoch;
|
|
161
|
+
void poll(mine)
|
|
162
|
+
.catch((error) => {
|
|
163
|
+
if (error instanceof StaleRequest || mine !== epoch) {
|
|
164
|
+
halted = true;
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (negotiated === "poll")
|
|
168
|
+
interrupted = true;
|
|
169
|
+
if (error instanceof SyncUnauthorized) {
|
|
170
|
+
halted = true;
|
|
171
|
+
unauthorized(error);
|
|
172
|
+
if (transport === "poll") {
|
|
173
|
+
transport = undefined;
|
|
174
|
+
connectTimer = schedule(() => {
|
|
175
|
+
connectTimer = undefined;
|
|
176
|
+
void connect();
|
|
177
|
+
}, backoff());
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
settings.onError?.(error);
|
|
182
|
+
})
|
|
183
|
+
.finally(() => {
|
|
184
|
+
if (!halted)
|
|
185
|
+
schedulePoll(nextPollDelay());
|
|
186
|
+
});
|
|
187
|
+
}, ms);
|
|
188
|
+
};
|
|
189
|
+
const nextPollDelay = () => {
|
|
190
|
+
if (transport === "sse")
|
|
191
|
+
return settings.safetyPollMs;
|
|
192
|
+
const base = Math.max(negotiatedPollMs, options.pollEveryMs?.() ?? 0);
|
|
193
|
+
return isHidden() ? Math.max(base, settings.hiddenPollMs) : base;
|
|
194
|
+
};
|
|
195
|
+
let negotiatedPollMs = 2000;
|
|
196
|
+
const negotiate = async () => {
|
|
197
|
+
return withTimeout(async (signal) => {
|
|
198
|
+
const response = await options.request(`${basePath}/sync/token`, {
|
|
199
|
+
method: "POST",
|
|
200
|
+
signal,
|
|
201
|
+
});
|
|
202
|
+
if (refused(response))
|
|
203
|
+
throw new SyncUnauthorized(response.status);
|
|
204
|
+
if (!response.ok)
|
|
205
|
+
throw new Error(`sync negotiation failed with HTTP ${response.status}`);
|
|
206
|
+
return (await response.json());
|
|
207
|
+
});
|
|
208
|
+
};
|
|
209
|
+
let live;
|
|
210
|
+
const stream = async (endpoint) => {
|
|
211
|
+
const own = new AbortController();
|
|
212
|
+
controller = own;
|
|
213
|
+
let keepAliveMs = LEGACY_KEEP_ALIVE_MS;
|
|
214
|
+
let silent = false;
|
|
215
|
+
let watchdog;
|
|
216
|
+
const rearm = () => {
|
|
217
|
+
if (watchdog !== undefined)
|
|
218
|
+
cancel(watchdog);
|
|
219
|
+
watchdog = schedule(() => {
|
|
220
|
+
silent = true;
|
|
221
|
+
own.abort();
|
|
222
|
+
}, streamLivenessMs(keepAliveMs));
|
|
223
|
+
};
|
|
224
|
+
const open = new Set();
|
|
225
|
+
const current = { listed: new Set(), open };
|
|
226
|
+
live = current;
|
|
227
|
+
rearm();
|
|
228
|
+
try {
|
|
229
|
+
const response = await options.request(`${endpoint}?since=${encodeURIComponent(since())}`, {
|
|
230
|
+
headers: { accept: "text/event-stream" },
|
|
231
|
+
signal: own.signal,
|
|
232
|
+
});
|
|
233
|
+
if (refused(response))
|
|
234
|
+
throw new SyncUnauthorized(response.status);
|
|
235
|
+
if (!response.ok || !response.body) {
|
|
236
|
+
throw new Error(`sync stream failed with HTTP ${response.status}`);
|
|
237
|
+
}
|
|
238
|
+
reconnectDelay = settings.reconnectBaseMs;
|
|
239
|
+
resumed();
|
|
240
|
+
await readSse(response.body, (frame) => {
|
|
241
|
+
if (frame.event === "message") {
|
|
242
|
+
const batch = JSON.parse(frame.data);
|
|
243
|
+
accept(batch.events);
|
|
244
|
+
const parsed = decodeCursor(batch.cursor);
|
|
245
|
+
if (compareCursors(parsed, cursor) > 0)
|
|
246
|
+
cursor = parsed;
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (frame.event === "handshake") {
|
|
250
|
+
const handshake = parseHandshake(frame.data);
|
|
251
|
+
keepAliveMs = handshake.keepAliveMs ?? LEGACY_KEEP_ALIVE_MS;
|
|
252
|
+
rearm();
|
|
253
|
+
current.listed = new Set(handshake.capabilities);
|
|
254
|
+
for (const event of handshake.capabilities) {
|
|
255
|
+
const channel = channels.get(event);
|
|
256
|
+
if (!channel || open.has(channel))
|
|
257
|
+
continue;
|
|
258
|
+
open.add(channel);
|
|
259
|
+
guarded(() => channel.opened());
|
|
260
|
+
}
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const channel = channels.get(frame.event);
|
|
264
|
+
if (channel && open.has(channel))
|
|
265
|
+
guarded(() => channel.receive(frame.data));
|
|
266
|
+
}, own.signal, rearm);
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
if (!silent)
|
|
270
|
+
throw error;
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
if (watchdog !== undefined)
|
|
274
|
+
cancel(watchdog);
|
|
275
|
+
if (live === current)
|
|
276
|
+
live = undefined;
|
|
277
|
+
for (const channel of open)
|
|
278
|
+
guarded(() => channel.closed());
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
const unauthorized = (error) => {
|
|
282
|
+
if (pollTimer !== undefined)
|
|
283
|
+
cancel(pollTimer);
|
|
284
|
+
pollTimer = undefined;
|
|
285
|
+
(options.onUnauthorized ?? settings.onError)?.(error);
|
|
286
|
+
};
|
|
287
|
+
const backoff = () => {
|
|
288
|
+
const delay = reconnectDelay;
|
|
289
|
+
reconnectDelay = Math.min(reconnectDelay * 2, settings.reconnectMaxMs);
|
|
290
|
+
return delay;
|
|
291
|
+
};
|
|
292
|
+
let connecting = false;
|
|
293
|
+
const connect = async () => {
|
|
294
|
+
if (connecting)
|
|
295
|
+
return;
|
|
296
|
+
connecting = true;
|
|
297
|
+
const mine = epoch;
|
|
298
|
+
try {
|
|
299
|
+
await connectLoop(mine);
|
|
300
|
+
}
|
|
301
|
+
finally {
|
|
302
|
+
if (mine === epoch)
|
|
303
|
+
connecting = false;
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
const connectLoop = async (mine) => {
|
|
307
|
+
const active = () => running && mine === epoch;
|
|
308
|
+
for (;;) {
|
|
309
|
+
if (!active())
|
|
310
|
+
return;
|
|
311
|
+
let endpoint;
|
|
312
|
+
try {
|
|
313
|
+
const negotiation = await negotiate();
|
|
314
|
+
if (!active())
|
|
315
|
+
return;
|
|
316
|
+
negotiatedPollMs = negotiation.pollIntervalMs;
|
|
317
|
+
negotiated = negotiation.transport;
|
|
318
|
+
guarded(() => options.onNegotiated?.(negotiation.transport));
|
|
319
|
+
if (negotiation.transport === "poll") {
|
|
320
|
+
transport = "poll";
|
|
321
|
+
schedulePoll(0);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
endpoint = negotiation.endpoint;
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
if (!active())
|
|
328
|
+
return;
|
|
329
|
+
interrupted = true;
|
|
330
|
+
if (error instanceof SyncUnauthorized) {
|
|
331
|
+
transport = undefined;
|
|
332
|
+
unauthorized(error);
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
settings.onError?.(error);
|
|
336
|
+
if (transport !== "poll") {
|
|
337
|
+
transport = "poll";
|
|
338
|
+
schedulePoll(negotiatedPollMs);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (endpoint !== undefined) {
|
|
343
|
+
transport = "sse";
|
|
344
|
+
schedulePoll(settings.safetyPollMs);
|
|
345
|
+
try {
|
|
346
|
+
await stream(endpoint);
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
if (!active())
|
|
350
|
+
return;
|
|
351
|
+
if (error instanceof SyncUnauthorized)
|
|
352
|
+
unauthorized(error);
|
|
353
|
+
else
|
|
354
|
+
settings.onError?.(error);
|
|
355
|
+
}
|
|
356
|
+
interrupted = true;
|
|
357
|
+
}
|
|
358
|
+
if (!active())
|
|
359
|
+
return;
|
|
360
|
+
const delay = backoff();
|
|
361
|
+
await new Promise((resolve) => {
|
|
362
|
+
const timer = schedule(() => {
|
|
363
|
+
endReconnectWait = undefined;
|
|
364
|
+
resolve();
|
|
365
|
+
}, delay);
|
|
366
|
+
endReconnectWait = () => {
|
|
367
|
+
cancel(timer);
|
|
368
|
+
endReconnectWait = undefined;
|
|
369
|
+
resolve();
|
|
370
|
+
};
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
const detachChannel = (channel) => {
|
|
375
|
+
if (channels.get(channel.event) !== channel)
|
|
376
|
+
return;
|
|
377
|
+
channels.delete(channel.event);
|
|
378
|
+
if (live?.open.delete(channel))
|
|
379
|
+
guarded(() => channel.closed());
|
|
380
|
+
};
|
|
381
|
+
const start = () => {
|
|
382
|
+
if (running)
|
|
383
|
+
return;
|
|
384
|
+
running = true;
|
|
385
|
+
void connect();
|
|
386
|
+
};
|
|
387
|
+
const reconnectNow = () => {
|
|
388
|
+
if (!running)
|
|
389
|
+
return;
|
|
390
|
+
if (endReconnectWait) {
|
|
391
|
+
reconnectDelay = settings.reconnectBaseMs;
|
|
392
|
+
endReconnectWait();
|
|
393
|
+
}
|
|
394
|
+
else if (connectTimer !== undefined) {
|
|
395
|
+
cancel(connectTimer);
|
|
396
|
+
connectTimer = undefined;
|
|
397
|
+
reconnectDelay = settings.reconnectBaseMs;
|
|
398
|
+
void connect();
|
|
399
|
+
}
|
|
400
|
+
if (transport === "poll" && pollTimer !== undefined)
|
|
401
|
+
schedulePoll(0);
|
|
402
|
+
};
|
|
403
|
+
const stop = () => {
|
|
404
|
+
running = false;
|
|
405
|
+
epoch += 1;
|
|
406
|
+
for (const request of inflight)
|
|
407
|
+
request.abort();
|
|
408
|
+
inflight.clear();
|
|
409
|
+
controller?.abort();
|
|
410
|
+
controller = undefined;
|
|
411
|
+
connecting = false;
|
|
412
|
+
endReconnectWait?.();
|
|
413
|
+
if (pollTimer !== undefined)
|
|
414
|
+
cancel(pollTimer);
|
|
415
|
+
if (flushTimer !== undefined)
|
|
416
|
+
cancel(flushTimer);
|
|
417
|
+
if (connectTimer !== undefined)
|
|
418
|
+
cancel(connectTimer);
|
|
419
|
+
pollTimer = flushTimer = connectTimer = undefined;
|
|
420
|
+
pending = [];
|
|
421
|
+
};
|
|
422
|
+
return {
|
|
423
|
+
start,
|
|
424
|
+
stop,
|
|
425
|
+
addChannel(channel) {
|
|
426
|
+
const replaced = channels.get(channel.event);
|
|
427
|
+
if (replaced === channel)
|
|
428
|
+
return () => detachChannel(channel);
|
|
429
|
+
if (replaced)
|
|
430
|
+
detachChannel(replaced);
|
|
431
|
+
channels.set(channel.event, channel);
|
|
432
|
+
if (live?.listed.has(channel.event)) {
|
|
433
|
+
live.open.add(channel);
|
|
434
|
+
guarded(() => channel.opened());
|
|
435
|
+
}
|
|
436
|
+
return () => detachChannel(channel);
|
|
437
|
+
},
|
|
438
|
+
reconnectNow,
|
|
439
|
+
get cursor() {
|
|
440
|
+
return since();
|
|
441
|
+
},
|
|
442
|
+
get transport() {
|
|
443
|
+
return transport;
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
//# sourceMappingURL=sync-client.js.map
|