diff --git a/.github/workflows/e2e-api-tests.yaml b/.github/workflows/e2e-api-tests.yaml index 74918d292..5524855d1 100644 --- a/.github/workflows/e2e-api-tests.yaml +++ b/.github/workflows/e2e-api-tests.yaml @@ -132,13 +132,10 @@ jobs: - name: Backfill Bulldozer from Postgres run: pnpm run db:backfill-bulldozer-from-prisma - # The built server (node dist/server.mjs) does not auto-load .env files the way - # `next start` used to, so it must be wrapped in with-env:test to pick up - # .env.test.local (mirrors the e2e-fallback-tests workflow). - name: Start stack-backend in background uses: JarvusInnovations/background-action@2428e7b970a846423095c79d43f759abf979a635 # v1.0.7 with: - run: pnpm -C apps/backend run with-env:test pnpm run start & + run: pnpm run start:backend --log-order=stream & wait-on: | http://localhost:8102 tail: true diff --git a/.github/workflows/e2e-custom-base-port-api-tests.yaml b/.github/workflows/e2e-custom-base-port-api-tests.yaml index 3018bff73..34836b66b 100644 --- a/.github/workflows/e2e-custom-base-port-api-tests.yaml +++ b/.github/workflows/e2e-custom-base-port-api-tests.yaml @@ -126,15 +126,10 @@ jobs: - name: Backfill Bulldozer from Postgres run: pnpm run db:backfill-bulldozer-from-prisma - # The built server (node dist/server.mjs) does not auto-load .env files the way - # `next start` used to, so it must be wrapped in with-env:test to pick up - # .env.test.local (mirrors the e2e-fallback-tests workflow). The job-level - # NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX=67 stays in effect because dotenv does not - # override already-set process env vars. - name: Start stack-backend in background uses: JarvusInnovations/background-action@2428e7b970a846423095c79d43f759abf979a635 # v1.0.7 with: - run: pnpm -C apps/backend run with-env:test pnpm run start & + run: pnpm run start:backend --log-order=stream & wait-on: | http://localhost:6702 tail: true diff --git a/.github/workflows/e2e-fallback-tests.yaml b/.github/workflows/e2e-fallback-tests.yaml index 2f3adee92..2e1aefdea 100644 --- a/.github/workflows/e2e-fallback-tests.yaml +++ b/.github/workflows/e2e-fallback-tests.yaml @@ -113,7 +113,7 @@ jobs: - name: Start stack-backend on fallback port (8110) uses: JarvusInnovations/background-action@2428e7b970a846423095c79d43f759abf979a635 # v1.0.7 with: - run: PORT=8110 pnpm -C apps/backend run with-env:test pnpm run start & + run: pnpm -C apps/backend run with-env:test next start --port 8110 & wait-on: | http://localhost:8110 tail: true diff --git a/apps/backend/api/dist-vercel.d.ts b/apps/backend/api/dist-vercel.d.ts deleted file mode 100644 index e697c91ec..000000000 --- a/apps/backend/api/dist-vercel.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module "*/dist/vercel.mjs" { - const app: typeof import("../src/server/vercel").default; - export default app; -} diff --git a/apps/backend/api/index.ts b/apps/backend/api/index.ts deleted file mode 100644 index 69025b3ef..000000000 --- a/apps/backend/api/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -import app from "../dist/vercel.mjs"; - -export const config = { - runtime: "nodejs", - maxDuration: 60, -}; - -export default function handler(request: Request): Response | Promise { - return app.handle(request); -} diff --git a/apps/backend/instrumentation-client.ts b/apps/backend/instrumentation-client.ts index d6baf7ab3..e94648170 100644 --- a/apps/backend/instrumentation-client.ts +++ b/apps/backend/instrumentation-client.ts @@ -1,11 +1,11 @@ // This file configures the initialization of Sentry on the client. // The config you add here will be used whenever a users loads a page in their browser. -// https://docs.sentry.io/platforms/javascript/guides/browser/ +// https://docs.sentry.io/platforms/javascript/guides/nextjs/ +import * as Sentry from "@sentry/nextjs"; import { getBrowserCompatibilityReport } from "@hexclave/shared/dist/utils/browser-compat"; import { sentryBaseConfig } from "@hexclave/shared/dist/utils/sentry"; import { nicify } from "@hexclave/shared/dist/utils/strings"; -import * as Sentry from "@sentry/browser"; Sentry.init({ ...sentryBaseConfig, diff --git a/apps/backend/next.config.mjs b/apps/backend/next.config.mjs new file mode 100644 index 000000000..d8b22a527 --- /dev/null +++ b/apps/backend/next.config.mjs @@ -0,0 +1,99 @@ +import { withSentryConfig } from "@sentry/nextjs"; + +const withConfiguredSentryConfig = (nextConfig) => + withSentryConfig( + nextConfig, + { + // For all available options, see: + // https://github.com/getsentry/sentry-webpack-plugin#options + + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + + widenClientFileUpload: true, + telemetry: false, + }, + { + // For all available options, see: + // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ + + // Upload a larger set of source maps for prettier stack traces (increases build time) + widenClientFileUpload: true, + + // Transpiles SDK to be compatible with IE11 (increases bundle size) + transpileClientSDK: true, + + // Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers. + // This can increase your server load as well as your hosting bill. + // Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client- + // side errors will fail. + tunnelRoute: "/monitoring", + + // Hides source maps from generated client bundles + hideSourceMaps: true, + + // Automatically tree-shake Sentry logger statements to reduce bundle size + disableLogger: true, + + // Enables automatic instrumentation of Vercel Cron Monitors. + // See the following for more information: + // https://docs.sentry.io/product/crons/ + // https://vercel.com/docs/cron-jobs + automaticVercelMonitors: true, + } + ); + +/** @type {import('next').NextConfig} */ +const nextConfig = { + // optionally set output to "standalone" for Docker builds + // https://nextjs.org/docs/pages/api-reference/next-config-js/output + output: process.env.NEXT_CONFIG_OUTPUT, + + // we're open-source, so we can provide source maps + productionBrowserSourceMaps: true, + poweredByHeader: false, + + experimental: { + serverMinification: false, // needs to be disabled for oidc-provider to work, which relies on the original constructor names + }, + + serverExternalPackages: [ + 'oidc-provider', + ], + + async headers() { + return [ + { + source: "/(.*)", + headers: [ + { + key: "Cross-Origin-Opener-Policy", + value: "same-origin", + }, + { + key: "Permissions-Policy", + value: "", + }, + { + key: "Referrer-Policy", + value: "strict-origin-when-cross-origin", + }, + { + key: "X-Content-Type-Options", + value: "nosniff", + }, + { + key: "X-Frame-Options", + value: "SAMEORIGIN", + }, + { + key: "Content-Security-Policy", + value: "", + }, + ], + }, + ]; + }, +}; + +export default withConfiguredSentryConfig(nextConfig); diff --git a/apps/backend/package.json b/apps/backend/package.json index 61e9629ef..7f2a2aea4 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -5,20 +5,20 @@ "private": true, "type": "module", "scripts": { - "clean": "rimraf src/generated && rimraf dist && rimraf node_modules", + "clean": "rimraf src/generated && rimraf .next && rimraf node_modules", "typecheck": "tsc --noEmit", "with-env": "dotenv -c --", "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} next dev --port $BACKEND_PORT ${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", - "docker-build": "pnpm run codegen && tsdown --config tsdown.config.ts", + "build": "pnpm run codegen && next build", + "docker-build": "pnpm run codegen && next build --experimental-build-mode compile", "build-self-host-migration-script": "tsdown --config scripts/db-migrations.tsdown.config.ts", - - "start": "node dist/server.mjs", + "analyze-bundle": "next experimental-analyze", + "start": "next start --port ${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}02", "codegen-prisma": "HEXCLAVE_DATABASE_CONNECTION_STRING=\"${HEXCLAVE_DATABASE_CONNECTION_STRING:-${STACK_DATABASE_CONNECTION_STRING:-placeholder-database-connection-string}}\" pnpm run prisma generate", "codegen-prisma:watch": "HEXCLAVE_DATABASE_CONNECTION_STRING=\"${HEXCLAVE_DATABASE_CONNECTION_STRING:-${STACK_DATABASE_CONNECTION_STRING:-placeholder-database-connection-string}}\" pnpm run prisma generate --watch", "generate-private-sign-up-risk-engine": "pnpm run with-env tsx scripts/generate-private-sign-up-risk-engine.ts", @@ -59,12 +59,9 @@ }, "dependencies": { "@ai-sdk/mcp": "^1.0.21", + "spacetimedb": "^2.1.0", "@aws-sdk/client-s3": "^3.855.0", "@clickhouse/client": "^1.14.0", - "@elysiajs/node": "^1.4.5", - "@hexclave/next": "workspace:*", - "@hexclave/shared": "workspace:*", - "@hexclave/shared-backend": "workspace:*", "@node-oauth/oauth2-server": "^5.1.0", "@openrouter/ai-sdk-provider": "2.2.3", "@opentelemetry/api": "^1.9.0", @@ -76,7 +73,6 @@ "@opentelemetry/instrumentation": "^0.53.0", "@opentelemetry/resources": "^1.26.0", "@opentelemetry/sdk-logs": "^0.53.0", - "@opentelemetry/sdk-node": "0.53.0", "@opentelemetry/sdk-trace-base": "^1.26.0", "@opentelemetry/sdk-trace-node": "^1.26.0", "@opentelemetry/semantic-conventions": "^1.27.0", @@ -85,13 +81,14 @@ "@prisma/client": "^7.0.0", "@prisma/extension-read-replicas": "^0.5.0", "@prisma/instrumentation": "^7.0.0", - "@sentry/browser": "^10.45.0", - "@sentry/node": "^10.45.0", - "@sentry/rollup-plugin": "^4.6.1", + "@sentry/nextjs": "^10.45.0", "@simplewebauthn/server": "^13.3.0", - "@sinclair/typebox": "^0.34.49", + "@hexclave/next": "workspace:*", + "@hexclave/shared": "workspace:*", + "@hexclave/shared-backend": "workspace:*", "@upstash/qstash": "^2.8.2", "@vercel/functions": "^2.0.0", + "@vercel/otel": "^1.10.4", "@vercel/sandbox": "^1.2.0", "ai": "^6.0.0", "cel-js": "^0.8.2", @@ -99,7 +96,6 @@ "diff": "^8.0.3", "dotenv": "^16.4.5", "dotenv-cli": "^7.3.0", - "elysia": "1.4.28", "emailable": "^3.1.1", "freestyle": "^0.1.63", "jiti": "^2.6.1", @@ -116,7 +112,6 @@ "resend": "^6.0.1", "semver": "^7.6.3", "sharp": "^0.34.4", - "spacetimedb": "^2.1.0", "stripe": "^18.3.0", "svix": "^1.89.0", "vite": "^6.1.0", diff --git a/apps/backend/scripts/db-migrations.tsdown.config.ts b/apps/backend/scripts/db-migrations.tsdown.config.ts index aa716be0e..d4d72b50b 100644 --- a/apps/backend/scripts/db-migrations.tsdown.config.ts +++ b/apps/backend/scripts/db-migrations.tsdown.config.ts @@ -55,9 +55,7 @@ export default defineConfig({ inlineOnly: false, // Externalize Node.js builtins so they're imported rather than shimmed external: [...nodeBuiltins, ...externalPackages], - // Docker builds the backend server into the same dist directory before this - // script. Cleaning here deletes dist/server.mjs from the final image. - clean: false, + clean: true, // Use banner to add createRequire for CommonJS modules that use require() for builtins // The imported require is used by the shimmed __require2 function banner: { diff --git a/apps/backend/scripts/generate-route-info.ts b/apps/backend/scripts/generate-route-info.ts index e805e557f..d2373af4a 100644 --- a/apps/backend/scripts/generate-route-info.ts +++ b/apps/backend/scripts/generate-route-info.ts @@ -2,48 +2,12 @@ import { SmartRouter } from "@/smart-router"; import { writeFileSyncIfChanged } from "@hexclave/shared/dist/utils/fs"; import fs from "fs"; -const httpMethodNames = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"] as const; - -function stringCompare(a: string, b: string) { - return a < b ? -1 : a > b ? 1 : 0; -} - -function routeFilePathToImportPath(filePath: string) { - return `@/${filePath.replace(/^src\//, "").replace(/\.(ts|tsx|js|jsx)$/, "")}`; -} - -function generateRouteModules(routes: Awaited>) { - const routeFiles = routes - .filter(route => route.isRoute && /\/route\.(ts|tsx|js|jsx)$/.test(route.filePath)) - .sort((a, b) => stringCompare(a.normalizedPath, b.normalizedPath) || stringCompare(a.filePath, b.filePath)); - - const imports = routeFiles.map((route, index) => { - return `import * as r${index} from ${JSON.stringify(routeFilePathToImportPath(route.filePath))};`; - }); - - const entries = routeFiles.map((route, index) => { - return ` { normalizedPath: ${JSON.stringify(route.normalizedPath)}, module: r${index} },`; - }); - - return `import type { UnknownRouteModule } from "@/server/registry"; - -${imports.join("\n")} - -export const httpMethodNames = ${JSON.stringify(httpMethodNames)} as const; - -export const routeModules: readonly { normalizedPath: string, module: UnknownRouteModule }[] = [ -${entries.join("\n")} -]; -`; -} - async function main() { const routes = await SmartRouter.listRoutes(); const apiVersions = await SmartRouter.listApiVersions(); fs.mkdirSync("src/generated", { recursive: true }); writeFileSyncIfChanged("src/generated/routes.json", JSON.stringify(routes, null, 2)); writeFileSyncIfChanged("src/generated/api-versions.json", JSON.stringify(apiVersions, null, 2)); - writeFileSyncIfChanged("src/generated/route-modules.ts", generateRouteModules(routes)); console.log("Successfully updated route info"); } // eslint-disable-next-line no-restricted-syntax 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/dev-stats/page.tsx b/apps/backend/src/app/dev-stats/page.tsx new file mode 100644 index 000000000..34113203c --- /dev/null +++ b/apps/backend/src/app/dev-stats/page.tsx @@ -0,0 +1,1555 @@ +"use client"; + +import { runAsynchronously } from "@hexclave/shared/dist/utils/promises"; +import { stringCompare } from "@hexclave/shared/dist/utils/strings"; +import { useCallback, useMemo, useState } from "react"; + +type RequestStat = { + method: string, + path: string, + count: number, + totalTimeMs: number, + minTimeMs: number, + maxTimeMs: number, + lastCalledAt: number, +}; + +type AggregateStats = { + totalRequests: number, + totalTimeMs: number, + uniqueEndpoints: number, + averageTimeMs: number, +}; + +type PgPoolSingleStats = { + total: number, + idle: number, + waiting: number, +}; + +type PgPoolStats = { + pools: Record, + total: number, + idle: number, + waiting: number, +}; + +type EventLoopDelayStats = { + minMs: number, + maxMs: number, + meanMs: number, + p50Ms: number, + p95Ms: number, + p99Ms: number, +}; + +type EventLoopUtilizationStats = { + utilization: number, + idle: number, + active: number, +}; + +type MemoryStats = { + heapUsedMB: number, + heapTotalMB: number, + rssMB: number, + externalMB: number, + arrayBuffersMB: number, +}; + +type PerformanceSnapshot = { + timestamp: number, + pgPool: PgPoolStats | null, + eventLoopDelay: EventLoopDelayStats | null, + eventLoopUtilization: EventLoopUtilizationStats | null, + memory: MemoryStats, +}; + +type PerfAggregate = { + pgPool: { avgTotal: number, avgIdle: number, maxWaiting: number } | null, + eventLoopDelay: { avgP50Ms: number, avgP99Ms: number, maxP99Ms: number } | null, + eventLoopUtilization: { avgUtilization: number, maxUtilization: number } | null, + memory: { avgHeapUsedMB: number, avgRssMB: number, maxRssMB: number }, +}; + +type StatsData = { + aggregate: AggregateStats, + mostCommon: RequestStat[], + mostTimeConsuming: RequestStat[], + slowest: RequestStat[], + perfCurrent: PerformanceSnapshot, + perfHistory: PerformanceSnapshot[], + perfAggregate: PerfAggregate, +}; + +type SortColumn = "endpoint" | "count" | "totalTime" | "avgTime" | "minTime" | "maxTime" | "lastCalled"; +type SortDirection = "asc" | "desc"; + +function formatDuration(ms: number): string { + if (ms < 1) return `${ms.toFixed(2)}ms`; + if (ms < 1000) return `${ms.toFixed(0)}ms`; + return `${(ms / 1000).toFixed(2)}s`; +} + +function formatRelativeTime(timestamp: number): string { + const now = Date.now(); + const diff = now - timestamp; + if (diff < 1000) return "just now"; + if (diff < 60000) return `${Math.floor(diff / 1000)}s ago`; + if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; + return `${Math.floor(diff / 3600000)}h ago`; +} + +const methodColors: Record = { + GET: { bg: "rgba(16, 185, 129, 0.2)", text: "#6ee7b7", border: "rgba(16, 185, 129, 0.3)" }, + POST: { bg: "rgba(59, 130, 246, 0.2)", text: "#93c5fd", border: "rgba(59, 130, 246, 0.3)" }, + PUT: { bg: "rgba(245, 158, 11, 0.2)", text: "#fcd34d", border: "rgba(245, 158, 11, 0.3)" }, + PATCH: { bg: "rgba(249, 115, 22, 0.2)", text: "#fdba74", border: "rgba(249, 115, 22, 0.3)" }, + DELETE: { bg: "rgba(239, 68, 68, 0.2)", text: "#fca5a5", border: "rgba(239, 68, 68, 0.3)" }, + OPTIONS: { bg: "rgba(100, 116, 139, 0.2)", text: "#cbd5e1", border: "rgba(100, 116, 139, 0.3)" }, +}; + +function MethodBadge({ method }: { method: string }) { + const colors = methodColors[method] ?? { bg: "rgba(107, 114, 128, 0.2)", text: "#d1d5db", border: "rgba(107, 114, 128, 0.3)" }; + + return ( + + {method} + + ); +} + +function SortArrow({ direction, active }: { direction: SortDirection, active: boolean }) { + return ( + + {direction === "asc" ? "↑" : "↓"} + + ); +} + +function StatCard({ + title, + value, + subtitle, + gradient, +}: { + title: string, + value: string | number, + subtitle?: string, + gradient: string, +}) { + return ( +
+
+
+

