mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
Revert "refactor(backend): remove Next.js compat shims, use standard Web APIs (#1652)"
This reverts commit ae09be9c66.
This commit is contained in:
parent
0dacb04a19
commit
7525526730
@ -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",
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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.
|
||||
//
|
||||
|
||||
13
apps/backend/src/lib/next-compat/fetch.d.ts
vendored
Normal file
13
apps/backend/src/lib/next-compat/fetch.d.ts
vendored
Normal 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[],
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
37
apps/backend/src/lib/next-compat/server.tsx
Normal file
37
apps/backend/src/lib/next-compat/server.tsx
Normal 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;
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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[]>();
|
||||
|
||||
@ -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()) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
26
apps/backend/src/server/next-request-shim.ts
Normal file
26
apps/backend/src/server/next-request-shim.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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>,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user