@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.
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 -364
  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 -75
  41. package/dist/src/arc-host.d.ts.map +0 -1
  42. package/src/arc-host.ts +0 -818
@@ -0,0 +1,414 @@
1
+ import type { ArcRouteAny } from "@arcote.tech/arc";
2
+ import { ScopedModel } from "@arcote.tech/arc";
3
+ import type { ConnectionManager } from "../connection-manager";
4
+ import type { ContextHandler } from "../context-handler";
5
+ import type { ArcHttpHandler, ArcRequestContext } from "./types";
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Shared
9
+ // ---------------------------------------------------------------------------
10
+
11
+ async function parseCommandParams(req: Request): Promise<any> {
12
+ const contentType = req.headers.get("Content-Type") || "";
13
+ if (contentType.includes("multipart/form-data")) {
14
+ const formData = await req.formData();
15
+ const params: Record<string, any> = {};
16
+ for (const [key, value] of formData.entries()) {
17
+ if (
18
+ typeof value === "object" &&
19
+ value !== null &&
20
+ "name" in value &&
21
+ "size" in value
22
+ ) {
23
+ params[key] = value;
24
+ } else {
25
+ try {
26
+ params[key] = JSON.parse(value as string);
27
+ } catch {
28
+ params[key] = value;
29
+ }
30
+ }
31
+ }
32
+ return params;
33
+ }
34
+ return await req.json();
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Health
39
+ // ---------------------------------------------------------------------------
40
+
41
+ export function healthHandler(cm: ConnectionManager): ArcHttpHandler {
42
+ return (_req, url, ctx) => {
43
+ if (url.pathname !== "/health") return null;
44
+ return Response.json(
45
+ { status: "ok", clients: cm.clientCount },
46
+ { headers: ctx.corsHeaders },
47
+ );
48
+ };
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Command — POST /command/:name
53
+ // ---------------------------------------------------------------------------
54
+
55
+ export function commandHandler(ch: ContextHandler): ArcHttpHandler {
56
+ return async (req, url, ctx) => {
57
+ if (!url.pathname.startsWith("/command/") || req.method !== "POST")
58
+ return null;
59
+
60
+ const commandName = url.pathname.split("/command/")[1];
61
+ if (!commandName) {
62
+ return new Response("Invalid command path", {
63
+ status: 400,
64
+ headers: ctx.corsHeaders,
65
+ });
66
+ }
67
+
68
+ try {
69
+ const params = await parseCommandParams(req);
70
+ const result = await ch.executeCommand(
71
+ commandName,
72
+ params,
73
+ ctx.rawToken,
74
+ );
75
+ return Response.json(result ?? { success: true }, {
76
+ headers: ctx.corsHeaders,
77
+ });
78
+ } catch (error) {
79
+ console.error(`[ARC] Command '${commandName}' error:`, error);
80
+ return Response.json(
81
+ { error: (error as Error).message },
82
+ { status: 500, headers: ctx.corsHeaders },
83
+ );
84
+ }
85
+ };
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Query — POST /query/:viewName
90
+ // ---------------------------------------------------------------------------
91
+
92
+ export function queryHandler(ch: ContextHandler): ArcHttpHandler {
93
+ return async (req, url, ctx) => {
94
+ if (!url.pathname.startsWith("/query/") || req.method !== "POST")
95
+ return null;
96
+
97
+ const viewName = url.pathname.split("/query/")[1];
98
+ if (!viewName) {
99
+ return new Response("Invalid query path", {
100
+ status: 400,
101
+ headers: ctx.corsHeaders,
102
+ });
103
+ }
104
+
105
+ try {
106
+ const params = await req.json();
107
+ const viewElement = ch.getModel().context.get(viewName) as any;
108
+ if (!viewElement?.queryContext) {
109
+ return Response.json(
110
+ { error: "View not found" },
111
+ { status: 404, headers: ctx.corsHeaders },
112
+ );
113
+ }
114
+
115
+ const scoped = new ScopedModel(ch.getModel(), "request");
116
+ if (ctx.rawToken) scoped.setToken(ctx.rawToken);
117
+ const queryCtx = viewElement.queryContext(scoped.getAdapters());
118
+ const result = await queryCtx.find(params);
119
+
120
+ return Response.json(result, { headers: ctx.corsHeaders });
121
+ } catch (error) {
122
+ return Response.json(
123
+ { error: (error as Error).message },
124
+ { status: 500, headers: ctx.corsHeaders },
125
+ );
126
+ }
127
+ };
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Stream — GET /stream/:viewName (SSE)
132
+ // ---------------------------------------------------------------------------
133
+
134
+ interface StreamConnection {
135
+ id: string;
136
+ controller: ReadableStreamDefaultController<Uint8Array>;
137
+ unsubscribe: () => void;
138
+ }
139
+
140
+ let streamIdCounter = 0;
141
+ const streamConnections = new Map<string, StreamConnection>();
142
+
143
+ export function streamHandler(ch: ContextHandler): ArcHttpHandler {
144
+ return (_req, url, ctx) => {
145
+ if (!url.pathname.startsWith("/stream/") || _req.method !== "GET")
146
+ return null;
147
+
148
+ const viewName = url.pathname.split("/stream/")[1];
149
+ if (!viewName) {
150
+ return new Response("Invalid stream path", {
151
+ status: 400,
152
+ headers: ctx.corsHeaders,
153
+ });
154
+ }
155
+
156
+ const findOptions: any = {};
157
+ const whereParam = url.searchParams.get("where");
158
+ if (whereParam) {
159
+ try {
160
+ findOptions.where = JSON.parse(whereParam);
161
+ } catch {
162
+ return new Response("Invalid 'where' parameter", {
163
+ status: 400,
164
+ headers: ctx.corsHeaders,
165
+ });
166
+ }
167
+ }
168
+ const orderByParam = url.searchParams.get("orderBy");
169
+ if (orderByParam) {
170
+ try {
171
+ findOptions.orderBy = JSON.parse(orderByParam);
172
+ } catch {
173
+ return new Response("Invalid 'orderBy' parameter", {
174
+ status: 400,
175
+ headers: ctx.corsHeaders,
176
+ });
177
+ }
178
+ }
179
+ const limitParam = url.searchParams.get("limit");
180
+ if (limitParam) findOptions.limit = parseInt(limitParam, 10);
181
+
182
+ const streamId = `stream_${++streamIdCounter}_${Date.now()}`;
183
+ const rawToken = ctx.rawToken;
184
+
185
+ const stream = new ReadableStream<Uint8Array>({
186
+ start(controller) {
187
+ const scoped = new ScopedModel(ch.getModel(), "stream");
188
+ if (rawToken) scoped.setToken(rawToken);
189
+
190
+ const descriptor = { element: viewName, method: "find", args: [findOptions] };
191
+
192
+ const sendData = async () => {
193
+ try {
194
+ const data = await scoped.callQuery(descriptor);
195
+ controller.enqueue(
196
+ new TextEncoder().encode(
197
+ `data: ${JSON.stringify({ type: "data", data })}\n\n`,
198
+ ),
199
+ );
200
+ } catch {
201
+ unsubscribe();
202
+ }
203
+ };
204
+
205
+ // Initial data
206
+ sendData();
207
+
208
+ // Subscribe to changes
209
+ const unsubscribe = ch
210
+ .getEventPublisher()
211
+ .subscribe("*", () => sendData());
212
+
213
+ streamConnections.set(streamId, { id: streamId, controller, unsubscribe });
214
+ controller.enqueue(
215
+ new TextEncoder().encode(
216
+ `data: ${JSON.stringify({ type: "connected", streamId })}\n\n`,
217
+ ),
218
+ );
219
+ },
220
+ cancel() {
221
+ const conn = streamConnections.get(streamId);
222
+ if (conn) {
223
+ conn.unsubscribe();
224
+ streamConnections.delete(streamId);
225
+ }
226
+ },
227
+ });
228
+
229
+ return new Response(stream, {
230
+ headers: {
231
+ ...ctx.corsHeaders,
232
+ "Content-Type": "text/event-stream",
233
+ "Cache-Control": "no-cache",
234
+ Connection: "keep-alive",
235
+ },
236
+ });
237
+ };
238
+ }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // Event Sync — POST /sync/events
242
+ // ---------------------------------------------------------------------------
243
+
244
+ export function eventSyncHandler(ch: ContextHandler): ArcHttpHandler {
245
+ return async (req, url, ctx) => {
246
+ if (url.pathname !== "/sync/events" || req.method !== "POST") return null;
247
+
248
+ try {
249
+ const body = await req.json();
250
+ const events = body.events || [];
251
+ const persisted = await ch.persistEvents(
252
+ events.map((e: any) => ({
253
+ localId: e.localId,
254
+ type: e.type,
255
+ payload: e.payload,
256
+ createdAt: e.createdAt,
257
+ })),
258
+ "http-sync",
259
+ ctx.tokenPayload,
260
+ );
261
+ return Response.json(
262
+ { success: true, syncedIds: persisted.map((e) => e.localId) },
263
+ { headers: ctx.corsHeaders },
264
+ );
265
+ } catch (error) {
266
+ return Response.json(
267
+ { success: false, error: (error as Error).message },
268
+ { status: 500, headers: ctx.corsHeaders },
269
+ );
270
+ }
271
+ };
272
+ }
273
+
274
+ // ---------------------------------------------------------------------------
275
+ // Route — /route/*
276
+ // ---------------------------------------------------------------------------
277
+
278
+ export function routeHandler(ch: ContextHandler): ArcHttpHandler {
279
+ return async (req, url, ctx) => {
280
+ if (!url.pathname.startsWith("/route/")) return null;
281
+
282
+ const method = req.method as "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
283
+ const context = ch.getModel().context;
284
+ let matchedRoute: ArcRouteAny | null = null;
285
+ let routeParams: Record<string, string> = {};
286
+
287
+ for (const element of context.elements) {
288
+ if (element && typeof (element as any).matchesPath === "function") {
289
+ const route = element as ArcRouteAny;
290
+ const match = route.matchesPath(url.pathname);
291
+ if (match.matches) {
292
+ matchedRoute = route;
293
+ routeParams = match.params;
294
+ break;
295
+ }
296
+ }
297
+ }
298
+
299
+ if (!matchedRoute) {
300
+ return Response.json(
301
+ { error: "Route not found" },
302
+ { status: 404, headers: ctx.corsHeaders },
303
+ );
304
+ }
305
+
306
+ const handler = matchedRoute.getHandler(method);
307
+ if (!handler) {
308
+ return Response.json(
309
+ { error: `Method ${method} not allowed` },
310
+ { status: 405, headers: ctx.corsHeaders },
311
+ );
312
+ }
313
+
314
+ // Protection checks
315
+ if (!matchedRoute.isPublic && matchedRoute.hasProtections) {
316
+ if (!ctx.tokenPayload) {
317
+ return Response.json(
318
+ { error: "Unauthorized" },
319
+ { status: 401, headers: ctx.corsHeaders },
320
+ );
321
+ }
322
+ let isAuthorized = false;
323
+ for (const protection of matchedRoute.protections) {
324
+ if (protection.token.name === ctx.tokenPayload.tokenType) {
325
+ const mockTokenInstance = {
326
+ params: ctx.tokenPayload.params,
327
+ getTokenDefinition: () => protection.token,
328
+ };
329
+ const allowed = await protection.check(mockTokenInstance as any);
330
+ if (allowed) {
331
+ isAuthorized = true;
332
+ break;
333
+ }
334
+ }
335
+ }
336
+ if (!isAuthorized) {
337
+ return Response.json(
338
+ { error: "Forbidden" },
339
+ { status: 403, headers: ctx.corsHeaders },
340
+ );
341
+ }
342
+ } else if (!matchedRoute.isPublic && !matchedRoute.hasProtections) {
343
+ if (!ctx.tokenPayload) {
344
+ return Response.json(
345
+ { error: "Unauthorized" },
346
+ { status: 401, headers: ctx.corsHeaders },
347
+ );
348
+ }
349
+ }
350
+
351
+ // Build route context with scoped auth
352
+ const scoped = new ScopedModel(ch.getModel(), "request");
353
+ if (ctx.rawToken) scoped.setToken(ctx.rawToken);
354
+
355
+ const authParams = ctx.tokenPayload
356
+ ? {
357
+ params: ctx.tokenPayload.params,
358
+ tokenName: ctx.tokenPayload.tokenType,
359
+ }
360
+ : undefined;
361
+
362
+ const routeContext = matchedRoute.buildContext(
363
+ scoped.getAdapters(),
364
+ authParams,
365
+ );
366
+
367
+ try {
368
+ const response = await handler(routeContext, req, routeParams, url);
369
+ const newHeaders = new Headers(response.headers);
370
+ for (const [key, value] of Object.entries(ctx.corsHeaders))
371
+ newHeaders.set(key, value);
372
+ const body =
373
+ response.status >= 300 && response.status < 400
374
+ ? null
375
+ : response.body;
376
+ return new Response(body, {
377
+ status: response.status,
378
+ statusText: response.statusText,
379
+ headers: newHeaders,
380
+ });
381
+ } catch (error) {
382
+ return Response.json(
383
+ { error: (error as Error).message },
384
+ { status: 500, headers: ctx.corsHeaders },
385
+ );
386
+ }
387
+ };
388
+ }
389
+
390
+ // ---------------------------------------------------------------------------
391
+ // Convenience: all Arc HTTP handlers
392
+ // ---------------------------------------------------------------------------
393
+
394
+ export function arcHttpHandlers(
395
+ ch: ContextHandler,
396
+ cm: ConnectionManager,
397
+ ): ArcHttpHandler[] {
398
+ return [
399
+ healthHandler(cm),
400
+ commandHandler(ch),
401
+ queryHandler(ch),
402
+ streamHandler(ch),
403
+ eventSyncHandler(ch),
404
+ routeHandler(ch),
405
+ ];
406
+ }
407
+
408
+ /**
409
+ * Cleanup all active SSE stream connections.
410
+ */
411
+ export function cleanupStreams(): void {
412
+ for (const conn of streamConnections.values()) conn.unsubscribe();
413
+ streamConnections.clear();
414
+ }
@@ -0,0 +1,27 @@
1
+ export type {
2
+ ArcHttpHandler,
3
+ ArcRequestContext,
4
+ ArcWsContext,
5
+ ArcWsHandler,
6
+ } from "./types";
7
+
8
+ export {
9
+ arcHttpHandlers,
10
+ cleanupStreams,
11
+ commandHandler,
12
+ eventSyncHandler,
13
+ healthHandler,
14
+ queryHandler,
15
+ routeHandler,
16
+ streamHandler,
17
+ } from "./http";
18
+
19
+ export {
20
+ arcWsHandlers,
21
+ cleanupClientSubs,
22
+ executeCommandHandler,
23
+ requestSyncHandler,
24
+ scopeAuthHandler,
25
+ syncEventsHandler,
26
+ querySubscriptionHandler,
27
+ } from "./ws";
@@ -0,0 +1,42 @@
1
+ import type { ConnectionManager } from "../connection-manager";
2
+ import type { ContextHandler } from "../context-handler";
3
+ import type { ConnectedClient, TokenPayload } from "../types";
4
+
5
+ /**
6
+ * Per-request context passed to HTTP handlers.
7
+ * Created by createArcServer before running the handler chain.
8
+ */
9
+ export interface ArcRequestContext {
10
+ rawToken: string | null;
11
+ tokenPayload: TokenPayload | null;
12
+ corsHeaders: Record<string, string>;
13
+ }
14
+
15
+ /**
16
+ * HTTP middleware handler.
17
+ * Return a Response to stop the chain, or null to pass to the next handler.
18
+ */
19
+ export type ArcHttpHandler = (
20
+ req: Request,
21
+ url: URL,
22
+ ctx: ArcRequestContext,
23
+ ) => Promise<Response | null> | Response | null;
24
+
25
+ /**
26
+ * Server context shared by all WS handlers.
27
+ */
28
+ export interface ArcWsContext {
29
+ contextHandler: ContextHandler;
30
+ connectionManager: ConnectionManager;
31
+ verifyToken: (token: string) => TokenPayload | null;
32
+ }
33
+
34
+ /**
35
+ * WebSocket message handler.
36
+ * Return true if handled, false to pass to the next handler.
37
+ */
38
+ export type ArcWsHandler = (
39
+ client: ConnectedClient,
40
+ message: any,
41
+ ctx: ArcWsContext,
42
+ ) => Promise<boolean>;