meridian-sdk 1.0.1 → 1.2.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/CHANGELOG.md +14 -0
- package/README.md +48 -0
- package/dist/agents.d.ts +60 -0
- package/dist/agents.d.ts.map +1 -0
- package/dist/agents.js +200 -0
- package/dist/agents.js.map +1 -0
- package/dist/client.d.ts +30 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +43 -1
- package/dist/client.js.map +1 -1
- package/dist/crdt/awareness.d.ts +3 -0
- package/dist/crdt/awareness.d.ts.map +1 -1
- package/dist/crdt/awareness.js +8 -4
- package/dist/crdt/awareness.js.map +1 -1
- package/dist/crdt/crdtmap.d.ts +3 -0
- package/dist/crdt/crdtmap.d.ts.map +1 -1
- package/dist/crdt/crdtmap.js +8 -0
- package/dist/crdt/crdtmap.js.map +1 -1
- package/dist/crdt/gcounter.d.ts +3 -0
- package/dist/crdt/gcounter.d.ts.map +1 -1
- package/dist/crdt/gcounter.js +8 -0
- package/dist/crdt/gcounter.js.map +1 -1
- package/dist/crdt/lwwregister.d.ts +3 -1
- package/dist/crdt/lwwregister.d.ts.map +1 -1
- package/dist/crdt/lwwregister.js +8 -1
- package/dist/crdt/lwwregister.js.map +1 -1
- package/dist/crdt/orset.d.ts +3 -1
- package/dist/crdt/orset.d.ts.map +1 -1
- package/dist/crdt/orset.js +8 -1
- package/dist/crdt/orset.js.map +1 -1
- package/dist/crdt/pncounter.d.ts +3 -0
- package/dist/crdt/pncounter.d.ts.map +1 -1
- package/dist/crdt/pncounter.js +8 -0
- package/dist/crdt/pncounter.js.map +1 -1
- package/dist/crdt/presence.d.ts +3 -1
- package/dist/crdt/presence.d.ts.map +1 -1
- package/dist/crdt/presence.js +8 -1
- package/dist/crdt/presence.js.map +1 -1
- package/dist/crdt/rga.d.ts +54 -0
- package/dist/crdt/rga.d.ts.map +1 -0
- package/dist/crdt/rga.js +122 -0
- package/dist/crdt/rga.js.map +1 -0
- package/dist/errors.d.ts +6 -6
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/layer.d.ts +34 -0
- package/dist/layer.d.ts.map +1 -0
- package/dist/layer.js +31 -0
- package/dist/layer.js.map +1 -0
- package/dist/schema.d.ts +2 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +2 -1
- package/dist/schema.js.map +1 -1
- package/dist/sync/delta.d.ts +6 -0
- package/dist/sync/delta.d.ts.map +1 -1
- package/dist/sync/delta.js +4 -0
- package/dist/sync/delta.js.map +1 -1
- package/dist/transport/http.d.ts +12 -1
- package/dist/transport/http.d.ts.map +1 -1
- package/dist/transport/http.js +4 -0
- package/dist/transport/http.js.map +1 -1
- package/dist/transport/websocket.d.ts +22 -7
- package/dist/transport/websocket.d.ts.map +1 -1
- package/dist/transport/websocket.js +173 -98
- package/dist/transport/websocket.js.map +1 -1
- package/package.json +2 -2
- package/src/agents.ts +277 -0
- package/src/client.ts +49 -1
- package/src/crdt/awareness.ts +9 -9
- package/src/crdt/crdtmap.ts +9 -0
- package/src/crdt/gcounter.ts +9 -0
- package/src/crdt/lwwregister.ts +9 -1
- package/src/crdt/orset.ts +9 -1
- package/src/crdt/pncounter.ts +9 -0
- package/src/crdt/presence.ts +9 -1
- package/src/crdt/rga.ts +136 -0
- package/src/index.ts +17 -1
- package/src/layer.ts +44 -0
- package/src/schema.ts +2 -1
- package/src/sync/delta.ts +11 -1
- package/src/transport/http.ts +18 -1
- package/src/transport/websocket.ts +220 -96
- package/test/agents.test.ts +104 -0
- package/test/crdt.test.ts +0 -28
- package/test/integration.test.ts +0 -7
- package/test/offline-queue.test.ts +0 -8
- package/test/sync.test.ts +0 -12
package/src/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ export type {
|
|
|
12
12
|
LwwRegisterSnapshotEntry,
|
|
13
13
|
PresenceSnapshotEntry,
|
|
14
14
|
CRDTMapSnapshotEntry,
|
|
15
|
+
RGASnapshotEntry,
|
|
15
16
|
} from "./client.js";
|
|
16
17
|
|
|
17
18
|
// CRDT handles
|
|
@@ -25,10 +26,11 @@ export { CRDTMapHandle } from "./crdt/crdtmap.js";
|
|
|
25
26
|
export type { CrdtMapValue } from "./crdt/crdtmap.js";
|
|
26
27
|
export { AwarenessHandle } from "./crdt/awareness.js";
|
|
27
28
|
export type { AwarenessEntry } from "./crdt/awareness.js";
|
|
29
|
+
export { RGAHandle } from "./crdt/rga.js";
|
|
28
30
|
|
|
29
31
|
// Transport
|
|
30
32
|
export { HttpClient } from "./transport/http.js";
|
|
31
|
-
export type { HttpClientConfig } from "./transport/http.js";
|
|
33
|
+
export type { HttpClientConfig, HistoryEntry, HistoryResponse } from "./transport/http.js";
|
|
32
34
|
export { WsTransport } from "./transport/websocket.js";
|
|
33
35
|
export type { WsTransportConfig, WsState } from "./transport/websocket.js";
|
|
34
36
|
|
|
@@ -63,6 +65,20 @@ export {
|
|
|
63
65
|
} from "./schema.js";
|
|
64
66
|
export type { TimestampMs, ClientId } from "./schema.js";
|
|
65
67
|
|
|
68
|
+
// AI Agents — Claude tool use helpers
|
|
69
|
+
export {
|
|
70
|
+
getMeridianTools,
|
|
71
|
+
executeMeridianTool,
|
|
72
|
+
} from "./agents.js";
|
|
73
|
+
export type {
|
|
74
|
+
Tool,
|
|
75
|
+
ToolUseBlock,
|
|
76
|
+
MeridianAgentConfig,
|
|
77
|
+
} from "./agents.js";
|
|
78
|
+
|
|
79
|
+
// Effect Layer — dependency injection
|
|
80
|
+
export { MeridianService, MeridianLive } from "./layer.js";
|
|
81
|
+
|
|
66
82
|
// Codec (for advanced use / testing)
|
|
67
83
|
export {
|
|
68
84
|
encode,
|
package/src/layer.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Context, Effect, Layer } from "effect";
|
|
2
|
+
import { MeridianClient, type MeridianClientConfig } from "./client.js";
|
|
3
|
+
import type { TokenParseError, TokenExpiredError } from "./errors.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Effect Context tag for `MeridianClient`.
|
|
7
|
+
*
|
|
8
|
+
* Use this to inject a `MeridianClient` into Effect programs via `MeridianLive`.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```ts
|
|
12
|
+
* import { Effect } from 'effect';
|
|
13
|
+
* import { MeridianService, MeridianLive } from 'meridian-sdk';
|
|
14
|
+
*
|
|
15
|
+
* const program = Effect.gen(function* () {
|
|
16
|
+
* const client = yield* MeridianService;
|
|
17
|
+
* client.gcounter('visitors').increment();
|
|
18
|
+
* });
|
|
19
|
+
*
|
|
20
|
+
* await Effect.runPromise(program.pipe(Effect.provide(MeridianLive(config))));
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export class MeridianService extends Context.Tag("MeridianService")<
|
|
24
|
+
MeridianService,
|
|
25
|
+
MeridianClient
|
|
26
|
+
>() {}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Scoped Layer that creates a `MeridianClient` and closes it on release.
|
|
30
|
+
*
|
|
31
|
+
* The client is created with `MeridianClient.create(config)` and closed
|
|
32
|
+
* automatically when the scope is closed (e.g. at the end of `Effect.runPromise`
|
|
33
|
+
* or when `Layer.build` is interrupted).
|
|
34
|
+
*/
|
|
35
|
+
export const MeridianLive = (
|
|
36
|
+
config: MeridianClientConfig,
|
|
37
|
+
): Layer.Layer<MeridianService, TokenParseError | TokenExpiredError> =>
|
|
38
|
+
Layer.scoped(
|
|
39
|
+
MeridianService,
|
|
40
|
+
Effect.acquireRelease(
|
|
41
|
+
MeridianClient.create(config),
|
|
42
|
+
(client) => Effect.sync(() => client.close()),
|
|
43
|
+
),
|
|
44
|
+
);
|
package/src/schema.ts
CHANGED
|
@@ -47,6 +47,7 @@ export const ClientMsg = Schema.Union(
|
|
|
47
47
|
crdt_id: Schema.String,
|
|
48
48
|
op_bytes: Schema.Uint8ArrayFromSelf,
|
|
49
49
|
ttl_ms: Schema.optional(Schema.Number),
|
|
50
|
+
client_seq: Schema.optional(Schema.Number),
|
|
50
51
|
}),
|
|
51
52
|
}),
|
|
52
53
|
Schema.Struct({
|
|
@@ -68,7 +69,7 @@ export const ServerMsg = Schema.Union(
|
|
|
68
69
|
delta_bytes: Schema.Uint8ArrayFromSelf,
|
|
69
70
|
}),
|
|
70
71
|
}),
|
|
71
|
-
Schema.Struct({ Ack: Schema.Struct({ seq: Schema.Number }) }),
|
|
72
|
+
Schema.Struct({ Ack: Schema.Struct({ seq: Schema.Number, client_seq: Schema.optional(Schema.Number) }) }),
|
|
72
73
|
Schema.Struct({
|
|
73
74
|
Error: Schema.Struct({ code: Schema.Number, message: Schema.String }),
|
|
74
75
|
}),
|
package/src/sync/delta.ts
CHANGED
|
@@ -72,12 +72,22 @@ export const decodePresenceDelta = (bytes: Uint8Array): PresenceDelta => {
|
|
|
72
72
|
return { changes: raw.changes ?? {} };
|
|
73
73
|
};
|
|
74
74
|
|
|
75
|
+
export interface RGADelta {
|
|
76
|
+
text: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const decodeRGADelta = (bytes: Uint8Array): RGADelta => {
|
|
80
|
+
const raw = decode(bytes) as { text?: string };
|
|
81
|
+
return { text: raw.text ?? "" };
|
|
82
|
+
};
|
|
83
|
+
|
|
75
84
|
export type CrdtValueDelta =
|
|
76
85
|
| { GCounter: GCounterDelta }
|
|
77
86
|
| { PNCounter: PNCounterDelta }
|
|
78
87
|
| { ORSet: ORSetDelta }
|
|
79
88
|
| { LwwRegister: LwwDelta }
|
|
80
|
-
| { Presence: PresenceDelta }
|
|
89
|
+
| { Presence: PresenceDelta }
|
|
90
|
+
| { RGA: RGADelta };
|
|
81
91
|
|
|
82
92
|
export interface CRDTMapDelta {
|
|
83
93
|
deltas: Record<string, CrdtValueDelta>;
|
package/src/transport/http.ts
CHANGED
|
@@ -16,8 +16,20 @@ export interface HttpClientConfig {
|
|
|
16
16
|
timeoutMs?: number;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
export interface HistoryEntry {
|
|
20
|
+
seq: number;
|
|
21
|
+
timestamp_ms: number;
|
|
22
|
+
op: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface HistoryResponse {
|
|
26
|
+
crdt_id: string;
|
|
27
|
+
entries: HistoryEntry[];
|
|
28
|
+
next_seq: number | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
19
31
|
export class HttpClient {
|
|
20
|
-
|
|
32
|
+
readonly baseUrl: string;
|
|
21
33
|
private readonly timeoutMs: number;
|
|
22
34
|
token: string;
|
|
23
35
|
|
|
@@ -48,6 +60,11 @@ export class HttpClient {
|
|
|
48
60
|
return this.request(CrdtGetResponse, "GET", path);
|
|
49
61
|
}
|
|
50
62
|
|
|
63
|
+
getHistory(ns: string, id: string, sinceSeq = 0, limit = 50): Promise<HistoryResponse> {
|
|
64
|
+
const url = `${this.baseUrl}/v1/namespaces/${ns}/crdts/${encodeURIComponent(id)}/history?since_seq=${sinceSeq}&limit=${limit}`;
|
|
65
|
+
return fetch(url, { headers: { Authorization: `Bearer ${this.token}` } }).then(r => r.json() as Promise<HistoryResponse>);
|
|
66
|
+
}
|
|
67
|
+
|
|
51
68
|
issueToken(
|
|
52
69
|
ns: string,
|
|
53
70
|
opts: { client_id: number; ttl_ms: number; permissions: Permissions },
|
|
@@ -1,14 +1,23 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Effect,
|
|
3
|
+
Fiber,
|
|
4
|
+
Layer,
|
|
5
|
+
ManagedRuntime,
|
|
6
|
+
Option,
|
|
7
|
+
Queue,
|
|
8
|
+
Schedule,
|
|
9
|
+
Duration,
|
|
10
|
+
} from "effect";
|
|
2
11
|
import { encodeClientMsg, decodeServerMsg, encodeVectorClock } from "../codec.js";
|
|
3
12
|
import type { ServerMsg, VectorClock } from "../schema.js";
|
|
4
13
|
import {
|
|
5
14
|
BACKOFF_INITIAL_MS,
|
|
6
15
|
BACKOFF_MAX_MS,
|
|
7
16
|
BACKOFF_MULTIPLIER,
|
|
8
|
-
JITTER_MULTIPLIER,
|
|
9
17
|
DEFAULT_TIMEOUT_MS,
|
|
10
18
|
OFFLINE_QUEUE_MAX,
|
|
11
19
|
} from "../constants.js";
|
|
20
|
+
import { TransportError } from "../errors.js";
|
|
12
21
|
|
|
13
22
|
export type WsState =
|
|
14
23
|
| "DISCONNECTED"
|
|
@@ -24,46 +33,84 @@ export interface WsTransportConfig {
|
|
|
24
33
|
maxBackoffMs?: number;
|
|
25
34
|
}
|
|
26
35
|
|
|
36
|
+
type ClientMsg = Parameters<typeof encodeClientMsg>[0];
|
|
37
|
+
|
|
27
38
|
export class WsTransport {
|
|
28
39
|
private readonly config: WsTransportConfig;
|
|
29
40
|
private readonly maxBackoffMs: number;
|
|
30
41
|
|
|
31
42
|
private ws: WebSocket | null = null;
|
|
32
43
|
private state: WsState = "DISCONNECTED";
|
|
33
|
-
private backoffMs = BACKOFF_INITIAL_MS;
|
|
34
|
-
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
35
44
|
private closed = false;
|
|
36
45
|
|
|
46
|
+
// Effect runtime owned by this transport
|
|
47
|
+
private readonly runtime = ManagedRuntime.make(Layer.empty);
|
|
48
|
+
private lifecycleFiber: Fiber.RuntimeFiber<void, never> | null = null;
|
|
49
|
+
|
|
50
|
+
// State change listeners (replacing fragile function-chain pattern)
|
|
51
|
+
private readonly stateListeners = new Set<(state: WsState) => void>();
|
|
52
|
+
|
|
37
53
|
private readonly subscriptions = new Map<string, VectorClock>();
|
|
38
|
-
|
|
54
|
+
|
|
55
|
+
// Effect sliding queue — drops oldest when full (same behaviour as previous array)
|
|
56
|
+
private readonly pendingQueue: Queue.Queue<ClientMsg>;
|
|
57
|
+
|
|
58
|
+
// Latency tracking
|
|
59
|
+
private nextClientSeq = 0;
|
|
60
|
+
/** Maps client_seq → `performance.now()` timestamp at send time. */
|
|
61
|
+
private readonly pendingAcks = new Map<number, number>();
|
|
62
|
+
private readonly latencySamples: number[] = []; // rolling window of round-trip ms
|
|
63
|
+
private static readonly LATENCY_WINDOW = 128;
|
|
39
64
|
|
|
40
65
|
constructor(config: WsTransportConfig) {
|
|
41
66
|
this.config = config;
|
|
42
67
|
this.maxBackoffMs = config.maxBackoffMs ?? BACKOFF_MAX_MS;
|
|
68
|
+
|
|
69
|
+
// Queue creation is synchronous
|
|
70
|
+
this.pendingQueue = Effect.runSync(Queue.sliding<ClientMsg>(OFFLINE_QUEUE_MAX));
|
|
71
|
+
|
|
72
|
+
// Register initial onStateChange listener if provided
|
|
73
|
+
if (config.onStateChange) {
|
|
74
|
+
this.stateListeners.add(config.onStateChange);
|
|
75
|
+
}
|
|
43
76
|
}
|
|
44
77
|
|
|
45
78
|
connect(): void {
|
|
46
79
|
if (this.closed) return;
|
|
47
80
|
this.closed = false;
|
|
48
|
-
this.
|
|
81
|
+
this.transitionTo("CONNECTING");
|
|
82
|
+
const ws = this.makeWs();
|
|
83
|
+
this.lifecycleFiber = this.runtime.runFork(this.buildLifecycleEffect(ws));
|
|
49
84
|
}
|
|
50
85
|
|
|
51
86
|
close(): void {
|
|
52
87
|
this.closed = true;
|
|
53
|
-
|
|
54
|
-
this.
|
|
88
|
+
// Drain synchronously so pendingOpCount returns 0 immediately after close()
|
|
89
|
+
Effect.runSync(Queue.takeAll(this.pendingQueue));
|
|
90
|
+
this.runtime.runFork(Queue.shutdown(this.pendingQueue));
|
|
55
91
|
this.transitionTo("CLOSING");
|
|
92
|
+
if (this.lifecycleFiber !== null) {
|
|
93
|
+
const fiber = this.lifecycleFiber;
|
|
94
|
+
this.lifecycleFiber = null;
|
|
95
|
+
this.runtime.runFork(Fiber.interrupt(fiber));
|
|
96
|
+
}
|
|
56
97
|
this.ws?.close(1000, "client close");
|
|
98
|
+
this.ws = null;
|
|
99
|
+
void this.runtime.dispose();
|
|
57
100
|
}
|
|
58
101
|
|
|
59
102
|
reopen(): void {
|
|
60
|
-
|
|
61
|
-
|
|
103
|
+
if (this.lifecycleFiber !== null) {
|
|
104
|
+
const fiber = this.lifecycleFiber;
|
|
105
|
+
this.lifecycleFiber = null;
|
|
106
|
+
this.runtime.runFork(Fiber.interrupt(fiber));
|
|
107
|
+
}
|
|
62
108
|
const old = this.ws;
|
|
63
109
|
this.ws = null;
|
|
64
110
|
old?.close(1000, "reopen");
|
|
65
111
|
this.closed = false;
|
|
66
|
-
this.
|
|
112
|
+
this.transitionTo("CONNECTING");
|
|
113
|
+
this.lifecycleFiber = this.runtime.runFork(this.buildLifecycleEffect(this.makeWs()));
|
|
67
114
|
}
|
|
68
115
|
|
|
69
116
|
subscribe(crdtId: string, sinceVc: VectorClock = {}): void {
|
|
@@ -77,19 +124,49 @@ export class WsTransport {
|
|
|
77
124
|
this.subscriptions.set(crdtId, vc);
|
|
78
125
|
}
|
|
79
126
|
|
|
80
|
-
send(msg:
|
|
127
|
+
send(msg: ClientMsg): void {
|
|
128
|
+
const tagged = this.tagOp(msg);
|
|
81
129
|
if (this.state !== "CONNECTED" || this.ws === null) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
this.pendingOps.push(msg);
|
|
130
|
+
// sliding queue drops oldest automatically if full
|
|
131
|
+
this.runtime.runFork(Queue.offer(this.pendingQueue, tagged));
|
|
86
132
|
return;
|
|
87
133
|
}
|
|
88
|
-
this.ws.send(encodeClientMsg(
|
|
134
|
+
this.ws.send(encodeClientMsg(tagged));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Returns P50 and P99 op round-trip latency in milliseconds, or null if not enough samples. */
|
|
138
|
+
getLatencyStats(): { p50: number; p99: number; count: number } | null {
|
|
139
|
+
if (this.latencySamples.length < 2) return null;
|
|
140
|
+
const sorted = [...this.latencySamples].sort((a, b) => a - b);
|
|
141
|
+
const p50 = sorted[Math.floor(sorted.length * 0.5)] ?? 0;
|
|
142
|
+
const p99 = sorted[Math.floor(sorted.length * 0.99)] ?? 0;
|
|
143
|
+
return { p50, p99, count: sorted.length };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private tagOp(msg: ClientMsg): ClientMsg {
|
|
147
|
+
if (!("Op" in msg)) return msg;
|
|
148
|
+
const seq = this.nextClientSeq++;
|
|
149
|
+
this.pendingAcks.set(seq, performance.now());
|
|
150
|
+
return { Op: { ...msg.Op, client_seq: seq } };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private recordAck(clientSeq: number): void {
|
|
154
|
+
const sentAt = this.pendingAcks.get(clientSeq);
|
|
155
|
+
if (sentAt === undefined) return;
|
|
156
|
+
this.pendingAcks.delete(clientSeq);
|
|
157
|
+
const rtt = performance.now() - sentAt;
|
|
158
|
+
this.latencySamples.push(rtt);
|
|
159
|
+
if (this.latencySamples.length > WsTransport.LATENCY_WINDOW) {
|
|
160
|
+
this.latencySamples.shift();
|
|
161
|
+
}
|
|
89
162
|
}
|
|
90
163
|
|
|
91
164
|
get pendingOpCount(): number {
|
|
92
|
-
|
|
165
|
+
try {
|
|
166
|
+
return Effect.runSync(Queue.size(this.pendingQueue));
|
|
167
|
+
} catch {
|
|
168
|
+
return 0;
|
|
169
|
+
}
|
|
93
170
|
}
|
|
94
171
|
|
|
95
172
|
/**
|
|
@@ -97,18 +174,8 @@ export class WsTransport {
|
|
|
97
174
|
* Compatible with React's `useSyncExternalStore` subscribe parameter.
|
|
98
175
|
*/
|
|
99
176
|
onStateChange(listener: (state: WsState) => void): () => void {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
prev?.(s);
|
|
103
|
-
listener(s);
|
|
104
|
-
};
|
|
105
|
-
return () => {
|
|
106
|
-
if (prev !== undefined) {
|
|
107
|
-
this.config.onStateChange = prev;
|
|
108
|
-
} else {
|
|
109
|
-
delete (this.config as Partial<WsTransportConfig>).onStateChange;
|
|
110
|
-
}
|
|
111
|
-
};
|
|
177
|
+
this.stateListeners.add(listener);
|
|
178
|
+
return () => { this.stateListeners.delete(listener); };
|
|
112
179
|
}
|
|
113
180
|
|
|
114
181
|
get currentState(): WsState {
|
|
@@ -118,90 +185,141 @@ export class WsTransport {
|
|
|
118
185
|
waitForConnected(timeoutMs = DEFAULT_TIMEOUT_MS): Promise<void> {
|
|
119
186
|
if (this.state === "CONNECTED") return Promise.resolve();
|
|
120
187
|
return new Promise((resolve, reject) => {
|
|
121
|
-
const orig = this.config.onStateChange;
|
|
122
|
-
const restore = () => {
|
|
123
|
-
if (orig !== undefined) {
|
|
124
|
-
this.config.onStateChange = orig;
|
|
125
|
-
} else {
|
|
126
|
-
delete (this.config as Partial<WsTransportConfig>).onStateChange;
|
|
127
|
-
}
|
|
128
|
-
};
|
|
129
188
|
const timer = setTimeout(() => {
|
|
130
|
-
|
|
189
|
+
this.stateListeners.delete(listener);
|
|
131
190
|
reject(new Error("WsTransport: connect timeout"));
|
|
132
191
|
}, timeoutMs);
|
|
133
|
-
|
|
134
|
-
orig?.(s);
|
|
192
|
+
const listener = (s: WsState) => {
|
|
135
193
|
if (s === "CONNECTED") {
|
|
136
194
|
clearTimeout(timer);
|
|
137
|
-
|
|
195
|
+
this.stateListeners.delete(listener);
|
|
138
196
|
resolve();
|
|
139
197
|
}
|
|
140
198
|
};
|
|
199
|
+
this.stateListeners.add(listener);
|
|
141
200
|
});
|
|
142
201
|
}
|
|
143
202
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// Effect lifecycle
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
147
206
|
|
|
207
|
+
private makeWs(): {
|
|
208
|
+
ws: WebSocket;
|
|
209
|
+
connected: Promise<WebSocket>;
|
|
210
|
+
cancel: () => void;
|
|
211
|
+
} {
|
|
148
212
|
const url = `${this.config.url}${this.config.url.includes("?") ? "&" : "?"}token=${encodeURIComponent(this.config.token)}`;
|
|
149
213
|
const ws = new WebSocket(url);
|
|
150
214
|
ws.binaryType = "arraybuffer";
|
|
151
|
-
this.ws = ws;
|
|
152
215
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
216
|
+
// Register open/error listeners synchronously so they fire even if the
|
|
217
|
+
// Fiber hasn't started yet (runFork is async).
|
|
218
|
+
let resolve!: (ws: WebSocket) => void;
|
|
219
|
+
let reject!: (e: TransportError) => void;
|
|
220
|
+
const connected = new Promise<WebSocket>((res, rej) => { resolve = res; reject = rej; });
|
|
221
|
+
|
|
222
|
+
const onOpen = () => {
|
|
223
|
+
this.ws = ws;
|
|
156
224
|
this.transitionTo("CONNECTED");
|
|
157
225
|
this.resubscribeAll();
|
|
158
|
-
|
|
226
|
+
resolve(ws);
|
|
227
|
+
};
|
|
228
|
+
const onError = () => {
|
|
229
|
+
reject(new TransportError({ message: "WebSocket connection failed" }));
|
|
230
|
+
};
|
|
231
|
+
ws.addEventListener("open", onOpen, { once: true });
|
|
232
|
+
ws.addEventListener("error", onError, { once: true });
|
|
159
233
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
});
|
|
234
|
+
const cancel = () => {
|
|
235
|
+
ws.removeEventListener("open", onOpen);
|
|
236
|
+
ws.removeEventListener("error", onError);
|
|
237
|
+
ws.close(1000, "fiber interrupt");
|
|
238
|
+
};
|
|
239
|
+
return { ws, connected, cancel };
|
|
240
|
+
}
|
|
168
241
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
242
|
+
private buildLifecycleEffect(wsHandle: ReturnType<WsTransport["makeWs"]>): Effect.Effect<void, never> {
|
|
243
|
+
const { ws, connected, cancel } = wsHandle;
|
|
244
|
+
|
|
245
|
+
const connectOnce = Effect.async<WebSocket, TransportError>((resume) => {
|
|
246
|
+
if (this.closed) {
|
|
247
|
+
cancel();
|
|
248
|
+
resume(Effect.fail(new TransportError({ message: "transport closed" })));
|
|
249
|
+
return Effect.void;
|
|
175
250
|
}
|
|
176
|
-
});
|
|
177
251
|
|
|
178
|
-
|
|
179
|
-
|
|
252
|
+
// connected already has the listeners; just await its outcome
|
|
253
|
+
connected.then(
|
|
254
|
+
(w) => resume(Effect.succeed(w)),
|
|
255
|
+
() => resume(Effect.fail(new TransportError({ message: "WebSocket connection failed" }))),
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
// Finalizer — called if Fiber is interrupted before open
|
|
259
|
+
return Effect.sync(cancel);
|
|
180
260
|
});
|
|
181
|
-
}
|
|
182
261
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
262
|
+
const handleConnection = (_ws: WebSocket): Effect.Effect<void, TransportError> =>
|
|
263
|
+
Effect.async((resume) => {
|
|
264
|
+
const onMessage = (event: MessageEvent) => {
|
|
265
|
+
const bytes = new Uint8Array(event.data as ArrayBuffer);
|
|
266
|
+
Effect.runPromise(decodeServerMsg(bytes)).then(
|
|
267
|
+
(msg) => {
|
|
268
|
+
if ("Ack" in msg && msg.Ack.client_seq !== undefined) {
|
|
269
|
+
this.recordAck(msg.Ack.client_seq);
|
|
270
|
+
}
|
|
271
|
+
this.config.onMessage(msg);
|
|
272
|
+
},
|
|
273
|
+
(e) => { console.warn("[meridian] failed to decode server message", e); },
|
|
274
|
+
);
|
|
275
|
+
};
|
|
276
|
+
const onClose = () => {
|
|
277
|
+
this.ws = null;
|
|
278
|
+
resume(Effect.fail(new TransportError({ message: "WebSocket closed" })));
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
ws.addEventListener("message", onMessage);
|
|
282
|
+
ws.addEventListener("close", onClose, { once: true });
|
|
283
|
+
|
|
284
|
+
// Finalizer — clean up listeners if Fiber is interrupted
|
|
285
|
+
return Effect.sync(() => {
|
|
286
|
+
ws.removeEventListener("message", onMessage);
|
|
287
|
+
ws.removeEventListener("close", onClose);
|
|
288
|
+
ws.close(1000, "fiber interrupt");
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const reconnectSchedule = Schedule.exponential(
|
|
293
|
+
Duration.millis(BACKOFF_INITIAL_MS),
|
|
294
|
+
BACKOFF_MULTIPLIER,
|
|
295
|
+
).pipe(
|
|
296
|
+
Schedule.map((d) => Duration.min(d, Duration.millis(this.maxBackoffMs))),
|
|
297
|
+
Schedule.jittered,
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
return connectOnce.pipe(
|
|
301
|
+
Effect.flatMap(handleConnection),
|
|
302
|
+
Effect.tapError(() => Effect.sync(() => {
|
|
303
|
+
if (!this.closed) this.transitionTo("DISCONNECTED");
|
|
304
|
+
})),
|
|
305
|
+
Effect.retry(reconnectSchedule),
|
|
306
|
+
Effect.catchAll(() => Effect.void),
|
|
307
|
+
);
|
|
199
308
|
}
|
|
200
309
|
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
// Internals
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
|
|
201
314
|
private transitionTo(next: WsState): void {
|
|
202
315
|
if (this.state === next) return;
|
|
203
316
|
this.state = next;
|
|
317
|
+
// Notify initial listener from config (for backward compat with client.ts)
|
|
204
318
|
this.config.onStateChange?.(next);
|
|
319
|
+
// Notify all registered listeners
|
|
320
|
+
for (const listener of this.stateListeners) {
|
|
321
|
+
listener(next);
|
|
322
|
+
}
|
|
205
323
|
}
|
|
206
324
|
|
|
207
325
|
private resubscribeAll(): void {
|
|
@@ -212,18 +330,24 @@ export class WsTransport {
|
|
|
212
330
|
}
|
|
213
331
|
|
|
214
332
|
private flushPendingOps(): void {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
333
|
+
this.runtime.runFork(
|
|
334
|
+
Effect.gen((function* (this: WsTransport) {
|
|
335
|
+
for (;;) {
|
|
336
|
+
const next: Option.Option<ClientMsg> = yield* Queue.poll(this.pendingQueue);
|
|
337
|
+
if (Option.isNone(next)) break;
|
|
338
|
+
if (this.ws === null || this.state !== "CONNECTED") {
|
|
339
|
+
yield* Queue.offer(this.pendingQueue, next.value);
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
try {
|
|
343
|
+
this.ws.send(encodeClientMsg(this.tagOp(next.value)));
|
|
344
|
+
} catch {
|
|
345
|
+
yield* Queue.offer(this.pendingQueue, next.value);
|
|
346
|
+
break;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}).bind(this)),
|
|
350
|
+
);
|
|
227
351
|
}
|
|
228
352
|
|
|
229
353
|
private sendSubscribe(crdtId: string, vc: VectorClock): void {
|