Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions apps/api/scripts/bench-startup-cpu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
//
// Cloudflare error 10021 ("Script startup exceeded CPU time limit") fires during
// upload validation, which runs ONLY the worker's top-level module scope against
// a fixed budget (~400ms documented; behaved like ~1s here). So the only thing
// that matters is: how much CPU does *constructing* these schemas burn at import?
// a fixed 1s budget. The relevant question is therefore: how much CPU does
// *constructing* these schemas burn at import?
//
// bun run scripts/bench-startup-cpu.ts # micro (default)
// bun run scripts/bench-startup-cpu.ts micro --json
Expand All @@ -35,9 +35,9 @@ import { join, resolve } from "node:path"
import { Predicate, Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"

const CF_STARTUP_BUDGET_MS = 400 // documented startup CPU ceiling
// https://developers.cloudflare.com/workers/platform/limits/#worker-startup-time
const CF_STARTUP_BUDGET_MS = 1_000
const OBSERVED_BLOWUP_MS = 1000 // what the team saw blow up (per the fix session)
const POST_FIX_STARTUP_MS = 25 // post lazy-import startup CPU (per memory)

type Sample = { wallMs: number; cpuMs: number }

Expand Down Expand Up @@ -224,10 +224,10 @@ const runMicro = (opts: {
` evaluating the ENTIRE static import graph (all of @maple/domain + MCP tool/JSON-schema` +
` derivation + OpenApi.fromApi), not the error taxonomy — which is why the fix was deferring`,
)
console.log(` ./app behind a dynamic import, not trimming error classes.`)
console.log(
` ./app behind a dynamic import, not trimming error classes. Post-fix startup is ~${POST_FIX_STARTUP_MS} ms.`,
` Use \`bun run scripts/bench-startup-cpu.ts worker\` for the current authoritative V8/workerd number.\n`,
)
console.log(` Authoritative V8/workerd number: \`bun run scripts/bench-startup-cpu.ts worker\`.\n`)
}

type CpuProfile = {
Expand Down
54 changes: 54 additions & 0 deletions apps/api/src/http/api-cors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest"
import { Layer } from "effect"
import { HttpRouter, HttpServerResponse } from "effect/unstable/http"
import { API_CORS_OPTIONS, API_CORS_RESPONSE_HEADERS, apiCorsPreflightResponse } from "./api-cors"

describe("apiCorsPreflightResponse", () => {
it("answers preflights with the global API CORS contract", async () => {
const response = apiCorsPreflightResponse()

expect(response.status).toBe(204)
expect(await response.text()).toBe("")
expect(response.headers.get("access-control-allow-origin")).toBe(
API_CORS_RESPONSE_HEADERS["access-control-allow-origin"],
)
expect(response.headers.get("vary")).toBe(API_CORS_RESPONSE_HEADERS.vary)
expect(response.headers.get("access-control-allow-methods")).toBe(
API_CORS_OPTIONS.allowedMethods.join(", "),
)
expect(response.headers.get("access-control-allow-headers")).toBe(
API_CORS_OPTIONS.allowedHeaders.join(","),
)
expect(response.headers.get("access-control-expose-headers")).toBe(
API_CORS_RESPONSE_HEADERS["access-control-expose-headers"],
)
expect(response.headers.get("access-control-max-age")).toBe(String(API_CORS_OPTIONS.maxAge))
})

it("stays byte-equivalent to Effect's configured CORS middleware", async () => {
const probe = HttpRouter.use((router) =>
router.add("GET", "/probe", HttpServerResponse.text("probe")),
).pipe(Layer.provideMerge(HttpRouter.cors(API_CORS_OPTIONS)))
const { handler, dispose } = HttpRouter.toWebHandler(probe, { disableLogger: true })
try {
const request = new Request("https://api.example/probe", {
method: "OPTIONS",
headers: {
origin: "https://app.maple.dev",
"access-control-request-method": "GET",
"access-control-request-headers": "authorization,content-type",
},
})
const [fast, effect] = await Promise.all([
Promise.resolve(apiCorsPreflightResponse()),
handler(request),
])

expect(fast.status).toBe(effect.status)
expect([...fast.headers]).toEqual([...effect.headers])
expect(await fast.text()).toBe(await effect.text())
} finally {
await dispose()
}
})
})
26 changes: 26 additions & 0 deletions apps/api/src/http/api-cors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,29 @@ export const API_CORS_OPTIONS = {
// for OPTIONS. Browsers clamp this value themselves (Chrome 2h, Firefox 24h).
maxAge: 86_400,
}

/** Headers Effect's CORS middleware adds to every non-OPTIONS response. */
export const API_CORS_RESPONSE_HEADERS = {
"access-control-allow-origin": API_CORS_OPTIONS.allowedOrigins[0],
vary: "Origin",
"access-control-expose-headers": API_CORS_OPTIONS.exposedHeaders.join(","),
} as const

/**
* Bootstrap-safe equivalent of Effect's global CORS middleware for OPTIONS.
*
* The configured origin and header policies are static, so preflights do not
* need the route, service, auth, database, or telemetry graphs. Keep this in
* lockstep with `API_CORS_OPTIONS`; non-OPTIONS responses still receive their
* CORS headers from `HttpRouter.cors`.
*/
export const apiCorsPreflightResponse = (): Response =>
new Response(null, {
status: 204,
headers: {
...API_CORS_RESPONSE_HEADERS,
"access-control-allow-methods": API_CORS_OPTIONS.allowedMethods.join(", "),
"access-control-allow-headers": API_CORS_OPTIONS.allowedHeaders.join(","),
"access-control-max-age": String(API_CORS_OPTIONS.maxAge),
},
})
120 changes: 81 additions & 39 deletions apps/api/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import * as HttpPlatform from "effect/unstable/http/HttpPlatform"
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
import { serverErrorSpanMiddleware } from "./http/server-error-span"
import { v2WorkerUnavailableResponse } from "./http/v2-worker-unavailable"
import { API_CORS_RESPONSE_HEADERS, apiCorsPreflightResponse } from "./http/api-cors"
import { persistSession, preloadSession, type SessionsBinding } from "./mcp/lib/session-store"
import { classifyWorkerQueue } from "./queue-dispatch"

Expand Down Expand Up @@ -52,6 +53,11 @@ const WorkerPlatformLive = Layer.mergeAll(
WorkerHttpPlatformLive,
)

// HttpRouter accepts an immutable request-local context. The worker does not
// inject per-request services here, so reuse the same empty value rather than
// rebuilding it on every invocation.
const HandlerContext = Context.empty() as never

// Construct telemetry once at module scope — `layer` is stable, `flush(env)`
// resolves env lazily on first call. Including `telemetry.layer` in the
// handler's layer composition is the critical bit: the Tracer reference must
Expand All @@ -66,23 +72,6 @@ const telemetry = MapleCloudflareSDK.make({
anticipatedErrorIdentifiers: [...ANTICIPATED_ERROR_IDENTIFIERS, ...MCP_ANTICIPATED_ERROR_IDENTIFIERS],
})

// `HttpMiddleware.tracer` ends the root server span on a deferred macrotask
// (`scheduleTask(span.end, 0)`), but `telemetry.flush` drains synchronously.
// Flushing immediately after the response loses the server span — its macrotask
// hasn't fired yet. Isolated requests (e.g. a GitHub webhook) freeze the isolate
// before a subsequent request rescues it, so the trace is silently dropped.
// Yield one macrotask first so `span.end` runs before we drain.
//
// The SDK now owns this drain — `telemetry.flush` yields a macrotask inside its
// own serialized body. This local yield is kept deliberately redundant (an extra
// macrotask is harmless) so a version skew between the worker and the published
// SDK can't drop spans; remove it once the SDK version carrying that change is
// pinned here.
const flushTelemetry = async (env: Record<string, unknown>): Promise<void> => {
await new Promise<void>((resolve) => setTimeout(resolve, 0))
await telemetry.flush(env)
}

/**
* Install one Postgres connection for the whole of `program`.
*
Expand All @@ -104,10 +93,17 @@ const scoped = async <A, E, R>(program: Effect.Effect<A, E, R>) => {
// the top level near-empty; the cost moves to the first request, which runs
// under the far larger per-request CPU budget.
const buildHandler = async () => {
const { HttpServicesLive } = await import("./runtime/service-graph")
const { AllRoutes, ApiAuthLive, ApiObservabilityLive } = await import("./runtime/http-graph")
const { layerPg } = await import("@/platform/DatabasePgLive")
const { pgConnectionMiddleware } = await import("@/platform/pg-connection-scope")
const [
{ HttpServicesLive },
{ AllRoutes, ApiAuthLive, ApiObservabilityLive },
{ layerPg },
{ pgConnectionMiddleware },
] = await Promise.all([
import("./runtime/service-graph"),
import("./runtime/http-graph"),
import("@/platform/DatabasePgLive"),
import("@/platform/pg-connection-scope"),
])
// The worker's one per-request middleware stack. Ordering is load-bearing:
// `serverErrorSpanMiddleware` must stay OUTERMOST (directly under
// `HttpMiddleware.tracer`) so it converts a 5xx success into the failure the
Expand Down Expand Up @@ -138,18 +134,28 @@ const buildHandler = async () => {

// Single isolate-wide handler — `toWebHandler` builds its own ManagedRuntime
// lazily on first invocation and keeps it for the lifetime of the isolate.
// Memoized via the build promise so concurrent first requests share one build;
// a construction failure surfaces as a 504 in `handle` rather than bricking the
// isolate.
// Memoized via the build promise so concurrent first requests share one build.
// A rejected build is cleared after those callers observe it, allowing a later
// request to recover instead of pinning the isolate to a rejected promise.
let handlerPromise: ReturnType<typeof buildHandler> | undefined
const getHandler = () => (handlerPromise ??= buildHandler())
const getHandler = (): ReturnType<typeof buildHandler> => {
if (handlerPromise !== undefined) return handlerPromise
const pending = buildHandler()
handlerPromise = pending
void pending.catch(() => {
if (handlerPromise === pending) handlerPromise = undefined
})
return pending
}

// RPC has no HttpApi request to construct the application services for it, so
// it gets a sibling isolate-wide ManagedRuntime. Its headless service graph
// stays behind a dynamic import, preserving the worker's startup-CPU budget.
const buildRpcRuntime = async (env: Record<string, unknown>) => {
const { InvestigationServicesLive } = await import("./runtime/mcp-service-graph")
const { layerPg } = await import("@/platform/DatabasePgLive")
const [{ InvestigationServicesLive }, { layerPg }] = await Promise.all([
import("./runtime/mcp-service-graph"),
import("@/platform/DatabasePgLive"),
])
return ManagedRuntime.make(
InvestigationServicesLive.pipe(
Layer.provideMerge(WorkerPlatformLive),
Expand All @@ -162,7 +168,15 @@ const buildRpcRuntime = async (env: Record<string, unknown>) => {
}

let rpcRuntimePromise: ReturnType<typeof buildRpcRuntime> | undefined
const getRpcRuntime = (env: Record<string, unknown>) => (rpcRuntimePromise ??= buildRpcRuntime(env))
const getRpcRuntime = (env: Record<string, unknown>): ReturnType<typeof buildRpcRuntime> => {
if (rpcRuntimePromise !== undefined) return rpcRuntimePromise
const pending = buildRpcRuntime(env)
rpcRuntimePromise = pending
void pending.catch(() => {
if (rpcRuntimePromise === pending) rpcRuntimePromise = undefined
})
return pending
}

type InternalRpcMethod = "listMcpTools" | "callMcpTool" | "submitDiagnosis"

Expand Down Expand Up @@ -210,7 +224,7 @@ const runInternalRpc = async (
exit = await runtime.runPromiseExit(await scoped(submitDiagnosisRpc(input)))
break
}
ctx.waitUntil(flushTelemetry(env))
ctx.waitUntil(telemetry.flush(env))
if (exit._tag === "Success") return exit.value
const defect = exit.cause.reasons.find(Cause.isDieReason)
if (defect) throw defect.defect
Expand Down Expand Up @@ -242,6 +256,26 @@ const isV2Request = (request: Request): boolean => {
}
}

/**
* Liveness does not need the domain graph, service graph, authentication,
* database scope, route codecs, or telemetry runtime. Keeping it
* bootstrap-safe also lets a cold isolate report health when an unrelated
* application binding is unavailable.
*/
const isHealthRequest = (request: Request): boolean => {
if (request.method !== "GET") return false
try {
return new URL(request.url).pathname === "/health"
} catch {
return false
}
}

const healthResponse = (): Response =>
new Response("OK", {
headers: { ...API_CORS_RESPONSE_HEADERS, "content-type": "text/plain; charset=utf-8" },
})

const readMcpSessionsBinding = (env: Record<string, unknown>): SessionsBinding | undefined => {
const candidate = env.MCP_SESSIONS
if (candidate && typeof candidate === "object" && "get" in candidate && "put" in candidate) {
Expand Down Expand Up @@ -283,16 +317,24 @@ const handle = async (
env: Record<string, unknown>,
ctx: ExecutionContext,
): Promise<Response> => {
const kv = readMcpSessionsBinding(env)
if (isHealthRequest(request)) return healthResponse()
if (request.method === "OPTIONS") return apiCorsPreflightResponse()

const isMcp = isMcpPost(request)
const kv = isMcp ? readMcpSessionsBinding(env) : undefined
const reqSid = isMcp ? request.headers.get("mcp-session-id") : null
// Start the expensive cold handler build and the independent KV read before
// buffering an MCP body. Warm requests resolve both promises immediately;
// cold MCP requests hide module evaluation and KV latency behind body I/O.
const pendingHandler = getHandler()
const pendingSession = kv && reqSid ? preloadSession(kv, reqSid) : undefined

// MCP diagnostics: buffer the body so we can peek the JSON-RPC method/id
// before handing it off to Effect, then re-emit the request with the
// buffered body so the inner handler still sees a readable stream.
let forwardRequest = request
let mcpFrame: McpFrame | null = null
const startedAt = Date.now()
const startedAt = isMcp ? Date.now() : undefined
if (isMcp) {
const bodyText = await request.text()
mcpFrame = peekMcpFrame(bodyText)
Expand All @@ -307,11 +349,11 @@ const handle = async (
)
}

if (kv && reqSid) await preloadSession(kv, reqSid)

try {
const { handler } = await getHandler()
const response = await handler(forwardRequest, Context.empty() as never)
const { handler } = pendingSession
? (await Promise.all([pendingHandler, pendingSession]))[0]
: await pendingHandler
const response = await handler(forwardRequest, HandlerContext)
if (kv && isMcp) {
const resSid = response.headers.get("mcp-session-id")
// Only persist when the server issued a new session — i.e. on
Expand All @@ -323,15 +365,15 @@ const handle = async (
if (put) ctx.waitUntil(put)
}
}
if (isMcp && mcpFrame) {
if (isMcp && mcpFrame && startedAt !== undefined) {
console.log(
`[mcp-out] method=${mcpFrame.method} id=${mcpFrame.id}` +
` status=${response.status} dur=${Date.now() - startedAt}ms` +
` body_len=${response.headers.get("content-length") ?? "-"}` +
` resp_sid=${response.headers.get("mcp-session-id") ?? "-"}`,
)
}
ctx.waitUntil(flushTelemetry(env))
ctx.waitUntil(telemetry.flush(env))
return response
} catch (err) {
console.error("[worker] handler failed:", err)
Expand All @@ -348,12 +390,12 @@ const handle = async (
Effect.provide(telemetry.layer),
),
)
if (isMcp && mcpFrame) {
if (isMcp && mcpFrame && startedAt !== undefined) {
console.error(
`[mcp-err] method=${mcpFrame.method} id=${mcpFrame.id}` + ` dur=${Date.now() - startedAt}ms`,
)
}
ctx.waitUntil(flushTelemetry(env))
ctx.waitUntil(telemetry.flush(env))
return isV2Request(request)
? v2WorkerUnavailableResponse()
: new Response("The API worker is temporarily unavailable.", { status: 504 })
Expand Down
2 changes: 2 additions & 0 deletions packages/domain/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
"./chat-session": "./src/chat-session.ts"
},
"scripts": {
"gen:anticipated-errors": "bun scripts/gen-anticipated-errors.ts",
"gen:anticipated-errors:check": "bun scripts/gen-anticipated-errors.ts --check",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
Expand Down
39 changes: 39 additions & 0 deletions packages/domain/scripts/gen-anticipated-errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
import { dirname } from "node:path"
import { fileURLToPath } from "node:url"
import { deriveAnticipatedIdentifiers } from "../src/anticipated-errors-derive"

const outputPath = fileURLToPath(
new URL("../src/generated/anticipated-error-identifiers.ts", import.meta.url),
)
const checkMode = process.argv.includes("--check")
const identifiers = [...deriveAnticipatedIdentifiers()].sort()
const renderedModule = `// This file is generated by packages/domain/scripts/gen-anticipated-errors.ts
// Do not edit manually.
//
// Anticipated 4xx error identifiers are reflected from the domain HTTP
// contracts outside production so worker startup imports only this literal.

export const ANTICIPATED_ERROR_IDENTIFIER_LIST: ReadonlyArray<string> = [
${identifiers.map((identifier) => `\t${JSON.stringify(identifier)},`).join("\n")}
]
`

let existingModule = ""
try {
existingModule = readFileSync(outputPath, "utf8")
} catch {
existingModule = ""
}

if (checkMode) {
if (existingModule !== renderedModule) {
console.error("Anticipated error identifiers are out of date. Run `bun run gen:anticipated-errors`.")
process.exit(1)
}
console.log(`Anticipated error identifiers are up to date (${identifiers.length} identifiers).`)
} else {
mkdirSync(dirname(outputPath), { recursive: true })
writeFileSync(outputPath, renderedModule)
console.log(`Wrote ${identifiers.length} anticipated error identifiers to ${outputPath}.`)
}
Loading
Loading