@adobe/data-sync 0.9.90
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 +646 -0
- package/dist/create-sync-server.d.ts +29 -0
- package/dist/create-sync-server.js +156 -0
- package/dist/create-sync-server.js.map +1 -0
- package/dist/create-sync-service.d.ts +120 -0
- package/dist/create-sync-service.js +192 -0
- package/dist/create-sync-service.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/keepalive.test.d.ts +1 -0
- package/dist/keepalive.test.js +161 -0
- package/dist/keepalive.test.js.map +1 -0
- package/dist/loopback-transport.d.ts +20 -0
- package/dist/loopback-transport.js +98 -0
- package/dist/loopback-transport.js.map +1 -0
- package/dist/sync.reconnect.test.d.ts +1 -0
- package/dist/sync.reconnect.test.js +248 -0
- package/dist/sync.reconnect.test.js.map +1 -0
- package/dist/sync.test.d.ts +1 -0
- package/dist/sync.test.js +233 -0
- package/dist/sync.test.js.map +1 -0
- package/dist/transport-onclose.test.d.ts +1 -0
- package/dist/transport-onclose.test.js +74 -0
- package/dist/transport-onclose.test.js.map +1 -0
- package/dist/transport.d.ts +94 -0
- package/dist/transport.js +3 -0
- package/dist/transport.js.map +1 -0
- package/dist/websocket-transport.d.ts +71 -0
- package/dist/websocket-transport.js +148 -0
- package/dist/websocket-transport.js.map +1 -0
- package/package.json +38 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// © 2026 Adobe. MIT License. See /LICENSE for details.
|
|
2
|
+
//
|
|
3
|
+
// Soundness tests for the @adobe/data-sync protocol.
|
|
4
|
+
//
|
|
5
|
+
// Each test wires up a SyncServer + one or more SyncServices over loopback
|
|
6
|
+
// transports and verifies convergence / correctness properties. Application
|
|
7
|
+
// code only ever calls `db.transactions.X(args)` — the sync service forwards
|
|
8
|
+
// envelopes transparently.
|
|
9
|
+
import { describe, it, expect } from "vitest";
|
|
10
|
+
import { Database, createRebaseReplayConcurrency } from "@adobe/data/ecs";
|
|
11
|
+
import { createLoopbackTransport } from "./loopback-transport.js";
|
|
12
|
+
import { createSyncServer } from "./create-sync-server.js";
|
|
13
|
+
import { createSyncService } from "./create-sync-service.js";
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Shared test fixture
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
const plugin = Database.Plugin.create({
|
|
18
|
+
components: {
|
|
19
|
+
x: { type: "number", default: 0 },
|
|
20
|
+
y: { type: "number", default: 0 },
|
|
21
|
+
label: { type: "string" },
|
|
22
|
+
},
|
|
23
|
+
resources: {
|
|
24
|
+
// Synced resource — replicates to peers.
|
|
25
|
+
score: { default: 0 },
|
|
26
|
+
// Local-only resource — never replicates because of `nonPersistent: true`.
|
|
27
|
+
// (Verified by the "nonPersistent resource never replicates" test below.)
|
|
28
|
+
bannerText: { default: "", nonPersistent: true },
|
|
29
|
+
// Local + durable resource — persisted locally, but never replicated to
|
|
30
|
+
// peers because of `nonShared: true`. (Verified by the nonShared test.)
|
|
31
|
+
localPref: { default: "", nonShared: true },
|
|
32
|
+
},
|
|
33
|
+
archetypes: {
|
|
34
|
+
Point: ["x", "y", "label"],
|
|
35
|
+
},
|
|
36
|
+
transactions: {
|
|
37
|
+
createPoint(s, args) {
|
|
38
|
+
return s.archetypes.Point.insert(args);
|
|
39
|
+
},
|
|
40
|
+
movePoint(s, args) {
|
|
41
|
+
s.update(args.entity, { x: args.x, y: args.y });
|
|
42
|
+
},
|
|
43
|
+
bumpScore(s, args) {
|
|
44
|
+
s.resources.score = s.resources.score + args.delta;
|
|
45
|
+
},
|
|
46
|
+
setBanner(s, args) {
|
|
47
|
+
s.resources.bannerText = args.text;
|
|
48
|
+
},
|
|
49
|
+
setLocalPref(s, args) {
|
|
50
|
+
s.resources.localPref = args.text;
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
const makeDb = (userId) => Database.create(plugin, { concurrency: createRebaseReplayConcurrency(userId) });
|
|
55
|
+
const snap = (db) => db.select(["x", "y", "label"])
|
|
56
|
+
.map(e => ({ entity: e, ...db.read(e) }))
|
|
57
|
+
.sort((a, b) => (a.label ?? "") < (b.label ?? "") ? -1 : 1);
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
describe("sync soundness", () => {
|
|
60
|
+
// -----------------------------------------------------------------------
|
|
61
|
+
// 1. Determinism: two clients receiving the same committed sequence converge
|
|
62
|
+
// -----------------------------------------------------------------------
|
|
63
|
+
it("two peers converge after one peer's transactions are forwarded via loopback", () => {
|
|
64
|
+
const server = createSyncServer();
|
|
65
|
+
const { client: c1t, server: s1t } = createLoopbackTransport();
|
|
66
|
+
const { client: c2t, server: s2t } = createLoopbackTransport();
|
|
67
|
+
const db1 = makeDb("peer1");
|
|
68
|
+
const db2 = makeDb("peer2");
|
|
69
|
+
server.connect(s1t);
|
|
70
|
+
server.connect(s2t);
|
|
71
|
+
const sync1 = createSyncService({ database: db1, transport: c1t });
|
|
72
|
+
// peer 2 is a passive observer — sync service alone, no transactions
|
|
73
|
+
createSyncService({ database: db2, transport: c2t });
|
|
74
|
+
db1.transactions.createPoint({ x: 1, y: 2, label: "A" });
|
|
75
|
+
db1.transactions.createPoint({ x: 3, y: 4, label: "B" });
|
|
76
|
+
// Both DBs should have the same two points with the same entity ids.
|
|
77
|
+
const snap1 = snap(db1);
|
|
78
|
+
const snap2 = snap(db2);
|
|
79
|
+
expect(snap1.length).toBe(2);
|
|
80
|
+
expect(snap2.length).toBe(2);
|
|
81
|
+
expect(snap1[0].entity).toBe(snap2[0].entity);
|
|
82
|
+
expect(snap1[1].entity).toBe(snap2[1].entity);
|
|
83
|
+
expect(snap1[0].label).toBe("A");
|
|
84
|
+
expect(snap2[0].label).toBe("A");
|
|
85
|
+
sync1.dispose();
|
|
86
|
+
server.dispose();
|
|
87
|
+
});
|
|
88
|
+
// -----------------------------------------------------------------------
|
|
89
|
+
// 2. Round-trip rebase: a local transaction is promoted from transient
|
|
90
|
+
// to committed and the database converges.
|
|
91
|
+
// -----------------------------------------------------------------------
|
|
92
|
+
it("a local transaction is promoted to committed and the database converges", () => {
|
|
93
|
+
const server = createSyncServer();
|
|
94
|
+
const { client: ct, server: st } = createLoopbackTransport();
|
|
95
|
+
const db = makeDb("peer");
|
|
96
|
+
server.connect(st);
|
|
97
|
+
const sync = createSyncService({ database: db, transport: ct });
|
|
98
|
+
db.transactions.createPoint({ x: 10, y: 20, label: "X" });
|
|
99
|
+
const points = db.select(["label"]);
|
|
100
|
+
expect(points.length).toBe(1);
|
|
101
|
+
expect(db.read(points[0])?.label).toBe("X");
|
|
102
|
+
sync.dispose();
|
|
103
|
+
server.dispose();
|
|
104
|
+
});
|
|
105
|
+
// -----------------------------------------------------------------------
|
|
106
|
+
// 3. Concurrent inserts from two clients converge to identical entity ids.
|
|
107
|
+
// -----------------------------------------------------------------------
|
|
108
|
+
it("concurrent inserts from two peers converge to identical entity ids", () => {
|
|
109
|
+
const server = createSyncServer();
|
|
110
|
+
const { client: c1t, server: s1t } = createLoopbackTransport();
|
|
111
|
+
const { client: c2t, server: s2t } = createLoopbackTransport();
|
|
112
|
+
const db1 = makeDb("peer1");
|
|
113
|
+
const db2 = makeDb("peer2");
|
|
114
|
+
server.connect(s1t);
|
|
115
|
+
server.connect(s2t);
|
|
116
|
+
const sync1 = createSyncService({ database: db1, transport: c1t });
|
|
117
|
+
const sync2 = createSyncService({ database: db2, transport: c2t });
|
|
118
|
+
db1.transactions.createPoint({ x: 1, y: 0, label: "Alpha" });
|
|
119
|
+
db2.transactions.createPoint({ x: 2, y: 0, label: "Beta" });
|
|
120
|
+
const entityByLabel = (db, lbl) => db.select(["label"]).find(e => db.read(e)?.label === lbl);
|
|
121
|
+
expect(entityByLabel(db1, "Alpha")).toBe(entityByLabel(db2, "Alpha"));
|
|
122
|
+
expect(entityByLabel(db1, "Beta")).toBe(entityByLabel(db2, "Beta"));
|
|
123
|
+
sync1.dispose();
|
|
124
|
+
sync2.dispose();
|
|
125
|
+
server.dispose();
|
|
126
|
+
});
|
|
127
|
+
// -----------------------------------------------------------------------
|
|
128
|
+
// 4. Late-join: a peer that connects after commits have already been
|
|
129
|
+
// made receives the full history and ends up in identical state.
|
|
130
|
+
// -----------------------------------------------------------------------
|
|
131
|
+
it("a late-joining peer receives full history and converges with existing peers", () => {
|
|
132
|
+
const server = createSyncServer();
|
|
133
|
+
const { client: c1t, server: s1t } = createLoopbackTransport();
|
|
134
|
+
const db1 = makeDb("peer1");
|
|
135
|
+
server.connect(s1t);
|
|
136
|
+
const sync1 = createSyncService({ database: db1, transport: c1t });
|
|
137
|
+
db1.transactions.createPoint({ x: 1, y: 2, label: "First" });
|
|
138
|
+
db1.transactions.createPoint({ x: 3, y: 4, label: "Second" });
|
|
139
|
+
// late peer joins AFTER both commits are already in the server log
|
|
140
|
+
const { client: c2t, server: s2t } = createLoopbackTransport();
|
|
141
|
+
const db2 = makeDb("peer2");
|
|
142
|
+
server.connect(s2t);
|
|
143
|
+
createSyncService({ database: db2, transport: c2t });
|
|
144
|
+
const snap1 = snap(db1);
|
|
145
|
+
const snap2 = snap(db2);
|
|
146
|
+
expect(snap2.length).toBe(2);
|
|
147
|
+
expect(snap2[0].entity).toBe(snap1[0].entity);
|
|
148
|
+
expect(snap2[1].entity).toBe(snap1[1].entity);
|
|
149
|
+
expect(snap2[0].label).toBe("First");
|
|
150
|
+
expect(snap2[1].label).toBe("Second");
|
|
151
|
+
sync1.dispose();
|
|
152
|
+
server.dispose();
|
|
153
|
+
});
|
|
154
|
+
// -----------------------------------------------------------------------
|
|
155
|
+
// 5. NonPersistent resource never replicates.
|
|
156
|
+
// Red-green: this is the semantic guarantee that nonPersistent: true
|
|
157
|
+
// resources stay local. Sync service skips envelopes whose
|
|
158
|
+
// TransactionResult.nonPersistent === true.
|
|
159
|
+
// -----------------------------------------------------------------------
|
|
160
|
+
it("nonPersistent resource mutations are never replicated to peers", () => {
|
|
161
|
+
const server = createSyncServer();
|
|
162
|
+
const { client: c1t, server: s1t } = createLoopbackTransport();
|
|
163
|
+
const { client: c2t, server: s2t } = createLoopbackTransport();
|
|
164
|
+
const db1 = makeDb("peer1");
|
|
165
|
+
const db2 = makeDb("peer2");
|
|
166
|
+
server.connect(s1t);
|
|
167
|
+
server.connect(s2t);
|
|
168
|
+
const sync1 = createSyncService({ database: db1, transport: c1t });
|
|
169
|
+
createSyncService({ database: db2, transport: c2t });
|
|
170
|
+
db1.transactions.setBanner({ text: "private to peer1" });
|
|
171
|
+
// Local DB sees the local change.
|
|
172
|
+
expect(db1.resources.bannerText).toBe("private to peer1");
|
|
173
|
+
// Peer 2 must NOT see it.
|
|
174
|
+
expect(db2.resources.bannerText).toBe("");
|
|
175
|
+
// Sanity check: a non-nonPersistent mutation in the same session DOES replicate.
|
|
176
|
+
db1.transactions.bumpScore({ delta: 5 });
|
|
177
|
+
expect(db1.resources.score).toBe(5);
|
|
178
|
+
expect(db2.resources.score).toBe(5);
|
|
179
|
+
sync1.dispose();
|
|
180
|
+
server.dispose();
|
|
181
|
+
});
|
|
182
|
+
// -----------------------------------------------------------------------
|
|
183
|
+
// 5b. NonShared resource never replicates (but is still local + durable).
|
|
184
|
+
// The sync service skips envelopes with no shared effect.
|
|
185
|
+
// -----------------------------------------------------------------------
|
|
186
|
+
it("nonShared resource mutations are never replicated to peers", () => {
|
|
187
|
+
const server = createSyncServer();
|
|
188
|
+
const { client: c1t, server: s1t } = createLoopbackTransport();
|
|
189
|
+
const { client: c2t, server: s2t } = createLoopbackTransport();
|
|
190
|
+
const db1 = makeDb("peer1");
|
|
191
|
+
const db2 = makeDb("peer2");
|
|
192
|
+
server.connect(s1t);
|
|
193
|
+
server.connect(s2t);
|
|
194
|
+
const sync1 = createSyncService({ database: db1, transport: c1t });
|
|
195
|
+
createSyncService({ database: db2, transport: c2t });
|
|
196
|
+
db1.transactions.setLocalPref({ text: "local to peer1" });
|
|
197
|
+
// Local DB sees the change; peer 2 must NOT.
|
|
198
|
+
expect(db1.resources.localPref).toBe("local to peer1");
|
|
199
|
+
expect(db2.resources.localPref).toBe("");
|
|
200
|
+
// Sanity: a shared mutation in the same session DOES replicate.
|
|
201
|
+
db1.transactions.bumpScore({ delta: 3 });
|
|
202
|
+
expect(db2.resources.score).toBe(3);
|
|
203
|
+
sync1.dispose();
|
|
204
|
+
server.dispose();
|
|
205
|
+
});
|
|
206
|
+
// -----------------------------------------------------------------------
|
|
207
|
+
// 6. (userId, id) compound key keeps two peers' independent local
|
|
208
|
+
// counters from colliding on the wire.
|
|
209
|
+
// -----------------------------------------------------------------------
|
|
210
|
+
it("two peers' identical local id counters do not collide via (userId, id) compound key", () => {
|
|
211
|
+
const server = createSyncServer();
|
|
212
|
+
const { client: c1t, server: s1t } = createLoopbackTransport();
|
|
213
|
+
const { client: c2t, server: s2t } = createLoopbackTransport();
|
|
214
|
+
const db1 = makeDb("peerA");
|
|
215
|
+
const db2 = makeDb("peerB");
|
|
216
|
+
server.connect(s1t);
|
|
217
|
+
server.connect(s2t);
|
|
218
|
+
const sync1 = createSyncService({ database: db1, transport: c1t });
|
|
219
|
+
const sync2 = createSyncService({ database: db2, transport: c2t });
|
|
220
|
+
// Both peers' local id counters start at 1; if compound keying were
|
|
221
|
+
// broken these envelopes would overwrite each other in the
|
|
222
|
+
// reconciler queue and one of the entities would be lost.
|
|
223
|
+
db1.transactions.createPoint({ x: 0, y: 0, label: "FromA" });
|
|
224
|
+
db2.transactions.createPoint({ x: 0, y: 0, label: "FromB" });
|
|
225
|
+
const labels = (db) => db.select(["label"]).map(e => db.read(e)?.label).sort();
|
|
226
|
+
expect(labels(db1)).toEqual(["FromA", "FromB"]);
|
|
227
|
+
expect(labels(db2)).toEqual(["FromA", "FromB"]);
|
|
228
|
+
sync1.dispose();
|
|
229
|
+
sync2.dispose();
|
|
230
|
+
server.dispose();
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
//# sourceMappingURL=sync.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sync.test.js","sourceRoot":"","sources":["../src/sync.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// © 2026 Adobe. MIT License. See /LICENSE for details.
|
|
2
|
+
//
|
|
3
|
+
// Tests for SyncTransport.onClose across the loopback transport.
|
|
4
|
+
// (WebRTC and WebSocket adapters are tested via their integration paths.)
|
|
5
|
+
import { describe, it, expect, vi } from "vitest";
|
|
6
|
+
import { createLoopbackTransport } from "./loopback-transport.js";
|
|
7
|
+
describe("SyncTransport.onClose — loopback", () => {
|
|
8
|
+
it("fires listener when close() is called on client side", () => {
|
|
9
|
+
const { client } = createLoopbackTransport();
|
|
10
|
+
const fn = vi.fn();
|
|
11
|
+
client.onClose(fn);
|
|
12
|
+
expect(fn).not.toHaveBeenCalled();
|
|
13
|
+
client.close();
|
|
14
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
15
|
+
});
|
|
16
|
+
it("fires listener when close() is called on server side", () => {
|
|
17
|
+
const { client, server } = createLoopbackTransport();
|
|
18
|
+
const fn = vi.fn();
|
|
19
|
+
client.onClose(fn);
|
|
20
|
+
server.close();
|
|
21
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
22
|
+
});
|
|
23
|
+
it("server onClose also fires when channel closes", () => {
|
|
24
|
+
const { client, server } = createLoopbackTransport();
|
|
25
|
+
const fn = vi.fn();
|
|
26
|
+
server.onClose(fn);
|
|
27
|
+
client.close();
|
|
28
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
29
|
+
});
|
|
30
|
+
it("fires at most once even if close() is called multiple times", () => {
|
|
31
|
+
const { client } = createLoopbackTransport();
|
|
32
|
+
const fn = vi.fn();
|
|
33
|
+
client.onClose(fn);
|
|
34
|
+
client.close();
|
|
35
|
+
client.close();
|
|
36
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
37
|
+
});
|
|
38
|
+
it("calls listener immediately if already closed at registration time", () => {
|
|
39
|
+
const { client } = createLoopbackTransport();
|
|
40
|
+
client.close();
|
|
41
|
+
const fn = vi.fn();
|
|
42
|
+
client.onClose(fn);
|
|
43
|
+
expect(fn).toHaveBeenCalledTimes(1);
|
|
44
|
+
});
|
|
45
|
+
it("unsubscribe prevents the listener from firing", () => {
|
|
46
|
+
const { client } = createLoopbackTransport();
|
|
47
|
+
const fn = vi.fn();
|
|
48
|
+
const unsub = client.onClose(fn);
|
|
49
|
+
unsub();
|
|
50
|
+
client.close();
|
|
51
|
+
expect(fn).not.toHaveBeenCalled();
|
|
52
|
+
});
|
|
53
|
+
it("multiple listeners all fire", () => {
|
|
54
|
+
const { client } = createLoopbackTransport();
|
|
55
|
+
const fn1 = vi.fn();
|
|
56
|
+
const fn2 = vi.fn();
|
|
57
|
+
client.onClose(fn1);
|
|
58
|
+
client.onClose(fn2);
|
|
59
|
+
client.close();
|
|
60
|
+
expect(fn1).toHaveBeenCalledTimes(1);
|
|
61
|
+
expect(fn2).toHaveBeenCalledTimes(1);
|
|
62
|
+
});
|
|
63
|
+
it("messages can be exchanged before close fires", () => {
|
|
64
|
+
const { client, server } = createLoopbackTransport();
|
|
65
|
+
const received = [];
|
|
66
|
+
server.onMessage((msg) => received.push(msg));
|
|
67
|
+
client.send({ kind: "cancel", id: 1 });
|
|
68
|
+
expect(received).toHaveLength(1);
|
|
69
|
+
client.close();
|
|
70
|
+
// No more messages after close
|
|
71
|
+
expect(received).toHaveLength(1);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
//# sourceMappingURL=transport-onclose.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"transport-onclose.test.js","sourceRoot":"","sources":["../src/transport-onclose.test.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { TransactionEnvelope } from "@adobe/data/ecs";
|
|
2
|
+
/**
|
|
3
|
+
* Messages flowing from server → client (broadcasts).
|
|
4
|
+
*
|
|
5
|
+
* - `committed` — an envelope has been accepted by the server and assigned a
|
|
6
|
+
* canonical wall-clock `time`. Every client must apply this in the order it
|
|
7
|
+
* is received; the `time` values are monotonically increasing per session.
|
|
8
|
+
* - `cancelled` — an envelope the server received was rejected (e.g. auth
|
|
9
|
+
* failure, validation error). Clients that hold a matching transient should
|
|
10
|
+
* roll it back.
|
|
11
|
+
* - `welcome` — response to `hello`. Sent before any replay. When
|
|
12
|
+
* `resetRequired` is true the client must wipe its database before applying
|
|
13
|
+
* the full replay that follows; when false the server will replay only the
|
|
14
|
+
* tail since `lastAppliedTime`.
|
|
15
|
+
* - `pong` — keep-alive reply to a client `ping`. Carries no payload;
|
|
16
|
+
* its only purpose is to refresh the client's liveness deadline and keep
|
|
17
|
+
* intermediate NAT mappings warm.
|
|
18
|
+
*/
|
|
19
|
+
export type ServerMessage = {
|
|
20
|
+
readonly kind: "committed";
|
|
21
|
+
readonly envelope: TransactionEnvelope;
|
|
22
|
+
} | {
|
|
23
|
+
readonly kind: "cancelled";
|
|
24
|
+
readonly id: number;
|
|
25
|
+
readonly reason?: string;
|
|
26
|
+
} | {
|
|
27
|
+
readonly kind: "welcome";
|
|
28
|
+
readonly sessionId: string;
|
|
29
|
+
readonly resetRequired: boolean;
|
|
30
|
+
} | {
|
|
31
|
+
readonly kind: "pong";
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Messages flowing from client → server (proposals).
|
|
35
|
+
*
|
|
36
|
+
* - `propose` — client wants to commit an operation. The envelope's `time`
|
|
37
|
+
* must be negative (transient) when sent; the server assigns the final time.
|
|
38
|
+
* - `intermediate` — speculative state the client wants other clients to see
|
|
39
|
+
* (e.g. cursor position, dragging). Servers may broadcast these as-is or
|
|
40
|
+
* drop/throttle them; they are never persisted.
|
|
41
|
+
* - `cancel` — client withdraws a previously proposed envelope.
|
|
42
|
+
* - `hello` — sent exactly once per connection, immediately after wiring.
|
|
43
|
+
* Carries the client's prior `sessionId` (omit for a fresh client) and the
|
|
44
|
+
* highest committed `time` already applied locally. The server responds with
|
|
45
|
+
* `welcome` and replays only what the client is missing.
|
|
46
|
+
* - `ping` — keep-alive probe. The server replies with `pong` and uses
|
|
47
|
+
* the receipt to refresh its per-client liveness deadline. Pings carry no
|
|
48
|
+
* payload and are independent of the hello/welcome handshake.
|
|
49
|
+
*/
|
|
50
|
+
export type ClientMessage = {
|
|
51
|
+
readonly kind: "propose";
|
|
52
|
+
readonly envelope: TransactionEnvelope;
|
|
53
|
+
} | {
|
|
54
|
+
readonly kind: "intermediate";
|
|
55
|
+
readonly envelope: TransactionEnvelope;
|
|
56
|
+
} | {
|
|
57
|
+
readonly kind: "cancel";
|
|
58
|
+
readonly id: number;
|
|
59
|
+
} | {
|
|
60
|
+
readonly kind: "hello";
|
|
61
|
+
readonly sessionId?: string;
|
|
62
|
+
readonly lastAppliedTime: number;
|
|
63
|
+
} | {
|
|
64
|
+
readonly kind: "ping";
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Runtime-agnostic, bidirectional message channel.
|
|
68
|
+
*
|
|
69
|
+
* Implementations are provided for:
|
|
70
|
+
* - in-process loopback (testing / single-tab)
|
|
71
|
+
* - WebSocket (browser ↔ Node.js)
|
|
72
|
+
*
|
|
73
|
+
* The transport does not own serialization — callers pass structured objects.
|
|
74
|
+
* Ordering of `send` calls is preserved; delivery is assumed reliable (use a
|
|
75
|
+
* reliable transport like WebSocket, not UDP, for the committed stream).
|
|
76
|
+
*/
|
|
77
|
+
export interface SyncTransport<Send, Receive> {
|
|
78
|
+
/** Send a message to the remote end. */
|
|
79
|
+
readonly send: (msg: Send) => void;
|
|
80
|
+
/** Register a listener that will receive messages from the remote end. */
|
|
81
|
+
readonly onMessage: (listener: (msg: Receive) => void) => () => void;
|
|
82
|
+
/**
|
|
83
|
+
* Register a listener that fires once when the channel closes — whether
|
|
84
|
+
* due to a remote close, network drop, or a local `close()` call.
|
|
85
|
+
* Returns an unsubscribe function. Firing is idempotent (at most once).
|
|
86
|
+
*/
|
|
87
|
+
readonly onClose: (listener: () => void) => () => void;
|
|
88
|
+
/** Tear down the channel. */
|
|
89
|
+
readonly close: () => void;
|
|
90
|
+
}
|
|
91
|
+
/** Transport from the client's perspective. */
|
|
92
|
+
export type ClientTransport = SyncTransport<ClientMessage, ServerMessage>;
|
|
93
|
+
/** Transport from the server's perspective (per connected client). */
|
|
94
|
+
export type ServerTransport = SyncTransport<ServerMessage, ClientMessage>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"transport.js","sourceRoot":"","sources":["../src/transport.ts"],"names":[],"mappings":"AAAA,uDAAuD"}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ClientTransport, ServerTransport } from "./transport.js";
|
|
2
|
+
/**
|
|
3
|
+
* Options for {@link createWebSocketClientTransport}.
|
|
4
|
+
*/
|
|
5
|
+
export interface WebSocketClientTransportOptions {
|
|
6
|
+
/**
|
|
7
|
+
* WebSocket URL to connect to (e.g. `"ws://localhost:4000/sync"`).
|
|
8
|
+
*/
|
|
9
|
+
readonly url: string;
|
|
10
|
+
/**
|
|
11
|
+
* Optional WebSocket constructor override for environments that do not have
|
|
12
|
+
* a global `WebSocket` (Node.js < 22). Pass `require("ws")` or a polyfill.
|
|
13
|
+
*/
|
|
14
|
+
readonly WebSocket?: typeof globalThis.WebSocket;
|
|
15
|
+
/**
|
|
16
|
+
* Called when the connection opens. Use to send an initial handshake if
|
|
17
|
+
* needed (e.g. authentication token).
|
|
18
|
+
*/
|
|
19
|
+
readonly onOpen?: (ws: globalThis.WebSocket) => void;
|
|
20
|
+
/** Called when the connection closes (clean or error). */
|
|
21
|
+
readonly onClose?: (event: CloseEvent) => void;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Creates a {@link ClientTransport} backed by a WebSocket connection.
|
|
25
|
+
*
|
|
26
|
+
* The connection is established immediately. Messages sent before the socket
|
|
27
|
+
* is open are buffered and flushed on connect. Closing the transport closes
|
|
28
|
+
* the socket with code 1000 (normal closure).
|
|
29
|
+
*
|
|
30
|
+
* Works in browsers (native `WebSocket`) and in Node.js 22+ (global
|
|
31
|
+
* `WebSocket`). For earlier Node.js, pass `options.WebSocket = require("ws")`.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```ts
|
|
35
|
+
* const transport = createWebSocketClientTransport({ url: "ws://localhost:4000/sync" });
|
|
36
|
+
* const sync = createSyncService({ database: myDb, transport });
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export declare const createWebSocketClientTransport: (options: WebSocketClientTransportOptions) => ClientTransport;
|
|
40
|
+
/**
|
|
41
|
+
* Minimal interface for an already-open WebSocket connection on the server
|
|
42
|
+
* side. Compatible with the browser `WebSocket` API and the `ws` npm package.
|
|
43
|
+
*/
|
|
44
|
+
export interface WebSocketLike {
|
|
45
|
+
readonly readyState: number;
|
|
46
|
+
send(data: string): void;
|
|
47
|
+
addEventListener(event: "message", handler: (event: {
|
|
48
|
+
data: string;
|
|
49
|
+
}) => void): void;
|
|
50
|
+
addEventListener(event: "close", handler: () => void): void;
|
|
51
|
+
removeEventListener(event: string, handler: (...args: any[]) => void): void;
|
|
52
|
+
close(code?: number, reason?: string): void;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Wraps an already-accepted `WebSocket` connection into a {@link ServerTransport}.
|
|
56
|
+
*
|
|
57
|
+
* Call this inside your WebSocket server's "connection" handler:
|
|
58
|
+
*
|
|
59
|
+
* ```ts
|
|
60
|
+
* const server = createSyncServer();
|
|
61
|
+
*
|
|
62
|
+
* // With the `ws` package:
|
|
63
|
+
* const wss = new WebSocketServer({ port: 4000 });
|
|
64
|
+
* wss.on("connection", (ws) => {
|
|
65
|
+
* const transport = createWebSocketServerTransport(ws);
|
|
66
|
+
* const disconnect = server.connect(transport);
|
|
67
|
+
* ws.on("close", disconnect);
|
|
68
|
+
* });
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
export declare const createWebSocketServerTransport: (ws: WebSocketLike) => ServerTransport;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// © 2026 Adobe. MIT License. See /LICENSE for details.
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Serialization helpers
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
const encode = (msg) => JSON.stringify(msg);
|
|
6
|
+
const decode = (raw) => JSON.parse(raw);
|
|
7
|
+
/**
|
|
8
|
+
* Creates a {@link ClientTransport} backed by a WebSocket connection.
|
|
9
|
+
*
|
|
10
|
+
* The connection is established immediately. Messages sent before the socket
|
|
11
|
+
* is open are buffered and flushed on connect. Closing the transport closes
|
|
12
|
+
* the socket with code 1000 (normal closure).
|
|
13
|
+
*
|
|
14
|
+
* Works in browsers (native `WebSocket`) and in Node.js 22+ (global
|
|
15
|
+
* `WebSocket`). For earlier Node.js, pass `options.WebSocket = require("ws")`.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* const transport = createWebSocketClientTransport({ url: "ws://localhost:4000/sync" });
|
|
20
|
+
* const sync = createSyncService({ database: myDb, transport });
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export const createWebSocketClientTransport = (options) => {
|
|
24
|
+
const WS = options.WebSocket ?? globalThis.WebSocket;
|
|
25
|
+
const ws = new WS(options.url);
|
|
26
|
+
const listeners = new Set();
|
|
27
|
+
const closeListeners = new Set();
|
|
28
|
+
const pendingOutbound = [];
|
|
29
|
+
let closed = false;
|
|
30
|
+
const fireClose = () => {
|
|
31
|
+
if (closed)
|
|
32
|
+
return;
|
|
33
|
+
closed = true;
|
|
34
|
+
for (const l of closeListeners)
|
|
35
|
+
l();
|
|
36
|
+
closeListeners.clear();
|
|
37
|
+
};
|
|
38
|
+
ws.addEventListener("open", () => {
|
|
39
|
+
options.onOpen?.(ws);
|
|
40
|
+
for (const msg of pendingOutbound)
|
|
41
|
+
ws.send(msg);
|
|
42
|
+
pendingOutbound.length = 0;
|
|
43
|
+
});
|
|
44
|
+
ws.addEventListener("message", (event) => {
|
|
45
|
+
const msg = decode(event.data);
|
|
46
|
+
for (const l of listeners)
|
|
47
|
+
l(msg);
|
|
48
|
+
});
|
|
49
|
+
ws.addEventListener("close", (event) => {
|
|
50
|
+
options.onClose?.(event);
|
|
51
|
+
fireClose();
|
|
52
|
+
});
|
|
53
|
+
return {
|
|
54
|
+
send(msg) {
|
|
55
|
+
const encoded = encode(msg);
|
|
56
|
+
if (ws.readyState === ws.OPEN) {
|
|
57
|
+
ws.send(encoded);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
pendingOutbound.push(encoded);
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
onMessage(listener) {
|
|
64
|
+
listeners.add(listener);
|
|
65
|
+
return () => listeners.delete(listener);
|
|
66
|
+
},
|
|
67
|
+
onClose(listener) {
|
|
68
|
+
if (closed) {
|
|
69
|
+
listener();
|
|
70
|
+
return () => undefined;
|
|
71
|
+
}
|
|
72
|
+
closeListeners.add(listener);
|
|
73
|
+
return () => closeListeners.delete(listener);
|
|
74
|
+
},
|
|
75
|
+
close() {
|
|
76
|
+
fireClose();
|
|
77
|
+
listeners.clear();
|
|
78
|
+
if (ws.readyState === ws.OPEN || ws.readyState === ws.CONNECTING) {
|
|
79
|
+
ws.close(1000, "client disposed");
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Wraps an already-accepted `WebSocket` connection into a {@link ServerTransport}.
|
|
86
|
+
*
|
|
87
|
+
* Call this inside your WebSocket server's "connection" handler:
|
|
88
|
+
*
|
|
89
|
+
* ```ts
|
|
90
|
+
* const server = createSyncServer();
|
|
91
|
+
*
|
|
92
|
+
* // With the `ws` package:
|
|
93
|
+
* const wss = new WebSocketServer({ port: 4000 });
|
|
94
|
+
* wss.on("connection", (ws) => {
|
|
95
|
+
* const transport = createWebSocketServerTransport(ws);
|
|
96
|
+
* const disconnect = server.connect(transport);
|
|
97
|
+
* ws.on("close", disconnect);
|
|
98
|
+
* });
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
export const createWebSocketServerTransport = (ws) => {
|
|
102
|
+
const listeners = new Set();
|
|
103
|
+
const closeListeners = new Set();
|
|
104
|
+
let closed = false;
|
|
105
|
+
const fireClose = () => {
|
|
106
|
+
if (closed)
|
|
107
|
+
return;
|
|
108
|
+
closed = true;
|
|
109
|
+
for (const l of closeListeners)
|
|
110
|
+
l();
|
|
111
|
+
closeListeners.clear();
|
|
112
|
+
};
|
|
113
|
+
const messageHandler = (event) => {
|
|
114
|
+
const msg = decode(event.data);
|
|
115
|
+
for (const l of listeners)
|
|
116
|
+
l(msg);
|
|
117
|
+
};
|
|
118
|
+
ws.addEventListener("message", messageHandler);
|
|
119
|
+
ws.addEventListener("close", fireClose);
|
|
120
|
+
return {
|
|
121
|
+
send(msg) {
|
|
122
|
+
if (ws.readyState === 1 /* OPEN */) {
|
|
123
|
+
ws.send(encode(msg));
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
onMessage(listener) {
|
|
127
|
+
listeners.add(listener);
|
|
128
|
+
return () => listeners.delete(listener);
|
|
129
|
+
},
|
|
130
|
+
onClose(listener) {
|
|
131
|
+
if (closed) {
|
|
132
|
+
listener();
|
|
133
|
+
return () => undefined;
|
|
134
|
+
}
|
|
135
|
+
closeListeners.add(listener);
|
|
136
|
+
return () => closeListeners.delete(listener);
|
|
137
|
+
},
|
|
138
|
+
close() {
|
|
139
|
+
fireClose();
|
|
140
|
+
ws.removeEventListener("message", messageHandler);
|
|
141
|
+
listeners.clear();
|
|
142
|
+
if (ws.readyState === 1) {
|
|
143
|
+
ws.close(1000, "server transport disposed");
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
//# sourceMappingURL=websocket-transport.js.map
|