mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
165 lines
6.0 KiB
TypeScript
165 lines
6.0 KiB
TypeScript
import { getEnvVariable, getNodeEnvironment } from '@stackframe/stack-shared/dist/utils/env';
|
|
import { StackAssertionError } from '@stackframe/stack-shared/dist/utils/errors';
|
|
import { wait } from '@stackframe/stack-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-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',
|
|
];
|
|
|
|
// 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 StackAssertionError('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": corsAllowedRequestHeaders.join(', '),
|
|
"Access-Control-Expose-Headers": corsAllowedResponseHeaders.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")) {
|
|
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);
|
|
// here we could update the request headers (currently we don't)
|
|
|
|
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 StackAssertionError(`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*',
|
|
};
|