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 {