diff --git a/.github/workflows/e2e-api-tests.yaml b/.github/workflows/e2e-api-tests.yaml index 5524855d1..74918d292 100644 --- a/.github/workflows/e2e-api-tests.yaml +++ b/.github/workflows/e2e-api-tests.yaml @@ -132,10 +132,13 @@ 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 run start:backend --log-order=stream & + run: pnpm -C apps/backend run with-env:test pnpm run start & 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 34836b66b..3018bff73 100644 --- a/.github/workflows/e2e-custom-base-port-api-tests.yaml +++ b/.github/workflows/e2e-custom-base-port-api-tests.yaml @@ -126,10 +126,15 @@ 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 run start:backend --log-order=stream & + run: pnpm -C apps/backend run with-env:test pnpm run start & wait-on: | http://localhost:6702 tail: true diff --git a/.github/workflows/e2e-fallback-tests.yaml b/.github/workflows/e2e-fallback-tests.yaml index 2e1aefdea..2f3adee92 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: pnpm -C apps/backend run with-env:test next start --port 8110 & + run: PORT=8110 pnpm -C apps/backend run with-env:test pnpm run start & 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 new file mode 100644 index 000000000..e697c91ec --- /dev/null +++ b/apps/backend/api/dist-vercel.d.ts @@ -0,0 +1,4 @@ +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 new file mode 100644 index 000000000..69025b3ef --- /dev/null +++ b/apps/backend/api/index.ts @@ -0,0 +1,10 @@ +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 e94648170..d6baf7ab3 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/nextjs/ +// https://docs.sentry.io/platforms/javascript/guides/browser/ -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 deleted file mode 100644 index d8b22a527..000000000 --- a/apps/backend/next.config.mjs +++ /dev/null @@ -1,99 +0,0 @@ -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 7f2a2aea4..61e9629ef 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 .next && rimraf node_modules", + "clean": "rimraf src/generated && rimraf dist && 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} 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": "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: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 && next build", - "docker-build": "pnpm run codegen && next build --experimental-build-mode compile", + "build": "pnpm run codegen && tsdown --config tsdown.config.ts", + "docker-build": "pnpm run codegen && tsdown --config tsdown.config.ts", "build-self-host-migration-script": "tsdown --config scripts/db-migrations.tsdown.config.ts", - "analyze-bundle": "next experimental-analyze", - "start": "next start --port ${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}02", + + "start": "node dist/server.mjs", "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,9 +59,12 @@ }, "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", @@ -73,6 +76,7 @@ "@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", @@ -81,14 +85,13 @@ "@prisma/client": "^7.0.0", "@prisma/extension-read-replicas": "^0.5.0", "@prisma/instrumentation": "^7.0.0", - "@sentry/nextjs": "^10.45.0", + "@sentry/browser": "^10.45.0", + "@sentry/node": "^10.45.0", + "@sentry/rollup-plugin": "^4.6.1", "@simplewebauthn/server": "^13.3.0", - "@hexclave/next": "workspace:*", - "@hexclave/shared": "workspace:*", - "@hexclave/shared-backend": "workspace:*", + "@sinclair/typebox": "^0.34.49", "@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", @@ -96,6 +99,7 @@ "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", @@ -112,6 +116,7 @@ "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 d4d72b50b..aa716be0e 100644 --- a/apps/backend/scripts/db-migrations.tsdown.config.ts +++ b/apps/backend/scripts/db-migrations.tsdown.config.ts @@ -55,7 +55,9 @@ export default defineConfig({ inlineOnly: false, // Externalize Node.js builtins so they're imported rather than shimmed external: [...nodeBuiltins, ...externalPackages], - clean: true, + // 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, // 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 d2373af4a..e805e557f 100644 --- a/apps/backend/scripts/generate-route-info.ts +++ b/apps/backend/scripts/generate-route-info.ts @@ -2,12 +2,48 @@ 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 00f72e69d..b908a560b 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 "next/headers"; -import { redirect } from "next/navigation"; +import { cookies } from "@/lib/runtime/headers"; +import { redirect } from "@/lib/runtime/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 2b86a0d78..b531c3b72 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 "next/headers"; -import { redirect } from "next/navigation"; +import { cookies } from "@/lib/runtime/headers"; +import { redirect } from "@/lib/runtime/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 8a1bcb920..1b037c85b 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,9 +3,8 @@ 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: NextRequest) { +export async function GET(request: Request) { 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 778b473db..33e3a3840 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,7 +4,6 @@ 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"; @@ -38,7 +37,7 @@ function sanitizeBody(raw: ArrayBuffer): Uint8Array { return new TextEncoder().encode(JSON.stringify(parsed)); } -async function proxyToOpenRouter(req: NextRequest, options: { params: Promise<{ path?: string[] }> }) { +async function proxyToOpenRouter(req: Request, options: { params: Promise<{ path?: string[] }> }) { const apiKey = getEnvVariable("STACK_OPENROUTER_API_KEY"); const params = await options.params; const subpath = params.path?.join("/") ?? ""; @@ -49,7 +48,7 @@ async function proxyToOpenRouter(req: NextRequest, options: { params: Promise<{ : undefined; if (apiKey === "FORWARD_TO_PRODUCTION") { - const targetUrl = `${PRODUCTION_AI_PROXY_BASE_URL}/${subpath}${req.nextUrl.search}`; + const targetUrl = `${PRODUCTION_AI_PROXY_BASE_URL}/${subpath}${new URL(req.url).search}`; const headers: Record = {}; if (contentType) { headers["Content-Type"] = contentType; @@ -70,7 +69,7 @@ async function proxyToOpenRouter(req: NextRequest, options: { params: Promise<{ }); } - const targetUrl = `${OPENROUTER_BASE_URL}/${subpath}${req.nextUrl.search}`; + const targetUrl = `${OPENROUTER_BASE_URL}/${subpath}${new URL(req.url).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 9ad9d95e5..eea7d9f79 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 "next/navigation"; +import { redirect } from "@/lib/runtime/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 f98be3891..cb0a34c57 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,7 +2,6 @@ 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"; @@ -27,7 +26,7 @@ function getOidcCallbackPromise() { return _oidcCallbackPromiseCache; } -const handler = handleApiRequest(async (req: NextRequest) => { +const handler = handleApiRequest(async (req: Request) => { 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 }); @@ -60,7 +59,7 @@ const handler = handleApiRequest(async (req: NextRequest) => { // 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 NextResponse(body, { + return new Response(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 903972eca..07edef725 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 "next/navigation"; +import { redirect } from "@/lib/runtime/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 4861a1f19..cfbbac587 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,7 +2,6 @@ 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"; @@ -27,7 +26,7 @@ function getOidcCallbackPromise() { return _oidcCallbackPromiseCache; } -const handler = handleApiRequest(async (req: NextRequest) => { +const handler = handleApiRequest(async (req: Request) => { 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 }); @@ -60,7 +59,7 @@ const handler = handleApiRequest(async (req: NextRequest) => { // 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 NextResponse(body, { + return new Response(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 6698d0f9c..cb749d6ad 100644 --- a/apps/backend/src/app/api/latest/internal/changelog/route.tsx +++ b/apps/backend/src/app/api/latest/internal/changelog/route.tsx @@ -5,8 +5,6 @@ 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 = { @@ -173,9 +171,6 @@ 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 facc0b919..5278a6046 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 "next/navigation"; +import { redirect } from "@/lib/runtime/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 deleted file mode 100644 index 34113203c..000000000 --- a/apps/backend/src/app/dev-stats/page.tsx +++ /dev/null @@ -1,1555 +0,0 @@ -"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 deleted file mode 100644 index 96f8773dd..000000000 --- a/apps/backend/src/app/global-error.tsx +++ /dev/null @@ -1,23 +0,0 @@ -"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 deleted file mode 100644 index da28678af..000000000 --- a/apps/backend/src/app/health/error-handler-debug/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -"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 100ead693..7767b7dec 100644 --- a/apps/backend/src/app/health/route.tsx +++ b/apps/backend/src/app/health/route.tsx @@ -1,9 +1,8 @@ import { globalPrismaClient } from "@/prisma-client"; import { HexclaveAssertionError } from "@hexclave/shared/dist/utils/errors"; -import { NextRequest } from "next/server"; -export async function GET(req: NextRequest) { - if (req.nextUrl.searchParams.get("db")) { +export async function GET(req: Request) { + if (new URL(req.url).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 deleted file mode 100644 index 07b56f5de..000000000 --- a/apps/backend/src/app/layout.tsx +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 9efba7713..000000000 --- a/apps/backend/src/app/not-found.tsx +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index 24ac5aed6..000000000 --- a/apps/backend/src/app/page.tsx +++ /dev/null @@ -1,22 +0,0 @@ -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 new file mode 100644 index 000000000..1bcbe098e --- /dev/null +++ b/apps/backend/src/index.ts @@ -0,0 +1,6 @@ +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 new file mode 100644 index 000000000..835967047 --- /dev/null +++ b/apps/backend/src/instrument.ts @@ -0,0 +1,82 @@ +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 deleted file mode 100644 index ebdbe788c..000000000 --- a/apps/backend/src/instrumentation.ts +++ /dev/null @@ -1,73 +0,0 @@ -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 f5adf1e96..8042f7805 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 "next/headers"; +import { headers } from "@/lib/runtime/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 new file mode 100644 index 000000000..193e1467c --- /dev/null +++ b/apps/backend/src/lib/runtime/headers.tsx @@ -0,0 +1,128 @@ +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 new file mode 100644 index 000000000..3347271d4 --- /dev/null +++ b/apps/backend/src/lib/runtime/navigation.tsx @@ -0,0 +1,44 @@ +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 new file mode 100644 index 000000000..9109eca68 --- /dev/null +++ b/apps/backend/src/lib/runtime/request-context.tsx @@ -0,0 +1,55 @@ +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 cdcaf77e1..57a3cc1e4 100644 --- a/apps/backend/src/polyfills.tsx +++ b/apps/backend/src/polyfills.tsx @@ -1,4 +1,4 @@ -import * as Sentry from "@sentry/nextjs"; +import * as Sentry from "@sentry/node"; 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 235f92d7a..850def0a8 100644 --- a/apps/backend/src/proxy.tsx +++ b/apps/backend/src/proxy.tsx @@ -5,8 +5,6 @@ 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; @@ -65,7 +63,7 @@ const corsAllowedRequestHeadersWithAliases = withHexclaveHeaderAliases(corsAllow const corsAllowedResponseHeadersWithAliases = withHexclaveHeaderAliases(corsAllowedResponseHeaders); // This function can be marked `async` if using `await` inside -export async function proxy(request: NextRequest) { +export async function proxy(request: Request) { const url = new URL(request.url); const delay = +getEnvVariable('STACK_ARTIFICIAL_DEVELOPMENT_DELAY_MS', '0'); if (delay) { @@ -98,7 +96,7 @@ export async function proxy(request: NextRequest) { const waitMs = Math.max(0, DEV_RATE_LIMIT_WINDOW_MS - (now - devRateLimitTimestamps[0])); const retryAfterSeconds = Math.max(1, Math.ceil(waitMs / 1000)); - const response = NextResponse.json({ + const response = Response.json({ message: 'Artificial development rate limit triggered. Wait before retrying.', }, { status: 429, @@ -173,9 +171,11 @@ export async function proxy(request: NextRequest) { } } - const newUrl = request.nextUrl.clone(); + const newUrl = new URL(request.url); newUrl.pathname = pathname; - return NextResponse.rewrite(newUrl, responseInit); + const rewriteHeaders = new Headers(responseInit?.headers); + rewriteHeaders.set("x-middleware-rewrite", newUrl.toString()); + return new Response(null, { ...responseInit, headers: rewriteHeaders }); } // 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 77ebbfb2f..f94fc1b99 100644 --- a/apps/backend/src/route-handlers/redirect-handler.tsx +++ b/apps/backend/src/route-handlers/redirect-handler.tsx @@ -1,10 +1,9 @@ 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: NextRequest, options: any) => Promise { +export function redirectHandler(redirectPath: string, statusCode: 303 | 307 | 308 = 307): (req: Request, 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 1c0497159..1726b1091 100644 --- a/apps/backend/src/route-handlers/smart-request.tsx +++ b/apps/backend/src/route-handlers/smart-request.tsx @@ -15,7 +15,6 @@ 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; @@ -70,7 +69,7 @@ export type MergeSmartRequest = ) ); -async function validate(obj: SmartRequest, schema: yup.Schema, req: NextRequest | null): Promise { +async function validate(obj: SmartRequest, schema: yup.Schema, req: Request | null): Promise { try { return await yupValidate(schema, obj, { abortEarly: false, @@ -101,7 +100,7 @@ async function validate(obj: SmartRequest, schema: yup.Schema, req: NextRe throw new KnownErrors.SchemaError( deindent` - Request validation failed on ${req.method} ${req.nextUrl.pathname}: + Request validation failed on ${req.method} ${new URL(req.url).pathname}: ${inners.map(e => deindent` - ${e.message} `).join("\n")} @@ -114,7 +113,7 @@ async function validate(obj: SmartRequest, schema: yup.Schema, req: NextRe } -async function parseBody(req: NextRequest, bodyBuffer: ArrayBuffer): Promise { +async function parseBody(req: Request, bodyBuffer: ArrayBuffer): Promise { const contentType = req.method === "GET" || req.method === "HEAD" ? undefined : req.headers.get("content-type")?.split(";")[0]; const getText = () => { @@ -158,7 +157,7 @@ async function parseBody(req: NextRequest, bodyBuffer: ArrayBuffer): Promise => { +const parseAuth = withTraceSpan('smart request parseAuth', async (req: Request): 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"); @@ -337,7 +336,7 @@ const parseAuth = withTraceSpan('smart request parseAuth', async (req: NextReque }; }); -export async function createSmartRequest(req: NextRequest, bodyBuffer: ArrayBuffer, options?: { params: Promise> }): Promise { +export async function createSmartRequest(req: Request, 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.]+)$/); @@ -363,6 +362,6 @@ export async function createSmartRequest(req: NextRequest, bodyBuffer: ArrayBuff }); } -export async function validateSmartRequest(nextReq: NextRequest | null, smartReq: SmartRequest, schema: yup.Schema): Promise { +export async function validateSmartRequest(nextReq: Request | 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 5a870951a..fce739494 100644 --- a/apps/backend/src/route-handlers/smart-response.tsx +++ b/apps/backend/src/route-handlers/smart-response.tsx @@ -3,7 +3,6 @@ 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"; @@ -42,7 +41,7 @@ export type SmartResponse = { } ); -export async function validateSmartResponse(req: NextRequest | null, smartReq: SmartRequest, obj: unknown, schema: yup.Schema): Promise { +export async function validateSmartResponse(req: Request | null, smartReq: SmartRequest, obj: unknown, schema: yup.Schema): Promise { try { return await yupValidate(schema, obj, { abortEarly: false, @@ -68,7 +67,7 @@ function isResponseBody(body: unknown): body is Response { return typeof body === "object" && body !== null && body instanceof Response; } -export async function createResponse(req: NextRequest | null, requestId: string, obj: T): Promise { +export async function createResponse(req: Request | 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 ed439fd99..c69436d15 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/nextjs"; +import * as Sentry from "@sentry/node"; 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,7 +9,6 @@ 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"; @@ -65,8 +64,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: NextRequest, options: any, requestId: string) => Promise): (req: NextRequest, options: any) => Promise { - return async (req: NextRequest, options: any) => { +export function handleApiRequest(handler: (req: Request, options: any, requestId: string) => Promise): (req: Request, options: any) => Promise { + return async (req: Request, options: any) => { concurrentRequestsInProcess++; try { const requestId = generateSecureRandomString(80); @@ -80,12 +79,13 @@ export function handleApiRequest(handler: (req: NextRequest, options: any, reque "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(req.nextUrl.searchParams), + query: Object.fromEntries(requestUrl.searchParams), headers: Object.fromEntries(req.headers), }); @@ -121,11 +121,11 @@ export function handleApiRequest(handler: (req: NextRequest, options: any, reque ...allowedLongRequestPaths, ...allowedLongRequestPaths.map(path => path.replace(/^\/api\/latest\//, "/api/v1/")), ]; - const warnAfterSeconds = allAllowedLongRequestPaths.includes(req.nextUrl.pathname) ? 240 : 12; + const warnAfterSeconds = allAllowedLongRequestPaths.includes(requestUrl.pathname) ? 240 : 12; runAsynchronously(async () => { await wait(warnAfterSeconds * 1000); if (!hasRequestFinished) { - 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.`)); + 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.`)); } }); @@ -135,10 +135,10 @@ export function handleApiRequest(handler: (req: NextRequest, options: any, reque const time = (performance.now() - timeStart); // Record request stats for dev-stats page - recordRequestStats(req.method, req.nextUrl.pathname, time); + recordRequestStats(req.method, requestUrl.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: req.nextUrl, 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: requestUrl, 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: NextRequest, options: any) => Promise) & { +> = ((req: Request, 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: NextRequest | null, requestId: string, smartRequest: SmartRequest, shouldSetContext: boolean = false) => { + const invoke = async (nextRequest: Request | 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 new file mode 100644 index 000000000..ea9e13dd2 --- /dev/null +++ b/apps/backend/src/server/app.ts @@ -0,0 +1,314 @@ +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 { createBackendRequest } from "./backend-request"; +import { handleUncaughtBackendError } from "./error-handler"; +import { runRequestPipeline } from "./middleware"; +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`); + }) + .onError(({ error }) => withGlobalHeaders(handleUncaughtBackendError(error))) + .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 new file mode 100644 index 000000000..45274b803 --- /dev/null +++ b/apps/backend/src/server/backend-request.ts @@ -0,0 +1,17 @@ +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 new file mode 100644 index 000000000..803246607 --- /dev/null +++ b/apps/backend/src/server/env-expand.ts @@ -0,0 +1,36 @@ +/* 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/error-handler.ts b/apps/backend/src/server/error-handler.ts new file mode 100644 index 000000000..fa7caf051 --- /dev/null +++ b/apps/backend/src/server/error-handler.ts @@ -0,0 +1,31 @@ +import { captureError } from "@hexclave/shared/dist/utils/errors"; + +function createUncaughtErrorResponse() { + return new Response("Internal Server Error", { + status: 500, + headers: { + "content-type": "text/plain; charset=utf-8", + }, + }); +} + +export function handleUncaughtBackendError(error: unknown) { + captureError("backend-global-error", error); + return createUncaughtErrorResponse(); +} + +import.meta.vitest?.test("uncaught backend errors do not expose internal details", async ({ expect }) => { + const response = createUncaughtErrorResponse(); + + expect({ + status: response.status, + contentType: response.headers.get("content-type"), + body: await response.text(), + }).toMatchInlineSnapshot(` + { + "body": "Internal Server Error", + "contentType": "text/plain; charset=utf-8", + "status": 500, + } + `); +}); diff --git a/apps/backend/src/server/middleware.ts b/apps/backend/src/server/middleware.ts new file mode 100644 index 000000000..a769f05c2 --- /dev/null +++ b/apps/backend/src/server/middleware.ts @@ -0,0 +1,244 @@ +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 new file mode 100644 index 000000000..09da49d4a --- /dev/null +++ b/apps/backend/src/server/registry.ts @@ -0,0 +1,126 @@ +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 new file mode 100644 index 000000000..4906255ec --- /dev/null +++ b/apps/backend/src/server/server.ts @@ -0,0 +1,21 @@ +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 new file mode 100644 index 000000000..905ee2b2c --- /dev/null +++ b/apps/backend/src/server/vercel.ts @@ -0,0 +1,5 @@ +import "@/instrument"; +import "@/polyfills"; +import { app } from "./app"; + +export default app; diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index 7fff7e1d1..fef42b3ca 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -6,7 +6,7 @@ "dom.iterable", "esnext" ], - "allowJs": true, + "allowJs": false, "strict": true, "noEmit": true, "esModuleInterop": true, @@ -17,11 +17,6 @@ "jsx": "react-jsx", "incremental": true, "noErrorTruncation": true, - "plugins": [ - { - "name": "next" - } - ], "paths": { "@/*": [ "./src/*" @@ -33,16 +28,15 @@ ] }, "include": [ - "next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.?ts", - "**/*.?tsx", - ".next/types/**/*.ts", - ".next/dev/types/**/*.ts" + "**/*.?tsx" ], "exclude": [ + "api", "node_modules", - ".next/dev" + ".next/dev", + "dist" ] } diff --git a/apps/backend/tsdown.config.ts b/apps/backend/tsdown.config.ts new file mode 100644 index 000000000..07844d33a --- /dev/null +++ b/apps/backend/tsdown.config.ts @@ -0,0 +1,110 @@ +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 f40117b3e..921bc8f0d 100644 --- a/apps/backend/vercel.json +++ b/apps/backend/vercel.json @@ -1,5 +1,9 @@ { "$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 a1759118e..ce18d6eef 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/docker/backend/Dockerfile b/docker/backend/Dockerfile index 53eb395a0..1b5f2dfc2 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -52,11 +52,9 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile COPY --from=pruner /app/out/full/ . -# Docs are required for the NextJS backend build +# Docs are required for backend OpenAPI/codegen COPY docs ./docs -ENV NEXT_CONFIG_OUTPUT=standalone - # Build backend only RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm turbo run docker-build --filter=@hexclave/backend... @@ -71,10 +69,14 @@ RUN apt-get update && \ apt-get install -y --no-install-recommends openssl && \ rm -rf /var/lib/apt/lists/* -# Copy Next.js standalone output — this includes a traced, minimal copy of -# node_modules/ and packages/ (only the files the server actually imports). -COPY --from=builder --chown=node:node /app/apps/backend/.next/standalone ./ -COPY --from=builder --chown=node:node /app/apps/backend/.next/static ./apps/backend/.next/static +# Copy the bundled Elysia server plus the pruned workspace/runtime deps that +# tsdown externalizes for native packages, Prisma, OpenTelemetry, and SDK calls. +COPY --from=builder --chown=node:node /app/node_modules ./node_modules +COPY --from=builder --chown=node:node /app/package.json ./package.json +COPY --from=builder --chown=node:node /app/pnpm-workspace.yaml ./pnpm-workspace.yaml +COPY --from=builder --chown=node:node /app/apps/backend ./apps/backend +COPY --from=builder --chown=node:node /app/packages ./packages +COPY --from=builder --chown=node:node /app/configs ./configs # Prisma schema (needed at runtime by Prisma client) COPY --from=builder --chown=node:node /app/apps/backend/prisma ./apps/backend/prisma @@ -87,4 +89,4 @@ USER node EXPOSE 8102 -CMD ["node", "apps/backend/server.js"] +CMD ["node", "apps/backend/dist/server.mjs"] diff --git a/docker/server/Dockerfile b/docker/server/Dockerfile index 70c1d5a91..83f886895 100644 --- a/docker/server/Dockerfile +++ b/docker/server/Dockerfile @@ -49,13 +49,13 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile # copy over the rest of the code for the build COPY --from=pruner /app/out/full/ . -# docs are currently required for the NextJS backend build, but won't exist in the final image +# Docs are required for backend OpenAPI/codegen COPY docs ./docs -# https://nextjs.org/docs/pages/api-reference/next-config-js/output +# Dashboard still needs standalone output for its Next.js build ENV NEXT_CONFIG_OUTPUT=standalone -# Build the backend NextJS app +# Build the backend and dashboard RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm turbo run docker-build --filter=@hexclave/backend... --filter=@hexclave/dashboard... # Build the self-host seed script @@ -74,14 +74,7 @@ RUN apt-get update && \ apt-get install -y openssl postgresql-client socat && \ rm -rf /var/lib/apt/lists -# Copy built backend -COPY --from=builder --chown=node:node /app/apps/backend/.next/standalone ./ -COPY --from=builder --chown=node:node /app/apps/backend/.next/static ./apps/backend/.next/static -COPY --from=builder --chown=node:node /app/apps/backend/prisma ./apps/backend/prisma -COPY --from=builder --chown=node:node /app/apps/backend/dist ./apps/backend/dist -COPY --from=builder --chown=node:node /app/apps/backend/node_modules ./apps/backend/node_modules - -# Copy built dashboard +# Copy built dashboard (standalone includes monorepo apps/ structure) COPY --from=builder --chown=node:node /app/apps/dashboard/.next/standalone ./ COPY --from=builder --chown=node:node /app/apps/dashboard/.next/static ./apps/dashboard/.next/static COPY --from=builder --chown=node:node /app/apps/dashboard/public ./apps/dashboard/public @@ -90,6 +83,15 @@ COPY --from=builder --chown=node:node /app/apps/dashboard/public ./apps/dashboar COPY --from=builder --chown=node:node /app/node_modules ./node_modules COPY --from=builder --chown=node:node /app/packages ./packages +# Copy the bundled Elysia backend LAST — the dashboard standalone output includes +# a traced subset of apps/backend/ via pnpm workspace symlinks, and Docker COPY +# creates opaque directory layers that hide files from earlier layers. By copying +# the real backend dist after everything else, its files are guaranteed visible. +COPY --from=builder --chown=node:node /app/apps/backend/dist ./apps/backend/dist +COPY --from=builder --chown=node:node /app/apps/backend/prisma ./apps/backend/prisma +COPY --from=builder --chown=node:node /app/apps/backend/node_modules ./apps/backend/node_modules +COPY --from=builder --chown=node:node /app/apps/backend/package.json ./apps/backend/package.json + # Add the entrypoint script COPY ./docker/server/entrypoint.sh . RUN chmod +x entrypoint.sh diff --git a/docker/server/entrypoint.sh b/docker/server/entrypoint.sh index 44ae419f9..563881451 100644 --- a/docker/server/entrypoint.sh +++ b/docker/server/entrypoint.sh @@ -204,7 +204,7 @@ fi echo "Starting backend on port $BACKEND_PORT..." cd "$WORK_DIR" -PORT=$BACKEND_PORT HOSTNAME=0.0.0.0 node apps/backend/server.js & +PORT=$BACKEND_PORT HOSTNAME=0.0.0.0 node apps/backend/dist/server.mjs & echo "Starting dashboard on port $DASHBOARD_PORT..." PORT=$DASHBOARD_PORT HOSTNAME=0.0.0.0 node apps/dashboard/server.js & diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b17ce71b7..071f3c523 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -114,6 +114,9 @@ importers: '@clickhouse/client': specifier: ^1.14.0 version: 1.16.0 + '@elysiajs/node': + specifier: ^1.4.5 + version: 1.4.5(elysia@1.4.28(@sinclair/typebox@0.34.49)(exact-mirror@1.1.1)(file-type@22.0.1)(openapi-types@12.1.3)(typescript@6.0.3)) '@hexclave/next': specifier: workspace:* version: link:../../packages/next @@ -156,6 +159,9 @@ importers: '@opentelemetry/sdk-logs': specifier: ^0.53.0 version: 0.53.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-node': + specifier: 0.53.0 + version: 0.53.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': specifier: ^1.26.0 version: 1.26.0(@opentelemetry/api@1.9.0) @@ -180,21 +186,27 @@ importers: '@prisma/instrumentation': specifier: ^7.0.0 version: 7.1.0(@opentelemetry/api@1.9.0) - '@sentry/nextjs': + '@sentry/browser': specifier: ^10.45.0 - version: 10.45.0(@opentelemetry/context-async-hooks@1.26.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.26.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.26.0(@opentelemetry/api@1.9.0))(next@16.2.9(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.92.0(esbuild@0.24.2)) + version: 10.45.0 + '@sentry/node': + specifier: ^10.45.0 + version: 10.45.0 + '@sentry/rollup-plugin': + specifier: ^4.6.1 + version: 4.9.1(rollup@4.57.1) '@simplewebauthn/server': specifier: ^13.3.0 version: 13.3.0 + '@sinclair/typebox': + specifier: ^0.34.49 + version: 0.34.49 '@upstash/qstash': specifier: ^2.8.2 version: 2.8.2 '@vercel/functions': specifier: ^2.0.0 version: 2.0.0(@aws-sdk/credential-provider-web-identity@3.972.27) - '@vercel/otel': - specifier: ^1.10.4 - version: 1.10.4(@opentelemetry/api-logs@0.53.0)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.53.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@1.26.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-logs@0.53.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-metrics@2.8.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.26.0(@opentelemetry/api@1.9.0)) '@vercel/sandbox': specifier: ^1.2.0 version: 1.2.0 @@ -216,6 +228,9 @@ importers: dotenv-cli: specifier: ^7.3.0 version: 7.4.1 + elysia: + specifier: 1.4.28 + version: 1.4.28(@sinclair/typebox@0.34.49)(exact-mirror@1.1.1)(file-type@22.0.1)(openapi-types@12.1.3)(typescript@6.0.3) emailable: specifier: ^3.1.1 version: 3.1.1 @@ -1658,10 +1673,10 @@ importers: version: link:../../packages/next '@supabase/ssr': specifier: latest - version: 0.12.0(@supabase/supabase-js@2.108.2) + version: 0.12.0(@supabase/supabase-js@2.110.0) '@supabase/supabase-js': specifier: latest - version: 2.108.2 + version: 2.110.0 jose: specifier: ^5.2.2 version: 5.6.3 @@ -5851,6 +5866,12 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-logs-otlp-grpc@0.53.0': + resolution: {integrity: sha512-x5ygAQgWAQOI+UOhyV3z9eW7QU2dCfnfOuIBiyYmC2AWr74f6x/3JBnP27IAcEx6aihpqBYWKnpoUTztkVPAZw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + '@opentelemetry/exporter-logs-otlp-http@0.200.0': resolution: {integrity: sha512-KfWw49htbGGp9s8N4KI8EQ9XuqKJ0VG+yVYVYFiCYSjEV32qpQ5qZ9UZBzOZ6xRb+E16SXOSCT3RkqBVSABZ+g==} engines: {node: ^18.19.0 || >=20.6.0} @@ -5869,6 +5890,12 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-logs-otlp-http@0.53.0': + resolution: {integrity: sha512-cSRKgD/n8rb+Yd+Cif6EnHEL/VZg1o8lEcEwFji1lwene6BdH51Zh3feAD9p2TyVoBKrl6Q9Zm2WltSp2k9gWQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + '@opentelemetry/exporter-logs-otlp-proto@0.200.0': resolution: {integrity: sha512-GmahpUU/55hxfH4TP77ChOfftADsCq/nuri73I/AVLe2s4NIglvTsaACkFVZAVmnXXyPS00Fk3x27WS3yO07zA==} engines: {node: ^18.19.0 || >=20.6.0} @@ -5881,6 +5908,12 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-logs-otlp-proto@0.53.0': + resolution: {integrity: sha512-jhEcVL1deeWNmTUP05UZMriZPSWUBcfg94ng7JuBb1q2NExgnADQFl1VQQ+xo62/JepK+MxQe4xAwlsDQFbISA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + '@opentelemetry/exporter-metrics-otlp-grpc@0.200.0': resolution: {integrity: sha512-uHawPRvKIrhqH09GloTuYeq2BjyieYHIpiklOvxm9zhrCL2eRsnI/6g9v2BZTVtGp8tEgIa7rCQ6Ltxw6NBgew==} engines: {node: ^18.19.0 || >=20.6.0} @@ -5941,6 +5974,12 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-trace-otlp-grpc@0.53.0': + resolution: {integrity: sha512-m6KSh6OBDwfDjpzPVbuJbMgMbkoZfpxYH2r262KckgX9cMYvooWXEKzlJYsNDC6ADr28A1rtRoUVRwNfIN4tUg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + '@opentelemetry/exporter-trace-otlp-http@0.200.0': resolution: {integrity: sha512-Goi//m/7ZHeUedxTGVmEzH19NgqJY+Bzr6zXo1Rni1+hwqaksEyJ44gdlEMREu6dzX1DlAaH/qSykSVzdrdafA==} engines: {node: ^18.19.0 || >=20.6.0} @@ -5971,6 +6010,18 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-trace-otlp-proto@0.53.0': + resolution: {integrity: sha512-T/bdXslwRKj23S96qbvGtaYOdfyew3TjPEKOk5mHjkCmkVl1O9C/YMdejwSsdLdOq2YW30KjR9kVi0YMxZushQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/exporter-zipkin@1.26.0': + resolution: {integrity: sha512-PW5R34n3SJHO4t0UetyHKiXL6LixIqWN6lWncg3eRXhKuT30x+b7m5sDJS0kEWRfHeS+kG7uCw2vBzmB2lk3Dw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + '@opentelemetry/exporter-zipkin@2.0.0': resolution: {integrity: sha512-icxaKZ+jZL/NHXX8Aru4HGsrdhK0MLcuRXkX5G5IRmCgoRLw+Br6I/nMVozX2xjGGwV7hw2g+4Slj8K7s4HbVg==} engines: {node: ^18.19.0 || >=20.6.0} @@ -6440,6 +6491,12 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/otlp-grpc-exporter-base@0.53.0': + resolution: {integrity: sha512-F7RCN8VN+lzSa4fGjewit8Z5fEUpY/lmMVy5EWn2ZpbAabg3EE3sCLuTNfOiooNGnmvzimUPruoeqeko/5/TzQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + '@opentelemetry/otlp-transformer@0.200.0': resolution: {integrity: sha512-+9YDZbYybOnv7sWzebWOeK6gKyt2XE7iarSyBFkwwnP559pEevKOUD8NyDHhRjCSp13ybh9iVXlMfcj/DwF/yw==} engines: {node: ^18.19.0 || >=20.6.0} @@ -6618,12 +6675,6 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.9.0 <1.10.0' - '@opentelemetry/sdk-metrics@2.8.0': - resolution: {integrity: sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.9.0 <1.10.0' - '@opentelemetry/sdk-node@0.200.0': resolution: {integrity: sha512-S/YSy9GIswnhYoDor1RusNkmRughipvTCOQrlF1dzI70yQaf68qgf5WMnzUxdlCl3/et/pvaO75xfPfuEmCK5A==} engines: {node: ^18.19.0 || >=20.6.0} @@ -6636,6 +6687,12 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/sdk-node@0.53.0': + resolution: {integrity: sha512-0hsxfq3BKy05xGktwG8YdGdxV978++x40EAKyKr1CaHZRh8uqVlXnclnl7OMi9xLMJEcXUw7lGhiRlArFcovyg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/sdk-trace-base@1.26.0': resolution: {integrity: sha512-olWQldtvbK4v22ymrKLbIcBi9L2SpMO84sCPY54IVsJhP9fRsxJT194C/AVaAuJzLE30EdhhM1VmvVYR7az+cw==} engines: {node: '>=14'} @@ -8388,9 +8445,9 @@ packages: resolution: {integrity: sha512-OuxqBprXRyhe8Pkfyz/4yHQJc5c3lm+TmYWSSx8u48g5yKewSQDOxkiLU5pAk3WnbLPy8XwU/PN+2BG0YFU9Nw==} engines: {node: '>= 14'} - '@sentry/babel-plugin-component-annotate@5.1.1': - resolution: {integrity: sha512-x2wEpBHwsTyTF2rWsLKJlzrRF1TTIGOfX+ngdE+Yd5DBkoS58HwQv824QOviPGQRla4/ypISqAXzjdDPL/zalg==} - engines: {node: '>= 18'} + '@sentry/babel-plugin-component-annotate@4.9.1': + resolution: {integrity: sha512-0gEoi2Lb54MFYPOmdTfxlNKxI7kCOvNV7gP8lxMXJ7nCazF5OqOOZIVshfWjDLrc0QrSV6XdVvwPV9GDn4wBMg==} + engines: {node: '>= 14'} '@sentry/browser@10.11.0': resolution: {integrity: sha512-qemaKCJKJHHCyGBpdLq23xL5u9Xvir20XN7YFTnHcEq4Jvj0GoWsslxKi5cQB2JvpYn62WxTiDgVLeQlleZhSg==} @@ -8404,9 +8461,9 @@ packages: resolution: {integrity: sha512-dmR4DJhJ4jqVWGWppuTL2blNFqOZZnt4aLkewbD1myFG3KVfUx8CrMQWEmGjkgPOtj5TO6xH9PyTJjXC6o5tnA==} engines: {node: '>= 14'} - '@sentry/bundler-plugin-core@5.1.1': - resolution: {integrity: sha512-F+itpwR9DyQR7gEkrXd2tigREPTvtF5lC8qu6e4anxXYRTui1+dVR0fXNwjpyAZMhIesLfXRN7WY7ggdj7hi0Q==} - engines: {node: '>= 18'} + '@sentry/bundler-plugin-core@4.9.1': + resolution: {integrity: sha512-moii+w7N8k8WdvkX7qCDY9iRBlhgHlhTHTUQwF2FNMhBHuqlNpVcSJJqJMjFUQcjYMBDrZgxhfKV18bt5ixwlQ==} + engines: {node: '>= 14'} '@sentry/cli-darwin@2.53.0': resolution: {integrity: sha512-NNPfpILMwKgpHiyJubHHuauMKltkrgLQ5tvMdxNpxY60jBNdo5VJtpESp4XmXlnidzV4j1z61V4ozU6ttDgt5Q==} @@ -8526,12 +8583,6 @@ packages: peerDependencies: next: ^13.2.0 || ^14.0 || ^15.0.0-rc.0 - '@sentry/nextjs@10.45.0': - resolution: {integrity: sha512-4LE+UvnfdOYyG8YEb/9TWaJQzMPuGLlph/iqowvsMdxaW6la+mvADiuzNTXly4QfsjeD3KIb7dKlGTqiVV0Ttw==} - engines: {node: '>=18'} - peerDependencies: - next: ^13.2.0 || ^14.0 || ^15.0.0-rc.0 || ^16.0.0-0 - '@sentry/node-core@10.11.0': resolution: {integrity: sha512-dkVZ06F+W5W0CsD47ATTTOTTocmccT/ezrF9idspQq+HVOcjoKSU60WpWo22NjtVNdSYKLnom0q1LKRoaRA/Ww==} engines: {node: '>=18'} @@ -8605,32 +8656,22 @@ packages: peerDependencies: react: ^16.14.0 || 17.x || 18.x || 19.x - '@sentry/react@10.45.0': - resolution: {integrity: sha512-jLezuxi4BUIU3raKyAPR5xMbQG/nhwnWmKo5p11NCbLmWzkS+lxoyDTUB4B8TAKZLfdtdkKLOn1S0tFc8vbUHw==} - engines: {node: '>=18'} + '@sentry/rollup-plugin@4.9.1': + resolution: {integrity: sha512-cjr/S4I9Xj4481K0o47PuteGgqFBs3hRRPOFs1Vz1Jql6VeCm5+ghbtsLGQMwB2lCOpWcYggdc03JK67UJT4ZQ==} + engines: {node: '>= 14'} peerDependencies: - react: ^16.14.0 || 17.x || 18.x || 19.x + rollup: '>=3.2.0' '@sentry/vercel-edge@10.11.0': resolution: {integrity: sha512-jAsJ8RbbF2JWj2wnXfd6BwWxCR6GBITMtlaoWc7pG22HknEtoH15dKsQC3Ew5r/KRcofr2e+ywdnBn5CPr1Pbg==} engines: {node: '>=18'} - '@sentry/vercel-edge@10.45.0': - resolution: {integrity: sha512-sSF+Ex5NwT60gMinLcP/JNZb3cDaIv0mL1cRjfvN6zN2ZNEw0C9rhdgxa0EdD4G6PCHQ0XnCuAMDsfJ6gnRmfA==} - engines: {node: '>=18'} - '@sentry/webpack-plugin@4.3.0': resolution: {integrity: sha512-K4nU1SheK/tvyakBws2zfd+MN6hzmpW+wPTbSbDWn1+WL9+g9hsPh8hjFFiVe47AhhUoUZ3YgiH2HyeHXjHflA==} engines: {node: '>= 14'} peerDependencies: webpack: '>=4.40.0' - '@sentry/webpack-plugin@5.1.1': - resolution: {integrity: sha512-XgQg+t2aVrlQDfIiAEizqR/bsy6GtBygwgR+Kw11P/cYczj4W9PZ2IYqQEStBzHqnRTh5DbpyMcUNW2CujdA9A==} - engines: {node: '>= 18'} - peerDependencies: - webpack: '>=5.0.0' - '@shikijs/core@3.14.0': resolution: {integrity: sha512-qRSeuP5vlYHCNUIrpEBQFO7vSkR7jn7Kv+5X3FO/zBKVDGQbcnlScD3XhkrHi/R8Ltz0kEjvFR9Szp/XMRbFMw==} @@ -9188,37 +9229,37 @@ packages: resolution: {integrity: sha512-SXuhqhuR5FXaYgKTXzZJeqtVA6JKb9IZWaGeEUxHHiOcFy2p51wccO72bYpXwoK4D5pzQOIYLTuAc7etxyMmwg==} engines: {node: '>=12.16'} - '@supabase/auth-js@2.108.2': - resolution: {integrity: sha512-tNaQmBgodDZwgB40mRwVbxFy8IDYwjdpcZ0BYrWiwlULCSQoJj4QoG4zgJT7QRPXcqipefNOzvO/qAu4dF98ag==} - engines: {node: '>=20.0.0'} + '@supabase/auth-js@2.110.0': + resolution: {integrity: sha512-Mi288WCTp6wxMFCOu/UgzgHEXODjdl2uVTLqK11eanzGZaldU3RyP8Am+ZbNuVzFP+5+iOvppxzv7N5Ym84xTg==} + engines: {node: '>=22.0.0'} - '@supabase/functions-js@2.108.2': - resolution: {integrity: sha512-RNUX8EiBy3iLwAX19jtRzLyePnl11/fHcgwDHLnpKcDSXt/5qBnh3LUwAtIjT21Q66QsmNUR2esrHziLCpNubw==} - engines: {node: '>=20.0.0'} + '@supabase/functions-js@2.110.0': + resolution: {integrity: sha512-Fde5wlY8ZZy+9yqrWlQHo8MacSyUBArBEtN2boB4thJQigPnQD/cc61qZN0n3I1L0gwhWtHYwIMnOBKxSvF6Hw==} + engines: {node: '>=22.0.0'} - '@supabase/phoenix@0.4.2': - resolution: {integrity: sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==} + '@supabase/phoenix@0.4.4': + resolution: {integrity: sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==} - '@supabase/postgrest-js@2.108.2': - resolution: {integrity: sha512-GQ28/Y8hk3CFmkb3kXH1h/AQx6JIYSQfO0CJMRVBcEKZoNy6C45cXAZ4fcJvRC5Id0cs6xnkUV0+c0rIocigsw==} - engines: {node: '>=20.0.0'} + '@supabase/postgrest-js@2.110.0': + resolution: {integrity: sha512-ZbC1QZL3jcvBUfVKjJbgRM27G4Mg3Zzqdm44m5pJafe1e52Cli793EOnwQucomBAGEUDd03Nzaf7XV3ji/XexQ==} + engines: {node: '>=22.0.0'} - '@supabase/realtime-js@2.108.2': - resolution: {integrity: sha512-aAGxCSUemZvQIibnCdvNvgaKib28I4rfrNjKbQ9cG1uBLwUsI7hVpGXgEbypCCDhLjQlDTAiJlu7rgljYUT73g==} - engines: {node: '>=20.0.0'} + '@supabase/realtime-js@2.110.0': + resolution: {integrity: sha512-Wn2AWpneZuDFTkp/65tqctvoh+3JvyTjMam8sTMqVWy5BgkU8zAvFwilPYPPPhkINeKF8NAJKP7FclJ2iGCUMw==} + engines: {node: '>=22.0.0'} '@supabase/ssr@0.12.0': resolution: {integrity: sha512-d9XV5XzJvzzZbeAIM7fWTCUYxQJZ2Ru6ny3dJHmHGp/LIrJ+o9FpD7N9Rf/UhhWEvHXSoDe8SI32Z2ouOdMjBg==} peerDependencies: '@supabase/supabase-js': ^2.108.0 - '@supabase/storage-js@2.108.2': - resolution: {integrity: sha512-TVZPQxXGxY2+A6yTtm77zUHsh70lBhYUEaJL8RQC+BghcX/ygiMG/rmXrNVBce30/WAeNPa8FiG8HbqlGeV05g==} - engines: {node: '>=20.0.0'} + '@supabase/storage-js@2.110.0': + resolution: {integrity: sha512-71+gU3HrhiylAhftY6FmO5PPdcsScnVcS766CVD+vTYK9qTDLbrx8FhgBYbqGm3iV/wkTfzrNJfjGsMeFRkJRQ==} + engines: {node: '>=22.0.0'} - '@supabase/supabase-js@2.108.2': - resolution: {integrity: sha512-hFhnPveb5JQg4a0QYicM0swT253YHMdfeRAl2BKHOlI5VAzuHxUGSr8RbwNLYNPauWOgQMS1H8sz8bvYlgwUfQ==} - engines: {node: '>=20.0.0'} + '@supabase/supabase-js@2.110.0': + resolution: {integrity: sha512-8yI84VJiEVW4zxZpLUmxXmjzQ7O2St9X/ymzlBETDHTURPWG3LmvbSiibq+7dqAJmyoUfxZnSfXeM4HCM8s4XQ==} + engines: {node: '>=22.0.0'} '@swc/counter@0.1.3': resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} @@ -10142,18 +10183,6 @@ packages: resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} engines: {node: '>= 20'} - '@vercel/otel@1.10.4': - resolution: {integrity: sha512-X01cOLU2Aiku4y1Vn9HQ8VmkQEdIQiWhjHa7DEUspcNGNg0Xm7GYAU7ErZ0pH/5spsrvb2wpIIHSBFgs0M5p4g==} - engines: {node: '>=18'} - peerDependencies: - '@opentelemetry/api': ^1.7.0 - '@opentelemetry/api-logs': '>=0.46.0 && <1.0.0' - '@opentelemetry/instrumentation': '>=0.46.0 && <1.0.0' - '@opentelemetry/resources': ^1.19.0 - '@opentelemetry/sdk-logs': '>=0.46.0 && <1.0.0' - '@opentelemetry/sdk-metrics': ^1.19.0 - '@opentelemetry/sdk-trace-base': ^1.19.0 - '@vercel/sandbox@1.2.0': resolution: {integrity: sha512-oq0d2xQuiDq8LyoZOZW+i+QmNkMQ0/ddEGHMukBsF+cxX7ohBEXOV/a0+SopBwEsKCjq9j55QFP56G3MU/iEjA==} @@ -13015,6 +13044,11 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + glob@13.0.6: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} @@ -20366,6 +20400,12 @@ snapshots: elysia: 1.4.28(@sinclair/typebox@0.34.49)(exact-mirror@1.1.1)(file-type@22.0.1)(openapi-types@12.1.3)(typescript@5.9.3) srvx: 0.11.15 + '@elysiajs/node@1.4.5(elysia@1.4.28(@sinclair/typebox@0.34.49)(exact-mirror@1.1.1)(file-type@22.0.1)(openapi-types@12.1.3)(typescript@6.0.3))': + dependencies: + crossws: 0.4.4(srvx@0.11.15) + elysia: 1.4.28(@sinclair/typebox@0.34.49)(exact-mirror@1.1.1)(file-type@22.0.1)(openapi-types@12.1.3)(typescript@6.0.3) + srvx: 0.11.15 + '@emnapi/core@1.11.1': dependencies: '@emnapi/wasi-threads': 1.2.2 @@ -22528,6 +22568,15 @@ snapshots: '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-grpc@0.53.0(@opentelemetry/api@1.9.0)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.26.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.53.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.53.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.53.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-http@0.200.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -22555,6 +22604,15 @@ snapshots: '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-http@0.53.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.53.0 + '@opentelemetry/core': 1.26.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.53.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.53.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.53.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-proto@0.200.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -22577,6 +22635,17 @@ snapshots: '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-proto@0.53.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.53.0 + '@opentelemetry/core': 1.26.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.53.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.53.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.26.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.53.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 1.26.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-grpc@0.200.0(@opentelemetry/api@1.9.0)': dependencies: '@grpc/grpc-js': 1.14.3 @@ -22676,6 +22745,16 @@ snapshots: '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-grpc@0.53.0(@opentelemetry/api@1.9.0)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.26.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.53.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.53.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.26.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 1.26.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-http@0.200.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -22721,6 +22800,23 @@ snapshots: '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-proto@0.53.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.26.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.53.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.53.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.26.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 1.26.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-zipkin@1.26.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.26.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.26.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 1.26.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.27.0 + '@opentelemetry/exporter-zipkin@2.0.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -23384,6 +23480,14 @@ snapshots: '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base@0.53.0(@opentelemetry/api@1.9.0)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 1.26.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.53.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.53.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer@0.200.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -23584,12 +23688,6 @@ snapshots: '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics@2.8.0(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-node@0.200.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -23648,6 +23746,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@opentelemetry/sdk-node@0.53.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.53.0 + '@opentelemetry/core': 1.26.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-grpc': 0.53.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-http': 0.53.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-proto': 0.53.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-grpc': 0.53.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-http': 0.53.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-proto': 0.53.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-zipkin': 1.26.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.53.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 1.26.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.53.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 1.26.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 1.26.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-node': 1.26.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.27.0 + transitivePeerDependencies: + - supports-color + '@opentelemetry/sdk-trace-base@1.26.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -26254,18 +26374,6 @@ snapshots: optionalDependencies: rollup: 4.50.1 - '@rollup/plugin-commonjs@28.0.1(rollup@4.57.1)': - dependencies: - '@rollup/pluginutils': 5.1.0(rollup@4.57.1) - commondir: 1.0.1 - estree-walker: 2.0.2 - fdir: 6.5.0(picomatch@4.0.3) - is-reference: 1.2.1 - magic-string: 0.30.17 - picomatch: 4.0.3 - optionalDependencies: - rollup: 4.57.1 - '@rollup/pluginutils@5.1.0(rollup@4.50.1)': dependencies: '@types/estree': 1.0.8 @@ -26274,14 +26382,6 @@ snapshots: optionalDependencies: rollup: 4.50.1 - '@rollup/pluginutils@5.1.0(rollup@4.57.1)': - dependencies: - '@types/estree': 1.0.8 - estree-walker: 2.0.2 - picomatch: 2.3.1 - optionalDependencies: - rollup: 4.57.1 - '@rollup/rollup-android-arm-eabi@4.34.8': optional: true @@ -26530,7 +26630,7 @@ snapshots: '@sentry/babel-plugin-component-annotate@4.3.0': {} - '@sentry/babel-plugin-component-annotate@5.1.1': {} + '@sentry/babel-plugin-component-annotate@4.9.1': {} '@sentry/browser@10.11.0': dependencies: @@ -26562,15 +26662,16 @@ snapshots: - encoding - supports-color - '@sentry/bundler-plugin-core@5.1.1': + '@sentry/bundler-plugin-core@4.9.1': dependencies: '@babel/core': 7.29.0 - '@sentry/babel-plugin-component-annotate': 5.1.1 + '@sentry/babel-plugin-component-annotate': 4.9.1 '@sentry/cli': 2.58.5 dotenv: 16.6.1 find-up: 5.0.0 - glob: 13.0.6 - magic-string: 0.30.17 + glob: 10.5.0 + magic-string: 0.30.8 + unplugin: 1.0.1 transitivePeerDependencies: - encoding - supports-color @@ -26694,31 +26795,6 @@ snapshots: - supports-color - webpack - '@sentry/nextjs@10.45.0(@opentelemetry/context-async-hooks@1.26.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.26.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.26.0(@opentelemetry/api@1.9.0))(next@16.2.9(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.92.0(esbuild@0.24.2))': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/semantic-conventions': 1.40.0 - '@rollup/plugin-commonjs': 28.0.1(rollup@4.57.1) - '@sentry-internal/browser-utils': 10.45.0 - '@sentry/bundler-plugin-core': 5.1.1 - '@sentry/core': 10.45.0 - '@sentry/node': 10.45.0 - '@sentry/opentelemetry': 10.45.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.26.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.26.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.26.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.40.0) - '@sentry/react': 10.45.0(react@19.2.3) - '@sentry/vercel-edge': 10.45.0 - '@sentry/webpack-plugin': 5.1.1(webpack@5.92.0(esbuild@0.24.2)) - next: 16.2.9(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - rollup: 4.57.1 - stacktrace-parser: 0.1.11 - transitivePeerDependencies: - - '@opentelemetry/context-async-hooks' - - '@opentelemetry/core' - - '@opentelemetry/sdk-trace-base' - - encoding - - react - - supports-color - - webpack - '@sentry/node-core@10.11.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.6.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.8.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.40.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -26844,15 +26920,6 @@ snapshots: '@opentelemetry/semantic-conventions': 1.40.0 '@sentry/core': 10.11.0 - '@sentry/opentelemetry@10.45.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@1.26.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.26.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.26.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.40.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/context-async-hooks': 1.26.0(@opentelemetry/api@1.9.0) - '@opentelemetry/core': 1.26.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 1.26.0(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.40.0 - '@sentry/core': 10.45.0 - '@sentry/opentelemetry@10.45.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.6.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.40.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -26869,11 +26936,14 @@ snapshots: hoist-non-react-statics: 3.3.2 react: 19.2.3 - '@sentry/react@10.45.0(react@19.2.3)': + '@sentry/rollup-plugin@4.9.1(rollup@4.57.1)': dependencies: - '@sentry/browser': 10.45.0 - '@sentry/core': 10.45.0 - react: 19.2.3 + '@sentry/bundler-plugin-core': 4.9.1 + rollup: 4.57.1 + unplugin: 1.0.1 + transitivePeerDependencies: + - encoding + - supports-color '@sentry/vercel-edge@10.11.0': dependencies: @@ -26881,12 +26951,6 @@ snapshots: '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.0) '@sentry/core': 10.11.0 - '@sentry/vercel-edge@10.45.0': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.0) - '@sentry/core': 10.45.0 - '@sentry/webpack-plugin@4.3.0(webpack@5.92.0(esbuild@0.24.2))': dependencies: '@sentry/bundler-plugin-core': 4.3.0 @@ -26897,15 +26961,6 @@ snapshots: - encoding - supports-color - '@sentry/webpack-plugin@5.1.1(webpack@5.92.0(esbuild@0.24.2))': - dependencies: - '@sentry/bundler-plugin-core': 5.1.1 - uuid: 9.0.1 - webpack: 5.92.0(esbuild@0.24.2) - transitivePeerDependencies: - - encoding - - supports-color - '@shikijs/core@3.14.0': dependencies: '@shikijs/types': 3.14.0 @@ -27797,42 +27852,42 @@ snapshots: '@stripe/stripe-js@7.7.0': {} - '@supabase/auth-js@2.108.2': + '@supabase/auth-js@2.110.0': dependencies: tslib: 2.8.1 - '@supabase/functions-js@2.108.2': + '@supabase/functions-js@2.110.0': dependencies: tslib: 2.8.1 - '@supabase/phoenix@0.4.2': {} + '@supabase/phoenix@0.4.4': {} - '@supabase/postgrest-js@2.108.2': + '@supabase/postgrest-js@2.110.0': dependencies: tslib: 2.8.1 - '@supabase/realtime-js@2.108.2': + '@supabase/realtime-js@2.110.0': dependencies: - '@supabase/phoenix': 0.4.2 + '@supabase/phoenix': 0.4.4 tslib: 2.8.1 - '@supabase/ssr@0.12.0(@supabase/supabase-js@2.108.2)': + '@supabase/ssr@0.12.0(@supabase/supabase-js@2.110.0)': dependencies: - '@supabase/supabase-js': 2.108.2 + '@supabase/supabase-js': 2.110.0 cookie: 1.0.2 - '@supabase/storage-js@2.108.2': + '@supabase/storage-js@2.110.0': dependencies: iceberg-js: 0.8.1 tslib: 2.8.1 - '@supabase/supabase-js@2.108.2': + '@supabase/supabase-js@2.110.0': dependencies: - '@supabase/auth-js': 2.108.2 - '@supabase/functions-js': 2.108.2 - '@supabase/postgrest-js': 2.108.2 - '@supabase/realtime-js': 2.108.2 - '@supabase/storage-js': 2.108.2 + '@supabase/auth-js': 2.110.0 + '@supabase/functions-js': 2.110.0 + '@supabase/postgrest-js': 2.110.0 + '@supabase/realtime-js': 2.110.0 + '@supabase/storage-js': 2.110.0 '@swc/counter@0.1.3': {} @@ -29259,16 +29314,6 @@ snapshots: '@vercel/oidc@3.1.0': {} - '@vercel/otel@1.10.4(@opentelemetry/api-logs@0.53.0)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.53.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@1.26.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-logs@0.53.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-metrics@2.8.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.26.0(@opentelemetry/api@1.9.0))': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.53.0 - '@opentelemetry/instrumentation': 0.53.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 1.26.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.53.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.8.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 1.26.0(@opentelemetry/api@1.9.0) - '@vercel/sandbox@1.2.0': dependencies: '@vercel/oidc': 3.1.0 @@ -31268,6 +31313,18 @@ snapshots: optionalDependencies: typescript: 5.9.3 + elysia@1.4.28(@sinclair/typebox@0.34.49)(exact-mirror@1.1.1)(file-type@22.0.1)(openapi-types@12.1.3)(typescript@6.0.3): + dependencies: + '@sinclair/typebox': 0.34.49 + cookie: 1.1.1 + exact-mirror: 1.1.1 + fast-decode-uri-component: 1.0.1 + file-type: 22.0.1 + memoirist: 0.4.0 + openapi-types: 12.1.3 + optionalDependencies: + typescript: 6.0.3 + emailable@3.1.1: dependencies: axios: 1.7.7 @@ -33151,6 +33208,15 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + glob@13.0.6: dependencies: minimatch: 10.2.4