From b37efa2f8eff5a7ab4197ae4c94ac94ae67fd3a3 Mon Sep 17 00:00:00 2001 From: mantrakp04 Date: Thu, 18 Jun 2026 20:47:21 -0700 Subject: [PATCH] Migrate backend transport from Next.js to ElysiaJS (WIP) Squashed recommit of codex work (orig: 92b149256, dbcce2c98, 04619d1b1, 3ec5c62c2, 657900476). Elysia Node server + next/* shims + route registry codegen + observability preload + deploy configs. Verification in progress; full e2e not yet green. --- apps/backend/instrumentation-client.ts | 4 +- apps/backend/next.config.mjs | 99 -- apps/backend/package.json | 25 +- .../generate-private-sign-up-risk-engine.ts | 11 +- apps/backend/scripts/generate-route-info.ts | 36 + apps/backend/src/app/dev-stats/page.tsx | 1555 ----------------- apps/backend/src/app/global-error.tsx | 23 - .../app/health/error-handler-debug/page.tsx | 15 - apps/backend/src/app/layout.tsx | 22 - apps/backend/src/app/not-found.tsx | 13 - apps/backend/src/app/page.tsx | 22 - apps/backend/src/hexclave.tsx | 9 +- apps/backend/src/index.ts | 5 + apps/backend/src/instrument.ts | 75 + apps/backend/src/instrumentation.ts | 72 +- apps/backend/src/lib/next-compat/fetch.d.ts | 13 + apps/backend/src/lib/next-compat/headers.tsx | 122 ++ .../src/lib/next-compat/navigation.tsx | 44 + .../src/lib/next-compat/request-context.tsx | 55 + apps/backend/src/lib/next-compat/server.tsx | 37 + apps/backend/src/polyfills.tsx | 2 +- .../route-handlers/smart-route-handler.tsx | 2 +- apps/backend/src/server/app.ts | 278 +++ apps/backend/src/server/middleware.ts | 174 ++ apps/backend/src/server/next-request-shim.ts | 26 + apps/backend/src/server/registry.ts | 103 ++ apps/backend/src/server/server.ts | 23 + apps/backend/src/server/vercel.ts | 4 + apps/backend/src/smart-router.tsx | 59 +- apps/backend/tsconfig.json | 19 +- apps/backend/tsdown.config.ts | 113 ++ apps/backend/vitest.config.ts | 5 +- .../api/v1/emails/email-queue.test.ts | 6 +- .../endpoints/api/v1/internal/config.test.ts | 2 +- docker/backend/Dockerfile | 18 +- packages/shared/src/utils/esbuild.tsx | 21 +- pnpm-lock.yaml | 730 +++++--- 37 files changed, 1673 insertions(+), 2169 deletions(-) delete mode 100644 apps/backend/next.config.mjs delete mode 100644 apps/backend/src/app/dev-stats/page.tsx delete mode 100644 apps/backend/src/app/global-error.tsx delete mode 100644 apps/backend/src/app/health/error-handler-debug/page.tsx delete mode 100644 apps/backend/src/app/layout.tsx delete mode 100644 apps/backend/src/app/not-found.tsx delete mode 100644 apps/backend/src/app/page.tsx create mode 100644 apps/backend/src/index.ts create mode 100644 apps/backend/src/instrument.ts create mode 100644 apps/backend/src/lib/next-compat/fetch.d.ts create mode 100644 apps/backend/src/lib/next-compat/headers.tsx create mode 100644 apps/backend/src/lib/next-compat/navigation.tsx create mode 100644 apps/backend/src/lib/next-compat/request-context.tsx create mode 100644 apps/backend/src/lib/next-compat/server.tsx create mode 100644 apps/backend/src/server/app.ts create mode 100644 apps/backend/src/server/middleware.ts create mode 100644 apps/backend/src/server/next-request-shim.ts create mode 100644 apps/backend/src/server/registry.ts create mode 100644 apps/backend/src/server/server.ts create mode 100644 apps/backend/src/server/vercel.ts create mode 100644 apps/backend/tsdown.config.ts diff --git a/apps/backend/instrumentation-client.ts b/apps/backend/instrumentation-client.ts index e94648170..3f978d3d8 100644 --- a/apps/backend/instrumentation-client.ts +++ b/apps/backend/instrumentation-client.ts @@ -1,8 +1,8 @@ // 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 * as Sentry from "@sentry/browser"; 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"; 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 b339b319c..aead90f86 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 tsx watch src/server/server.ts ${STACK_BACKEND_DEV_EXTRA_ARGS:-}\" \"pnpm run codegen:watch\" \"pnpm run prisma-studio\" \"pnpm run run-email-queue\" \"pnpm run run-cron-jobs\" \"pnpm run run-bulldozer-studio\"", "dev:inspect": "STACK_BACKEND_DEV_EXTRA_ARGS=\"--inspect\" pnpm run dev", "dev:profile": "STACK_BACKEND_DEV_EXTRA_ARGS=\"--experimental-cpu-prof\" pnpm run dev", - "build": "pnpm run codegen && 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": "STACK_DATABASE_CONNECTION_STRING=\"${STACK_DATABASE_CONNECTION_STRING:-placeholder-database-connection-string}\" pnpm run prisma generate", "codegen-prisma:watch": "STACK_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", @@ -58,10 +58,12 @@ }, "dependencies": { "@ai-sdk/mcp": "^1.0.21", - "spacetimedb": "^2.1.0", "@ai-sdk/openai": "^3.0.29", "@aws-sdk/client-s3": "^3.855.0", "@clickhouse/client": "^1.14.0", + "@elysiajs/node": "^1.4.5", + "@hexclave/next": "workspace:*", + "@hexclave/shared": "workspace:*", "@node-oauth/oauth2-server": "^5.1.0", "@openrouter/ai-sdk-provider": "2.2.3", "@opentelemetry/api": "^1.9.0", @@ -73,6 +75,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", @@ -83,13 +86,13 @@ "@prisma/extension-read-replicas": "^0.5.0", "@prisma/instrumentation": "^7.0.0", "@react-email/render": "^1.2.1", - "@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:*", + "@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", "bcrypt": "^6.0.0", @@ -98,6 +101,7 @@ "dotenv": "^16.4.5", "dotenv-cli": "^7.3.0", "elkjs": "^0.11.1", + "elysia": "1.4.28", "emailable": "^3.1.1", "freestyle-sandboxes": "^0.1.6", "jiti": "^2.6.1", @@ -115,6 +119,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/generate-private-sign-up-risk-engine.ts b/apps/backend/scripts/generate-private-sign-up-risk-engine.ts index 93d47b6ef..be4dba5e1 100644 --- a/apps/backend/scripts/generate-private-sign-up-risk-engine.ts +++ b/apps/backend/scripts/generate-private-sign-up-risk-engine.ts @@ -1,10 +1,19 @@ -import { deindent } from "@hexclave/shared/dist/utils/strings"; import fs from "node:fs"; import path from "node:path"; const generatedFilePath = path.join("src", "private", "implementation.generated.ts"); const privateEnginePath = path.join("src", "private", "implementation", "index.ts"); +function deindent(strings: TemplateStringsArray, ...values: unknown[]) { + const value = strings.reduce((result, string, index) => { + return result + string + (index < values.length ? String(values[index]) : ""); + }, ""); + const lines = value.split("\n"); + const nonEmptyLines = lines.filter(line => line.trim() !== ""); + const indent = Math.min(...nonEmptyLines.map(line => line.match(/^ */)?.[0].length ?? 0)); + return lines.map(line => line.slice(indent)).join("\n").trim(); +} + function main() { const existingContents = fs.existsSync(generatedFilePath) ? fs.readFileSync(generatedFilePath, "utf8") diff --git a/apps/backend/scripts/generate-route-info.ts b/apps/backend/scripts/generate-route-info.ts index aa3059237..debf643e2 100644 --- a/apps/backend/scripts/generate-route-info.ts +++ b/apps/backend/scripts/generate-route-info.ts @@ -1,12 +1,48 @@ import { SmartRouter } from "@/smart-router"; 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 }); fs.writeFileSync("src/generated/routes.json", JSON.stringify(routes, null, 2)); fs.writeFileSync("src/generated/api-versions.json", JSON.stringify(apiVersions, null, 2)); + fs.writeFileSync("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/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/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/hexclave.tsx b/apps/backend/src/hexclave.tsx index e66869f3d..03a98a9d1 100644 --- a/apps/backend/src/hexclave.tsx +++ b/apps/backend/src/hexclave.tsx @@ -1,5 +1,5 @@ import { StackServerApp } from '@hexclave/next'; -import { getEnvVariable } from '@hexclave/shared/dist/utils/env'; +import { getEnvVariable, getNodeEnvironment } from '@hexclave/shared/dist/utils/env'; export function getHexclaveServerApp() { // Fail fast if the backend self-URL env var is missing — without it the SDK @@ -19,5 +19,12 @@ export function getHexclaveServerApp() { tokenStore: null, publishableClientKey: getEnvVariable('STACK_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY'), secretServerKey: getEnvVariable('STACK_INTERNAL_PROJECT_SECRET_SERVER_KEY'), + extraRequestHeaders: getNodeEnvironment() === "development" ? { + // Backend self-calls should not trip the artificial development delay/rate + // limiter. External e2e requests already set this header; mirroring it here + // keeps self-calls on the primary URL instead of falling through to the + // local hardcoded fallback port when the dev limiter returns a synthetic 429. + "x-stack-disable-artificial-development-delay": "yes", + } : undefined, }); } diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts new file mode 100644 index 000000000..7b6adf7c9 --- /dev/null +++ b/apps/backend/src/index.ts @@ -0,0 +1,5 @@ +import "@/polyfills"; +import "@/instrument"; +import { app } from "./server/app"; + +export default app; diff --git a/apps/backend/src/instrument.ts b/apps/backend/src/instrument.ts new file mode 100644 index 000000000..c9d60ca5b --- /dev/null +++ b/apps/backend/src/instrument.ts @@ -0,0 +1,75 @@ +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 { 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 { 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, + 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 index ebdbe788c..4dba44ad6 100644 --- a/apps/backend/src/instrumentation.ts +++ b/apps/backend/src/instrumentation.ts @@ -1,73 +1,5 @@ -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; +import { registerBackendInstrumentation } from "./instrument"; 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; - }, - }); - - } + registerBackendInstrumentation(); } diff --git a/apps/backend/src/lib/next-compat/fetch.d.ts b/apps/backend/src/lib/next-compat/fetch.d.ts new file mode 100644 index 000000000..bc4278559 --- /dev/null +++ b/apps/backend/src/lib/next-compat/fetch.d.ts @@ -0,0 +1,13 @@ +export {}; + +declare global { + // RequestInit is defined as an interface by lib.dom; interface merging is the + // TypeScript mechanism for adding Next's fetch metadata option. + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface RequestInit { + next?: { + revalidate?: number | false, + tags?: string[], + }, + } +} diff --git a/apps/backend/src/lib/next-compat/headers.tsx b/apps/backend/src/lib/next-compat/headers.tsx new file mode 100644 index 000000000..9f9506a36 --- /dev/null +++ b/apps/backend/src/lib/next-compat/headers.tsx @@ -0,0 +1,122 @@ +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)}`); + } + if (options.httpOnly === true) { + parts.push("HttpOnly"); + } + if (options.secure === true) { + parts.push("Secure"); + } + + 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.deletedCookies.push(cookie); + context.incomingCookies.delete(name); + }, + }; +} diff --git a/apps/backend/src/lib/next-compat/navigation.tsx b/apps/backend/src/lib/next-compat/navigation.tsx new file mode 100644 index 000000000..3347271d4 --- /dev/null +++ b/apps/backend/src/lib/next-compat/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/next-compat/request-context.tsx b/apps/backend/src/lib/next-compat/request-context.tsx new file mode 100644 index 000000000..b1cd8afce --- /dev/null +++ b/apps/backend/src/lib/next-compat/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("next-compat 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, decodeURIComponent(rawValue)); + } + return cookies; +} diff --git a/apps/backend/src/lib/next-compat/server.tsx b/apps/backend/src/lib/next-compat/server.tsx new file mode 100644 index 000000000..24245cc59 --- /dev/null +++ b/apps/backend/src/lib/next-compat/server.tsx @@ -0,0 +1,37 @@ +export class NextRequest extends Request { + get nextUrl() { + return new NextURL(this.url); + } +} + +export class NextURL extends URL { + clone() { + return new NextURL(this.toString()); + } +} + +export class NextResponse extends Response { + static json(body: unknown, init?: ResponseInit) { + const headers = new Headers(init?.headers); + if (!headers.has("content-type")) { + headers.set("content-type", "application/json"); + } + return new NextResponse(JSON.stringify(body), { + ...init, + headers, + }); + } + + static rewrite(url: URL | string, init?: ResponseInit) { + const headers = new Headers(init?.headers); + headers.set("x-middleware-rewrite", url.toString()); + return new NextResponse(null, { + ...init, + headers, + }); + } +} + +export async function connection() { + return undefined; +} diff --git a/apps/backend/src/polyfills.tsx b/apps/backend/src/polyfills.tsx index 7a4d65234..90c02d74c 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/route-handlers/smart-route-handler.tsx b/apps/backend/src/route-handlers/smart-route-handler.tsx index 712a60e0c..239da9667 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"; diff --git a/apps/backend/src/server/app.ts b/apps/backend/src/server/app.ts new file mode 100644 index 000000000..7daf18abd --- /dev/null +++ b/apps/backend/src/server/app.ts @@ -0,0 +1,278 @@ +import { Elysia } from "elysia"; +import { node } from "@elysiajs/node"; +import { parseCookieHeader, requestContextALS, type RequestContext } from "@/lib/next-compat/request-context"; +import { serializeSetCookie } from "@/lib/next-compat/headers"; +import { createNextRequestShim } from "./next-request-shim"; +import { httpMethodNames } from "@/generated/route-modules"; +import { matchRoute } from "./registry"; +import { runRequestPipeline } from "./middleware"; +import { getEnvVariable, getNodeEnvironment } from "@hexclave/shared/dist/utils/env"; + +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); + +export const app = new Elysia({ + adapter: node(), +}) + .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); + } + + const match = matchRoute(pipeline.dispatchPath); + 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 nextRequest = createNextRequestShim(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(nextRequest, { + params: Promise.resolve(match.params), + }); + } catch (error) { + if (isRedirectError(error)) { + return new Response(null, { + status: error.redirectStatus, + headers: { + Location: error.redirectUrl, + }, + }); + } + 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) { + const parsedEnvelopeHeader: unknown = JSON.parse(envelopeHeaderBytes); + 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/middleware.ts b/apps/backend/src/server/middleware.ts new file mode 100644 index 000000000..658bdd54f --- /dev/null +++ b/apps/backend/src/server/middleware.ts @@ -0,0 +1,174 @@ +import { getEnvVariable, getNodeEnvironment } from "@hexclave/shared/dist/utils/env"; +import { wait } from "@hexclave/shared/dist/utils/promises"; +import apiVersions from "@/generated/api-versions.json"; +import routes from "@/generated/routes.json"; +import { SmartRouter } from "@/smart-router"; + +const DEV_RATE_LIMIT_MAX_REQUESTS = 100; +const DEV_RATE_LIMIT_WINDOW_MS = 10_000; +const devRateLimitTimestamps: 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 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 = Date.now(); + while (devRateLimitTimestamps.length > 0 && now - devRateLimitTimestamps[0] > DEV_RATE_LIMIT_WINDOW_MS) { + devRateLimitTimestamps.shift(); + } + if (devRateLimitTimestamps.length >= DEV_RATE_LIMIT_MAX_REQUESTS) { + const waitMs = Math.max(0, DEV_RATE_LIMIT_WINDOW_MS - (now - devRateLimitTimestamps[0])); + const retryAfterSeconds = Math.max(1, Math.ceil(waitMs / 1000)); + + const response = Response.json({ + 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: mergeHexclaveHeaderAliases(request.headers), + originalUrl: request.url, + shortCircuitResponse: response, + }; + } + devRateLimitTimestamps.push(now); + } + + const mergedHeaders = mergeHexclaveHeaderAliases(request.headers); + + 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; +} + +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/next-request-shim.ts b/apps/backend/src/server/next-request-shim.ts new file mode 100644 index 000000000..90ecb93d3 --- /dev/null +++ b/apps/backend/src/server/next-request-shim.ts @@ -0,0 +1,26 @@ +import type { NextRequest } from "next/server"; +import { NextURL } from "@/lib/next-compat/server"; + +type NodeRequestInit = RequestInit & { + duplex?: "half", +}; + +export function createNextRequestShim(request: Request, headers: Headers, originalUrl: string): NextRequest { + const init: NodeRequestInit = { + method: request.method, + headers, + }; + + if (request.method !== "GET" && request.method !== "HEAD") { + init.body = request.body; + init.duplex = "half"; + } + + return new BackendNextRequest(originalUrl, init); +} + +class BackendNextRequest extends Request { + get nextUrl() { + return new NextURL(this.url); + } +} diff --git a/apps/backend/src/server/registry.ts b/apps/backend/src/server/registry.ts new file mode 100644 index 000000000..c729e1f46 --- /dev/null +++ b/apps/backend/src/server/registry.ts @@ -0,0 +1,103 @@ +import { httpMethodNames, routeModules } from "@/generated/route-modules"; +import { SmartRouter } from "@/smart-router"; +import type { NextRequest } from "next/server"; + +export type HttpMethod = typeof httpMethodNames[number]; +export type RouteParams = Record; +export type RouteHandlerOptions = { params: Promise }; +export type RouteHandler = (request: NextRequest, options: RouteHandlerOptions) => Promise | Response; +export type UnknownRouteModule = Partial>; +type UnknownRouteFunction = (request: NextRequest, 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, + }; + } + } + return undefined; +} + +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..38d0c7bb9 --- /dev/null +++ b/apps/backend/src/server/server.ts @@ -0,0 +1,23 @@ +import "@/polyfills"; +import "@/instrument"; +import { getEnvVariable } from "@hexclave/shared/dist/utils/env"; +import { app } from "./app"; + +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", () => { + app.stop().then(() => undefined, (error: unknown) => { + setTimeout(() => { + throw error; + }, 0); + }); +}); diff --git a/apps/backend/src/server/vercel.ts b/apps/backend/src/server/vercel.ts new file mode 100644 index 000000000..b613574dd --- /dev/null +++ b/apps/backend/src/server/vercel.ts @@ -0,0 +1,4 @@ +import "@/polyfills"; +import { app } from "./app"; + +export default app; diff --git a/apps/backend/src/smart-router.tsx b/apps/backend/src/smart-router.tsx index ceca8831b..6fe97f115 100644 --- a/apps/backend/src/smart-router.tsx +++ b/apps/backend/src/smart-router.tsx @@ -1,21 +1,44 @@ -import { isTruthy } from "@hexclave/shared/dist/utils/booleans"; -import { HexclaveAssertionError } from "@hexclave/shared/dist/utils/errors"; -import { numberCompare } from "@hexclave/shared/dist/utils/numbers"; - - -// SmartRouter may be imported on the edge, so we can't import fs at the top level -// hence, we define some wrapper functions -const listRecursively: typeof import("@hexclave/shared/dist/utils/fs").listRecursively = async (...args) => { - // SmartRouter may be imported on the edge, so we can't import fs at the top level - // hence, this wrapper function - const m = await import("@hexclave/shared/dist/utils/fs"); - return await m.listRecursively(...args); -}; const readFile = async (path: string) => { const fs = await import("fs"); return await fs.promises.readFile(path, "utf-8"); }; +const listRecursively = async (root: string, options: { excludeDirectories: boolean }) => { + const fs = await import("fs"); + const path = await import("path"); + const results: string[] = []; + const visit = async (currentPath: string) => { + const entries = await fs.promises.readdir(currentPath, { withFileTypes: true }); + for (const entry of entries) { + const entryPath = path.join(currentPath, entry.name); + if (entry.isDirectory()) { + if (!options.excludeDirectories) { + results.push(entryPath); + } + await visit(entryPath); + } else { + results.push(entryPath); + } + } + }; + await visit(root); + return results; +}; + +function isTruthy(value: T | false | null | undefined | ""): value is T { + return !!value; +} + +function numberCompare(a: number, b: number) { + return a < b ? -1 : a > b ? 1 : 0; +} + +class SmartRouterAssertionError extends Error { + constructor(message: string, options?: unknown) { + super(options === undefined ? message : `${message}: ${JSON.stringify(options)}`); + } +} + export const SmartRouter = { listRoutes: async () => { @@ -76,10 +99,10 @@ export const SmartRouter = { } as const; } else { if (!allFiles.includes(`src/app/api/migrations/${version}/beta-changes.txt`)) { - throw new HexclaveAssertionError(`API version ${version} does not have a beta-changes.txt file. The beta-changes.txt file should contain the changes since the last beta release.`); + throw new SmartRouterAssertionError(`API version ${version} does not have a beta-changes.txt file. The beta-changes.txt file should contain the changes since the last beta release.`); } if (!version.includes("beta") && !allFiles.includes(`src/app/api/migrations/${version}/changes.txt`)) { - throw new HexclaveAssertionError(`API version ${version} does not have a changes.txt file. The changes.txt file should contain the changes since the last full (non-beta) release.`); + throw new SmartRouterAssertionError(`API version ${version} does not have a changes.txt file. The changes.txt file should contain the changes since the last full (non-beta) release.`); } return { name: version, @@ -97,7 +120,7 @@ export const SmartRouter = { function parseApiVersionStringToArray(version: string): [number, number] { const matchResult = version.match(/^v(\d+)(?:beta(\d+))?$/); - if (!matchResult) throw new HexclaveAssertionError(`Invalid API version string: ${version}`); + if (!matchResult) throw new SmartRouterAssertionError(`Invalid API version string: ${version}`); return [+matchResult[1], matchResult[2] === "" ? Number.POSITIVE_INFINITY : +matchResult[2]]; } @@ -118,14 +141,14 @@ function matchPath(path: string, toMatchWith: string): Record [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) { + if (specifier === "next" || specifier.startsWith("next/")) { + return true; + } + return customNoExternal.has(packageNameFromSpecifier(specifier)); +} + +const basePlugin: Rolldown.Plugin = createBasePlugin({}); +const nextCompatAliases = new Map([ + ["next/headers", resolve(backendDir, "src/lib/next-compat/headers.tsx")], + ["next/navigation", resolve(backendDir, "src/lib/next-compat/navigation.tsx")], + ["next/server", resolve(backendDir, "src/lib/next-compat/server.tsx")], +]); +const nextCompatPlugin: Rolldown.Plugin = { + name: "backend-next-compat-aliases", + resolveId(source) { + return nextCompatAliases.get(source) ?? null; + }, +}; +const sentryRelease = process.env.SENTRY_RELEASE ?? `${packageJson.name}@${packageJson.version}`; +const shouldUploadSourcemaps = process.env.SENTRY_ORG != null + && process.env.SENTRY_PROJECT != null + && process.env.SENTRY_AUTH_TOKEN != null; +const plugins = [ + basePlugin, + nextCompatPlugin, + ...(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")], + 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"), + ...Object.fromEntries(nextCompatAliases), + }, + 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/vitest.config.ts b/apps/backend/vitest.config.ts index 49ed590ab..0b767f67c 100644 --- a/apps/backend/vitest.config.ts +++ b/apps/backend/vitest.config.ts @@ -17,7 +17,10 @@ export default mergeConfig( }, resolve: { alias: { - '@': resolve(__dirname, './src') + '@': resolve(__dirname, './src'), + 'next/headers': resolve(__dirname, './src/lib/next-compat/headers.tsx'), + 'next/navigation': resolve(__dirname, './src/lib/next-compat/navigation.tsx'), + 'next/server': resolve(__dirname, './src/lib/next-compat/server.tsx'), } }, envDir: __dirname, diff --git a/apps/e2e/tests/backend/endpoints/api/v1/emails/email-queue.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/emails/email-queue.test.ts index cd0d6e3a6..206bdf260 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/emails/email-queue.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/emails/email-queue.test.ts @@ -419,7 +419,7 @@ describe("send email to all users", () => { "email_programmatic_call_template_id": null, "has_delivered": true, "has_rendered": true, - "html": "

All users test

", + "html": "

All users test

", "id": "", "is_high_priority": false, "is_paused": false, @@ -462,7 +462,7 @@ describe("send email to all users", () => { "email_programmatic_call_template_id": null, "has_delivered": true, "has_rendered": true, - "html": "

All users test

", + "html": "

All users test

", "id": "", "is_high_priority": false, "is_paused": false, @@ -911,7 +911,7 @@ describe("template variables", () => { -
+
diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts index da78433d2..d520ed555 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts @@ -421,7 +421,7 @@ describe("oauth config", () => { expect(invalidTypeResponse).toMatchInlineSnapshot(` NiceResponse { "status": 400, - "body": "auth.oauth.providers.invalid.type must be one of the following values: google, github, microsoft, spotify, facebook, discord, gitlab, bitbucket, linkedin, apple, x, twitch", + "body": "auth.oauth.providers.invalid.type must be one of the following values: google, github, microsoft, spotify, facebook, discord, gitlab, bitbucket, linkedin, apple, x, twitch, custom_oidc", "headers": Headers {