@arcote.tech/arc-host 0.1.11 → 0.3.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.
@@ -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
+ }