@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.
- package/basic/demo.ts +11 -0
- package/basic/index.ts +490 -0
- package/core/Auth.ts +63 -0
- package/core/Currency.ts +16 -0
- package/core/Env.ts +186 -0
- package/core/Fragment.ts +43 -0
- package/core/FragmentServingMode.ts +37 -0
- package/core/JsonSchemaValidator.ts +173 -0
- package/core/ProjectRoot.ts +76 -0
- package/core/Scratch.ts +44 -0
- package/core/URI.ts +203 -0
- package/core/Universe.ts +406 -0
- package/core/index.ts +27 -0
- package/gsuite/core/GSuite.ts +237 -0
- package/gsuite/core/GSuiteAdmin.ts +81 -0
- package/gsuite/core/GSuiteOrgConfig.ts +47 -0
- package/gsuite/core/GSuiteUser.ts +115 -0
- package/gsuite/core/index.ts +21 -0
- package/gsuite/documents/Document.ts +173 -0
- package/gsuite/documents/Documents.ts +52 -0
- package/gsuite/documents/index.ts +19 -0
- package/gsuite/drive/Drive.ts +118 -0
- package/gsuite/drive/DriveFile.ts +147 -0
- package/gsuite/drive/index.ts +19 -0
- package/gsuite/gmail/Gmail.ts +430 -0
- package/gsuite/gmail/GmailLabel.ts +55 -0
- package/gsuite/gmail/GmailMessage.ts +428 -0
- package/gsuite/gmail/GmailMessagePart.ts +298 -0
- package/gsuite/gmail/GmailThread.ts +97 -0
- package/gsuite/gmail/index.ts +5 -0
- package/gsuite/gmail/utils.ts +184 -0
- package/gsuite/index.ts +28 -0
- package/gsuite/spreadsheets/CellValue.ts +71 -0
- package/gsuite/spreadsheets/Sheet.ts +128 -0
- package/gsuite/spreadsheets/SheetValues.ts +12 -0
- package/gsuite/spreadsheets/Spreadsheet.ts +76 -0
- package/gsuite/spreadsheets/Spreadsheets.ts +52 -0
- package/gsuite/spreadsheets/index.ts +25 -0
- package/gsuite/spreadsheets/utils.ts +52 -0
- package/gsuite/utils.ts +104 -0
- package/http-server/HttpServer.ts +110 -0
- package/http-server/NativeHttpServer.ts +1084 -0
- package/http-server/index.ts +3 -0
- package/http-server/middlewares/01-cors.ts +33 -0
- package/http-server/middlewares/02-static.ts +67 -0
- package/http-server/middlewares/03-request-logger.ts +159 -0
- package/http-server/middlewares/04-body-parser.ts +54 -0
- package/http-server/middlewares/05-no-cache.ts +23 -0
- package/http-server/middlewares/06-response-handler.ts +39 -0
- package/http-server/middlewares/handler-wrapper.ts +250 -0
- package/http-server/middlewares/index.ts +37 -0
- package/http-server/middlewares/types.ts +27 -0
- package/index.ts +24 -0
- package/package.json +37 -0
- package/queue/EmailQueue.ts +228 -0
- package/queue/RateLimiter.ts +54 -0
- package/queue/index.ts +2 -0
- package/resend/Resend.ts +190 -0
- package/resend/index.ts +11 -0
- package/s2/S2.ts +335 -0
- 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
|
+
}
|