stack/apps/backend/src/proxy.tsx
BilalG1 609579abab
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
DB migration compat / Check if migrations changed (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Build and Run / docker (push) Has been cancelled
Runs E2E API Tests (Local Emulator) / E2E Tests (Local Emulator, Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (mock, 22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (prod, 22.x) (push) Has been cancelled
Runs E2E API Tests with custom port prefix / build (22.x) (push) Has been cancelled
Runs E2E Fallback Tests / E2E Fallback Tests (Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Lint & build / lint_and_build (24) (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled
feat(hexclave): PR 3 — native @hexclave/* source rename + delete dual-publish wiring (#1482)
2026-05-29 15:21:59 -07:00

185 lines
7.2 KiB
TypeScript

import { getEnvVariable, getNodeEnvironment } from '@hexclave/shared/dist/utils/env';
import { HexclaveAssertionError } from '@hexclave/shared/dist/utils/errors';
import { wait } from '@hexclave/shared/dist/utils/promises';
import apiVersions from './generated/api-versions.json';
import routes from './generated/routes.json';
import './polyfills';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { SmartRouter } from './smart-router';
const DEV_RATE_LIMIT_MAX_REQUESTS = 100;
const DEV_RATE_LIMIT_WINDOW_MS = 10_000;
const devRateLimitTimestamps: number[] = [];
const corsAllowedRequestHeaders = [
// General
'content-type',
'authorization', // used for OAuth basic authentication
'x-stack-project-id',
'x-stack-branch-id',
'x-stack-override-error-status',
'x-stack-random-nonce', // used to forcefully disable some caches
'x-stack-client-version',
'x-stack-disable-artificial-development-delay',
// Project auth
'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',
// User auth
'x-stack-refresh-token',
'x-stack-access-token',
'x-stack-allow-restricted-user',
'x-stack-allow-anonymous-user',
// Sentry
'baggage',
'sentry-trace',
// Vercel
'x-vercel-protection-bypass',
// ngrok
'ngrok-skip-browser-warning',
];
const corsAllowedResponseHeaders = [
'content-type',
'x-stack-actual-status',
'x-stack-known-error',
];
// Hexclave rebrand: every `x-stack-*` header is dual-accepted under its `x-hexclave-*` equivalent.
// Derive the alias names so the CORS allowlists never drift.
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);
// This function can be marked `async` if using `await` inside
export async function proxy(request: NextRequest) {
const url = new URL(request.url);
const delay = +getEnvVariable('STACK_ARTIFICIAL_DEVELOPMENT_DELAY_MS', '0');
if (delay) {
if (getNodeEnvironment().includes('production')) {
throw new HexclaveAssertionError('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 ? {
// CORS headers
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
"Access-Control-Max-Age": "86400", // 1 day (capped to lower values, eg. 10min, by some browsers)
"Access-Control-Allow-Headers": corsAllowedRequestHeadersWithAliases.join(', '),
"Access-Control-Expose-Headers": corsAllowedResponseHeadersWithAliases.join(', '),
"Vary": corsAllowedRequestHeadersWithAliases.join(', '),
} : undefined;
// ensure our clients can handle 429 responses
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 = NextResponse.json({
message: 'Artificial development rate limit triggered. Wait before retrying.',
}, {
status: 429,
});
// since not all firewalls return CORS headers with their 429 responses, 50% chance that we don't set the CORS headers
if (Math.random() < 0.5 && corsHeadersInit) {
for (const [key, value] of Object.entries(corsHeadersInit)) {
response.headers.set(key, value);
}
}
if (Math.random() < 0.5) {
// for debugging, make sure we don't always set the Retry-After header
response.headers.set('Retry-After', retryAfterSeconds.toString());
}
return response;
} else {
devRateLimitTimestamps.push(now);
}
}
const newRequestHeaders = new Headers(request.headers);
// Hexclave rebrand: dual-accept request headers. New SDKs emit `x-hexclave-*`; copy each onto its
// `x-stack-*` equivalent here — before routing and yup validation — so downstream auth parsing
// and route schemas (which read `x-stack-*`) keep working unchanged. The new form wins when both
// are present.
for (const [name, value] of request.headers) {
if (name.startsWith('x-hexclave-')) {
newRequestHeaders.set(`x-stack-${name.slice('x-hexclave-'.length)}`, value);
}
}
const responseInit = isApiRequest ? {
request: {
headers: newRequestHeaders,
},
headers: corsHeadersInit,
} as const : undefined;
// we want to allow preflight requests to pass through
// even if the API route does not implement OPTIONS
if (request.method === 'OPTIONS' && isApiRequest) {
return new Response(null, responseInit);
}
// if no route is available for the requested version, rewrite to newer version
let pathname = url.pathname;
outer: for (let i = 0; i < apiVersions.length - 1; i++) {
const version = apiVersions[i];
const nextVersion = apiVersions[i + 1];
if (!nextVersion.migrationFolder) {
throw new HexclaveAssertionError(`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);
// okay, we're in an API version of the current version. let's check if at least one route matches this URL (doesn't matter which)
for (const route of routes) {
if (nextVersion.migrationFolder && (route.normalizedPath + "/").startsWith(nextVersion.migrationFolder + "/")) {
if (SmartRouter.matchNormalizedPath(migrationPathname, route.normalizedPath)) {
// success! we found a route that matches the request
// rewrite request to the migration folder
pathname = migrationPathname;
break outer;
}
}
}
// if no route matches, rewrite to the next version
pathname = nextPathname;
}
}
const newUrl = request.nextUrl.clone();
newUrl.pathname = pathname;
return NextResponse.rewrite(newUrl, responseInit);
}
// See "Matching Paths" below to learn more
export const config = {
matcher: '/:path*',
};