@divizend/scratch-core 1.0.0 → 1.0.2

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,1175 @@
1
+ /**
2
+ * ExpressHttpServer - Express-based HTTP server implementation
3
+ *
4
+ * Elegant and concise implementation using Express for dynamic endpoint handling
5
+ */
6
+
7
+ import express, {
8
+ Express,
9
+ Request,
10
+ Response as ExpressResponse,
11
+ RequestHandler,
12
+ } from "express";
13
+ import { readFile, writeFile, mkdir, readdir } from "node:fs/promises";
14
+ import { resolve, isAbsolute, join, basename, dirname } from "node:path";
15
+ import { randomUUID } from "node:crypto";
16
+ import { platform } from "node:process";
17
+ import { Server } from "node:http";
18
+ import { stat } from "node:fs/promises";
19
+ import { getProjectRoot } from "../core/ProjectRoot";
20
+ import { HttpServer } from "./HttpServer";
21
+ import {
22
+ Universe,
23
+ ScratchContext,
24
+ ScratchEndpointDefinition,
25
+ ScratchBlock,
26
+ JsonSchema,
27
+ JsonSchemaValidator,
28
+ UniverseModule,
29
+ envOrDefault,
30
+ } from "../index";
31
+
32
+ // Minimal structured logger
33
+ const log = (record: Record<string, unknown>) => {
34
+ if (!record.ts) record.ts = new Date().toISOString();
35
+ console.log(JSON.stringify(record));
36
+ };
37
+
38
+ // Type alias for schema property values
39
+ type SchemaProperty = NonNullable<ScratchBlock["schema"]>[string];
40
+
41
+ export class ExpressHttpServer implements HttpServer {
42
+ private app: Express;
43
+ private server: Server | null = null;
44
+ private universe: Universe;
45
+ private endpoints: Map<string, ScratchEndpointDefinition> = new Map();
46
+ private staticRoot: string | null = null;
47
+ private routeHandlers: Map<string, RequestHandler> = new Map();
48
+
49
+ constructor(universe: Universe) {
50
+ this.universe = universe;
51
+ this.app = express();
52
+ this.setupMiddleware();
53
+ }
54
+
55
+ async initializeDefaultEndpoints(): Promise<void> {
56
+ // Dynamically load all endpoints from default-endpoints directory
57
+ const projectRoot = await getProjectRoot();
58
+ const defaultEndpointsDir = join(
59
+ projectRoot,
60
+ "package",
61
+ "http-server",
62
+ "default-endpoints"
63
+ );
64
+ const files = await readdir(defaultEndpointsDir, { withFileTypes: true });
65
+
66
+ for (const file of files) {
67
+ if (
68
+ file.isFile() &&
69
+ file.name.endsWith(".ts") &&
70
+ file.name !== "index.ts"
71
+ ) {
72
+ const modulePath = join(defaultEndpointsDir, file.name);
73
+
74
+ try {
75
+ const module = await import(modulePath);
76
+ // Find the exported endpoint definition
77
+ const endpoint = Object.values(module).find(
78
+ (value): value is ScratchEndpointDefinition =>
79
+ value !== null &&
80
+ typeof value === "object" &&
81
+ "block" in value &&
82
+ "handler" in value
83
+ );
84
+
85
+ if (endpoint) {
86
+ const blockDef = await endpoint.block({});
87
+ if (blockDef.opcode) {
88
+ this.endpoints.set(blockDef.opcode, endpoint);
89
+ await this.registerRoute(blockDef.opcode, endpoint);
90
+ }
91
+ }
92
+ } catch (error) {
93
+ log({
94
+ level: "error",
95
+ event: "default_endpoint_load_failed",
96
+ file: file.name,
97
+ error: error instanceof Error ? error.message : String(error),
98
+ });
99
+ }
100
+ }
101
+ }
102
+
103
+ // Middleware is already set up in constructor - don't call it again
104
+
105
+ // Set up catch-all route handler for all endpoints
106
+ this.setupCatchAllRoute();
107
+
108
+ // Set up 404 handler - only triggers if catch-all route doesn't match
109
+ this.setup404Handler();
110
+ }
111
+
112
+ private setupCatchAllRoute(): void {
113
+ // Single catch-all route that handles all endpoint matching
114
+ // Express 5 (path-to-regexp v6) no longer accepts bare "*" patterns,
115
+ // so we use a regex to catch everything.
116
+ this.app.all(
117
+ /.*/,
118
+ async (
119
+ req: Request,
120
+ res: ExpressResponse,
121
+ next: express.NextFunction
122
+ ) => {
123
+ // Skip if response already sent
124
+ if (res.headersSent) return next();
125
+
126
+ // Handle root path specially
127
+ if (req.path === "/") {
128
+ if (this.routeHandlers.has("root")) {
129
+ const rootHandler = this.routeHandlers.get("root");
130
+ if (rootHandler) {
131
+ await rootHandler(req, res, next);
132
+ return;
133
+ }
134
+ }
135
+ // No root endpoint - delegate to loadEndpointsUI
136
+ if (this.routeHandlers.has("loadEndpointsUI")) {
137
+ const uiHandler = this.routeHandlers.get("loadEndpointsUI");
138
+ if (uiHandler) {
139
+ await uiHandler(req, res, next);
140
+ return;
141
+ }
142
+ }
143
+ return next(); // Fall through to 404
144
+ }
145
+
146
+ // Extract opcode from path (remove leading /)
147
+ const opcode = req.path.slice(1);
148
+ const endpoint = this.endpoints.get(opcode);
149
+
150
+ if (!endpoint) {
151
+ return next(); // No endpoint found, fall through to 404
152
+ }
153
+
154
+ // Get block definition to check method
155
+ const blockDef = await endpoint.block({});
156
+ const expectedMethod =
157
+ blockDef.blockType === "reporter" ? "GET" : "POST";
158
+
159
+ if (req.method !== expectedMethod) {
160
+ return next(); // Method mismatch, fall through to 404
161
+ }
162
+
163
+ // Get handler and call it
164
+ const handler = this.routeHandlers.get(opcode);
165
+ if (!handler) {
166
+ return next(); // No handler, fall through to 404
167
+ }
168
+
169
+ log({
170
+ level: "info",
171
+ event: "endpoint_matched",
172
+ opcode,
173
+ method: req.method,
174
+ path: req.path,
175
+ });
176
+
177
+ await handler(req, res, next);
178
+ }
179
+ );
180
+ }
181
+
182
+ private setup404Handler(): void {
183
+ // 404 handler - only triggers if catch-all route calls next()
184
+ this.app.use(
185
+ (req: Request, res: ExpressResponse, next: express.NextFunction) => {
186
+ if (res.headersSent) return next();
187
+ log({
188
+ level: "warn",
189
+ event: "route_not_found",
190
+ method: req.method,
191
+ path: req.path,
192
+ registeredRoutes: Array.from(this.routeHandlers.keys()),
193
+ });
194
+ res.status(404).json({ error: "Not found", path: req.path });
195
+ }
196
+ );
197
+ }
198
+
199
+ /**
200
+ * Register the catch-all 404 handler
201
+ * This is called during initialization and never changes
202
+ */
203
+ register404Handler(): void {
204
+ // No-op - 404 handler is set up in initializeDefaultEndpoints
205
+ }
206
+
207
+ private setupMiddleware(): void {
208
+ // Request logging - FIRST so we can track all requests
209
+ this.app.use(
210
+ (req: Request, res: ExpressResponse, next: express.NextFunction) => {
211
+ const reqId = randomUUID();
212
+ log({
213
+ level: "info",
214
+ event: "request",
215
+ req_id: reqId,
216
+ method: req.method,
217
+ path: req.path,
218
+ query: Object.keys(req.query).length > 0 ? req.query : undefined,
219
+ });
220
+ (req as any).reqId = reqId;
221
+ next();
222
+ }
223
+ );
224
+
225
+ // CORS
226
+ this.app.use(
227
+ (req: Request, res: ExpressResponse, next: express.NextFunction) => {
228
+ res.header("Access-Control-Allow-Origin", "*");
229
+ res.header(
230
+ "Access-Control-Allow-Methods",
231
+ "GET, POST, PUT, DELETE, OPTIONS"
232
+ );
233
+ res.header(
234
+ "Access-Control-Allow-Headers",
235
+ "Content-Type, Authorization"
236
+ );
237
+ if (req.method === "OPTIONS") return res.sendStatus(200);
238
+ next();
239
+ }
240
+ );
241
+
242
+ // Body parsing
243
+ this.app.use(express.json());
244
+ this.app.use(express.urlencoded({ extended: true }));
245
+ }
246
+
247
+ private async registerRoute(
248
+ opcode: string,
249
+ endpoint: ScratchEndpointDefinition
250
+ ): Promise<void> {
251
+ try {
252
+ const handler = await this.createHandlerForEndpoint(endpoint);
253
+
254
+ // Verify handler is a function
255
+ if (typeof handler !== "function") {
256
+ throw new Error(
257
+ `Handler for ${opcode} is not a function: ${typeof handler}`
258
+ );
259
+ }
260
+
261
+ // Just store the handler - the catch-all route will match it
262
+ this.routeHandlers.set(opcode, handler);
263
+
264
+ log({
265
+ level: "info",
266
+ event: "endpoint_registered",
267
+ opcode,
268
+ });
269
+ } catch (error) {
270
+ log({
271
+ level: "error",
272
+ event: "endpoint_registration_failed",
273
+ opcode,
274
+ error: error instanceof Error ? error.message : String(error),
275
+ stack: error instanceof Error ? error.stack : undefined,
276
+ });
277
+ throw error;
278
+ }
279
+ }
280
+
281
+ private async wrapHandlerWithAuthAndValidation(
282
+ endpoint: ScratchEndpointDefinition
283
+ ): Promise<
284
+ (
285
+ context: ScratchContext,
286
+ query?: Record<string, string>,
287
+ requestBody?: any,
288
+ authHeader?: string
289
+ ) => Promise<any>
290
+ > {
291
+ const universe = this.universe;
292
+ const noAuth = endpoint.noAuth || false;
293
+ const requiredModules = endpoint.requiredModules || [];
294
+
295
+ // Get the block definition to extract schema
296
+ const blockDef = await endpoint.block({});
297
+ const schema = blockDef.schema;
298
+ const isLoadEndpointsUI = blockDef.opcode === "loadEndpointsUI";
299
+
300
+ // Create the wrapped handler
301
+ return async (
302
+ context: ScratchContext,
303
+ query: Record<string, string> = {},
304
+ requestBody: any = undefined,
305
+ authHeader: string | undefined = undefined
306
+ ) => {
307
+ // Auth check - skip for loadEndpointsUI GET requests (it handles auth via query param)
308
+ if (!noAuth && !isLoadEndpointsUI) {
309
+ if (!universe.auth.isConfigured()) {
310
+ throw new Error("JWT authentication not configured");
311
+ }
312
+
313
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
314
+ throw new Error("Missing or invalid authorization header");
315
+ }
316
+
317
+ const token = authHeader.substring(7);
318
+ try {
319
+ const payload = await universe.auth.validateJwtToken(token);
320
+ if (!payload) {
321
+ throw new Error("Invalid or expired token");
322
+ }
323
+ } catch {
324
+ throw new Error("Invalid or expired token");
325
+ }
326
+ }
327
+
328
+ // Extract user email from auth header if present
329
+ let userEmail: string | undefined;
330
+ try {
331
+ if (authHeader?.startsWith("Bearer ")) {
332
+ const payload = await universe.auth.validateJwtToken(
333
+ authHeader.substring(7)
334
+ );
335
+ if (payload) userEmail = (payload as any)?.email;
336
+ }
337
+ } catch {}
338
+
339
+ // Update context with user email, authHeader, and ensure universe is set
340
+ const enrichedContext: ScratchContext = {
341
+ ...context,
342
+ userEmail,
343
+ universe: universe,
344
+ authHeader: authHeader,
345
+ };
346
+
347
+ // Module validation
348
+ if (requiredModules.length > 0) {
349
+ const missingModules = requiredModules.filter(
350
+ (module) => !universe.hasModule(module)
351
+ );
352
+ if (missingModules.length > 0) {
353
+ throw new Error(
354
+ `Required modules not available: ${missingModules.join(", ")}`
355
+ );
356
+ }
357
+ }
358
+
359
+ // Schema validation
360
+ if (schema) {
361
+ const validator =
362
+ universe?.jsonSchemaValidator || new JsonSchemaValidator();
363
+ const fullSchema = this.constructJsonSchema(schema);
364
+ const isGet = blockDef.blockType === "reporter";
365
+
366
+ // For GET requests, query params go directly into inputs
367
+ // For POST requests, request body goes into inputs
368
+ let data: any = isGet
369
+ ? Object.fromEntries(
370
+ Object.keys(schema).map((key) => [key, query[key] || undefined])
371
+ )
372
+ : requestBody || {};
373
+
374
+ // Handle JSON type properties
375
+ if (schema) {
376
+ for (const [key, propSchema] of Object.entries(schema)) {
377
+ const typedPropSchema = propSchema as SchemaProperty;
378
+ if (
379
+ typedPropSchema.type === "json" &&
380
+ data[key] !== undefined &&
381
+ data[key] !== null &&
382
+ data[key] !== ""
383
+ ) {
384
+ try {
385
+ // If data[key] is already an object, use it directly
386
+ let parsed = data[key];
387
+ if (typeof data[key] === "string") {
388
+ parsed = JSON.parse(data[key]);
389
+ }
390
+ if (typedPropSchema.schema) {
391
+ const wrappedSchema: JsonSchema = {
392
+ type: "object",
393
+ properties: { value: typedPropSchema.schema },
394
+ required: ["value"],
395
+ };
396
+ const result = validator.validate(wrappedSchema, {
397
+ value: parsed,
398
+ });
399
+ if (!result.valid) {
400
+ throw new Error(
401
+ `Validation failed for ${key}: ${JSON.stringify(
402
+ result.errors
403
+ )}`
404
+ );
405
+ }
406
+ data[key] = result.data?.value ?? parsed;
407
+ } else {
408
+ data[key] = parsed;
409
+ }
410
+ } catch (parseError) {
411
+ throw new Error(
412
+ `Invalid JSON for ${key}: ${
413
+ parseError instanceof Error
414
+ ? parseError.message
415
+ : "Unknown error"
416
+ }`
417
+ );
418
+ }
419
+ }
420
+ }
421
+ }
422
+
423
+ // Validate the data
424
+ const dataForValidation: any = { ...data };
425
+ if (schema) {
426
+ for (const [key, propSchema] of Object.entries(schema)) {
427
+ const typedPropSchema = propSchema as SchemaProperty;
428
+ if (
429
+ typedPropSchema.type === "json" &&
430
+ dataForValidation[key] !== undefined
431
+ )
432
+ delete dataForValidation[key];
433
+ }
434
+ }
435
+
436
+ const result = validator.validate(fullSchema, dataForValidation);
437
+ if (!result.valid) {
438
+ throw new Error(
439
+ `Validation failed: ${JSON.stringify(result.errors)}`
440
+ );
441
+ }
442
+
443
+ const finalData = { ...result.data };
444
+ if (schema) {
445
+ for (const [key, propSchema] of Object.entries(schema)) {
446
+ const typedPropSchema = propSchema as SchemaProperty;
447
+ if (typedPropSchema.type === "json" && data[key] !== undefined)
448
+ finalData[key] = data[key];
449
+ }
450
+ }
451
+
452
+ enrichedContext.inputs = finalData;
453
+ } else {
454
+ // For endpoints without schema, set empty inputs
455
+ enrichedContext.inputs = {};
456
+ }
457
+
458
+ // Call the original handler
459
+ return await endpoint.handler(enrichedContext);
460
+ };
461
+ }
462
+
463
+ private constructJsonSchema(schema?: ScratchBlock["schema"]): JsonSchema {
464
+ if (!schema)
465
+ return {
466
+ type: "object",
467
+ properties: {},
468
+ required: [],
469
+ additionalProperties: false,
470
+ };
471
+ const properties: any = {};
472
+ const required: string[] = [];
473
+ for (const [key, propSchema] of Object.entries(schema)) {
474
+ const typedPropSchema = propSchema as SchemaProperty;
475
+ if (typedPropSchema.type === "json") {
476
+ if (!typedPropSchema.schema)
477
+ throw new Error(
478
+ `Property ${key} has type "json" but no schema provided`
479
+ );
480
+ properties[key] = {
481
+ type: "string",
482
+ description: typedPropSchema.description,
483
+ _jsonSchema: typedPropSchema.schema,
484
+ };
485
+ } else {
486
+ // Copy schema but exclude non-JSON-Schema fields
487
+ const {
488
+ default: _,
489
+ description: __,
490
+ ...jsonSchemaProps
491
+ } = typedPropSchema;
492
+ properties[key] = jsonSchemaProps;
493
+ // Only add to required if there's no default or default is a placeholder
494
+ if (
495
+ !typedPropSchema.default ||
496
+ typedPropSchema.default === `[${key}]`
497
+ ) {
498
+ required.push(key);
499
+ }
500
+ }
501
+ }
502
+ return {
503
+ type: "object",
504
+ properties,
505
+ required,
506
+ additionalProperties: false,
507
+ };
508
+ }
509
+
510
+ private async createHandlerForEndpoint(
511
+ endpoint: ScratchEndpointDefinition
512
+ ): Promise<RequestHandler> {
513
+ const blockDef = await endpoint.block({});
514
+ const opcode = blockDef.opcode || "unknown";
515
+
516
+ return async (
517
+ req: Request,
518
+ res: ExpressResponse,
519
+ next: express.NextFunction
520
+ ) => {
521
+ // Log when handler is invoked - this should ALWAYS be called if route matches
522
+ log({
523
+ level: "info",
524
+ event: "handler_invoked",
525
+ opcode,
526
+ path: req.path,
527
+ method: req.method,
528
+ url: req.url,
529
+ });
530
+
531
+ try {
532
+ const query = this.filterVercelParams(
533
+ req.query as Record<string, string>
534
+ );
535
+ const authHeader = req.headers.authorization || "";
536
+ const requestBody = req.body;
537
+
538
+ const scratchContext: ScratchContext = {
539
+ universe: this.universe,
540
+ authHeader: authHeader as string,
541
+ requestHost: req.headers.host || "",
542
+ };
543
+
544
+ // Get blockDef to check if this is loadEndpointsUI
545
+ const blockDef = await endpoint.block({});
546
+ const wrappedHandler = await this.wrapHandlerWithAuthAndValidation(
547
+ endpoint
548
+ );
549
+
550
+ const result = await wrappedHandler(
551
+ scratchContext,
552
+ query,
553
+ requestBody,
554
+ authHeader as string
555
+ );
556
+
557
+ // Handle different result types
558
+ if (result instanceof globalThis.Response) {
559
+ // Copy Fetch Response to Express response
560
+ const headers = Object.fromEntries(result.headers.entries());
561
+ res.status(result.status);
562
+ Object.entries(headers).forEach(([key, value]) => {
563
+ res.header(key, value);
564
+ });
565
+ const text = await result.text();
566
+ res.send(text);
567
+ return;
568
+ }
569
+
570
+ if (typeof result === "string") {
571
+ res.contentType("text/html");
572
+ res.send(result);
573
+ return;
574
+ }
575
+
576
+ if (result === null || result === undefined) {
577
+ res.json({ success: true });
578
+ return;
579
+ }
580
+
581
+ res.json(result);
582
+ } catch (error: any) {
583
+ const errorMessage =
584
+ error instanceof Error ? error.message : "Unknown error";
585
+ const statusCode =
586
+ errorMessage.includes("authentication") ||
587
+ errorMessage.includes("authorization") ||
588
+ errorMessage.includes("token")
589
+ ? 401
590
+ : errorMessage.includes("Validation failed") ||
591
+ errorMessage.includes("Invalid")
592
+ ? 400
593
+ : errorMessage.includes("modules not available")
594
+ ? 503
595
+ : 500;
596
+
597
+ log({
598
+ level: "error",
599
+ event: "handler_error",
600
+ req_id: (req as any).reqId,
601
+ opcode: (req as any).opcode || "unknown",
602
+ error: errorMessage,
603
+ statusCode,
604
+ });
605
+
606
+ res.status(statusCode).json({ error: errorMessage });
607
+ }
608
+ };
609
+ }
610
+
611
+ private filterVercelParams(
612
+ params: Record<string, string>
613
+ ): Record<string, string> {
614
+ const filtered: Record<string, string> = {};
615
+ for (const [key, value] of Object.entries(params)) {
616
+ if (!key.startsWith("...")) {
617
+ filtered[key] = value;
618
+ }
619
+ }
620
+ return filtered;
621
+ }
622
+
623
+ registerStaticFiles(rootPath: string): void {
624
+ this.staticRoot = rootPath;
625
+ // Register static files only for /public/* paths to avoid conflicts with API routes
626
+ this.app.use("/public", express.static(rootPath));
627
+ }
628
+
629
+ async start(port: number): Promise<void> {
630
+ return new Promise((resolve) => {
631
+ this.server = this.app.listen(port, () => {
632
+ console.log(`🚀 Server running on http://localhost:${port}`);
633
+
634
+ // Note: Router inspection removed - Express may lazy-load the router,
635
+ // and it's not critical for server operation. Routes are handled by
636
+ // the catch-all route and endpoint maps, which are the source of truth.
637
+
638
+ resolve();
639
+ });
640
+ });
641
+ }
642
+
643
+ async stop(): Promise<void> {
644
+ return new Promise((resolve) => {
645
+ if (!this.server) {
646
+ resolve();
647
+ return;
648
+ }
649
+ this.server.close(() => {
650
+ this.server = null;
651
+ resolve();
652
+ });
653
+ });
654
+ }
655
+
656
+ getExpressApp(): Express {
657
+ return this.app;
658
+ }
659
+
660
+ async registerEndpoints(
661
+ endpoints: ScratchEndpointDefinition[]
662
+ ): Promise<void> {
663
+ for (const endpoint of endpoints) {
664
+ const blockDef = await endpoint.block({});
665
+ if (!blockDef.opcode || blockDef.opcode === "") {
666
+ throw new Error("Endpoint opcode cannot be empty");
667
+ }
668
+ this.endpoints.set(blockDef.opcode, endpoint);
669
+ await this.registerRoute(blockDef.opcode, endpoint);
670
+ }
671
+ }
672
+
673
+ getAllEndpoints(): ScratchEndpointDefinition[] {
674
+ return Array.from(this.endpoints.values());
675
+ }
676
+
677
+ async getRegisteredEndpoints(): Promise<
678
+ Array<{
679
+ method: string;
680
+ endpoint: string;
681
+ blockType: string;
682
+ auth: string;
683
+ text: string;
684
+ }>
685
+ > {
686
+ const endpoints = this.getAllEndpoints();
687
+ const endpointInfos = await Promise.all(
688
+ endpoints.map(async (endpoint) => {
689
+ const blockDef = await endpoint.block({});
690
+ const opcode = blockDef.opcode!;
691
+ const method = blockDef.blockType === "reporter" ? "GET" : "POST";
692
+ const auth = endpoint.noAuth ? " (no auth)" : "";
693
+ return {
694
+ method,
695
+ endpoint: `/${opcode}`,
696
+ blockType: blockDef.blockType,
697
+ auth,
698
+ text: blockDef.text,
699
+ };
700
+ })
701
+ );
702
+ return endpointInfos.sort((a, b) => a.text.localeCompare(b.text));
703
+ }
704
+
705
+ async getEndpointHandlers(): Promise<
706
+ Record<string, (context: any) => Promise<any>>
707
+ > {
708
+ const handlers: Record<string, (context: any) => Promise<any>> = {};
709
+ for (const [opcode, endpoint] of this.endpoints) {
710
+ const wrappedHandler = await this.wrapHandlerWithAuthAndValidation(
711
+ endpoint
712
+ );
713
+ handlers[opcode] = wrappedHandler;
714
+ }
715
+ return handlers;
716
+ }
717
+
718
+ async getHandler(
719
+ opcode: string
720
+ ): Promise<
721
+ | ((
722
+ context: ScratchContext,
723
+ query?: Record<string, string>,
724
+ requestBody?: any,
725
+ authHeader?: string
726
+ ) => Promise<any>)
727
+ | undefined
728
+ > {
729
+ const endpoint = this.endpoints.get(opcode);
730
+ if (!endpoint) return undefined;
731
+
732
+ return await this.wrapHandlerWithAuthAndValidation(endpoint);
733
+ }
734
+
735
+ async registerEndpoint(source: string): Promise<{
736
+ success: boolean;
737
+ opcode?: string;
738
+ message?: string;
739
+ error?: string;
740
+ }> {
741
+ try {
742
+ const tempFile = `/tmp/endpoint_${Date.now()}_${randomUUID()}.ts`;
743
+ await Bun.write(tempFile, source);
744
+
745
+ try {
746
+ const module = await import(tempFile);
747
+ const endpoint = Object.values(module).find(
748
+ (value): value is ScratchEndpointDefinition =>
749
+ value !== null &&
750
+ typeof value === "object" &&
751
+ "block" in value &&
752
+ "handler" in value
753
+ );
754
+
755
+ if (!endpoint) {
756
+ throw new Error("No endpoint definition found in source code");
757
+ }
758
+
759
+ const blockDef = await endpoint.block({});
760
+ if (!blockDef.opcode || blockDef.opcode === "") {
761
+ throw new Error("Endpoint opcode cannot be empty");
762
+ }
763
+
764
+ this.endpoints.set(blockDef.opcode, endpoint);
765
+ await this.registerRoute(blockDef.opcode, endpoint);
766
+
767
+ return {
768
+ success: true,
769
+ opcode: blockDef.opcode,
770
+ message: `Endpoint "${blockDef.opcode}" registered successfully`,
771
+ };
772
+ } finally {
773
+ try {
774
+ await Bun.file(tempFile).unlink();
775
+ } catch {}
776
+ }
777
+ } catch (error: any) {
778
+ return {
779
+ success: false,
780
+ error: error.message || String(error),
781
+ };
782
+ }
783
+ }
784
+
785
+ async removeEndpoint(opcode: string): Promise<{
786
+ success: boolean;
787
+ message?: string;
788
+ error?: string;
789
+ }> {
790
+ try {
791
+ if (!opcode || opcode === "") {
792
+ throw new Error("Opcode cannot be empty");
793
+ }
794
+
795
+ const existed = this.endpoints.has(opcode);
796
+ this.endpoints.delete(opcode);
797
+ this.routeHandlers.delete(opcode);
798
+
799
+ // Note: Express doesn't have a direct way to remove routes, but since we track them
800
+ // in routeHandlers, new requests will use the updated endpoint map
801
+
802
+ return {
803
+ success: true,
804
+ message: existed
805
+ ? `Endpoint "${opcode}" removed successfully`
806
+ : `Endpoint "${opcode}" was not registered`,
807
+ };
808
+ } catch (error: any) {
809
+ return {
810
+ success: false,
811
+ error: error.message || String(error),
812
+ };
813
+ }
814
+ }
815
+
816
+ // Endpoint loading methods
817
+ async loadEndpointsFromUrl(
818
+ url: string
819
+ ): Promise<{ loaded: number; failed: number; errors: string[] }> {
820
+ log({
821
+ level: "info",
822
+ event: "loading_endpoints",
823
+ url,
824
+ });
825
+
826
+ try {
827
+ const parsedUrl = new URL(url);
828
+
829
+ if (parsedUrl.protocol === "file:") {
830
+ let localPath = parsedUrl.pathname;
831
+ if (platform === "win32" && localPath.match(/^\/[A-Za-z]:/)) {
832
+ localPath = localPath.substring(1);
833
+ }
834
+ localPath = decodeURIComponent(localPath);
835
+
836
+ if (!isAbsolute(localPath)) {
837
+ throw new Error(
838
+ `Expected absolute path from file:// URL, got: ${localPath}`
839
+ );
840
+ }
841
+
842
+ return await this.loadEndpointsFromFilesystem(localPath);
843
+ } else if (
844
+ parsedUrl.protocol === "https:" &&
845
+ parsedUrl.hostname === "github.com"
846
+ ) {
847
+ return await this.loadEndpointsFromGitHub(url);
848
+ } else {
849
+ throw new Error(
850
+ `Unsupported URL protocol: ${parsedUrl.protocol}. Supported: file://, https://github.com`
851
+ );
852
+ }
853
+ } catch (error) {
854
+ if (error instanceof TypeError) {
855
+ throw new Error(
856
+ `Invalid URL format: ${url}. Must be a valid file:// or https://github.com URL`
857
+ );
858
+ } else {
859
+ throw error;
860
+ }
861
+ }
862
+ }
863
+
864
+ private async loadEndpointsFromFilesystem(
865
+ directoryPath: string
866
+ ): Promise<{ loaded: number; failed: number; errors: string[] }> {
867
+ if (!isAbsolute(directoryPath)) {
868
+ throw new Error(`Expected absolute path, got: ${directoryPath}`);
869
+ }
870
+
871
+ log({
872
+ level: "info",
873
+ event: "loading_endpoints_from_filesystem",
874
+ directoryPath,
875
+ });
876
+
877
+ const filePaths = await this.iterateEndpointFiles(directoryPath);
878
+ log({
879
+ level: "info",
880
+ event: "endpoint_files_found",
881
+ count: filePaths.length,
882
+ files: filePaths.map((f) => f.split("/").pop()),
883
+ });
884
+
885
+ const loadedEndpoints: ScratchEndpointDefinition[] = [];
886
+ const errors: string[] = [];
887
+ let loadedCount = 0;
888
+ let failedCount = 0;
889
+
890
+ for (const filePath of filePaths) {
891
+ try {
892
+ const source = await readFile(filePath, "utf-8");
893
+ const endpoint = await this.parseEndpointFromSource(source, filePath);
894
+ const blockDef = await endpoint.block({});
895
+ if (!blockDef.opcode || blockDef.opcode === "") {
896
+ throw new Error(`Endpoint from ${filePath} has empty opcode`);
897
+ }
898
+ this.endpoints.set(blockDef.opcode, endpoint);
899
+ loadedEndpoints.push(endpoint);
900
+ loadedCount++;
901
+ await this.registerRoute(blockDef.opcode, endpoint);
902
+ log({
903
+ level: "info",
904
+ event: "endpoint_loaded",
905
+ opcode: blockDef.opcode,
906
+ file: filePath.split("/").pop(),
907
+ });
908
+ } catch (error) {
909
+ failedCount++;
910
+ const errorMessage =
911
+ error instanceof Error ? error.message : String(error);
912
+ const fileName = filePath.split("/").pop() || filePath;
913
+ errors.push(`${fileName}: ${errorMessage}`);
914
+ log({
915
+ level: "error",
916
+ event: "endpoint_load_failed",
917
+ file: fileName,
918
+ error: errorMessage,
919
+ });
920
+ }
921
+ }
922
+
923
+ await this.registerEndpoints(loadedEndpoints);
924
+
925
+ log({
926
+ level: "info",
927
+ event: "endpoints_loaded",
928
+ total: this.endpoints.size,
929
+ loaded: loadedCount,
930
+ failed: failedCount,
931
+ });
932
+
933
+ return { loaded: loadedCount, failed: failedCount, errors };
934
+ }
935
+
936
+ private async loadEndpointsFromGitHub(
937
+ githubUrl: string
938
+ ): Promise<{ loaded: number; failed: number; errors: string[] }> {
939
+ const errors: string[] = [];
940
+ let loadedCount = 0;
941
+ let failedCount = 0;
942
+
943
+ const urlMatch = githubUrl.match(
944
+ /https:\/\/github\.com\/([^\/]+)\/([^\/]+)\/tree\/([^\/]+)\/(.+)$/
945
+ );
946
+ if (!urlMatch) {
947
+ const errorMsg = `Invalid GitHub URL format. Expected: https://github.com/owner/repo/tree/branch/path, got: ${githubUrl}`;
948
+ errors.push(errorMsg);
949
+ return { loaded: 0, failed: 1, errors };
950
+ }
951
+
952
+ const [, owner, repo, branch, path] = urlMatch;
953
+ const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`;
954
+ log({ level: "info", event: "fetching_github_file_list", apiUrl });
955
+
956
+ let files: Array<{ name: string; type: string; download_url?: string }> =
957
+ [];
958
+ try {
959
+ const response = await fetch(apiUrl, {
960
+ headers: {
961
+ Accept: "application/vnd.github.v3+json",
962
+ "User-Agent": "scratch-server",
963
+ },
964
+ });
965
+
966
+ if (!response.ok) {
967
+ const errorMsg = `Failed to fetch GitHub file list: ${response.status} ${response.statusText}`;
968
+ errors.push(errorMsg);
969
+ log({
970
+ level: "error",
971
+ event: "github_file_list_fetch_failed",
972
+ error: errorMsg,
973
+ });
974
+ return { loaded: 0, failed: 1, errors };
975
+ }
976
+
977
+ files = (await response.json()) as Array<{
978
+ name: string;
979
+ type: string;
980
+ download_url?: string;
981
+ }>;
982
+ } catch (error) {
983
+ const errorMsg = error instanceof Error ? error.message : String(error);
984
+ errors.push(`Failed to fetch GitHub file list: ${errorMsg}`);
985
+ log({
986
+ level: "error",
987
+ event: "github_file_list_fetch_error",
988
+ error: errorMsg,
989
+ });
990
+ return { loaded: 0, failed: 1, errors };
991
+ }
992
+
993
+ const tsFiles = files.filter(
994
+ (f) => f.type === "file" && f.name.endsWith(".ts")
995
+ );
996
+
997
+ log({
998
+ level: "info",
999
+ event: "github_files_found",
1000
+ count: tsFiles.length,
1001
+ files: tsFiles.map((f) => f.name),
1002
+ });
1003
+
1004
+ const loadedEndpoints: ScratchEndpointDefinition[] = [];
1005
+
1006
+ for (const file of tsFiles) {
1007
+ try {
1008
+ const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}/${file.name}`;
1009
+ log({
1010
+ level: "info",
1011
+ event: "fetching_github_file",
1012
+ file: file.name,
1013
+ url: rawUrl,
1014
+ });
1015
+
1016
+ const fileResponse = await fetch(rawUrl, {
1017
+ headers: { "User-Agent": "scratch-server" },
1018
+ });
1019
+
1020
+ if (!fileResponse.ok) {
1021
+ throw new Error(
1022
+ `Failed to fetch file ${file.name}: ${fileResponse.status} ${fileResponse.statusText}`
1023
+ );
1024
+ }
1025
+
1026
+ const source = await fileResponse.text();
1027
+
1028
+ const firstLines = source.split("\n").slice(0, 5).join("\n");
1029
+ log({
1030
+ level: "info",
1031
+ event: "github_file_content_preview",
1032
+ file: file.name,
1033
+ firstLines: firstLines,
1034
+ totalLines: source.split("\n").length,
1035
+ });
1036
+
1037
+ const endpoint = await this.parseEndpointFromSource(source, file.name);
1038
+ const blockDef = await endpoint.block({});
1039
+ if (!blockDef.opcode || blockDef.opcode === "") {
1040
+ throw new Error(`Endpoint from ${file.name} has empty opcode`);
1041
+ }
1042
+ this.endpoints.set(blockDef.opcode, endpoint);
1043
+ loadedEndpoints.push(endpoint);
1044
+ loadedCount++;
1045
+ await this.registerRoute(blockDef.opcode, endpoint);
1046
+ log({
1047
+ level: "info",
1048
+ event: "endpoint_loaded",
1049
+ opcode: blockDef.opcode,
1050
+ file: file.name,
1051
+ });
1052
+ } catch (error) {
1053
+ failedCount++;
1054
+ const errorMessage =
1055
+ error instanceof Error ? error.message : String(error);
1056
+ errors.push(`${file.name}: ${errorMessage}`);
1057
+ log({
1058
+ level: "error",
1059
+ event: "endpoint_load_failed",
1060
+ file: file.name,
1061
+ error: errorMessage,
1062
+ });
1063
+ }
1064
+ }
1065
+
1066
+ await this.registerEndpoints(loadedEndpoints);
1067
+
1068
+ log({
1069
+ level: "info",
1070
+ event: "github_endpoints_loaded",
1071
+ total: this.endpoints.size,
1072
+ loaded: loadedCount,
1073
+ failed: failedCount,
1074
+ });
1075
+
1076
+ return { loaded: loadedCount, failed: failedCount, errors };
1077
+ }
1078
+
1079
+ private async iterateEndpointFiles(directoryPath: string): Promise<string[]> {
1080
+ const files: string[] = [];
1081
+ try {
1082
+ const entries = await readdir(directoryPath, { withFileTypes: true });
1083
+ for (const entry of entries) {
1084
+ const fullPath = join(directoryPath, entry.name);
1085
+ if (entry.isDirectory()) {
1086
+ files.push(...(await this.iterateEndpointFiles(fullPath)));
1087
+ } else if (entry.isFile() && entry.name.endsWith(".ts")) {
1088
+ files.push(fullPath);
1089
+ }
1090
+ }
1091
+ } catch (error) {
1092
+ log({
1093
+ level: "error",
1094
+ event: "directory_read_failed",
1095
+ directoryPath,
1096
+ error: error instanceof Error ? error.message : String(error),
1097
+ });
1098
+ }
1099
+ return files;
1100
+ }
1101
+
1102
+ private async parseEndpointFromSource(
1103
+ source: string,
1104
+ filePath: string
1105
+ ): Promise<ScratchEndpointDefinition> {
1106
+ const absolutePath = resolve(filePath);
1107
+ let finalPath = absolutePath;
1108
+ let fileExists = false;
1109
+ try {
1110
+ await stat(absolutePath);
1111
+ fileExists = true;
1112
+ } catch {
1113
+ // File doesn't exist - write GitHub source to temporary file in project directory
1114
+ // This ensures npm packages can be resolved correctly
1115
+ const projectRoot = await getProjectRoot();
1116
+ const tempDir = join(
1117
+ projectRoot,
1118
+ ".scratch-endpoints-temp",
1119
+ randomUUID()
1120
+ );
1121
+ await mkdir(tempDir, { recursive: true });
1122
+ finalPath = join(tempDir, basename(filePath));
1123
+ await writeFile(finalPath, source, "utf-8");
1124
+ }
1125
+
1126
+ // Import the file using native import - works identically for local and GitHub files
1127
+ const module = await import(finalPath);
1128
+
1129
+ // Extract the endpoint definition from the module
1130
+ const endpointName = basename(filePath, ".ts");
1131
+
1132
+ // Try to find the endpoint in the module
1133
+ let endpoint = module[endpointName] || module.default;
1134
+
1135
+ // If not found by name, search for any object with block and handler
1136
+ if (!endpoint && module && typeof module === "object") {
1137
+ endpoint =
1138
+ Object.values(module).find(
1139
+ (value): value is ScratchEndpointDefinition =>
1140
+ value !== null &&
1141
+ typeof value === "object" &&
1142
+ "block" in value &&
1143
+ "handler" in value
1144
+ ) || null;
1145
+ }
1146
+
1147
+ // If still not found, check if module itself is the endpoint
1148
+ if (
1149
+ !endpoint &&
1150
+ module &&
1151
+ typeof module === "object" &&
1152
+ "block" in module &&
1153
+ "handler" in module
1154
+ ) {
1155
+ endpoint = module as ScratchEndpointDefinition;
1156
+ }
1157
+
1158
+ if (!endpoint) {
1159
+ throw new Error(`No endpoint definition found in ${filePath}`);
1160
+ }
1161
+
1162
+ if (
1163
+ !endpoint.block ||
1164
+ typeof endpoint.block !== "function" ||
1165
+ !endpoint.handler ||
1166
+ typeof endpoint.handler !== "function"
1167
+ ) {
1168
+ throw new Error(
1169
+ `Invalid endpoint definition in ${filePath}: missing block or handler`
1170
+ );
1171
+ }
1172
+
1173
+ return endpoint as ScratchEndpointDefinition;
1174
+ }
1175
+ }