@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,854 @@
|
|
|
1
|
+
import { WebSocket, WebSocketServer } from 'ws';
|
|
2
|
+
import type { IncomingMessage, Server } from 'http';
|
|
3
|
+
import type { Http2SecureServer } from 'http2';
|
|
4
|
+
import type {
|
|
5
|
+
Gateway,
|
|
6
|
+
GatewayOptions,
|
|
7
|
+
RegisteredStore,
|
|
8
|
+
BrowserMessage,
|
|
9
|
+
ClientMessage,
|
|
10
|
+
SubscriberMessage,
|
|
11
|
+
StoreId,
|
|
12
|
+
} from './types.js';
|
|
13
|
+
import type { SetStateOptions, StateAppliedPayload, ClientSetStatePayload } from '../shared/types.js';
|
|
14
|
+
import { StoreRegistry } from './registry.js';
|
|
15
|
+
import { FileStore, VersionConflictError } from './fileStore.js';
|
|
16
|
+
import { logger } from '../utils/logger.js';
|
|
17
|
+
|
|
18
|
+
const DEFAULT_WS_PATH = '/_bridge';
|
|
19
|
+
const DEFAULT_HEARTBEAT_TIMEOUT = 60000;
|
|
20
|
+
const DEFAULT_ACK_TIMEOUT = 3000;
|
|
21
|
+
const DEFAULT_ACK_RETRY_COUNT = 1;
|
|
22
|
+
|
|
23
|
+
interface PendingAck {
|
|
24
|
+
storeId: StoreId;
|
|
25
|
+
resolve: (payload: StateAppliedPayload) => void;
|
|
26
|
+
reject: (error: Error) => void;
|
|
27
|
+
timer: ReturnType<typeof setTimeout>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createBridgeGateway(options: GatewayOptions = {}): Gateway {
|
|
31
|
+
const wsPath = options.wsPath || DEFAULT_WS_PATH;
|
|
32
|
+
const heartbeatTimeout = options.heartbeatTimeout || DEFAULT_HEARTBEAT_TIMEOUT;
|
|
33
|
+
const pagesDir = options.pagesDir || process.cwd();
|
|
34
|
+
const ackTimeout = options.ackTimeout ?? DEFAULT_ACK_TIMEOUT;
|
|
35
|
+
const ackRetryCount = options.ackRetryCount ?? DEFAULT_ACK_RETRY_COUNT;
|
|
36
|
+
|
|
37
|
+
const registry = new StoreRegistry();
|
|
38
|
+
const fileStore = new FileStore({ pagesDir });
|
|
39
|
+
const lastHeartbeat = new Map<StoreId, number>();
|
|
40
|
+
const fileUnsubscribers = new Map<string, () => void>();
|
|
41
|
+
const pageRefCount = new Map<string, number>();
|
|
42
|
+
const wsStores = new Map<WebSocket, Set<StoreId>>();
|
|
43
|
+
const pendingAcks = new Map<string, PendingAck>();
|
|
44
|
+
let ackSequence = 0;
|
|
45
|
+
|
|
46
|
+
function getStoreIdsByWs(ws: WebSocket): Set<StoreId> {
|
|
47
|
+
return wsStores.get(ws) ?? new Set<StoreId>();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function trackStoreOnWs(ws: WebSocket, storeId: StoreId): void {
|
|
51
|
+
if (!wsStores.has(ws)) {
|
|
52
|
+
wsStores.set(ws, new Set());
|
|
53
|
+
}
|
|
54
|
+
wsStores.get(ws)!.add(storeId);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function untrackStoreFromWs(ws: WebSocket, storeId: StoreId): void {
|
|
58
|
+
const ids = wsStores.get(ws);
|
|
59
|
+
if (!ids) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
ids.delete(storeId);
|
|
63
|
+
if (ids.size === 0) {
|
|
64
|
+
wsStores.delete(ws);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function setupFileWatcher(pageId: string): void {
|
|
69
|
+
if (fileUnsubscribers.has(pageId)) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const unsubscribe = fileStore.watch(pageId, (data) => {
|
|
74
|
+
void (async () => {
|
|
75
|
+
logger.debug('[Gateway] File changed, broadcasting to browsers', { pageId, version: data.version });
|
|
76
|
+
const stores = registry.findByPage(pageId);
|
|
77
|
+
if (stores.length === 0) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const result = await Promise.allSettled(
|
|
82
|
+
stores.map(async (store) => {
|
|
83
|
+
await sendSetStateWithAck(
|
|
84
|
+
store.id,
|
|
85
|
+
{
|
|
86
|
+
state: data.state,
|
|
87
|
+
version: data.version,
|
|
88
|
+
},
|
|
89
|
+
{ timeoutMs: ackTimeout }
|
|
90
|
+
);
|
|
91
|
+
store.currentState = data.state;
|
|
92
|
+
store.version = data.version;
|
|
93
|
+
})
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
for (const item of result) {
|
|
97
|
+
if (item.status === 'rejected') {
|
|
98
|
+
logger.error('[Gateway] Failed to apply file state to browser', {
|
|
99
|
+
pageId,
|
|
100
|
+
error: item.reason instanceof Error ? item.reason.message : String(item.reason),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
})();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
fileUnsubscribers.set(pageId, unsubscribe);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function retainPage(pageId: string): void {
|
|
111
|
+
const next = (pageRefCount.get(pageId) ?? 0) + 1;
|
|
112
|
+
pageRefCount.set(pageId, next);
|
|
113
|
+
if (next === 1) {
|
|
114
|
+
setupFileWatcher(pageId);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function releasePage(pageId: string): void {
|
|
119
|
+
const count = pageRefCount.get(pageId);
|
|
120
|
+
if (!count) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (count <= 1) {
|
|
124
|
+
pageRefCount.delete(pageId);
|
|
125
|
+
const unsubscribe = fileUnsubscribers.get(pageId);
|
|
126
|
+
if (unsubscribe) {
|
|
127
|
+
unsubscribe();
|
|
128
|
+
fileUnsubscribers.delete(pageId);
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
pageRefCount.set(pageId, count - 1);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function rejectPendingAcksForStore(storeId: StoreId, reason: string): void {
|
|
136
|
+
for (const [requestId, pending] of pendingAcks) {
|
|
137
|
+
if (pending.storeId !== storeId) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
clearTimeout(pending.timer);
|
|
141
|
+
pendingAcks.delete(requestId);
|
|
142
|
+
pending.reject(new Error(reason));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function disconnectStore(storeId: StoreId, reason: string): void {
|
|
147
|
+
const store = registry.get(storeId);
|
|
148
|
+
if (!store) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
registry.disconnect(storeId, reason);
|
|
153
|
+
lastHeartbeat.delete(storeId);
|
|
154
|
+
untrackStoreFromWs(store.ws, storeId);
|
|
155
|
+
releasePage(store.pageId);
|
|
156
|
+
rejectPendingAcksForStore(storeId, `Store disconnected: ${reason}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function disconnectAllStoresOnWs(ws: WebSocket, reason: string): void {
|
|
160
|
+
const storeIds = Array.from(getStoreIdsByWs(ws));
|
|
161
|
+
for (const storeId of storeIds) {
|
|
162
|
+
disconnectStore(storeId, reason);
|
|
163
|
+
}
|
|
164
|
+
wsStores.delete(ws);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function settleStateAppliedAck(payload: StateAppliedPayload): void {
|
|
168
|
+
const pending = pendingAcks.get(payload.requestId);
|
|
169
|
+
if (!pending) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
clearTimeout(pending.timer);
|
|
174
|
+
pendingAcks.delete(payload.requestId);
|
|
175
|
+
|
|
176
|
+
if (payload.status === 'applied') {
|
|
177
|
+
pending.resolve(payload);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const detail = payload.error ? `: ${payload.error}` : '';
|
|
182
|
+
pending.reject(new Error(`State apply failed (${payload.status})${detail}`));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function sendToBrowser(storeId: StoreId, message: ClientMessage): Promise<void> {
|
|
186
|
+
return new Promise((resolve, reject) => {
|
|
187
|
+
const store = registry.get(storeId);
|
|
188
|
+
if (!store) {
|
|
189
|
+
reject(new Error(`Store not found: ${storeId}`));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (store.ws.readyState !== WebSocket.OPEN) {
|
|
194
|
+
reject(new Error(`Store not connected: ${storeId}`));
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const data = JSON.stringify(message);
|
|
199
|
+
logger.wsMessage('out', 'browser', data);
|
|
200
|
+
|
|
201
|
+
store.ws.send(data, (err) => {
|
|
202
|
+
if (err) {
|
|
203
|
+
reject(err);
|
|
204
|
+
} else {
|
|
205
|
+
resolve();
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function waitForStateAppliedAck(
|
|
212
|
+
storeId: StoreId,
|
|
213
|
+
message: { type: 'client.setState'; payload: ClientSetStatePayload & { requestId: string } },
|
|
214
|
+
timeoutMs: number
|
|
215
|
+
): Promise<StateAppliedPayload> {
|
|
216
|
+
return new Promise<StateAppliedPayload>((resolve, reject) => {
|
|
217
|
+
const timer = setTimeout(() => {
|
|
218
|
+
pendingAcks.delete(message.payload.requestId);
|
|
219
|
+
reject(new Error(`ACK timeout for ${storeId}`));
|
|
220
|
+
}, timeoutMs);
|
|
221
|
+
|
|
222
|
+
pendingAcks.set(message.payload.requestId, {
|
|
223
|
+
storeId,
|
|
224
|
+
resolve,
|
|
225
|
+
reject,
|
|
226
|
+
timer,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
sendToBrowser(storeId, message).catch((error) => {
|
|
230
|
+
clearTimeout(timer);
|
|
231
|
+
pendingAcks.delete(message.payload.requestId);
|
|
232
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function sendSetStateWithAck(
|
|
238
|
+
storeId: StoreId,
|
|
239
|
+
payload: ClientSetStatePayload,
|
|
240
|
+
options: { timeoutMs?: number } = {}
|
|
241
|
+
): Promise<StateAppliedPayload> {
|
|
242
|
+
const timeoutMs = options.timeoutMs ?? ackTimeout;
|
|
243
|
+
const maxAttempts = ackRetryCount + 1;
|
|
244
|
+
|
|
245
|
+
let lastError: Error | null = null;
|
|
246
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
247
|
+
const requestId = `${storeId}:${Date.now()}:${++ackSequence}`;
|
|
248
|
+
const message: { type: 'client.setState'; payload: ClientSetStatePayload & { requestId: string } } = {
|
|
249
|
+
type: 'client.setState',
|
|
250
|
+
payload: {
|
|
251
|
+
...payload,
|
|
252
|
+
storeId,
|
|
253
|
+
requestId,
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
return await waitForStateAppliedAck(storeId, message, timeoutMs);
|
|
259
|
+
} catch (error) {
|
|
260
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
261
|
+
if (attempt < maxAttempts) {
|
|
262
|
+
logger.warn('[Gateway] setState ACK timeout, retrying', { storeId, attempt, maxAttempts });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
throw lastError ?? new Error(`Failed to apply state for ${storeId}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const cleanupInterval = setInterval(() => {
|
|
271
|
+
const now = Date.now();
|
|
272
|
+
for (const [id, last] of lastHeartbeat) {
|
|
273
|
+
if (now - last > heartbeatTimeout) {
|
|
274
|
+
logger.info('[Gateway] Store timed out', { storeId: id });
|
|
275
|
+
disconnectStore(id, 'timeout');
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
registry.cleanup();
|
|
279
|
+
}, 30000);
|
|
280
|
+
|
|
281
|
+
logger.info('[Gateway] Created new gateway instance', { pagesDir });
|
|
282
|
+
|
|
283
|
+
function handleBrowserMessage(ws: WebSocket, data: string): void {
|
|
284
|
+
logger.wsMessage('in', 'browser', data);
|
|
285
|
+
try {
|
|
286
|
+
const msg = JSON.parse(data) as BrowserMessage;
|
|
287
|
+
|
|
288
|
+
switch (msg.type) {
|
|
289
|
+
case 'store.register': {
|
|
290
|
+
const { storeId, pageId, storeKey, description, initialState } = msg.payload;
|
|
291
|
+
|
|
292
|
+
void fileStore.load(pageId).then(async (fileData) => {
|
|
293
|
+
const existing = registry.findStoreByKey(pageId, storeKey);
|
|
294
|
+
if (existing) {
|
|
295
|
+
disconnectStore(existing.id, 'replaced');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const stateToUse = fileData ? fileData.state : initialState;
|
|
299
|
+
const versionToUse = fileData ? fileData.version : 0;
|
|
300
|
+
|
|
301
|
+
const store: RegisteredStore = {
|
|
302
|
+
id: storeId,
|
|
303
|
+
pageId,
|
|
304
|
+
storeKey,
|
|
305
|
+
description,
|
|
306
|
+
currentState: stateToUse,
|
|
307
|
+
version: versionToUse,
|
|
308
|
+
ws,
|
|
309
|
+
subscribers: new Set(),
|
|
310
|
+
connectedAt: new Date(),
|
|
311
|
+
lastActivity: new Date(),
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
registry.register(store);
|
|
315
|
+
lastHeartbeat.set(storeId, Date.now());
|
|
316
|
+
trackStoreOnWs(ws, storeId);
|
|
317
|
+
retainPage(pageId);
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
await sendSetStateWithAck(storeId, {
|
|
321
|
+
state: stateToUse,
|
|
322
|
+
version: versionToUse,
|
|
323
|
+
});
|
|
324
|
+
} catch (error) {
|
|
325
|
+
logger.error('[Gateway] Failed to hydrate browser store', {
|
|
326
|
+
storeId,
|
|
327
|
+
error: error instanceof Error ? error.message : String(error),
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
logger.info('[Gateway] Store registered', { storeId, pageId, storeKey });
|
|
332
|
+
});
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
case 'store.stateChanged': {
|
|
337
|
+
const { storeId, state, version } = msg.payload;
|
|
338
|
+
const store = registry.get(storeId);
|
|
339
|
+
if (!store) {
|
|
340
|
+
logger.warn('[Gateway] State change for unknown store', { storeId });
|
|
341
|
+
break;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const expectedVersion = typeof version === 'number' ? Math.max(version - 1, 0) : undefined;
|
|
345
|
+
|
|
346
|
+
void fileStore
|
|
347
|
+
.save(
|
|
348
|
+
store.pageId,
|
|
349
|
+
{
|
|
350
|
+
state,
|
|
351
|
+
version: store.version,
|
|
352
|
+
updatedAt: new Date().toISOString(),
|
|
353
|
+
pageId: store.pageId,
|
|
354
|
+
},
|
|
355
|
+
expectedVersion
|
|
356
|
+
)
|
|
357
|
+
.then((saved) => {
|
|
358
|
+
registry.updateState(storeId, state, saved.version);
|
|
359
|
+
store.lastActivity = new Date();
|
|
360
|
+
|
|
361
|
+
const notification: SubscriberMessage = {
|
|
362
|
+
type: 'store.stateChanged',
|
|
363
|
+
payload: {
|
|
364
|
+
storeId,
|
|
365
|
+
state,
|
|
366
|
+
version: saved.version,
|
|
367
|
+
source: 'browser',
|
|
368
|
+
},
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
for (const sub of store.subscribers) {
|
|
372
|
+
if (sub.readyState === WebSocket.OPEN) {
|
|
373
|
+
sub.send(JSON.stringify(notification));
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
})
|
|
377
|
+
.catch(async (error) => {
|
|
378
|
+
if (error instanceof VersionConflictError) {
|
|
379
|
+
logger.warn('[Gateway] Version conflict from browser update', {
|
|
380
|
+
storeId,
|
|
381
|
+
expectedVersion: error.expectedVersion,
|
|
382
|
+
actualVersion: error.actualVersion,
|
|
383
|
+
});
|
|
384
|
+
const latest = await fileStore.load(store.pageId);
|
|
385
|
+
if (latest) {
|
|
386
|
+
try {
|
|
387
|
+
await sendSetStateWithAck(storeId, {
|
|
388
|
+
state: latest.state,
|
|
389
|
+
version: latest.version,
|
|
390
|
+
});
|
|
391
|
+
} catch (ackError) {
|
|
392
|
+
logger.error('[Gateway] Failed to reconcile browser after conflict', {
|
|
393
|
+
storeId,
|
|
394
|
+
error: ackError instanceof Error ? ackError.message : String(ackError),
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
logger.error('[Gateway] Failed to save state to file', {
|
|
402
|
+
storeId,
|
|
403
|
+
error: error instanceof Error ? error.message : String(error),
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
case 'store.stateApplied': {
|
|
410
|
+
settleStateAppliedAck(msg.payload);
|
|
411
|
+
const store = registry.get(msg.payload.storeId);
|
|
412
|
+
if (store) {
|
|
413
|
+
store.lastActivity = new Date();
|
|
414
|
+
lastHeartbeat.set(store.id, Date.now());
|
|
415
|
+
}
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
case 'store.heartbeat': {
|
|
420
|
+
const now = Date.now();
|
|
421
|
+
for (const storeId of getStoreIdsByWs(ws)) {
|
|
422
|
+
const store = registry.get(storeId);
|
|
423
|
+
if (!store) {
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
lastHeartbeat.set(storeId, now);
|
|
427
|
+
store.lastActivity = new Date(now);
|
|
428
|
+
}
|
|
429
|
+
break;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
case 'store.disconnect': {
|
|
433
|
+
disconnectAllStoresOnWs(ws, 'client_disconnect');
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
} catch (err) {
|
|
438
|
+
logger.error('[Gateway] Failed to handle browser message', err);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function handleClientMessage(ws: WebSocket, data: string): void {
|
|
443
|
+
logger.wsMessage('in', 'client', data);
|
|
444
|
+
|
|
445
|
+
try {
|
|
446
|
+
const msg = JSON.parse(data);
|
|
447
|
+
|
|
448
|
+
if (typeof msg?.id === 'number' && typeof msg?.method === 'string') {
|
|
449
|
+
const id: number = msg.id;
|
|
450
|
+
const method: string = msg.method;
|
|
451
|
+
const params: unknown = msg.params;
|
|
452
|
+
|
|
453
|
+
void (async () => {
|
|
454
|
+
try {
|
|
455
|
+
switch (method) {
|
|
456
|
+
case 'listStores': {
|
|
457
|
+
ws.send(JSON.stringify({ id, result: gateway.listStores() }));
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
case 'findStoreByKey': {
|
|
462
|
+
const p = params as { pageId?: unknown; storeKey?: unknown } | null;
|
|
463
|
+
const pageId = p?.pageId;
|
|
464
|
+
const storeKey = p?.storeKey;
|
|
465
|
+
if (typeof pageId !== 'string' || typeof storeKey !== 'string') {
|
|
466
|
+
throw new Error('Invalid params: pageId and storeKey are required');
|
|
467
|
+
}
|
|
468
|
+
const found = gateway.findStoreByKey(pageId, storeKey);
|
|
469
|
+
ws.send(
|
|
470
|
+
JSON.stringify({
|
|
471
|
+
id,
|
|
472
|
+
result: found
|
|
473
|
+
? {
|
|
474
|
+
id: found.id,
|
|
475
|
+
pageId: found.pageId,
|
|
476
|
+
storeKey: found.storeKey,
|
|
477
|
+
version: found.version,
|
|
478
|
+
connectedAt: found.connectedAt,
|
|
479
|
+
}
|
|
480
|
+
: null,
|
|
481
|
+
})
|
|
482
|
+
);
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
case 'describe': {
|
|
487
|
+
const storeId = (params as { storeId?: unknown } | null)?.storeId;
|
|
488
|
+
if (typeof storeId !== 'string') {
|
|
489
|
+
throw new Error('Invalid params: storeId');
|
|
490
|
+
}
|
|
491
|
+
const description = gateway.getDescription(storeId) ?? null;
|
|
492
|
+
ws.send(JSON.stringify({ id, result: description }));
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
case 'getState': {
|
|
497
|
+
const p = params as { pageId?: unknown; storeKey?: unknown; storeId?: unknown } | null;
|
|
498
|
+
const pageId = p?.pageId as string | undefined;
|
|
499
|
+
const storeKey = p?.storeKey as string | undefined;
|
|
500
|
+
const storeId = p?.storeId as string | undefined;
|
|
501
|
+
|
|
502
|
+
if (pageId) {
|
|
503
|
+
const key = storeKey ?? 'main';
|
|
504
|
+
const activeStore = gateway.findStoreByKey(pageId, key);
|
|
505
|
+
if (activeStore) {
|
|
506
|
+
ws.send(
|
|
507
|
+
JSON.stringify({
|
|
508
|
+
id,
|
|
509
|
+
result: { state: activeStore.currentState, version: activeStore.version },
|
|
510
|
+
})
|
|
511
|
+
);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (key !== 'main') {
|
|
516
|
+
ws.send(JSON.stringify({ id, result: null }));
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const fileData = await fileStore.load(pageId);
|
|
521
|
+
ws.send(
|
|
522
|
+
JSON.stringify({
|
|
523
|
+
id,
|
|
524
|
+
result: fileData ? { state: fileData.state, version: fileData.version } : null,
|
|
525
|
+
})
|
|
526
|
+
);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (storeId) {
|
|
531
|
+
ws.send(JSON.stringify({ id, result: gateway.getState(storeId) ?? null }));
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
throw new Error('Invalid params: need pageId or storeId');
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
case 'setState': {
|
|
539
|
+
const p = params as {
|
|
540
|
+
pageId?: unknown;
|
|
541
|
+
storeKey?: unknown;
|
|
542
|
+
storeId?: unknown;
|
|
543
|
+
state?: unknown;
|
|
544
|
+
expectedVersion?: unknown;
|
|
545
|
+
waitForAck?: unknown;
|
|
546
|
+
timeoutMs?: unknown;
|
|
547
|
+
} | null;
|
|
548
|
+
const pageId = p?.pageId as string | undefined;
|
|
549
|
+
const storeKey = p?.storeKey as string | undefined;
|
|
550
|
+
const storeId = p?.storeId as string | undefined;
|
|
551
|
+
const state = p?.state;
|
|
552
|
+
const options: SetStateOptions = {
|
|
553
|
+
expectedVersion: typeof p?.expectedVersion === 'number' ? p.expectedVersion : undefined,
|
|
554
|
+
waitForAck: p?.waitForAck === true,
|
|
555
|
+
timeoutMs: typeof p?.timeoutMs === 'number' ? p.timeoutMs : undefined,
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
if (pageId) {
|
|
559
|
+
const saved = await fileStore.save(
|
|
560
|
+
pageId,
|
|
561
|
+
{
|
|
562
|
+
state,
|
|
563
|
+
version: 0,
|
|
564
|
+
updatedAt: new Date().toISOString(),
|
|
565
|
+
pageId,
|
|
566
|
+
},
|
|
567
|
+
options.expectedVersion
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
const targets = storeKey
|
|
571
|
+
? [registry.findStoreByKey(pageId, storeKey)].filter(
|
|
572
|
+
(store): store is RegisteredStore => Boolean(store)
|
|
573
|
+
)
|
|
574
|
+
: registry.findByPage(pageId);
|
|
575
|
+
|
|
576
|
+
if (options.waitForAck && targets.length === 0) {
|
|
577
|
+
throw new Error(`No connected browser for ${pageId}${storeKey ? `:${storeKey}` : ''}`);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
for (const store of targets) {
|
|
581
|
+
store.currentState = state;
|
|
582
|
+
store.version = saved.version;
|
|
583
|
+
|
|
584
|
+
if (options.waitForAck) {
|
|
585
|
+
await sendSetStateWithAck(
|
|
586
|
+
store.id,
|
|
587
|
+
{
|
|
588
|
+
state,
|
|
589
|
+
version: saved.version,
|
|
590
|
+
},
|
|
591
|
+
{ timeoutMs: options.timeoutMs }
|
|
592
|
+
);
|
|
593
|
+
} else {
|
|
594
|
+
void sendSetStateWithAck(
|
|
595
|
+
store.id,
|
|
596
|
+
{
|
|
597
|
+
state,
|
|
598
|
+
version: saved.version,
|
|
599
|
+
},
|
|
600
|
+
{ timeoutMs: options.timeoutMs }
|
|
601
|
+
).catch((error) => {
|
|
602
|
+
logger.error('[Gateway] Failed to deliver setState', {
|
|
603
|
+
storeId: store.id,
|
|
604
|
+
error: error instanceof Error ? error.message : String(error),
|
|
605
|
+
});
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
ws.send(JSON.stringify({ id, result: { version: saved.version } }));
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (storeId) {
|
|
615
|
+
await gateway.setState(storeId, state, options);
|
|
616
|
+
ws.send(JSON.stringify({ id, result: null }));
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
throw new Error('Invalid params: need pageId or storeId');
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
case 'dispatch': {
|
|
624
|
+
const p = params as { storeId?: unknown; action?: unknown } | null;
|
|
625
|
+
const storeId = p?.storeId;
|
|
626
|
+
if (typeof storeId !== 'string') {
|
|
627
|
+
throw new Error('Invalid params: storeId');
|
|
628
|
+
}
|
|
629
|
+
const action = p?.action as { type?: unknown; payload?: unknown } | undefined;
|
|
630
|
+
if (!action || typeof action.type !== 'string') {
|
|
631
|
+
throw new Error('Invalid params: action');
|
|
632
|
+
}
|
|
633
|
+
await gateway.dispatch(storeId, { type: action.type, payload: action.payload });
|
|
634
|
+
ws.send(JSON.stringify({ id, result: null }));
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
default:
|
|
639
|
+
throw new Error(`Unknown method: ${method}`);
|
|
640
|
+
}
|
|
641
|
+
} catch (error) {
|
|
642
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
643
|
+
ws.send(JSON.stringify({ id, error: { message } }));
|
|
644
|
+
}
|
|
645
|
+
})();
|
|
646
|
+
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (msg.type === 'subscribe' && msg.payload?.storeId) {
|
|
651
|
+
try {
|
|
652
|
+
const unsubscribe = registry.addSubscriber(msg.payload.storeId, ws);
|
|
653
|
+
(ws as unknown as { _unsubscribe: () => void })._unsubscribe = unsubscribe;
|
|
654
|
+
ws.send(JSON.stringify({ type: 'subscribed', payload: { storeId: msg.payload.storeId } }));
|
|
655
|
+
} catch (err) {
|
|
656
|
+
logger.error('[Gateway] Failed to subscribe client to store', { storeId: msg.payload.storeId, error: err instanceof Error ? err.message : String(err) });
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if (msg.type === 'unsubscribe' && msg.payload?.storeId) {
|
|
661
|
+
const store = registry.get(msg.payload.storeId);
|
|
662
|
+
if (store) {
|
|
663
|
+
store.subscribers.delete(ws);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
} catch (err) {
|
|
667
|
+
logger.error('[Gateway] Failed to handle client message', err);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const gateway: Gateway = {
|
|
672
|
+
get stores() {
|
|
673
|
+
return registry.list().reduce((map, store) => {
|
|
674
|
+
map.set(store.id, store);
|
|
675
|
+
return map;
|
|
676
|
+
}, new Map<StoreId, RegisteredStore>());
|
|
677
|
+
},
|
|
678
|
+
|
|
679
|
+
listStores() {
|
|
680
|
+
return registry.list().map((s) => ({
|
|
681
|
+
id: s.id,
|
|
682
|
+
pageId: s.pageId,
|
|
683
|
+
storeKey: s.storeKey,
|
|
684
|
+
version: s.version,
|
|
685
|
+
connectedAt: s.connectedAt,
|
|
686
|
+
}));
|
|
687
|
+
},
|
|
688
|
+
|
|
689
|
+
getStore(id: StoreId) {
|
|
690
|
+
return registry.get(id);
|
|
691
|
+
},
|
|
692
|
+
|
|
693
|
+
findStore(pageId, storeKey) {
|
|
694
|
+
return registry.find(pageId, storeKey);
|
|
695
|
+
},
|
|
696
|
+
|
|
697
|
+
findStoreByKey(pageId, storeKey) {
|
|
698
|
+
return registry.findStoreByKey(pageId, storeKey);
|
|
699
|
+
},
|
|
700
|
+
|
|
701
|
+
getDescription(id: StoreId) {
|
|
702
|
+
return registry.get(id)?.description;
|
|
703
|
+
},
|
|
704
|
+
|
|
705
|
+
getState(id: StoreId) {
|
|
706
|
+
const store = registry.get(id);
|
|
707
|
+
if (!store) {
|
|
708
|
+
return undefined;
|
|
709
|
+
}
|
|
710
|
+
return { state: store.currentState, version: store.version };
|
|
711
|
+
},
|
|
712
|
+
|
|
713
|
+
async setState(id, state, options = {}) {
|
|
714
|
+
const store = registry.get(id);
|
|
715
|
+
if (!store) {
|
|
716
|
+
throw new Error(`Store not found: ${id}`);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const saved = await fileStore.save(
|
|
720
|
+
store.pageId,
|
|
721
|
+
{
|
|
722
|
+
state,
|
|
723
|
+
version: store.version,
|
|
724
|
+
updatedAt: new Date().toISOString(),
|
|
725
|
+
pageId: store.pageId,
|
|
726
|
+
},
|
|
727
|
+
options.expectedVersion
|
|
728
|
+
);
|
|
729
|
+
|
|
730
|
+
registry.updateState(id, state, saved.version);
|
|
731
|
+
await sendSetStateWithAck(
|
|
732
|
+
id,
|
|
733
|
+
{
|
|
734
|
+
state,
|
|
735
|
+
version: saved.version,
|
|
736
|
+
},
|
|
737
|
+
{ timeoutMs: options.timeoutMs }
|
|
738
|
+
);
|
|
739
|
+
},
|
|
740
|
+
|
|
741
|
+
async dispatch(id, action) {
|
|
742
|
+
const store = registry.get(id);
|
|
743
|
+
if (!store) {
|
|
744
|
+
throw new Error(`Store not found: ${id}`);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
await sendToBrowser(id, {
|
|
748
|
+
type: 'client.dispatch',
|
|
749
|
+
payload: { action },
|
|
750
|
+
});
|
|
751
|
+
},
|
|
752
|
+
|
|
753
|
+
subscribe(id, ws, callback) {
|
|
754
|
+
const unsubscribe = registry.addSubscriber(id, ws);
|
|
755
|
+
|
|
756
|
+
if (callback) {
|
|
757
|
+
const handler = registry.onChange((event) => {
|
|
758
|
+
if (event.storeId === id) {
|
|
759
|
+
callback(event);
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
return () => {
|
|
764
|
+
unsubscribe();
|
|
765
|
+
handler();
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
return unsubscribe;
|
|
770
|
+
},
|
|
771
|
+
|
|
772
|
+
attach(server: Server | Http2SecureServer) {
|
|
773
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
774
|
+
const httpServer = server as Server;
|
|
775
|
+
|
|
776
|
+
httpServer.prependListener('upgrade', (request, socket, head) => {
|
|
777
|
+
const pathname = request.url?.split('?')[0] || '/';
|
|
778
|
+
if (pathname !== wsPath) {
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
783
|
+
wss.emit('connection', ws, request);
|
|
784
|
+
});
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
wss.on('connection', (ws, req: IncomingMessage) => {
|
|
788
|
+
let clientType: 'browser' | 'client' | 'unknown' = 'unknown';
|
|
789
|
+
|
|
790
|
+
logger.info('[Gateway] New WebSocket connection', { ip: req.socket.remoteAddress, url: req.url });
|
|
791
|
+
|
|
792
|
+
ws.on('message', (data) => {
|
|
793
|
+
const str = data.toString('utf8');
|
|
794
|
+
|
|
795
|
+
if (clientType === 'unknown') {
|
|
796
|
+
try {
|
|
797
|
+
const msg = JSON.parse(str);
|
|
798
|
+
if (msg.type?.startsWith('store.')) {
|
|
799
|
+
clientType = 'browser';
|
|
800
|
+
} else if (msg.id !== undefined && msg.method) {
|
|
801
|
+
clientType = 'client';
|
|
802
|
+
} else if (msg.type === 'subscribe' || msg.type === 'unsubscribe') {
|
|
803
|
+
clientType = 'client';
|
|
804
|
+
}
|
|
805
|
+
} catch {
|
|
806
|
+
// Ignore malformed data.
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
if (clientType === 'browser') {
|
|
811
|
+
handleBrowserMessage(ws, str);
|
|
812
|
+
} else {
|
|
813
|
+
handleClientMessage(ws, str);
|
|
814
|
+
}
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
ws.on('close', () => {
|
|
818
|
+
disconnectAllStoresOnWs(ws, 'connection_closed');
|
|
819
|
+
const unsubscribe = (ws as unknown as { _unsubscribe?: () => void })._unsubscribe;
|
|
820
|
+
if (unsubscribe) {
|
|
821
|
+
unsubscribe();
|
|
822
|
+
}
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
ws.on('error', (err) => {
|
|
826
|
+
logger.error('[Gateway] WebSocket error', err);
|
|
827
|
+
});
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
return wss;
|
|
831
|
+
},
|
|
832
|
+
|
|
833
|
+
destroy() {
|
|
834
|
+
clearInterval(cleanupInterval);
|
|
835
|
+
for (const store of registry.list()) {
|
|
836
|
+
disconnectStore(store.id, 'server_shutdown');
|
|
837
|
+
}
|
|
838
|
+
for (const pending of pendingAcks.values()) {
|
|
839
|
+
clearTimeout(pending.timer);
|
|
840
|
+
pending.reject(new Error('Gateway destroyed'));
|
|
841
|
+
}
|
|
842
|
+
pendingAcks.clear();
|
|
843
|
+
for (const unsubscribe of fileUnsubscribers.values()) {
|
|
844
|
+
unsubscribe();
|
|
845
|
+
}
|
|
846
|
+
fileUnsubscribers.clear();
|
|
847
|
+
pageRefCount.clear();
|
|
848
|
+
wsStores.clear();
|
|
849
|
+
fileStore.destroy();
|
|
850
|
+
},
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
return gateway;
|
|
854
|
+
}
|