loop.ts605 lines · main
| 1 | // Runs inside the Deno isolate. |
| 2 | // |
| 3 | // Wire protocol: |
| 4 | // stdin: newline-delimited JSON HostToIsolate messages |
| 5 | // stdout: newline-delimited JSON IsolateToHost messages |
| 6 | // stderr: newline-delimited JSON LogLine envelopes (best-effort) |
| 7 | |
| 8 | const encoder = new TextEncoder(); |
| 9 | const originalStderrWrite = Deno.stderr.write.bind(Deno.stderr); |
| 10 | |
| 11 | let currentRequestId: string | null = null; |
| 12 | |
| 13 | function writeStdout(obj: unknown): Promise<number> { |
| 14 | return Deno.stdout.write(encoder.encode(JSON.stringify(obj) + '\n')); |
| 15 | } |
| 16 | |
| 17 | function emitLog(level: 'debug' | 'info' | 'warn' | 'error', args: unknown[]): void { |
| 18 | const msg = args.map(stringify).join(' '); |
| 19 | const line = JSON.stringify({ |
| 20 | type: 'log', |
| 21 | requestId: currentRequestId, |
| 22 | level, |
| 23 | msg, |
| 24 | ts: Date.now(), |
| 25 | }) + '\n'; |
| 26 | originalStderrWrite(encoder.encode(line)); |
| 27 | } |
| 28 | |
| 29 | function stringify(v: unknown): string { |
| 30 | if (typeof v === 'string') return v; |
| 31 | try { |
| 32 | return JSON.stringify(v); |
| 33 | } catch { |
| 34 | return String(v); |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | // Replace global console — must happen before customer code runs. |
| 39 | (globalThis as { console: unknown }).console = { |
| 40 | log: (...a: unknown[]) => emitLog('info', a), |
| 41 | info: (...a: unknown[]) => emitLog('info', a), |
| 42 | warn: (...a: unknown[]) => emitLog('warn', a), |
| 43 | error: (...a: unknown[]) => emitLog('error', a), |
| 44 | debug: (...a: unknown[]) => emitLog('debug', a), |
| 45 | }; |
| 46 | |
| 47 | // Belt-and-braces v6 deny — Deno 2.x's --deny-net doesn't accept v6 CIDRs, |
| 48 | // so we shim fetch to reject literal v6 addresses in ::1, fc00::/7, fe80::/10. |
| 49 | // Hostnames that resolve to v6 are NOT covered here — the Phase 3 host-level |
| 50 | // proxy will close that gap. This guards the obvious literal-IP case. |
| 51 | const originalFetch = globalThis.fetch; |
| 52 | function isBlockedV6(host: string): boolean { |
| 53 | // strip brackets if present |
| 54 | const h = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host; |
| 55 | // ::1 (loopback) |
| 56 | if (h === '::1') return true; |
| 57 | // fc00::/7 — first 7 bits = 1111110_ → first hextet starts with fc or fd |
| 58 | if (/^fc[0-9a-f]{2}:/i.test(h) || /^fd[0-9a-f]{2}:/i.test(h)) return true; |
| 59 | // fe80::/10 — first 10 bits → first hextet starts with fe8, fe9, fea, feb |
| 60 | if (/^fe[89ab][0-9a-f]:/i.test(h)) return true; |
| 61 | return false; |
| 62 | } |
| 63 | (globalThis as { fetch: typeof fetch }).fetch = ((input: RequestInfo | URL, init?: RequestInit) => { |
| 64 | try { |
| 65 | const url = typeof input === 'string' || input instanceof URL ? new URL(input.toString()) : new URL(input.url); |
| 66 | if (isBlockedV6(url.hostname)) { |
| 67 | return Promise.reject( |
| 68 | Object.assign(new Error(`fetch blocked: IPv6 ${url.hostname} in deny range`), { name: 'PermissionDenied' }), |
| 69 | ); |
| 70 | } |
| 71 | } catch { /* fall through */ } |
| 72 | return originalFetch(input as never, init); |
| 73 | }) as typeof fetch; |
| 74 | |
| 75 | // Pending queries — qid → resolver. Multiple in flight per invocation |
| 76 | // when customer uses Promise.all. |
| 77 | const pendingQueries = new Map< |
| 78 | string, |
| 79 | { resolve: (rows: readonly unknown[]) => void; reject: (err: Error) => void } |
| 80 | >(); |
| 81 | let qidCounter = 0; |
| 82 | |
| 83 | // --------------------------------------------------------------------------- |
| 84 | // Ctx — structurally satisfies `Ctx` from @briven/schema. We can't import |
| 85 | // from @briven/schema here because loop.ts is materialized into the isolate |
| 86 | // and resolved by Deno (no node_modules access). The shape MUST stay in sync |
| 87 | // with packages/schema/src/ctx.ts. |
| 88 | // --------------------------------------------------------------------------- |
| 89 | |
| 90 | export interface AuthContext { |
| 91 | readonly userId: string; |
| 92 | readonly tokenType: 'session' | 'api_key'; |
| 93 | } |
| 94 | |
| 95 | export interface Logger { |
| 96 | debug(event: string, fields?: Record<string, unknown>): void; |
| 97 | info(event: string, fields?: Record<string, unknown>): void; |
| 98 | warn(event: string, fields?: Record<string, unknown>): void; |
| 99 | error(event: string, fields?: Record<string, unknown>): void; |
| 100 | } |
| 101 | |
| 102 | export interface SelectQuery extends PromiseLike<unknown[]> { |
| 103 | where(predicate: Record<string, unknown>): SelectQuery; |
| 104 | orderBy(column: string, direction?: 'asc' | 'desc'): SelectQuery; |
| 105 | limit(n: number): SelectQuery; |
| 106 | offset(n: number): SelectQuery; |
| 107 | } |
| 108 | |
| 109 | export interface InsertQuery extends PromiseLike<unknown[]> { |
| 110 | returning(columns?: readonly string[]): PromiseLike<unknown[]>; |
| 111 | } |
| 112 | |
| 113 | export interface UpdateQuery extends PromiseLike<unknown[]> { |
| 114 | where(predicate: Record<string, unknown>): UpdateQuery; |
| 115 | returning(columns?: readonly string[]): PromiseLike<unknown[]>; |
| 116 | } |
| 117 | |
| 118 | export interface DeleteQuery extends PromiseLike<unknown[]> { |
| 119 | where(predicate: Record<string, unknown>): DeleteQuery; |
| 120 | returning(columns?: readonly string[]): PromiseLike<unknown[]>; |
| 121 | } |
| 122 | |
| 123 | export interface TableQuery { |
| 124 | select(columns?: readonly string[]): SelectQuery; |
| 125 | insert(values: Record<string, unknown> | readonly Record<string, unknown>[]): InsertQuery; |
| 126 | update(patch: Record<string, unknown>): UpdateQuery; |
| 127 | delete(): DeleteQuery; |
| 128 | } |
| 129 | |
| 130 | export interface DbClient { |
| 131 | <TTable extends string>(table: TTable): TableQuery; |
| 132 | execute(sql: string, params?: readonly unknown[]): Promise<unknown[]>; |
| 133 | } |
| 134 | |
| 135 | export interface Ctx { |
| 136 | readonly db: DbClient; |
| 137 | readonly requestId: string; |
| 138 | readonly log: Logger; |
| 139 | readonly env: Readonly<Record<string, string | undefined>>; |
| 140 | readonly auth: AuthContext | null; |
| 141 | } |
| 142 | |
| 143 | // --------------------------------------------------------------------------- |
| 144 | // Query plumbing |
| 145 | // --------------------------------------------------------------------------- |
| 146 | |
| 147 | /** |
| 148 | * Allocate a qid, register a pending entry, send the query frame to the host, |
| 149 | * and return a promise that resolves with the rows the host sends back. |
| 150 | * |
| 151 | * Centralising this means every builder's `then()` and `db.execute()` shares |
| 152 | * the same null-guard, error-rejection, and stdout-failure handling. |
| 153 | */ |
| 154 | function sendQuery(req: { sql: string; params: readonly unknown[]; table: string }): Promise<unknown[]> { |
| 155 | if (currentRequestId === null) { |
| 156 | return Promise.reject(new Error('ctx.db(...) used outside an invocation')); |
| 157 | } |
| 158 | const requestId = currentRequestId; |
| 159 | const qid = `q${++qidCounter}`; |
| 160 | return new Promise<unknown[]>((resolve, reject) => { |
| 161 | pendingQueries.set(qid, { |
| 162 | resolve: (rows) => resolve(rows as unknown[]), |
| 163 | reject, |
| 164 | }); |
| 165 | writeStdout({ |
| 166 | type: 'query', |
| 167 | requestId, |
| 168 | qid, |
| 169 | sql: req.sql, |
| 170 | params: req.params, |
| 171 | table: req.table, |
| 172 | }).catch((err: unknown) => { |
| 173 | pendingQueries.delete(qid); |
| 174 | reject(err instanceof Error ? err : new Error(String(err))); |
| 175 | }); |
| 176 | }); |
| 177 | } |
| 178 | |
| 179 | function makeCtx(auth: AuthContext | null, requestId: string): Ctx { |
| 180 | const db = ((table: string) => makeTableProxy(table)) as DbClient; |
| 181 | db.execute = (sql: string, params?: readonly unknown[]) => |
| 182 | sendQuery({ sql, params: params ?? [], table: '' }); |
| 183 | |
| 184 | const log: Logger = { |
| 185 | debug: (event, fields) => emitLog('debug', fields ? [event, fields] : [event]), |
| 186 | info: (event, fields) => emitLog('info', fields ? [event, fields] : [event]), |
| 187 | warn: (event, fields) => emitLog('warn', fields ? [event, fields] : [event]), |
| 188 | error: (event, fields) => emitLog('error', fields ? [event, fields] : [event]), |
| 189 | }; |
| 190 | |
| 191 | const env = new Proxy({} as Record<string, string | undefined>, { |
| 192 | get(_, key: string) { |
| 193 | return Deno.env.get(key); |
| 194 | }, |
| 195 | ownKeys() { |
| 196 | // Phase 1: customer iterates ctx.env at their own peril; allow-env |
| 197 | // gates which keys are even visible to Deno.env. |
| 198 | return []; |
| 199 | }, |
| 200 | }); |
| 201 | |
| 202 | return { |
| 203 | db, |
| 204 | requestId, |
| 205 | log, |
| 206 | env, |
| 207 | auth, |
| 208 | }; |
| 209 | } |
| 210 | |
| 211 | function makeTableProxy(table: string): TableQuery { |
| 212 | return { |
| 213 | select: (columns?: readonly string[]) => makeSelectProxy(table, columns), |
| 214 | insert: (values) => makeInsertProxy(table, values), |
| 215 | update: (patch) => makeUpdateProxy(table, patch), |
| 216 | delete: () => makeDeleteProxy(table), |
| 217 | }; |
| 218 | } |
| 219 | |
| 220 | // --------------------------------------------------------------------------- |
| 221 | // SELECT |
| 222 | // --------------------------------------------------------------------------- |
| 223 | |
| 224 | interface SelectState { |
| 225 | where?: Record<string, unknown>; |
| 226 | orderBy?: { col: string; dir: 'asc' | 'desc' }; |
| 227 | limit?: number; |
| 228 | offset?: number; |
| 229 | } |
| 230 | |
| 231 | function makeSelectProxy( |
| 232 | table: string, |
| 233 | columns?: readonly string[], |
| 234 | state: SelectState = {}, |
| 235 | ): SelectQuery { |
| 236 | const run = (): Promise<unknown[]> => { |
| 237 | const { sql, params } = buildSelectSql(table, columns, state); |
| 238 | return sendQuery({ sql, params, table }); |
| 239 | }; |
| 240 | return { |
| 241 | where(p) { |
| 242 | return makeSelectProxy(table, columns, { ...state, where: { ...state.where, ...p } }); |
| 243 | }, |
| 244 | orderBy(col, dir = 'asc') { |
| 245 | return makeSelectProxy(table, columns, { ...state, orderBy: { col, dir } }); |
| 246 | }, |
| 247 | limit(n) { |
| 248 | return makeSelectProxy(table, columns, { ...state, limit: n }); |
| 249 | }, |
| 250 | offset(n) { |
| 251 | return makeSelectProxy(table, columns, { ...state, offset: n }); |
| 252 | }, |
| 253 | then(onfulfilled, onrejected) { |
| 254 | return run().then(onfulfilled as never, onrejected as never); |
| 255 | }, |
| 256 | }; |
| 257 | } |
| 258 | |
| 259 | // --------------------------------------------------------------------------- |
| 260 | // INSERT |
| 261 | // --------------------------------------------------------------------------- |
| 262 | |
| 263 | function makeInsertProxy( |
| 264 | table: string, |
| 265 | values: Record<string, unknown> | readonly Record<string, unknown>[], |
| 266 | returningCols: readonly string[] | null = null, |
| 267 | ): InsertQuery { |
| 268 | const run = (): Promise<unknown[]> => { |
| 269 | const { sql, params } = buildInsertSql(table, values, returningCols); |
| 270 | return sendQuery({ sql, params, table }); |
| 271 | }; |
| 272 | return { |
| 273 | returning(cols) { |
| 274 | const next = cols ?? []; |
| 275 | return makeInsertProxy(table, values, next); |
| 276 | }, |
| 277 | then(onfulfilled, onrejected) { |
| 278 | return run().then(onfulfilled as never, onrejected as never); |
| 279 | }, |
| 280 | }; |
| 281 | } |
| 282 | |
| 283 | // --------------------------------------------------------------------------- |
| 284 | // UPDATE |
| 285 | // --------------------------------------------------------------------------- |
| 286 | |
| 287 | interface UpdateState { |
| 288 | where?: Record<string, unknown>; |
| 289 | } |
| 290 | |
| 291 | function makeUpdateProxy( |
| 292 | table: string, |
| 293 | patch: Record<string, unknown>, |
| 294 | state: UpdateState = {}, |
| 295 | returningCols: readonly string[] | null = null, |
| 296 | ): UpdateQuery { |
| 297 | const run = (): Promise<unknown[]> => { |
| 298 | const { sql, params } = buildUpdateSql(table, patch, state, returningCols); |
| 299 | return sendQuery({ sql, params, table }); |
| 300 | }; |
| 301 | return { |
| 302 | where(p) { |
| 303 | return makeUpdateProxy( |
| 304 | table, |
| 305 | patch, |
| 306 | { ...state, where: { ...state.where, ...p } }, |
| 307 | returningCols, |
| 308 | ); |
| 309 | }, |
| 310 | returning(cols) { |
| 311 | return makeUpdateProxy(table, patch, state, cols ?? []); |
| 312 | }, |
| 313 | then(onfulfilled, onrejected) { |
| 314 | return run().then(onfulfilled as never, onrejected as never); |
| 315 | }, |
| 316 | }; |
| 317 | } |
| 318 | |
| 319 | // --------------------------------------------------------------------------- |
| 320 | // DELETE |
| 321 | // --------------------------------------------------------------------------- |
| 322 | |
| 323 | interface DeleteState { |
| 324 | where?: Record<string, unknown>; |
| 325 | } |
| 326 | |
| 327 | function makeDeleteProxy( |
| 328 | table: string, |
| 329 | state: DeleteState = {}, |
| 330 | returningCols: readonly string[] | null = null, |
| 331 | ): DeleteQuery { |
| 332 | const run = (): Promise<unknown[]> => { |
| 333 | const { sql, params } = buildDeleteSql(table, state, returningCols); |
| 334 | return sendQuery({ sql, params, table }); |
| 335 | }; |
| 336 | return { |
| 337 | where(p) { |
| 338 | return makeDeleteProxy(table, { ...state, where: { ...state.where, ...p } }, returningCols); |
| 339 | }, |
| 340 | returning(cols) { |
| 341 | return makeDeleteProxy(table, state, cols ?? []); |
| 342 | }, |
| 343 | then(onfulfilled, onrejected) { |
| 344 | return run().then(onfulfilled as never, onrejected as never); |
| 345 | }, |
| 346 | }; |
| 347 | } |
| 348 | |
| 349 | // --------------------------------------------------------------------------- |
| 350 | // SQL builders — mirror apps/runtime/src/query-builder.ts identifier rules. |
| 351 | // --------------------------------------------------------------------------- |
| 352 | |
| 353 | const IDENT = /^[a-zA-Z_][a-zA-Z0-9_]{0,62}$/; |
| 354 | function quote(name: string): string { |
| 355 | if (!IDENT.test(name)) throw new Error(`invalid identifier: ${JSON.stringify(name)}`); |
| 356 | return `"${name}"`; |
| 357 | } |
| 358 | |
| 359 | function quoteList(names: readonly string[]): string { |
| 360 | return names.map(quote).join(', '); |
| 361 | } |
| 362 | |
| 363 | function buildSelectSql( |
| 364 | table: string, |
| 365 | columns: readonly string[] | undefined, |
| 366 | state: SelectState, |
| 367 | ): { sql: string; params: readonly unknown[] } { |
| 368 | const cols = columns ? quoteList(columns) : '*'; |
| 369 | let sql = `SELECT ${cols} FROM ${quote(table)}`; |
| 370 | const params: unknown[] = []; |
| 371 | if (state.where && Object.keys(state.where).length > 0) { |
| 372 | const parts: string[] = []; |
| 373 | for (const [col, val] of Object.entries(state.where)) { |
| 374 | parts.push(`${quote(col)} = $${params.length + 1}`); |
| 375 | params.push(val); |
| 376 | } |
| 377 | sql += ' WHERE ' + parts.join(' AND '); |
| 378 | } |
| 379 | if (state.orderBy) sql += ` ORDER BY ${quote(state.orderBy.col)} ${state.orderBy.dir.toUpperCase()}`; |
| 380 | if (state.limit != null) sql += ` LIMIT ${Number(state.limit)}`; |
| 381 | if (state.offset != null) sql += ` OFFSET ${Number(state.offset)}`; |
| 382 | return { sql, params }; |
| 383 | } |
| 384 | |
| 385 | function buildInsertSql( |
| 386 | table: string, |
| 387 | values: Record<string, unknown> | readonly Record<string, unknown>[], |
| 388 | returningCols: readonly string[] | null, |
| 389 | ): { sql: string; params: readonly unknown[] } { |
| 390 | const rows = Array.isArray(values) ? values : [values as Record<string, unknown>]; |
| 391 | if (rows.length === 0) { |
| 392 | // Postgres has no useful zero-row INSERT shape; surface explicitly so the |
| 393 | // host doesn't try to send malformed SQL. |
| 394 | throw new Error('insert() called with empty array'); |
| 395 | } |
| 396 | const cols = Object.keys(rows[0]!); |
| 397 | const colSql = quoteList(cols); |
| 398 | const params: unknown[] = []; |
| 399 | const valueRows = rows.map((r) => { |
| 400 | const placeholders = cols.map((c) => { |
| 401 | params.push(r[c]); |
| 402 | return `$${params.length}`; |
| 403 | }); |
| 404 | return `(${placeholders.join(', ')})`; |
| 405 | }); |
| 406 | let sql = `INSERT INTO ${quote(table)} (${colSql}) VALUES ${valueRows.join(', ')}`; |
| 407 | if (returningCols !== null) { |
| 408 | const ret = returningCols.length === 0 ? '*' : quoteList(returningCols); |
| 409 | sql += ` RETURNING ${ret}`; |
| 410 | } |
| 411 | return { sql, params }; |
| 412 | } |
| 413 | |
| 414 | function buildUpdateSql( |
| 415 | table: string, |
| 416 | patch: Record<string, unknown>, |
| 417 | state: UpdateState, |
| 418 | returningCols: readonly string[] | null, |
| 419 | ): { sql: string; params: readonly unknown[] } { |
| 420 | const setParts: string[] = []; |
| 421 | const params: unknown[] = []; |
| 422 | for (const [col, value] of Object.entries(patch)) { |
| 423 | params.push(value); |
| 424 | setParts.push(`${quote(col)} = $${params.length}`); |
| 425 | } |
| 426 | if (setParts.length === 0) { |
| 427 | throw new Error('update() called with empty patch'); |
| 428 | } |
| 429 | let sql = `UPDATE ${quote(table)} SET ${setParts.join(', ')}`; |
| 430 | if (state.where && Object.keys(state.where).length > 0) { |
| 431 | const whereParts: string[] = []; |
| 432 | for (const [col, val] of Object.entries(state.where)) { |
| 433 | params.push(val); |
| 434 | whereParts.push(`${quote(col)} = $${params.length}`); |
| 435 | } |
| 436 | sql += ' WHERE ' + whereParts.join(' AND '); |
| 437 | } |
| 438 | if (returningCols !== null) { |
| 439 | const ret = returningCols.length === 0 ? '*' : quoteList(returningCols); |
| 440 | sql += ` RETURNING ${ret}`; |
| 441 | } |
| 442 | return { sql, params }; |
| 443 | } |
| 444 | |
| 445 | function buildDeleteSql( |
| 446 | table: string, |
| 447 | state: DeleteState, |
| 448 | returningCols: readonly string[] | null, |
| 449 | ): { sql: string; params: readonly unknown[] } { |
| 450 | let sql = `DELETE FROM ${quote(table)}`; |
| 451 | const params: unknown[] = []; |
| 452 | if (state.where && Object.keys(state.where).length > 0) { |
| 453 | const whereParts: string[] = []; |
| 454 | for (const [col, val] of Object.entries(state.where)) { |
| 455 | params.push(val); |
| 456 | whereParts.push(`${quote(col)} = $${params.length}`); |
| 457 | } |
| 458 | sql += ' WHERE ' + whereParts.join(' AND '); |
| 459 | } |
| 460 | if (returningCols !== null) { |
| 461 | const ret = returningCols.length === 0 ? '*' : quoteList(returningCols); |
| 462 | sql += ` RETURNING ${ret}`; |
| 463 | } |
| 464 | return { sql, params }; |
| 465 | } |
| 466 | |
| 467 | // --------------------------------------------------------------------------- |
| 468 | // Loop |
| 469 | // --------------------------------------------------------------------------- |
| 470 | |
| 471 | export async function runQuery(): Promise<never> { |
| 472 | throw new Error('runQuery is reserved for future use'); |
| 473 | } |
| 474 | |
| 475 | export async function runIsolateLoop( |
| 476 | dispatch: Record<string, (ctx: Ctx, args: unknown) => Promise<unknown> | unknown>, |
| 477 | deploymentId: string, |
| 478 | // Per-function load failures captured by __entry.ts. A function whose file |
| 479 | // had a bad import (or no valid export) lands here instead of in `dispatch`; |
| 480 | // invoking it surfaces this message rather than a generic "not found". Empty |
| 481 | // in the healthy case. |
| 482 | loadErrors: Record<string, string> = {}, |
| 483 | ): Promise<void> { |
| 484 | // Send ready handshake. |
| 485 | await writeStdout({ type: 'ready', deploymentId }); |
| 486 | |
| 487 | const decoder = new TextDecoder(); |
| 488 | let buf = ''; |
| 489 | for await (const chunk of Deno.stdin.readable) { |
| 490 | buf += decoder.decode(chunk, { stream: true }); |
| 491 | let nl: number; |
| 492 | while ((nl = buf.indexOf('\n')) !== -1) { |
| 493 | const line = buf.slice(0, nl); |
| 494 | buf = buf.slice(nl + 1); |
| 495 | if (!line) continue; |
| 496 | let msg: { type: string } & Record<string, unknown>; |
| 497 | try { |
| 498 | msg = JSON.parse(line); |
| 499 | } catch { |
| 500 | // Malformed input — surface a structured error to the host (when we |
| 501 | // know the in-flight request) before exiting. |
| 502 | if (currentRequestId !== null) { |
| 503 | await writeStdout({ |
| 504 | type: 'error', |
| 505 | requestId: currentRequestId, |
| 506 | code: 'isolate_protocol_error', |
| 507 | message: 'malformed input from host', |
| 508 | durationMs: 0, |
| 509 | }).catch(() => undefined); |
| 510 | } |
| 511 | Deno.exit(1); |
| 512 | } |
| 513 | if (msg.type === 'shutdown') { |
| 514 | Deno.exit(0); |
| 515 | } else if (msg.type === 'invoke') { |
| 516 | // Do NOT await — the invoked function may issue queries whose |
| 517 | // `query_result` replies arrive as *later* stdin messages handled by |
| 518 | // THIS same loop. Awaiting here blocks the reader, so the reply is |
| 519 | // never read, the query promise never resolves, and Deno aborts with |
| 520 | // "Top-level await promise never resolved" (a self-deadlock). Running |
| 521 | // it concurrently lets the loop keep reading; handleInvoke writes its |
| 522 | // own result/error frame to stdout when it finishes. The host |
| 523 | // serialises one invocation per isolate, so at most one is in flight. |
| 524 | void handleInvoke(msg as never, dispatch, loadErrors).catch(() => undefined); |
| 525 | } else if (msg.type === 'query_result') { |
| 526 | const m = msg as { qid: string; rows?: readonly unknown[]; error?: { code: string; message: string } }; |
| 527 | const pending = pendingQueries.get(m.qid); |
| 528 | if (pending) { |
| 529 | pendingQueries.delete(m.qid); |
| 530 | if (m.error) pending.reject(new Error(m.error.message)); |
| 531 | else pending.resolve(m.rows ?? []); |
| 532 | } else { |
| 533 | emitLog('warn', [`unknown qid: ${m.qid}`]); |
| 534 | } |
| 535 | } |
| 536 | } |
| 537 | } |
| 538 | } |
| 539 | |
| 540 | async function handleInvoke( |
| 541 | msg: { requestId: string; functionName: string; args: unknown; auth: AuthContext | null }, |
| 542 | dispatch: Record<string, (ctx: Ctx, args: unknown) => Promise<unknown> | unknown>, |
| 543 | loadErrors: Record<string, string> = {}, |
| 544 | ): Promise<void> { |
| 545 | currentRequestId = msg.requestId; |
| 546 | const started = performance.now(); |
| 547 | const fn = dispatch[msg.functionName]; |
| 548 | // A function excluded from `dispatch` because its file failed to load gets a |
| 549 | // structured import error carrying the real cause, not a generic not-found. |
| 550 | const loadError = loadErrors[msg.functionName]; |
| 551 | try { |
| 552 | if (!fn) { |
| 553 | if (loadError !== undefined) { |
| 554 | await writeStdout({ |
| 555 | type: 'error', |
| 556 | requestId: msg.requestId, |
| 557 | code: 'import_blocked', |
| 558 | message: `function ${msg.functionName} failed to load: ${loadError}`, |
| 559 | durationMs: Math.round(performance.now() - started), |
| 560 | }); |
| 561 | return; |
| 562 | } |
| 563 | throw new Error(`function not found: ${msg.functionName}`); |
| 564 | } |
| 565 | const ctx = makeCtx(msg.auth, msg.requestId); |
| 566 | const value = await fn(ctx, msg.args); |
| 567 | const durationMs = Math.round(performance.now() - started); |
| 568 | try { |
| 569 | await writeStdout({ |
| 570 | type: 'result', |
| 571 | requestId: msg.requestId, |
| 572 | value, |
| 573 | durationMs, |
| 574 | }); |
| 575 | } catch { |
| 576 | // Most likely the value contains something JSON.stringify can't |
| 577 | // serialise (cyclic ref, BigInt, etc.). Fall back to a structured |
| 578 | // error frame so the host doesn't hang waiting for a result. |
| 579 | await writeStdout({ |
| 580 | type: 'error', |
| 581 | requestId: msg.requestId, |
| 582 | code: 'function_threw', |
| 583 | message: 'result not JSON-serializable', |
| 584 | durationMs, |
| 585 | }).catch(() => undefined); |
| 586 | } |
| 587 | } catch (err) { |
| 588 | const e = err as { name?: string; message?: string }; |
| 589 | let code = 'function_threw'; |
| 590 | if (e?.name === 'PermissionDenied') { |
| 591 | code = (e.message ?? '').includes('net') ? 'network_blocked' |
| 592 | : (e.message ?? '').includes('env') ? 'env_access_denied' |
| 593 | : 'fs_access_denied'; |
| 594 | } |
| 595 | await writeStdout({ |
| 596 | type: 'error', |
| 597 | requestId: msg.requestId, |
| 598 | code, |
| 599 | message: e?.message ?? String(err), |
| 600 | durationMs: Math.round(performance.now() - started), |
| 601 | }); |
| 602 | } finally { |
| 603 | currentRequestId = null; |
| 604 | } |
| 605 | } |