From 2a67053758bc03f85edc423b230b1f91cf5ea5b7 Mon Sep 17 00:00:00 2001 From: BilalG1 Date: Thu, 16 Jul 2026 14:40:09 -0700 Subject: [PATCH] Fix Elysia production compatibility (#1757) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - use Vercel's native Elysia framework support with `src/index.ts` as the deployment entrypoint - remove the manual `api/` entrypoints and all `vercel.json` rewrites - continue loading the prebuilt `dist/vercel.mjs` bundle so the backend's existing tsdown bundling remains unchanged - keep API-version migration paths internal instead of exposing them through `x-middleware-rewrite` - add integration coverage for the migration branch's global Elysia error boundary and sanitized 500 response - inherit the Vercel project's 800-second maximum duration for every backend route ## Why The staging deployment timed out every request at the configured 60-second function limit. The serverless entrypoint returned a Web `Response` from a default-exported function, but Vercel interpreted that export shape as the legacy Node `(request, response)` handler contract and waited for the unused response object to be ended. The Elysia migration also exposed the internal API-version migration destination through `x-middleware-rewrite`. That header was internal Next.js routing state and was not exposed by the previous production transport. Consolidating the backend into one function initially hardcoded a 60-second duration. That was especially significant for the external DB sync poller and sequencer, whose normal work loops allow up to 180 seconds. The Vercel project is already configured with an 800-second maximum, so the backend now inherits that setting directly instead of maintaining route-specific wrapper files. Vercel's native Elysia builder recognizes `src/index.ts` when it contains a direct `elysia` import, and automatically routes requests to that application. This makes the manual catch-all rewrite and root `api/` directory unnecessary. ## Production behavior - Vercel requests are routed directly to the Elysia application through the native Elysia builder. - API migration routes retain their public URL without revealing internal migration paths. - Uncaught middleware or dispatch errors are captured by the global boundary and return a generic `500 Internal Server Error` with the global security headers. - All external DB sync endpoints—`fusebox`, `poller`, `sequencer`, `status`, and `sync-engine`—inherit the project's 800-second function duration. - GitHub config `apply`, `commit`, and `cancel` also inherit 800 seconds. This raises the previous 120-second `commit` and 60-second `cancel` maximums; normal successful behavior is unchanged, but a stuck invocation can run and be billed for longer before Vercel terminates it. ## Tradeoffs The function-duration policy is now controlled in one place—the Vercel project—rather than split between dashboard configuration and route wrappers. This is simpler and ensures long-running backend routes have enough time, but changing the project setting affects every endpoint. The native entrypoint remains a thin wrapper around the tsdown-generated bundle rather than asking Vercel to bundle the backend source graph. This preserves the existing alias and dependency bundling behavior while still using Vercel's native Elysia routing. The remaining lower-priority Elysia migration compatibility items are intentionally deferred to follow-up PRs. ## Validation - `pnpm test run apps/backend/src/server/app.ts` — 2 tests passed - focused TypeScript check for the native entrypoint and bundle declaration - the installed Vercel Elysia builder identifies `src/index.ts` as the application entrypoint - `pnpm test run apps/e2e/tests/backend/endpoints/api/migration-tests.test.ts` against the running local backend — 13 tests passed - live browser and raw HTTP checks confirmed a migrated route returns 200 and does not expose `x-middleware-rewrite` - backend build not run locally, per repository policy --- apps/backend/api/dist-vercel.d.ts | 4 - apps/backend/api/index.ts | 10 -- apps/backend/src/dist-vercel.d.ts | 6 + apps/backend/src/index.ts | 7 +- apps/backend/src/server/app.ts | 129 +++++++++++++++--- apps/backend/src/server/middleware.ts | 39 ++++-- apps/backend/vercel.json | 5 +- .../endpoints/api/migration-tests.test.ts | 6 +- 8 files changed, 150 insertions(+), 56 deletions(-) delete mode 100644 apps/backend/api/dist-vercel.d.ts delete mode 100644 apps/backend/api/index.ts create mode 100644 apps/backend/src/dist-vercel.d.ts diff --git a/apps/backend/api/dist-vercel.d.ts b/apps/backend/api/dist-vercel.d.ts deleted file mode 100644 index e697c91ec..000000000 --- a/apps/backend/api/dist-vercel.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module "*/dist/vercel.mjs" { - const app: typeof import("../src/server/vercel").default; - export default app; -} diff --git a/apps/backend/api/index.ts b/apps/backend/api/index.ts deleted file mode 100644 index 69025b3ef..000000000 --- a/apps/backend/api/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -import app from "../dist/vercel.mjs"; - -export const config = { - runtime: "nodejs", - maxDuration: 60, -}; - -export default function handler(request: Request): Response | Promise { - return app.handle(request); -} diff --git a/apps/backend/src/dist-vercel.d.ts b/apps/backend/src/dist-vercel.d.ts new file mode 100644 index 000000000..7931c3144 --- /dev/null +++ b/apps/backend/src/dist-vercel.d.ts @@ -0,0 +1,6 @@ +declare module "*/dist/vercel.mjs" { + import type { Elysia } from "elysia"; + + const app: Elysia; + export default app; +} diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 1bcbe098e..c7acd08c1 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -1,6 +1,5 @@ -import "@/instrument"; -import "@/polyfills"; -import { app } from "./server/app"; -import "./server/env-expand"; +// Vercel detects native Elysia entrypoints by a direct framework import. +import "elysia"; +import app from "../dist/vercel.mjs"; export default app; diff --git a/apps/backend/src/server/app.ts b/apps/backend/src/server/app.ts index ea9e13dd2..f126b4baa 100644 --- a/apps/backend/src/server/app.ts +++ b/apps/backend/src/server/app.ts @@ -7,7 +7,7 @@ import { getEnvVariable, getNodeEnvironment } from "@hexclave/shared/dist/utils/ import { Elysia } from "elysia"; import { createBackendRequest } from "./backend-request"; import { handleUncaughtBackendError } from "./error-handler"; -import { runRequestPipeline } from "./middleware"; +import { getCorsHeadersInit, runRequestPipeline } from "./middleware"; import { MalformedRouteParamError, matchRoute } from "./registry"; const globalSecurityHeaders = { @@ -30,7 +30,7 @@ export const app = new Elysia({ developmentRequestStartTimes.set(request, performance.now()); } }) - .onAfterResponse(({ request, set }) => { + .onAfterResponse(({ request, response, set }) => { if (!shouldLogDevelopmentRequests) { return; } @@ -38,9 +38,9 @@ export const app = new Elysia({ const startTime = developmentRequestStartTimes.get(request); const elapsedMilliseconds = startTime == null ? "unknown" : (performance.now() - startTime).toFixed(1); const pathname = new URL(request.url).pathname; - console.log(`[Elysia] ${request.method} ${pathname} ${set.status} ${elapsedMilliseconds}ms`); + console.log(`[Elysia] ${request.method} ${pathname} ${getLoggedResponseStatus(response, set.status)} ${elapsedMilliseconds}ms`); }) - .onError(({ error }) => withGlobalHeaders(handleUncaughtBackendError(error))) + .onError(({ error, request }) => withResponseHeaders(handleUncaughtBackendError(error), getCorsHeadersInit(request))) .get("/", () => htmlResponse(homeHtml())) .get("/dev-stats", () => htmlResponse(devStatsHtml())) .get("/health/error-handler-debug", () => htmlResponse(errorHandlerDebugHtml())) @@ -54,7 +54,7 @@ export const app = new Elysia({ export async function dispatch(request: Request) { const pipeline = await runRequestPipeline(request); if (pipeline.shortCircuitResponse != null) { - return withGlobalHeaders(pipeline.shortCircuitResponse); + return withResponseHeaders(pipeline.shortCircuitResponse, pipeline.corsHeadersInit); } let match; @@ -62,30 +62,30 @@ export async function dispatch(request: Request) { match = matchRoute(pipeline.dispatchPath); } catch (error) { if (error instanceof MalformedRouteParamError) { - return withGlobalHeaders(new Response("Bad Request", { status: 400 })); + return withResponseHeaders(new Response("Bad Request", { status: 400 }), pipeline.corsHeadersInit); } throw error; } if (match == null) { - return withGlobalHeaders(new Response("
404 Not Found
", { + return withResponseHeaders(new Response("
404 Not Found
", { status: 404, headers: { "content-type": "text/html; charset=utf-8", }, - })); + }), pipeline.corsHeadersInit); } const method = request.method.toUpperCase(); if (!isHttpMethod(method)) { - return withGlobalHeaders(new Response(null, { + return withResponseHeaders(new Response(null, { status: 405, - })); + }), pipeline.corsHeadersInit); } const handler = match.methods.get(method) ?? (method === "HEAD" ? match.methods.get("GET") : undefined); if (handler == null) { - return withGlobalHeaders(new Response(null, { + return withResponseHeaders(new Response(null, { status: 405, - })); + }), pipeline.corsHeadersInit); } const backendRequest = createBackendRequest(request, pipeline.mergedHeaders, pipeline.originalUrl); @@ -129,17 +129,18 @@ export async function dispatch(request: Request) { for (const cookie of context.deletedCookies) { finalResponse.headers.append("Set-Cookie", serializeSetCookie(cookie.name, cookie.value, cookie.options)); } - if (pipeline.corsHeadersInit != null) { - for (const [key, value] of Object.entries(pipeline.corsHeadersInit)) { - finalResponse.headers.set(key, value); - } - } - if (pipeline.middlewareRewrite != null) { - finalResponse.headers.set("x-middleware-rewrite", pipeline.middlewareRewrite); - } - return withGlobalHeaders(finalResponse); + return withResponseHeaders(finalResponse, pipeline.corsHeadersInit); } +function getLoggedResponseStatus(response: unknown, fallbackStatus: number | string | undefined) { + return response instanceof Response ? response.status : fallbackStatus; +} + +import.meta.vitest?.test("development logging uses the returned Response status", ({ expect }) => { + expect(getLoggedResponseStatus(new Response(null, { status: 404 }), 200)).toBe(404); + expect(getLoggedResponseStatus("response body", 201)).toBe(201); +}); + function isRedirectError(error: unknown): error is { redirectStatus: 307 | 308, redirectUrl: string } { if (!(error instanceof Error) || !("digest" in error) || !("redirectUrl" in error) || !("redirectStatus" in error)) { return false; @@ -157,6 +158,15 @@ function withGlobalHeaders(response: Response) { return response; } +function withResponseHeaders(response: Response, corsHeadersInit?: HeadersInit) { + if (corsHeadersInit != null) { + for (const [key, value] of new Headers(corsHeadersInit)) { + response.headers.set(key, value); + } + } + return withGlobalHeaders(response); +} + function htmlResponse(body: string, status = 200) { return withGlobalHeaders(new Response(body, { status, @@ -166,6 +176,83 @@ function htmlResponse(body: string, status = 200) { })); } +import.meta.vitest?.test("API version migrations do not expose their internal rewrite", async ({ expect }) => { + const { vi } = import.meta.vitest!; + vi.stubEnv("HEXCLAVE_ARTIFICIAL_DEVELOPMENT_DELAY_MS", "0"); + vi.stubEnv("STACK_ARTIFICIAL_DEVELOPMENT_DELAY_MS", "0"); + + try { + const response = await app.handle(new Request("http://localhost/api/v2beta1/migration-tests/smart-route-handler")); + + expect(response.status).toBe(200); + expect(response.headers.get("x-middleware-rewrite")).toBeNull(); + } finally { + vi.unstubAllEnvs(); + } +}); + +import.meta.vitest?.test("dispatcher-generated API errors retain CORS headers", async ({ expect }) => { + const { vi } = import.meta.vitest!; + vi.stubEnv("HEXCLAVE_ARTIFICIAL_DEVELOPMENT_DELAY_MS", "0"); + vi.stubEnv("STACK_ARTIFICIAL_DEVELOPMENT_DELAY_MS", "0"); + + try { + const [notFound, methodNotAllowed] = await Promise.all([ + app.handle(new Request("http://localhost/api/latest/this-route-does-not-exist")), + app.handle(new Request("http://localhost/api/v2beta1/migration-tests/smart-route-handler", { + method: "POST", + })), + ]); + + expect([ + { status: notFound.status, allowOrigin: notFound.headers.get("access-control-allow-origin") }, + { status: methodNotAllowed.status, allowOrigin: methodNotAllowed.headers.get("access-control-allow-origin") }, + ]).toMatchInlineSnapshot(` + [ + { + "allowOrigin": "*", + "status": 404, + }, + { + "allowOrigin": "*", + "status": 405, + }, + ] + `); + } finally { + vi.unstubAllEnvs(); + } +}); + +import.meta.vitest?.test("uncaught dispatch errors use the global sanitized error boundary", async ({ expect }) => { + const { vi } = import.meta.vitest!; + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("HEXCLAVE_ARTIFICIAL_DEVELOPMENT_DELAY_MS", "1"); + vi.stubEnv("STACK_ARTIFICIAL_DEVELOPMENT_DELAY_MS", "1"); + + try { + const response = await app.handle(new Request("http://localhost/api/v1")); + + expect({ + status: response.status, + body: await response.text(), + accessControlAllowOrigin: response.headers.get("access-control-allow-origin"), + contentType: response.headers.get("content-type"), + contentTypeOptions: response.headers.get("x-content-type-options"), + }).toMatchInlineSnapshot(` + { + "accessControlAllowOrigin": "*", + "body": "Internal Server Error", + "contentType": "text/plain; charset=utf-8", + "contentTypeOptions": "nosniff", + "status": 500, + } + `); + } finally { + vi.unstubAllEnvs(); + } +}); + function homeHtml() { const devStatsLink = getNodeEnvironment() === "development" ? `
Dev Stats
` diff --git a/apps/backend/src/server/middleware.ts b/apps/backend/src/server/middleware.ts index a769f05c2..178f9c376 100644 --- a/apps/backend/src/server/middleware.ts +++ b/apps/backend/src/server/middleware.ts @@ -47,11 +47,21 @@ function withHexclaveHeaderAliases(headers: string[]): string[] { const corsAllowedRequestHeadersWithAliases = withHexclaveHeaderAliases(corsAllowedRequestHeaders); const corsAllowedResponseHeadersWithAliases = withHexclaveHeaderAliases(corsAllowedResponseHeaders); +export function getCorsHeadersInit(request: Request): HeadersInit | undefined { + return new URL(request.url).pathname.startsWith("/api/") ? { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS", + "Access-Control-Max-Age": "86400", + "Access-Control-Allow-Headers": corsAllowedRequestHeadersWithAliases.join(", "), + "Access-Control-Expose-Headers": corsAllowedResponseHeadersWithAliases.join(", "), + "Vary": corsAllowedRequestHeadersWithAliases.join(", "), + } : undefined; +} + export type PipelineResult = { corsHeadersInit?: HeadersInit, dispatchPath: string, mergedHeaders: Headers, - middlewareRewrite?: string, originalUrl: string, shortCircuitResponse?: Response, }; @@ -60,27 +70,21 @@ export async function runRequestPipeline(request: Request): Promise 0 && now - devRateLimitMarks[0] > DEV_RATE_LIMIT_WINDOW_MS) { devRateLimitMarks.shift(); @@ -133,7 +137,6 @@ export async function runRequestPipeline(request: Request): Promise { + const mergedHeaders = mergeHexclaveHeaderAliases(new Headers({ + "x-hexclave-disable-artificial-development-delay": "true", + })); + + expect(isArtificialDevelopmentBehaviorDisabled(mergedHeaders)).toBe(true); +}); + const clientIpForwardingHeaders = ["x-forwarded-for", "x-real-ip", "x-vercel-forwarded-for", "cf-connecting-ip"]; function ensureForwardedForHeader(headers: Headers, request: Request) { diff --git a/apps/backend/vercel.json b/apps/backend/vercel.json index 921bc8f0d..6aa38b831 100644 --- a/apps/backend/vercel.json +++ b/apps/backend/vercel.json @@ -1,9 +1,6 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", - "framework": null, - "rewrites": [ - { "source": "/(.*)", "destination": "/api" } - ], + "framework": "elysia", "crons": [ { "path": "/api/latest/internal/email-queue-step", diff --git a/apps/e2e/tests/backend/endpoints/api/migration-tests.test.ts b/apps/e2e/tests/backend/endpoints/api/migration-tests.test.ts index 0787f1598..49e559dc6 100644 --- a/apps/e2e/tests/backend/endpoints/api/migration-tests.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/migration-tests.test.ts @@ -27,6 +27,7 @@ describe("SmartRouteHandler", () => { "headers": Headers {