From 7525526730aa810c054cb9947da216adbb6561c3 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 13 Jul 2026 12:36:36 -0700 Subject: [PATCH] Revert "refactor(backend): remove Next.js compat shims, use standard Web APIs (#1652)" This reverts commit ae09be9c66b513ed8a07af53636674dbaa41c40a. --- apps/backend/package.json | 2 +- .../oauth/authorize/[provider_id]/route.tsx | 4 +- .../oauth/callback/[provider_id]/route.tsx | 4 +- .../latest/emails/unsubscribe-link/route.tsx | 3 +- .../ai-proxy/[[...path]]/route.ts | 7 ++-- .../custom/oauth/authorize/route.tsx | 2 +- .../custom/oauth/idp/[[...route]]/route.tsx | 5 ++- .../neon/oauth/authorize/route.tsx | 2 +- .../neon/oauth/idp/[[...route]]/route.tsx | 5 ++- .../api/latest/internal/changelog/route.tsx | 5 +++ .../[project_id]/.well-known/[...route].ts | 2 +- apps/backend/src/app/health/route.tsx | 5 ++- apps/backend/src/lib/end-users.tsx | 2 +- apps/backend/src/lib/next-compat/fetch.d.ts | 13 +++++++ .../lib/{runtime => next-compat}/headers.tsx | 0 .../{runtime => next-compat}/navigation.tsx | 0 .../request-context.tsx | 2 +- apps/backend/src/lib/next-compat/server.tsx | 37 +++++++++++++++++++ apps/backend/src/proxy.tsx | 12 +++--- .../src/route-handlers/redirect-handler.tsx | 3 +- .../src/route-handlers/smart-request.tsx | 13 ++++--- .../src/route-handlers/smart-response.tsx | 5 ++- .../route-handlers/smart-route-handler.tsx | 20 +++++----- apps/backend/src/server/app.ts | 12 +++--- apps/backend/src/server/backend-request.ts | 17 --------- apps/backend/src/server/next-request-shim.ts | 26 +++++++++++++ apps/backend/src/server/registry.ts | 5 ++- apps/backend/tsconfig.json | 9 +++++ apps/backend/tsdown.config.ts | 16 ++++++++ apps/backend/vitest.config.ts | 3 ++ 30 files changed, 171 insertions(+), 70 deletions(-) create mode 100644 apps/backend/src/lib/next-compat/fetch.d.ts rename apps/backend/src/lib/{runtime => next-compat}/headers.tsx (100%) rename apps/backend/src/lib/{runtime => next-compat}/navigation.tsx (100%) rename apps/backend/src/lib/{runtime => next-compat}/request-context.tsx (92%) create mode 100644 apps/backend/src/lib/next-compat/server.tsx delete mode 100644 apps/backend/src/server/backend-request.ts create mode 100644 apps/backend/src/server/next-request-shim.ts diff --git a/apps/backend/package.json b/apps/backend/package.json index 61e9629ef..55de68c8c 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -11,7 +11,7 @@ "with-env:dev": "dotenv -c development --", "with-env:prod": "dotenv -c production --", "with-env:test": "dotenv -c test --", - "dev": "BACKEND_PORT=${STACK_DEV_FALLBACK_BACKEND:+${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}10} && BACKEND_PORT=${BACKEND_PORT:-${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}02} && concurrently -n \"dev,codegen,prisma-studio,email-queue,cron-jobs,bulldozer-studio\" -k \"STACK_DISABLE_REACT_ASYNC_DEBUG_INFO=${STACK_DISABLE_REACT_ASYNC_DEBUG_INFO:-true} NODE_ENV=development BACKEND_PORT=$BACKEND_PORT dotenv -c development -- tsx watch --clear-screen=false src/server/server.ts ${STACK_BACKEND_DEV_EXTRA_ARGS:-}\" \"pnpm run codegen:watch\" \"pnpm run prisma-studio\" \"pnpm run run-email-queue\" \"pnpm run run-cron-jobs\" \"pnpm run run-bulldozer-studio\"", + "dev": "BACKEND_PORT=${STACK_DEV_FALLBACK_BACKEND:+${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}10} && BACKEND_PORT=${BACKEND_PORT:-${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}02} && concurrently -n \"dev,codegen,prisma-studio,email-queue,cron-jobs,bulldozer-studio\" -k \"STACK_DISABLE_REACT_ASYNC_DEBUG_INFO=${STACK_DISABLE_REACT_ASYNC_DEBUG_INFO:-true} NODE_ENV=development BACKEND_PORT=$BACKEND_PORT dotenv -c development -- tsx watch src/server/server.ts ${STACK_BACKEND_DEV_EXTRA_ARGS:-}\" \"pnpm run codegen:watch\" \"pnpm run prisma-studio\" \"pnpm run run-email-queue\" \"pnpm run run-cron-jobs\" \"pnpm run run-bulldozer-studio\"", "dev:inspect": "STACK_BACKEND_DEV_EXTRA_ARGS=\"--inspect\" pnpm run dev", "dev:profile": "STACK_BACKEND_DEV_EXTRA_ARGS=\"--experimental-cpu-prof\" pnpm run dev", "build": "pnpm run codegen && tsdown --config tsdown.config.ts", diff --git a/apps/backend/src/app/api/latest/auth/oauth/authorize/[provider_id]/route.tsx b/apps/backend/src/app/api/latest/auth/oauth/authorize/[provider_id]/route.tsx index b908a560b..00f72e69d 100644 --- a/apps/backend/src/app/api/latest/auth/oauth/authorize/[provider_id]/route.tsx +++ b/apps/backend/src/app/api/latest/auth/oauth/authorize/[provider_id]/route.tsx @@ -12,8 +12,8 @@ import { KnownErrors } from "@hexclave/shared/dist/known-errors"; import { urlSchema, yupArray, yupNumber, yupObject, yupString, yupUnion } from "@hexclave/shared/dist/schema-fields"; import { getNodeEnvironment } from "@hexclave/shared/dist/utils/env"; import { StatusError } from "@hexclave/shared/dist/utils/errors"; -import { cookies } from "@/lib/runtime/headers"; -import { redirect } from "@/lib/runtime/navigation"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; import { generators } from "openid-client"; import type { InferType, Schema } from "yup"; diff --git a/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx b/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx index b531c3b72..2b86a0d78 100644 --- a/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx +++ b/apps/backend/src/app/api/latest/auth/oauth/callback/[provider_id]/route.tsx @@ -15,8 +15,8 @@ import { KnownError, KnownErrors } from "@hexclave/shared"; import { yupMixed, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields"; import { HexclaveAssertionError, StatusError, captureError } from "@hexclave/shared/dist/utils/errors"; import { deindent, extractScopes, mergeScopeStrings } from "@hexclave/shared/dist/utils/strings"; -import { cookies } from "@/lib/runtime/headers"; -import { redirect } from "@/lib/runtime/navigation"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; import { oauthResponseToSmartResponse } from "../../oauth-helpers"; /** diff --git a/apps/backend/src/app/api/latest/emails/unsubscribe-link/route.tsx b/apps/backend/src/app/api/latest/emails/unsubscribe-link/route.tsx index 1b037c85b..8a1bcb920 100644 --- a/apps/backend/src/app/api/latest/emails/unsubscribe-link/route.tsx +++ b/apps/backend/src/app/api/latest/emails/unsubscribe-link/route.tsx @@ -3,8 +3,9 @@ import { getSoleTenancyFromProjectBranch } from "@/lib/tenancies"; import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; import { VerificationCodeType } from "@/generated/prisma/client"; import { KnownErrors } from "@hexclave/shared/dist/known-errors"; +import { NextRequest } from "next/server"; -export async function GET(request: Request) { +export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const code = searchParams.get('code'); if (!code || code.length !== 45) diff --git a/apps/backend/src/app/api/latest/integrations/ai-proxy/[[...path]]/route.ts b/apps/backend/src/app/api/latest/integrations/ai-proxy/[[...path]]/route.ts index 33e3a3840..778b473db 100644 --- a/apps/backend/src/app/api/latest/integrations/ai-proxy/[[...path]]/route.ts +++ b/apps/backend/src/app/api/latest/integrations/ai-proxy/[[...path]]/route.ts @@ -4,6 +4,7 @@ import { preprocessProxyBody } from "@/private"; import { handleApiRequest } from "@/route-handlers/smart-route-handler"; import { getEnvVariable } from "@hexclave/shared/dist/utils/env"; import { StatusError } from "@hexclave/shared/dist/utils/errors"; +import { NextRequest } from "next/server"; const OPENROUTER_BASE_URL = "https://openrouter.ai/api"; const OPENROUTER_DEFAULT_MODEL = "anthropic/claude-sonnet-4.6"; @@ -37,7 +38,7 @@ function sanitizeBody(raw: ArrayBuffer): Uint8Array { return new TextEncoder().encode(JSON.stringify(parsed)); } -async function proxyToOpenRouter(req: Request, options: { params: Promise<{ path?: string[] }> }) { +async function proxyToOpenRouter(req: NextRequest, options: { params: Promise<{ path?: string[] }> }) { const apiKey = getEnvVariable("STACK_OPENROUTER_API_KEY"); const params = await options.params; const subpath = params.path?.join("/") ?? ""; @@ -48,7 +49,7 @@ async function proxyToOpenRouter(req: Request, options: { params: Promise<{ path : undefined; if (apiKey === "FORWARD_TO_PRODUCTION") { - const targetUrl = `${PRODUCTION_AI_PROXY_BASE_URL}/${subpath}${new URL(req.url).search}`; + const targetUrl = `${PRODUCTION_AI_PROXY_BASE_URL}/${subpath}${req.nextUrl.search}`; const headers: Record = {}; if (contentType) { headers["Content-Type"] = contentType; @@ -69,7 +70,7 @@ async function proxyToOpenRouter(req: Request, options: { params: Promise<{ path }); } - const targetUrl = `${OPENROUTER_BASE_URL}/${subpath}${new URL(req.url).search}`; + const targetUrl = `${OPENROUTER_BASE_URL}/${subpath}${req.nextUrl.search}`; const headers: Record = { "Authorization": `Bearer ${apiKey}`, "anthropic-version": "2023-06-01", diff --git a/apps/backend/src/app/api/latest/integrations/custom/oauth/authorize/route.tsx b/apps/backend/src/app/api/latest/integrations/custom/oauth/authorize/route.tsx index eea7d9f79..9ad9d95e5 100644 --- a/apps/backend/src/app/api/latest/integrations/custom/oauth/authorize/route.tsx +++ b/apps/backend/src/app/api/latest/integrations/custom/oauth/authorize/route.tsx @@ -1,7 +1,7 @@ import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { yupNever, yupObject, yupString } from "@hexclave/shared/dist/schema-fields"; import { HexclaveAssertionError } from "@hexclave/shared/dist/utils/errors"; -import { redirect } from "@/lib/runtime/navigation"; +import { redirect } from "next/navigation"; export const GET = createSmartRouteHandler({ metadata: { diff --git a/apps/backend/src/app/api/latest/integrations/custom/oauth/idp/[[...route]]/route.tsx b/apps/backend/src/app/api/latest/integrations/custom/oauth/idp/[[...route]]/route.tsx index cb0a34c57..f98be3891 100644 --- a/apps/backend/src/app/api/latest/integrations/custom/oauth/idp/[[...route]]/route.tsx +++ b/apps/backend/src/app/api/latest/integrations/custom/oauth/idp/[[...route]]/route.tsx @@ -2,6 +2,7 @@ import { handleApiRequest } from "@/route-handlers/smart-route-handler"; import { getEnvVariable } from "@hexclave/shared/dist/utils/env"; import { HexclaveAssertionError } from "@hexclave/shared/dist/utils/errors"; import { createNodeHttpServerDuplex } from "@hexclave/shared/dist/utils/node-http"; +import { NextRequest, NextResponse } from "next/server"; import { createOidcProvider } from "../../../../idp"; export const dynamic = "force-dynamic"; @@ -26,7 +27,7 @@ function getOidcCallbackPromise() { return _oidcCallbackPromiseCache; } -const handler = handleApiRequest(async (req: Request) => { +const handler = handleApiRequest(async (req: NextRequest) => { const newUrl = req.url.replace(pathPrefix, ""); if (newUrl === req.url) { throw new HexclaveAssertionError("No path prefix found in request URL. Is the pathPrefix correct?", { newUrl, url: req.url, pathPrefix }); @@ -59,7 +60,7 @@ const handler = handleApiRequest(async (req: Request) => { // filter out session cookies; we don't want to keep sessions open, every OAuth flow should start a new session headers = headers.filter(([k, v]) => k !== "set-cookie" || !v.toString().match(/^_session\.?/)); - return new Response(body, { + return new NextResponse(body, { headers: headers, status: { // our API never returns 301 or 302 by convention, so transform them to 307 or 308 diff --git a/apps/backend/src/app/api/latest/integrations/neon/oauth/authorize/route.tsx b/apps/backend/src/app/api/latest/integrations/neon/oauth/authorize/route.tsx index 07edef725..903972eca 100644 --- a/apps/backend/src/app/api/latest/integrations/neon/oauth/authorize/route.tsx +++ b/apps/backend/src/app/api/latest/integrations/neon/oauth/authorize/route.tsx @@ -1,7 +1,7 @@ import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { yupNever, yupObject, yupString } from "@hexclave/shared/dist/schema-fields"; import { HexclaveAssertionError } from "@hexclave/shared/dist/utils/errors"; -import { redirect } from "@/lib/runtime/navigation"; +import { redirect } from "next/navigation"; export const GET = createSmartRouteHandler({ metadata: { diff --git a/apps/backend/src/app/api/latest/integrations/neon/oauth/idp/[[...route]]/route.tsx b/apps/backend/src/app/api/latest/integrations/neon/oauth/idp/[[...route]]/route.tsx index cfbbac587..4861a1f19 100644 --- a/apps/backend/src/app/api/latest/integrations/neon/oauth/idp/[[...route]]/route.tsx +++ b/apps/backend/src/app/api/latest/integrations/neon/oauth/idp/[[...route]]/route.tsx @@ -2,6 +2,7 @@ import { handleApiRequest } from "@/route-handlers/smart-route-handler"; import { getEnvVariable } from "@hexclave/shared/dist/utils/env"; import { HexclaveAssertionError } from "@hexclave/shared/dist/utils/errors"; import { createNodeHttpServerDuplex } from "@hexclave/shared/dist/utils/node-http"; +import { NextRequest, NextResponse } from "next/server"; import { createOidcProvider } from "../../../../idp"; export const dynamic = "force-dynamic"; @@ -26,7 +27,7 @@ function getOidcCallbackPromise() { return _oidcCallbackPromiseCache; } -const handler = handleApiRequest(async (req: Request) => { +const handler = handleApiRequest(async (req: NextRequest) => { const newUrl = req.url.replace(pathPrefix, ""); if (newUrl === req.url) { throw new HexclaveAssertionError("No path prefix found in request URL. Is the pathPrefix correct?", { newUrl, url: req.url, pathPrefix }); @@ -59,7 +60,7 @@ const handler = handleApiRequest(async (req: Request) => { // filter out session cookies; we don't want to keep sessions open, every OAuth flow should start a new session headers = headers.filter(([k, v]) => k !== "set-cookie" || !v.toString().match(/^_session\.?/)); - return new Response(body, { + return new NextResponse(body, { headers: headers, status: { // our API never returns 301 or 302 by convention, so transform them to 307 or 308 diff --git a/apps/backend/src/app/api/latest/internal/changelog/route.tsx b/apps/backend/src/app/api/latest/internal/changelog/route.tsx index cb749d6ad..6698d0f9c 100644 --- a/apps/backend/src/app/api/latest/internal/changelog/route.tsx +++ b/apps/backend/src/app/api/latest/internal/changelog/route.tsx @@ -5,6 +5,8 @@ import { HexclaveAssertionError } from "@hexclave/shared/dist/utils/errors"; import * as fs from "fs/promises"; import * as path from "path"; +const REVALIDATE_SECONDS = 60 * 60; + type ChangeType = "major" | "minor" | "patch"; type ChangelogEntry = { @@ -171,6 +173,9 @@ export const GET = createSmartRouteHandler({ "Accept": "text/plain", "User-Agent": "stack-auth-backend-changelog", }, + next: { + revalidate: REVALIDATE_SECONDS, + }, }); if (!response.ok) { diff --git a/apps/backend/src/app/api/latest/projects-anonymous-users/[project_id]/.well-known/[...route].ts b/apps/backend/src/app/api/latest/projects-anonymous-users/[project_id]/.well-known/[...route].ts index 5278a6046..facc0b919 100644 --- a/apps/backend/src/app/api/latest/projects-anonymous-users/[project_id]/.well-known/[...route].ts +++ b/apps/backend/src/app/api/latest/projects-anonymous-users/[project_id]/.well-known/[...route].ts @@ -2,7 +2,7 @@ // redirect to projects/.well-known/[...route]?include_anonymous=true import { yupNever, yupObject, yupString } from "@hexclave/shared/dist/schema-fields"; -import { redirect } from "@/lib/runtime/navigation"; +import { redirect } from "next/navigation"; import { createSmartRouteHandler } from "../../../../../../route-handlers/smart-route-handler"; const handler = createSmartRouteHandler({ diff --git a/apps/backend/src/app/health/route.tsx b/apps/backend/src/app/health/route.tsx index 7767b7dec..100ead693 100644 --- a/apps/backend/src/app/health/route.tsx +++ b/apps/backend/src/app/health/route.tsx @@ -1,8 +1,9 @@ import { globalPrismaClient } from "@/prisma-client"; import { HexclaveAssertionError } from "@hexclave/shared/dist/utils/errors"; +import { NextRequest } from "next/server"; -export async function GET(req: Request) { - if (new URL(req.url).searchParams.get("db")) { +export async function GET(req: NextRequest) { + if (req.nextUrl.searchParams.get("db")) { const project = await globalPrismaClient.project.findFirst({}); if (!project) { diff --git a/apps/backend/src/lib/end-users.tsx b/apps/backend/src/lib/end-users.tsx index 8042f7805..f5adf1e96 100644 --- a/apps/backend/src/lib/end-users.tsx +++ b/apps/backend/src/lib/end-users.tsx @@ -3,7 +3,7 @@ import { getEnvVariable } from "@hexclave/shared/dist/utils/env"; import { HexclaveAssertionError } from "@hexclave/shared/dist/utils/errors"; import { isIpAddress } from "@hexclave/shared/dist/utils/ips"; import { pick } from "@hexclave/shared/dist/utils/objects"; -import { headers } from "@/lib/runtime/headers"; +import { headers } from "next/headers"; // An end user is a person sitting behind a computer screen. // diff --git a/apps/backend/src/lib/next-compat/fetch.d.ts b/apps/backend/src/lib/next-compat/fetch.d.ts new file mode 100644 index 000000000..7a13e92de --- /dev/null +++ b/apps/backend/src/lib/next-compat/fetch.d.ts @@ -0,0 +1,13 @@ +export { }; + +declare global { + // RequestInit is defined as an interface by lib.dom; interface merging is the + // TypeScript mechanism for adding Next's fetch metadata option. + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface RequestInit { + next?: { + revalidate?: number | false, + tags?: string[], + }, + } +} diff --git a/apps/backend/src/lib/runtime/headers.tsx b/apps/backend/src/lib/next-compat/headers.tsx similarity index 100% rename from apps/backend/src/lib/runtime/headers.tsx rename to apps/backend/src/lib/next-compat/headers.tsx diff --git a/apps/backend/src/lib/runtime/navigation.tsx b/apps/backend/src/lib/next-compat/navigation.tsx similarity index 100% rename from apps/backend/src/lib/runtime/navigation.tsx rename to apps/backend/src/lib/next-compat/navigation.tsx diff --git a/apps/backend/src/lib/runtime/request-context.tsx b/apps/backend/src/lib/next-compat/request-context.tsx similarity index 92% rename from apps/backend/src/lib/runtime/request-context.tsx rename to apps/backend/src/lib/next-compat/request-context.tsx index 9109eca68..56a0571a4 100644 --- a/apps/backend/src/lib/runtime/request-context.tsx +++ b/apps/backend/src/lib/next-compat/request-context.tsx @@ -29,7 +29,7 @@ export const requestContextALS = new AsyncLocalStorage(); export function getRequestContext() { const context = requestContextALS.getStore(); if (context == null) { - throw new Error("Backend request context is only available while handling a backend request"); + throw new Error("next-compat request context is only available while handling a backend request"); } return context; } diff --git a/apps/backend/src/lib/next-compat/server.tsx b/apps/backend/src/lib/next-compat/server.tsx new file mode 100644 index 000000000..24245cc59 --- /dev/null +++ b/apps/backend/src/lib/next-compat/server.tsx @@ -0,0 +1,37 @@ +export class NextRequest extends Request { + get nextUrl() { + return new NextURL(this.url); + } +} + +export class NextURL extends URL { + clone() { + return new NextURL(this.toString()); + } +} + +export class NextResponse extends Response { + static json(body: unknown, init?: ResponseInit) { + const headers = new Headers(init?.headers); + if (!headers.has("content-type")) { + headers.set("content-type", "application/json"); + } + return new NextResponse(JSON.stringify(body), { + ...init, + headers, + }); + } + + static rewrite(url: URL | string, init?: ResponseInit) { + const headers = new Headers(init?.headers); + headers.set("x-middleware-rewrite", url.toString()); + return new NextResponse(null, { + ...init, + headers, + }); + } +} + +export async function connection() { + return undefined; +} diff --git a/apps/backend/src/proxy.tsx b/apps/backend/src/proxy.tsx index 850def0a8..235f92d7a 100644 --- a/apps/backend/src/proxy.tsx +++ b/apps/backend/src/proxy.tsx @@ -5,6 +5,8 @@ import apiVersions from './generated/api-versions.json'; import routes from './generated/routes.json'; import './polyfills'; +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; import { SmartRouter } from './smart-router'; const DEV_RATE_LIMIT_MAX_REQUESTS = 100; @@ -63,7 +65,7 @@ const corsAllowedRequestHeadersWithAliases = withHexclaveHeaderAliases(corsAllow const corsAllowedResponseHeadersWithAliases = withHexclaveHeaderAliases(corsAllowedResponseHeaders); // This function can be marked `async` if using `await` inside -export async function proxy(request: Request) { +export async function proxy(request: NextRequest) { const url = new URL(request.url); const delay = +getEnvVariable('STACK_ARTIFICIAL_DEVELOPMENT_DELAY_MS', '0'); if (delay) { @@ -96,7 +98,7 @@ export async function proxy(request: Request) { const waitMs = Math.max(0, DEV_RATE_LIMIT_WINDOW_MS - (now - devRateLimitTimestamps[0])); const retryAfterSeconds = Math.max(1, Math.ceil(waitMs / 1000)); - const response = Response.json({ + const response = NextResponse.json({ message: 'Artificial development rate limit triggered. Wait before retrying.', }, { status: 429, @@ -171,11 +173,9 @@ export async function proxy(request: Request) { } } - const newUrl = new URL(request.url); + const newUrl = request.nextUrl.clone(); newUrl.pathname = pathname; - const rewriteHeaders = new Headers(responseInit?.headers); - rewriteHeaders.set("x-middleware-rewrite", newUrl.toString()); - return new Response(null, { ...responseInit, headers: rewriteHeaders }); + return NextResponse.rewrite(newUrl, responseInit); } // See "Matching Paths" below to learn more diff --git a/apps/backend/src/route-handlers/redirect-handler.tsx b/apps/backend/src/route-handlers/redirect-handler.tsx index f94fc1b99..77ebbfb2f 100644 --- a/apps/backend/src/route-handlers/redirect-handler.tsx +++ b/apps/backend/src/route-handlers/redirect-handler.tsx @@ -1,9 +1,10 @@ import "../polyfills"; import { yupArray, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields"; +import { NextRequest } from "next/server"; import { createSmartRouteHandler } from "./smart-route-handler"; -export function redirectHandler(redirectPath: string, statusCode: 303 | 307 | 308 = 307): (req: Request, options: any) => Promise { +export function redirectHandler(redirectPath: string, statusCode: 303 | 307 | 308 = 307): (req: NextRequest, options: any) => Promise { return createSmartRouteHandler({ request: yupObject({ url: yupString().defined(), diff --git a/apps/backend/src/route-handlers/smart-request.tsx b/apps/backend/src/route-handlers/smart-request.tsx index 1726b1091..1c0497159 100644 --- a/apps/backend/src/route-handlers/smart-request.tsx +++ b/apps/backend/src/route-handlers/smart-request.tsx @@ -15,6 +15,7 @@ import { getEnvVariable, getNodeEnvironment } from "@hexclave/shared/dist/utils/ import { HexclaveAssertionError, StatusError, captureError, throwErr } from "@hexclave/shared/dist/utils/errors"; import { deindent } from "@hexclave/shared/dist/utils/strings"; import { traceSpan, withTraceSpan } from "@hexclave/shared/dist/utils/telemetry"; +import { NextRequest } from "next/server"; import * as yup from "yup"; const allowedMethods = ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] as const; @@ -69,7 +70,7 @@ export type MergeSmartRequest = ) ); -async function validate(obj: SmartRequest, schema: yup.Schema, req: Request | null): Promise { +async function validate(obj: SmartRequest, schema: yup.Schema, req: NextRequest | null): Promise { try { return await yupValidate(schema, obj, { abortEarly: false, @@ -100,7 +101,7 @@ async function validate(obj: SmartRequest, schema: yup.Schema, req: Reques throw new KnownErrors.SchemaError( deindent` - Request validation failed on ${req.method} ${new URL(req.url).pathname}: + Request validation failed on ${req.method} ${req.nextUrl.pathname}: ${inners.map(e => deindent` - ${e.message} `).join("\n")} @@ -113,7 +114,7 @@ async function validate(obj: SmartRequest, schema: yup.Schema, req: Reques } -async function parseBody(req: Request, bodyBuffer: ArrayBuffer): Promise { +async function parseBody(req: NextRequest, bodyBuffer: ArrayBuffer): Promise { const contentType = req.method === "GET" || req.method === "HEAD" ? undefined : req.headers.get("content-type")?.split(";")[0]; const getText = () => { @@ -157,7 +158,7 @@ async function parseBody(req: Request, bodyBuffer: ArrayBuffer): Promise => { +const parseAuth = withTraceSpan('smart request parseAuth', async (req: NextRequest): Promise => { const projectId = req.headers.get("x-stack-project-id"); const branchId = req.headers.get("x-stack-branch-id") ?? DEFAULT_BRANCH_ID; let requestType = req.headers.get("x-stack-access-type"); @@ -336,7 +337,7 @@ const parseAuth = withTraceSpan('smart request parseAuth', async (req: Request): }; }); -export async function createSmartRequest(req: Request, bodyBuffer: ArrayBuffer, options?: { params: Promise> }): Promise { +export async function createSmartRequest(req: NextRequest, bodyBuffer: ArrayBuffer, options?: { params: Promise> }): Promise { return await traceSpan("creating smart request", async () => { const urlObject = new URL(req.url); const clientVersionMatch = req.headers.get("x-stack-client-version")?.match(/^(\w+)\s+(@[\w\/]+)@([\d.]+)$/); @@ -362,6 +363,6 @@ export async function createSmartRequest(req: Request, bodyBuffer: ArrayBuffer, }); } -export async function validateSmartRequest(nextReq: Request | null, smartReq: SmartRequest, schema: yup.Schema): Promise { +export async function validateSmartRequest(nextReq: NextRequest | null, smartReq: SmartRequest, schema: yup.Schema): Promise { return await validate(smartReq, schema, nextReq); } diff --git a/apps/backend/src/route-handlers/smart-response.tsx b/apps/backend/src/route-handlers/smart-response.tsx index fce739494..5a870951a 100644 --- a/apps/backend/src/route-handlers/smart-response.tsx +++ b/apps/backend/src/route-handlers/smart-response.tsx @@ -3,6 +3,7 @@ import { HexclaveAssertionError } from "@hexclave/shared/dist/utils/errors"; import { Json } from "@hexclave/shared/dist/utils/json"; import { deepPlainEquals } from "@hexclave/shared/dist/utils/objects"; import { traceSpan } from "@hexclave/shared/dist/utils/telemetry"; +import { NextRequest } from "next/server"; import * as yup from "yup"; import "../polyfills"; import { SmartRequest } from "./smart-request"; @@ -41,7 +42,7 @@ export type SmartResponse = { } ); -export async function validateSmartResponse(req: Request | null, smartReq: SmartRequest, obj: unknown, schema: yup.Schema): Promise { +export async function validateSmartResponse(req: NextRequest | null, smartReq: SmartRequest, obj: unknown, schema: yup.Schema): Promise { try { return await yupValidate(schema, obj, { abortEarly: false, @@ -67,7 +68,7 @@ function isResponseBody(body: unknown): body is Response { return typeof body === "object" && body !== null && body instanceof Response; } -export async function createResponse(req: Request | null, requestId: string, obj: T): Promise { +export async function createResponse(req: NextRequest | null, requestId: string, obj: T): Promise { return await traceSpan("creating HTTP response from smart response", async () => { let status = obj.statusCode; const headers = new Map(); diff --git a/apps/backend/src/route-handlers/smart-route-handler.tsx b/apps/backend/src/route-handlers/smart-route-handler.tsx index c69436d15..1e988d4a6 100644 --- a/apps/backend/src/route-handlers/smart-route-handler.tsx +++ b/apps/backend/src/route-handlers/smart-route-handler.tsx @@ -9,6 +9,7 @@ import { getNodeEnvironment } from "@hexclave/shared/dist/utils/env"; import { HexclaveAssertionError, StatusError, captureError, errorToNiceString } from "@hexclave/shared/dist/utils/errors"; import { runAsynchronously, wait } from "@hexclave/shared/dist/utils/promises"; import { traceSpan } from "@hexclave/shared/dist/utils/telemetry"; +import { NextRequest } from "next/server"; import * as yup from "yup"; import { DeepPartialSmartRequestWithSentinel, MergeSmartRequest, SmartRequest, createSmartRequest, validateSmartRequest } from "./smart-request"; import { SmartResponse, createResponse, validateSmartResponse } from "./smart-response"; @@ -64,8 +65,8 @@ let concurrentRequestsInProcess = 0; * Catches any errors thrown in the handler and returns a 500 response with the thrown error message. Also logs the * request details. */ -export function handleApiRequest(handler: (req: Request, options: any, requestId: string) => Promise): (req: Request, options: any) => Promise { - return async (req: Request, options: any) => { +export function handleApiRequest(handler: (req: NextRequest, options: any, requestId: string) => Promise): (req: NextRequest, options: any) => Promise { + return async (req: NextRequest, options: any) => { concurrentRequestsInProcess++; try { const requestId = generateSecureRandomString(80); @@ -79,13 +80,12 @@ export function handleApiRequest(handler: (req: Request, options: any, requestId "stack.process.concurrent-requests": concurrentRequestsInProcess, }, }, async (span) => { - const requestUrl = new URL(req.url); // Set Sentry scope to include request details Sentry.setContext("stack-request", { requestId: requestId, method: req.method, url: req.url, - query: Object.fromEntries(requestUrl.searchParams), + query: Object.fromEntries(req.nextUrl.searchParams), headers: Object.fromEntries(req.headers), }); @@ -121,11 +121,11 @@ export function handleApiRequest(handler: (req: Request, options: any, requestId ...allowedLongRequestPaths, ...allowedLongRequestPaths.map(path => path.replace(/^\/api\/latest\//, "/api/v1/")), ]; - const warnAfterSeconds = allAllowedLongRequestPaths.includes(requestUrl.pathname) ? 240 : 12; + const warnAfterSeconds = allAllowedLongRequestPaths.includes(req.nextUrl.pathname) ? 240 : 12; runAsynchronously(async () => { await wait(warnAfterSeconds * 1000); if (!hasRequestFinished) { - captureError("request-timeout-watcher", new Error(`Request with ID ${requestId} to ${req.method} ${requestUrl.pathname} has been running for ${warnAfterSeconds} seconds. Try to keep requests short. The request may be cancelled by the serverless provider if it takes too long.`)); + captureError("request-timeout-watcher", new Error(`Request with ID ${requestId} to ${req.method} ${req.nextUrl.pathname} has been running for ${warnAfterSeconds} seconds. Try to keep requests short. The request may be cancelled by the serverless provider if it takes too long.`)); } }); @@ -135,10 +135,10 @@ export function handleApiRequest(handler: (req: Request, options: any, requestId const time = (performance.now() - timeStart); // Record request stats for dev-stats page - recordRequestStats(req.method, requestUrl.pathname, time); + recordRequestStats(req.method, req.nextUrl.pathname, time); if ([301, 302].includes(res.status)) { - throw new HexclaveAssertionError("HTTP status codes 301 and 302 should not be returned by our APIs because the behavior for non-GET methods is inconsistent across implementations. Use 303 (to rewrite method to GET) or 307/308 (to preserve the original method and data) instead.", { status: res.status, url: requestUrl, req, res }); + throw new HexclaveAssertionError("HTTP status codes 301 and 302 should not be returned by our APIs because the behavior for non-GET methods is inconsistent across implementations. Use 303 (to rewrite method to GET) or 307/308 (to preserve the original method and data) instead.", { status: res.status, url: req.nextUrl, req, res }); } if (!disableExtendedLogging) console.log(`[ RES] [${requestId}] ${req.method} ${censoredUrl}: ${res.status} (in ${time.toFixed(0)}ms)`); return res; @@ -201,7 +201,7 @@ export type SmartRouteHandler< Req extends DeepPartialSmartRequestWithSentinel = DeepPartialSmartRequestWithSentinel, Res extends SmartResponse = SmartResponse, InitArgs extends [readonly OverloadParam[], SmartRouteHandlerOverloadGenerator] | [SmartRouteHandlerOverload] = any, -> = ((req: Request, options: any) => Promise) & { +> = ((req: NextRequest, options: any) => Promise) & { overloads: Map>, invoke: (smartRequest: SmartRequest) => Promise, initArgs: InitArgs, @@ -247,7 +247,7 @@ export function createSmartRouteHandler< throw new HexclaveAssertionError("Duplicate overload parameters"); } - const invoke = async (nextRequest: Request | null, requestId: string, smartRequest: SmartRequest, shouldSetContext: boolean = false) => { + const invoke = async (nextRequest: NextRequest | null, requestId: string, smartRequest: SmartRequest, shouldSetContext: boolean = false) => { const reqsParsed: [[Req, SmartRequest], SmartRouteHandlerOverload][] = []; const reqsErrors: unknown[] = []; for (const [overloadParam, overload] of overloads.entries()) { diff --git a/apps/backend/src/server/app.ts b/apps/backend/src/server/app.ts index 3129a282e..8bc82a957 100644 --- a/apps/backend/src/server/app.ts +++ b/apps/backend/src/server/app.ts @@ -1,13 +1,13 @@ import { httpMethodNames } from "@/generated/route-modules"; -import { serializeSetCookie } from "@/lib/runtime/headers"; -import { NextNotFoundError } from "@/lib/runtime/navigation"; -import { parseCookieHeader, requestContextALS, type RequestContext } from "@/lib/runtime/request-context"; +import { serializeSetCookie } from "@/lib/next-compat/headers"; +import { NextNotFoundError } from "@/lib/next-compat/navigation"; +import { parseCookieHeader, requestContextALS, type RequestContext } from "@/lib/next-compat/request-context"; import { node } from "@elysiajs/node"; import { getEnvVariable, getNodeEnvironment } from "@hexclave/shared/dist/utils/env"; import { Elysia } from "elysia"; -import { createBackendRequest } from "./backend-request"; import { handleUncaughtBackendError } from "./error-handler"; import { runRequestPipeline } from "./middleware"; +import { createNextRequestShim } from "./next-request-shim"; import { MalformedRouteParamError, matchRoute } from "./registry"; const globalSecurityHeaders = { @@ -71,7 +71,7 @@ export async function dispatch(request: Request) { })); } - const backendRequest = createBackendRequest(request, pipeline.mergedHeaders, pipeline.originalUrl); + const nextRequest = createNextRequestShim(request, pipeline.mergedHeaders, pipeline.originalUrl); const context: RequestContext = { headers: pipeline.mergedHeaders, incomingCookies: parseCookieHeader(pipeline.mergedHeaders.get("cookie")), @@ -81,7 +81,7 @@ export async function dispatch(request: Request) { const response = await requestContextALS.run(context, async () => { try { - return await handler(backendRequest, { + return await handler(nextRequest, { params: Promise.resolve(match.params), }); } catch (error) { diff --git a/apps/backend/src/server/backend-request.ts b/apps/backend/src/server/backend-request.ts deleted file mode 100644 index 45274b803..000000000 --- a/apps/backend/src/server/backend-request.ts +++ /dev/null @@ -1,17 +0,0 @@ -type NodeRequestInit = RequestInit & { - duplex?: "half", -}; - -export function createBackendRequest(request: Request, headers: Headers, originalUrl: string): Request { - const init: NodeRequestInit = { - method: request.method, - headers, - }; - - if (request.method !== "GET" && request.method !== "HEAD") { - init.body = request.body; - init.duplex = "half"; - } - - return new Request(originalUrl, init); -} diff --git a/apps/backend/src/server/next-request-shim.ts b/apps/backend/src/server/next-request-shim.ts new file mode 100644 index 000000000..24528f60b --- /dev/null +++ b/apps/backend/src/server/next-request-shim.ts @@ -0,0 +1,26 @@ +import { NextURL } from "@/lib/next-compat/server"; +import type { NextRequest } from "next/server"; + +type NodeRequestInit = RequestInit & { + duplex?: "half", +}; + +export function createNextRequestShim(request: Request, headers: Headers, originalUrl: string): NextRequest { + const init: NodeRequestInit = { + method: request.method, + headers, + }; + + if (request.method !== "GET" && request.method !== "HEAD") { + init.body = request.body; + init.duplex = "half"; + } + + return new BackendNextRequest(originalUrl, init); +} + +class BackendNextRequest extends Request { + get nextUrl() { + return new NextURL(this.url); + } +} diff --git a/apps/backend/src/server/registry.ts b/apps/backend/src/server/registry.ts index 09da49d4a..267a2418f 100644 --- a/apps/backend/src/server/registry.ts +++ b/apps/backend/src/server/registry.ts @@ -1,12 +1,13 @@ import { httpMethodNames, routeModules } from "@/generated/route-modules"; import { SmartRouter } from "@/smart-router"; +import type { NextRequest } from "next/server"; export type HttpMethod = typeof httpMethodNames[number]; export type RouteParams = Record; export type RouteHandlerOptions = { params: Promise }; -export type RouteHandler = (request: Request, options: RouteHandlerOptions) => Promise | Response; +export type RouteHandler = (request: NextRequest, options: RouteHandlerOptions) => Promise | Response; export type UnknownRouteModule = Partial>; -type UnknownRouteFunction = (request: Request, options: RouteHandlerOptions) => unknown; +type UnknownRouteFunction = (request: NextRequest, options: RouteHandlerOptions) => unknown; type RouteEntry = { methods: Map, diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index fef42b3ca..0651a5d6f 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -20,6 +20,15 @@ "paths": { "@/*": [ "./src/*" + ], + "next/headers": [ + "./src/lib/next-compat/headers.tsx" + ], + "next/navigation": [ + "./src/lib/next-compat/navigation.tsx" + ], + "next/server": [ + "./src/lib/next-compat/server.tsx" ] }, "skipLibCheck": true, diff --git a/apps/backend/tsdown.config.ts b/apps/backend/tsdown.config.ts index 07844d33a..c2441fb24 100644 --- a/apps/backend/tsdown.config.ts +++ b/apps/backend/tsdown.config.ts @@ -54,10 +54,24 @@ function packageNameFromSpecifier(specifier: string) { } function shouldBundleDependency(specifier: string) { + if (specifier === "next" || specifier.startsWith("next/")) { + return true; + } return customNoExternal.has(packageNameFromSpecifier(specifier)); } const basePlugin: Rolldown.Plugin = createBasePlugin({}); +const nextCompatAliases = new Map([ + ["next/headers", resolve(backendDir, "src/lib/next-compat/headers.tsx")], + ["next/navigation", resolve(backendDir, "src/lib/next-compat/navigation.tsx")], + ["next/server", resolve(backendDir, "src/lib/next-compat/server.tsx")], +]); +const nextCompatPlugin: Rolldown.Plugin = { + name: "backend-next-compat-aliases", + resolveId(source) { + return nextCompatAliases.get(source) ?? null; + }, +}; // Sentry release names may not contain slashes/whitespace, so sanitize the scoped package name. const sentryRelease = process.env.SENTRY_RELEASE ?? `${packageJson.name}@${packageJson.version}`.replace(/[/\s]/g, "-"); const shouldUploadSourcemaps = process.env.SENTRY_ORG != null @@ -65,6 +79,7 @@ const shouldUploadSourcemaps = process.env.SENTRY_ORG != null && process.env.SENTRY_AUTH_TOKEN != null; const plugins = [ basePlugin, + nextCompatPlugin, ...(shouldUploadSourcemaps ? [ sentryRollupPlugin({ org: process.env.SENTRY_ORG, @@ -97,6 +112,7 @@ export default defineConfig({ sourcemap: true, alias: { "@": resolve(backendDir, "src"), + ...Object.fromEntries(nextCompatAliases), }, banner: { js: `import { createRequire as __createRequire } from 'module'; diff --git a/apps/backend/vitest.config.ts b/apps/backend/vitest.config.ts index ce18d6eef..6ca9cd1f6 100644 --- a/apps/backend/vitest.config.ts +++ b/apps/backend/vitest.config.ts @@ -18,6 +18,9 @@ export default mergeConfig( resolve: { alias: { '@': resolve(__dirname, './src'), + 'next/headers': resolve(__dirname, './src/lib/next-compat/headers.tsx'), + 'next/navigation': resolve(__dirname, './src/lib/next-compat/navigation.tsx'), + 'next/server': resolve(__dirname, './src/lib/next-compat/server.tsx'), } }, envDir: __dirname,