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.
This commit is contained in:
mantrakp04
2026-06-18 20:47:21 -07:00
parent 75e497f3ec
commit b37efa2f8e
37 changed files with 1673 additions and 2169 deletions
+2 -2
View File
@@ -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";
-99
View File
@@ -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);
+15 -10
View File
@@ -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",
@@ -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")
@@ -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<ReturnType<typeof SmartRouter.listRoutes>>) {
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
File diff suppressed because it is too large Load Diff
-23
View File
@@ -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 (
<html>
<body>
[An unhandled error occurred.]
<Error
statusCode={500}
/>
</body>
</html>
);
}
@@ -1,15 +0,0 @@
"use client";
import { throwErr } from "@hexclave/shared/dist/utils/errors";
export default function Page() {
return <div>
This page is useful for testing error handling.<br />
Your observability platform should pick up on the errors thrown below.<br />
<button onClick={() => throwErr(`Client debug error thrown successfully!`)}>Throw client error</button>
<button onClick={() => {
console.log("Endpoint request", fetch("/health/error-handler-debug/endpoint"));
}}>Throw server error</button>
</div>;
}
-22
View File
@@ -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 (
<html lang="en" suppressHydrationWarning>
<body suppressHydrationWarning>
{children}
</body>
</html>
);
}
-13
View File
@@ -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 <div>
404 Not Found
</div>;
}
-22
View File
@@ -1,22 +0,0 @@
import { getNodeEnvironment } from "@hexclave/shared/dist/utils/env";
import Link from "next/link";
export default function Home() {
return (
<div>
Welcome to Hexclave&apos;s API endpoint.<br />
<br />
Were you looking for <Link href="https://app.hexclave.com">Hexclave&apos;s dashboard</Link> instead?<br />
<br />
You can also return to <Link href="https://hexclave.com">https://hexclave.com</Link>.<br />
<br />
<Link href="/api/v1">API v1</Link><br />
{getNodeEnvironment() === "development" && (
<>
<br />
<Link href="/dev-stats">Dev Stats</Link><br />
</>
)}
</div>
);
}
+8 -1
View File
@@ -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,
});
}
+5
View File
@@ -0,0 +1,5 @@
import "@/polyfills";
import "@/instrument";
import { app } from "./server/app";
export default app;
+75
View File
@@ -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();
+2 -70
View File
@@ -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();
}
+13
View File
@@ -0,0 +1,13 @@
export {};
declare global {
// RequestInit is defined as an interface by lib.dom; interface merging is the
// TypeScript mechanism for adding Next's fetch metadata option.
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface RequestInit {
next?: {
revalidate?: number | false,
tags?: string[],
},
}
}
@@ -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);
},
};
}
@@ -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");
}
@@ -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<string, string>,
pendingSetCookies: CookieWrite[],
deletedCookies: CookieWrite[],
};
export const requestContextALS = new AsyncLocalStorage<RequestContext>();
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<string, string>();
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;
}
@@ -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;
}
+1 -1
View File
@@ -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";
@@ -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";
+278
View File
@@ -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<string>(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("<div>404 Not Found</div>", {
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"
? `<br><a href="/dev-stats">Dev Stats</a><br>`
: "";
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hexclave API</title>
</head>
<body>
<div>
Welcome to Hexclave's API endpoint.<br>
<br>
Were you looking for <a href="https://app.hexclave.com">Hexclave's dashboard</a> instead?<br>
<br>
You can also return to <a href="https://hexclave.com">https://hexclave.com</a>.<br>
<br>
<a href="/api/v1">API v1</a><br>
${devStatsLink}
</div>
</body>
</html>`;
}
function devStatsHtml() {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hexclave Backend Dev Stats</title>
<style>
body { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; margin: 24px; }
button { font: inherit; padding: 4px 8px; }
pre { background: #f6f8fa; border: 1px solid #d0d7de; padding: 12px; overflow: auto; }
</style>
</head>
<body>
<h1>Dev Stats</h1>
<button id="refresh">Refresh</button>
<pre id="output">Loading...</pre>
<script>
async function refresh() {
const output = document.getElementById("output");
const response = await fetch("/dev-stats/api", { headers: { "accept": "application/json" } });
output.textContent = JSON.stringify(await response.json(), null, 2);
}
document.getElementById("refresh").addEventListener("click", refresh);
refresh();
</script>
</body>
</html>`;
}
function errorHandlerDebugHtml() {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Backend Error Debug</title>
</head>
<body>
<div>
This page is useful for testing error handling.<br>
Your observability platform should pick up on the errors thrown below.<br>
<button id="client-error">Throw client error</button>
<button id="server-error">Throw server error</button>
</div>
<script>
document.getElementById("client-error").addEventListener("click", () => {
throw new Error("Client debug error thrown successfully!");
});
document.getElementById("server-error").addEventListener("click", () => {
fetch("/health/error-handler-debug/endpoint").then((response) => console.log("Endpoint response", response));
});
</script>
</body>
</html>`;
}
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);
}
+174
View File
@@ -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<PipelineResult> {
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;
}
@@ -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);
}
}
+103
View File
@@ -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<string, string | string[]>;
export type RouteHandlerOptions = { params: Promise<RouteParams> };
export type RouteHandler = (request: NextRequest, options: RouteHandlerOptions) => Promise<Response> | Response;
export type UnknownRouteModule = Partial<Record<HttpMethod, unknown>>;
type UnknownRouteFunction = (request: NextRequest, options: RouteHandlerOptions) => unknown;
type RouteEntry = {
methods: Map<HttpMethod, RouteHandler>,
normalizedPath: string,
specificity: number[],
};
export type RouteMatch = {
handler?: RouteHandler,
methods: Map<HttpMethod, RouteHandler>,
normalizedPath: string,
params: Record<string, string | string[]>,
};
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<HttpMethod, RouteHandler>();
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;
}
+23
View File
@@ -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);
});
});
+4
View File
@@ -0,0 +1,4 @@
import "@/polyfills";
import { app } from "./app";
export default app;
+41 -18
View File
@@ -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<T>(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<string, string | s
if (toMatchWithFirst.startsWith("[[...") && toMatchWithFirst.endsWith("]]")) {
if (modifiedToMatchWith.includes("/")) {
throw new HexclaveAssertionError("Optional catch-all routes must be at the end of the path", { modifiedPath, modifiedToMatchWith });
throw new SmartRouterAssertionError("Optional catch-all routes must be at the end of the path", { modifiedPath, modifiedToMatchWith });
}
return {
[toMatchWithFirst.slice(5, -2)]: modifiedPath === "" ? [] : modifiedPath.split("/"),
};
} else if (toMatchWithFirst.startsWith("[...") && toMatchWithFirst.endsWith("]")) {
if (modifiedToMatchWith.includes("/")) {
throw new HexclaveAssertionError("Catch-all routes must be at the end of the path", { modifiedPath, modifiedToMatchWith });
throw new SmartRouterAssertionError("Catch-all routes must be at the end of the path", { modifiedPath, modifiedToMatchWith });
}
if (modifiedPath === "") return false;
return {
+10 -9
View File
@@ -17,14 +17,18 @@
"jsx": "react-jsx",
"incremental": true,
"noErrorTruncation": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
],
"next/headers": [
"./src/lib/next-compat/headers.tsx"
],
"next/navigation": [
"./src/lib/next-compat/navigation.tsx"
],
"next/server": [
"./src/lib/next-compat/server.tsx"
]
},
"skipLibCheck": true,
@@ -33,13 +37,10 @@
]
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
"**/*.?ts",
"**/*.?tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
"**/*.?tsx"
],
"exclude": [
"node_modules",
+113
View File
@@ -0,0 +1,113 @@
import { readFileSync } from "node:fs";
import { builtinModules } from "node:module";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { sentryRollupPlugin } from "@sentry/rollup-plugin";
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",
];
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) {
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);
+4 -1
View File
@@ -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,
@@ -419,7 +419,7 @@ describe("send email to all users", () => {
"email_programmatic_call_template_id": null,
"has_delivered": true,
"has_rendered": true,
"html": "<!DOCTYPE html PUBLIC \\"-//W3C//DTD XHTML 1.0 Transitional//EN\\" \\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\\"><html dir=\\"ltr\\" lang=\\"en\\"><head><meta content=\\"text/html; charset=UTF-8\\" http-equiv=\\"Content-Type\\"/><meta name=\\"x-apple-disable-message-reformatting\\"/></head><body style=\\"background-color:rgb(250,251,251);margin:0\\"><!--$--><table border=\\"0\\" width=\\"100%\\" cellPadding=\\"0\\" cellSpacing=\\"0\\" role=\\"presentation\\" align=\\"center\\"><tbody><tr><td style=\\"background-color:rgb(250,251,251);font-family:ui-sans-serif,system-ui,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;,&quot;Segoe UI Symbol&quot;,&quot;Noto Color Emoji&quot;;font-size:1rem;line-height:1.5;margin:0rem;padding:0rem;overflow-x:hidden\\"><div style=\\"padding-bottom:2rem;padding-top:2rem;padding-right:1rem;padding-left:1rem;display:flex;justify-content:center\\"><table align=\\"center\\" width=\\"100%\\" border=\\"0\\" cellPadding=\\"0\\" cellSpacing=\\"0\\" role=\\"presentation\\" style=\\"max-width:600px;background-color:rgb(255,255,255);padding:45px;border-radius:0.5rem;box-shadow:0 0 rgb(0,0,0,0),0 0 rgb(0,0,0,0),0 0 rgb(0,0,0,0),0 0 rgb(0,0,0,0),0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)),0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));margin-right:auto;margin-left:auto;width:100%\\"><tbody><tr style=\\"width:100%\\"><td><div><p>All users test</p></div></td></tr></tbody></table></div></td></tr></tbody></table><!--7--><!--/$--></body></html>",
"html": "<!DOCTYPE html PUBLIC \\"-//W3C//DTD XHTML 1.0 Transitional//EN\\" \\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\\"><html dir=\\"ltr\\" lang=\\"en\\"><head><meta content=\\"text/html; charset=UTF-8\\" http-equiv=\\"Content-Type\\"/><meta name=\\"x-apple-disable-message-reformatting\\"/></head><body style=\\"background-color:rgb(250,251,251);margin:0\\"><!--$--><table border=\\"0\\" width=\\"100%\\" cellPadding=\\"0\\" cellSpacing=\\"0\\" role=\\"presentation\\" align=\\"center\\"><tbody><tr><td style=\\"background-color:rgb(250,251,251);font-family:ui-sans-serif,system-ui,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;,&quot;Segoe UI Symbol&quot;,&quot;Noto Color Emoji&quot;;font-size:1rem;line-height:1.5;margin:0;padding:0;overflow-x:hidden\\"><div style=\\"padding-bottom:2rem;padding-top:2rem;padding-right:1rem;padding-left:1rem;display:flex;justify-content:center\\"><table align=\\"center\\" width=\\"100%\\" border=\\"0\\" cellPadding=\\"0\\" cellSpacing=\\"0\\" role=\\"presentation\\" style=\\"max-width:600px;background-color:rgb(255,255,255);padding:45px;border-radius:0.5rem;box-shadow:0 0 rgb(0,0,0,0),0 0 rgb(0,0,0,0),0 0 rgb(0,0,0,0),0 0 rgb(0,0,0,0),0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)),0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));margin-right:auto;margin-left:auto;width:100%\\"><tbody><tr style=\\"width:100%\\"><td><div><p>All users test</p></div></td></tr></tbody></table></div></td></tr></tbody></table><!--7--><!--/$--></body></html>",
"id": "<stripped UUID>",
"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": "<!DOCTYPE html PUBLIC \\"-//W3C//DTD XHTML 1.0 Transitional//EN\\" \\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\\"><html dir=\\"ltr\\" lang=\\"en\\"><head><meta content=\\"text/html; charset=UTF-8\\" http-equiv=\\"Content-Type\\"/><meta name=\\"x-apple-disable-message-reformatting\\"/></head><body style=\\"background-color:rgb(250,251,251);margin:0\\"><!--$--><table border=\\"0\\" width=\\"100%\\" cellPadding=\\"0\\" cellSpacing=\\"0\\" role=\\"presentation\\" align=\\"center\\"><tbody><tr><td style=\\"background-color:rgb(250,251,251);font-family:ui-sans-serif,system-ui,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;,&quot;Segoe UI Symbol&quot;,&quot;Noto Color Emoji&quot;;font-size:1rem;line-height:1.5;margin:0rem;padding:0rem;overflow-x:hidden\\"><div style=\\"padding-bottom:2rem;padding-top:2rem;padding-right:1rem;padding-left:1rem;display:flex;justify-content:center\\"><table align=\\"center\\" width=\\"100%\\" border=\\"0\\" cellPadding=\\"0\\" cellSpacing=\\"0\\" role=\\"presentation\\" style=\\"max-width:600px;background-color:rgb(255,255,255);padding:45px;border-radius:0.5rem;box-shadow:0 0 rgb(0,0,0,0),0 0 rgb(0,0,0,0),0 0 rgb(0,0,0,0),0 0 rgb(0,0,0,0),0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)),0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));margin-right:auto;margin-left:auto;width:100%\\"><tbody><tr style=\\"width:100%\\"><td><div><p>All users test</p></div></td></tr></tbody></table></div></td></tr></tbody></table><!--7--><!--/$--></body></html>",
"html": "<!DOCTYPE html PUBLIC \\"-//W3C//DTD XHTML 1.0 Transitional//EN\\" \\"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\\"><html dir=\\"ltr\\" lang=\\"en\\"><head><meta content=\\"text/html; charset=UTF-8\\" http-equiv=\\"Content-Type\\"/><meta name=\\"x-apple-disable-message-reformatting\\"/></head><body style=\\"background-color:rgb(250,251,251);margin:0\\"><!--$--><table border=\\"0\\" width=\\"100%\\" cellPadding=\\"0\\" cellSpacing=\\"0\\" role=\\"presentation\\" align=\\"center\\"><tbody><tr><td style=\\"background-color:rgb(250,251,251);font-family:ui-sans-serif,system-ui,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;,&quot;Segoe UI Symbol&quot;,&quot;Noto Color Emoji&quot;;font-size:1rem;line-height:1.5;margin:0;padding:0;overflow-x:hidden\\"><div style=\\"padding-bottom:2rem;padding-top:2rem;padding-right:1rem;padding-left:1rem;display:flex;justify-content:center\\"><table align=\\"center\\" width=\\"100%\\" border=\\"0\\" cellPadding=\\"0\\" cellSpacing=\\"0\\" role=\\"presentation\\" style=\\"max-width:600px;background-color:rgb(255,255,255);padding:45px;border-radius:0.5rem;box-shadow:0 0 rgb(0,0,0,0),0 0 rgb(0,0,0,0),0 0 rgb(0,0,0,0),0 0 rgb(0,0,0,0),0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)),0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));margin-right:auto;margin-left:auto;width:100%\\"><tbody><tr style=\\"width:100%\\"><td><div><p>All users test</p></div></td></tr></tbody></table></div></td></tr></tbody></table><!--7--><!--/$--></body></html>",
"id": "<stripped UUID>",
"is_high_priority": false,
"is_paused": false,
@@ -911,7 +911,7 @@ describe("template variables", () => {
<table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center">
<tbody>
<tr>
<td style="background-color:rgb(250,251,251);font-family:ui-sans-serif,system-ui,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;,&quot;Segoe UI Symbol&quot;,&quot;Noto Color Emoji&quot;;font-size:1rem;line-height:1.5;margin:0rem;padding:0rem;overflow-x:hidden">
<td style="background-color:rgb(250,251,251);font-family:ui-sans-serif,system-ui,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;,&quot;Segoe UI Symbol&quot;,&quot;Noto Color Emoji&quot;;font-size:1rem;line-height:1.5;margin:0;padding:0;overflow-x:hidden">
<div style="padding-bottom:2rem;padding-top:2rem;padding-right:1rem;padding-left:1rem;display:flex;justify-content:center">
<table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:600px;background-color:rgb(255,255,255);padding:45px;border-radius:0.5rem;box-shadow:0 0 rgb(0,0,0,0),0 0 rgb(0,0,0,0),0 0 rgb(0,0,0,0),0 0 rgb(0,0,0,0),0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)),0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));margin-right:auto;margin-left:auto;width:100%">
<tbody>
@@ -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 { <some fields may have been hidden> },
}
`);
+10 -8
View File
@@ -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"]
+18 -3
View File
@@ -7,6 +7,21 @@ import { ignoreUnhandledRejection, runAsynchronously } from './promises';
import { Result } from "./results";
import { traceSpan, withTraceSpan } from './telemetry';
type EsbuildBrowserRuntimeModule = Partial<typeof esbuild> & {
default?: typeof esbuild,
};
// The built ESM package can import esbuild-wasm's CJS browser entry as a
// default-only namespace under Node. Normalize once so both tsx source runs and
// built dist runs see the same API surface.
const esbuildModule: EsbuildBrowserRuntimeModule = esbuild;
function getEsbuildApi(module: EsbuildBrowserRuntimeModule): typeof esbuild {
return module.version == null && module.default != null
? module.default
: esbuild;
}
const esbuildApi = getEsbuildApi(esbuildModule);
// esbuild requires self property to be set, and it is not set by default in nodejs
(globalThis.self as any) ??= globalThis as any;
@@ -25,7 +40,7 @@ if (typeof process !== "undefined" && typeof process.exit === "function" && getP
}
export function initializeEsbuild(): Promise<void> {
const esbuildWasmUrl = `https://unpkg.com/esbuild-wasm@${esbuild.version}/esbuild.wasm`;
const esbuildWasmUrl = `https://unpkg.com/esbuild-wasm@${esbuildApi.version}/esbuild.wasm`;
if (esbuildInitializePromise == null) {
esbuildInitializePromise = withTraceSpan('initializeEsbuild', async () => {
try {
@@ -53,7 +68,7 @@ export function initializeEsbuild(): Promise<void> {
};
}
try {
await esbuild.initialize(initOptions);
await esbuildApi.initialize(initOptions);
} catch (e) {
if (e instanceof Error && e.message === 'Cannot call "initialize" more than once') {
// this happens especially in local development, just ignore
@@ -97,7 +112,7 @@ export async function bundleJavaScript(sourceFiles: Record<string, string> & { '
]);
let result;
try {
result = await traceSpan('bundleJavaScript', async () => await esbuild.build({
result = await traceSpan('bundleJavaScript', async () => await esbuildApi.build({
entryPoints: ['/entry.js'],
bundle: true,
write: false,
+439 -291
View File
File diff suppressed because it is too large Load Diff