@divizend/scratch-core 1.0.0

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 (61) hide show
  1. package/basic/demo.ts +11 -0
  2. package/basic/index.ts +490 -0
  3. package/core/Auth.ts +63 -0
  4. package/core/Currency.ts +16 -0
  5. package/core/Env.ts +186 -0
  6. package/core/Fragment.ts +43 -0
  7. package/core/FragmentServingMode.ts +37 -0
  8. package/core/JsonSchemaValidator.ts +173 -0
  9. package/core/ProjectRoot.ts +76 -0
  10. package/core/Scratch.ts +44 -0
  11. package/core/URI.ts +203 -0
  12. package/core/Universe.ts +406 -0
  13. package/core/index.ts +27 -0
  14. package/gsuite/core/GSuite.ts +237 -0
  15. package/gsuite/core/GSuiteAdmin.ts +81 -0
  16. package/gsuite/core/GSuiteOrgConfig.ts +47 -0
  17. package/gsuite/core/GSuiteUser.ts +115 -0
  18. package/gsuite/core/index.ts +21 -0
  19. package/gsuite/documents/Document.ts +173 -0
  20. package/gsuite/documents/Documents.ts +52 -0
  21. package/gsuite/documents/index.ts +19 -0
  22. package/gsuite/drive/Drive.ts +118 -0
  23. package/gsuite/drive/DriveFile.ts +147 -0
  24. package/gsuite/drive/index.ts +19 -0
  25. package/gsuite/gmail/Gmail.ts +430 -0
  26. package/gsuite/gmail/GmailLabel.ts +55 -0
  27. package/gsuite/gmail/GmailMessage.ts +428 -0
  28. package/gsuite/gmail/GmailMessagePart.ts +298 -0
  29. package/gsuite/gmail/GmailThread.ts +97 -0
  30. package/gsuite/gmail/index.ts +5 -0
  31. package/gsuite/gmail/utils.ts +184 -0
  32. package/gsuite/index.ts +28 -0
  33. package/gsuite/spreadsheets/CellValue.ts +71 -0
  34. package/gsuite/spreadsheets/Sheet.ts +128 -0
  35. package/gsuite/spreadsheets/SheetValues.ts +12 -0
  36. package/gsuite/spreadsheets/Spreadsheet.ts +76 -0
  37. package/gsuite/spreadsheets/Spreadsheets.ts +52 -0
  38. package/gsuite/spreadsheets/index.ts +25 -0
  39. package/gsuite/spreadsheets/utils.ts +52 -0
  40. package/gsuite/utils.ts +104 -0
  41. package/http-server/HttpServer.ts +110 -0
  42. package/http-server/NativeHttpServer.ts +1084 -0
  43. package/http-server/index.ts +3 -0
  44. package/http-server/middlewares/01-cors.ts +33 -0
  45. package/http-server/middlewares/02-static.ts +67 -0
  46. package/http-server/middlewares/03-request-logger.ts +159 -0
  47. package/http-server/middlewares/04-body-parser.ts +54 -0
  48. package/http-server/middlewares/05-no-cache.ts +23 -0
  49. package/http-server/middlewares/06-response-handler.ts +39 -0
  50. package/http-server/middlewares/handler-wrapper.ts +250 -0
  51. package/http-server/middlewares/index.ts +37 -0
  52. package/http-server/middlewares/types.ts +27 -0
  53. package/index.ts +24 -0
  54. package/package.json +37 -0
  55. package/queue/EmailQueue.ts +228 -0
  56. package/queue/RateLimiter.ts +54 -0
  57. package/queue/index.ts +2 -0
  58. package/resend/Resend.ts +190 -0
  59. package/resend/index.ts +11 -0
  60. package/s2/S2.ts +335 -0
  61. package/s2/index.ts +11 -0
