Fix Elysia production compatibility (#1757)
Some checks failed
DB migration compat / Check if migrations changed (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled

## 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
This commit is contained in:
BilalG1 2026-07-16 14:40:09 -07:00 committed by GitHub
parent b11257fd5b
commit 2a67053758
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 150 additions and 56 deletions

View File

@ -1,4 +0,0 @@
declare module "*/dist/vercel.mjs" {
const app: typeof import("../src/server/vercel").default;
export default app;
}

View File

@ -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<Response> {
return app.handle(request);
}

6
apps/backend/src/dist-vercel.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
declare module "*/dist/vercel.mjs" {
import type { Elysia } from "elysia";
const app: Elysia;
export default app;
}

View File

@ -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;

View File

@ -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("<div>404 Not Found</div>", {
return withResponseHeaders(new Response("<div>404 Not Found</div>", {
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"
? `<br><a href="/dev-stats">Dev Stats</a><br>`

View File

@ -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<PipelineResu
const url = new URL(request.url);
const mergedHeaders = mergeHexclaveHeaderAliases(request.headers);
ensureForwardedForHeader(mergedHeaders, request);
const artificialDevelopmentBehaviorDisabled = isArtificialDevelopmentBehaviorDisabled(mergedHeaders);
const delay = +getEnvVariable("STACK_ARTIFICIAL_DEVELOPMENT_DELAY_MS", "0");
if (delay) {
if (getNodeEnvironment().includes("production")) {
throw new Error("STACK_ARTIFICIAL_DEVELOPMENT_DELAY_MS environment variable is only allowed in development");
}
if (!request.headers.get("x-stack-disable-artificial-development-delay")) {
if (!artificialDevelopmentBehaviorDisabled) {
await wait(delay);
}
}
const isApiRequest = url.pathname.startsWith("/api/");
const corsHeadersInit = isApiRequest ? {
"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;
const corsHeadersInit = getCorsHeadersInit(request);
if (isApiRequest && !request.headers.get("x-stack-disable-artificial-development-delay") && getNodeEnvironment() === "development" && request.method !== "OPTIONS" && !request.url.includes(".well-known") && !request.url.includes("/api/latest/internal/external-db-sync/")) {
if (isApiRequest && !artificialDevelopmentBehaviorDisabled && getNodeEnvironment() === "development" && request.method !== "OPTIONS" && !request.url.includes(".well-known") && !request.url.includes("/api/latest/internal/external-db-sync/")) {
const now = performance.now();
while (devRateLimitMarks.length > 0 && now - devRateLimitMarks[0] > DEV_RATE_LIMIT_WINDOW_MS) {
devRateLimitMarks.shift();
@ -133,7 +137,6 @@ export async function runRequestPipeline(request: Request): Promise<PipelineResu
corsHeadersInit,
dispatchPath,
mergedHeaders,
middlewareRewrite: dispatchPath === url.pathname ? undefined : dispatchPath,
originalUrl: request.url,
};
}
@ -148,6 +151,18 @@ function mergeHexclaveHeaderAliases(headers: Headers) {
return newRequestHeaders;
}
function isArtificialDevelopmentBehaviorDisabled(headers: Headers) {
return Boolean(headers.get("x-stack-disable-artificial-development-delay"));
}
import.meta.vitest?.test("Hexclave header aliases control artificial development behavior", ({ expect }) => {
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) {

View File

@ -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",

View File

@ -27,6 +27,7 @@ describe("SmartRouteHandler", () => {
"headers": Headers { <some fields may have been hidden> },
}
`);
expect(response.headers.get("access-control-allow-origin")).toMatchInlineSnapshot(`"*"`);
});
});
@ -52,7 +53,10 @@ describe("SmartRouteHandler", () => {
"headers": Headers { <some fields may have been hidden> },
}
`);
expect(response.headers.get("x-middleware-rewrite")).toBe(`/api/migrations/v2beta2/migration-tests/smart-route-handler`);
// The version migration is internal routing state. Next.js strips this
// implementation header in production, so the Elysia transport must not
// expose the rewritten path either.
expect(response.headers.get("x-middleware-rewrite")).toBeNull();
});
it("should return 200 with queryParam", async ({ expect }) => {