@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.
- package/basic/index.ts +6 -1
- package/core/ProjectRoot.ts +9 -0
- package/core/Universe.ts +20 -27
- package/http-server/ExpressHttpServer.ts +1175 -0
- package/http-server/HttpServer.ts +13 -10
- package/http-server/default-endpoints/index.ts +4 -0
- package/http-server/default-endpoints/loadEndpoints.ts +69 -0
- package/http-server/default-endpoints/loadEndpointsUI.ts +43 -0
- package/http-server/index.ts +1 -1
- package/index.ts +43 -0
- package/package.json +3 -1
- package/s2/index.ts +19 -0
- package/http-server/NativeHttpServer.ts +0 -1084
- package/http-server/middlewares/01-cors.ts +0 -33
- package/http-server/middlewares/02-static.ts +0 -67
- package/http-server/middlewares/03-request-logger.ts +0 -159
- package/http-server/middlewares/04-body-parser.ts +0 -54
- package/http-server/middlewares/05-no-cache.ts +0 -23
- package/http-server/middlewares/06-response-handler.ts +0 -39
- package/http-server/middlewares/handler-wrapper.ts +0 -250
- package/http-server/middlewares/index.ts +0 -37
- package/http-server/middlewares/types.ts +0 -27
|
@@ -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
|
+
}
|