@@ -0,0 +1,1084 @@
1
+ /**
2
+ * NativeHttpServer - Native Node.js HTTP server implementation
3
+ *
4
+ * This implementation uses Node's native http module for full control
5
+ * over routing and dynamic route registration.
6
+ */
7
+
8
+ import {
9
+ createServer,
10
+ Server,
11
+ IncomingMessage,
12
+ ServerResponse,
13
+ } from "node:http";
14
+ import { readFile } from "node:fs/promises";
15
+ import { resolve, isAbsolute, join, basename } from "node:path";
16
+ import { readdir } from "node:fs/promises";
17
+ import { randomUUID } from "node:crypto";
18
+ import { HttpServer } from "./HttpServer";
19
+ import {
20
+ Universe,
21
+ ScratchContext,
22
+ ScratchEndpointDefinition,
23
+ envOrDefault,
24
+ } from "../index";
25
+ import {
26
+ createMiddlewareChain,
27
+ wrapHandlerWithAuthAndValidation,
28
+ handleHandlerResult,
29
+ MiddlewareContext,
30
+ Middleware,
31
+ } from "./middlewares";
32
+
33
+ // Minimal structured logger - automatically adds timestamp unless explicitly provided
34
+ const log = (record: Record<string, unknown>) => {
35
+ if (!record.ts) {
36
+ record.ts = new Date().toISOString();
37
+ }
38
+ console.log(JSON.stringify(record));
39
+ };
40
+
41
+ export class NativeHttpServer implements HttpServer {
42
+ private server: Server | null = null;
43
+ private universe: Universe;
44
+ private isInitialized: boolean = false;
45
+ private initPromise: Promise<void> | null = null;
46
+ // Single source of truth: endpoints KV store
47
+ private endpoints: Map<string, ScratchEndpointDefinition> = new Map();
48
+ private staticRoot: string | null = null;
49
+ private middlewares: Middleware[] = [];
50
+
51
+ constructor(universe: Universe) {
52
+ this.universe = universe;
53
+ this.setupMiddlewares();
54
+ }
55
+
56
+ private setupMiddlewares(): void {
57
+ // Setup middleware chain in order
58
+ this.middlewares = createMiddlewareChain(this.staticRoot);
59
+ }
60
+
61
+ private filterVercelParams(params: URLSearchParams): Record<string, string> {
62
+ const query: Record<string, string> = {};
63
+ params.forEach((value, key) => {
64
+ if (!key.startsWith("...")) {
65
+ query[key] = value;
66
+ }
67
+ });
68
+ return query;
69
+ }
70
+
71
+ private parseQuery(url: string): Record<string, string> {
72
+ try {
73
+ const urlObj = new URL(url, "http://localhost");
74
+ return this.filterVercelParams(urlObj.searchParams);
75
+ } catch {
76
+ // Fallback for relative URLs
77
+ const queryIndex = url.indexOf("?");
78
+ if (queryIndex < 0) return {};
79
+ const params = new URLSearchParams(url.substring(queryIndex + 1));
80
+ return this.filterVercelParams(params);
81
+ }
82
+ }
83
+
84
+ private parsePath(url: string): string {
85
+ try {
86
+ const urlObj = new URL(url, "http://localhost");
87
+ return urlObj.pathname;
88
+ } catch {
89
+ // Fallback for relative URLs
90
+ const queryIndex = url.indexOf("?");
91
+ return queryIndex >= 0 ? url.substring(0, queryIndex) : url;
92
+ }
93
+ }
94
+
95
+ private async executeMiddlewareChain(
96
+ ctx: MiddlewareContext,
97
+ index: number = 0
98
+ ): Promise<void> {
99
+ if (index >= this.middlewares.length) {
100
+ // All middlewares executed, now handle routing
101
+ log({
102
+ level: "info",
103
+ event: "middleware_chain_complete",
104
+ req_id: ctx.metadata.requestId,
105
+ path: ctx.context.path,
106
+ });
107
+ await this.handleRouting(ctx);
108
+ return;
109
+ }
110
+
111
+ const middleware = this.middlewares[index];
112
+ let nextCalled = false;
113
+
114
+ const next = async () => {
115
+ if (nextCalled) return; // Prevent double-calling
116
+ nextCalled = true;
117
+ await this.executeMiddlewareChain(ctx, index + 1);
118
+ };
119
+
120
+ const result = middleware(ctx, next);
121
+ if (result instanceof Promise) {
122
+ await result;
123
+ }
124
+
125
+ // If middleware didn't call next(), it handled the request itself
126
+ // Don't continue the chain in that case
127
+ if (!nextCalled) {
128
+ return;
129
+ }
130
+ }
131
+
132
+ private async handleRouting(ctx: MiddlewareContext): Promise<void> {
133
+ const { req, res, context } = ctx;
134
+ const method = req.method || "GET";
135
+ const path = context.path || "";
136
+
137
+ log({
138
+ level: "info",
139
+ event: "handle_routing_start",
140
+ req_id: ctx.metadata.requestId,
141
+ method,
142
+ path,
143
+ });
144
+
145
+ // Extract opcode from path (remove leading slash, handle empty path)
146
+ // Map root path "/" to "root" endpoint
147
+ let opcode =
148
+ path === "/" ? "" : path.startsWith("/") ? path.substring(1) : path;
149
+
150
+ // If opcode is empty (root path), use "root" endpoint
151
+ if (opcode === "") {
152
+ opcode = "root";
153
+ }
154
+
155
+ log({
156
+ level: "info",
157
+ event: "opcode_extracted",
158
+ req_id: ctx.metadata.requestId,
159
+ opcode,
160
+ });
161
+
162
+ // Look up endpoint directly from KV store (current state, no cache)
163
+ const endpoint = this.endpoints.get(opcode);
164
+
165
+ if (!endpoint) {
166
+ log({
167
+ level: "info",
168
+ event: "endpoint_not_found",
169
+ req_id: ctx.metadata.requestId,
170
+ opcode,
171
+ total_endpoints: this.endpoints.size,
172
+ });
173
+ res.writeHead(404, { "Content-Type": "application/json" });
174
+ res.end(JSON.stringify({ error: "Not found" }));
175
+ return;
176
+ }
177
+
178
+ log({
179
+ level: "info",
180
+ event: "endpoint_found",
181
+ req_id: ctx.metadata.requestId,
182
+ opcode,
183
+ });
184
+
185
+ // Get endpoint metadata directly from endpoint definition
186
+ const blockDef = await endpoint.block({});
187
+ if (!blockDef.opcode || blockDef.opcode === "") {
188
+ res.writeHead(500, { "Content-Type": "application/json" });
189
+ res.end(JSON.stringify({ error: "Endpoint opcode cannot be empty" }));
190
+ return;
191
+ }
192
+
193
+ const expectedMethod = blockDef.blockType === "reporter" ? "GET" : "POST";
194
+ if (method.toUpperCase() !== expectedMethod) {
195
+ res.writeHead(405, { "Content-Type": "application/json" });
196
+ res.end(
197
+ JSON.stringify({
198
+ error: `Method not allowed. Expected ${expectedMethod}`,
199
+ })
200
+ );
201
+ return;
202
+ }
203
+
204
+ try {
205
+ log({
206
+ level: "info",
207
+ event: "building_handler",
208
+ req_id: ctx.metadata.requestId,
209
+ opcode,
210
+ });
211
+ // Build handler on-demand from current endpoint (always fresh)
212
+ const wrappedHandler = await wrapHandlerWithAuthAndValidation({
213
+ universe: this.universe,
214
+ endpoint,
215
+ noAuth: endpoint.noAuth || false,
216
+ requiredModules: endpoint.requiredModules || [],
217
+ });
218
+ log({
219
+ level: "info",
220
+ event: "handler_built",
221
+ req_id: ctx.metadata.requestId,
222
+ opcode,
223
+ });
224
+
225
+ const query = this.parseQuery(req.url || "");
226
+ const authHeader = req.headers.authorization || "";
227
+ const requestBody = (req as any).body;
228
+
229
+ // Extract request host for extension generation
230
+ const requestHost = req.headers.host || req.headers["host"] || "";
231
+
232
+ const scratchContext: ScratchContext = {
233
+ universe: this.universe,
234
+ authHeader: authHeader,
235
+ requestHost: requestHost,
236
+ };
237
+
238
+ log({
239
+ level: "info",
240
+ event: "executing_handler",
241
+ req_id: ctx.metadata.requestId,
242
+ opcode,
243
+ });
244
+ // Execute handler with current endpoint
245
+ const result = await wrappedHandler(
246
+ scratchContext,
247
+ query,
248
+ requestBody,
249
+ authHeader
250
+ );
251
+ log({
252
+ level: "info",
253
+ event: "handler_completed",
254
+ req_id: ctx.metadata.requestId,
255
+ opcode,
256
+ result_type: typeof result,
257
+ });
258
+
259
+ // Handle the result
260
+ handleHandlerResult(result, res);
261
+ log({
262
+ level: "info",
263
+ event: "response_sent",
264
+ req_id: ctx.metadata.requestId,
265
+ opcode,
266
+ });
267
+ } catch (error) {
268
+ const errorMessage =
269
+ error instanceof Error ? error.message : "Unknown error";
270
+ const statusCode =
271
+ errorMessage.includes("authentication") ||
272
+ errorMessage.includes("authorization") ||
273
+ errorMessage.includes("token")
274
+ ? 401
275
+ : errorMessage.includes("Validation failed") ||
276
+ errorMessage.includes("Invalid")
277
+ ? 400
278
+ : errorMessage.includes("modules not available")
279
+ ? 503
280
+ : 500;
281
+
282
+ log({
283
+ level: "error",
284
+ event: "handler_error",
285
+ req_id: ctx.metadata.requestId,
286
+ opcode,
287
+ error: errorMessage,
288
+ statusCode,
289
+ });
290
+ res.writeHead(statusCode, { "Content-Type": "application/json" });
291
+ res.end(JSON.stringify({ error: errorMessage }));
292
+ }
293
+ }
294
+
295
+ private async handleRequest(
296
+ req: IncomingMessage | any,
297
+ res: ServerResponse | any
298
+ ): Promise<void> {
299
+ // Wait for initialization
300
+ if (!this.isInitialized && this.initPromise) {
301
+ await this.initPromise;
302
+ }
303
+
304
+ const method = req.method || "GET";
305
+ const urlString = req.url || "/";
306
+ const path = this.parsePath(urlString);
307
+ const query = this.parseQuery(urlString);
308
+
309
+ // Create middleware context
310
+ const ctx: MiddlewareContext = {
311
+ req,
312
+ res,
313
+ context: {
314
+ universe: this.universe,
315
+ path,
316
+ query,
317
+ },
318
+ metadata: {},
319
+ };
320
+
321
+ try {
322
+ await this.executeMiddlewareChain(ctx);
323
+ } catch (error) {
324
+ if (!res.headersSent) {
325
+ res.writeHead(500, { "Content-Type": "application/json" });
326
+ res.end(
327
+ JSON.stringify({
328
+ error: error instanceof Error ? error.message : "Unknown error",
329
+ })
330
+ );
331
+ }
332
+ }
333
+ }
334
+
335
+ registerStaticFiles(rootPath: string): void {
336
+ this.staticRoot = rootPath;
337
+ // Rebuild middleware chain with new static root
338
+ this.setupMiddlewares();
339
+ }
340
+
341
+ /**
342
+ * PUT operation: Add/overwrite endpoints in KV store
343
+ */
344
+ async registerEndpoints(
345
+ endpoints: ScratchEndpointDefinition[]
346
+ ): Promise<void> {
347
+ // PUT: Always overwrite in endpoints KV store
348
+ for (const endpoint of endpoints) {
349
+ const blockDef = await endpoint.block({});
350
+ if (!blockDef.opcode || blockDef.opcode === "") {
351
+ throw new Error("Endpoint opcode cannot be empty");
352
+ }
353
+ this.endpoints.set(blockDef.opcode, endpoint);
354
+ console.log(`[KV Store] PUT endpoint: ${blockDef.opcode}`);
355
+ }
356
+ }
357
+
358
+ async start(port: number): Promise<void> {
359
+ return new Promise((resolve, reject) => {
360
+ this.server = createServer((req, res) => {
361
+ this.handleRequest(req, res).catch((err) => {
362
+ res.writeHead(500, { "Content-Type": "application/json" });
363
+ res.end(JSON.stringify({ error: "Internal server error" }));
364
+ });
365
+ });
366
+
367
+ this.server.listen(port, () => {
368
+ console.log(`🚀 Server running on http://localhost:${port}`);
369
+ resolve();
370
+ });
371
+
372
+ this.server.on("error", reject);
373
+ });
374
+ }
375
+
376
+ async stop(): Promise<void> {
377
+ return new Promise((resolve, reject) => {
378
+ if (!this.server) {
379
+ resolve();
380
+ return;
381
+ }
382
+ this.server.close((err) => {
383
+ if (err) reject(err);
384
+ else resolve();
385
+ });
386
+ });
387
+ }
388
+
389
+ getFetchHandler(): (request: Request) => Promise<Response> {
390
+ // Helper to get header value from either Headers object or plain object
391
+ const getHeader = (headers: any, name: string): string | null => {
392
+ if (headers && typeof headers.get === "function") {
393
+ return headers.get(name) || headers.get(name.toLowerCase());
394
+ }
395
+ if (headers && typeof headers === "object") {
396
+ return headers[name] || headers[name.toLowerCase()] || null;
397
+ }
398
+ return null;
399
+ };
400
+
401
+ // Convert Fetch API Request to Node.js-like request/response
402
+ return async (request: Request): Promise<Response> => {
403
+ // Handle relative URLs (Vercel may pass relative URLs)
404
+ let requestUrl = request.url;
405
+ if (
406
+ !requestUrl.startsWith("http://") &&
407
+ !requestUrl.startsWith("https://")
408
+ ) {
409
+ // Construct absolute URL from request headers
410
+ const host =
411
+ getHeader(request.headers, "host") ||
412
+ getHeader(request.headers, "Host") ||
413
+ "localhost";
414
+ const protocol =
415
+ getHeader(request.headers, "x-forwarded-proto") ||
416
+ (host.includes("localhost") ? "http" : "https");
417
+ requestUrl = `${protocol}://${host}${
418
+ requestUrl.startsWith("/") ? requestUrl : "/" + requestUrl
419
+ }`;
420
+ }
421
+ const url = new URL(requestUrl);
422
+ const method = request.method;
423
+ const path = url.pathname;
424
+ // Use parseQuery to get filtered query params (removes Vercel's ...path parameter)
425
+ const query = this.parseQuery(url.pathname + url.search);
426
+
427
+ // Read body if present
428
+ let body: any = null;
429
+ if (["POST", "PUT", "PATCH"].includes(method)) {
430
+ const contentType = getHeader(request.headers, "content-type") || "";
431
+ if (contentType.includes("application/json")) {
432
+ try {
433
+ body = await request.json();
434
+ } catch {
435
+ body = {};
436
+ }
437
+ } else {
438
+ try {
439
+ body = await request.text();
440
+ } catch {
441
+ body = "";
442
+ }
443
+ }
444
+ }
445
+
446
+ // Create mock Node.js request/response objects
447
+ const headers: Record<string, string> = {};
448
+ // Handle both Headers object and plain object
449
+ if (request.headers && typeof request.headers.entries === "function") {
450
+ for (const [key, value] of request.headers.entries()) {
451
+ headers[key] = value;
452
+ }
453
+ } else if (request.headers && typeof request.headers === "object") {
454
+ for (const [key, value] of Object.entries(request.headers)) {
455
+ headers[key] = String(value);
456
+ }
457
+ }
458
+ // Ensure host header is set (extract from URL if not present)
459
+ if (!headers.host && !headers["host"]) {
460
+ headers.host = url.host;
461
+ }
462
+
463
+ const nodeReq = {
464
+ method,
465
+ url: path + url.search,
466
+ headers,
467
+ body, // Store body for later use in route handlers
468
+ on: () => {},
469
+ } as any;
470
+
471
+ let responseBody: string = "";
472
+ let statusCode = 200;
473
+ const responseHeaders: Record<string, string> = {};
474
+
475
+ const nodeRes = {
476
+ writeHead: (status: number, headers?: Record<string, string>) => {
477
+ statusCode = status;
478
+ if (headers) {
479
+ Object.assign(responseHeaders, headers);
480
+ }
481
+ },
482
+ setHeader: (key: string, value: string) => {
483
+ responseHeaders[key] = value;
484
+ },
485
+ end: (data?: string) => {
486
+ if (data) responseBody = data;
487
+ },
488
+ headers: responseHeaders,
489
+ } as any;
490
+
491
+ // Handle the request
492
+ await this.handleRequest(nodeReq, nodeRes);
493
+
494
+ return new Response(responseBody || "", {
495
+ status: statusCode,
496
+ headers: responseHeaders,
497
+ });
498
+ };
499
+ }
500
+
501
+ async getRegisteredEndpoints(): Promise<
502
+ Array<{
503
+ method: string;
504
+ endpoint: string;
505
+ blockType: string;
506
+ auth: string;
507
+ text: string;
508
+ }>
509
+ > {
510
+ const endpoints = this.getAllEndpoints();
511
+ const endpointInfos = await Promise.all(
512
+ endpoints.map(async (endpoint) => {
513
+ const blockDef = await endpoint.block({});
514
+ if (!blockDef.opcode || blockDef.opcode === "") {
515
+ throw new Error("Endpoint opcode cannot be empty");
516
+ }
517
+ const opcode = blockDef.opcode;
518
+ const endpointPath = `/${opcode}`;
519
+ const method = blockDef.blockType === "reporter" ? "GET" : "POST";
520
+ const auth = endpoint.noAuth ? " (no auth)" : "";
521
+ return {
522
+ method,
523
+ endpoint: endpointPath,
524
+ blockType: blockDef.blockType,
525
+ auth,
526
+ text: blockDef.text,
527
+ };
528
+ })
529
+ );
530
+ endpointInfos.sort((a, b) => a.text.localeCompare(b.text));
531
+ return endpointInfos;
532
+ }
533
+
534
+ // Endpoint management methods
535
+ async loadEndpointsFromDirectory(directoryPath: string): Promise<void> {
536
+ const hostType = envOrDefault(undefined, "HOST_TYPE", "local");
537
+ const githubUrl = process.env.ENDPOINTS_GITHUB_URL;
538
+
539
+ if (hostType === "production" && githubUrl) {
540
+ log({ level: "info", event: "loading_endpoints_from_github", githubUrl });
541
+ await this.loadEndpointsFromGitHub(githubUrl);
542
+ return;
543
+ }
544
+
545
+ if (!isAbsolute(directoryPath)) {
546
+ throw new Error(`Expected absolute path, got: ${directoryPath}`);
547
+ }
548
+
549
+ log({ level: "info", event: "loading_endpoints", directoryPath });
550
+ const filePaths = await this.iterateEndpointFiles(directoryPath);
551
+ log({
552
+ level: "info",
553
+ event: "endpoint_files_found",
554
+ count: filePaths.length,
555
+ files: filePaths.map((f) => f.split("/").pop()),
556
+ });
557
+
558
+ const loadedEndpoints: ScratchEndpointDefinition[] = [];
559
+ for (const filePath of filePaths) {
560
+ try {
561
+ const source = await readFile(filePath, "utf-8");
562
+ const endpoint = await this.parseEndpointFromSource(source, filePath);
563
+ if (endpoint) {
564
+ const blockDef = await endpoint.block({});
565
+ if (!blockDef.opcode || blockDef.opcode === "") {
566
+ throw new Error(`Endpoint from ${filePath} has empty opcode`);
567
+ }
568
+ this.endpoints.set(blockDef.opcode, endpoint);
569
+ loadedEndpoints.push(endpoint);
570
+ log({
571
+ level: "info",
572
+ event: "endpoint_loaded",
573
+ opcode: blockDef.opcode,
574
+ file: filePath.split("/").pop(),
575
+ });
576
+ }
577
+ } catch (error) {
578
+ log({
579
+ level: "error",
580
+ event: "endpoint_load_failed",
581
+ file: filePath.split("/").pop(),
582
+ error: error instanceof Error ? error.message : String(error),
583
+ });
584
+ }
585
+ }
586
+
587
+ await this.registerEndpoints(loadedEndpoints);
588
+
589
+ log({
590
+ level: "info",
591
+ event: "endpoints_loaded",
592
+ total: this.endpoints.size,
593
+ });
594
+
595
+ // Mark as initialized
596
+ this.isInitialized = true;
597
+ }
598
+
599
+ private async loadEndpointsFromGitHub(githubUrl: string): Promise<void> {
600
+ // Parse GitHub URL: https://github.com/owner/repo/tree/branch/path
601
+ // Example: https://github.com/divizend/scratch/tree/main/endpoints
602
+ const urlMatch = githubUrl.match(
603
+ /https:\/\/github\.com\/([^\/]+)\/([^\/]+)\/tree\/([^\/]+)\/(.+)$/
604
+ );
605
+ if (!urlMatch) {
606
+ throw new Error(
607
+ `Invalid GitHub URL format. Expected: https://github.com/owner/repo/tree/branch/path, got: ${githubUrl}`
608
+ );
609
+ }
610
+
611
+ const [, owner, repo, branch, path] = urlMatch;
612
+
613
+ // Fetch file list from GitHub API
614
+ const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/${path}?ref=${branch}`;
615
+ log({ level: "info", event: "fetching_github_file_list", apiUrl });
616
+
617
+ const response = await fetch(apiUrl, {
618
+ headers: {
619
+ Accept: "application/vnd.github.v3+json",
620
+ "User-Agent": "scratch-server",
621
+ },
622
+ });
623
+
624
+ if (!response.ok) {
625
+ throw new Error(
626
+ `Failed to fetch GitHub file list: ${response.status} ${response.statusText}`
627
+ );
628
+ }
629
+
630
+ const files = (await response.json()) as Array<{
631
+ name: string;
632
+ type: string;
633
+ download_url?: string;
634
+ }>;
635
+
636
+ // Filter for .ts files
637
+ const tsFiles = files.filter(
638
+ (f) => f.type === "file" && f.name.endsWith(".ts")
639
+ );
640
+
641
+ log({
642
+ level: "info",
643
+ event: "github_files_found",
644
+ count: tsFiles.length,
645
+ files: tsFiles.map((f) => f.name),
646
+ });
647
+
648
+ const loadedEndpoints: ScratchEndpointDefinition[] = [];
649
+
650
+ // Fetch and parse each file
651
+ for (const file of tsFiles) {
652
+ try {
653
+ // Use raw.githubusercontent.com for file content
654
+ const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}/${path}/${file.name}`;
655
+ log({
656
+ level: "info",
657
+ event: "fetching_github_file",
658
+ file: file.name,
659
+ url: rawUrl,
660
+ });
661
+
662
+ const fileResponse = await fetch(rawUrl, {
663
+ headers: {
664
+ "User-Agent": "scratch-server",
665
+ },
666
+ });
667
+
668
+ if (!fileResponse.ok) {
669
+ throw new Error(
670
+ `Failed to fetch file ${file.name}: ${fileResponse.status} ${fileResponse.statusText}`
671
+ );
672
+ }
673
+
674
+ const source = await fileResponse.text();
675
+ const endpoint = await this.parseEndpointFromSource(source, file.name);
676
+ if (endpoint) {
677
+ const blockDef = await endpoint.block({});
678
+ if (!blockDef.opcode || blockDef.opcode === "") {
679
+ throw new Error(`Endpoint from ${file.name} has empty opcode`);
680
+ }
681
+ this.endpoints.set(blockDef.opcode, endpoint);
682
+ loadedEndpoints.push(endpoint);
683
+ log({
684
+ level: "info",
685
+ event: "endpoint_loaded",
686
+ opcode: blockDef.opcode,
687
+ file: file.name,
688
+ });
689
+ }
690
+ } catch (error) {
691
+ log({
692
+ level: "error",
693
+ event: "endpoint_load_failed",
694
+ file: file.name,
695
+ error: error instanceof Error ? error.message : String(error),
696
+ });
697
+ }
698
+ }
699
+
700
+ // PUT: Add all loaded endpoints to KV store
701
+ await this.registerEndpoints(loadedEndpoints);
702
+ }
703
+
704
+ getAllEndpoints(): ScratchEndpointDefinition[] {
705
+ return Array.from(this.endpoints.values());
706
+ }
707
+
708
+ /**
709
+ * Get handlers computed from current endpoints KV store (always fresh)
710
+ */
711
+ async getEndpointHandlers(): Promise<
712
+ Record<
713
+ string,
714
+ (
715
+ context: ScratchContext,
716
+ query?: Record<string, string>,
717
+ requestBody?: any,
718
+ authHeader?: string
719
+ ) => Promise<any>
720
+ >
721
+ > {
722
+ const handlers: Record<string, any> = {};
723
+ // Always compute from current endpoints KV store
724
+ for (const [opcode, endpoint] of this.endpoints.entries()) {
725
+ const wrappedHandler = await wrapHandlerWithAuthAndValidation({
726
+ universe: this.universe,
727
+ endpoint,
728
+ noAuth: endpoint.noAuth || false,
729
+ requiredModules: endpoint.requiredModules || [],
730
+ });
731
+ handlers[opcode] = wrappedHandler;
732
+ }
733
+ return handlers;
734
+ }
735
+
736
+ /**
737
+ * Get handler for specific opcode from current endpoints KV store (always fresh)
738
+ */
739
+ async getHandler(
740
+ opcode: string
741
+ ): Promise<
742
+ | ((
743
+ context: ScratchContext,
744
+ query?: Record<string, string>,
745
+ requestBody?: any,
746
+ authHeader?: string
747
+ ) => Promise<any>)
748
+ | undefined
749
+ > {
750
+ // Always look up from current endpoints KV store
751
+ const endpoint = this.endpoints.get(opcode);
752
+ if (!endpoint) {
753
+ return undefined;
754
+ }
755
+ // Build handler on-demand from current endpoint
756
+ return await wrapHandlerWithAuthAndValidation({
757
+ universe: this.universe,
758
+ endpoint,
759
+ noAuth: endpoint.noAuth || false,
760
+ requiredModules: endpoint.requiredModules || [],
761
+ });
762
+ }
763
+
764
+ private async iterateEndpointFiles(directoryPath: string): Promise<string[]> {
765
+ try {
766
+ const { stat } = await import("node:fs/promises");
767
+ const dirStats = await stat(directoryPath);
768
+ if (!dirStats.isDirectory()) {
769
+ log({
770
+ level: "error",
771
+ event: "endpoints_path_not_directory",
772
+ directoryPath,
773
+ });
774
+ return [];
775
+ }
776
+ const files = await readdir(directoryPath);
777
+ log({
778
+ level: "info",
779
+ event: "directory_read",
780
+ directoryPath,
781
+ total_files: files.length,
782
+ all_files: files,
783
+ });
784
+ // Include all .ts files - files that don't export endpoints will be filtered out during parsing
785
+ const tsFiles = files.filter((f) => f.endsWith(".ts"));
786
+ return tsFiles.map((f) => join(directoryPath, f));
787
+ } catch (error) {
788
+ log({
789
+ level: "error",
790
+ event: "directory_read_failed",
791
+ directoryPath,
792
+ error: error instanceof Error ? error.message : String(error),
793
+ });
794
+ return [];
795
+ }
796
+ }
797
+
798
+ private async parseEndpointFromSource(
799
+ source: string,
800
+ filePath: string
801
+ ): Promise<ScratchEndpointDefinition | null> {
802
+ try {
803
+ // Check if filePath is an absolute path that exists (filesystem loading)
804
+ const absolutePath = resolve(filePath);
805
+ const { stat } = await import("node:fs/promises");
806
+ let fileExists = false;
807
+ try {
808
+ await stat(absolutePath);
809
+ fileExists = true;
810
+ } catch {
811
+ // File doesn't exist
812
+ }
813
+
814
+ let module: any;
815
+
816
+ if (fileExists) {
817
+ // Use existing file path (filesystem loading)
818
+ module = await import(absolutePath);
819
+ } else {
820
+ // File doesn't exist, so source is from GitHub - transpile and evaluate with manual import resolution
821
+ try {
822
+ // Use relative import to ensure it works regardless of project root location
823
+ // From src/http-server/NativeHttpServer.ts, ../index resolves to src/index.ts
824
+ const srcModule = await import("../index");
825
+
826
+ // Replace import statements with variable assignments from provided module
827
+ let processedSource = source;
828
+
829
+ // Replace: import { X, Y } from "../src"
830
+ processedSource = processedSource.replace(
831
+ /import\s+{([^}]+)}\s+from\s+["']\.\.\/src["']/g,
832
+ (match, imports) => {
833
+ const importList = imports
834
+ .split(",")
835
+ .map((i: string) => i.trim());
836
+ return importList
837
+ .map((imp: string) => {
838
+ const [name, alias] = imp
839
+ .split(" as ")
840
+ .map((s: string) => s.trim());
841
+ const varName = alias || name;
842
+ return `const ${varName} = srcModule.${name};`;
843
+ })
844
+ .join("\n");
845
+ }
846
+ );
847
+
848
+ // Replace: import X from "../src"
849
+ processedSource = processedSource.replace(
850
+ /import\s+(\w+)\s+from\s+["']\.\.\/src["']/g,
851
+ "const $1 = srcModule;"
852
+ );
853
+
854
+ // Transpile TypeScript to JavaScript
855
+ const transpiler = new Bun.Transpiler({ loader: "ts" });
856
+ let js = transpiler.transformSync(processedSource);
857
+
858
+ // Convert ES module exports to CommonJS
859
+ // Pattern 1: export const endpointName = ... -> const endpointName = ...; module.exports.endpointName = endpointName;
860
+ js = js.replace(/export\s+const\s+(\w+)\s*=/g, (match, varName) => {
861
+ return `const ${varName} =`;
862
+ });
863
+
864
+ // Pattern 2: export { X, Y } -> module.exports.X = X; module.exports.Y = Y;
865
+ js = js.replace(/export\s+{\s*([^}]+)\s*}/g, (match, exports) => {
866
+ const exportList = exports.split(",").map((e: string) => e.trim());
867
+ return exportList
868
+ .map((exp: string) => {
869
+ const [name, alias] = exp
870
+ .split(" as ")
871
+ .map((s: string) => s.trim());
872
+ const exportName = alias || name;
873
+ return `module.exports.${exportName} = ${name};`;
874
+ })
875
+ .join("\n");
876
+ });
877
+
878
+ // Pattern 3: export default X -> module.exports.default = X;
879
+ js = js.replace(
880
+ /export\s+default\s+(\w+)/g,
881
+ "module.exports.default = $1"
882
+ );
883
+
884
+ // After removing exports, we need to add the exports back
885
+ // Extract the endpoint name from filename (e.g., "admin.ts" -> "admin")
886
+ const endpointName = basename(filePath, ".ts");
887
+
888
+ // Find the const declaration for the endpoint name and export it
889
+ const endpointVarPattern = new RegExp(
890
+ `const\\s+${endpointName}\\s*=`,
891
+ "g"
892
+ );
893
+ if (endpointVarPattern.test(js)) {
894
+ // Add export for the endpoint variable
895
+ js += `\nmodule.exports.${endpointName} = ${endpointName};`;
896
+ } else {
897
+ // If endpoint name doesn't match, try to find any const that looks like an endpoint
898
+ // (has block and handler properties based on the code structure)
899
+ // For now, export all top-level const declarations
900
+ const constDeclarations = js.matchAll(/const\s+(\w+)\s*=/g);
901
+ const varsToExport: string[] = [];
902
+ for (const match of constDeclarations) {
903
+ const varName = match[1];
904
+ // Export if it's a valid identifier and not a common internal variable
905
+ if (
906
+ varName &&
907
+ !["require", "module", "exports", "process", "global"].includes(
908
+ varName
909
+ )
910
+ ) {
911
+ varsToExport.push(varName);
912
+ }
913
+ }
914
+ // Export all found variables
915
+ if (varsToExport.length > 0) {
916
+ js +=
917
+ "\n" +
918
+ varsToExport
919
+ .map((v) => `module.exports.${v} = ${v};`)
920
+ .join("\n");
921
+ }
922
+ }
923
+
924
+ // Evaluate with srcModule in scope
925
+ const wrappedCode = `
926
+ (function(srcModule) {
927
+ const exports = {};
928
+ const module = { exports };
929
+ ${js}
930
+ return module.exports;
931
+ })
932
+ `;
933
+
934
+ const factory = eval(wrappedCode);
935
+ const exports = factory(srcModule);
936
+ module = exports;
937
+ } catch (evalError) {
938
+ const errorMessage =
939
+ evalError instanceof Error ? evalError.message : String(evalError);
940
+ const errorStack =
941
+ evalError instanceof Error ? evalError.stack : undefined;
942
+ console.error(
943
+ `Failed to evaluate GitHub source for ${filePath}:`,
944
+ errorMessage,
945
+ errorStack
946
+ );
947
+ throw evalError;
948
+ }
949
+ }
950
+
951
+ // Try to find endpoint by filename first
952
+ const fileName = basename(filePath, ".ts");
953
+ if (module && module[fileName]) {
954
+ return module[fileName] as ScratchEndpointDefinition;
955
+ }
956
+
957
+ // Search all exports for an endpoint definition
958
+ const endpoint =
959
+ module && typeof module === "object"
960
+ ? Object.values(module).find(
961
+ (value): value is ScratchEndpointDefinition =>
962
+ value !== null &&
963
+ typeof value === "object" &&
964
+ "block" in value &&
965
+ "handler" in value
966
+ )
967
+ : null;
968
+
969
+ if (endpoint) {
970
+ return endpoint;
971
+ }
972
+
973
+ // If module itself is an endpoint definition
974
+ if (
975
+ module &&
976
+ typeof module === "object" &&
977
+ "block" in module &&
978
+ "handler" in module
979
+ ) {
980
+ return module as ScratchEndpointDefinition;
981
+ }
982
+
983
+ console.warn(`No endpoint definition found in ${filePath}`);
984
+ return null;
985
+ } catch (error) {
986
+ console.error(`Failed to parse endpoint from ${filePath}:`, error);
987
+ return null;
988
+ }
989
+ }
990
+
991
+ /**
992
+ * PUT operation: Register/overwrite an endpoint from TypeScript source code
993
+ * Simple KV store operation - just evaluate and store
994
+ */
995
+ async registerEndpoint(source: string): Promise<{
996
+ success: boolean;
997
+ opcode?: string;
998
+ message?: string;
999
+ error?: string;
1000
+ }> {
1001
+ try {
1002
+ // Create temp file, evaluate, and store - that's it
1003
+ const tempFile = `/tmp/endpoint_${Date.now()}_${randomUUID()}.ts`;
1004
+ await Bun.write(tempFile, source);
1005
+
1006
+ try {
1007
+ const module = await import(tempFile);
1008
+ const endpoint = Object.values(module).find(
1009
+ (value): value is ScratchEndpointDefinition =>
1010
+ value !== null &&
1011
+ typeof value === "object" &&
1012
+ "block" in value &&
1013
+ "handler" in value
1014
+ );
1015
+
1016
+ if (!endpoint) {
1017
+ throw new Error("No endpoint definition found in source code");
1018
+ }
1019
+
1020
+ const blockDef = await endpoint.block({});
1021
+ if (!blockDef.opcode || blockDef.opcode === "") {
1022
+ throw new Error("Endpoint opcode cannot be empty");
1023
+ }
1024
+
1025
+ // PUT: Store in KV store (that's all!)
1026
+ this.endpoints.set(blockDef.opcode, endpoint);
1027
+
1028
+ return {
1029
+ success: true,
1030
+ opcode: blockDef.opcode,
1031
+ message: `Endpoint "${blockDef.opcode}" registered successfully`,
1032
+ };
1033
+ } finally {
1034
+ // Always clean up temp file
1035
+ try {
1036
+ await Bun.file(tempFile).unlink();
1037
+ } catch {
1038
+ // Ignore cleanup errors
1039
+ }
1040
+ }
1041
+ } catch (error: any) {
1042
+ return {
1043
+ success: false,
1044
+ error: error.message || String(error),
1045
+ };
1046
+ }
1047
+ }
1048
+
1049
+ /**
1050
+ * DELETE operation: Remove an endpoint by opcode (KV store behavior)
1051
+ */
1052
+ async removeEndpoint(opcode: string): Promise<{
1053
+ success: boolean;
1054
+ message?: string;
1055
+ error?: string;
1056
+ }> {
1057
+ try {
1058
+ if (!opcode || opcode === "") {
1059
+ throw new Error("Opcode cannot be empty");
1060
+ }
1061
+
1062
+ // DELETE: Remove from endpoints KV store
1063
+ const existed = this.endpoints.has(opcode);
1064
+ this.endpoints.delete(opcode);
1065
+
1066
+ if (existed) {
1067
+ return {
1068
+ success: true,
1069
+ message: `Endpoint "${opcode}" removed successfully`,
1070
+ };
1071
+ } else {
1072
+ return {
1073
+ success: true,
1074
+ message: `Endpoint "${opcode}" was not registered`,
1075
+ };
1076
+ }
1077
+ } catch (error: any) {
1078
+ return {
1079
+ success: false,
1080
+ error: error.message || String(error),
1081
+ };
1082
+ }
1083
+ }
1084
+ }