mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
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:
@@ -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";
|
||||
|
||||
@@ -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
@@ -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
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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's API endpoint.<br />
|
||||
<br />
|
||||
Were you looking for <Link href="https://app.hexclave.com">Hexclave'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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import "@/polyfills";
|
||||
import "@/instrument";
|
||||
import { app } from "./server/app";
|
||||
|
||||
export default app;
|
||||
@@ -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();
|
||||
@@ -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
@@ -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,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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
import "@/polyfills";
|
||||
import { app } from "./app";
|
||||
|
||||
export default app;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
@@ -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,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";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,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";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,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";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,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";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,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";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,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";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> },
|
||||
}
|
||||
`);
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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,
|
||||
|
||||
Generated
+439
-291
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user