| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import type { QuickJSContext, QuickJSHandle, QuickJSWASMModule } from 'quickjs-emscripten-core'; |
| import { newQuickJSWASMModuleFromVariant } from 'quickjs-emscripten-core'; |
| import variant from '@jitl/quickjs-wasmfile-release-sync'; |
| import { EdgeFunction, ServerFunction } from '@/lib/vfs/types'; |
| import { |
| FunctionRequest, |
| FunctionResponse, |
| ExecutionResult, |
| DatabaseAPI, |
| } from './types'; |
| import { createDatabaseAPI } from './database-api'; |
| import { RuntimeDatabase } from '@/lib/vfs/adapters/runtime-database'; |
| import { decryptSecret, isEncryptionConfigured } from './secrets-crypto'; |
| import { createHash, randomUUID } from 'crypto'; |
|
|
| |
| let quickJSModulePromise: Promise<QuickJSWASMModule> | null = null; |
|
|
| |
| |
| |
| |
| async function getQuickJSModule(): Promise<QuickJSWASMModule> { |
| if (!quickJSModulePromise) { |
| quickJSModulePromise = newQuickJSWASMModuleFromVariant(variant); |
| } |
| return quickJSModulePromise; |
| } |
|
|
| |
| |
| |
| const MAX_EXECUTION_TIME_MS = 30000; |
|
|
| |
| |
| |
| const MAX_MEMORY_BYTES = 64 * 1024 * 1024; |
|
|
| |
| |
| |
| const MAX_FETCH_REQUESTS = 10; |
| const FETCH_TIMEOUT_MS = 10000; |
| const MAX_RESPONSE_SIZE = 5 * 1024 * 1024; |
|
|
| |
| |
| |
| |
| function isPrivateUrl(urlString: string): boolean { |
| |
| if (process.env.NODE_ENV !== 'production') { |
| return false; |
| } |
|
|
| try { |
| const url = new URL(urlString); |
| const hostname = url.hostname.toLowerCase(); |
|
|
| |
| if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') { |
| return true; |
| } |
|
|
| |
| |
| if (/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname)) { |
| return true; |
| } |
| |
| if (/^172\.(1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}$/.test(hostname)) { |
| return true; |
| } |
| |
| if (/^192\.168\.\d{1,3}\.\d{1,3}$/.test(hostname)) { |
| return true; |
| } |
| |
| if (/^169\.254\.\d{1,3}\.\d{1,3}$/.test(hostname)) { |
| return true; |
| } |
|
|
| return false; |
| } catch { |
| return true; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export async function executeFunction( |
| fn: EdgeFunction, |
| request: FunctionRequest, |
| deploymentDb: RuntimeDatabase |
| ): Promise<ExecutionResult> { |
| const startTime = Date.now(); |
| const logs: string[] = []; |
|
|
| |
| let responseSet = false; |
| let response: FunctionResponse = { |
| status: 200, |
| headers: {}, |
| body: '', |
| }; |
|
|
| |
| const db = createDatabaseAPI(deploymentDb); |
|
|
| |
| const serverFunctions = deploymentDb.listServerFunctions().filter(f => f.enabled); |
|
|
| |
| const decryptedSecrets: Record<string, string> = {}; |
| if (isEncryptionConfigured()) { |
| try { |
| const secretRecords = deploymentDb.listSecretsWithValues(); |
| for (const record of secretRecords) { |
| try { |
| decryptedSecrets[record.name] = decryptSecret( |
| record.encryptedValue, |
| record.iv, |
| record.authTag |
| ); |
| } catch { |
| logs.push(`[WARN] Failed to decrypt secret "${record.name}"`); |
| } |
| } |
| } catch (error) { |
| logs.push(`[WARN] Failed to load secrets: ${error instanceof Error ? error.message : String(error)}`); |
| } |
| } |
|
|
| |
| const QuickJS = await getQuickJSModule(); |
|
|
| |
| const runtime = QuickJS.newRuntime(); |
| runtime.setMemoryLimit(MAX_MEMORY_BYTES); |
|
|
| |
| const timeout = Math.min(fn.timeoutMs || 5000, MAX_EXECUTION_TIME_MS); |
| const deadline = Date.now() + timeout; |
|
|
| |
| runtime.setInterruptHandler(() => { |
| if (Date.now() > deadline) { |
| return true; |
| } |
| return false; |
| }); |
|
|
| |
| const context = runtime.newContext(); |
|
|
| |
| const fetchState: FetchState = { requestCount: 0, pendingFetches: [] }; |
|
|
| try { |
| |
| injectGlobals(context, { |
| request, |
| db, |
| logs, |
| decryptedSecrets, |
| serverFunctions, |
| setResponse: (newResponse: FunctionResponse) => { |
| if (!responseSet) { |
| responseSet = true; |
| response = newResponse; |
| } else { |
| logs.push('[WARN] Response already set, ignoring subsequent call'); |
| } |
| }, |
| fetchState, |
| }); |
|
|
| |
| const wrappedCode = buildWrappedCode(fn.code); |
|
|
| |
| const result = context.evalCode(wrappedCode, `edge-function:${fn.name}`); |
|
|
| |
| if (result.error) { |
| const errorStr = context.dump(result.error); |
| result.error.dispose(); |
| const message = typeof errorStr === 'object' && errorStr !== null |
| ? (errorStr as Record<string, unknown>).message as string || JSON.stringify(errorStr) |
| : String(errorStr); |
| throw new Error(message); |
| } |
|
|
| |
| const value = result.value; |
|
|
| |
| let executePending = true; |
| while (executePending) { |
| |
| const incompleteFetches = fetchState.pendingFetches.filter(f => !f.completed && f.promise !== null); |
| if (incompleteFetches.length > 0) { |
| |
| await Promise.race([ |
| Promise.all(incompleteFetches.map(f => f.promise!)), |
| new Promise(resolve => setTimeout(resolve, 10)), |
| ]); |
| } |
|
|
| |
| const pendingResult = runtime.executePendingJobs(); |
| if (pendingResult.error) { |
| const errorStr = context.dump(pendingResult.error); |
| pendingResult.error.dispose(); |
| const message = typeof errorStr === 'object' && errorStr !== null |
| ? (errorStr as Record<string, unknown>).message as string || JSON.stringify(errorStr) |
| : String(errorStr); |
| throw new Error(message); |
| } |
|
|
| |
| const hasMoreJobs = pendingResult.value > 0; |
| const hasPendingFetches = fetchState.pendingFetches.some(f => !f.completed); |
| executePending = hasMoreJobs || hasPendingFetches; |
|
|
| |
| if (Date.now() > deadline) { |
| throw new Error('Function execution timed out'); |
| } |
| } |
|
|
| value.dispose(); |
|
|
| |
| if (!responseSet) { |
| response = { status: 204, headers: {}, body: '' }; |
| } |
|
|
| return { |
| response, |
| logs, |
| durationMs: Date.now() - startTime, |
| }; |
| } catch (error) { |
| const errorMessage = formatError(error); |
| logs.push(`[ERROR] ${errorMessage}`); |
|
|
| return { |
| response: { |
| status: 500, |
| headers: { 'Content-Type': 'application/json' }, |
| body: { error: errorMessage }, |
| }, |
| logs, |
| durationMs: Date.now() - startTime, |
| error: errorMessage, |
| }; |
| } finally { |
| |
| context.dispose(); |
| runtime.dispose(); |
| } |
| } |
|
|
| |
| |
| |
| interface GlobalsConfig { |
| request: FunctionRequest; |
| db: DatabaseAPI; |
| logs: string[]; |
| decryptedSecrets: Record<string, string>; |
| serverFunctions: ServerFunction[]; |
| setResponse: (response: FunctionResponse) => void; |
| fetchState: FetchState; |
| } |
|
|
| |
| |
| |
| interface FetchState { |
| requestCount: number; |
| pendingFetches: Array<{ |
| promise: Promise<void> | null; |
| completed: boolean; |
| }>; |
| } |
|
|
| |
| |
| |
| function injectGlobals(context: QuickJSContext, config: GlobalsConfig): void { |
| const { request, db, logs, decryptedSecrets, serverFunctions, setResponse, fetchState } = config; |
|
|
| |
| const requestHandle = jsonToHandle(context, request); |
| context.setProp(context.global, 'request', requestHandle); |
| requestHandle.dispose(); |
|
|
| |
| injectConsole(context, logs); |
|
|
| |
| injectResponseHelpers(context, setResponse); |
|
|
| |
| injectDatabaseAPI(context, db, logs); |
|
|
| |
| injectSecretsAPI(context, decryptedSecrets); |
|
|
| |
| injectServerFunctions(context, serverFunctions, logs); |
|
|
| |
| injectFetch(context, logs, fetchState); |
|
|
| |
| injectBase64Helpers(context); |
|
|
| |
| injectCryptoHelpers(context); |
| } |
|
|
| |
| |
| |
| function injectConsole(context: QuickJSContext, logs: string[]): void { |
| const consoleObj = context.newObject(); |
|
|
| const methods = ['log', 'error', 'warn', 'info'] as const; |
| const prefixes = { |
| log: '[LOG]', |
| error: '[ERROR]', |
| warn: '[WARN]', |
| info: '[INFO]', |
| }; |
|
|
| for (const method of methods) { |
| const fn = context.newFunction(method, (...args: QuickJSHandle[]) => { |
| const parts = args.map(arg => { |
| const value = context.dump(arg); |
| return formatValue(value); |
| }); |
| logs.push(`${prefixes[method]} ${parts.join(' ')}`); |
| }); |
| context.setProp(consoleObj, method, fn); |
| fn.dispose(); |
| } |
|
|
| context.setProp(context.global, 'console', consoleObj); |
| consoleObj.dispose(); |
| } |
|
|
| |
| |
| |
| function injectResponseHelpers(context: QuickJSContext, setResponse: (response: FunctionResponse) => void): void { |
| const responseObj = context.newObject(); |
|
|
| |
| const jsonFn = context.newFunction('json', (dataHandle: QuickJSHandle, statusHandle?: QuickJSHandle, headersHandle?: QuickJSHandle) => { |
| const data = context.dump(dataHandle); |
| const status = statusHandle ? (context.dump(statusHandle) as number) : 200; |
| const extraHeaders = headersHandle ? (context.dump(headersHandle) as Record<string, string>) : {}; |
| setResponse({ |
| status, |
| headers: { 'Content-Type': 'application/json', ...extraHeaders }, |
| body: data as object, |
| }); |
| }); |
| context.setProp(responseObj, 'json', jsonFn); |
| jsonFn.dispose(); |
|
|
| |
| const textFn = context.newFunction('text', (textHandle: QuickJSHandle, statusHandle?: QuickJSHandle, headersHandle?: QuickJSHandle) => { |
| const text = context.dump(textHandle) as string; |
| const status = statusHandle ? (context.dump(statusHandle) as number) : 200; |
| const extraHeaders = headersHandle ? (context.dump(headersHandle) as Record<string, string>) : {}; |
| setResponse({ |
| status, |
| headers: { 'Content-Type': 'text/plain', ...extraHeaders }, |
| body: text, |
| }); |
| }); |
| context.setProp(responseObj, 'text', textFn); |
| textFn.dispose(); |
|
|
| |
| const errorFn = context.newFunction('error', (msgHandle: QuickJSHandle, statusHandle?: QuickJSHandle, headersHandle?: QuickJSHandle) => { |
| const message = context.dump(msgHandle) as string; |
| const status = statusHandle ? (context.dump(statusHandle) as number) : 500; |
| const extraHeaders = headersHandle ? (context.dump(headersHandle) as Record<string, string>) : {}; |
| setResponse({ |
| status, |
| headers: { 'Content-Type': 'application/json', ...extraHeaders }, |
| body: { error: message }, |
| }); |
| }); |
| context.setProp(responseObj, 'error', errorFn); |
| errorFn.dispose(); |
|
|
| context.setProp(context.global, 'Response', responseObj); |
| responseObj.dispose(); |
| } |
|
|
| |
| |
| |
| function injectDatabaseAPI(context: QuickJSContext, db: DatabaseAPI, logs: string[]): void { |
| const dbObj = context.newObject(); |
|
|
| |
| const queryFn = context.newFunction('query', (sqlHandle: QuickJSHandle, paramsHandle?: QuickJSHandle) => { |
| try { |
| const sql = context.dump(sqlHandle) as string; |
| const params = paramsHandle ? (context.dump(paramsHandle) as unknown[]) : undefined; |
| const result = db.query(sql, params); |
| return jsonToHandle(context, result); |
| } catch (error) { |
| const msg = error instanceof Error ? error.message : String(error); |
| logs.push(`[ERROR] db.query failed: ${msg}`); |
| throw error; |
| } |
| }); |
| context.setProp(dbObj, 'query', queryFn); |
| queryFn.dispose(); |
|
|
| |
| const runFn = context.newFunction('run', (sqlHandle: QuickJSHandle, paramsHandle?: QuickJSHandle) => { |
| try { |
| const sql = context.dump(sqlHandle) as string; |
| const params = paramsHandle ? (context.dump(paramsHandle) as unknown[]) : undefined; |
| const result = db.run(sql, params); |
| return jsonToHandle(context, result); |
| } catch (error) { |
| const msg = error instanceof Error ? error.message : String(error); |
| logs.push(`[ERROR] db.run failed: ${msg}`); |
| throw error; |
| } |
| }); |
| context.setProp(dbObj, 'run', runFn); |
| runFn.dispose(); |
|
|
| |
| const allFn = context.newFunction('all', (sqlHandle: QuickJSHandle, paramsHandle?: QuickJSHandle) => { |
| try { |
| const sql = context.dump(sqlHandle) as string; |
| const params = paramsHandle ? (context.dump(paramsHandle) as unknown[]) : undefined; |
| const result = db.all(sql, params); |
| return jsonToHandle(context, result); |
| } catch (error) { |
| const msg = error instanceof Error ? error.message : String(error); |
| logs.push(`[ERROR] db.all failed: ${msg}`); |
| throw error; |
| } |
| }); |
| context.setProp(dbObj, 'all', allFn); |
| allFn.dispose(); |
|
|
| context.setProp(context.global, 'db', dbObj); |
| dbObj.dispose(); |
| } |
|
|
| |
| |
| |
| function injectSecretsAPI(context: QuickJSContext, secrets: Record<string, string>): void { |
| const secretsObj = context.newObject(); |
|
|
| |
| const getFn = context.newFunction('get', (nameHandle: QuickJSHandle) => { |
| const name = context.dump(nameHandle) as string; |
| const value = secrets[name]; |
| if (value === undefined) { |
| return context.null; |
| } |
| return context.newString(value); |
| }); |
| context.setProp(secretsObj, 'get', getFn); |
| getFn.dispose(); |
|
|
| |
| const hasFn = context.newFunction('has', (nameHandle: QuickJSHandle) => { |
| const name = context.dump(nameHandle) as string; |
| return name in secrets ? context.true : context.false; |
| }); |
| context.setProp(secretsObj, 'has', hasFn); |
| hasFn.dispose(); |
|
|
| |
| const listFn = context.newFunction('list', () => { |
| return jsonToHandle(context, Object.keys(secrets)); |
| }); |
| context.setProp(secretsObj, 'list', listFn); |
| listFn.dispose(); |
|
|
| context.setProp(context.global, 'secrets', secretsObj); |
| secretsObj.dispose(); |
| } |
|
|
| |
| |
| |
| function injectServerFunctions( |
| context: QuickJSContext, |
| serverFunctions: ServerFunction[], |
| logs: string[] |
| ): void { |
| const serverObj = context.newObject(); |
|
|
| for (const serverFn of serverFunctions) { |
| const fn = context.newFunction(serverFn.name, (...args: QuickJSHandle[]) => { |
| try { |
| const jsArgs = args.map(arg => context.dump(arg)); |
|
|
| const serverFnCode = ` |
| (function(args, db) { |
| 'use strict'; |
| ${serverFn.code} |
| }) |
| `; |
|
|
| const evalResult = context.evalCode(serverFnCode, `server-function:${serverFn.name}`); |
| if (evalResult.error) { |
| const errorStr = context.dump(evalResult.error); |
| evalResult.error.dispose(); |
| throw new Error(`Server function "${serverFn.name}" error: ${errorStr}`); |
| } |
|
|
| const fnHandle = evalResult.value; |
| const argsHandle = jsonToHandle(context, jsArgs); |
| const dbHandle = context.getProp(context.global, 'db'); |
|
|
| const callResult = context.callFunction(fnHandle, context.undefined, argsHandle, dbHandle); |
|
|
| fnHandle.dispose(); |
| argsHandle.dispose(); |
| dbHandle.dispose(); |
|
|
| if (callResult.error) { |
| const errorStr = context.dump(callResult.error); |
| callResult.error.dispose(); |
| throw new Error(`Server function "${serverFn.name}" error: ${errorStr}`); |
| } |
|
|
| const result = callResult.value; |
| const resultValue = context.dump(result); |
| result.dispose(); |
|
|
| return jsonToHandle(context, resultValue); |
| } catch (error) { |
| const msg = error instanceof Error ? error.message : String(error); |
| logs.push(`[ERROR] Server function "${serverFn.name}" failed: ${msg}`); |
| throw error; |
| } |
| }); |
|
|
| context.setProp(serverObj, serverFn.name, fn); |
| fn.dispose(); |
| } |
|
|
| context.setProp(context.global, 'server', serverObj); |
| serverObj.dispose(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function injectFetch(context: QuickJSContext, logs: string[], fetchState: FetchState): void { |
| const fetchFn = context.newFunction('fetch', (urlHandle: QuickJSHandle, optionsHandle?: QuickJSHandle) => { |
| try { |
| const url = context.dump(urlHandle) as string; |
| const options = optionsHandle ? (context.dump(optionsHandle) as Record<string, unknown>) : {}; |
|
|
| |
| if (fetchState.requestCount >= MAX_FETCH_REQUESTS) { |
| throw new Error(`Maximum fetch requests (${MAX_FETCH_REQUESTS}) exceeded`); |
| } |
|
|
| |
| let protocol: string; |
| try { |
| const parsedUrl = new URL(url); |
| protocol = parsedUrl.protocol; |
| } catch { |
| throw new Error('Invalid URL'); |
| } |
|
|
| if (!['http:', 'https:'].includes(protocol)) { |
| throw new Error('Only http and https protocols are allowed'); |
| } |
|
|
| |
| if (isPrivateUrl(url)) { |
| throw new Error('Requests to private/internal addresses are not allowed'); |
| } |
|
|
| fetchState.requestCount++; |
| logs.push(`[INFO] fetch: ${options.method || 'GET'} ${url}`); |
|
|
| |
| const promiseCode = ` |
| (function() { |
| let _resolve, _reject; |
| const promise = new Promise((resolve, reject) => { |
| _resolve = resolve; |
| _reject = reject; |
| }); |
| promise._resolve = _resolve; |
| promise._reject = _reject; |
| return promise; |
| })() |
| `; |
|
|
| const promiseResult = context.evalCode(promiseCode, 'fetch-promise'); |
| if (promiseResult.error) { |
| const errorStr = context.dump(promiseResult.error); |
| promiseResult.error.dispose(); |
| throw new Error(`Failed to create promise: ${errorStr}`); |
| } |
|
|
| const promiseHandle = promiseResult.value; |
|
|
| |
| const fetchEntry: { promise: Promise<void> | null; completed: boolean } = { promise: null, completed: false }; |
|
|
| |
| fetchEntry.promise = (async () => { |
| try { |
| const controller = new AbortController(); |
| const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); |
|
|
| const fetchOptions: RequestInit = { |
| method: (options.method as string) || 'GET', |
| headers: options.headers as Record<string, string>, |
| body: options.body ? JSON.stringify(options.body) : undefined, |
| signal: controller.signal, |
| }; |
|
|
| const response = await globalThis.fetch(url, fetchOptions); |
| clearTimeout(timeoutId); |
|
|
| |
| const contentLength = response.headers.get('content-length'); |
| if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) { |
| throw new Error(`Response too large (max ${MAX_RESPONSE_SIZE / 1024 / 1024}MB)`); |
| } |
|
|
| |
| const text = await response.text(); |
| if (text.length > MAX_RESPONSE_SIZE) { |
| throw new Error(`Response too large (max ${MAX_RESPONSE_SIZE / 1024 / 1024}MB)`); |
| } |
|
|
| |
| const headers: Record<string, string> = {}; |
| response.headers.forEach((value, key) => { |
| headers[key] = value; |
| }); |
|
|
| const responseObj = { |
| ok: response.ok, |
| status: response.status, |
| statusText: response.statusText, |
| headers, |
| url: response.url, |
| }; |
|
|
| |
| const resolveHandle = context.getProp(promiseHandle, '_resolve'); |
| const responseHandle = jsonToHandle(context, responseObj); |
|
|
| |
| const jsonMethod = context.newFunction('json', () => { |
| try { |
| return jsonToHandle(context, JSON.parse(text)); |
| } catch { |
| throw new Error('Failed to parse JSON'); |
| } |
| }); |
| context.setProp(responseHandle, 'json', jsonMethod); |
| jsonMethod.dispose(); |
|
|
| const textMethod = context.newFunction('text', () => { |
| return context.newString(text); |
| }); |
| context.setProp(responseHandle, 'text', textMethod); |
| textMethod.dispose(); |
|
|
| context.callFunction(resolveHandle, context.undefined, responseHandle); |
| resolveHandle.dispose(); |
| responseHandle.dispose(); |
| } catch (error) { |
| const errorMsg = error instanceof Error ? error.message : String(error); |
| logs.push(`[ERROR] fetch failed: ${errorMsg}`); |
|
|
| |
| const rejectHandle = context.getProp(promiseHandle, '_reject'); |
| const errorHandle = context.newString(errorMsg); |
| context.callFunction(rejectHandle, context.undefined, errorHandle); |
| rejectHandle.dispose(); |
| errorHandle.dispose(); |
| } finally { |
| fetchEntry.completed = true; |
| } |
| })(); |
|
|
| fetchState.pendingFetches.push(fetchEntry); |
|
|
| return promiseHandle; |
| } catch (error) { |
| const errorMsg = error instanceof Error ? error.message : String(error); |
| logs.push(`[ERROR] fetch failed: ${errorMsg}`); |
| throw context.newError(errorMsg); |
| } |
| }); |
|
|
| context.setProp(context.global, 'fetch', fetchFn); |
| fetchFn.dispose(); |
| } |
|
|
| |
| |
| |
| function injectBase64Helpers(context: QuickJSContext): void { |
| |
| const btoaFn = context.newFunction('btoa', (dataHandle: QuickJSHandle) => { |
| const data = context.dump(dataHandle) as string; |
| const encoded = Buffer.from(data, 'binary').toString('base64'); |
| return context.newString(encoded); |
| }); |
| context.setProp(context.global, 'btoa', btoaFn); |
| btoaFn.dispose(); |
|
|
| |
| const atobFn = context.newFunction('atob', (dataHandle: QuickJSHandle) => { |
| const data = context.dump(dataHandle) as string; |
| const decoded = Buffer.from(data, 'base64').toString('binary'); |
| return context.newString(decoded); |
| }); |
| context.setProp(context.global, 'atob', atobFn); |
| atobFn.dispose(); |
| } |
|
|
| |
| |
| |
| |
| function injectCryptoHelpers(context: QuickJSContext): void { |
| const cryptoObj = context.newObject(); |
|
|
| const sha256Fn = context.newFunction('sha256', (dataHandle: QuickJSHandle) => { |
| const data = context.dump(dataHandle) as string; |
| const hash = createHash('sha256').update(data).digest('hex'); |
| return context.newString(hash); |
| }); |
| context.setProp(cryptoObj, 'sha256', sha256Fn); |
| sha256Fn.dispose(); |
|
|
| const uuidFn = context.newFunction('randomUUID', () => { |
| return context.newString(randomUUID()); |
| }); |
| context.setProp(cryptoObj, 'randomUUID', uuidFn); |
| uuidFn.dispose(); |
|
|
| context.setProp(context.global, 'crypto', cryptoObj); |
| cryptoObj.dispose(); |
| } |
|
|
| |
| |
| |
| function buildWrappedCode(code: string): string { |
| return ` |
| (async function() { |
| 'use strict'; |
| try { |
| ${code} |
| } catch (error) { |
| const msg = error instanceof Error ? error.message : String(error); |
| Response.error(msg, 500); |
| } |
| })(); |
| `; |
| } |
|
|
| |
| |
| |
| function jsonToHandle(context: QuickJSContext, value: unknown): QuickJSHandle { |
| if (value === null) { |
| return context.null; |
| } |
| if (value === undefined) { |
| return context.undefined; |
| } |
| if (typeof value === 'boolean') { |
| return value ? context.true : context.false; |
| } |
| if (typeof value === 'number') { |
| return context.newNumber(value); |
| } |
| if (typeof value === 'string') { |
| return context.newString(value); |
| } |
| if (Array.isArray(value)) { |
| const arr = context.newArray(); |
| for (let i = 0; i < value.length; i++) { |
| const itemHandle = jsonToHandle(context, value[i]); |
| context.setProp(arr, i, itemHandle); |
| itemHandle.dispose(); |
| } |
| return arr; |
| } |
| if (typeof value === 'object') { |
| const obj = context.newObject(); |
| for (const [key, val] of Object.entries(value)) { |
| const valHandle = jsonToHandle(context, val); |
| context.setProp(obj, key, valHandle); |
| valHandle.dispose(); |
| } |
| return obj; |
| } |
| return context.newString(String(value)); |
| } |
|
|
| |
| |
| |
| function formatValue(value: unknown): string { |
| if (value === null) return 'null'; |
| if (value === undefined) return 'undefined'; |
| if (typeof value === 'string') return value; |
| if (typeof value === 'number' || typeof value === 'boolean') return String(value); |
|
|
| try { |
| return JSON.stringify(value, null, 2); |
| } catch { |
| return String(value); |
| } |
| } |
|
|
| |
| |
| |
| function formatError(error: unknown): string { |
| if (error instanceof Error) { |
| if (error.message.includes('interrupted')) { |
| return 'Function execution timed out'; |
| } |
| if (error.message.includes('memory')) { |
| return 'Function exceeded memory limit'; |
| } |
| return error.message; |
| } |
| return String(error); |
| } |
|
|
| |
| |
| |
| |
| export function validateFunctionCode(code: string): string | null { |
| if (!code || code.trim().length === 0) { |
| return 'Function code cannot be empty'; |
| } |
|
|
| |
| if (code.includes('import ')) { |
| return 'ES6 import statements are not supported. Use the provided APIs.'; |
| } |
| if (code.includes('require(')) { |
| return 'require() is not available. Use the provided APIs.'; |
| } |
|
|
| return null; |
| } |
|
|
| |
| |
| |
| |
| export function validateFunctionName(name: string): string | null { |
| if (!name || name.trim().length === 0) { |
| return 'Function name cannot be empty'; |
| } |
|
|
| if (name.length > 64) { |
| return 'Function name must be 64 characters or less'; |
| } |
|
|
| |
| if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(name)) { |
| return 'Function name must be URL-safe (lowercase letters, numbers, hyphens only, cannot start or end with hyphen)'; |
| } |
|
|
| |
| const reserved = ['health', 'status', 'ping', 'api', 'admin', 'static']; |
| if (reserved.includes(name)) { |
| return `Function name "${name}" is reserved`; |
| } |
|
|
| return null; |
| } |
|
|
| |
| |
| |
| |
| export function validateServerFunctionName(name: string): string | null { |
| if (!name || name.trim().length === 0) { |
| return 'Function name cannot be empty'; |
| } |
|
|
| if (name.length > 64) { |
| return 'Function name must be 64 characters or less'; |
| } |
|
|
| |
| if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) { |
| return 'Function name must be a valid identifier (letters, numbers, underscores; cannot start with number)'; |
| } |
|
|
| |
| const jsReserved = [ |
| 'break', 'case', 'catch', 'continue', 'debugger', 'default', 'delete', 'do', |
| 'else', 'finally', 'for', 'function', 'if', 'in', 'instanceof', 'new', |
| 'return', 'switch', 'this', 'throw', 'try', 'typeof', 'var', 'void', |
| 'while', 'with', 'class', 'const', 'enum', 'export', 'extends', 'import', |
| 'super', 'implements', 'interface', 'let', 'package', 'private', 'protected', |
| 'public', 'static', 'yield', 'await', 'async', |
| ]; |
|
|
| if (jsReserved.includes(name)) { |
| return `"${name}" is a reserved JavaScript keyword`; |
| } |
|
|
| |
| const reserved = ['db', 'fetch', 'console', 'args', 'request', 'Response', 'server', 'secrets', 'atob', 'btoa']; |
| if (reserved.includes(name)) { |
| return `"${name}" is reserved and cannot be used as a server function name`; |
| } |
|
|
| return null; |
| } |
|
|