mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 13:35:42 +09:00
docs: move the reference site to astro starlight (#2527)
This commit is contained in:
1 parent
7f8f09ee51
commit
5367c0ab42
198 files changed
+59927
-1216
No files matched your search
@@ -0,0 +1,667 @@
|
||||
// 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 './AstroServerTermination';
|
||||
import {
|
||||
CanonicalNetworkProtocol,
|
||||
getNodeErrorCode,
|
||||
HTTPHeader,
|
||||
HTTPMethod,
|
||||
HTTPStatusCode,
|
||||
isCanonicalHTTPNetworkProtocol,
|
||||
MIMEType,
|
||||
NodeErrorCode,
|
||||
OUTBOUND_USER_AGENT,
|
||||
} from './HTTPConstants';
|
||||
import {siteSecurityHeaders} from './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()}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export type AstroServerShutdown = () => Promise<void>;
|
||||
|
||||
interface AstroServerTerminationDependencies {
|
||||
readonly closeListener: () => Promise<void>;
|
||||
readonly serverName: string;
|
||||
readonly shutdown: AstroServerShutdown | null;
|
||||
readonly timeoutMs: number;
|
||||
}
|
||||
|
||||
export class AstroServerTermination {
|
||||
private installed = false;
|
||||
private terminationPromise: Promise<void> | null = null;
|
||||
|
||||
public constructor(private readonly dependencies: AstroServerTerminationDependencies) {}
|
||||
|
||||
public install(): void {
|
||||
if (this.installed) {
|
||||
throw new Error(`${this.dependencies.serverName} termination handlers are already installed`);
|
||||
}
|
||||
this.installed = true;
|
||||
process.on('SIGINT', this.handleSIGINT);
|
||||
process.on('SIGTERM', this.handleSIGTERM);
|
||||
}
|
||||
|
||||
private readonly handleSIGINT = (): void => {
|
||||
this.startTermination('SIGINT');
|
||||
};
|
||||
|
||||
private readonly handleSIGTERM = (): void => {
|
||||
this.startTermination('SIGTERM');
|
||||
};
|
||||
|
||||
private startTermination(signal: NodeJS.Signals): void {
|
||||
if (this.terminationPromise != null) {
|
||||
return;
|
||||
}
|
||||
this.terminationPromise = this.performTermination(signal);
|
||||
}
|
||||
|
||||
private async performTermination(signal: NodeJS.Signals): Promise<void> {
|
||||
const watchdog = setTimeout(() => {
|
||||
console.error(
|
||||
`Forcing ${this.dependencies.serverName} termination after cleanup timeout`,
|
||||
`${signal} ${this.dependencies.timeoutMs.toString()}ms`,
|
||||
);
|
||||
process.exit(1);
|
||||
}, this.dependencies.timeoutMs);
|
||||
const failures = await this.releaseResources();
|
||||
clearTimeout(watchdog);
|
||||
if (failures.length === 0) {
|
||||
process.exit(0);
|
||||
}
|
||||
const message = `${this.dependencies.serverName} shutdown failed`;
|
||||
console.error(message, new AggregateError(failures, message));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
private async releaseResources(): Promise<Array<unknown>> {
|
||||
const failures: Array<unknown> = [];
|
||||
try {
|
||||
await this.dependencies.closeListener();
|
||||
} catch (cleanupFailure) {
|
||||
failures.push(cleanupFailure);
|
||||
}
|
||||
const shutdown = this.dependencies.shutdown;
|
||||
if (shutdown == null) {
|
||||
return failures;
|
||||
}
|
||||
try {
|
||||
await shutdown();
|
||||
} catch (cleanupFailure) {
|
||||
failures.push(cleanupFailure);
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {buildUrl} from '@fluxer/config/src/EndpointDerivation';
|
||||
|
||||
const DEFAULT_BASE_DOMAIN = 'localhost';
|
||||
const DEFAULT_PUBLIC_SCHEME = 'http';
|
||||
const DEFAULT_LISTEN_HOST = '0.0.0.0';
|
||||
const DEFAULT_LISTEN_PORT = 8080;
|
||||
|
||||
function readString(name: string, fallback: string): string {
|
||||
const value = process.env[name];
|
||||
if (value == null) {
|
||||
return fallback;
|
||||
}
|
||||
if (value.length === 0) {
|
||||
return fallback;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readPort(name: string): number | undefined {
|
||||
const value = process.env[name];
|
||||
if (value == null) {
|
||||
return undefined;
|
||||
}
|
||||
if (value.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function readScheme(name: string): 'http' | 'https' {
|
||||
if (readString(name, DEFAULT_PUBLIC_SCHEME) === 'https') {
|
||||
return 'https';
|
||||
}
|
||||
return 'http';
|
||||
}
|
||||
|
||||
export function docsBaseDomain(): string {
|
||||
return readString('FLUXER_BASE_DOMAIN', DEFAULT_BASE_DOMAIN);
|
||||
}
|
||||
|
||||
export function docsPublicEndpoint(): string {
|
||||
const override = process.env.FLUXER_DOCS_ENDPOINT;
|
||||
if (override != null && override.length > 0) {
|
||||
return override;
|
||||
}
|
||||
|
||||
return buildUrl(readScheme('FLUXER_PUBLIC_SCHEME'), docsBaseDomain(), readPort('FLUXER_PUBLIC_PORT'));
|
||||
}
|
||||
|
||||
export function docsListenHost(): string {
|
||||
return readString('FLUXER_DOCS_LISTEN_HOST', DEFAULT_LISTEN_HOST);
|
||||
}
|
||||
|
||||
export function docsListenPort(): number {
|
||||
const parsed = readPort('FLUXER_DOCS_LISTEN_PORT');
|
||||
if (parsed == null) {
|
||||
return DEFAULT_LISTEN_PORT;
|
||||
}
|
||||
if (parsed < 1) {
|
||||
return DEFAULT_LISTEN_PORT;
|
||||
}
|
||||
if (parsed > 65_535) {
|
||||
return DEFAULT_LISTEN_PORT;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function docsSearchIndexing(): boolean {
|
||||
return readString('FLUXER_DOCS_SEARCH_INDEXING', 'false') === 'true';
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export const HTTPMethod = Object.freeze({
|
||||
GET: 'GET',
|
||||
HEAD: 'HEAD',
|
||||
} as const);
|
||||
|
||||
export type HTTPMethod = (typeof HTTPMethod)[keyof typeof HTTPMethod];
|
||||
|
||||
export const HTTPStatusCode = Object.freeze({
|
||||
OK: 200,
|
||||
MOVED_PERMANENTLY: 301,
|
||||
BAD_REQUEST: 400,
|
||||
NOT_FOUND: 404,
|
||||
METHOD_NOT_ALLOWED: 405,
|
||||
PAYLOAD_TOO_LARGE: 413,
|
||||
INTERNAL_SERVER_ERROR: 500,
|
||||
SERVICE_UNAVAILABLE: 503,
|
||||
} as const);
|
||||
|
||||
export type HTTPStatusCode = (typeof HTTPStatusCode)[keyof typeof HTTPStatusCode];
|
||||
|
||||
export const MIMEType = Object.freeze({
|
||||
JSON: 'application/json',
|
||||
PLAIN: 'text/plain',
|
||||
} as const);
|
||||
|
||||
export const HTTPHeader = Object.freeze({
|
||||
ALLOW: 'Allow',
|
||||
CACHE_CONTROL: 'Cache-Control',
|
||||
CONNECTION: 'Connection',
|
||||
CONTENT_LENGTH: 'Content-Length',
|
||||
CONTENT_TYPE: 'Content-Type',
|
||||
EXPECT: 'Expect',
|
||||
LOCATION: 'Location',
|
||||
PERMISSIONS_POLICY: 'Permissions-Policy',
|
||||
REFERRER_POLICY: 'Referrer-Policy',
|
||||
STRICT_TRANSPORT_SECURITY: 'Strict-Transport-Security',
|
||||
TRANSFER_ENCODING: 'Transfer-Encoding',
|
||||
USER_AGENT: 'User-Agent',
|
||||
X_CONTENT_TYPE_OPTIONS: 'X-Content-Type-Options',
|
||||
X_FRAME_OPTIONS: 'X-Frame-Options',
|
||||
} as const);
|
||||
|
||||
export const OUTBOUND_USER_AGENT = 'Fluxer (https://fluxer.app)';
|
||||
|
||||
export const CanonicalNetworkProtocol = Object.freeze({
|
||||
HTTP: 'http:',
|
||||
HTTPS: 'https:',
|
||||
} as const);
|
||||
|
||||
export type CanonicalNetworkProtocol = (typeof CanonicalNetworkProtocol)[keyof typeof CanonicalNetworkProtocol];
|
||||
|
||||
export function isCanonicalHTTPNetworkProtocol(protocol: string): boolean {
|
||||
if (protocol === CanonicalNetworkProtocol.HTTP) {
|
||||
return true;
|
||||
}
|
||||
return protocol === CanonicalNetworkProtocol.HTTPS;
|
||||
}
|
||||
|
||||
export const NodeErrorCode = Object.freeze({
|
||||
NOT_FOUND: 'ENOENT',
|
||||
NOT_DIRECTORY: 'ENOTDIR',
|
||||
} as const);
|
||||
|
||||
export function getNodeErrorCode(error: unknown): string | null {
|
||||
if (typeof error !== 'object') {
|
||||
return null;
|
||||
}
|
||||
if (error == null) {
|
||||
return null;
|
||||
}
|
||||
if (!('code' in error)) {
|
||||
return null;
|
||||
}
|
||||
const code = (error as {code: unknown}).code;
|
||||
if (typeof code !== 'string') {
|
||||
return null;
|
||||
}
|
||||
return code;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {HTTPHeader} from './HTTPConstants';
|
||||
|
||||
const PERMISSIONS_POLICY =
|
||||
'accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()';
|
||||
const REFERRER_POLICY = 'strict-origin-when-cross-origin';
|
||||
const CONTENT_TYPE_OPTIONS = 'nosniff';
|
||||
const FRAME_OPTIONS = 'DENY';
|
||||
const STRICT_TRANSPORT_SECURITY = 'max-age=31536000; includeSubDomains; preload';
|
||||
|
||||
interface SiteSecurityHeader {
|
||||
readonly name: string;
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
const INSECURE_ORIGIN_HEADERS: ReadonlyArray<SiteSecurityHeader> = Object.freeze([
|
||||
Object.freeze({name: HTTPHeader.PERMISSIONS_POLICY, value: PERMISSIONS_POLICY}),
|
||||
Object.freeze({name: HTTPHeader.REFERRER_POLICY, value: REFERRER_POLICY}),
|
||||
Object.freeze({name: HTTPHeader.X_CONTENT_TYPE_OPTIONS, value: CONTENT_TYPE_OPTIONS}),
|
||||
Object.freeze({name: HTTPHeader.X_FRAME_OPTIONS, value: FRAME_OPTIONS}),
|
||||
]);
|
||||
|
||||
const SECURE_ORIGIN_HEADERS: ReadonlyArray<SiteSecurityHeader> = Object.freeze([
|
||||
...INSECURE_ORIGIN_HEADERS,
|
||||
Object.freeze({name: HTTPHeader.STRICT_TRANSPORT_SECURITY, value: STRICT_TRANSPORT_SECURITY}),
|
||||
]);
|
||||
|
||||
export function siteSecurityHeaders(secureOrigin: boolean): ReadonlyArray<SiteSecurityHeader> {
|
||||
if (secureOrigin) {
|
||||
return SECURE_ORIGIN_HEADERS;
|
||||
}
|
||||
return INSECURE_ORIGIN_HEADERS;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {CanonicalNetworkProtocol} from './HTTPConstants';
|
||||
|
||||
const ViteDevWebSocketProtocol = Object.freeze({
|
||||
WS: 'ws',
|
||||
WSS: 'wss',
|
||||
} as const);
|
||||
|
||||
type ViteDevWebSocketProtocol = (typeof ViteDevWebSocketProtocol)[keyof typeof ViteDevWebSocketProtocol];
|
||||
|
||||
interface ViteDevWebSocketOptions {
|
||||
readonly endpoint: URL;
|
||||
readonly path: string;
|
||||
}
|
||||
|
||||
interface ViteDevWebSocketConfig {
|
||||
readonly protocol: ViteDevWebSocketProtocol;
|
||||
readonly host: string;
|
||||
readonly clientPort: number;
|
||||
readonly path: string;
|
||||
}
|
||||
|
||||
function resolveProtocol(endpoint: URL): ViteDevWebSocketProtocol {
|
||||
if (endpoint.protocol === CanonicalNetworkProtocol.HTTPS) {
|
||||
return ViteDevWebSocketProtocol.WSS;
|
||||
}
|
||||
return ViteDevWebSocketProtocol.WS;
|
||||
}
|
||||
|
||||
function resolveClientPort(endpoint: URL): number {
|
||||
if (endpoint.port.length > 0) {
|
||||
return Number.parseInt(endpoint.port, 10);
|
||||
}
|
||||
if (endpoint.protocol === CanonicalNetworkProtocol.HTTPS) {
|
||||
return 443;
|
||||
}
|
||||
return 80;
|
||||
}
|
||||
|
||||
export function resolveViteDevWebSocket({endpoint, path}: ViteDevWebSocketOptions): ViteDevWebSocketConfig {
|
||||
return {
|
||||
protocol: resolveProtocol(endpoint),
|
||||
host: endpoint.hostname,
|
||||
clientPort: resolveClientPort(endpoint),
|
||||
path,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user