{title}

+

+ {value} +

+ {subtitle && ( +

+ {subtitle} +

+ )} +
+
+ ); +} + +// ============================================================================ +// Health Status Indicator +// ============================================================================ + +type HealthStatus = "good" | "warning" | "critical" | "unknown"; + +function getHealthColor(status: HealthStatus): { bg: string, text: string, border: string } { + if (status === "good") { + return { bg: "rgba(16, 185, 129, 0.2)", text: "#6ee7b7", border: "rgba(16, 185, 129, 0.4)" }; + } else if (status === "warning") { + return { bg: "rgba(245, 158, 11, 0.2)", text: "#fcd34d", border: "rgba(245, 158, 11, 0.4)" }; + } else if (status === "critical") { + return { bg: "rgba(239, 68, 68, 0.2)", text: "#fca5a5", border: "rgba(239, 68, 68, 0.4)" }; + } else { + return { bg: "rgba(100, 116, 139, 0.2)", text: "#cbd5e1", border: "rgba(100, 116, 139, 0.4)" }; + } +} + +function HealthBadge({ status, label }: { status: HealthStatus, label: string }) { + const colors = getHealthColor(status); + return ( + + {label} + + ); +} + +// ============================================================================ +// Mini Sparkline Graph +// ============================================================================ + +let _sparklineCounter = 0; + +function Sparkline({ + data, + width = 400, + height = 80, + color = "#22d3ee", + showDots = false, + showAxes = false, + formatValue = (v: number) => v.toFixed(1), + timestamps, +}: { + data: number[], + width?: number, + height?: number, + color?: string, + showDots?: boolean, + showAxes?: boolean, + formatValue?: (v: number) => string, + timestamps?: number[], +}) { + const [gradientId] = useState(() => `sg-${_sparklineCounter++}`); + + if (data.length === 0) { + return ( +
+ No data +
+ ); + } + + const ml = showAxes ? 52 : 4; + const mr = 4; + const mt = showAxes ? 8 : 4; + const mb = showAxes ? 20 : 4; + const cw = width - ml - mr; + const ch = height - mt - mb; + + const min = Math.min(...data); + const max = Math.max(...data); + const range = max - min || 1; + + const points = data.map((v, i) => ({ + x: ml + (i / Math.max(data.length - 1, 1)) * cw, + y: mt + ch - ((v - min) / range) * ch, + v, + })); + + const pathD = points + .map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`) + .join(" "); + + const yTicks = [ + { value: max, y: mt }, + { value: (min + max) / 2, y: mt + ch / 2 }, + { value: min, y: mt + ch }, + ]; + + const xTicks: { label: string, x: number }[] = []; + if (showAxes && timestamps && timestamps.length === data.length && data.length > 1) { + const tickCount = Math.min(5, data.length); + for (let i = 0; i < tickCount; i++) { + const idx = Math.round((i / (tickCount - 1)) * (data.length - 1)); + const date = new Date(timestamps[idx]); + xTicks.push({ + label: date.toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }), + x: points[idx].x, + }); + } + } + + return ( + + + + + + + + + {showAxes && ( + <> + {yTicks.map((tick, i) => ( + + + + {formatValue(tick.value)} + + + ))} + {xTicks.map((tick, i) => ( + + {tick.label} + + ))} + + + + )} + + + + {showDots && points.map((p, i) => ( + + ))} + + ); +} + +// ============================================================================ +// Performance Metric Card with Graph +// ============================================================================ + +function PerfMetricCard({ + title, + description, + value, + unit, + history, + status, + statusLabel, + color, + thresholds, + timestamps, + formatValue, +}: { + title: string, + description: string, + value: number | null, + unit: string, + history: number[], + status: HealthStatus, + statusLabel: string, + color: string, + thresholds: { good: string, warning: string, critical: string }, + timestamps?: number[], + formatValue?: (v: number) => string, +}) { + const [showThresholds, setShowThresholds] = useState(false); + + return ( +
+
+
+

{title}

+

{description}

+
+ +
+ +
+ + {value !== null ? value.toFixed(2) : "—"} + + {unit} +
+ +
+ +
+ + + + {showThresholds && ( +
+
● Good: {thresholds.good}
+
● Warning: {thresholds.warning}
+
● Critical: {thresholds.critical}
+
+ )} +
+ ); +} + +// ============================================================================ +// Performance Section +// ============================================================================ + +// ============================================================================ +// Raw History Table +// ============================================================================ + +function RawHistoryTable({ perfHistory }: { perfHistory: PerformanceSnapshot[] }) { + const reversedHistory = useMemo(() => [...perfHistory].reverse(), [perfHistory]); + + if (reversedHistory.length === 0) { + return ( +
+ No measurements recorded yet. Wait a few seconds for data to accumulate. +
+ ); + } + + const cellStyle: React.CSSProperties = { + padding: "8px 12px", + fontFamily: "monospace", + fontSize: "12px", + whiteSpace: "nowrap", + borderBottom: "1px solid rgba(51, 65, 85, 0.3)", + }; + + const headerStyle: React.CSSProperties = { + ...cellStyle, + fontWeight: 600, + color: "#94a3b8", + textTransform: "uppercase", + fontSize: "10px", + letterSpacing: "0.05em", + position: "sticky" as const, + top: 0, + backgroundColor: "rgba(15, 23, 42, 0.95)", + borderBottom: "1px solid rgba(51, 65, 85, 0.5)", + }; + + return ( +
+
+ + + + + + + + + + + + + + + + + {reversedHistory.map((snapshot, i) => { + const date = new Date(snapshot.timestamp); + const timeStr = date.toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }); + const msStr = String(date.getMilliseconds()).padStart(3, "0"); + + return ( + + + + + + + + + + + + + ); + })} + +
TimestampELUEL p50EL p99EL MaxHeap MBRSS MBPG TotalPG IdlePG Wait
+ {timeStr}.{msStr} + + {snapshot.eventLoopUtilization?.utilization.toFixed(3) ?? "—"} + + {snapshot.eventLoopDelay?.p50Ms.toFixed(2) ?? "—"} + + {snapshot.eventLoopDelay?.p99Ms.toFixed(2) ?? "—"} + + {snapshot.eventLoopDelay?.maxMs.toFixed(2) ?? "—"} + + {snapshot.memory.heapUsedMB.toFixed(1)} + + {snapshot.memory.rssMB.toFixed(1)} + + {snapshot.pgPool?.total ?? "—"} + + {snapshot.pgPool?.idle ?? "—"} + + {snapshot.pgPool?.waiting ?? "—"} +
+
+
+ Showing {reversedHistory.length} measurements (newest first) +
+
+ ); +} + +function PerformanceSection({ perfCurrent, perfHistory, perfAggregate }: { + perfCurrent: PerformanceSnapshot, + perfHistory: PerformanceSnapshot[], + perfAggregate: PerfAggregate, +}) { + const [viewMode, setViewMode] = useState<"graphs" | "table">("graphs"); + + const sliced = perfHistory.slice(-60); + const timestamps = sliced.map(s => s.timestamp); + + const eluHistory = sliced.map(s => s.eventLoopUtilization?.utilization ?? 0); + const p50History = sliced.map(s => s.eventLoopDelay?.p50Ms ?? 0); + const p99History = sliced.map(s => s.eventLoopDelay?.p99Ms ?? 0); + const heapHistory = sliced.map(s => s.memory.heapUsedMB); + const heapTotalHistory = sliced.map(s => s.memory.heapTotalMB); + const rssHistory = sliced.map(s => s.memory.rssMB); + const externalHistory = sliced.map(s => s.memory.externalMB); + const arrayBuffersHistory = sliced.map(s => s.memory.arrayBuffersMB); + const pgTotalHistory = sliced.map(s => s.pgPool?.total ?? 0); + const pgWaitingHistory = sliced.map(s => s.pgPool?.waiting ?? 0); + + const latestEventLoopDelay = perfCurrent.eventLoopDelay ?? sliced[sliced.length - 1]?.eventLoopDelay ?? null; + const latestELU = perfCurrent.eventLoopUtilization ?? sliced[sliced.length - 1]?.eventLoopUtilization ?? null; + + // Calculate health status for each metric + function getELUStatus(elu: number | null): { status: HealthStatus, label: string } { + if (elu === null) return { status: "unknown", label: "N/A" }; + if (elu < 0.5) return { status: "good", label: "Healthy" }; + if (elu < 0.8) return { status: "warning", label: "Elevated" }; + return { status: "critical", label: "High Load" }; + } + + function getEventLoopDelayStatus(p99: number | null): { status: HealthStatus, label: string } { + if (p99 === null) return { status: "unknown", label: "N/A" }; + if (p99 < 50) return { status: "good", label: "Fast" }; + if (p99 < 200) return { status: "warning", label: "Slow" }; + return { status: "critical", label: "Very Slow" }; + } + + function getPgPoolStatus(waiting: number, total: number): { status: HealthStatus, label: string } { + if (total === 0) return { status: "unknown", label: "No Pool" }; + if (waiting === 0) return { status: "good", label: "Healthy" }; + if (waiting < 5) return { status: "warning", label: "Queueing" }; + return { status: "critical", label: "Saturated" }; + } + + function getMemoryStatus(rss: number): { status: HealthStatus, label: string } { + if (rss < 500) return { status: "good", label: "Low" }; + if (rss < 1000) return { status: "warning", label: "Moderate" }; + return { status: "critical", label: "High" }; + } + + const eluStatus = getELUStatus(latestELU?.utilization ?? null); + const delayStatus = getEventLoopDelayStatus(latestEventLoopDelay?.p99Ms ?? null); + const pgStatus = getPgPoolStatus(perfCurrent.pgPool?.waiting ?? 0, perfCurrent.pgPool?.total ?? 0); + const memStatus = getMemoryStatus(perfCurrent.memory.rssMB); + + return ( +
+
+
+

+ 🔬 Performance Metrics +

+

+ Real-time Node.js process and PostgreSQL pool statistics +

+
+
+ + +
+
+ + {viewMode === "table" ? ( +
+

+ Raw Measurements +

+ +
+ ) : ( +
+ {/* Event Loop Section */} +

+ Event Loop +

+
+ v.toFixed(2)} + thresholds={{ + good: "< 0.5 — Plenty of headroom for handling requests", + warning: "0.5–0.8 — Getting busy, consider optimizing", + critical: "> 0.8 — Event loop is saturated, requests may queue", + }} + /> + v.toFixed(1)} + thresholds={{ + good: "< 20ms — Callbacks run promptly", + warning: "20–100ms — Some blocking occurring", + critical: "> 100ms — Significant blocking, impacts responsiveness", + }} + /> + v.toFixed(1)} + thresholds={{ + good: "< 50ms — Rare spikes only", + warning: "50–200ms — Occasional blocking spikes", + critical: "> 200ms — Frequent blocking, tail latency issues", + }} + /> +
+ + {/* Memory Section */} +

+ Memory +

+
+ v.toFixed(0)} + thresholds={{ + good: "< 200 MB — Normal for development", + warning: "200–500 MB — Getting large, watch for leaks", + critical: "> 500 MB — May cause GC pressure", + }} + /> + v.toFixed(0)} + thresholds={{ + good: "< 300 MB — Normal allocation", + warning: "300–600 MB — Growing, watch for leaks", + critical: "> 600 MB — High heap allocation", + }} + /> + v.toFixed(0)} + thresholds={{ + good: "< 500 MB — Normal footprint", + warning: "500–1000 MB — Getting heavy", + critical: "> 1000 MB — Very large, may impact system", + }} + /> + v.toFixed(1)} + thresholds={{ + good: "< 50 MB — Normal", + warning: "50–200 MB — Elevated native allocations", + critical: "> 200 MB — High native memory usage", + }} + /> + v.toFixed(1)} + thresholds={{ + good: "< 20 MB — Normal", + warning: "20–100 MB — Elevated buffer usage", + critical: "> 100 MB — High buffer allocations", + }} + /> +
+ + {/* PostgreSQL Pool Section */} +

+ PostgreSQL Connection Pool +

+
+ v.toFixed(0)} + thresholds={{ + good: "< 15 — Pool is sized appropriately", + warning: "15–23 — Nearing max pool size (25)", + critical: "≥ 25 — Pool is maxed out", + }} + /> + v.toFixed(0)} + thresholds={{ + good: "0 — No queue, instant connection", + warning: "1–5 — Short queue, slight delays", + critical: "> 5 — Long queue, significant delays", + }} + /> +
+

Pool Breakdown

+
+ {perfCurrent.pgPool?.pools && Object.entries(perfCurrent.pgPool.pools).map(([label, stats]) => ( +
+
{label}
+
+
+
+ {stats.total} +
+
Total
+
+
+
+ {stats.idle} +
+
Idle
+
+
+
+ {stats.waiting} +
+
Waiting
+
+
+
+ ))} + {(!perfCurrent.pgPool?.pools || Object.keys(perfCurrent.pgPool.pools).length === 0) && ( +
No pools registered
+ )} +
+
+
+ + {/* Aggregate Stats Table */} +

+ Aggregate Statistics (Last 60 seconds) +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MetricAverageMax
Event Loop Utilization + {perfAggregate.eventLoopUtilization?.avgUtilization.toFixed(3) ?? "—"} + + {perfAggregate.eventLoopUtilization?.maxUtilization.toFixed(3) ?? "—"} +
Event Loop Delay (p50) + {perfAggregate.eventLoopDelay?.avgP50Ms.toFixed(2) ?? "—"} ms +
Event Loop Delay (p99) + {perfAggregate.eventLoopDelay?.avgP99Ms.toFixed(2) ?? "—"} ms + + {perfAggregate.eventLoopDelay?.maxP99Ms.toFixed(2) ?? "—"} ms +
Heap Used + {perfAggregate.memory.avgHeapUsedMB.toFixed(1)} MB +
RSS + {perfAggregate.memory.avgRssMB.toFixed(1)} MB + + {perfAggregate.memory.maxRssMB.toFixed(1)} MB +
PG Pool Connections + {perfAggregate.pgPool?.avgTotal.toFixed(1) ?? "—"} +
+
+
+ )} +
+ ); +} + +function RequestTable({ + title, + description, + requests, + defaultSortColumn, +}: { + title: string, + description: string, + requests: RequestStat[], + defaultSortColumn: SortColumn, +}) { + const [sortColumn, setSortColumn] = useState(defaultSortColumn); + const [sortDirection, setSortDirection] = useState("desc"); + + const sortedRequests = useMemo(() => { + const sorted = [...requests]; + + const getSortValue = (stat: RequestStat, column: SortColumn): number | string => { + const columnGetters: Record number | string> = { + endpoint: () => `${stat.method}:${stat.path}`, + count: () => stat.count, + totalTime: () => stat.totalTimeMs, + avgTime: () => stat.totalTimeMs / stat.count, + minTime: () => stat.minTimeMs, + maxTime: () => stat.maxTimeMs, + lastCalled: () => stat.lastCalledAt, + }; + return columnGetters[column](); + }; + + sorted.sort((a, b) => { + const aVal = getSortValue(a, sortColumn); + const bVal = getSortValue(b, sortColumn); + + if (typeof aVal === "string" && typeof bVal === "string") { + const cmp = stringCompare(aVal, bVal); + return sortDirection === "asc" ? cmp : -cmp; + } + + return sortDirection === "asc" ? (aVal as number) - (bVal as number) : (bVal as number) - (aVal as number); + }); + return sorted; + }, [requests, sortColumn, sortDirection]); + + const handleSort = (column: SortColumn) => { + if (sortColumn === column) { + setSortDirection(sortDirection === "asc" ? "desc" : "asc"); + } else { + setSortColumn(column); + setSortDirection("desc"); + } + }; + + const headerStyle: React.CSSProperties = { + padding: "12px 24px", + textAlign: "left", + fontSize: "11px", + fontWeight: 500, + textTransform: "uppercase", + letterSpacing: "0.05em", + color: "#94a3b8", + cursor: "pointer", + userSelect: "none", + transition: "color 0.15s", + }; + + const headerStyleRight: React.CSSProperties = { ...headerStyle, textAlign: "right" }; + + const cellStyle: React.CSSProperties = { + padding: "16px 24px", + whiteSpace: "nowrap", + }; + + const cellStyleRight: React.CSSProperties = { ...cellStyle, textAlign: "right" }; + + const getHeaderColor = (column: SortColumn) => sortColumn === column ? "#22d3ee" : "#94a3b8"; + + if (requests.length === 0) { + return ( +
+

No requests recorded yet

+
+ ); + } + + return ( +
+
+

{title}

+

{description}

+
+
+ + + + + + + + + + + + + + {sortedRequests.map((stat) => ( + + + + + + + + + + ))} + +
handleSort("endpoint")} + > + Endpoint + + handleSort("count")} + > + Count + + handleSort("totalTime")} + > + Total Time + + handleSort("avgTime")} + > + Avg Time + + handleSort("minTime")} + > + Min + + handleSort("maxTime")} + > + Max + + handleSort("lastCalled")} + > + Last Called + +
+
+ + + {stat.path} + +
+
+ {stat.count.toLocaleString()} + + {formatDuration(stat.totalTimeMs)} + + {formatDuration(stat.totalTimeMs / stat.count)} + + {formatDuration(stat.minTimeMs)} + + {formatDuration(stat.maxTimeMs)} + + {formatRelativeTime(stat.lastCalledAt)} +
+
+
+ ); +} + +export default function DevStatsPage() { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [lastRefresh, setLastRefresh] = useState(null); + + const fetchStats = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch("/dev-stats/api"); + if (!res.ok) { + throw new Error(await res.text()); + } + const data = await res.json(); + setStats(data); + setLastRefresh(new Date()); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to fetch stats"); + } finally { + setLoading(false); + } + }, []); + + const clearStats = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch("/dev-stats/api", { method: "DELETE" }); + if (!res.ok) { + throw new Error(await res.text()); + } + setStats(null); + setLastRefresh(null); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to clear stats"); + } finally { + setLoading(false); + } + }, []); + + const buttonStyle: React.CSSProperties = { + padding: "8px 16px", + borderRadius: "8px", + fontWeight: 500, + fontSize: "14px", + border: "1px solid", + cursor: "pointer", + transition: "all 0.2s", + }; + + return ( +
+ {/* Background pattern */} +
+ +
+ {/* Header */} +
+
+
+

+ Dev Request Stats +

+

+ Monitor API performance during development +

+
+
+ {lastRefresh && ( + + Last refresh: {lastRefresh.toLocaleTimeString()} + + )} + + +
+
+
+ + {error && ( +
+ {error} +
+ )} + + {!stats ? ( +
+
+ + + +
+

+ No stats loaded yet +

+

+ Click the “Refresh” button to load request and performance statistics. + Stats are collected automatically in development mode. +

+ +
+ ) : ( +
+ {/* Aggregate stats cards */} +
+ + + + +
+ + {/* Performance Metrics */} + + + {/* Tables */} +
+ + + + + +
+
+ )} + + {/* Footer */} +
+

+ This page is only available in development mode. +
+ Stats are stored in memory and will be cleared when the server restarts. +

+
+
+
+ ); +} diff --git a/apps/backend/src/app/global-error.tsx b/apps/backend/src/app/global-error.tsx new file mode 100644 index 000000000..96f8773dd --- /dev/null +++ b/apps/backend/src/app/global-error.tsx @@ -0,0 +1,23 @@ +"use client"; + +import * as Sentry from "@sentry/nextjs"; +import { captureError } from "@hexclave/shared/dist/utils/errors"; +import Error from "next/error"; +import { useEffect } from "react"; + +export default function GlobalError({ error }: any) { + useEffect(() => { + captureError("backend-global-error", error); + }, [error]); + + return ( + + + [An unhandled error occurred.] + + + + ); +} diff --git a/apps/backend/src/app/health/error-handler-debug/page.tsx b/apps/backend/src/app/health/error-handler-debug/page.tsx new file mode 100644 index 000000000..da28678af --- /dev/null +++ b/apps/backend/src/app/health/error-handler-debug/page.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { throwErr } from "@hexclave/shared/dist/utils/errors"; + + +export default function Page() { + return
+ This page is useful for testing error handling.
+ Your observability platform should pick up on the errors thrown below.
+ + +
; +} 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/app/layout.tsx b/apps/backend/src/app/layout.tsx new file mode 100644 index 000000000..07b56f5de --- /dev/null +++ b/apps/backend/src/app/layout.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from 'next'; +import React from 'react'; +import '../polyfills'; + +export const metadata: Metadata = { + title: 'Hexclave API', + description: 'API endpoint of Hexclave.', +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode, +}) { + return ( + + + {children} + + + ); +} diff --git a/apps/backend/src/app/not-found.tsx b/apps/backend/src/app/not-found.tsx new file mode 100644 index 000000000..9efba7713 --- /dev/null +++ b/apps/backend/src/app/not-found.tsx @@ -0,0 +1,13 @@ +import { connection } from "next/server"; + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; + +export default async function NotFound() { + await connection(); // guarantees we will never prerender + + return
+ 404 Not Found +
; +} diff --git a/apps/backend/src/app/page.tsx b/apps/backend/src/app/page.tsx new file mode 100644 index 000000000..24ac5aed6 --- /dev/null +++ b/apps/backend/src/app/page.tsx @@ -0,0 +1,22 @@ +import { getNodeEnvironment } from "@hexclave/shared/dist/utils/env"; +import Link from "next/link"; + +export default function Home() { + return ( +
+ Welcome to Hexclave's API endpoint.
+
+ Were you looking for Hexclave's dashboard instead?
+
+ You can also return to https://hexclave.com.
+
+ API v1
+ {getNodeEnvironment() === "development" && ( + <> +
+ Dev Stats
+ + )} +
+ ); +} diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts deleted file mode 100644 index 1bcbe098e..000000000 --- a/apps/backend/src/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -import "@/instrument"; -import "@/polyfills"; -import { app } from "./server/app"; -import "./server/env-expand"; - -export default app; diff --git a/apps/backend/src/instrument.ts b/apps/backend/src/instrument.ts deleted file mode 100644 index 835967047..000000000 --- a/apps/backend/src/instrument.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { getEnvVariable, getNodeEnvironment } from "@hexclave/shared/dist/utils/env"; -import { sentryBaseConfig } from "@hexclave/shared/dist/utils/sentry"; -import { nicify } from "@hexclave/shared/dist/utils/strings"; -import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node"; -import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; -import { NodeSDK } from "@opentelemetry/sdk-node"; -import { PrismaInstrumentation } from "@prisma/instrumentation"; -import * as Sentry from "@sentry/node"; -import { initPerfStats } from "./lib/dev-perf-stats"; - -globalThis.global = globalThis; -// The Elysia process is the Node runtime; set the marker before shared helpers that still ask for Next runtime metadata. -// eslint-disable-next-line no-restricted-syntax -process.env.NEXT_RUNTIME ??= "nodejs"; - -let registered = false; - -export function registerBackendInstrumentation() { - if (registered) { - return; - } - registered = true; - - const portPrefix = getEnvVariable("NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX", "81"); - const isDevelopment = getNodeEnvironment() === "development"; - - const sdk = new NodeSDK({ - serviceName: "stack-backend", - instrumentations: [ - new PrismaInstrumentation(), - ...getNodeAutoInstrumentations({ - "@opentelemetry/instrumentation-http": { - enabled: false, - }, - }), - ], - ...isDevelopment ? { - traceExporter: new OTLPTraceExporter({ - url: `http://localhost:${portPrefix}31/v1/traces`, - }), - } : {}, - }); - sdk.start(); - - process.title = `stack-backend:${portPrefix} (node/elysia)`; - initPerfStats(); - - Sentry.init({ - ...sentryBaseConfig, - // We run our own OpenTelemetry NodeSDK above (for Prisma + the dev OTLP exporter), which already - // registers the global trace/context/propagation APIs. Without this flag, @sentry/node (v10 is - // OpenTelemetry-native) tries to register them again, logging "Attempted duplicate registration - // of API: trace/propagation/context". Skipping Sentry's OTel setup lets the NodeSDK own it while - // error capture continues to work normally. (Letting Sentry own OTel instead would require migrating - // our OpenTelemetry deps from v1 to v2 to match @sentry/node v10.) - skipOpenTelemetrySetup: true, - dsn: getEnvVariable("NEXT_PUBLIC_SENTRY_DSN", ""), - enabled: getNodeEnvironment() !== "development" && !getEnvVariable("CI", ""), - beforeSend(event, hint) { - const error = hint.originalException; - let nicified; - try { - nicified = nicify(error, { maxDepth: 8 }); - } catch (e) { - nicified = `Error occurred during nicification: ${e}`; - } - if (error instanceof Error) { - event.extra = { - ...event.extra, - cause: error.cause, - errorProps: { - ...error, - }, - nicifiedError: nicified, - }; - } - return event; - }, - }); -} - -registerBackendInstrumentation(); diff --git a/apps/backend/src/instrumentation.ts b/apps/backend/src/instrumentation.ts new file mode 100644 index 000000000..ebdbe788c --- /dev/null +++ b/apps/backend/src/instrumentation.ts @@ -0,0 +1,73 @@ +import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node"; +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; +import { PrismaInstrumentation } from "@prisma/instrumentation"; +import * as Sentry from "@sentry/nextjs"; +import { getEnvVariable, getNextRuntime, getNodeEnvironment } from "@hexclave/shared/dist/utils/env"; +import { sentryBaseConfig } from "@hexclave/shared/dist/utils/sentry"; +import { nicify } from "@hexclave/shared/dist/utils/strings"; +import { registerOTel } from '@vercel/otel'; +import { initPerfStats } from "./lib/dev-perf-stats"; +import "./polyfills"; + +// this is a hack for making prisma instrumentation work +// somehow prisma instrumentation accesses global and it makes edge instrumentation complain +globalThis.global = globalThis; + +export async function register() { + registerOTel({ + serviceName: 'stack-backend', + instrumentations: [ + new PrismaInstrumentation(), + ...getNextRuntime() === "nodejs" ? getNodeAutoInstrumentations({ + '@opentelemetry/instrumentation-http': { + enabled: false, + }, + }) : [], + ], + ...getNodeEnvironment() === "development" && getNextRuntime() === "nodejs" ? { + traceExporter: new OTLPTraceExporter({ + url: `http://localhost:${getEnvVariable("NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX", "81")}31/v1/traces`, + }), + } : {}, + }); + + if (getNextRuntime() === "nodejs") { + (globalThis as any).process.title = `stack-backend:${getEnvVariable("NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX", "81")} (node/nextjs)`; + + // Initialize performance stats collection in development + initPerfStats(); + } + + if (getNextRuntime() === "nodejs" || getNextRuntime() === "edge") { + Sentry.init({ + ...sentryBaseConfig, + + dsn: getEnvVariable("NEXT_PUBLIC_SENTRY_DSN", ""), + + enabled: getNodeEnvironment() !== "development" && !getEnvVariable("CI", ""), + + // Add exception metadata to the event + beforeSend(event, hint) { + const error = hint.originalException; + let nicified; + try { + nicified = nicify(error, { maxDepth: 8 }); + } catch (e) { + nicified = `Error occurred during nicification: ${e}`; + } + if (error instanceof Error) { + event.extra = { + ...event.extra, + cause: error.cause, + errorProps: { + ...error, + }, + nicifiedError: nicified, + }; + } + return event; + }, + }); + + } +} 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/runtime/headers.tsx b/apps/backend/src/lib/runtime/headers.tsx deleted file mode 100644 index 193e1467c..000000000 --- a/apps/backend/src/lib/runtime/headers.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { getRequestContext, ResponseCookieOptions } from "./request-context"; - -type CookieSetObject = ResponseCookieOptions & { - name: string, - value: string, -}; - -type CookieDeleteObject = { - name: string, -}; - -function encodeCookieComponent(value: string) { - return encodeURIComponent(value); -} - -function normalizeSameSite(sameSite: ResponseCookieOptions["sameSite"]) { - if (sameSite === true) { - return "Strict"; - } - if (sameSite === "lax") { - return "Lax"; - } - if (sameSite === "strict") { - return "Strict"; - } - if (sameSite === "none") { - return "None"; - } - return undefined; -} - -export function serializeSetCookie(name: string, value: string, options: ResponseCookieOptions = {}) { - const parts = [`${encodeCookieComponent(name)}=${encodeCookieComponent(value)}`]; - - if (options.domain != null) { - parts.push(`Domain=${options.domain}`); - } - parts.push(`Path=${options.path ?? "/"}`); - const expires = options.expires != null - ? options.expires instanceof Date ? options.expires : new Date(options.expires) - : options.maxAge != null ? new Date(Date.now() + options.maxAge * 1000) : undefined; - if (expires != null) { - parts.push(`Expires=${expires.toUTCString()}`); - } - if (options.maxAge != null) { - parts.push(`Max-Age=${Math.trunc(options.maxAge)}`); - } - // Emit Secure before HttpOnly to match the attribute order the pre-ElysiaJS - // (Next.js) backend produced. Cookie attribute order is semantically irrelevant - // per RFC 6265, but the e2e suite asserts the exact Set-Cookie string for the - // oauth-inner cookies (`...; Max-Age=N;( Secure;)? HttpOnly`), so keeping this - // order avoids a spurious regression there. - if (options.secure === true) { - parts.push("Secure"); - } - if (options.httpOnly === true) { - parts.push("HttpOnly"); - } - - const sameSite = normalizeSameSite(options.sameSite); - if (sameSite != null) { - parts.push(`SameSite=${sameSite}`); - } - if (options.priority != null) { - parts.push(`Priority=${options.priority}`); - } - - return parts.join("; "); -} - -export async function headers() { - return getRequestContext().headers; -} - -export async function cookies() { - const context = getRequestContext(); - - return { - get(name: string) { - const pending = [...context.pendingSetCookies].reverse().find(cookie => cookie.name === name); - if (pending != null) { - return { - name, - value: pending.value, - }; - } - - const value = context.incomingCookies.get(name); - return value == null ? undefined : { - name, - value, - }; - }, - - set(nameOrCookie: string | CookieSetObject, value?: string, options?: ResponseCookieOptions) { - const cookie = typeof nameOrCookie === "string" - ? { - name: nameOrCookie, - value: value ?? "", - options: options ?? {}, - } - : { - name: nameOrCookie.name, - value: nameOrCookie.value, - options: nameOrCookie, - }; - - context.pendingSetCookies.push(cookie); - context.incomingCookies.set(cookie.name, cookie.value); - }, - - delete(nameOrCookie: string | CookieDeleteObject) { - const name = typeof nameOrCookie === "string" ? nameOrCookie : nameOrCookie.name; - const cookie = { - name, - value: "", - options: { - expires: new Date(0), - maxAge: 0, - path: "/", - }, - }; - context.pendingSetCookies = context.pendingSetCookies.filter(c => c.name !== name); - context.deletedCookies.push(cookie); - context.incomingCookies.delete(name); - }, - }; -} diff --git a/apps/backend/src/lib/runtime/navigation.tsx b/apps/backend/src/lib/runtime/navigation.tsx deleted file mode 100644 index 3347271d4..000000000 --- a/apps/backend/src/lib/runtime/navigation.tsx +++ /dev/null @@ -1,44 +0,0 @@ -export class NextRedirectError extends Error { - digest = "NEXT_REDIRECT"; - redirectUrl: string; - redirectStatus: 307 | 308; - - constructor(url: string, status: 307 | 308) { - super("NEXT_REDIRECT"); - this.redirectUrl = url; - this.redirectStatus = status; - } -} - -export class NextNotFoundError extends Error { - digest = "NEXT_NOT_FOUND"; - - constructor() { - super("NEXT_NOT_FOUND"); - } -} - -export const RedirectType = { - push: "push", - replace: "replace", -} as const; - -export function redirect(url: string, _type?: typeof RedirectType[keyof typeof RedirectType]): never { - throw new NextRedirectError(url, 307); -} - -export function permanentRedirect(url: string): never { - throw new NextRedirectError(url, 308); -} - -export function notFound(): never { - throw new NextNotFoundError(); -} - -export function usePathname(): never { - throw new Error("next/navigation usePathname() was called in the backend runtime"); -} - -export function useSearchParams(): never { - throw new Error("next/navigation useSearchParams() was called in the backend runtime"); -} diff --git a/apps/backend/src/lib/runtime/request-context.tsx b/apps/backend/src/lib/runtime/request-context.tsx deleted file mode 100644 index 9109eca68..000000000 --- a/apps/backend/src/lib/runtime/request-context.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { AsyncLocalStorage } from "node:async_hooks"; - -export type CookieWrite = { - name: string, - value: string, - options: ResponseCookieOptions, -}; - -export type ResponseCookieOptions = { - domain?: string, - expires?: Date | number | string, - httpOnly?: boolean, - maxAge?: number, - path?: string, - priority?: "low" | "medium" | "high", - sameSite?: boolean | "lax" | "strict" | "none", - secure?: boolean, -}; - -export type RequestContext = { - headers: Headers, - incomingCookies: Map, - pendingSetCookies: CookieWrite[], - deletedCookies: CookieWrite[], -}; - -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"); - } - return context; -} - -export function parseCookieHeader(cookieHeader: string | null) { - const cookies = new Map(); - if (cookieHeader == null || cookieHeader === "") { - return cookies; - } - for (const part of cookieHeader.split(";")) { - const separatorIndex = part.indexOf("="); - if (separatorIndex < 0) { - continue; - } - const name = part.slice(0, separatorIndex).trim(); - if (name === "") { - continue; - } - const rawValue = part.slice(separatorIndex + 1).trim(); - cookies.set(name, rawValue); - } - return cookies; -} diff --git a/apps/backend/src/polyfills.tsx b/apps/backend/src/polyfills.tsx index 57a3cc1e4..cdcaf77e1 100644 --- a/apps/backend/src/polyfills.tsx +++ b/apps/backend/src/polyfills.tsx @@ -1,4 +1,4 @@ -import * as Sentry from "@sentry/node"; +import * as Sentry from "@sentry/nextjs"; import { getEnvVariable, getNodeEnvironment } from "@hexclave/shared/dist/utils/env"; import { captureError, registerErrorSink } from "@hexclave/shared/dist/utils/errors"; import * as util from "util"; 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..ed439fd99 100644 --- a/apps/backend/src/route-handlers/smart-route-handler.tsx +++ b/apps/backend/src/route-handlers/smart-route-handler.tsx @@ -1,7 +1,7 @@ import "../polyfills"; import { recordRequestStats } from "@/lib/dev-request-stats"; -import * as Sentry from "@sentry/node"; +import * as Sentry from "@sentry/nextjs"; import { EndpointDocumentation } from "@hexclave/shared/dist/crud"; import { KnownError, KnownErrors } from "@hexclave/shared/dist/known-errors"; import { generateSecureRandomString } from "@hexclave/shared/dist/utils/crypto"; @@ -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 deleted file mode 100644 index 6c8be7203..000000000 --- a/apps/backend/src/server/app.ts +++ /dev/null @@ -1,312 +0,0 @@ -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 { node } from "@elysiajs/node"; -import { getEnvVariable, getNodeEnvironment } from "@hexclave/shared/dist/utils/env"; -import { Elysia } from "elysia"; -import { runRequestPipeline } from "./middleware"; -import { createBackendRequest } from "./backend-request"; -import { MalformedRouteParamError, matchRoute } from "./registry"; - -const globalSecurityHeaders = { - "Cross-Origin-Opener-Policy": "same-origin", - "Permissions-Policy": "", - "Referrer-Policy": "strict-origin-when-cross-origin", - "X-Content-Type-Options": "nosniff", - "X-Frame-Options": "SAMEORIGIN", - "Content-Security-Policy": "", -}; -const knownHttpMethods = new Set(httpMethodNames); -const developmentRequestStartTimes = new WeakMap(); -const shouldLogDevelopmentRequests = getNodeEnvironment() === "development"; - -export const app = new Elysia({ - adapter: node(), -}) - .onRequest(({ request }) => { - if (shouldLogDevelopmentRequests) { - developmentRequestStartTimes.set(request, performance.now()); - } - }) - .onAfterResponse(({ request, set }) => { - if (!shouldLogDevelopmentRequests) { - return; - } - - const startTime = developmentRequestStartTimes.get(request); - const elapsedMilliseconds = startTime == null ? "unknown" : (performance.now() - startTime).toFixed(1); - const pathname = new URL(request.url).pathname; - console.log(`[Elysia] ${request.method} ${pathname} ${set.status} ${elapsedMilliseconds}ms`); - }) - .get("/", () => htmlResponse(homeHtml())) - .get("/dev-stats", () => htmlResponse(devStatsHtml())) - .get("/health/error-handler-debug", () => htmlResponse(errorHandlerDebugHtml())) - .post("/monitoring", async ({ request }) => withGlobalHeaders(await handleMonitoringTunnel(request)), { - parse: "none", - }) - .all("/*", async ({ request }) => await dispatch(request), { - parse: "none", - }); - -export async function dispatch(request: Request) { - const pipeline = await runRequestPipeline(request); - if (pipeline.shortCircuitResponse != null) { - return withGlobalHeaders(pipeline.shortCircuitResponse); - } - - let match; - try { - match = matchRoute(pipeline.dispatchPath); - } catch (error) { - if (error instanceof MalformedRouteParamError) { - return withGlobalHeaders(new Response("Bad Request", { status: 400 })); - } - throw error; - } - if (match == null) { - return withGlobalHeaders(new Response("
404 Not Found
", { - status: 404, - headers: { - "content-type": "text/html; charset=utf-8", - }, - })); - } - - const method = request.method.toUpperCase(); - if (!isHttpMethod(method)) { - return withGlobalHeaders(new Response(null, { - status: 405, - })); - } - const handler = match.methods.get(method) ?? (method === "HEAD" ? match.methods.get("GET") : undefined); - if (handler == null) { - return withGlobalHeaders(new Response(null, { - status: 405, - })); - } - - const backendRequest = createBackendRequest(request, pipeline.mergedHeaders, pipeline.originalUrl); - const context: RequestContext = { - headers: pipeline.mergedHeaders, - incomingCookies: parseCookieHeader(pipeline.mergedHeaders.get("cookie")), - pendingSetCookies: [], - deletedCookies: [], - }; - - const response = await requestContextALS.run(context, async () => { - try { - return await handler(backendRequest, { - params: Promise.resolve(match.params), - }); - } catch (error) { - if (isRedirectError(error)) { - return new Response(null, { - status: error.redirectStatus, - headers: { - Location: error.redirectUrl, - }, - }); - } - if (error instanceof NextNotFoundError) { - return new Response("Not Found", { status: 404 }); - } - throw error; - } - }); - - const finalResponse = method === "HEAD" ? new Response(null, { - status: response.status, - statusText: response.statusText, - headers: response.headers, - }) : response; - - for (const cookie of context.pendingSetCookies) { - finalResponse.headers.append("Set-Cookie", serializeSetCookie(cookie.name, cookie.value, cookie.options)); - } - for (const cookie of context.deletedCookies) { - finalResponse.headers.append("Set-Cookie", serializeSetCookie(cookie.name, cookie.value, cookie.options)); - } - if (pipeline.corsHeadersInit != null) { - for (const [key, value] of Object.entries(pipeline.corsHeadersInit)) { - finalResponse.headers.set(key, value); - } - } - if (pipeline.middlewareRewrite != null) { - finalResponse.headers.set("x-middleware-rewrite", pipeline.middlewareRewrite); - } - return withGlobalHeaders(finalResponse); -} - -function isRedirectError(error: unknown): error is { redirectStatus: 307 | 308, redirectUrl: string } { - if (!(error instanceof Error) || !("digest" in error) || !("redirectUrl" in error) || !("redirectStatus" in error)) { - return false; - } - return typeof error.digest === "string" - && error.digest.startsWith("NEXT_REDIRECT") - && typeof error.redirectUrl === "string" - && (error.redirectStatus === 307 || error.redirectStatus === 308); -} - -function withGlobalHeaders(response: Response) { - for (const [key, value] of Object.entries(globalSecurityHeaders)) { - response.headers.set(key, value); - } - return response; -} - -function htmlResponse(body: string, status = 200) { - return withGlobalHeaders(new Response(body, { - status, - headers: { - "content-type": "text/html; charset=utf-8", - }, - })); -} - -function homeHtml() { - const devStatsLink = getNodeEnvironment() === "development" - ? `
Dev Stats
` - : ""; - return ` - - - - Hexclave API - - -
- Welcome to Hexclave's API endpoint.
-
- Were you looking for Hexclave's dashboard instead?
-
- You can also return to https://hexclave.com.
-
- API v1
- ${devStatsLink} -
- -`; -} - -function devStatsHtml() { - return ` - - - - Hexclave Backend Dev Stats - - - -

