@arcote.tech/arc-host 0.3.3 → 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.
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1024 -364
- package/dist/index.js.map +12 -8
- package/dist/src/connection-manager.d.ts +16 -1
- package/dist/src/connection-manager.d.ts.map +1 -1
- package/dist/src/context-handler.d.ts +3 -5
- package/dist/src/context-handler.d.ts.map +1 -1
- package/dist/src/create-server.d.ts +36 -0
- package/dist/src/create-server.d.ts.map +1 -0
- package/dist/src/cron-scheduler.d.ts +30 -0
- package/dist/src/cron-scheduler.d.ts.map +1 -0
- package/dist/src/event-auth.d.ts +6 -1
- package/dist/src/event-auth.d.ts.map +1 -1
- package/dist/src/index.d.ts +6 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/middleware/http.d.ts +15 -0
- package/dist/src/middleware/http.d.ts.map +1 -0
- package/dist/src/middleware/index.d.ts +4 -0
- package/dist/src/middleware/index.d.ts.map +1 -0
- package/dist/src/middleware/types.d.ts +31 -0
- package/dist/src/middleware/types.d.ts.map +1 -0
- package/dist/src/middleware/ws.d.ts +9 -0
- package/dist/src/middleware/ws.d.ts.map +1 -0
- package/dist/src/types.d.ts +25 -4
- package/dist/src/types.d.ts.map +1 -1
- package/index.ts +2 -4
- package/package.json +2 -1
- package/src/connection-manager.ts +37 -7
- package/src/context-handler.ts +22 -23
- package/src/create-server.ts +213 -0
- package/src/cron-scheduler.ts +124 -0
- package/src/event-auth.ts +26 -1
- package/src/index.ts +39 -9
- package/src/middleware/http.ts +414 -0
- package/src/middleware/index.ts +27 -0
- package/src/middleware/types.ts +42 -0
- package/src/middleware/ws.ts +266 -0
- package/src/types.ts +22 -4
- package/dist/src/arc-host.d.ts +0 -75
- package/dist/src/arc-host.d.ts.map +0 -1
- package/src/arc-host.ts +0 -818
package/src/arc-host.ts
DELETED
|
@@ -1,818 +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
|
-
*/
|
|
423
|
-
private async handleHttpCommand(
|
|
424
|
-
req: Request,
|
|
425
|
-
url: URL,
|
|
426
|
-
corsHeaders: Record<string, string>,
|
|
427
|
-
rawToken: string | null,
|
|
428
|
-
): Promise<Response> {
|
|
429
|
-
const commandName = url.pathname.split("/command/")[1];
|
|
430
|
-
if (!commandName) {
|
|
431
|
-
return new Response("Invalid command path", {
|
|
432
|
-
status: 400,
|
|
433
|
-
headers: corsHeaders,
|
|
434
|
-
});
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
try {
|
|
438
|
-
const params = await req.json();
|
|
439
|
-
const result = await this.contextHandler.executeCommand(
|
|
440
|
-
commandName,
|
|
441
|
-
params,
|
|
442
|
-
rawToken,
|
|
443
|
-
);
|
|
444
|
-
|
|
445
|
-
return new Response(JSON.stringify(result ?? { success: true }), {
|
|
446
|
-
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
447
|
-
});
|
|
448
|
-
} catch (error) {
|
|
449
|
-
console.error(`[ARC HTTP] Command '${commandName}' error:`, error);
|
|
450
|
-
if (error instanceof Error && error.stack) {
|
|
451
|
-
console.error(`[ARC HTTP] Stack trace:`, error.stack);
|
|
452
|
-
}
|
|
453
|
-
return new Response(JSON.stringify({ error: (error as Error).message }), {
|
|
454
|
-
status: 500,
|
|
455
|
-
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
456
|
-
});
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
/**
|
|
461
|
-
* Handle HTTP query request
|
|
462
|
-
* Uses view.queryContext() to apply protections consistently with liveQuery
|
|
463
|
-
*/
|
|
464
|
-
private async handleHttpQuery(
|
|
465
|
-
req: Request,
|
|
466
|
-
url: URL,
|
|
467
|
-
_token: TokenPayload | null,
|
|
468
|
-
corsHeaders: Record<string, string>,
|
|
469
|
-
rawToken: string | null,
|
|
470
|
-
): Promise<Response> {
|
|
471
|
-
const viewName = url.pathname.split("/query/")[1];
|
|
472
|
-
if (!viewName) {
|
|
473
|
-
return new Response("Invalid query path", {
|
|
474
|
-
status: 400,
|
|
475
|
-
headers: corsHeaders,
|
|
476
|
-
});
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
try {
|
|
480
|
-
const params = await req.json();
|
|
481
|
-
|
|
482
|
-
// Get view element
|
|
483
|
-
const viewElement = this.contextHandler
|
|
484
|
-
.getModel()
|
|
485
|
-
.context.get(viewName) as any;
|
|
486
|
-
|
|
487
|
-
if (!viewElement || !viewElement.queryContext) {
|
|
488
|
-
return new Response(JSON.stringify({ error: "View not found" }), {
|
|
489
|
-
status: 404,
|
|
490
|
-
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
491
|
-
});
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
// Set auth token so view.queryContext() can apply protections
|
|
495
|
-
this.contextHandler.setAuthToken(rawToken);
|
|
496
|
-
|
|
497
|
-
// Use view's queryContext which applies protections automatically
|
|
498
|
-
const model = this.contextHandler.getModel();
|
|
499
|
-
const adapters = model.getAdapters();
|
|
500
|
-
const queryCtx = viewElement.queryContext(adapters);
|
|
501
|
-
|
|
502
|
-
// Execute query through view's queryContext (protections applied)
|
|
503
|
-
const result = await queryCtx.find(params);
|
|
504
|
-
|
|
505
|
-
return new Response(JSON.stringify(result), {
|
|
506
|
-
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
507
|
-
});
|
|
508
|
-
} catch (error) {
|
|
509
|
-
return new Response(JSON.stringify({ error: (error as Error).message }), {
|
|
510
|
-
status: 500,
|
|
511
|
-
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
512
|
-
});
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
/**
|
|
517
|
-
* Handle HTTP stream (SSE) request for live queries
|
|
518
|
-
*/
|
|
519
|
-
private handleHttpStream(
|
|
520
|
-
_req: Request,
|
|
521
|
-
url: URL,
|
|
522
|
-
token: TokenPayload | null,
|
|
523
|
-
corsHeaders: Record<string, string>,
|
|
524
|
-
rawToken: string | null,
|
|
525
|
-
): Response {
|
|
526
|
-
const viewName = url.pathname.split("/stream/")[1];
|
|
527
|
-
if (!viewName) {
|
|
528
|
-
return new Response("Invalid stream path", {
|
|
529
|
-
status: 400,
|
|
530
|
-
headers: corsHeaders,
|
|
531
|
-
});
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
// Parse query options from URL params
|
|
535
|
-
const findOptions: any = {};
|
|
536
|
-
const whereParam = url.searchParams.get("where");
|
|
537
|
-
if (whereParam) {
|
|
538
|
-
try {
|
|
539
|
-
findOptions.where = JSON.parse(whereParam);
|
|
540
|
-
} catch {
|
|
541
|
-
return new Response("Invalid 'where' parameter", {
|
|
542
|
-
status: 400,
|
|
543
|
-
headers: corsHeaders,
|
|
544
|
-
});
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
const orderByParam = url.searchParams.get("orderBy");
|
|
549
|
-
if (orderByParam) {
|
|
550
|
-
try {
|
|
551
|
-
findOptions.orderBy = JSON.parse(orderByParam);
|
|
552
|
-
} catch {
|
|
553
|
-
return new Response("Invalid 'orderBy' parameter", {
|
|
554
|
-
status: 400,
|
|
555
|
-
headers: corsHeaders,
|
|
556
|
-
});
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
const limitParam = url.searchParams.get("limit");
|
|
561
|
-
if (limitParam) {
|
|
562
|
-
findOptions.limit = parseInt(limitParam, 10);
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
// Create SSE stream
|
|
566
|
-
const streamId = `stream_${++this.streamIdCounter}_${Date.now()}`;
|
|
567
|
-
const self = this;
|
|
568
|
-
|
|
569
|
-
const stream = new ReadableStream<Uint8Array>({
|
|
570
|
-
start(controller) {
|
|
571
|
-
// Set up live query
|
|
572
|
-
const model = self.contextHandler.getModel();
|
|
573
|
-
|
|
574
|
-
// Set the token on the auth adapter so view protection is applied
|
|
575
|
-
self.contextHandler.setAuthToken(rawToken);
|
|
576
|
-
|
|
577
|
-
const { unsubscribe } = liveQuery(
|
|
578
|
-
model,
|
|
579
|
-
async (q: any) => {
|
|
580
|
-
const view = q[viewName];
|
|
581
|
-
if (!view) {
|
|
582
|
-
throw new Error(`View '${viewName}' not found`);
|
|
583
|
-
}
|
|
584
|
-
return view.find(findOptions);
|
|
585
|
-
},
|
|
586
|
-
(data: any[]) => {
|
|
587
|
-
// Send SSE event
|
|
588
|
-
const event = `data: ${JSON.stringify({ type: "data", data })}\n\n`;
|
|
589
|
-
try {
|
|
590
|
-
controller.enqueue(new TextEncoder().encode(event));
|
|
591
|
-
} catch {
|
|
592
|
-
// Stream closed
|
|
593
|
-
unsubscribe();
|
|
594
|
-
}
|
|
595
|
-
},
|
|
596
|
-
);
|
|
597
|
-
|
|
598
|
-
// Store connection for cleanup
|
|
599
|
-
self.streamConnections.set(streamId, {
|
|
600
|
-
id: streamId,
|
|
601
|
-
controller,
|
|
602
|
-
unsubscribe,
|
|
603
|
-
});
|
|
604
|
-
|
|
605
|
-
// Send initial connection event
|
|
606
|
-
const connectEvent = `data: ${JSON.stringify({ type: "connected", streamId })}\n\n`;
|
|
607
|
-
controller.enqueue(new TextEncoder().encode(connectEvent));
|
|
608
|
-
},
|
|
609
|
-
|
|
610
|
-
cancel() {
|
|
611
|
-
// Clean up on client disconnect
|
|
612
|
-
const conn = self.streamConnections.get(streamId);
|
|
613
|
-
if (conn) {
|
|
614
|
-
conn.unsubscribe();
|
|
615
|
-
self.streamConnections.delete(streamId);
|
|
616
|
-
}
|
|
617
|
-
},
|
|
618
|
-
});
|
|
619
|
-
|
|
620
|
-
return new Response(stream, {
|
|
621
|
-
headers: {
|
|
622
|
-
...corsHeaders,
|
|
623
|
-
"Content-Type": "text/event-stream",
|
|
624
|
-
"Cache-Control": "no-cache",
|
|
625
|
-
Connection: "keep-alive",
|
|
626
|
-
},
|
|
627
|
-
});
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
/**
|
|
631
|
-
* Handle HTTP event sync request
|
|
632
|
-
*/
|
|
633
|
-
private async handleHttpEventSync(
|
|
634
|
-
req: Request,
|
|
635
|
-
token: TokenPayload | null,
|
|
636
|
-
corsHeaders: Record<string, string>,
|
|
637
|
-
): Promise<Response> {
|
|
638
|
-
try {
|
|
639
|
-
const body = await req.json();
|
|
640
|
-
const events = body.events || [];
|
|
641
|
-
|
|
642
|
-
// Persist events via context handler
|
|
643
|
-
const persistedEvents = await this.contextHandler.persistEvents(
|
|
644
|
-
events.map((e: any) => ({
|
|
645
|
-
localId: e.localId,
|
|
646
|
-
type: e.type,
|
|
647
|
-
payload: e.payload,
|
|
648
|
-
createdAt: e.createdAt,
|
|
649
|
-
})),
|
|
650
|
-
"http-sync",
|
|
651
|
-
token,
|
|
652
|
-
);
|
|
653
|
-
|
|
654
|
-
return new Response(
|
|
655
|
-
JSON.stringify({
|
|
656
|
-
success: true,
|
|
657
|
-
syncedIds: persistedEvents.map((e) => e.localId),
|
|
658
|
-
}),
|
|
659
|
-
{
|
|
660
|
-
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
661
|
-
},
|
|
662
|
-
);
|
|
663
|
-
} catch (error) {
|
|
664
|
-
return new Response(
|
|
665
|
-
JSON.stringify({
|
|
666
|
-
success: false,
|
|
667
|
-
error: (error as Error).message,
|
|
668
|
-
}),
|
|
669
|
-
{
|
|
670
|
-
status: 500,
|
|
671
|
-
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
672
|
-
},
|
|
673
|
-
);
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
/**
|
|
678
|
-
* Handle HTTP route request
|
|
679
|
-
* Matches path against registered routes and executes handler
|
|
680
|
-
*/
|
|
681
|
-
private async handleHttpRoute(
|
|
682
|
-
req: Request,
|
|
683
|
-
url: URL,
|
|
684
|
-
tokenPayload: TokenPayload | null,
|
|
685
|
-
corsHeaders: Record<string, string>,
|
|
686
|
-
rawToken: string | null,
|
|
687
|
-
): Promise<Response> {
|
|
688
|
-
const method = req.method as "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
|
689
|
-
|
|
690
|
-
// Find matching route in context
|
|
691
|
-
const context = this.contextHandler.getModel().context;
|
|
692
|
-
let matchedRoute: ArcRouteAny | null = null;
|
|
693
|
-
let routeParams: Record<string, string> = {};
|
|
694
|
-
|
|
695
|
-
for (const element of context.elements) {
|
|
696
|
-
// Check if element is a route (has matchesPath method)
|
|
697
|
-
if (element && typeof (element as any).matchesPath === "function") {
|
|
698
|
-
const route = element as ArcRouteAny;
|
|
699
|
-
const match = route.matchesPath(url.pathname);
|
|
700
|
-
if (match.matches) {
|
|
701
|
-
matchedRoute = route;
|
|
702
|
-
routeParams = match.params;
|
|
703
|
-
break;
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
if (!matchedRoute) {
|
|
709
|
-
return new Response(JSON.stringify({ error: "Route not found" }), {
|
|
710
|
-
status: 404,
|
|
711
|
-
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
712
|
-
});
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
// Get handler for this method
|
|
716
|
-
const handler = matchedRoute.getHandler(method);
|
|
717
|
-
if (!handler) {
|
|
718
|
-
return new Response(
|
|
719
|
-
JSON.stringify({ error: `Method ${method} not allowed` }),
|
|
720
|
-
{
|
|
721
|
-
status: 405,
|
|
722
|
-
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
723
|
-
},
|
|
724
|
-
);
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
// Check protection
|
|
728
|
-
if (!matchedRoute.isPublic && matchedRoute.hasProtections) {
|
|
729
|
-
if (!tokenPayload) {
|
|
730
|
-
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
|
731
|
-
status: 401,
|
|
732
|
-
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
733
|
-
});
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
// Set auth token for protection check
|
|
737
|
-
this.contextHandler.setAuthToken(rawToken);
|
|
738
|
-
|
|
739
|
-
// Check if token type matches any protection
|
|
740
|
-
let isAuthorized = false;
|
|
741
|
-
for (const protection of matchedRoute.protections) {
|
|
742
|
-
if (protection.token.name === tokenPayload.tokenType) {
|
|
743
|
-
// Create a mock token instance for the check
|
|
744
|
-
const mockTokenInstance = {
|
|
745
|
-
params: tokenPayload.params,
|
|
746
|
-
getTokenDefinition: () => protection.token,
|
|
747
|
-
};
|
|
748
|
-
const allowed = await protection.check(mockTokenInstance as any);
|
|
749
|
-
if (allowed) {
|
|
750
|
-
isAuthorized = true;
|
|
751
|
-
break;
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
if (!isAuthorized) {
|
|
757
|
-
return new Response(JSON.stringify({ error: "Forbidden" }), {
|
|
758
|
-
status: 403,
|
|
759
|
-
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
760
|
-
});
|
|
761
|
-
}
|
|
762
|
-
} else if (!matchedRoute.isPublic && !matchedRoute.hasProtections) {
|
|
763
|
-
// Route is not public and has no protections - require any valid token
|
|
764
|
-
if (!tokenPayload) {
|
|
765
|
-
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
|
766
|
-
status: 401,
|
|
767
|
-
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
768
|
-
});
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
// Build route context
|
|
773
|
-
this.contextHandler.setAuthToken(rawToken);
|
|
774
|
-
const model = this.contextHandler.getModel();
|
|
775
|
-
const adapters = model.getAdapters();
|
|
776
|
-
|
|
777
|
-
const authParams = tokenPayload
|
|
778
|
-
? { params: tokenPayload.params, tokenName: tokenPayload.tokenType }
|
|
779
|
-
: undefined;
|
|
780
|
-
|
|
781
|
-
const routeContext = matchedRoute.buildContext(adapters, authParams);
|
|
782
|
-
|
|
783
|
-
try {
|
|
784
|
-
// Execute handler
|
|
785
|
-
const response = await handler(routeContext, req, routeParams, url);
|
|
786
|
-
|
|
787
|
-
// Add CORS headers to response
|
|
788
|
-
const newHeaders = new Headers(response.headers);
|
|
789
|
-
for (const [key, value] of Object.entries(corsHeaders)) {
|
|
790
|
-
newHeaders.set(key, value);
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
return new Response(response.body, {
|
|
794
|
-
status: response.status,
|
|
795
|
-
statusText: response.statusText,
|
|
796
|
-
headers: newHeaders,
|
|
797
|
-
});
|
|
798
|
-
} catch (error) {
|
|
799
|
-
return new Response(JSON.stringify({ error: (error as Error).message }), {
|
|
800
|
-
status: 500,
|
|
801
|
-
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
802
|
-
});
|
|
803
|
-
}
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
/**
|
|
807
|
-
* Stop the server
|
|
808
|
-
*/
|
|
809
|
-
stop(): void {
|
|
810
|
-
// Clean up all stream connections
|
|
811
|
-
for (const conn of this.streamConnections.values()) {
|
|
812
|
-
conn.unsubscribe();
|
|
813
|
-
}
|
|
814
|
-
this.streamConnections.clear();
|
|
815
|
-
|
|
816
|
-
this.server?.stop();
|
|
817
|
-
}
|
|
818
|
-
}
|