Revert "refactor(backend): remove Next.js compat shims, use standard Web APIs (#1652)"

This reverts commit ae09be9c66.
This commit is contained in:
Konstantin Wohlwend 2026-07-13 12:36:36 -07:00
parent 0dacb04a19
commit 7525526730
30 changed files with 171 additions and 70 deletions

View File

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

View File

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

View File

@ -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";
/**

View File

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

View File

@ -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<string, string> = {};
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<string, string> = {
"Authorization": `Bearer ${apiKey}`,
"anthropic-version": "2023-06-01",

View File

@ -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: {

View File

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

View File

@ -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: {

View File

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

View File

@ -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) {

View File

@ -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({

View File

@ -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) {

View File

@ -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.
//

View File

@ -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[],
},
}
}

View File

@ -29,7 +29,7 @@ export const requestContextALS = new AsyncLocalStorage<RequestContext>();
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;
}

View File

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

View File

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

View File

@ -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<Response> {
export function redirectHandler(redirectPath: string, statusCode: 303 | 307 | 308 = 307): (req: NextRequest, options: any) => Promise<Response> {
return createSmartRouteHandler({
request: yupObject({
url: yupString().defined(),

View File

@ -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<T, MSQ = SmartRequest> =
)
);
async function validate<T>(obj: SmartRequest, schema: yup.Schema<T>, req: Request | null): Promise<T> {
async function validate<T>(obj: SmartRequest, schema: yup.Schema<T>, req: NextRequest | null): Promise<T> {
try {
return await yupValidate(schema, obj, {
abortEarly: false,
@ -100,7 +101,7 @@ async function validate<T>(obj: SmartRequest, schema: yup.Schema<T>, 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<T>(obj: SmartRequest, schema: yup.Schema<T>, req: Reques
}
async function parseBody(req: Request, bodyBuffer: ArrayBuffer): Promise<SmartRequest["body"]> {
async function parseBody(req: NextRequest, bodyBuffer: ArrayBuffer): Promise<SmartRequest["body"]> {
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<SmartRe
}
}
const parseAuth = withTraceSpan('smart request parseAuth', async (req: Request): Promise<SmartRequestAuth | null> => {
const parseAuth = withTraceSpan('smart request parseAuth', async (req: NextRequest): Promise<SmartRequestAuth | null> => {
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<Record<string, string>> }): Promise<SmartRequest> {
export async function createSmartRequest(req: NextRequest, bodyBuffer: ArrayBuffer, options?: { params: Promise<Record<string, string>> }): Promise<SmartRequest> {
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<T extends DeepPartialSmartRequestWithSentinel>(nextReq: Request | null, smartReq: SmartRequest, schema: yup.Schema<T>): Promise<T> {
export async function validateSmartRequest<T extends DeepPartialSmartRequestWithSentinel>(nextReq: NextRequest | null, smartReq: SmartRequest, schema: yup.Schema<T>): Promise<T> {
return await validate(smartReq, schema, nextReq);
}

View File

@ -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<T>(req: Request | null, smartReq: SmartRequest, obj: unknown, schema: yup.Schema<T>): Promise<T> {
export async function validateSmartResponse<T>(req: NextRequest | null, smartReq: SmartRequest, obj: unknown, schema: yup.Schema<T>): Promise<T> {
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<T extends SmartResponse>(req: Request | null, requestId: string, obj: T): Promise<Response> {
export async function createResponse<T extends SmartResponse>(req: NextRequest | null, requestId: string, obj: T): Promise<Response> {
return await traceSpan("creating HTTP response from smart response", async () => {
let status = obj.statusCode;
const headers = new Map<string, string[]>();

View File

@ -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<Response>): (req: Request, options: any) => Promise<Response> {
return async (req: Request, options: any) => {
export function handleApiRequest(handler: (req: NextRequest, options: any, requestId: string) => Promise<Response>): (req: NextRequest, options: any) => Promise<Response> {
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<OverloadParam, Req, Res>] | [SmartRouteHandlerOverload<Req, Res>] = any,
> = ((req: Request, options: any) => Promise<Response>) & {
> = ((req: NextRequest, options: any) => Promise<Response>) & {
overloads: Map<OverloadParam, SmartRouteHandlerOverload<Req, Res>>,
invoke: (smartRequest: SmartRequest) => Promise<Res>,
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<Req, Res>][] = [];
const reqsErrors: unknown[] = [];
for (const [overloadParam, overload] of overloads.entries()) {

View File

@ -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) {

View File

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

View File

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

View File

@ -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<string, string | string[]>;
export type RouteHandlerOptions = { params: Promise<RouteParams> };
export type RouteHandler = (request: Request, options: RouteHandlerOptions) => Promise<Response> | Response;
export type RouteHandler = (request: NextRequest, options: RouteHandlerOptions) => Promise<Response> | Response;
export type UnknownRouteModule = Partial<Record<HttpMethod, unknown>>;
type UnknownRouteFunction = (request: Request, options: RouteHandlerOptions) => unknown;
type UnknownRouteFunction = (request: NextRequest, options: RouteHandlerOptions) => unknown;
type RouteEntry = {
methods: Map<HttpMethod, RouteHandler>,

View File

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

View File

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

View File

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