@arcote.tech/arc-host 0.3.4 → 0.4.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.
Files changed (42) hide show
  1. package/dist/index.d.ts +1 -2
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1024 -384
  4. package/dist/index.js.map +12 -8
  5. package/dist/src/connection-manager.d.ts +16 -1
  6. package/dist/src/connection-manager.d.ts.map +1 -1
  7. package/dist/src/context-handler.d.ts +3 -5
  8. package/dist/src/context-handler.d.ts.map +1 -1
  9. package/dist/src/create-server.d.ts +36 -0
  10. package/dist/src/create-server.d.ts.map +1 -0
  11. package/dist/src/cron-scheduler.d.ts +30 -0
  12. package/dist/src/cron-scheduler.d.ts.map +1 -0
  13. package/dist/src/event-auth.d.ts +6 -1
  14. package/dist/src/event-auth.d.ts.map +1 -1
  15. package/dist/src/index.d.ts +6 -2
  16. package/dist/src/index.d.ts.map +1 -1
  17. package/dist/src/middleware/http.d.ts +15 -0
  18. package/dist/src/middleware/http.d.ts.map +1 -0
  19. package/dist/src/middleware/index.d.ts +4 -0
  20. package/dist/src/middleware/index.d.ts.map +1 -0
  21. package/dist/src/middleware/types.d.ts +31 -0
  22. package/dist/src/middleware/types.d.ts.map +1 -0
  23. package/dist/src/middleware/ws.d.ts +9 -0
  24. package/dist/src/middleware/ws.d.ts.map +1 -0
  25. package/dist/src/types.d.ts +25 -4
  26. package/dist/src/types.d.ts.map +1 -1
  27. package/index.ts +2 -4
  28. package/package.json +2 -1
  29. package/src/connection-manager.ts +37 -7
  30. package/src/context-handler.ts +22 -23
  31. package/src/create-server.ts +213 -0
  32. package/src/cron-scheduler.ts +124 -0
  33. package/src/event-auth.ts +26 -1
  34. package/src/index.ts +39 -9
  35. package/src/middleware/http.ts +414 -0
  36. package/src/middleware/index.ts +27 -0
  37. package/src/middleware/types.ts +42 -0
  38. package/src/middleware/ws.ts +266 -0
  39. package/src/types.ts +22 -4
  40. package/dist/src/arc-host.d.ts +0 -81
  41. package/dist/src/arc-host.d.ts.map +0 -1
  42. package/src/arc-host.ts +0 -858
