@arcote.tech/arc-host 0.1.11 → 0.3.1
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/dist/index.d.ts +8 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4433 -425
- package/dist/index.js.map +16 -14
- package/dist/sqliteAdapter.d.ts +1 -1
- package/dist/sqliteAdapter.d.ts.map +1 -1
- package/dist/src/arc-host.d.ts +69 -0
- package/dist/src/arc-host.d.ts.map +1 -0
- package/dist/src/connection-manager.d.ts +50 -0
- package/dist/src/connection-manager.d.ts.map +1 -0
- package/dist/src/context-handler.d.ts +55 -0
- package/dist/src/context-handler.d.ts.map +1 -0
- package/dist/src/event-auth.d.ts +19 -0
- package/dist/src/event-auth.d.ts.map +1 -0
- package/dist/src/index.d.ts +7 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/types.d.ts +100 -0
- package/dist/src/types.d.ts.map +1 -0
- package/index.ts +18 -7
- package/package.json +2 -1
- package/sqliteAdapter.ts +4 -29
- package/src/arc-host.ts +646 -0
- package/src/connection-manager.ts +115 -0
- package/src/context-handler.ts +219 -0
- package/src/event-auth.ts +127 -0
- package/src/index.ts +24 -0
- package/src/types.ts +112 -0
- package/dist/host.d.ts +0 -45
- package/dist/host.d.ts.map +0 -1
- package/dist/postgresAdapter.d.ts +0 -3
- package/dist/postgresAdapter.d.ts.map +0 -1
- package/host.ts +0 -510
- package/postgresAdapter.ts +0 -50
package/src/arc-host.ts
ADDED
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
import { liveQuery } from "@arcote.tech/arc";
|
|
2
|
+
import type { Server, ServerWebSocket } from "bun";
|
|
3
|
+
import jwt from "jsonwebtoken";
|
|
4
|
+
import { ConnectionManager } from "./connection-manager";
|
|
5
|
+
import { ContextHandler } from "./context-handler";
|
|
6
|
+
import { filterEventsForToken } from "./event-auth";
|
|
7
|
+
import type {
|
|
8
|
+
ArcHostConfig,
|
|
9
|
+
ClientToHostMessage,
|
|
10
|
+
HostToClientMessage,
|
|
11
|
+
TokenPayload,
|
|
12
|
+
} from "./types";
|
|
13
|
+
|
|
14
|
+
type WebSocketData = {
|
|
15
|
+
clientId: string;
|
|
16
|
+
token: TokenPayload | null;
|
|
17
|
+
rawToken: string | null;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Active SSE stream connection
|
|
22
|
+
*/
|
|
23
|
+
interface StreamConnection {
|
|
24
|
+
id: string;
|
|
25
|
+
controller: ReadableStreamDefaultController<Uint8Array>;
|
|
26
|
+
unsubscribe: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Arc Host - WebSocket server for real-time event sync
|
|
31
|
+
*/
|
|
32
|
+
export class ArcHost {
|
|
33
|
+
private server!: Server<WebSocketData>;
|
|
34
|
+
private connectionManager = new ConnectionManager();
|
|
35
|
+
private contextHandler!: ContextHandler;
|
|
36
|
+
private streamConnections = new Map<string, StreamConnection>();
|
|
37
|
+
private jwtSecret: string;
|
|
38
|
+
private port: number;
|
|
39
|
+
private streamIdCounter = 0;
|
|
40
|
+
|
|
41
|
+
constructor(private config: ArcHostConfig) {
|
|
42
|
+
this.jwtSecret =
|
|
43
|
+
config.jwtSecret ||
|
|
44
|
+
process.env.JWT_SECRET ||
|
|
45
|
+
"arc-host-secret-change-in-production";
|
|
46
|
+
this.port = config.port || 5005;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Start the host server
|
|
51
|
+
*/
|
|
52
|
+
async start(): Promise<void> {
|
|
53
|
+
// Initialize context handler
|
|
54
|
+
const dbAdapter = this.config.dbAdapterFactory(this.config.context);
|
|
55
|
+
this.contextHandler = new ContextHandler(this.config.context, dbAdapter);
|
|
56
|
+
await this.contextHandler.init();
|
|
57
|
+
|
|
58
|
+
this.setupServer();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Verify JWT token
|
|
63
|
+
* Supports both standard JWT (jsonwebtoken) and Arc's custom JWT format
|
|
64
|
+
*/
|
|
65
|
+
private verifyToken(token: string): TokenPayload | null {
|
|
66
|
+
try {
|
|
67
|
+
// Try standard JWT first
|
|
68
|
+
const decoded = jwt.verify(token, this.jwtSecret) as any;
|
|
69
|
+
|
|
70
|
+
// Handle Arc's custom JWT format (tokenName -> tokenType)
|
|
71
|
+
if (decoded.tokenName && !decoded.tokenType) {
|
|
72
|
+
return {
|
|
73
|
+
tokenType: decoded.tokenName,
|
|
74
|
+
params: decoded.params || {},
|
|
75
|
+
iat: decoded.iat,
|
|
76
|
+
exp: decoded.exp,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return decoded as TokenPayload;
|
|
81
|
+
} catch {
|
|
82
|
+
// Try Arc's custom JWT format (base64 encoded, custom signature)
|
|
83
|
+
try {
|
|
84
|
+
const parts = token.split(".");
|
|
85
|
+
if (parts.length !== 3) return null;
|
|
86
|
+
|
|
87
|
+
const payload = JSON.parse(atob(parts[1]));
|
|
88
|
+
|
|
89
|
+
// TODO: Verify signature with context's token secret
|
|
90
|
+
// For now, just decode and trust (signature verification should be added)
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
tokenType: payload.tokenName,
|
|
94
|
+
params: payload.params || {},
|
|
95
|
+
iat: payload.iat,
|
|
96
|
+
exp: payload.exp,
|
|
97
|
+
};
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Handle WebSocket message
|
|
106
|
+
*/
|
|
107
|
+
private async handleMessage(
|
|
108
|
+
ws: ServerWebSocket<WebSocketData>,
|
|
109
|
+
message: ClientToHostMessage,
|
|
110
|
+
): Promise<void> {
|
|
111
|
+
const client = this.connectionManager.getClientByWs(ws);
|
|
112
|
+
if (!client) {
|
|
113
|
+
this.sendError(ws, "Client not found");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
switch (message.type) {
|
|
118
|
+
case "sync-events":
|
|
119
|
+
await this.handleSyncEvents(client.id, message, client.token);
|
|
120
|
+
break;
|
|
121
|
+
|
|
122
|
+
case "request-sync":
|
|
123
|
+
await this.handleRequestSync(client.id, message, client.token);
|
|
124
|
+
break;
|
|
125
|
+
|
|
126
|
+
case "execute-command":
|
|
127
|
+
await this.handleExecuteCommand(client.id, message, client.rawToken);
|
|
128
|
+
break;
|
|
129
|
+
|
|
130
|
+
default:
|
|
131
|
+
this.sendError(ws, `Unknown message type: ${(message as any).type}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Handle sync-events message
|
|
137
|
+
*/
|
|
138
|
+
private async handleSyncEvents(
|
|
139
|
+
clientId: string,
|
|
140
|
+
message: Extract<ClientToHostMessage, { type: "sync-events" }>,
|
|
141
|
+
token: TokenPayload | null,
|
|
142
|
+
): Promise<void> {
|
|
143
|
+
// Persist events
|
|
144
|
+
const persistedEvents = await this.contextHandler.persistEvents(
|
|
145
|
+
message.events,
|
|
146
|
+
clientId,
|
|
147
|
+
token,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
if (persistedEvents.length === 0) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Broadcast to other authorized clients
|
|
155
|
+
const clients = this.connectionManager.getAllClients();
|
|
156
|
+
|
|
157
|
+
for (const client of clients) {
|
|
158
|
+
if (client.id === clientId) continue;
|
|
159
|
+
|
|
160
|
+
// Filter events for this client's token
|
|
161
|
+
const authorizedEvents = filterEventsForToken(
|
|
162
|
+
client.token,
|
|
163
|
+
persistedEvents,
|
|
164
|
+
this.contextHandler.getEventDefinitions(),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
if (authorizedEvents.length > 0) {
|
|
168
|
+
this.connectionManager.sendToClient(client.id, {
|
|
169
|
+
type: "events",
|
|
170
|
+
events: authorizedEvents,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Update sender's last synced ID
|
|
176
|
+
const lastEvent = persistedEvents[persistedEvents.length - 1];
|
|
177
|
+
this.connectionManager.updateLastSyncedEventId(clientId, lastEvent.hostId);
|
|
178
|
+
|
|
179
|
+
// Confirm sync to sender
|
|
180
|
+
this.connectionManager.sendToClient(clientId, {
|
|
181
|
+
type: "sync-complete",
|
|
182
|
+
lastHostEventId: lastEvent.hostId,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Handle request-sync message (initial sync or catch-up)
|
|
188
|
+
*/
|
|
189
|
+
private async handleRequestSync(
|
|
190
|
+
clientId: string,
|
|
191
|
+
message: Extract<ClientToHostMessage, { type: "request-sync" }>,
|
|
192
|
+
token: TokenPayload | null,
|
|
193
|
+
): Promise<void> {
|
|
194
|
+
const events = await this.contextHandler.getEventsSince(
|
|
195
|
+
message.lastHostEventId,
|
|
196
|
+
token,
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
this.connectionManager.sendToClient(clientId, {
|
|
200
|
+
type: "events",
|
|
201
|
+
events,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
if (events.length > 0) {
|
|
205
|
+
const lastEvent = events[events.length - 1];
|
|
206
|
+
this.connectionManager.updateLastSyncedEventId(
|
|
207
|
+
clientId,
|
|
208
|
+
lastEvent.hostId,
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
this.connectionManager.sendToClient(clientId, {
|
|
212
|
+
type: "sync-complete",
|
|
213
|
+
lastHostEventId: lastEvent.hostId,
|
|
214
|
+
});
|
|
215
|
+
} else {
|
|
216
|
+
this.connectionManager.sendToClient(clientId, {
|
|
217
|
+
type: "sync-complete",
|
|
218
|
+
lastHostEventId: message.lastHostEventId || "",
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Handle execute-command message
|
|
225
|
+
*/
|
|
226
|
+
private async handleExecuteCommand(
|
|
227
|
+
clientId: string,
|
|
228
|
+
message: Extract<ClientToHostMessage, { type: "execute-command" }>,
|
|
229
|
+
rawToken: string | null,
|
|
230
|
+
): Promise<void> {
|
|
231
|
+
try {
|
|
232
|
+
const result = await this.contextHandler.executeCommand(
|
|
233
|
+
message.commandName,
|
|
234
|
+
message.params,
|
|
235
|
+
rawToken,
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
this.connectionManager.sendToClient(clientId, {
|
|
239
|
+
type: "command-result",
|
|
240
|
+
requestId: message.requestId,
|
|
241
|
+
result,
|
|
242
|
+
});
|
|
243
|
+
} catch (error) {
|
|
244
|
+
this.connectionManager.sendToClient(clientId, {
|
|
245
|
+
type: "command-result",
|
|
246
|
+
requestId: message.requestId,
|
|
247
|
+
error: (error as Error).message,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Send error to WebSocket
|
|
254
|
+
*/
|
|
255
|
+
private sendError(ws: ServerWebSocket<WebSocketData>, message: string): void {
|
|
256
|
+
ws.send(JSON.stringify({ type: "error", message } as HostToClientMessage));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Setup Bun server
|
|
261
|
+
*/
|
|
262
|
+
private setupServer(): void {
|
|
263
|
+
const self = this;
|
|
264
|
+
|
|
265
|
+
this.server = Bun.serve<WebSocketData>({
|
|
266
|
+
port: this.port,
|
|
267
|
+
|
|
268
|
+
fetch(req, server) {
|
|
269
|
+
const url = new URL(req.url);
|
|
270
|
+
|
|
271
|
+
// CORS headers
|
|
272
|
+
const corsHeaders = {
|
|
273
|
+
"Access-Control-Allow-Origin": "*",
|
|
274
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
275
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
// Handle preflight
|
|
279
|
+
if (req.method === "OPTIONS") {
|
|
280
|
+
return new Response(null, { headers: corsHeaders });
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Extract token
|
|
284
|
+
const authHeader = req.headers.get("Authorization");
|
|
285
|
+
const token =
|
|
286
|
+
authHeader?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
287
|
+
|
|
288
|
+
// Verify token
|
|
289
|
+
let tokenPayload: TokenPayload | null = null;
|
|
290
|
+
if (token) {
|
|
291
|
+
tokenPayload = self.verifyToken(token);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// WebSocket upgrade for /ws
|
|
295
|
+
if (
|
|
296
|
+
url.pathname === "/ws" &&
|
|
297
|
+
req.headers.get("Upgrade") === "websocket"
|
|
298
|
+
) {
|
|
299
|
+
// Pass token to WebSocket data
|
|
300
|
+
if (
|
|
301
|
+
server.upgrade(req, {
|
|
302
|
+
data: {
|
|
303
|
+
clientId: "",
|
|
304
|
+
token: tokenPayload,
|
|
305
|
+
rawToken: token,
|
|
306
|
+
},
|
|
307
|
+
})
|
|
308
|
+
) {
|
|
309
|
+
// Connection will be set up in websocket.open
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
return new Response("WebSocket upgrade failed", {
|
|
313
|
+
status: 500,
|
|
314
|
+
headers: corsHeaders,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Health check
|
|
319
|
+
if (url.pathname === "/health") {
|
|
320
|
+
return new Response(
|
|
321
|
+
JSON.stringify({
|
|
322
|
+
status: "ok",
|
|
323
|
+
clients: self.connectionManager.clientCount,
|
|
324
|
+
}),
|
|
325
|
+
{
|
|
326
|
+
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
327
|
+
},
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// HTTP command endpoint: POST /command/:commandName
|
|
332
|
+
if (url.pathname.startsWith("/command/") && req.method === "POST") {
|
|
333
|
+
return self.handleHttpCommand(req, url, corsHeaders, token);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// HTTP query endpoint: POST /query/:viewName
|
|
337
|
+
if (url.pathname.startsWith("/query/") && req.method === "POST") {
|
|
338
|
+
return self.handleHttpQuery(req, url, tokenPayload, corsHeaders);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// SSE stream endpoint: GET /stream/:viewName
|
|
342
|
+
if (url.pathname.startsWith("/stream/") && req.method === "GET") {
|
|
343
|
+
return self.handleHttpStream(
|
|
344
|
+
req,
|
|
345
|
+
url,
|
|
346
|
+
tokenPayload,
|
|
347
|
+
corsHeaders,
|
|
348
|
+
token,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// HTTP event sync endpoint: POST /sync/events
|
|
353
|
+
if (url.pathname === "/sync/events" && req.method === "POST") {
|
|
354
|
+
return self.handleHttpEventSync(req, tokenPayload, corsHeaders);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return new Response("Not Found", { status: 404, headers: corsHeaders });
|
|
358
|
+
},
|
|
359
|
+
|
|
360
|
+
websocket: {
|
|
361
|
+
open(ws) {
|
|
362
|
+
// Get token from upgrade data
|
|
363
|
+
const tokenPayload = ws.data?.token || null;
|
|
364
|
+
const rawToken = ws.data?.rawToken || null;
|
|
365
|
+
|
|
366
|
+
// Add client to connection manager
|
|
367
|
+
const client = self.connectionManager.addClient(
|
|
368
|
+
ws,
|
|
369
|
+
tokenPayload,
|
|
370
|
+
rawToken,
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
console.log(`Client connected: ${client.id}`);
|
|
374
|
+
},
|
|
375
|
+
|
|
376
|
+
message(ws, messageStr) {
|
|
377
|
+
try {
|
|
378
|
+
const message = JSON.parse(
|
|
379
|
+
messageStr as string,
|
|
380
|
+
) as ClientToHostMessage;
|
|
381
|
+
self.handleMessage(ws, message);
|
|
382
|
+
} catch (error) {
|
|
383
|
+
console.error("Failed to parse message:", error);
|
|
384
|
+
self.sendError(ws, "Invalid message format");
|
|
385
|
+
}
|
|
386
|
+
},
|
|
387
|
+
|
|
388
|
+
close(ws) {
|
|
389
|
+
const client = self.connectionManager.getClientByWs(ws);
|
|
390
|
+
if (client) {
|
|
391
|
+
console.log(`Client disconnected: ${client.id}`);
|
|
392
|
+
self.connectionManager.removeClient(client.id);
|
|
393
|
+
}
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
console.log(`✅ Arc Host running on http://localhost:${this.port}`);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Handle HTTP command request
|
|
403
|
+
*/
|
|
404
|
+
private async handleHttpCommand(
|
|
405
|
+
req: Request,
|
|
406
|
+
url: URL,
|
|
407
|
+
corsHeaders: Record<string, string>,
|
|
408
|
+
rawToken: string | null,
|
|
409
|
+
): Promise<Response> {
|
|
410
|
+
const commandName = url.pathname.split("/command/")[1];
|
|
411
|
+
if (!commandName) {
|
|
412
|
+
return new Response("Invalid command path", {
|
|
413
|
+
status: 400,
|
|
414
|
+
headers: corsHeaders,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
try {
|
|
419
|
+
const params = await req.json();
|
|
420
|
+
const result = await this.contextHandler.executeCommand(
|
|
421
|
+
commandName,
|
|
422
|
+
params,
|
|
423
|
+
rawToken,
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
return new Response(JSON.stringify(result ?? { success: true }), {
|
|
427
|
+
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
428
|
+
});
|
|
429
|
+
} catch (error) {
|
|
430
|
+
return new Response(JSON.stringify({ error: (error as Error).message }), {
|
|
431
|
+
status: 500,
|
|
432
|
+
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Handle HTTP query request
|
|
439
|
+
*/
|
|
440
|
+
private async handleHttpQuery(
|
|
441
|
+
req: Request,
|
|
442
|
+
url: URL,
|
|
443
|
+
token: TokenPayload | null,
|
|
444
|
+
corsHeaders: Record<string, string>,
|
|
445
|
+
): Promise<Response> {
|
|
446
|
+
const viewName = url.pathname.split("/query/")[1];
|
|
447
|
+
if (!viewName) {
|
|
448
|
+
return new Response("Invalid query path", {
|
|
449
|
+
status: 400,
|
|
450
|
+
headers: corsHeaders,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
try {
|
|
455
|
+
const params = await req.json();
|
|
456
|
+
const dataStorage = this.contextHandler.getDataStorage();
|
|
457
|
+
const tx = await dataStorage.getReadTransaction();
|
|
458
|
+
const result = await tx.find(viewName, params);
|
|
459
|
+
|
|
460
|
+
// TODO: Apply view protection filtering based on token
|
|
461
|
+
|
|
462
|
+
return new Response(JSON.stringify(result), {
|
|
463
|
+
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
464
|
+
});
|
|
465
|
+
} catch (error) {
|
|
466
|
+
return new Response(JSON.stringify({ error: (error as Error).message }), {
|
|
467
|
+
status: 500,
|
|
468
|
+
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Handle HTTP stream (SSE) request for live queries
|
|
475
|
+
*/
|
|
476
|
+
private handleHttpStream(
|
|
477
|
+
_req: Request,
|
|
478
|
+
url: URL,
|
|
479
|
+
token: TokenPayload | null,
|
|
480
|
+
corsHeaders: Record<string, string>,
|
|
481
|
+
rawToken: string | null,
|
|
482
|
+
): Response {
|
|
483
|
+
const viewName = url.pathname.split("/stream/")[1];
|
|
484
|
+
if (!viewName) {
|
|
485
|
+
return new Response("Invalid stream path", {
|
|
486
|
+
status: 400,
|
|
487
|
+
headers: corsHeaders,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Parse query options from URL params
|
|
492
|
+
const findOptions: any = {};
|
|
493
|
+
const whereParam = url.searchParams.get("where");
|
|
494
|
+
if (whereParam) {
|
|
495
|
+
try {
|
|
496
|
+
findOptions.where = JSON.parse(whereParam);
|
|
497
|
+
} catch {
|
|
498
|
+
return new Response("Invalid 'where' parameter", {
|
|
499
|
+
status: 400,
|
|
500
|
+
headers: corsHeaders,
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const orderByParam = url.searchParams.get("orderBy");
|
|
506
|
+
if (orderByParam) {
|
|
507
|
+
try {
|
|
508
|
+
findOptions.orderBy = JSON.parse(orderByParam);
|
|
509
|
+
} catch {
|
|
510
|
+
return new Response("Invalid 'orderBy' parameter", {
|
|
511
|
+
status: 400,
|
|
512
|
+
headers: corsHeaders,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const limitParam = url.searchParams.get("limit");
|
|
518
|
+
if (limitParam) {
|
|
519
|
+
findOptions.limit = parseInt(limitParam, 10);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Create SSE stream
|
|
523
|
+
const streamId = `stream_${++this.streamIdCounter}_${Date.now()}`;
|
|
524
|
+
const self = this;
|
|
525
|
+
|
|
526
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
527
|
+
start(controller) {
|
|
528
|
+
// Set up live query
|
|
529
|
+
const model = self.contextHandler.getModel();
|
|
530
|
+
|
|
531
|
+
// Set the token on the auth adapter so view protection is applied
|
|
532
|
+
self.contextHandler.setAuthToken(rawToken);
|
|
533
|
+
|
|
534
|
+
const { unsubscribe } = liveQuery(
|
|
535
|
+
model,
|
|
536
|
+
async (q: any) => {
|
|
537
|
+
const view = q[viewName];
|
|
538
|
+
if (!view) {
|
|
539
|
+
throw new Error(`View '${viewName}' not found`);
|
|
540
|
+
}
|
|
541
|
+
return view.find(findOptions);
|
|
542
|
+
},
|
|
543
|
+
(data: any[]) => {
|
|
544
|
+
// Send SSE event
|
|
545
|
+
const event = `data: ${JSON.stringify({ type: "data", data })}\n\n`;
|
|
546
|
+
try {
|
|
547
|
+
controller.enqueue(new TextEncoder().encode(event));
|
|
548
|
+
} catch {
|
|
549
|
+
// Stream closed
|
|
550
|
+
unsubscribe();
|
|
551
|
+
}
|
|
552
|
+
},
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
// Store connection for cleanup
|
|
556
|
+
self.streamConnections.set(streamId, {
|
|
557
|
+
id: streamId,
|
|
558
|
+
controller,
|
|
559
|
+
unsubscribe,
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
// Send initial connection event
|
|
563
|
+
const connectEvent = `data: ${JSON.stringify({ type: "connected", streamId })}\n\n`;
|
|
564
|
+
controller.enqueue(new TextEncoder().encode(connectEvent));
|
|
565
|
+
},
|
|
566
|
+
|
|
567
|
+
cancel() {
|
|
568
|
+
// Clean up on client disconnect
|
|
569
|
+
const conn = self.streamConnections.get(streamId);
|
|
570
|
+
if (conn) {
|
|
571
|
+
conn.unsubscribe();
|
|
572
|
+
self.streamConnections.delete(streamId);
|
|
573
|
+
}
|
|
574
|
+
},
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
return new Response(stream, {
|
|
578
|
+
headers: {
|
|
579
|
+
...corsHeaders,
|
|
580
|
+
"Content-Type": "text/event-stream",
|
|
581
|
+
"Cache-Control": "no-cache",
|
|
582
|
+
Connection: "keep-alive",
|
|
583
|
+
},
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Handle HTTP event sync request
|
|
589
|
+
*/
|
|
590
|
+
private async handleHttpEventSync(
|
|
591
|
+
req: Request,
|
|
592
|
+
token: TokenPayload | null,
|
|
593
|
+
corsHeaders: Record<string, string>,
|
|
594
|
+
): Promise<Response> {
|
|
595
|
+
try {
|
|
596
|
+
const body = await req.json();
|
|
597
|
+
const events = body.events || [];
|
|
598
|
+
|
|
599
|
+
// Persist events via context handler
|
|
600
|
+
const persistedEvents = await this.contextHandler.persistEvents(
|
|
601
|
+
events.map((e: any) => ({
|
|
602
|
+
localId: e.localId,
|
|
603
|
+
type: e.type,
|
|
604
|
+
payload: e.payload,
|
|
605
|
+
createdAt: e.createdAt,
|
|
606
|
+
})),
|
|
607
|
+
"http-sync",
|
|
608
|
+
token,
|
|
609
|
+
);
|
|
610
|
+
|
|
611
|
+
return new Response(
|
|
612
|
+
JSON.stringify({
|
|
613
|
+
success: true,
|
|
614
|
+
syncedIds: persistedEvents.map((e) => e.localId),
|
|
615
|
+
}),
|
|
616
|
+
{
|
|
617
|
+
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
618
|
+
},
|
|
619
|
+
);
|
|
620
|
+
} catch (error) {
|
|
621
|
+
return new Response(
|
|
622
|
+
JSON.stringify({
|
|
623
|
+
success: false,
|
|
624
|
+
error: (error as Error).message,
|
|
625
|
+
}),
|
|
626
|
+
{
|
|
627
|
+
status: 500,
|
|
628
|
+
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
629
|
+
},
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Stop the server
|
|
636
|
+
*/
|
|
637
|
+
stop(): void {
|
|
638
|
+
// Clean up all stream connections
|
|
639
|
+
for (const conn of this.streamConnections.values()) {
|
|
640
|
+
conn.unsubscribe();
|
|
641
|
+
}
|
|
642
|
+
this.streamConnections.clear();
|
|
643
|
+
|
|
644
|
+
this.server?.stop();
|
|
645
|
+
}
|
|
646
|
+
}
|