@agentstage/bridge 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/counter/store.json +8 -0
- package/dist/browser/createBridgeStore.d.ts +5 -0
- package/dist/browser/createBridgeStore.d.ts.map +1 -0
- package/dist/browser/createBridgeStore.js +232 -0
- package/dist/browser/createBridgeStore.js.map +1 -0
- package/dist/browser/index.d.ts +4 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +2 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/browser/types.d.ts +36 -0
- package/dist/browser/types.d.ts.map +1 -0
- package/dist/browser/types.js +2 -0
- package/dist/browser/types.js.map +1 -0
- package/dist/gateway/apiHandler.d.ts +10 -0
- package/dist/gateway/apiHandler.d.ts.map +1 -0
- package/dist/gateway/apiHandler.js +91 -0
- package/dist/gateway/apiHandler.js.map +1 -0
- package/dist/gateway/createBridgeGateway.d.ts +3 -0
- package/dist/gateway/createBridgeGateway.d.ts.map +1 -0
- package/dist/gateway/createBridgeGateway.js +689 -0
- package/dist/gateway/createBridgeGateway.js.map +1 -0
- package/dist/gateway/fileStore.d.ts +39 -0
- package/dist/gateway/fileStore.d.ts.map +1 -0
- package/dist/gateway/fileStore.js +189 -0
- package/dist/gateway/fileStore.js.map +1 -0
- package/dist/gateway/index.d.ts +6 -0
- package/dist/gateway/index.d.ts.map +1 -0
- package/dist/gateway/index.js +5 -0
- package/dist/gateway/index.js.map +1 -0
- package/dist/gateway/registry.d.ts +21 -0
- package/dist/gateway/registry.d.ts.map +1 -0
- package/dist/gateway/registry.js +136 -0
- package/dist/gateway/registry.js.map +1 -0
- package/dist/gateway/types.d.ts +50 -0
- package/dist/gateway/types.d.ts.map +1 -0
- package/dist/gateway/types.js +2 -0
- package/dist/gateway/types.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/sdk/BridgeClient.d.ts +38 -0
- package/dist/sdk/BridgeClient.d.ts.map +1 -0
- package/dist/sdk/BridgeClient.js +163 -0
- package/dist/sdk/BridgeClient.js.map +1 -0
- package/dist/sdk/index.d.ts +3 -0
- package/dist/sdk/index.d.ts.map +1 -0
- package/dist/sdk/index.js +2 -0
- package/dist/sdk/index.js.map +1 -0
- package/dist/shared/types.d.ts +154 -0
- package/dist/shared/types.d.ts.map +1 -0
- package/dist/shared/types.js +5 -0
- package/dist/shared/types.js.map +1 -0
- package/dist/utils/logger.d.ts +33 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +206 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/vite/index.d.ts +6 -0
- package/dist/vite/index.d.ts.map +1 -0
- package/dist/vite/index.js +23 -0
- package/dist/vite/index.js.map +1 -0
- package/package.json +60 -0
- package/src/browser/createBridgeStore.ts +276 -0
- package/src/browser/index.ts +6 -0
- package/src/browser/types.ts +36 -0
- package/src/gateway/apiHandler.ts +107 -0
- package/src/gateway/createBridgeGateway.ts +854 -0
- package/src/gateway/fileStore.ts +244 -0
- package/src/gateway/index.ts +12 -0
- package/src/gateway/registry.ts +166 -0
- package/src/gateway/types.ts +65 -0
- package/src/index.ts +33 -0
- package/src/sdk/BridgeClient.ts +203 -0
- package/src/sdk/index.ts +2 -0
- package/src/shared/types.ts +117 -0
- package/src/utils/logger.ts +262 -0
- package/src/vite/index.ts +31 -0
- package/test/e2e/bridge.test.ts +386 -0
- package/test/integration/gateway.test.ts +485 -0
- package/test/mocks/mockWebSocket.ts +49 -0
- package/test/unit/browser.test.ts +267 -0
- package/test/unit/fileStore.test.ts +98 -0
- package/test/unit/registry.test.ts +345 -0
- package/test-page/store.json +8 -0
- package/tsconfig.json +20 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.config.ts +17 -0
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterAll, beforeAll } from 'vitest';
|
|
2
|
+
import { createServer } from 'http';
|
|
3
|
+
import { WebSocket, WebSocketServer } from 'ws';
|
|
4
|
+
import { mkdtempSync, readdirSync, rmSync } from 'fs';
|
|
5
|
+
import { tmpdir } from 'os';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
import { createBridgeGateway } from '../../src/gateway/createBridgeGateway.js';
|
|
8
|
+
import type { Gateway } from '../../src/gateway/types.js';
|
|
9
|
+
import getPort from 'get-port';
|
|
10
|
+
|
|
11
|
+
function attachAutoStateAppliedAck(ws: WebSocket): void {
|
|
12
|
+
ws.on('message', (data) => {
|
|
13
|
+
const msg = JSON.parse(data.toString());
|
|
14
|
+
if (msg.type !== 'client.setState' || !msg.payload?.requestId || !msg.payload?.storeId) {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
ws.send(
|
|
19
|
+
JSON.stringify({
|
|
20
|
+
type: 'store.stateApplied',
|
|
21
|
+
payload: {
|
|
22
|
+
storeId: msg.payload.storeId,
|
|
23
|
+
requestId: msg.payload.requestId,
|
|
24
|
+
status: 'applied',
|
|
25
|
+
version: typeof msg.payload.version === 'number' ? msg.payload.version : 0,
|
|
26
|
+
},
|
|
27
|
+
})
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('Gateway + Registry Integration', () => {
|
|
33
|
+
let gateway: Gateway & { attach: (server: any) => WebSocketServer; destroy: () => void };
|
|
34
|
+
let server: ReturnType<typeof createServer>;
|
|
35
|
+
let wss: WebSocketServer;
|
|
36
|
+
let port: number;
|
|
37
|
+
let tempDir: string;
|
|
38
|
+
|
|
39
|
+
beforeAll(async () => {
|
|
40
|
+
tempDir = mkdtempSync(join(tmpdir(), 'bridge-integration-'));
|
|
41
|
+
gateway = createBridgeGateway({ pagesDir: tempDir }) as any;
|
|
42
|
+
server = createServer();
|
|
43
|
+
wss = gateway.attach(server);
|
|
44
|
+
port = await getPort();
|
|
45
|
+
await new Promise<void>((resolve) => server.listen(port, resolve));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterAll(() => {
|
|
49
|
+
gateway.destroy();
|
|
50
|
+
wss.close();
|
|
51
|
+
server.close();
|
|
52
|
+
// Clean up temp directory
|
|
53
|
+
try {
|
|
54
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
55
|
+
} catch {}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
beforeEach(async () => {
|
|
59
|
+
// Clear all stores between tests by disconnecting all connected stores
|
|
60
|
+
for (const store of gateway.listStores()) {
|
|
61
|
+
const ws = gateway.getStore(store.id)?.ws;
|
|
62
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
63
|
+
ws.close();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Wait longer for all async cleanup to complete
|
|
67
|
+
await new Promise(r => setTimeout(r, 100));
|
|
68
|
+
|
|
69
|
+
// Clear any remaining stores from registry
|
|
70
|
+
for (const store of gateway.listStores()) {
|
|
71
|
+
const ws = gateway.getStore(store.id)?.ws;
|
|
72
|
+
if (ws) {
|
|
73
|
+
ws.terminate?.();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
await new Promise(r => setTimeout(r, 50));
|
|
77
|
+
|
|
78
|
+
// Clean up files in temp directory between tests
|
|
79
|
+
try {
|
|
80
|
+
for (const file of readdirSync(tempDir)) {
|
|
81
|
+
rmSync(join(tempDir, file), { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
} catch {}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('full lifecycle', () => {
|
|
87
|
+
it('should handle complete register -> stateChange -> disconnect flow', async () => {
|
|
88
|
+
// 1. Browser connects and registers
|
|
89
|
+
const browserWs = new WebSocket(`ws://localhost:${port}/_bridge?type=browser`);
|
|
90
|
+
attachAutoStateAppliedAck(browserWs);
|
|
91
|
+
|
|
92
|
+
await new Promise<void>((resolve, reject) => {
|
|
93
|
+
browserWs.on('open', resolve);
|
|
94
|
+
browserWs.on('error', reject);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Register store
|
|
98
|
+
browserWs.send(JSON.stringify({
|
|
99
|
+
type: 'store.register',
|
|
100
|
+
payload: {
|
|
101
|
+
storeId: 'page#test123',
|
|
102
|
+
pageId: 'test-page',
|
|
103
|
+
storeKey: 'main',
|
|
104
|
+
description: {
|
|
105
|
+
pageId: 'test-page',
|
|
106
|
+
storeKey: 'main',
|
|
107
|
+
schema: { type: 'object' },
|
|
108
|
+
actions: {},
|
|
109
|
+
},
|
|
110
|
+
initialState: { count: 0 },
|
|
111
|
+
},
|
|
112
|
+
}));
|
|
113
|
+
|
|
114
|
+
// Wait a bit for registration
|
|
115
|
+
await new Promise(r => setTimeout(r, 50));
|
|
116
|
+
|
|
117
|
+
// Verify store is registered
|
|
118
|
+
const store = gateway.getStore('page#test123');
|
|
119
|
+
expect(store).toBeDefined();
|
|
120
|
+
expect(store?.pageId).toBe('test-page');
|
|
121
|
+
expect(store?.currentState).toEqual({ count: 0 });
|
|
122
|
+
|
|
123
|
+
// 2. Browser sends state change
|
|
124
|
+
browserWs.send(JSON.stringify({
|
|
125
|
+
type: 'store.stateChanged',
|
|
126
|
+
payload: {
|
|
127
|
+
storeId: 'page#test123',
|
|
128
|
+
state: { count: 5 },
|
|
129
|
+
version: 1,
|
|
130
|
+
source: 'browser',
|
|
131
|
+
},
|
|
132
|
+
}));
|
|
133
|
+
|
|
134
|
+
await new Promise(r => setTimeout(r, 50));
|
|
135
|
+
|
|
136
|
+
// Verify state is updated
|
|
137
|
+
const updatedStore = gateway.getStore('page#test123');
|
|
138
|
+
expect(updatedStore?.currentState).toEqual({ count: 5 });
|
|
139
|
+
expect(updatedStore?.version).toBe(1);
|
|
140
|
+
|
|
141
|
+
// 3. Browser disconnects
|
|
142
|
+
browserWs.close();
|
|
143
|
+
|
|
144
|
+
await new Promise(r => setTimeout(r, 50));
|
|
145
|
+
|
|
146
|
+
// Verify store is removed
|
|
147
|
+
expect(gateway.getStore('page#test123')).toBeUndefined();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should clean up all stores on the same ws when connection closes', async () => {
|
|
151
|
+
const browserWs = new WebSocket(`ws://localhost:${port}/_bridge?type=browser`);
|
|
152
|
+
attachAutoStateAppliedAck(browserWs);
|
|
153
|
+
|
|
154
|
+
await new Promise<void>((resolve, reject) => {
|
|
155
|
+
browserWs.on('open', resolve);
|
|
156
|
+
browserWs.on('error', reject);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
browserWs.send(
|
|
160
|
+
JSON.stringify({
|
|
161
|
+
type: 'store.register',
|
|
162
|
+
payload: {
|
|
163
|
+
storeId: 'page#multi-a',
|
|
164
|
+
pageId: 'lifecycle-a',
|
|
165
|
+
storeKey: 'main',
|
|
166
|
+
description: {
|
|
167
|
+
pageId: 'lifecycle-a',
|
|
168
|
+
storeKey: 'main',
|
|
169
|
+
schema: { type: 'object' },
|
|
170
|
+
actions: {},
|
|
171
|
+
},
|
|
172
|
+
initialState: { count: 1 },
|
|
173
|
+
},
|
|
174
|
+
})
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
browserWs.send(
|
|
178
|
+
JSON.stringify({
|
|
179
|
+
type: 'store.register',
|
|
180
|
+
payload: {
|
|
181
|
+
storeId: 'page#multi-b',
|
|
182
|
+
pageId: 'lifecycle-b',
|
|
183
|
+
storeKey: 'main',
|
|
184
|
+
description: {
|
|
185
|
+
pageId: 'lifecycle-b',
|
|
186
|
+
storeKey: 'main',
|
|
187
|
+
schema: { type: 'object' },
|
|
188
|
+
actions: {},
|
|
189
|
+
},
|
|
190
|
+
initialState: { count: 2 },
|
|
191
|
+
},
|
|
192
|
+
})
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
196
|
+
expect(gateway.getStore('page#multi-a')).toBeDefined();
|
|
197
|
+
expect(gateway.getStore('page#multi-b')).toBeDefined();
|
|
198
|
+
|
|
199
|
+
browserWs.close();
|
|
200
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
201
|
+
|
|
202
|
+
expect(gateway.getStore('page#multi-a')).toBeUndefined();
|
|
203
|
+
expect(gateway.getStore('page#multi-b')).toBeUndefined();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('should broadcast state changes to subscribers', async () => {
|
|
207
|
+
// 1. Browser registers store
|
|
208
|
+
const browserWs = new WebSocket(`ws://localhost:${port}/_bridge?type=browser`);
|
|
209
|
+
attachAutoStateAppliedAck(browserWs);
|
|
210
|
+
await new Promise<void>((resolve) => browserWs.on('open', resolve));
|
|
211
|
+
|
|
212
|
+
browserWs.send(JSON.stringify({
|
|
213
|
+
type: 'store.register',
|
|
214
|
+
payload: {
|
|
215
|
+
storeId: 'page#test123',
|
|
216
|
+
pageId: 'test-page',
|
|
217
|
+
storeKey: 'main',
|
|
218
|
+
description: {
|
|
219
|
+
pageId: 'test-page',
|
|
220
|
+
storeKey: 'main',
|
|
221
|
+
schema: { type: 'object' },
|
|
222
|
+
actions: {},
|
|
223
|
+
},
|
|
224
|
+
initialState: { count: 0 },
|
|
225
|
+
},
|
|
226
|
+
}));
|
|
227
|
+
|
|
228
|
+
await new Promise(r => setTimeout(r, 50));
|
|
229
|
+
|
|
230
|
+
// 2. Client subscribes to store
|
|
231
|
+
const clientWs = new WebSocket(`ws://localhost:${port}/_bridge?type=client`);
|
|
232
|
+
const receivedMessages: unknown[] = [];
|
|
233
|
+
|
|
234
|
+
// Set up message handler BEFORE waiting for open
|
|
235
|
+
clientWs.on('message', (data) => {
|
|
236
|
+
receivedMessages.push(JSON.parse(data.toString()));
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
await new Promise<void>((resolve) => clientWs.on('open', resolve));
|
|
240
|
+
|
|
241
|
+
clientWs.send(JSON.stringify({
|
|
242
|
+
type: 'subscribe',
|
|
243
|
+
payload: { storeId: 'page#test123' },
|
|
244
|
+
}));
|
|
245
|
+
|
|
246
|
+
await new Promise(r => setTimeout(r, 50));
|
|
247
|
+
|
|
248
|
+
// Should receive snapshot immediately
|
|
249
|
+
expect(receivedMessages.length).toBeGreaterThanOrEqual(1);
|
|
250
|
+
const snapshot = receivedMessages.find(m => (m as any).type === 'store.stateChanged');
|
|
251
|
+
expect(snapshot).toBeDefined();
|
|
252
|
+
expect((snapshot as any).payload.source).toBe('snapshot');
|
|
253
|
+
|
|
254
|
+
// 3. Browser sends state change
|
|
255
|
+
browserWs.send(JSON.stringify({
|
|
256
|
+
type: 'store.stateChanged',
|
|
257
|
+
payload: {
|
|
258
|
+
storeId: 'page#test123',
|
|
259
|
+
state: { count: 10 },
|
|
260
|
+
version: 1,
|
|
261
|
+
source: 'browser',
|
|
262
|
+
},
|
|
263
|
+
}));
|
|
264
|
+
|
|
265
|
+
await new Promise(r => setTimeout(r, 50));
|
|
266
|
+
|
|
267
|
+
// 4. Client should receive broadcast
|
|
268
|
+
const stateChanged = receivedMessages.find(
|
|
269
|
+
m => (m as any).type === 'store.stateChanged' && (m as any).payload.source === 'browser'
|
|
270
|
+
);
|
|
271
|
+
expect(stateChanged).toBeDefined();
|
|
272
|
+
expect((stateChanged as any).payload.state).toEqual({ count: 10 });
|
|
273
|
+
|
|
274
|
+
browserWs.close();
|
|
275
|
+
clientWs.close();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('should handle page refresh scenario (new store replaces old)', async () => {
|
|
279
|
+
// 1. First browser tab connects
|
|
280
|
+
const browser1 = new WebSocket(`ws://localhost:${port}/_bridge?type=browser`);
|
|
281
|
+
attachAutoStateAppliedAck(browser1);
|
|
282
|
+
await new Promise<void>((resolve) => browser1.on('open', resolve));
|
|
283
|
+
|
|
284
|
+
browser1.send(JSON.stringify({
|
|
285
|
+
type: 'store.register',
|
|
286
|
+
payload: {
|
|
287
|
+
storeId: 'page#old',
|
|
288
|
+
pageId: 'test-page',
|
|
289
|
+
storeKey: 'main',
|
|
290
|
+
description: {
|
|
291
|
+
pageId: 'test-page',
|
|
292
|
+
storeKey: 'main',
|
|
293
|
+
schema: { type: 'object' },
|
|
294
|
+
actions: {},
|
|
295
|
+
},
|
|
296
|
+
initialState: { count: 0 },
|
|
297
|
+
},
|
|
298
|
+
}));
|
|
299
|
+
|
|
300
|
+
await new Promise(r => setTimeout(r, 50));
|
|
301
|
+
|
|
302
|
+
expect(gateway.getStore('page#old')).toBeDefined();
|
|
303
|
+
expect(gateway.findStore('test-page', 'main')?.id).toBe('page#old');
|
|
304
|
+
|
|
305
|
+
// 2. Page refreshes (new tab connects with same pageId+storeKey)
|
|
306
|
+
const browser2 = new WebSocket(`ws://localhost:${port}/_bridge?type=browser`);
|
|
307
|
+
attachAutoStateAppliedAck(browser2);
|
|
308
|
+
await new Promise<void>((resolve) => browser2.on('open', resolve));
|
|
309
|
+
|
|
310
|
+
browser2.send(JSON.stringify({
|
|
311
|
+
type: 'store.register',
|
|
312
|
+
payload: {
|
|
313
|
+
storeId: 'page#new',
|
|
314
|
+
pageId: 'test-page',
|
|
315
|
+
storeKey: 'main',
|
|
316
|
+
description: {
|
|
317
|
+
pageId: 'test-page',
|
|
318
|
+
storeKey: 'main',
|
|
319
|
+
schema: { type: 'object' },
|
|
320
|
+
actions: {},
|
|
321
|
+
},
|
|
322
|
+
initialState: { count: 5 },
|
|
323
|
+
},
|
|
324
|
+
}));
|
|
325
|
+
|
|
326
|
+
await new Promise(r => setTimeout(r, 50));
|
|
327
|
+
|
|
328
|
+
// Old store should be replaced
|
|
329
|
+
expect(gateway.getStore('page#old')).toBeUndefined();
|
|
330
|
+
expect(gateway.getStore('page#new')).toBeDefined();
|
|
331
|
+
expect(gateway.findStore('test-page', 'main')?.id).toBe('page#new');
|
|
332
|
+
|
|
333
|
+
browser1.close();
|
|
334
|
+
browser2.close();
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('should handle setState from client to browser', async () => {
|
|
338
|
+
// 1. Browser registers
|
|
339
|
+
const browserWs = new WebSocket(`ws://localhost:${port}/_bridge?type=browser`);
|
|
340
|
+
attachAutoStateAppliedAck(browserWs);
|
|
341
|
+
const browserMessages: unknown[] = [];
|
|
342
|
+
|
|
343
|
+
await new Promise<void>((resolve) => browserWs.on('open', resolve));
|
|
344
|
+
|
|
345
|
+
browserWs.on('message', (data) => {
|
|
346
|
+
browserMessages.push(JSON.parse(data.toString()));
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// Use unique IDs to avoid file storage pollution from other tests
|
|
350
|
+
browserWs.send(JSON.stringify({
|
|
351
|
+
type: 'store.register',
|
|
352
|
+
payload: {
|
|
353
|
+
storeId: 'page#setstate-test',
|
|
354
|
+
pageId: 'setstate-test-page',
|
|
355
|
+
storeKey: 'main',
|
|
356
|
+
description: {
|
|
357
|
+
pageId: 'setstate-test-page',
|
|
358
|
+
storeKey: 'main',
|
|
359
|
+
schema: { type: 'object' },
|
|
360
|
+
actions: {},
|
|
361
|
+
},
|
|
362
|
+
initialState: { count: 0 },
|
|
363
|
+
},
|
|
364
|
+
}));
|
|
365
|
+
|
|
366
|
+
await new Promise(r => setTimeout(r, 50));
|
|
367
|
+
|
|
368
|
+
// 2. Call gateway.setState
|
|
369
|
+
await gateway.setState('page#setstate-test', { count: 42 });
|
|
370
|
+
|
|
371
|
+
await new Promise(r => setTimeout(r, 50));
|
|
372
|
+
|
|
373
|
+
// 3. Browser should receive setState message (filter out the initial file-based setState)
|
|
374
|
+
const setStateMsgs = browserMessages.filter(
|
|
375
|
+
m => (m as any).type === 'client.setState'
|
|
376
|
+
);
|
|
377
|
+
expect(setStateMsgs.length).toBeGreaterThanOrEqual(1);
|
|
378
|
+
// The last setState should be from gateway.setState
|
|
379
|
+
expect((setStateMsgs[setStateMsgs.length - 1] as any).payload.state).toEqual({ count: 42 });
|
|
380
|
+
|
|
381
|
+
browserWs.close();
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('should handle dispatch from client to browser', async () => {
|
|
385
|
+
// 1. Browser registers
|
|
386
|
+
const browserWs = new WebSocket(`ws://localhost:${port}/_bridge?type=browser`);
|
|
387
|
+
attachAutoStateAppliedAck(browserWs);
|
|
388
|
+
const browserMessages: unknown[] = [];
|
|
389
|
+
|
|
390
|
+
await new Promise<void>((resolve) => browserWs.on('open', resolve));
|
|
391
|
+
|
|
392
|
+
browserWs.on('message', (data) => {
|
|
393
|
+
browserMessages.push(JSON.parse(data.toString()));
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
browserWs.send(JSON.stringify({
|
|
397
|
+
type: 'store.register',
|
|
398
|
+
payload: {
|
|
399
|
+
storeId: 'page#test123',
|
|
400
|
+
pageId: 'test-page',
|
|
401
|
+
storeKey: 'main',
|
|
402
|
+
description: {
|
|
403
|
+
pageId: 'test-page',
|
|
404
|
+
storeKey: 'main',
|
|
405
|
+
schema: { type: 'object' },
|
|
406
|
+
actions: {},
|
|
407
|
+
},
|
|
408
|
+
initialState: { count: 0 },
|
|
409
|
+
},
|
|
410
|
+
}));
|
|
411
|
+
|
|
412
|
+
await new Promise(r => setTimeout(r, 50));
|
|
413
|
+
|
|
414
|
+
// 2. Call gateway.dispatch
|
|
415
|
+
await gateway.dispatch('page#test123', { type: 'increment', payload: { by: 5 } });
|
|
416
|
+
|
|
417
|
+
await new Promise(r => setTimeout(r, 50));
|
|
418
|
+
|
|
419
|
+
// 3. Browser should receive dispatch message
|
|
420
|
+
const dispatchMsg = browserMessages.find(
|
|
421
|
+
m => (m as any).type === 'client.dispatch'
|
|
422
|
+
);
|
|
423
|
+
expect(dispatchMsg).toBeDefined();
|
|
424
|
+
expect((dispatchMsg as any).payload.action).toEqual({ type: 'increment', payload: { by: 5 } });
|
|
425
|
+
|
|
426
|
+
browserWs.close();
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it('should notify subscribers when store disconnects', async () => {
|
|
430
|
+
// 1. Browser registers
|
|
431
|
+
const browserWs = new WebSocket(`ws://localhost:${port}/_bridge?type=browser`);
|
|
432
|
+
attachAutoStateAppliedAck(browserWs);
|
|
433
|
+
await new Promise<void>((resolve) => browserWs.on('open', resolve));
|
|
434
|
+
|
|
435
|
+
browserWs.send(JSON.stringify({
|
|
436
|
+
type: 'store.register',
|
|
437
|
+
payload: {
|
|
438
|
+
storeId: 'page#test123',
|
|
439
|
+
pageId: 'test-page',
|
|
440
|
+
storeKey: 'main',
|
|
441
|
+
description: {
|
|
442
|
+
pageId: 'test-page',
|
|
443
|
+
storeKey: 'main',
|
|
444
|
+
schema: { type: 'object' },
|
|
445
|
+
actions: {},
|
|
446
|
+
},
|
|
447
|
+
initialState: { count: 0 },
|
|
448
|
+
},
|
|
449
|
+
}));
|
|
450
|
+
|
|
451
|
+
await new Promise(r => setTimeout(r, 50));
|
|
452
|
+
|
|
453
|
+
// 2. Client subscribes
|
|
454
|
+
const clientWs = new WebSocket(`ws://localhost:${port}/_bridge?type=client`);
|
|
455
|
+
const receivedMessages: unknown[] = [];
|
|
456
|
+
|
|
457
|
+
await new Promise<void>((resolve) => clientWs.on('open', resolve));
|
|
458
|
+
|
|
459
|
+
clientWs.on('message', (data) => {
|
|
460
|
+
receivedMessages.push(JSON.parse(data.toString()));
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
clientWs.send(JSON.stringify({
|
|
464
|
+
type: 'subscribe',
|
|
465
|
+
payload: { storeId: 'page#test123' },
|
|
466
|
+
}));
|
|
467
|
+
|
|
468
|
+
await new Promise(r => setTimeout(r, 50));
|
|
469
|
+
|
|
470
|
+
// 3. Browser disconnects
|
|
471
|
+
browserWs.close();
|
|
472
|
+
|
|
473
|
+
await new Promise(r => setTimeout(r, 100));
|
|
474
|
+
|
|
475
|
+
// 4. Client should receive disconnected message
|
|
476
|
+
const disconnectedMsg = receivedMessages.find(
|
|
477
|
+
m => (m as any).type === 'store.disconnected'
|
|
478
|
+
);
|
|
479
|
+
expect(disconnectedMsg).toBeDefined();
|
|
480
|
+
expect((disconnectedMsg as any).payload.storeId).toBe('page#test123');
|
|
481
|
+
|
|
482
|
+
clientWs.close();
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { EventEmitter } from 'events';
|
|
2
|
+
import type { WebSocket as WebSocketType } from 'ws';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Mock WebSocket for unit testing
|
|
6
|
+
*/
|
|
7
|
+
export class MockWebSocket extends EventEmitter implements Partial<WebSocketType> {
|
|
8
|
+
public readyState: number = 1; // OPEN
|
|
9
|
+
public static readonly CONNECTING = 0;
|
|
10
|
+
public static readonly OPEN = 1;
|
|
11
|
+
public static readonly CLOSING = 2;
|
|
12
|
+
public static readonly CLOSED = 3;
|
|
13
|
+
|
|
14
|
+
sentMessages: unknown[] = [];
|
|
15
|
+
url: string;
|
|
16
|
+
|
|
17
|
+
constructor(url: string) {
|
|
18
|
+
super();
|
|
19
|
+
this.url = url;
|
|
20
|
+
// Simulate async connection
|
|
21
|
+
setTimeout(() => this.emit('open'), 0);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
|
|
25
|
+
this.sentMessages.push(data);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
close(): void {
|
|
29
|
+
this.readyState = 3; // CLOSED
|
|
30
|
+
this.emit('close');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Helper to simulate receiving a message
|
|
34
|
+
simulateMessage(data: string): void {
|
|
35
|
+
this.emit('message', Buffer.from(data));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Helper to simulate error
|
|
39
|
+
simulateError(error: Error): void {
|
|
40
|
+
this.emit('error', error);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Create a mock WebSocket constructor
|
|
46
|
+
*/
|
|
47
|
+
export function createMockWebSocket() {
|
|
48
|
+
return MockWebSocket as unknown as typeof WebSocketType;
|
|
49
|
+
}
|