/** * Dev-only request lines for `oke dev` (Backend / Console / MCP). * * Gated by `OKE_DEV_REQUEST_LOG=1`. Surfaces share one TTY; label + color * keep streams readable without a multiplexer. */ import { AsyncLocalStorage } from "node:async_hooks"; import { formatRequestLine, type DevLogSurface } from "../term.ts"; const surfaceAls = new AsyncLocalStorage(); /** * Whether colored request lines should print. */ export function shouldLogDevRequests(): boolean { return process.env.OKE_DEV_REQUEST_LOG === "1"; } /** * Active surface for this async context, or `OKE_DEV_SURFACE`, or `Backend`. */ export function currentDevSurface(): DevLogSurface { const fromAls = surfaceAls.getStore(); if (fromAls) return fromAls; const fromEnv = process.env.OKE_DEV_SURFACE; if (fromEnv === "Backend" || fromEnv === "Console" || fromEnv === "MCP") { return fromEnv; } return "Backend"; } /** * Run a handler tagged with a surface (Console wrap around `app.fetch`). * * @param surface - Backend · Console · MCP * @param fn - Async work */ export function runWithDevSurface( surface: DevLogSurface, fn: () => T | Promise, ): T | Promise { return surfaceAls.run(surface, fn); } /** Fields for one request line. */ export type DevRequestLogInput = { readonly surface?: DevLogSurface; readonly method: string; readonly path: string; /** Flow name, RPC method, or tool id. */ readonly flow?: string; /** WideEvent / run id when the request executed a flow. */ readonly runId?: string; readonly status: number; readonly ms: number; /** Human failure detail (error.message / code) for 4xx/5xx. */ readonly detail?: string; /** Failure `error.code` for the title chip. */ readonly errorCode?: string; }; /** * Paths that would drown the TTY (live WS, health probes, static assets). * * @param method - HTTP method * @param path - URL pathname * @param surface - Optional surface (MCP hides Chrome inspector probes) */ export function isSilentDevRequest(method: string, path: string, surface?: DevLogSurface): boolean { if (path === "/console/live") return true; // Client-types regen — lands between hero and Logs and breaks the separator. if (path === "/_oke/client.json") return true; if (method === "GET" && (path === "/health" || path.endsWith("/health"))) { return true; } // Chrome / Cursor DevTools probe MCP (and sometimes Backend) as CDP. if (surface === "MCP" && method === "GET" && path === "/") return true; if (method === "GET" && (path === "/json" || path.startsWith("/json/"))) { return true; } return /\.(?:js|css|map|svg|png|ico|woff2?|ttf|webp)$/i.test(path); } /** * Extract a short failure detail from an OKE JSON envelope body. * * @param response - HTTP response (cloned; original body stays readable) */ export async function failureEnvelopeFromResponse( response: Response, ): Promise<{ readonly detail?: string; readonly code?: string }> { if (response.status < 400) return {}; try { const body: unknown = await response.clone().json(); if (body === null || typeof body !== "object" || !("error" in body)) return {}; const error = (body as { error: unknown }).error; if (error === null || typeof error !== "object") return {}; const rec = error as { message?: unknown; code?: unknown; data?: unknown }; const code = typeof rec.code === "string" ? rec.code : undefined; if (typeof rec.message === "string" && rec.message.trim().length > 0) { return { detail: rec.message.trim(), ...(code ? { code } : {}) }; } if ( rec.data !== null && typeof rec.data === "object" && "reason" in rec.data && typeof (rec.data as { reason: unknown }).reason === "string" ) { const reason = (rec.data as { reason: string }).reason; return { detail: `${code ?? "Error"}: ${reason}`, ...(code ? { code } : {}), }; } if (code) return { detail: code, code }; } catch { // non-JSON error bodies stay status-only } return {}; } /** * Extract a short failure detail from an OKE JSON envelope body. * * @param response - HTTP response (cloned; original body stays readable) */ export async function failureDetailFromResponse(response: Response): Promise { return (await failureEnvelopeFromResponse(response)).detail; } /** * Print one request line when {@link shouldLogDevRequests} is on. * * @param input - Request summary */ export function logDevRequest(input: DevRequestLogInput): void { if (!shouldLogDevRequests()) return; if ( isSilentDevRequest(input.method.toUpperCase(), input.path, input.surface ?? currentDevSurface()) ) { return; } process.stdout.write( formatRequestLine({ surface: input.surface ?? currentDevSurface(), method: input.method, path: input.path, flow: input.flow, runId: input.runId, status: input.status, ms: input.ms, detail: input.detail, errorCode: input.errorCode, }), ); } /** * Time an async fetch handler and log the outcome. * * @param surface - Fixed surface (MCP) or omit to use ALS/env * @param request - Incoming request * @param handle - Inner fetch * @param resolveFlow - Optional label after the response (e.g. RPC method) * @param resolveRunId - Optional WideEvent / run id after the response */ export async function timedDevFetch( request: Request, handle: (request: Request) => Response | Promise, options: { readonly surface?: DevLogSurface; readonly resolveFlow?: (request: Request, response: Response) => string | undefined; /** WideEvent / run id after the response (when the request executed a flow). */ readonly resolveRunId?: (request: Request, response: Response) => string | undefined; /** Skip logging for this path/method. */ readonly silent?: (request: Request) => boolean; } = {}, ): Promise { if (!shouldLogDevRequests() || options.silent?.(request)) { return handle(request); } const started = performance.now(); const url = new URL(request.url); const method = request.method.toUpperCase(); const response = await handle(request); const envelope = await failureEnvelopeFromResponse(response); logDevRequest({ surface: options.surface ?? currentDevSurface(), method, path: url.pathname, flow: options.resolveFlow?.(request, response), runId: options.resolveRunId?.(request, response), status: response.status, ms: Math.round(performance.now() - started), detail: envelope.detail, errorCode: envelope.code, }); return response; }