package/src/arc-host.ts DELETED
@@ -1,858 +0,0 @@
1
- import type { ArcRouteAny } from "@arcote.tech/arc";
2
- import { liveQuery } from "@arcote.tech/arc";
3
- import type { Server, ServerWebSocket } from "bun";
4
- import jwt from "jsonwebtoken";
5
- import { ConnectionManager } from "./connection-manager";
6
- import { ContextHandler } from "./context-handler";
7
- import { filterEventsForToken } from "./event-auth";
8
- import type {
9
- ArcHostConfig,
10
- ClientToHostMessage,
11
- HostToClientMessage,
12
- TokenPayload,
13
- } from "./types";
14
-
15
- type WebSocketData = {
16
- clientId: string;
17
- token: TokenPayload | null;
18
- rawToken: string | null;
19
- };
20
-
21
- /**
22
- * Active SSE stream connection
23
- */
24
- interface StreamConnection {
25
- id: string;
26
- controller: ReadableStreamDefaultController<Uint8Array>;
27
- unsubscribe: () => void;
28
- }
29
-
30
- /**
31
- * Arc Host - WebSocket server for real-time event sync
32
- */
33
- export class ArcHost {
34
- private server!: Server<WebSocketData>;
35
- private connectionManager = new ConnectionManager();
36
- private contextHandler!: ContextHandler;
37
- private streamConnections = new Map<string, StreamConnection>();
38
- private jwtSecret: string;
39
- private port: number;
40
- private streamIdCounter = 0;
41
-
42
- constructor(private config: ArcHostConfig) {
43
- this.jwtSecret =
44
- config.jwtSecret ||
45
- process.env.JWT_SECRET ||
46
- "arc-host-secret-change-in-production";
47
- this.port = config.port || 5005;
48
- }
49
-
50
- /**
51
- * Start the host server
52
- */
53
- async start(): Promise<void> {
54
- // Initialize context handler
55
- const dbAdapter = this.config.dbAdapterFactory(this.config.context);
56
- this.contextHandler = new ContextHandler(this.config.context, dbAdapter);
57
- await this.contextHandler.init();
58
-
59
- this.setupServer();
60
- }
61
-
62
- /**
63
- * Verify JWT token
64
- * Supports both standard JWT (jsonwebtoken) and Arc's custom JWT format
65
- */
66
- private verifyToken(token: string): TokenPayload | null {
67
- try {
68
- // Try standard JWT first
69
- const decoded = jwt.verify(token, this.jwtSecret) as any;
70
-
71
- // Handle Arc's custom JWT format (tokenName -> tokenType)
72
- if (decoded.tokenName && !decoded.tokenType) {
73
- return {
74
- tokenType: decoded.tokenName,
75
- params: decoded.params || {},
76
- iat: decoded.iat,
77
- exp: decoded.exp,
78
- };
79
- }
80
-
81
- return decoded as TokenPayload;
82
- } catch {
83
- // Try Arc's custom JWT format (base64 encoded, custom signature)
84
- try {
85
- const parts = token.split(".");
86
- if (parts.length !== 3) return null;
87
-
88
- const payload = JSON.parse(atob(parts[1]));
89
-
90
- // TODO: Verify signature with context's token secret
91
- // For now, just decode and trust (signature verification should be added)
92
-
93
- return {
94
- tokenType: payload.tokenName,
95
- params: payload.params || {},
96
- iat: payload.iat,
97
- exp: payload.exp,
98
- };
99
- } catch {
100
- return null;
101
- }
102
- }
103
- }
104
-
105
- /**
106
- * Handle WebSocket message
107
- */
108
- private async handleMessage(
109
- ws: ServerWebSocket<WebSocketData>,
110
- message: ClientToHostMessage,
111
- ): Promise<void> {
112
- const client = this.connectionManager.getClientByWs(ws);
113
- if (!client) {
114
- this.sendError(ws, "Client not found");
115
- return;
116
- }
117
-
118
- switch (message.type) {
119
- case "sync-events":
120
- await this.handleSyncEvents(client.id, message, client.token);
121
- break;
122
-
123
- case "request-sync":
124
- await this.handleRequestSync(client.id, message, client.token);
125
- break;
126
-
127
- case "execute-command":
128
- await this.handleExecuteCommand(client.id, message, client.rawToken);
129
- break;
130
-
131
- default:
132
- this.sendError(ws, `Unknown message type: ${(message as any).type}`);
133
- }
134
- }
135
-
136
- /**
137
- * Handle sync-events message
138
- */
139
- private async handleSyncEvents(
140
- clientId: string,
141
- message: Extract<ClientToHostMessage, { type: "sync-events" }>,
142
- token: TokenPayload | null,
143
- ): Promise<void> {
144
- // Persist events
145
- const persistedEvents = await this.contextHandler.persistEvents(
146
- message.events,
147
- clientId,
148
- token,
149
- );
150
-
151
- if (persistedEvents.length === 0) {
152
- return;
153
- }
154
-
155
- // Broadcast to other authorized clients
156
- const clients = this.connectionManager.getAllClients();
157
-
158
- for (const client of clients) {
159
- if (client.id === clientId) continue;
160
-
161
- // Filter events for this client's token
162
- const authorizedEvents = filterEventsForToken(
163
- client.token,
164
- persistedEvents,
165
- this.contextHandler.getEventDefinitions(),
166
- );
167
-
168
- if (authorizedEvents.length > 0) {
169
- this.connectionManager.sendToClient(client.id, {
170
- type: "events",
171
- events: authorizedEvents,
172
- });
173
- }
174
- }
175
-
176
- // Update sender's last synced ID
177
- const lastEvent = persistedEvents[persistedEvents.length - 1];
178
- this.connectionManager.updateLastSyncedEventId(clientId, lastEvent.hostId);
179
-
180
- // Confirm sync to sender
181
- this.connectionManager.sendToClient(clientId, {
182
- type: "sync-complete",
183
- lastHostEventId: lastEvent.hostId,
184
- });
185
- }
186
-
187
- /**
188
- * Handle request-sync message (initial sync or catch-up)
189
- */
190
- private async handleRequestSync(
191
- clientId: string,
192
- message: Extract<ClientToHostMessage, { type: "request-sync" }>,
193
- token: TokenPayload | null,
194
- ): Promise<void> {
195
- const events = await this.contextHandler.getEventsSince(
196
- message.lastHostEventId,
197
- token,
198
- );
199
-
200
- this.connectionManager.sendToClient(clientId, {
201
- type: "events",
202
- events,
203
- });
204
-
205
- if (events.length > 0) {
206
- const lastEvent = events[events.length - 1];
207
- this.connectionManager.updateLastSyncedEventId(
208
- clientId,
209
- lastEvent.hostId,
210
- );
211
-
212
- this.connectionManager.sendToClient(clientId, {
213
- type: "sync-complete",
214
- lastHostEventId: lastEvent.hostId,
215
- });
216
- } else {
217
- this.connectionManager.sendToClient(clientId, {
218
- type: "sync-complete",
219
- lastHostEventId: message.lastHostEventId || "",
220
- });
221
- }
222
- }
223
-
224
- /**
225
- * Handle execute-command message
226
- */
227
- private async handleExecuteCommand(
228
- clientId: string,
229
- message: Extract<ClientToHostMessage, { type: "execute-command" }>,
230
- rawToken: string | null,
231
- ): Promise<void> {
232
- try {
233
- const result = await this.contextHandler.executeCommand(
234
- message.commandName,
235
- message.params,
236
- rawToken,
237
- );
238
-
239
- this.connectionManager.sendToClient(clientId, {
240
- type: "command-result",
241
- requestId: message.requestId,
242
- result,
243
- });
244
- } catch (error) {
245
- this.connectionManager.sendToClient(clientId, {
246
- type: "command-result",
247
- requestId: message.requestId,
248
- error: (error as Error).message,
249
- });
250
- }
251
- }
252
-
253
- /**
254
- * Send error to WebSocket
255
- */
256
- private sendError(ws: ServerWebSocket<WebSocketData>, message: string): void {
257
- ws.send(JSON.stringify({ type: "error", message } as HostToClientMessage));
258
- }
259
-
260
- /**
261
- * Setup Bun server
262
- */
263
- private setupServer(): void {
264
- const self = this;
265
-
266
- this.server = Bun.serve<WebSocketData>({
267
- port: this.port,
268
- idleTimeout: 255,
269
-
270
- fetch(req, server) {
271
- const url = new URL(req.url);
272
-
273
- // CORS headers
274
- const corsHeaders = {
275
- "Access-Control-Allow-Origin": "*",
276
- "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
277
- "Access-Control-Allow-Headers": "Content-Type, Authorization",
278
- };
279
-
280
- // Handle preflight
281
- if (req.method === "OPTIONS") {
282
- return new Response(null, { headers: corsHeaders });
283
- }
284
-
285
- // Extract token
286
- const authHeader = req.headers.get("Authorization");
287
- const token =
288
- authHeader?.replace("Bearer ", "") || url.searchParams.get("token");
289
-
290
- // Verify token
291
- let tokenPayload: TokenPayload | null = null;
292
- if (token) {
293
- tokenPayload = self.verifyToken(token);
294
- }
295
-
296
- // WebSocket upgrade for /ws
297
- if (
298
- url.pathname === "/ws" &&
299
- req.headers.get("Upgrade") === "websocket"
300
- ) {
301
- // Pass token to WebSocket data
302
- if (
303
- server.upgrade(req, {
304
- data: {
305
- clientId: "",
306
- token: tokenPayload,
307
- rawToken: token,
308
- },
309
- })
310
- ) {
311
- // Connection will be set up in websocket.open
312
- return undefined;
313
- }
314
- return new Response("WebSocket upgrade failed", {
315
- status: 500,
316
- headers: corsHeaders,
317
- });
318
- }
319
-
320
- // Health check
321
- if (url.pathname === "/health") {
322
- return new Response(
323
- JSON.stringify({
324
- status: "ok",
325
- clients: self.connectionManager.clientCount,
326
- }),
327
- {
328
- headers: { ...corsHeaders, "Content-Type": "application/json" },
329
- },
330
- );
331
- }
332
-
333
- // HTTP command endpoint: POST /command/:commandName
334
- if (url.pathname.startsWith("/command/") && req.method === "POST") {
335
- return self.handleHttpCommand(req, url, corsHeaders, token);
336
- }
337
-
338
- // HTTP query endpoint: POST /query/:viewName
339
- if (url.pathname.startsWith("/query/") && req.method === "POST") {
340
- return self.handleHttpQuery(
341
- req,
342
- url,
343
- tokenPayload,
344
- corsHeaders,
345
- token,
346
- );
347
- }
348
-
349
- // SSE stream endpoint: GET /stream/:viewName
350
- if (url.pathname.startsWith("/stream/") && req.method === "GET") {
351
- return self.handleHttpStream(
352
- req,
353
- url,
354
- tokenPayload,
355
- corsHeaders,
356
- token,
357
- );
358
- }
359
-
360
- // HTTP event sync endpoint: POST /sync/events
361
- if (url.pathname === "/sync/events" && req.method === "POST") {
362
- return self.handleHttpEventSync(req, tokenPayload, corsHeaders);
363
- }
364
-
365
- // Route endpoints: /route/*
366
- if (url.pathname.startsWith("/route/")) {
367
- return self.handleHttpRoute(
368
- req,
369
- url,
370
- tokenPayload,
371
- corsHeaders,
372
- token,
373
- );
374
- }
375
-
376
- return new Response("Not Found", { status: 404, headers: corsHeaders });
377
- },
378
-
379
- websocket: {
380
- open(ws) {
381
- // Get token from upgrade data
382
- const tokenPayload = ws.data?.token || null;
383
- const rawToken = ws.data?.rawToken || null;
384
-
385
- // Add client to connection manager
386
- const client = self.connectionManager.addClient(
387
- ws,
388
- tokenPayload,
389
- rawToken,
390
- );
391
-
392
- console.log(`Client connected: ${client.id}`);
393
- },
394
-
395
- message(ws, messageStr) {
396
- try {
397
- const message = JSON.parse(
398
- messageStr as string,
399
- ) as ClientToHostMessage;
400
- self.handleMessage(ws, message);
401
- } catch (error) {
402
- console.error("Failed to parse message:", error);
403
- self.sendError(ws, "Invalid message format");
404
- }
405
- },
406
-
407
- close(ws) {
408
- const client = self.connectionManager.getClientByWs(ws);
409
- if (client) {
410
- console.log(`Client disconnected: ${client.id}`);
411
- self.connectionManager.removeClient(client.id);
412
- }
413
- },
414
- },
415
- });
416
-
417
- console.log(`✅ Arc Host running on http://localhost:${this.port}`);
418
- }
419
-
420
- /**
421
- * Handle HTTP command request
422
- * Supports both JSON and FormData (for file uploads)
423
- */
424
- private async handleHttpCommand(
425
- req: Request,
426
- url: URL,
427
- corsHeaders: Record<string, string>,
428
- rawToken: string | null,
429
- ): Promise<Response> {
430
- const commandName = url.pathname.split("/command/")[1];
431
- if (!commandName) {
432
- return new Response("Invalid command path", {
433
- status: 400,
434
- headers: corsHeaders,
435
- });
436
- }
437
-
438
- try {
439
- const params = await this.parseCommandParams(req);
440
- const result = await this.contextHandler.executeCommand(
441
- commandName,
442
- params,
443
- rawToken,
444
- );
445
-
446
- return new Response(JSON.stringify(result ?? { success: true }), {
447
- headers: { ...corsHeaders, "Content-Type": "application/json" },
448
- });
449
- } catch (error) {
450
- console.error(`[ARC HTTP] Command '${commandName}' error:`, error);
451
- if (error instanceof Error && error.stack) {
452
- console.error(`[ARC HTTP] Stack trace:`, error.stack);
453
- }
454
- return new Response(JSON.stringify({ error: (error as Error).message }), {
455
- status: 500,
456
- headers: { ...corsHeaders, "Content-Type": "application/json" },
457
- });
458
- }
459
- }
460
-
461
- /**
462
- * Parse command parameters from request body
463
- * Handles both JSON and FormData (multipart/form-data for file uploads)
464
- */
465
- private async parseCommandParams(req: Request): Promise<any> {
466
- const contentType = req.headers.get("Content-Type") || "";
467
-
468
- if (contentType.includes("multipart/form-data")) {
469
- // Parse FormData - files stay as File objects, other values are JSON-parsed
470
- const formData = await req.formData();
471
- const params: Record<string, any> = {};
472
-
473
- for (const [key, value] of formData.entries()) {
474
- if (
475
- typeof value === "object" &&
476
- value !== null &&
477
- "name" in value &&
478
- "size" in value
479
- ) {
480
- // Keep File objects as-is
481
- params[key] = value;
482
- } else {
483
- // Try to parse as JSON (non-file values are JSON-stringified by client)
484
- try {
485
- params[key] = JSON.parse(value as string);
486
- } catch {
487
- // If not valid JSON, use as plain string
488
- params[key] = value;
489
- }
490
- }
491
- }
492
-
493
- return params;
494
- }
495
-
496
- // Default: parse as JSON
497
- return await req.json();
498
- }
499
-
500
- /**
501
- * Handle HTTP query request
502
- * Uses view.queryContext() to apply protections consistently with liveQuery
503
- */
504
- private async handleHttpQuery(
505
- req: Request,
506
- url: URL,
507
- _token: TokenPayload | null,
508
- corsHeaders: Record<string, string>,
509
- rawToken: string | null,
510
- ): Promise<Response> {
511
- const viewName = url.pathname.split("/query/")[1];
512
- if (!viewName) {
513
- return new Response("Invalid query path", {
514
- status: 400,
515
- headers: corsHeaders,
516
- });
517
- }
518
-
519
- try {
520
- const params = await req.json();
521
-
522
- // Get view element
523
- const viewElement = this.contextHandler
524
- .getModel()
525
- .context.get(viewName) as any;
526
-
527
- if (!viewElement || !viewElement.queryContext) {
528
- return new Response(JSON.stringify({ error: "View not found" }), {
529
- status: 404,
530
- headers: { ...corsHeaders, "Content-Type": "application/json" },
531
- });
532
- }
533
-
534
- // Set auth token so view.queryContext() can apply protections
535
- this.contextHandler.setAuthToken(rawToken);
536
-
537
- // Use view's queryContext which applies protections automatically
538
- const model = this.contextHandler.getModel();
539
- const adapters = model.getAdapters();
540
- const queryCtx = viewElement.queryContext(adapters);
541
-
542
- // Execute query through view's queryContext (protections applied)
543
- const result = await queryCtx.find(params);
544
-
545
- return new Response(JSON.stringify(result), {
546
- headers: { ...corsHeaders, "Content-Type": "application/json" },
547
- });
548
- } catch (error) {
549
- return new Response(JSON.stringify({ error: (error as Error).message }), {
550
- status: 500,
551
- headers: { ...corsHeaders, "Content-Type": "application/json" },
552
- });
553
- }
554
- }
555
-
556
- /**
557
- * Handle HTTP stream (SSE) request for live queries
558
- */
559
- private handleHttpStream(
560
- _req: Request,
561
- url: URL,
562
- token: TokenPayload | null,
563
- corsHeaders: Record<string, string>,
564
- rawToken: string | null,
565
- ): Response {
566
- const viewName = url.pathname.split("/stream/")[1];
567
- if (!viewName) {
568
- return new Response("Invalid stream path", {
569
- status: 400,
570
- headers: corsHeaders,
571
- });
572
- }
573
-
574
- // Parse query options from URL params
575
- const findOptions: any = {};
576
- const whereParam = url.searchParams.get("where");
577
- if (whereParam) {
578
- try {
579
- findOptions.where = JSON.parse(whereParam);
580
- } catch {
581
- return new Response("Invalid 'where' parameter", {
582
- status: 400,
583
- headers: corsHeaders,
584
- });
585
- }
586
- }
587
-
588
- const orderByParam = url.searchParams.get("orderBy");
589
- if (orderByParam) {
590
- try {
591
- findOptions.orderBy = JSON.parse(orderByParam);
592
- } catch {
593
- return new Response("Invalid 'orderBy' parameter", {
594
- status: 400,
595
- headers: corsHeaders,
596
- });
597
- }
598
- }
599
-
600
- const limitParam = url.searchParams.get("limit");
601
- if (limitParam) {
602
- findOptions.limit = parseInt(limitParam, 10);
603
- }
604
-
605
- // Create SSE stream
606
- const streamId = `stream_${++this.streamIdCounter}_${Date.now()}`;
607
- const self = this;
608
-
609
- const stream = new ReadableStream<Uint8Array>({
610
- start(controller) {
611
- // Set up live query
612
- const model = self.contextHandler.getModel();
613
-
614
- // Set the token on the auth adapter so view protection is applied
615
- self.contextHandler.setAuthToken(rawToken);
616
-
617
- const { unsubscribe } = liveQuery(
618
- model,
619
- async (q: any) => {
620
- const view = q[viewName];
621
- if (!view) {
622
- throw new Error(`View '${viewName}' not found`);
623
- }
624
- return view.find(findOptions);
625
- },
626
- (data: any[]) => {
627
- // Send SSE event
628
- const event = `data: ${JSON.stringify({ type: "data", data })}\n\n`;
629
- try {
630
- controller.enqueue(new TextEncoder().encode(event));
631
- } catch {
632
- // Stream closed
633
- unsubscribe();
634
- }
635
- },
636
- );
637
-
638
- // Store connection for cleanup
639
- self.streamConnections.set(streamId, {
640
- id: streamId,
641
- controller,
642
- unsubscribe,
643
- });
644
-
645
- // Send initial connection event
646
- const connectEvent = `data: ${JSON.stringify({ type: "connected", streamId })}\n\n`;
647
- controller.enqueue(new TextEncoder().encode(connectEvent));
648
- },
649
-
650
- cancel() {
651
- // Clean up on client disconnect
652
- const conn = self.streamConnections.get(streamId);
653
- if (conn) {
654
- conn.unsubscribe();
655
- self.streamConnections.delete(streamId);
656
- }
657
- },
658
- });
659
-
660
- return new Response(stream, {
661
- headers: {
662
- ...corsHeaders,
663
- "Content-Type": "text/event-stream",
664
- "Cache-Control": "no-cache",
665
- Connection: "keep-alive",
666
- },
667
- });
668
- }
669
-
670
- /**
671
- * Handle HTTP event sync request
672
- */
673
- private async handleHttpEventSync(
674
- req: Request,
675
- token: TokenPayload | null,
676
- corsHeaders: Record<string, string>,
677
- ): Promise<Response> {
678
- try {
679
- const body = await req.json();
680
- const events = body.events || [];
681
-
682
- // Persist events via context handler
683
- const persistedEvents = await this.contextHandler.persistEvents(
684
- events.map((e: any) => ({
685
- localId: e.localId,
686
- type: e.type,
687
- payload: e.payload,
688
- createdAt: e.createdAt,
689
- })),
690
- "http-sync",
691
- token,
692
- );
693
-
694
- return new Response(
695
- JSON.stringify({
696
- success: true,
697
- syncedIds: persistedEvents.map((e) => e.localId),
698
- }),
699
- {
700
- headers: { ...corsHeaders, "Content-Type": "application/json" },
701
- },
702
- );
703
- } catch (error) {
704
- return new Response(
705
- JSON.stringify({
706
- success: false,
707
- error: (error as Error).message,
708
- }),
709
- {
710
- status: 500,
711
- headers: { ...corsHeaders, "Content-Type": "application/json" },
712
- },
713
- );
714
- }
715
- }
716
-
717
- /**
718
- * Handle HTTP route request
719
- * Matches path against registered routes and executes handler
720
- */
721
- private async handleHttpRoute(
722
- req: Request,
723
- url: URL,
724
- tokenPayload: TokenPayload | null,
725
- corsHeaders: Record<string, string>,
726
- rawToken: string | null,
727
- ): Promise<Response> {
728
- const method = req.method as "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
729
-
730
- // Find matching route in context
731
- const context = this.contextHandler.getModel().context;
732
- let matchedRoute: ArcRouteAny | null = null;
733
- let routeParams: Record<string, string> = {};
734
-
735
- for (const element of context.elements) {
736
- // Check if element is a route (has matchesPath method)
737
- if (element && typeof (element as any).matchesPath === "function") {
738
- const route = element as ArcRouteAny;
739
- const match = route.matchesPath(url.pathname);
740
- if (match.matches) {
741
- matchedRoute = route;
742
- routeParams = match.params;
743
- break;
744
- }
745
- }
746
- }
747
-
748
- if (!matchedRoute) {
749
- return new Response(JSON.stringify({ error: "Route not found" }), {
750
- status: 404,
751
- headers: { ...corsHeaders, "Content-Type": "application/json" },
752
- });
753
- }
754
-
755
- // Get handler for this method
756
- const handler = matchedRoute.getHandler(method);
757
- if (!handler) {
758
- return new Response(
759
- JSON.stringify({ error: `Method ${method} not allowed` }),
760
- {
761
- status: 405,
762
- headers: { ...corsHeaders, "Content-Type": "application/json" },
763
- },
764
- );
765
- }
766
-
767
- // Check protection
768
- if (!matchedRoute.isPublic && matchedRoute.hasProtections) {
769
- if (!tokenPayload) {
770
- return new Response(JSON.stringify({ error: "Unauthorized" }), {
771
- status: 401,
772
- headers: { ...corsHeaders, "Content-Type": "application/json" },
773
- });
774
- }
775
-
776
- // Set auth token for protection check
777
- this.contextHandler.setAuthToken(rawToken);
778
-
779
- // Check if token type matches any protection
780
- let isAuthorized = false;
781
- for (const protection of matchedRoute.protections) {
782
- if (protection.token.name === tokenPayload.tokenType) {
783
- // Create a mock token instance for the check
784
- const mockTokenInstance = {
785
- params: tokenPayload.params,
786
- getTokenDefinition: () => protection.token,
787
- };
788
- const allowed = await protection.check(mockTokenInstance as any);
789
- if (allowed) {
790
- isAuthorized = true;
791
- break;
792
- }
793
- }
794
- }
795
-
796
- if (!isAuthorized) {
797
- return new Response(JSON.stringify({ error: "Forbidden" }), {
798
- status: 403,
799
- headers: { ...corsHeaders, "Content-Type": "application/json" },
800
- });
801
- }
802
- } else if (!matchedRoute.isPublic && !matchedRoute.hasProtections) {
803
- // Route is not public and has no protections - require any valid token
804
- if (!tokenPayload) {
805
- return new Response(JSON.stringify({ error: "Unauthorized" }), {
806
- status: 401,
807
- headers: { ...corsHeaders, "Content-Type": "application/json" },
808
- });
809
- }
810
- }
811
-
812
- // Build route context
813
- this.contextHandler.setAuthToken(rawToken);
814
- const model = this.contextHandler.getModel();
815
- const adapters = model.getAdapters();
816
-
817
- const authParams = tokenPayload
818
- ? { params: tokenPayload.params, tokenName: tokenPayload.tokenType }
819
- : undefined;
820
-
821
- const routeContext = matchedRoute.buildContext(adapters, authParams);
822
-
823
- try {
824
- // Execute handler
825
- const response = await handler(routeContext, req, routeParams, url);
826
-
827
- // Add CORS headers to response
828
- const newHeaders = new Headers(response.headers);
829
- for (const [key, value] of Object.entries(corsHeaders)) {
830
- newHeaders.set(key, value);
831
- }
832
-
833
- return new Response(response.body, {
834
- status: response.status,
835
- statusText: response.statusText,
836
- headers: newHeaders,
837
- });
838
- } catch (error) {
839
- return new Response(JSON.stringify({ error: (error as Error).message }), {
840
- status: 500,
841
- headers: { ...corsHeaders, "Content-Type": "application/json" },
842
- });
843
- }
844
- }
845
-
846
- /**
847
- * Stop the server
848
- */
849
- stop(): void {
850
- // Clean up all stream connections
851
- for (const conn of this.streamConnections.values()) {
852
- conn.unsubscribe();
853
- }
854
- this.streamConnections.clear();
855
-
856
- this.server?.stop();
857
- }
858
- }