import { filterResponseHeaders, Method } from '../shared/gateway-utils'; export type RunnerBackendKind = 'knative' | 'docker'; export interface RunnerExecutePayload { context: Record; http: { method: Method; path: string; query: Record; headers: Record; body_b64?: string; }; limits: { timeout_ms: number }; endpoint?: string; providers?: unknown; secret_envelope?: unknown; [key: string]: unknown; } export interface RunnerExecuteOptions { requestId: string; timeoutMs: number; headers?: Record; } export interface RunnerExecuteResult { status: number; headers: Record; body?: Buffer; } export interface RunnerFetchAssetOptions { path: string; search: string; method: string; headers: Headers; } export interface RunnerBackend { readonly kind: RunnerBackendKind; execute(payload: RunnerExecutePayload, options: RunnerExecuteOptions): Promise; fetchStaticAsset(options: RunnerFetchAssetOptions): Promise; getPublicBase(): string | null; } export class RunnerConfigError extends Error { constructor(message: string) { super(message); this.name = 'RunnerConfigError'; } } export class RunnerRequestError extends Error { public readonly status?: number; public readonly backend: RunnerBackendKind; constructor(message: string, backend: RunnerBackendKind, status?: number) { super(message); this.name = 'RunnerRequestError'; this.backend = backend; this.status = status; } } const HOP_BY_HOP_HEADERS = new Set([ 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', ]); const STATIC_HEADER_ALLOWLIST = new Set([ 'accept', 'accept-encoding', 'accept-language', 'if-modified-since', 'if-none-match', 'range', 'user-agent', ]); class HttpRunnerBackend implements RunnerBackend { public readonly kind: RunnerBackendKind; private readonly baseUrl: string; private readonly publicBase: string | null; private readonly serviceToken: string | null; constructor(kind: RunnerBackendKind, baseUrl: string, publicBase: string | null, serviceToken: string | null) { this.kind = kind; this.baseUrl = baseUrl.replace(/\/+$/, ''); this.publicBase = publicBase ? trimTrailingSlashes(publicBase) : null; this.serviceToken = serviceToken; } getPublicBase(): string | null { return this.publicBase; } async execute(payload: RunnerExecutePayload, options: RunnerExecuteOptions): Promise { const endpoint = `${this.baseUrl}/v1/execute`; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), options.timeoutMs); const headers: Record = { 'content-type': 'application/json', 'x-request-id': options.requestId, }; if (this.serviceToken) { headers['x-runner-service-token'] = this.serviceToken; } if (options.headers) { for (const [key, value] of Object.entries(options.headers)) { if (value !== undefined && value !== null) { headers[key.toLowerCase()] = value; } } } let response: Response; try { console.log('[runner-backend] Fetching:', endpoint); response = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(payload), signal: controller.signal, cache: 'no-store', // Bypass Next.js fetch caching } as RequestInit); console.log('[runner-backend] Fetch succeeded:', response.status); } catch (error: any) { console.error('[runner-backend] Fetch error:', { name: error?.name, message: error?.message, code: error?.code, cause: error?.cause, causeName: error?.cause?.name, causeMessage: error?.cause?.message, causeCode: error?.cause?.code, causeErrno: error?.cause?.errno, causeHostname: error?.cause?.hostname, stack: error?.stack, }); throw wrapFetchError(error, this.kind); } finally { clearTimeout(timeout); } if (!response.ok) { const text = await response.text().catch(() => undefined); throw new RunnerRequestError( `Runner responded with non-success status ${response.status}${text ? `: ${text}` : ''}`, this.kind, response.status, ); } const payloadJson: any = await response.json().catch((error) => { throw new RunnerRequestError(`Runner returned invalid JSON: ${(error as Error).message}`, this.kind); }); const status = typeof payloadJson?.status === 'number' ? payloadJson.status : 200; const headersOut = filterResponseHeaders(payloadJson?.headers as Record | undefined); const body = typeof payloadJson?.body_b64 === 'string' ? Buffer.from(payloadJson.body_b64, 'base64') : undefined; return { status, headers: headersOut, body }; } async fetchStaticAsset(options: RunnerFetchAssetOptions): Promise { const url = buildUrlWithSearch(`${this.baseUrl}/${options.path}`.replace(/\/+$/, ''), options.search); const headers = new Headers(); for (const [key, value] of options.headers.entries()) { const lower = key.toLowerCase(); if (STATIC_HEADER_ALLOWLIST.has(lower)) { headers.set(lower, value); } } let response: Response; try { response = await fetch(url, { method: options.method, headers, }); } catch (error) { throw wrapFetchError(error, this.kind); } return response; } } function wrapFetchError(error: unknown, backend: RunnerBackendKind): Error { if (error instanceof RunnerRequestError || error instanceof RunnerConfigError) { return error; } if ((error as any)?.name === 'AbortError') { const err = new RunnerRequestError('Runner request timed out', backend); err.name = 'AbortError'; return err; } return new RunnerRequestError((error as Error)?.message ?? 'Runner request failed', backend); } function trimTrailingSlashes(value: string): string { return value.replace(/\/+$/, ''); } function buildUrlWithSearch(base: string, search: string): string { if (!search) return base; if (search.startsWith('?') || search.startsWith('&')) { return `${base}${search}`; } return `${base}?${search}`; } let cachedBackend: RunnerBackend | null = null; function resolveBackendKind(): RunnerBackendKind { const raw = (process.env.RUNNER_BACKEND || 'knative').trim().toLowerCase(); return raw === 'docker' ? 'docker' : 'knative'; } function resolveBaseUrl(kind: RunnerBackendKind): string { const base = kind === 'docker' ? process.env.RUNNER_DOCKER_HOST || process.env.RUNNER_BASE_URL : process.env.RUNNER_BASE_URL; if (!base) { throw new RunnerConfigError(`Runner base URL not configured for backend "${kind}"`); } return base.replace(/\/+$/, ''); } function resolvePublicBase(): string | null { const raw = process.env.RUNNER_PUBLIC_BASE; if (!raw) return null; return trimTrailingSlashes(raw); } function resolveServiceToken(): string | null { return process.env.RUNNER_SERVICE_TOKEN || null; } function buildBackend(): RunnerBackend { const kind = resolveBackendKind(); const base = resolveBaseUrl(kind); const publicBase = resolvePublicBase(); const serviceToken = resolveServiceToken(); return new HttpRunnerBackend(kind, base, publicBase, serviceToken); } export function getRunnerBackend(): RunnerBackend { if (process.env.NODE_ENV === 'development') { return buildBackend(); } if (!cachedBackend) { cachedBackend = buildBackend(); } return cachedBackend; } export function filterHopByHopHeaders(headers: Headers): Record { const out: Record = {}; headers.forEach((value, key) => { const lower = key.toLowerCase(); if (!HOP_BY_HOP_HEADERS.has(lower)) { out[lower] = value; } }); return out; }