open-design/apps/web/sidecar/server.ts
2026-05-19 17:50:50 +08:00

802 lines
25 KiB
TypeScript

import { spawn, type ChildProcess } from "node:child_process";
import {
createServer as createHttpServer,
request as createHttpRequest,
type IncomingMessage,
type Server as HttpServer,
type ServerResponse,
} from "node:http";
import { request as createHttpsRequest } from "node:https";
import { existsSync, readFileSync } from "node:fs";
import { readFile, rm, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { createServer as createTcpServer, type AddressInfo, type Server as TcpServer } from "node:net";
import { dirname, isAbsolute, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
SIDECAR_ENV,
SIDECAR_MESSAGES,
normalizeWebSidecarMessage,
type SidecarStamp,
type WebStatusSnapshot,
} from "@open-design/sidecar-proto";
import {
createJsonIpcServer,
type JsonIpcServerHandle,
type SidecarRuntimeContext,
} from "@open-design/sidecar";
const HOST = process.env.OD_HOST || "127.0.0.1";
if (process.env.OD_HOST != null && !/^[a-zA-Z0-9._\-:[\]@]+$/.test(process.env.OD_HOST)) {
throw new Error(`OD_HOST contains invalid characters: ${process.env.OD_HOST}`);
}
const DAEMON_HOST = "127.0.0.1";
const STANDALONE_BACKEND_HOST = "127.0.0.1";
const DAEMON_PORT_ENV = SIDECAR_ENV.DAEMON_PORT;
const WEB_DIST_DIR_ENV = SIDECAR_ENV.WEB_DIST_DIR;
const WEB_PORT_ENV = SIDECAR_ENV.WEB_PORT;
const TOOLS_DEV_PARENT_PID_ENV = SIDECAR_ENV.TOOLS_DEV_PARENT_PID;
const WEB_OUTPUT_MODE_ENV = "OD_WEB_OUTPUT_MODE";
const WEB_STANDALONE_ROOT_ENV = "OD_WEB_STANDALONE_ROOT";
const STANDALONE_PARENT_PID_ENV = "OD_STANDALONE_PARENT_PID";
const STANDALONE_STARTUP_TIMEOUT_ENV = "OD_STANDALONE_STARTUP_TIMEOUT_MS";
const SHUTDOWN_TIMEOUT_MS = 3000;
const require = createRequire(import.meta.url);
type NextApp = {
close?: () => Promise<void>;
getRequestHandler(): (request: IncomingMessage, response: ServerResponse) => Promise<void>;
prepare(): Promise<void>;
};
type StandaloneBackend = {
exitReason(): string | null;
isRunning(): boolean;
origin: string;
stop(): Promise<void>;
};
function createNextApp(options: { dev: boolean; dir: string }): NextApp {
const createNextServer = require("next") as (nextOptions: { dev: boolean; dir: string }) => NextApp;
return createNextServer(options);
}
export type WebSidecarHandle = {
status(): Promise<WebStatusSnapshot>;
stop(): Promise<void>;
waitUntilStopped(): Promise<void>;
};
function resolveWebRoot(): string {
let current = dirname(fileURLToPath(import.meta.url));
for (let depth = 0; depth < 8; depth += 1) {
try {
const packageJson = JSON.parse(readFileSync(join(current, "package.json"), "utf8")) as { name?: unknown };
if (packageJson.name === "@open-design/web") return current;
} catch {
// Keep walking until the package root is found. This must work from both
// sidecar/*.ts under tsx and dist/sidecar/*.js in packaged installs.
}
const parent = dirname(current);
if (parent === current) break;
current = parent;
}
throw new Error("failed to resolve @open-design/web package root");
}
function parsePort(value: string | undefined): number {
if (value == null || value.trim().length === 0) return 0;
const port = Number(value);
if (!Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error(`${WEB_PORT_ENV} must be an integer between 0 and 65535`);
}
return port;
}
function parsePositiveIntegerEnv(envName: string, defaultValue: number): number {
const value = process.env[envName];
if (value == null || value.trim().length === 0) return defaultValue;
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`${envName} must be a positive integer`);
}
return parsed;
}
function resolveStandaloneStartupTimeoutMs(): number {
return parsePositiveIntegerEnv(STANDALONE_STARTUP_TIMEOUT_ENV, 35_000);
}
export function createStandaloneParentMonitorImport(parentPidEnv = STANDALONE_PARENT_PID_ENV): string {
const source = `
const parentPid = Number(process.env[${JSON.stringify(parentPidEnv)}]);
if (Number.isInteger(parentPid) && parentPid > 0) {
const isParentAlive = () => {
try {
process.kill(parentPid, 0);
return true;
} catch {
return false;
}
};
const timer = setInterval(() => {
if (process.ppid === parentPid && isParentAlive()) return;
process.exit(0);
}, 1000);
timer.unref?.();
}
`;
return `data:text/javascript,${encodeURIComponent(source)}`;
}
export function createStandaloneServerArgs(entryPath: string): string[] {
return ["--import", createStandaloneParentMonitorImport(), entryPath];
}
export function resolveStandaloneBackendOrigin(port: number): string {
return `http://${STANDALONE_BACKEND_HOST}:${port}`;
}
export function createStandaloneBackendEnv(options: {
baseEnv?: NodeJS.ProcessEnv;
parentPid?: number;
port: number;
}): NodeJS.ProcessEnv {
return {
...(options.baseEnv ?? process.env),
HOSTNAME: STANDALONE_BACKEND_HOST,
NODE_ENV: "production",
PORT: String(options.port),
[STANDALONE_PARENT_PID_ENV]: String(options.parentPid ?? process.pid),
};
}
function resolveWebDistDir(webRoot: string): string {
const configured = process.env[WEB_DIST_DIR_ENV];
if (configured == null || configured.length === 0) return join(webRoot, ".next");
return isAbsolute(configured) ? configured : join(webRoot, configured);
}
function resolveConfiguredStandaloneRoot(): string | null {
const configured = process.env[WEB_STANDALONE_ROOT_ENV];
if (configured == null || configured.length === 0) return null;
return isAbsolute(configured) ? configured : join(process.cwd(), configured);
}
export function resolveStandaloneServerEntry(
webRoot: string | null = resolveWebRoot(),
standaloneRoot: string | null = resolveConfiguredStandaloneRoot(),
): string | null {
const configuredRoot = standaloneRoot == null || standaloneRoot.length === 0
? null
: isAbsolute(standaloneRoot)
? standaloneRoot
: join(process.cwd(), standaloneRoot);
const candidates = [
...(configuredRoot == null
? []
: [
join(configuredRoot, "apps", "web", "server.js"),
join(configuredRoot, "server.js"),
]),
...(webRoot == null
? []
: [
join(resolveWebDistDir(webRoot), "standalone", "apps", "web", "server.js"),
join(resolveWebDistDir(webRoot), "standalone", "server.js"),
]),
];
return candidates.find((candidate) => existsSync(candidate)) ?? null;
}
function shouldUseStandaloneOutput(runtime: SidecarRuntimeContext<SidecarStamp>): boolean {
return runtime.mode !== "dev" && process.env[WEB_OUTPUT_MODE_ENV] === "standalone";
}
function resolveDaemonOrigin(): string | null {
const port = parsePort(process.env[DAEMON_PORT_ENV]);
return port === 0 ? null : `http://${DAEMON_HOST}:${port}`;
}
function isDaemonProxyPathname(pathname: string): boolean {
return (
pathname === "/api" ||
pathname.startsWith("/api/") ||
pathname === "/artifacts" ||
pathname.startsWith("/artifacts/") ||
pathname === "/frames" ||
pathname.startsWith("/frames/")
);
}
export function resolveDaemonProxyTarget(
daemonOrigin: string,
requestUrl: string | undefined,
): URL | null {
const target = resolveHttpProxyTarget(daemonOrigin, requestUrl);
if (target == null || !isDaemonProxyPathname(target.pathname)) return null;
return target;
}
function resolveHttpProxyTarget(
origin: string,
requestUrl: string | undefined,
): URL | null {
if (requestUrl == null) return null;
let parsedRequestUrl: URL;
try {
parsedRequestUrl = new URL(requestUrl, `http://${HOST}`);
} catch {
return null;
}
return new URL(`${parsedRequestUrl.pathname}${parsedRequestUrl.search}`, origin);
}
export function normalizeDaemonProxyOriginHeader(options: {
daemonOrigin: string;
origin: string | undefined;
requestHost?: string | string[];
webPort: number;
}): string | undefined {
if (options.origin == null || options.origin.length === 0) return options.origin;
const schemes = ["http", "https"];
const loopbackHosts = ["127.0.0.1", "localhost", "[::1]", HOST];
const allowedWebOrigins = new Set(
schemes.flatMap((scheme) => loopbackHosts.map((host) => `${scheme}://${host}:${options.webPort}`)),
);
if (allowedWebOrigins.has(options.origin)) return options.daemonOrigin;
const parsedOrigin = parseHttpOrigin(options.origin);
if (
parsedOrigin != null &&
isSameBrowserHostOrigin({
origin: parsedOrigin,
requestHost: options.requestHost,
webPort: options.webPort,
})
) {
return options.daemonOrigin;
}
return options.origin;
}
function firstHeaderValue(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
function parseHostHeader(value: string | string[] | undefined): URL | null {
const raw = firstHeaderValue(value)?.trim();
if (raw == null || raw.length === 0) return null;
try {
return new URL(`http://${raw}`);
} catch {
return null;
}
}
function parseHttpOrigin(value: string): URL | null {
try {
const parsed = new URL(value);
return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed : null;
} catch {
return null;
}
}
function parseAllowedDevHost(value: string): string | null {
const trimmed = value.trim();
if (trimmed.length === 0) return null;
try {
return new URL(trimmed).hostname.toLowerCase();
} catch {
try {
return new URL(`http://${trimmed}`).hostname.toLowerCase();
} catch {
return null;
}
}
}
function configuredAllowedDevHosts(): Set<string> {
return new Set(
(process.env.OD_ALLOWED_DEV_ORIGINS ?? "")
.split(",")
.map(parseAllowedDevHost)
.filter((host): host is string => host != null),
);
}
function isAllowedDevHost(hostname: string, allowedHosts: Set<string>): boolean {
const host = hostname.toLowerCase();
if (allowedHosts.has(host)) return true;
for (const allowedHost of allowedHosts) {
if (!allowedHost.startsWith("*.")) continue;
const suffix = allowedHost.slice(1);
if (host.endsWith(suffix) && host.length > suffix.length) return true;
}
return false;
}
function parseIpv4(value: string): [number, number, number, number] | null {
const parts = value.split(".");
if (parts.length !== 4) return null;
if (!parts.every((part) => /^\d+$/.test(part))) return null;
const octets = parts.map((part) => Number(part));
if (!octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255)) return null;
return octets as [number, number, number, number];
}
function isPrivateLanIpv4(value: string): boolean {
const octets = parseIpv4(value);
if (octets == null) return false;
const [a, b] = octets;
return (
a === 10 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 169 && b === 254) ||
(a === 100 && b >= 64 && b <= 127)
);
}
function isLoopbackOrPrivateLanHost(hostname: string): boolean {
const host = hostname.toLowerCase();
return (
host === "localhost" ||
host === "127.0.0.1" ||
host === "::1" ||
host === "[::1]" ||
host === "0.0.0.0" ||
host === "::" ||
isPrivateLanIpv4(host)
);
}
function defaultPortForProtocol(protocol: string): string {
return protocol === "https:" ? "443" : "80";
}
function isSameBrowserHostOrigin(options: {
origin: URL;
requestHost?: string | string[];
webPort: number;
}): boolean {
const requestHost = parseHostHeader(options.requestHost);
if (requestHost == null) return false;
const originPort = options.origin.port || defaultPortForProtocol(options.origin.protocol);
const requestPort = requestHost.port || "80";
if (originPort !== String(options.webPort) || requestPort !== originPort) return false;
if (requestHost.hostname.toLowerCase() !== options.origin.hostname.toLowerCase()) return false;
const allowedDevHosts = configuredAllowedDevHosts();
const originHost = options.origin.hostname.toLowerCase();
return isLoopbackOrPrivateLanHost(originHost) || isAllowedDevHost(originHost, allowedDevHosts);
}
async function proxyHttpRequest(
target: URL,
request: IncomingMessage,
response: ServerResponse,
options: { daemonWebPort?: number } = {},
): Promise<void> {
const proxyRequestFactory = target.protocol === "https:" ? createHttpsRequest : createHttpRequest;
const headers = { ...request.headers, host: target.host };
if (options.daemonWebPort != null) {
const origin = normalizeDaemonProxyOriginHeader({
daemonOrigin: target.origin,
origin: typeof request.headers.origin === "string" ? request.headers.origin : undefined,
requestHost: request.headers.host,
webPort: options.daemonWebPort,
});
if (origin == null || origin.length === 0) {
delete headers.origin;
} else {
headers.origin = origin;
}
}
await new Promise<void>((resolveProxy) => {
const proxyRequest = proxyRequestFactory(
target,
{
headers,
method: request.method,
},
(proxyResponse) => {
response.writeHead(proxyResponse.statusCode ?? 502, proxyResponse.headers);
proxyResponse.pipe(response);
proxyResponse.on("end", resolveProxy);
},
);
proxyRequest.on("error", (error) => {
if (!response.headersSent) {
response.statusCode = 502;
response.setHeader("content-type", "text/plain; charset=utf-8");
}
response.end(error instanceof Error ? error.message : String(error));
resolveProxy();
});
request.pipe(proxyRequest);
});
}
async function prepareNextApp(app: { prepare(): Promise<void> }, dir: string): Promise<void> {
const nextEnvPath = join(dir, "next-env.d.ts");
const previousNextEnv = await readFile(nextEnvPath, "utf8").catch(() => null);
await app.prepare();
if (previousNextEnv == null) {
await rm(nextEnvPath, { force: true }).catch(() => undefined);
return;
}
await writeFile(nextEnvPath, previousNextEnv, "utf8").catch(() => undefined);
}
async function listen(server: HttpServer | TcpServer, port: number, host = HOST): Promise<number> {
await new Promise<void>((resolveListen, rejectListen) => {
server.once("error", rejectListen);
server.listen({ host, port }, () => {
server.off("error", rejectListen);
resolveListen();
});
});
const address = server.address() as AddressInfo | string | null;
if (address == null || typeof address === "string") {
throw new Error("failed to resolve Next.js server address");
}
return address.port;
}
async function closeServer(server: HttpServer | TcpServer): Promise<void> {
if (!server.listening) return;
await new Promise<void>((resolveClose, rejectClose) => {
server.close((error) => (error == null ? resolveClose() : rejectClose(error)));
});
}
async function reserveTcpPort(host = HOST): Promise<number> {
const server = createTcpServer();
try {
return await listen(server, 0, host);
} finally {
await closeServer(server).catch(() => undefined);
}
}
async function waitForChildExit(child: ChildProcess): Promise<void> {
if (child.exitCode != null || child.signalCode != null) return;
await new Promise<void>((resolveExit) => {
child.once("exit", () => resolveExit());
});
}
async function stopStandaloneChild(child: ChildProcess): Promise<void> {
if (child.exitCode != null || child.signalCode != null) return;
child.kill("SIGTERM");
let timeout: NodeJS.Timeout | undefined;
try {
await Promise.race([
waitForChildExit(child),
new Promise<void>((resolveTimeout) => {
timeout = setTimeout(resolveTimeout, SHUTDOWN_TIMEOUT_MS);
timeout.unref();
}),
]);
} finally {
if (timeout != null) clearTimeout(timeout);
}
if (child.exitCode == null && child.signalCode == null) {
child.kill("SIGKILL");
await waitForChildExit(child).catch(() => undefined);
}
}
async function probeStandaloneBackend(origin: string): Promise<boolean> {
return await new Promise<boolean>((resolveProbe) => {
const request = createHttpRequest(new URL("/", origin), { method: "HEAD", timeout: 800 }, (response) => {
response.resume();
resolveProbe(true);
});
request.on("timeout", () => {
request.destroy();
resolveProbe(false);
});
request.on("error", () => resolveProbe(false));
request.end();
});
}
async function waitForStandaloneBackendReady(
child: ChildProcess,
origin: string,
timeoutMs = resolveStandaloneStartupTimeoutMs(),
): Promise<void> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (child.exitCode != null || child.signalCode != null) {
const elapsedMs = Date.now() - startedAt;
const likelyPortRace = elapsedMs <= 200;
throw new Error(
`standalone Next.js server exited before readiness after ${elapsedMs}ms: code=${child.exitCode} signal=${child.signalCode}`
+ (likelyPortRace
? "; the reserved startup port may have been claimed before the child process bound it, retry the launch"
: ""),
);
}
if (await probeStandaloneBackend(origin)) return;
await new Promise((resolveWait) => setTimeout(resolveWait, 150));
}
throw new Error(`timed out after ${timeoutMs}ms waiting for standalone Next.js server at ${origin}; override with ${STANDALONE_STARTUP_TIMEOUT_ENV}`);
}
async function startStandaloneBackend(webRoot: string | null): Promise<StandaloneBackend> {
const entryPath = resolveStandaloneServerEntry(webRoot);
if (entryPath == null) {
throw new Error(
webRoot == null
? `missing Next.js standalone server under ${WEB_STANDALONE_ROOT_ENV}; configure ${WEB_STANDALONE_ROOT_ENV} or install @open-design/web`
: `missing Next.js standalone server under ${resolveWebDistDir(webRoot)}; rebuild with ${WEB_OUTPUT_MODE_ENV}=standalone`,
);
}
const port = await reserveTcpPort(STANDALONE_BACKEND_HOST);
const origin = resolveStandaloneBackendOrigin(port);
console.log(`[open-design web] starting standalone Next.js server from ${entryPath}`);
const child = spawn(process.execPath, createStandaloneServerArgs(entryPath), {
cwd: dirname(entryPath),
env: createStandaloneBackendEnv({ port }),
stdio: ["ignore", "inherit", "inherit"],
...(process.platform === "win32" ? { windowsHide: true } : {}),
});
await new Promise<void>((resolveSpawn, rejectSpawn) => {
child.once("error", rejectSpawn);
child.once("spawn", resolveSpawn);
});
let standaloneRunning = true;
let standaloneExitReason: string | null = null;
child.once("exit", (code, signal) => {
standaloneRunning = false;
standaloneExitReason = `code=${code ?? "null"} signal=${signal ?? "null"}`;
console.error(`[open-design web] standalone Next.js server exited ${standaloneExitReason}`);
});
try {
await waitForStandaloneBackendReady(child, origin);
} catch (error) {
await stopStandaloneChild(child).catch(() => undefined);
throw error;
}
return {
exitReason() {
return standaloneExitReason;
},
isRunning() {
return standaloneRunning && child.exitCode == null && child.signalCode == null;
},
origin,
async stop() {
await stopStandaloneChild(child);
},
};
}
async function settleShutdownTask(task: Promise<unknown> | undefined): Promise<void> {
if (task == null) return;
let timeout: NodeJS.Timeout | undefined;
try {
await Promise.race([
task.catch(() => undefined),
new Promise<void>((resolveTimeout) => {
timeout = setTimeout(resolveTimeout, SHUTDOWN_TIMEOUT_MS);
timeout.unref();
}),
]);
} finally {
if (timeout != null) clearTimeout(timeout);
}
}
function stopThenExit(stop: () => Promise<void>): void {
const hardExit = setTimeout(() => process.exit(0), SHUTDOWN_TIMEOUT_MS + 1000);
hardExit.unref();
void stop().finally(() => {
clearTimeout(hardExit);
process.exit(0);
});
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function attachParentMonitor(stop: () => Promise<void>): void {
const parentPid = Number(process.env[TOOLS_DEV_PARENT_PID_ENV]);
if (!Number.isInteger(parentPid) || parentPid <= 0) return;
const timer = setInterval(() => {
if (isProcessAlive(parentPid)) return;
clearInterval(timer);
stopThenExit(stop);
}, 1000);
timer.unref();
}
async function createWebSidecarHandle(
runtime: SidecarRuntimeContext<SidecarStamp>,
httpServer: HttpServer,
closeRuntime: () => Promise<void> | void,
isRuntimeRunning?: () => boolean,
): Promise<WebSidecarHandle> {
const port = await listen(httpServer, parsePort(process.env[WEB_PORT_ENV]));
const state: WebStatusSnapshot = {
pid: process.pid,
state: "running",
updatedAt: new Date().toISOString(),
url: `http://${HOST}:${port}`,
};
let ipcServer: JsonIpcServerHandle | null = null;
let stopped = false;
let resolveStopped!: () => void;
const stoppedPromise = new Promise<void>((resolveStop) => {
resolveStopped = resolveStop;
});
function refreshRuntimeState(): void {
if (stopped || isRuntimeRunning == null || isRuntimeRunning()) return;
state.state = "stopped";
state.url = null;
state.updatedAt = new Date().toISOString();
}
async function stop(): Promise<void> {
if (stopped) return;
stopped = true;
state.state = "stopped";
state.updatedAt = new Date().toISOString();
await settleShutdownTask(ipcServer?.close());
await settleShutdownTask(closeServer(httpServer));
await settleShutdownTask(Promise.resolve().then(closeRuntime));
resolveStopped();
}
attachParentMonitor(stop);
ipcServer = await createJsonIpcServer({
socketPath: runtime.ipc,
handler: async (message: unknown) => {
const request = normalizeWebSidecarMessage(message);
switch (request.type) {
case SIDECAR_MESSAGES.STATUS:
refreshRuntimeState();
return { ...state };
case SIDECAR_MESSAGES.SHUTDOWN:
setImmediate(() => {
stopThenExit(stop);
});
return { accepted: true };
}
},
});
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
stopThenExit(stop);
});
}
return {
async status() {
refreshRuntimeState();
return { ...state };
},
stop,
waitUntilStopped() {
return stoppedPromise;
},
};
}
function createDaemonProxyHandler(
daemonOrigin: string | null,
fallback: (request: IncomingMessage, response: ServerResponse) => Promise<void>,
): (request: IncomingMessage, response: ServerResponse) => void {
return (request, response) => {
const daemonProxyTarget = daemonOrigin == null ? null : resolveDaemonProxyTarget(daemonOrigin, request.url);
if (daemonProxyTarget != null) {
const localPort = request.socket.localPort;
void proxyHttpRequest(daemonProxyTarget, request, response, {
daemonWebPort: typeof localPort === "number" ? localPort : 0,
}).catch((error: unknown) => {
response.statusCode = 502;
response.end(error instanceof Error ? error.message : String(error));
});
return;
}
void fallback(request, response).catch((error: unknown) => {
response.statusCode = 500;
response.end(error instanceof Error ? error.message : String(error));
});
};
}
async function startRegularNextSidecar(
runtime: SidecarRuntimeContext<SidecarStamp>,
webRoot: string,
): Promise<WebSidecarHandle> {
const app = createNextApp({ dev: process.env.OD_WEB_PROD !== "1" && runtime.mode === "dev", dir: webRoot });
await prepareNextApp(app, webRoot);
const daemonOrigin = resolveDaemonOrigin();
const handleRequest = app.getRequestHandler();
const httpServer = createHttpServer(createDaemonProxyHandler(daemonOrigin, handleRequest));
return await createWebSidecarHandle(runtime, httpServer, async () => {
await app.close?.();
});
}
async function startStandaloneNextSidecar(
runtime: SidecarRuntimeContext<SidecarStamp>,
webRoot: string | null,
): Promise<WebSidecarHandle> {
const daemonOrigin = resolveDaemonOrigin();
const backend = await startStandaloneBackend(webRoot);
const httpServer = createHttpServer(createDaemonProxyHandler(daemonOrigin, async (request, response) => {
if (!backend.isRunning()) {
response.statusCode = 502;
response.end(`standalone Next.js server is not running${backend.exitReason() == null ? "" : ` (${backend.exitReason()})`}`);
return;
}
const target = resolveHttpProxyTarget(backend.origin, request.url);
if (target == null) {
response.statusCode = 400;
response.end("invalid request URL");
return;
}
await proxyHttpRequest(target, request, response);
}));
try {
return await createWebSidecarHandle(runtime, httpServer, backend.stop, backend.isRunning);
} catch (error) {
await backend.stop().catch(() => undefined);
throw error;
}
}
export async function startWebSidecar(runtime: SidecarRuntimeContext<SidecarStamp>): Promise<WebSidecarHandle> {
if (shouldUseStandaloneOutput(runtime)) {
const webRoot = resolveConfiguredStandaloneRoot() == null ? resolveWebRoot() : null;
return await startStandaloneNextSidecar(runtime, webRoot);
}
const webRoot = resolveWebRoot();
return await startRegularNextSidecar(runtime, webRoot);
}