Dev Stats

- -
Loading...
- - -`; -} - -function errorHandlerDebugHtml() { - return ` - - - - Backend Error Debug - - -
- This page is useful for testing error handling.
- Your observability platform should pick up on the errors thrown below.
- - -
- - -`; -} - -async function handleMonitoringTunnel(request: Request) { - const allowedDsn = getEnvVariable("NEXT_PUBLIC_SENTRY_DSN", ""); - if (allowedDsn === "") { - return new Response(null, { status: 404 }); - } - - const envelope = await request.text(); - const firstLineEnd = envelope.indexOf("\n"); - const envelopeHeaderBytes = firstLineEnd === -1 ? envelope : envelope.slice(0, firstLineEnd); - const envelopeDsn = getEnvelopeDsn(envelopeHeaderBytes); - if (envelopeDsn !== allowedDsn) { - return new Response("Invalid Sentry envelope DSN", { - status: 400, - headers: { - "content-type": "text/plain; charset=utf-8", - }, - }); - } - - const sentryDsnUrl = new URL(allowedDsn); - const projectId = sentryDsnUrl.pathname.split("/").filter(Boolean).at(-1); - if (projectId == null) { - return new Response("Invalid configured Sentry DSN", { - status: 500, - headers: { - "content-type": "text/plain; charset=utf-8", - }, - }); - } - - const sentryEnvelopeUrl = new URL(`/api/${projectId}/envelope/`, sentryDsnUrl.origin); - const sentryResponse = await fetch(sentryEnvelopeUrl, { - method: "POST", - body: envelope, - headers: { - "content-type": request.headers.get("content-type") ?? "application/x-sentry-envelope", - }, - }); - return new Response(sentryResponse.body, { - status: sentryResponse.status, - statusText: sentryResponse.statusText, - headers: sentryResponse.headers, - }); -} - -function getEnvelopeDsn(envelopeHeaderBytes: string) { - let parsedEnvelopeHeader: unknown; - try { - parsedEnvelopeHeader = JSON.parse(envelopeHeaderBytes); - } catch { - return undefined; - } - if ( - parsedEnvelopeHeader == null - || typeof parsedEnvelopeHeader !== "object" - || !("dsn" in parsedEnvelopeHeader) - || typeof parsedEnvelopeHeader.dsn !== "string" - ) { - return undefined; - } - return parsedEnvelopeHeader.dsn; -} - -function isHttpMethod(method: string): method is typeof httpMethodNames[number] { - return knownHttpMethods.has(method); -} 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/env-expand.ts b/apps/backend/src/server/env-expand.ts deleted file mode 100644 index 803246607..000000000 --- a/apps/backend/src/server/env-expand.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* eslint-disable no-restricted-syntax -- This bootstrap normalizes process.env before getEnvVariable reads it. */ - -const envReferencePattern = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-(.*?))?\}|\$([A-Za-z_][A-Za-z0-9_]*)/g; - -function expandEnvValue(value: string): string { - return value.replace(envReferencePattern, (_match, bracedName: string | undefined, defaultValue: string | undefined, bareName: string | undefined) => { - const name = bracedName ?? bareName; - if (name == null) { - return ""; - } - const referencedValue = process.env[name]; - if (bracedName != null && defaultValue != null && (referencedValue == null || referencedValue === "")) { - return defaultValue; - } - return referencedValue ?? ""; - }); -} - -for (let iteration = 0; iteration < 10; iteration++) { - let changed = false; - for (const [key, value] of Object.entries(process.env)) { - if (value == null || !envReferencePattern.test(value)) { - envReferencePattern.lastIndex = 0; - continue; - } - envReferencePattern.lastIndex = 0; - const expandedValue = expandEnvValue(value); - if (expandedValue !== value) { - process.env[key] = expandedValue; - changed = true; - } - } - if (!changed) { - break; - } -} diff --git a/apps/backend/src/server/middleware.ts b/apps/backend/src/server/middleware.ts deleted file mode 100644 index a769f05c2..000000000 --- a/apps/backend/src/server/middleware.ts +++ /dev/null @@ -1,244 +0,0 @@ -import apiVersions from "@/generated/api-versions.json"; -import routes from "@/generated/routes.json"; -import { SmartRouter } from "@/smart-router"; -import { getEnvVariable, getNodeEnvironment } from "@hexclave/shared/dist/utils/env"; -import { wait } from "@hexclave/shared/dist/utils/promises"; - -const DEV_RATE_LIMIT_MAX_REQUESTS = 100; -const DEV_RATE_LIMIT_WINDOW_MS = 10_000; -const devRateLimitMarks: number[] = []; - -const corsAllowedRequestHeaders = [ - "content-type", - "authorization", - "x-stack-project-id", - "x-stack-branch-id", - "x-stack-override-error-status", - "x-stack-random-nonce", - "x-stack-client-version", - "x-stack-disable-artificial-development-delay", - "x-stack-access-type", - "x-stack-publishable-client-key", - "x-stack-secret-server-key", - "x-stack-super-secret-admin-key", - "x-stack-admin-access-token", - "x-stack-refresh-token", - "x-stack-access-token", - "x-stack-allow-restricted-user", - "x-stack-allow-anonymous-user", - "baggage", - "sentry-trace", - "x-vercel-protection-bypass", - "ngrok-skip-browser-warning", -]; - -const corsAllowedResponseHeaders = [ - "content-type", - "x-stack-actual-status", - "x-stack-known-error", -]; - -function withHexclaveHeaderAliases(headers: string[]): string[] { - return headers.flatMap((header) => header.startsWith("x-stack-") - ? [header, `x-hexclave-${header.slice("x-stack-".length)}`] - : [header]); -} - -const corsAllowedRequestHeadersWithAliases = withHexclaveHeaderAliases(corsAllowedRequestHeaders); -const corsAllowedResponseHeadersWithAliases = withHexclaveHeaderAliases(corsAllowedResponseHeaders); - -export type PipelineResult = { - corsHeadersInit?: HeadersInit, - dispatchPath: string, - mergedHeaders: Headers, - middlewareRewrite?: string, - originalUrl: string, - shortCircuitResponse?: Response, -}; - -export async function runRequestPipeline(request: Request): Promise { - const url = new URL(request.url); - const mergedHeaders = mergeHexclaveHeaderAliases(request.headers); - ensureForwardedForHeader(mergedHeaders, request); - const delay = +getEnvVariable("STACK_ARTIFICIAL_DEVELOPMENT_DELAY_MS", "0"); - if (delay) { - if (getNodeEnvironment().includes("production")) { - throw new Error("STACK_ARTIFICIAL_DEVELOPMENT_DELAY_MS environment variable is only allowed in development"); - } - if (!request.headers.get("x-stack-disable-artificial-development-delay")) { - await wait(delay); - } - } - - const isApiRequest = url.pathname.startsWith("/api/"); - const corsHeadersInit = isApiRequest ? { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS", - "Access-Control-Max-Age": "86400", - "Access-Control-Allow-Headers": corsAllowedRequestHeadersWithAliases.join(", "), - "Access-Control-Expose-Headers": corsAllowedResponseHeadersWithAliases.join(", "), - "Vary": corsAllowedRequestHeadersWithAliases.join(", "), - } : undefined; - - if (isApiRequest && !request.headers.get("x-stack-disable-artificial-development-delay") && getNodeEnvironment() === "development" && request.method !== "OPTIONS" && !request.url.includes(".well-known") && !request.url.includes("/api/latest/internal/external-db-sync/")) { - const now = performance.now(); - while (devRateLimitMarks.length > 0 && now - devRateLimitMarks[0] > DEV_RATE_LIMIT_WINDOW_MS) { - devRateLimitMarks.shift(); - } - if (devRateLimitMarks.length >= DEV_RATE_LIMIT_MAX_REQUESTS) { - const waitMs = Math.max(0, DEV_RATE_LIMIT_WINDOW_MS - (now - devRateLimitMarks[0])); - const retryAfterSeconds = Math.max(1, Math.ceil(waitMs / 1000)); - - const response = Response.json({ - message: "Artificial development rate limit triggered. Wait before retrying.", - }, { - status: 429, - }); - - if (Math.random() < 0.5 && corsHeadersInit) { - for (const [key, value] of Object.entries(corsHeadersInit)) { - response.headers.set(key, value); - } - } - - if (Math.random() < 0.5) { - response.headers.set("Retry-After", retryAfterSeconds.toString()); - } - - return { - corsHeadersInit, - dispatchPath: url.pathname, - mergedHeaders, - originalUrl: request.url, - shortCircuitResponse: response, - }; - } - devRateLimitMarks.push(now); - } - - if (request.method === "OPTIONS" && isApiRequest) { - return { - corsHeadersInit, - dispatchPath: url.pathname, - mergedHeaders, - originalUrl: request.url, - shortCircuitResponse: new Response(null, { - headers: corsHeadersInit, - }), - }; - } - - const dispatchPath = getDispatchPath(url.pathname); - return { - corsHeadersInit, - dispatchPath, - mergedHeaders, - middlewareRewrite: dispatchPath === url.pathname ? undefined : dispatchPath, - originalUrl: request.url, - }; -} - -function mergeHexclaveHeaderAliases(headers: Headers) { - const newRequestHeaders = new Headers(headers); - for (const [name, value] of headers) { - if (name.startsWith("x-hexclave-")) { - newRequestHeaders.set(`x-stack-${name.slice("x-hexclave-".length)}`, value); - } - } - return newRequestHeaders; -} - -const clientIpForwardingHeaders = ["x-forwarded-for", "x-real-ip", "x-vercel-forwarded-for", "cf-connecting-ip"]; - -function ensureForwardedForHeader(headers: Headers, request: Request) { - // Direct connections (local dev, proxy-less self-host) arrive without any forwarding - // header, so getEndUserIp() finds no client IP and warns on every request. The Node - // adapter still knows the socket's remote address, so synthesize x-forwarded-for from - // it — mirroring what the old Next.js dev server did. Behind a real proxy (e.g. Vercel) - // one of these headers is already set, so this stays a no-op there. - if (clientIpForwardingHeaders.some((header) => headers.has(header))) { - return; - } - const socketIp = readClientSocketIp(request); - if (socketIp == null) { - return; - } - headers.set("x-forwarded-for", normalizeClientIp(socketIp)); -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function readClientSocketIp(request: Request): string | undefined { - // The Node adapter (srvx) augments the web Request with the resolved client IP and - // the underlying Node socket, neither of which exist on the standard Request type. - // Read them defensively at runtime instead of casting; on other runtimes (e.g. the - // Vercel serverless entry) these are simply absent and we fall through to undefined. - const directIp: unknown = Reflect.get(request, "ip"); - if (typeof directIp === "string" && directIp !== "") { - return directIp; - } - const runtime: unknown = Reflect.get(request, "runtime"); - const node: unknown = isRecord(runtime) ? runtime.node : undefined; - const req: unknown = isRecord(node) ? node.req : undefined; - const socket: unknown = isRecord(req) ? req.socket : undefined; - const remoteAddress: unknown = isRecord(socket) ? socket.remoteAddress : undefined; - return typeof remoteAddress === "string" && remoteAddress !== "" ? remoteAddress : undefined; -} - -function normalizeClientIp(ip: string): string { - // Node sockets report IPv4 clients as IPv4-mapped IPv6 (e.g. "::ffff:127.0.0.1"). - // Normalize to the plain IPv4 form so it matches what proxies put in x-forwarded-for. - const mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i); - return mapped ? mapped[1] : ip; -} - -import.meta.vitest?.test("ensureForwardedForHeader synthesizes the client IP for direct connections", ({ expect }) => { - // Uses the adapter-provided client IP when no forwarding header is present. - const fromIp = new Headers(); - ensureForwardedForHeader(fromIp, Object.assign(new Request("http://localhost/api/v1"), { ip: "203.0.113.7" })); - expect(fromIp.get("x-forwarded-for")).toBe("203.0.113.7"); - - // Falls back to the raw Node socket and normalizes IPv4-mapped IPv6 to plain IPv4. - const fromSocket = new Headers(); - ensureForwardedForHeader(fromSocket, Object.assign(new Request("http://localhost/api/v1"), { - runtime: { node: { req: { socket: { remoteAddress: "::ffff:127.0.0.1" } } } }, - })); - expect(fromSocket.get("x-forwarded-for")).toBe("127.0.0.1"); - - // Never overwrites an existing forwarding header (e.g. behind a trusted proxy like Vercel). - const existing = new Headers({ "x-forwarded-for": "198.51.100.1" }); - ensureForwardedForHeader(existing, Object.assign(new Request("http://localhost/api/v1"), { ip: "203.0.113.7" })); - expect(existing.get("x-forwarded-for")).toBe("198.51.100.1"); - - // No socket info available (e.g. an unusual runtime) → leaves headers untouched. - const none = new Headers(); - ensureForwardedForHeader(none, new Request("http://localhost/api/v1")); - expect(none.get("x-forwarded-for")).toBe(null); -}); - -function getDispatchPath(originalPathname: string) { - let pathname = originalPathname; - outer: for (let i = 0; i < apiVersions.length - 1; i++) { - const version = apiVersions[i]; - const nextVersion = apiVersions[i + 1]; - if (!nextVersion.migrationFolder) { - throw new Error(`No migration folder found for version ${nextVersion.name}. This is a bug because every version except the first should have a migration folder.`); - } - if ((pathname + "/").startsWith(version.servedRoute + "/")) { - const nextPathname = pathname.replace(version.servedRoute, nextVersion.servedRoute); - const migrationPathname = nextPathname.replace(nextVersion.servedRoute, nextVersion.migrationFolder); - for (const route of routes) { - if (nextVersion.migrationFolder && (route.normalizedPath + "/").startsWith(nextVersion.migrationFolder + "/")) { - if (SmartRouter.matchNormalizedPath(migrationPathname, route.normalizedPath)) { - pathname = migrationPathname; - break outer; - } - } - } - pathname = nextPathname; - } - } - return pathname; -} diff --git a/apps/backend/src/server/registry.ts b/apps/backend/src/server/registry.ts deleted file mode 100644 index 09da49d4a..000000000 --- a/apps/backend/src/server/registry.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { httpMethodNames, routeModules } from "@/generated/route-modules"; -import { SmartRouter } from "@/smart-router"; - -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 UnknownRouteModule = Partial>; -type UnknownRouteFunction = (request: Request, options: RouteHandlerOptions) => unknown; - -type RouteEntry = { - methods: Map, - normalizedPath: string, - specificity: number[], -}; - -export type RouteMatch = { - handler?: RouteHandler, - methods: Map, - normalizedPath: string, - params: Record, -}; - -export const routeRegistry = buildRouteRegistry(); - -export function matchRoute(dispatchPath: string): RouteMatch | undefined { - for (const entry of routeRegistry) { - const params = SmartRouter.matchNormalizedPath(dispatchPath, entry.normalizedPath); - if (params !== false) { - return { - methods: entry.methods, - normalizedPath: entry.normalizedPath, - params: decodeRouteParams(params), - }; - } - } - return undefined; -} - -export class MalformedRouteParamError extends Error { - constructor(param: string) { - super(`Malformed percent-encoding in route parameter: ${param}`); - this.name = "MalformedRouteParamError"; - } -} - -function strictDecodeURIComponent(value: string): string { - try { - return decodeURIComponent(value); - } catch { - throw new MalformedRouteParamError(value); - } -} - -function decodeRouteParams(params: Record): Record { - return Object.fromEntries( - Object.entries(params).map(([key, value]) => [ - key, - Array.isArray(value) ? value.map(strictDecodeURIComponent) : strictDecodeURIComponent(value), - ]), - ); -} - -function buildRouteRegistry() { - return routeModules - .map((route) => { - const methods = new Map(); - for (const method of httpMethodNames) { - const handler = route.module[method]; - if (isRouteFunction(handler)) { - methods.set(method, createRouteHandler(route.normalizedPath, method, handler)); - } - } - return { - methods, - normalizedPath: route.normalizedPath, - specificity: getSpecificity(route.normalizedPath), - }; - }) - .filter((route) => route.methods.size > 0) - .sort(compareRouteEntries); -} - -function isRouteFunction(value: unknown): value is UnknownRouteFunction { - return typeof value === "function"; -} - -function createRouteHandler(normalizedPath: string, method: HttpMethod, handler: UnknownRouteFunction): RouteHandler { - return async (request, options) => { - const result = await handler(request, options); - if (result instanceof Response) { - return result; - } - throw new Error(`Route ${normalizedPath} ${method} did not return a Response`); - }; -} - -function compareRouteEntries(a: RouteEntry, b: RouteEntry) { - const maxLength = Math.max(a.specificity.length, b.specificity.length); - for (let i = 0; i < maxLength; i++) { - const diff = (b.specificity[i] ?? 0) - (a.specificity[i] ?? 0); - if (diff !== 0) { - return diff; - } - } - return stringCompare(a.normalizedPath, b.normalizedPath); -} - -function getSpecificity(normalizedPath: string) { - return normalizedPath.split("/").filter(Boolean).map((segment) => { - if (segment.startsWith("[[...") && segment.endsWith("]]")) { - return 0; - } - if (segment.startsWith("[...") && segment.endsWith("]")) { - return 1; - } - if (segment.startsWith("[") && segment.endsWith("]")) { - return 2; - } - return 3; - }); -} - -function stringCompare(a: string, b: string) { - return a < b ? -1 : a > b ? 1 : 0; -} diff --git a/apps/backend/src/server/server.ts b/apps/backend/src/server/server.ts deleted file mode 100644 index 4906255ec..000000000 --- a/apps/backend/src/server/server.ts +++ /dev/null @@ -1,21 +0,0 @@ -import "@/instrument"; -import "@/polyfills"; -import { getEnvVariable } from "@hexclave/shared/dist/utils/env"; -import { runAsynchronously } from "@hexclave/shared/dist/utils/promises"; -import { app } from "./app"; -import "./env-expand"; - -const portPrefix = getEnvVariable("NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX", "81"); -const port = Number(getEnvVariable("PORT", getEnvVariable("BACKEND_PORT", `${portPrefix}02`))); -const hostname = getEnvVariable("HOSTNAME", "0.0.0.0"); - -app.listen({ - hostname, - port, -}); - -console.log(`Hexclave backend listening on http://${hostname}:${port}`); - -process.once("SIGTERM", () => { - process.exit(0); -}); diff --git a/apps/backend/src/server/vercel.ts b/apps/backend/src/server/vercel.ts deleted file mode 100644 index 905ee2b2c..000000000 --- a/apps/backend/src/server/vercel.ts +++ /dev/null @@ -1,5 +0,0 @@ -import "@/instrument"; -import "@/polyfills"; -import { app } from "./app"; - -export default app; diff --git a/apps/backend/src/smart-router.tsx b/apps/backend/src/smart-router.tsx index ceca8831b..2dfa3e992 100644 --- a/apps/backend/src/smart-router.tsx +++ b/apps/backend/src/smart-router.tsx @@ -103,9 +103,11 @@ function parseApiVersionStringToArray(version: string): [number, number] { function matchPath(path: string, toMatchWith: string): Record | false { - // get the relative part, and modify it to have a leading slash, without a trailing slash, without ./.., etc. - const url = new URL(path + "/", "http://example.com"); - const modifiedPath = url.pathname.slice(1, -1); + // Normalize to relative path segments without a trailing slash. Appending "/" before URL parsing + // turns an already-trailing path into "//", which URL interprets as a protocol-relative URL during + // the final recursive match and rejects because it has no host. + const url = new URL(path === "" ? "/" : path, "http://example.com"); + const modifiedPath = url.pathname.slice(1).replace(/\/+$/, ""); const modifiedToMatchWith = toMatchWith.slice(1); if (modifiedPath === "" && modifiedToMatchWith === "") { @@ -146,6 +148,20 @@ function matchPath(path: string, toMatchWith: string): Record { + expect({ + staticRoute: SmartRouter.matchNormalizedPath("/api/v1/", "/api/v1"), + dynamicRoute: SmartRouter.matchNormalizedPath("/users/user-123/", "/users/[user_id]"), + }).toMatchInlineSnapshot(` + { + "dynamicRoute": { + "user_id": "user-123", + }, + "staticRoute": {}, + } + `); +}); + /** * Modified from: https://github.com/vercel/next.js/blob/6ff13369bb18045657d0f84ddc86b540340603a1/packages/next/src/shared/lib/router/utils/app-paths.ts#L23 */ diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index fef42b3ca..7fff7e1d1 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -6,7 +6,7 @@ "dom.iterable", "esnext" ], - "allowJs": false, + "allowJs": true, "strict": true, "noEmit": true, "esModuleInterop": true, @@ -17,6 +17,11 @@ "jsx": "react-jsx", "incremental": true, "noErrorTruncation": true, + "plugins": [ + { + "name": "next" + } + ], "paths": { "@/*": [ "./src/*" @@ -28,15 +33,16 @@ ] }, "include": [ + "next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.?ts", - "**/*.?tsx" + "**/*.?tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" ], "exclude": [ - "api", "node_modules", - ".next/dev", - "dist" + ".next/dev" ] } diff --git a/apps/backend/tsdown.config.ts b/apps/backend/tsdown.config.ts deleted file mode 100644 index 07844d33a..000000000 --- a/apps/backend/tsdown.config.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { sentryRollupPlugin } from "@sentry/rollup-plugin"; -import { readFileSync } from "node:fs"; -import { builtinModules } from "node:module"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { defineConfig, type Rolldown, type UserConfig } from "tsdown"; -// @ts-expect-error - this is a workspace tsdown helper imported from source. -import { createBasePlugin } from "../../configs/tsdown/plugins.ts"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); -const backendDir = __dirname; - -const packageJson = JSON.parse(readFileSync(resolve(backendDir, "package.json"), "utf-8")); - -const externalPackages = [ - "@prisma/client", - "@prisma/adapter-neon", - "@prisma/adapter-pg", - "@prisma/instrumentation", - "@prisma/extension-read-replicas", - "@prisma/client/runtime/library", - "@prisma/client/runtime/client", - "@prisma/client/runtime/edge", - "bcrypt", - "oidc-provider", - "pg", - "sharp", - // @aws-sdk and @smithy use complex class hierarchies that rolldown mis-scopes - // when bundled, emitting references to hoisted classes before they're defined - // (e.g. "ReferenceError: StructureSchema$1 is not defined" when the server - // bundle boots via `node dist/server.mjs`). Keep them external so Node resolves - // them from node_modules at runtime, which is always present alongside the - // bundle (Docker image, CI runner, Vercel NFT trace). Mirrors the same fix in - // scripts/db-migrations.tsdown.config.ts. - "@aws-sdk", - "@smithy", -]; - -const nodeBuiltins = builtinModules.flatMap((moduleName) => [moduleName, `node:${moduleName}`]); - -const customNoExternal = new Set([ - ...Object.keys(packageJson.dependencies).filter( - (dep) => !externalPackages.some((externalPackage) => dep === externalPackage || dep.startsWith(externalPackage + "/")) - ), -]); - -function packageNameFromSpecifier(specifier: string) { - if (specifier.startsWith("@")) { - const parts = specifier.split("/"); - return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : specifier; - } - return specifier.split("/")[0] ?? specifier; -} - -function shouldBundleDependency(specifier: string) { - return customNoExternal.has(packageNameFromSpecifier(specifier)); -} - -const basePlugin: Rolldown.Plugin = createBasePlugin({}); -// 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 - && process.env.SENTRY_PROJECT != null - && process.env.SENTRY_AUTH_TOKEN != null; -const plugins = [ - basePlugin, - ...(shouldUploadSourcemaps ? [ - sentryRollupPlugin({ - org: process.env.SENTRY_ORG, - project: process.env.SENTRY_PROJECT, - authToken: process.env.SENTRY_AUTH_TOKEN, - release: { - name: sentryRelease, - }, - sourcemaps: { - assets: resolve(backendDir, "dist/**/*.map"), - }, - }), - ] : []), -]; - -export default defineConfig({ - entry: [ - resolve(backendDir, "src/server/server.ts"), - resolve(backendDir, "src/server/vercel.ts"), - ], - format: ["esm"], - outDir: resolve(backendDir, "dist"), - target: "node22", - platform: "node", - noExternal: shouldBundleDependency, - inlineOnly: false, - external: [...nodeBuiltins, ...externalPackages], - clean: true, - minify: false, - sourcemap: true, - alias: { - "@": resolve(backendDir, "src"), - }, - banner: { - js: `import { createRequire as __createRequire } from 'module'; -import { fileURLToPath as __fileURLToPath } from 'url'; -import { dirname as __dirname_fn } from 'path'; -const __filename = __fileURLToPath(import.meta.url); -const __dirname = __dirname_fn(__filename); -const require = __createRequire(import.meta.url);`, - }, - plugins, -} satisfies UserConfig); diff --git a/apps/backend/vercel.json b/apps/backend/vercel.json index 921bc8f0d..f40117b3e 100644 --- a/apps/backend/vercel.json +++ b/apps/backend/vercel.json @@ -1,9 +1,5 @@ { "$schema": "https://openapi.vercel.sh/vercel.json", - "framework": null, - "rewrites": [ - { "source": "/(.*)", "destination": "/api" } - ], "crons": [ { "path": "/api/latest/internal/email-queue-step", diff --git a/apps/backend/vitest.config.ts b/apps/backend/vitest.config.ts index ce18d6eef..a1759118e 100644 --- a/apps/backend/vitest.config.ts +++ b/apps/backend/vitest.config.ts @@ -17,7 +17,7 @@ export default mergeConfig( }, resolve: { alias: { - '@': resolve(__dirname, './src'), + '@': resolve(__dirname, './src') } }, envDir: __dirname, diff --git a/apps/e2e/tests/backend/endpoints/api/v1/index.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/index.test.ts index 9f636f92f..74ae165d4 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/index.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/index.test.ts @@ -10,6 +10,21 @@ describe("without project ID", () => { it("should load", async ({ expect }) => { const response = await niceBackendFetch("/api/v1"); + expect(response).toMatchInlineSnapshot(` + NiceResponse { + "status": 200, + "body": deindent\` + Welcome to the Hexclave API endpoint! Please refer to the documentation at https://docs.hexclave.com. + + Authentication: None + \`, + "headers": Headers {