mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-14 06:28:32 +09:00
668 lines
20 KiB
TypeScript
668 lines
20 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {lstat} from 'node:fs/promises';
|
|
import {createServer, type IncomingMessage, type Server, type ServerResponse} from 'node:http';
|
|
import path from 'node:path';
|
|
import {fileURLToPath} from 'node:url';
|
|
import send from 'send';
|
|
import {type AstroServerShutdown, AstroServerTermination} from '@/server/AstroServerTermination';
|
|
import {
|
|
CanonicalNetworkProtocol,
|
|
getNodeErrorCode,
|
|
HTTPHeader,
|
|
HTTPMethod,
|
|
HTTPStatusCode,
|
|
isCanonicalHTTPNetworkProtocol,
|
|
MIMEType,
|
|
NodeErrorCode,
|
|
OUTBOUND_USER_AGENT,
|
|
} from '@/server/HTTPConstants';
|
|
import {siteSecurityHeaders} from '@/server/SecurityHeaders';
|
|
|
|
const HEALTH_PATH = '/_health';
|
|
const HEALTH_RESPONSE_BODY = JSON.stringify({status: 'ok'});
|
|
const NOT_READY_RESPONSE_BODY = JSON.stringify({status: 'not_ready'});
|
|
const WELL_KNOWN_PATH_PREFIX = '.well-known/';
|
|
const IMMUTABLE_ASSET_PATH_PREFIX = '_astro/';
|
|
const PARENT_SEGMENT_PATTERN = /(?:^|[\\/])\.\.(?:[\\/]|$)/u;
|
|
const IMMUTABLE_ASSET_CACHE_CONTROL = 'public, max-age=31536000, immutable';
|
|
const LOCAL_REQUEST_URL_BASE = `${CanonicalNetworkProtocol.HTTP}//localhost`;
|
|
const ASTRO_SERVER_SHUTDOWN_TIMEOUT_MS = 15_000;
|
|
const ASTRO_SERVER_NAME = 'Fluxer Astro server';
|
|
const ASTRO_SERVER_MAX_CONNECTIONS = 256;
|
|
const ASTRO_SERVER_MAX_HEADERS = 64;
|
|
const ASTRO_SERVER_MAX_HEADER_BYTES = 16 * 1024;
|
|
const ASTRO_SERVER_MAX_REQUESTS_PER_SOCKET = 100;
|
|
const ASTRO_SERVER_MAX_ACTIVE_REQUESTS = ASTRO_SERVER_MAX_CONNECTIONS;
|
|
const ASTRO_SERVER_REQUEST_TARGET_MAX_BYTES = 8 * 1024;
|
|
const ASTRO_SERVER_HEADERS_TIMEOUT_MS = 10_000;
|
|
const ASTRO_SERVER_REQUEST_TIMEOUT_MS = 15_000;
|
|
const ASTRO_SERVER_KEEP_ALIVE_TIMEOUT_MS = 5_000;
|
|
const ASTRO_SERVER_SOCKET_TIMEOUT_MS = 30_000;
|
|
const ALLOWED_SITE_METHODS = `${HTTPMethod.GET}, ${HTTPMethod.HEAD}`;
|
|
const NO_STORE_CACHE_CONTROL = 'no-store';
|
|
const TEXT_CONTENT_TYPE = `${MIMEType.PLAIN}; charset=utf-8`;
|
|
|
|
const AstroServerStartupPhase = Object.freeze({
|
|
ENTRYPOINT: 'entrypoint load',
|
|
INITIALIZE: 'initialization',
|
|
LISTEN: 'listener bind',
|
|
} as const);
|
|
|
|
type AstroServerStartupPhase = (typeof AstroServerStartupPhase)[keyof typeof AstroServerStartupPhase];
|
|
|
|
const StaticDotfilePolicy = Object.freeze({
|
|
ALLOW: 'allow',
|
|
DENY: 'deny',
|
|
} as const);
|
|
|
|
type StaticDotfilePolicy = (typeof StaticDotfilePolicy)[keyof typeof StaticDotfilePolicy];
|
|
|
|
type AstroRequestNext = (error: unknown) => void;
|
|
|
|
type AstroRequestHandler = (
|
|
request: IncomingMessage,
|
|
response: ServerResponse,
|
|
next: AstroRequestNext,
|
|
locals: object,
|
|
) => void | Promise<void>;
|
|
|
|
interface AstroServerEntrypoint {
|
|
readonly handler: AstroRequestHandler;
|
|
}
|
|
|
|
interface StaticRequestPath {
|
|
readonly pathname: string;
|
|
readonly search: string;
|
|
readonly staticPath: string;
|
|
}
|
|
|
|
interface ResolvedStaticFile {
|
|
readonly isDirectory: boolean;
|
|
readonly normalizedPath: string;
|
|
}
|
|
|
|
interface ResolvedPublicEndpoint {
|
|
readonly basePath: string;
|
|
readonly secureOrigin: boolean;
|
|
}
|
|
|
|
type AstroServerInitialize = () => Promise<void>;
|
|
|
|
type AstroServerReadiness = () => boolean;
|
|
|
|
type AstroServerRequestLocals = () => object;
|
|
|
|
interface AstroServerOptions {
|
|
readonly entrypoint: URL;
|
|
readonly initialize: AstroServerInitialize | null;
|
|
readonly listenHost: string;
|
|
readonly listenPort: number;
|
|
readonly publicEndpoint: string;
|
|
readonly readiness: AstroServerReadiness | null;
|
|
readonly requestLocals: AstroServerRequestLocals | null;
|
|
readonly shutdown: AstroServerShutdown | null;
|
|
}
|
|
|
|
interface AstroServerHealthcheckOptions {
|
|
readonly listenHost: string;
|
|
readonly listenPort: number;
|
|
readonly timeoutMs: number;
|
|
}
|
|
|
|
function isAstroServerEntrypoint(value: unknown): value is AstroServerEntrypoint {
|
|
if (typeof value !== 'object') {
|
|
return false;
|
|
}
|
|
if (value == null) {
|
|
return false;
|
|
}
|
|
if (!('handler' in value)) {
|
|
return false;
|
|
}
|
|
return typeof value.handler === 'function';
|
|
}
|
|
|
|
class InvalidAstroServerListenPortError extends Error {
|
|
constructor() {
|
|
super('Astro server listen port must be an integer between 1 and 65535');
|
|
this.name = 'InvalidAstroServerListenPortError';
|
|
}
|
|
}
|
|
|
|
class AstroServerStartupAbandonedError extends Error {
|
|
public readonly phase: AstroServerStartupPhase;
|
|
|
|
constructor(phase: AstroServerStartupPhase) {
|
|
super(`${ASTRO_SERVER_NAME} ran out of pending work during ${phase} and would exit without serving`);
|
|
this.name = 'AstroServerStartupAbandonedError';
|
|
this.phase = phase;
|
|
}
|
|
}
|
|
|
|
class InvalidAstroServerHealthcheckTimeoutError extends Error {
|
|
constructor() {
|
|
super('Astro server healthcheck timeout must be a positive integer');
|
|
this.name = 'InvalidAstroServerHealthcheckTimeoutError';
|
|
}
|
|
}
|
|
|
|
class InvalidAstroServerPublicEndpointError extends Error {
|
|
constructor() {
|
|
super('Astro server public endpoint must not contain credentials, a query, or a fragment');
|
|
this.name = 'InvalidAstroServerPublicEndpointError';
|
|
}
|
|
}
|
|
|
|
function validateListener(listenHost: string, listenPort: number): void {
|
|
if (listenHost.length === 0) {
|
|
throw new Error('Astro server listen host must not be empty');
|
|
}
|
|
if (!Number.isInteger(listenPort)) {
|
|
throw new InvalidAstroServerListenPortError();
|
|
}
|
|
if (listenPort < 1) {
|
|
throw new InvalidAstroServerListenPortError();
|
|
}
|
|
if (listenPort > 65_535) {
|
|
throw new InvalidAstroServerListenPortError();
|
|
}
|
|
}
|
|
|
|
function resolvePublicEndpoint(publicEndpoint: string): ResolvedPublicEndpoint {
|
|
const endpoint = new URL(publicEndpoint);
|
|
if (!isCanonicalHTTPNetworkProtocol(endpoint.protocol)) {
|
|
throw new Error('Astro server public endpoint must use HTTP or HTTPS');
|
|
}
|
|
if (endpoint.username.length > 0) {
|
|
throw new InvalidAstroServerPublicEndpointError();
|
|
}
|
|
if (endpoint.password.length > 0) {
|
|
throw new InvalidAstroServerPublicEndpointError();
|
|
}
|
|
if (endpoint.search.length > 0) {
|
|
throw new InvalidAstroServerPublicEndpointError();
|
|
}
|
|
if (endpoint.hash.length > 0) {
|
|
throw new InvalidAstroServerPublicEndpointError();
|
|
}
|
|
const secureOrigin = endpoint.protocol === CanonicalNetworkProtocol.HTTPS;
|
|
if (endpoint.pathname === '/') {
|
|
return {basePath: '', secureOrigin};
|
|
}
|
|
return {basePath: endpoint.pathname.replace(/\/+$/u, ''), secureOrigin};
|
|
}
|
|
|
|
function healthcheckHost(listenHost: string): string {
|
|
if (listenHost === '0.0.0.0') {
|
|
return '127.0.0.1';
|
|
}
|
|
if (listenHost === '::') {
|
|
return '[::1]';
|
|
}
|
|
if (listenHost === '[::]') {
|
|
return '[::1]';
|
|
}
|
|
if (listenHost.includes(':') && !listenHost.startsWith('[')) {
|
|
return `[${listenHost}]`;
|
|
}
|
|
return listenHost;
|
|
}
|
|
|
|
function pathWithinBase(pathname: string, basePath: string): string | null {
|
|
if (basePath.length === 0) {
|
|
return pathname;
|
|
}
|
|
if (pathname === basePath) {
|
|
return '/';
|
|
}
|
|
if (pathname.startsWith(`${basePath}/`)) {
|
|
return pathname.slice(basePath.length);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function requestURL(request: IncomingMessage): URL | null {
|
|
const requestTarget = request.url;
|
|
if (requestTarget == null) {
|
|
return null;
|
|
}
|
|
if (!requestTarget.startsWith('/')) {
|
|
return null;
|
|
}
|
|
if (requestTarget.includes('#')) {
|
|
return null;
|
|
}
|
|
if (Buffer.byteLength(requestTarget) > ASTRO_SERVER_REQUEST_TARGET_MAX_BYTES) {
|
|
return null;
|
|
}
|
|
try {
|
|
return new URL(`${LOCAL_REQUEST_URL_BASE}${requestTarget}`);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function resolveStaticRequestPath(
|
|
request: IncomingMessage,
|
|
requestURL: URL,
|
|
basePath: string,
|
|
): StaticRequestPath | null {
|
|
if (request.method !== HTTPMethod.GET) {
|
|
if (request.method !== HTTPMethod.HEAD) {
|
|
return null;
|
|
}
|
|
}
|
|
const staticPath = pathWithinBase(requestURL.pathname, basePath);
|
|
if (staticPath == null) {
|
|
return null;
|
|
}
|
|
return {
|
|
pathname: requestURL.pathname,
|
|
search: requestURL.search,
|
|
staticPath,
|
|
};
|
|
}
|
|
|
|
function normalizeServedPath(decodedPath: string): string {
|
|
if (decodedPath.length === 0) {
|
|
return decodedPath;
|
|
}
|
|
return path.normalize(`.${path.sep}${decodedPath}`);
|
|
}
|
|
|
|
async function resolveStaticFile(clientDirectory: string, staticPath: string): Promise<ResolvedStaticFile | null> {
|
|
let decodedPath: string;
|
|
try {
|
|
decodedPath = decodeURIComponent(staticPath);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (decodedPath.includes('\0')) {
|
|
return null;
|
|
}
|
|
const normalizedPath = normalizeServedPath(decodedPath);
|
|
if (PARENT_SEGMENT_PATTERN.test(normalizedPath)) {
|
|
return null;
|
|
}
|
|
const resolvedClientDirectory = path.resolve(clientDirectory);
|
|
const resolvedPath = path.resolve(resolvedClientDirectory, normalizedPath);
|
|
if (resolvedPath !== resolvedClientDirectory) {
|
|
if (!resolvedPath.startsWith(`${resolvedClientDirectory}${path.sep}`)) {
|
|
return null;
|
|
}
|
|
}
|
|
let isDirectory = false;
|
|
try {
|
|
isDirectory = (await lstat(resolvedPath)).isDirectory();
|
|
} catch (error) {
|
|
const code = getNodeErrorCode(error);
|
|
if (code !== NodeErrorCode.NOT_FOUND && code !== NodeErrorCode.NOT_DIRECTORY) throw error;
|
|
}
|
|
return {isDirectory, normalizedPath};
|
|
}
|
|
|
|
function respondToHealthcheck(
|
|
request: IncomingMessage,
|
|
requestURL: URL,
|
|
response: ServerResponse,
|
|
readiness: AstroServerReadiness | null,
|
|
): boolean {
|
|
if (requestURL.pathname !== HEALTH_PATH) {
|
|
return false;
|
|
}
|
|
if (isAstroServerReady(readiness)) {
|
|
return writeHealthcheckResponse(request, response, HTTPStatusCode.OK, HEALTH_RESPONSE_BODY);
|
|
}
|
|
return writeHealthcheckResponse(request, response, HTTPStatusCode.SERVICE_UNAVAILABLE, NOT_READY_RESPONSE_BODY);
|
|
}
|
|
|
|
function isAstroServerReady(readiness: AstroServerReadiness | null): boolean {
|
|
if (readiness == null) {
|
|
return true;
|
|
}
|
|
return readiness();
|
|
}
|
|
|
|
function writeHealthcheckResponse(
|
|
request: IncomingMessage,
|
|
response: ServerResponse,
|
|
status: HTTPStatusCode,
|
|
body: string,
|
|
): boolean {
|
|
response.writeHead(status, {
|
|
[HTTPHeader.CONTENT_TYPE]: MIMEType.JSON,
|
|
[HTTPHeader.CONTENT_LENGTH]: Buffer.byteLength(body).toString(),
|
|
[HTTPHeader.CACHE_CONTROL]: NO_STORE_CACHE_CONTROL,
|
|
});
|
|
if (request.method === HTTPMethod.HEAD) {
|
|
response.end();
|
|
return true;
|
|
}
|
|
response.end(body);
|
|
return true;
|
|
}
|
|
|
|
function writeTextResponse(
|
|
request: IncomingMessage,
|
|
response: ServerResponse,
|
|
status: HTTPStatusCode,
|
|
body: string,
|
|
): void {
|
|
response.writeHead(status, {
|
|
[HTTPHeader.CONTENT_TYPE]: TEXT_CONTENT_TYPE,
|
|
[HTTPHeader.CONTENT_LENGTH]: Buffer.byteLength(body).toString(),
|
|
[HTTPHeader.CACHE_CONTROL]: NO_STORE_CACHE_CONTROL,
|
|
});
|
|
if (request.method === HTTPMethod.HEAD) {
|
|
response.end();
|
|
return;
|
|
}
|
|
response.end(body);
|
|
}
|
|
|
|
function writeEmptyResponse(response: ServerResponse, status: HTTPStatusCode): void {
|
|
response.writeHead(status, {
|
|
[HTTPHeader.CONTENT_LENGTH]: '0',
|
|
[HTTPHeader.CACHE_CONTROL]: NO_STORE_CACHE_CONTROL,
|
|
});
|
|
response.end();
|
|
}
|
|
|
|
function rejectRequest(response: ServerResponse, status: HTTPStatusCode, allow: string | null): void {
|
|
response.shouldKeepAlive = false;
|
|
response.setHeader(HTTPHeader.CONTENT_LENGTH, '0');
|
|
response.setHeader(HTTPHeader.CACHE_CONTROL, NO_STORE_CACHE_CONTROL);
|
|
response.setHeader(HTTPHeader.CONNECTION, 'close');
|
|
if (allow != null) {
|
|
response.setHeader(HTTPHeader.ALLOW, allow);
|
|
}
|
|
response.writeHead(status);
|
|
response.end();
|
|
}
|
|
|
|
function requestDeclaresBody(request: IncomingMessage): boolean {
|
|
if (request.headers[HTTPHeader.TRANSFER_ENCODING.toLowerCase()] != null) {
|
|
return true;
|
|
}
|
|
if (request.headers[HTTPHeader.EXPECT.toLowerCase()] != null) {
|
|
return true;
|
|
}
|
|
const contentLength = request.headers[HTTPHeader.CONTENT_LENGTH.toLowerCase()];
|
|
if (contentLength == null) {
|
|
return false;
|
|
}
|
|
if (Array.isArray(contentLength)) {
|
|
return true;
|
|
}
|
|
return contentLength.trim() !== '0';
|
|
}
|
|
|
|
function sendFailure(request: IncomingMessage, response: ServerResponse, error: unknown): void {
|
|
if (response.headersSent) {
|
|
let resolvedError: Error;
|
|
if (error instanceof Error) {
|
|
resolvedError = error;
|
|
} else {
|
|
resolvedError = new Error(String(error));
|
|
}
|
|
response.destroy(resolvedError);
|
|
return;
|
|
}
|
|
writeTextResponse(request, response, HTTPStatusCode.INTERNAL_SERVER_ERROR, 'Internal server error');
|
|
}
|
|
|
|
interface AstroHandlerRequest {
|
|
readonly handler: AstroRequestHandler;
|
|
readonly request: IncomingMessage;
|
|
readonly requestLocals: AstroServerRequestLocals | null;
|
|
readonly response: ServerResponse;
|
|
}
|
|
|
|
interface StaticOrRenderRequest extends AstroHandlerRequest {
|
|
readonly clientDirectory: string;
|
|
readonly basePath: string;
|
|
readonly requestURL: URL;
|
|
}
|
|
|
|
async function runAstroHandler({handler, request, requestLocals, response}: AstroHandlerRequest): Promise<void> {
|
|
const next = (error: unknown): void => {
|
|
if (error != null) {
|
|
sendFailure(request, response, error);
|
|
return;
|
|
}
|
|
if (!response.writableEnded) {
|
|
writeEmptyResponse(response, HTTPStatusCode.NOT_FOUND);
|
|
}
|
|
};
|
|
try {
|
|
if (requestLocals == null) {
|
|
await handler(request, response, next, {});
|
|
return;
|
|
}
|
|
await handler(request, response, next, requestLocals());
|
|
} catch (error) {
|
|
sendFailure(request, response, error);
|
|
}
|
|
}
|
|
|
|
function shouldRedirectStaticDirectory(staticFile: ResolvedStaticFile, requestPath: StaticRequestPath): boolean {
|
|
return staticFile.isDirectory && requestPath.pathname !== '/' && !requestPath.pathname.endsWith('/');
|
|
}
|
|
|
|
async function serveStaticOrRender({
|
|
handler,
|
|
clientDirectory,
|
|
basePath,
|
|
request,
|
|
requestURL,
|
|
requestLocals,
|
|
response,
|
|
}: StaticOrRenderRequest): Promise<void> {
|
|
const requestPath = resolveStaticRequestPath(request, requestURL, basePath);
|
|
if (requestPath == null) {
|
|
await runAstroHandler({handler, request, requestLocals, response});
|
|
return;
|
|
}
|
|
const staticFile = await resolveStaticFile(clientDirectory, requestPath.staticPath);
|
|
if (staticFile == null) {
|
|
writeTextResponse(request, response, HTTPStatusCode.BAD_REQUEST, 'Bad request');
|
|
return;
|
|
}
|
|
if (shouldRedirectStaticDirectory(staticFile, requestPath)) {
|
|
response.writeHead(HTTPStatusCode.MOVED_PERMANENTLY, {
|
|
[HTTPHeader.LOCATION]: `${requestPath.pathname}/${requestPath.search}`,
|
|
[HTTPHeader.CONTENT_LENGTH]: '0',
|
|
[HTTPHeader.CACHE_CONTROL]: 'no-cache',
|
|
});
|
|
response.end();
|
|
return;
|
|
}
|
|
let encodedPath = requestPath.staticPath;
|
|
let normalizedPath = staticFile.normalizedPath;
|
|
if (staticFile.isDirectory) {
|
|
if (!encodedPath.endsWith('/')) {
|
|
encodedPath = `${encodedPath}/index.html`;
|
|
normalizedPath = normalizeServedPath(`${normalizedPath}/index.html`);
|
|
}
|
|
}
|
|
let dotfiles: StaticDotfilePolicy = StaticDotfilePolicy.DENY;
|
|
if (normalizedPath.startsWith(WELL_KNOWN_PATH_PREFIX)) {
|
|
dotfiles = StaticDotfilePolicy.ALLOW;
|
|
}
|
|
const stream = send(request, encodedPath, {
|
|
dotfiles,
|
|
extensions: ['html'],
|
|
index: ['index.html'],
|
|
root: clientDirectory,
|
|
});
|
|
let fileOpened = false;
|
|
stream.on('file', () => {
|
|
fileOpened = true;
|
|
if (normalizedPath.startsWith(IMMUTABLE_ASSET_PATH_PREFIX)) {
|
|
response.setHeader(HTTPHeader.CACHE_CONTROL, IMMUTABLE_ASSET_CACHE_CONTROL);
|
|
}
|
|
});
|
|
stream.on('error', (error) => {
|
|
if (!fileOpened) {
|
|
runAstroHandler({handler, request, requestLocals, response}).catch((handlerError: unknown) => {
|
|
sendFailure(request, response, handlerError);
|
|
});
|
|
return;
|
|
}
|
|
sendFailure(request, response, error);
|
|
});
|
|
stream.pipe(response);
|
|
}
|
|
|
|
async function loadServerEntrypoint(entrypointURL: URL): Promise<AstroServerEntrypoint> {
|
|
const entrypoint: unknown = await import(entrypointURL.href);
|
|
if (!isAstroServerEntrypoint(entrypoint)) {
|
|
throw new Error(`Astro server entrypoint does not export a handler: ${entrypointURL.href}`);
|
|
}
|
|
return entrypoint;
|
|
}
|
|
|
|
function closeServerListener(server: Server): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
server.close((error) => {
|
|
if (error == null) {
|
|
resolve();
|
|
return;
|
|
}
|
|
reject(error);
|
|
});
|
|
server.closeIdleConnections();
|
|
});
|
|
}
|
|
|
|
export async function runAstroServer(options: AstroServerOptions): Promise<void> {
|
|
validateListener(options.listenHost, options.listenPort);
|
|
const {basePath, secureOrigin} = resolvePublicEndpoint(options.publicEndpoint);
|
|
const securityHeaders = siteSecurityHeaders(secureOrigin);
|
|
let startupPhase: AstroServerStartupPhase | null = AstroServerStartupPhase.ENTRYPOINT;
|
|
const requireStartupProgress = (): void => {
|
|
const phase = startupPhase;
|
|
if (phase == null) {
|
|
return;
|
|
}
|
|
throw new AstroServerStartupAbandonedError(phase);
|
|
};
|
|
process.on('beforeExit', requireStartupProgress);
|
|
try {
|
|
const entrypoint = await loadServerEntrypoint(options.entrypoint);
|
|
if (options.initialize != null) {
|
|
startupPhase = AstroServerStartupPhase.INITIALIZE;
|
|
await options.initialize();
|
|
}
|
|
const clientDirectory = fileURLToPath(new URL('../client/', options.entrypoint));
|
|
let activeRequests = 0;
|
|
const handleRequest = (request: IncomingMessage, response: ServerResponse): void => {
|
|
for (const header of securityHeaders) {
|
|
response.setHeader(header.name, header.value);
|
|
}
|
|
const parsedRequestURL = requestURL(request);
|
|
if (parsedRequestURL == null) {
|
|
rejectRequest(response, HTTPStatusCode.BAD_REQUEST, null);
|
|
return;
|
|
}
|
|
if (request.method !== HTTPMethod.GET && request.method !== HTTPMethod.HEAD) {
|
|
rejectRequest(response, HTTPStatusCode.METHOD_NOT_ALLOWED, ALLOWED_SITE_METHODS);
|
|
return;
|
|
}
|
|
if (requestDeclaresBody(request)) {
|
|
rejectRequest(response, HTTPStatusCode.PAYLOAD_TOO_LARGE, null);
|
|
return;
|
|
}
|
|
if (respondToHealthcheck(request, parsedRequestURL, response, options.readiness)) {
|
|
return;
|
|
}
|
|
if (activeRequests >= ASTRO_SERVER_MAX_ACTIVE_REQUESTS) {
|
|
rejectRequest(response, HTTPStatusCode.SERVICE_UNAVAILABLE, null);
|
|
return;
|
|
}
|
|
activeRequests += 1;
|
|
let retained = true;
|
|
const release = (): void => {
|
|
if (!retained) {
|
|
return;
|
|
}
|
|
retained = false;
|
|
activeRequests -= 1;
|
|
};
|
|
response.once('finish', release);
|
|
response.once('close', release);
|
|
serveStaticOrRender({
|
|
handler: entrypoint.handler,
|
|
clientDirectory,
|
|
basePath,
|
|
request,
|
|
requestURL: parsedRequestURL,
|
|
requestLocals: options.requestLocals,
|
|
response,
|
|
}).catch((error: unknown) => {
|
|
sendFailure(request, response, error);
|
|
});
|
|
};
|
|
const server = createServer({maxHeaderSize: ASTRO_SERVER_MAX_HEADER_BYTES}, handleRequest);
|
|
server.on('checkContinue', handleRequest);
|
|
server.requestTimeout = ASTRO_SERVER_REQUEST_TIMEOUT_MS;
|
|
server.headersTimeout = ASTRO_SERVER_HEADERS_TIMEOUT_MS;
|
|
server.keepAliveTimeout = ASTRO_SERVER_KEEP_ALIVE_TIMEOUT_MS;
|
|
server.timeout = ASTRO_SERVER_SOCKET_TIMEOUT_MS;
|
|
server.maxHeadersCount = ASTRO_SERVER_MAX_HEADERS;
|
|
server.maxRequestsPerSocket = ASTRO_SERVER_MAX_REQUESTS_PER_SOCKET;
|
|
server.maxConnections = ASTRO_SERVER_MAX_CONNECTIONS;
|
|
const done = new Promise<void>((resolve, reject) => {
|
|
server.once('close', resolve);
|
|
server.once('error', reject);
|
|
});
|
|
new AstroServerTermination({
|
|
closeListener: () => closeServerListener(server),
|
|
serverName: ASTRO_SERVER_NAME,
|
|
shutdown: options.shutdown,
|
|
timeoutMs: ASTRO_SERVER_SHUTDOWN_TIMEOUT_MS,
|
|
}).install();
|
|
startupPhase = AstroServerStartupPhase.LISTEN;
|
|
server.once('listening', () => {
|
|
startupPhase = null;
|
|
});
|
|
server.listen(options.listenPort, options.listenHost);
|
|
await done;
|
|
} finally {
|
|
process.off('beforeExit', requireStartupProgress);
|
|
}
|
|
}
|
|
|
|
export async function checkAstroServerHealth(options: AstroServerHealthcheckOptions): Promise<void> {
|
|
validateListener(options.listenHost, options.listenPort);
|
|
if (!Number.isInteger(options.timeoutMs)) {
|
|
throw new InvalidAstroServerHealthcheckTimeoutError();
|
|
}
|
|
if (options.timeoutMs < 1) {
|
|
throw new InvalidAstroServerHealthcheckTimeoutError();
|
|
}
|
|
const endpoint = [
|
|
CanonicalNetworkProtocol.HTTP,
|
|
'//',
|
|
healthcheckHost(options.listenHost),
|
|
':',
|
|
options.listenPort.toString(),
|
|
HEALTH_PATH,
|
|
].join('');
|
|
const response = await fetch(endpoint, {
|
|
cache: 'no-store',
|
|
credentials: 'omit',
|
|
headers: {[HTTPHeader.USER_AGENT]: OUTBOUND_USER_AGENT},
|
|
redirect: 'error',
|
|
referrerPolicy: 'no-referrer',
|
|
signal: AbortSignal.timeout(options.timeoutMs),
|
|
});
|
|
if (response.body != null) {
|
|
await response.body.cancel();
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error(`Astro server healthcheck failed with status ${response.status.toString()}`);
|
|
}
|
|
}
|