Files
2026-02-13 22:02:30 +01:00

422 lines
16 KiB
JavaScript

import { a as green, c as url, i as gray, l as yellow, n as bold, o as magenta, r as cyan, s as red } from "./_chunks/_utils.mjs";
import { loadServerEntry } from "./loader.mjs";
import { parseArgs } from "node:util";
import { fileURLToPath } from "node:url";
import { fork } from "node:child_process";
import { createReadStream, existsSync, statSync } from "node:fs";
import { dirname, relative, resolve } from "node:path";
import { Readable } from "node:stream";
const NO_ENTRY_ERROR = "No server entry or public directory found";
async function cliServe(cliOpts) {
try {
if (!process.env.NODE_ENV) process.env.NODE_ENV = cliOpts.prod ? "production" : "development";
const loaded = await loadServerEntry({
entry: cliOpts.entry,
dir: cliOpts.dir
});
const { serve: srvxServe } = loaded.nodeCompat ? await import("srvx/node") : await import("srvx");
const { serveStatic } = await import("srvx/static");
const { log } = await import("srvx/log");
const staticDir = resolve(cliOpts.dir || (loaded.url ? dirname(fileURLToPath(loaded.url)) : "."), cliOpts.static || "public");
cliOpts.static = existsSync(staticDir) ? staticDir : "";
if (loaded.notFound && !cliOpts.static) {
process.send?.({ error: "no-entry" });
throw new Error(NO_ENTRY_ERROR, { cause: cliOpts });
}
const serverOptions = {
...loaded.module?.default,
default: void 0,
...loaded.module
};
printInfo(cliOpts, loaded);
await (globalThis.__srvx__ = srvxServe({
...serverOptions,
gracefulShutdown: !!cliOpts.prod,
port: cliOpts.port ?? serverOptions.port,
hostname: cliOpts.hostname ?? cliOpts.host ?? serverOptions.hostname,
tls: cliOpts.tls ? {
cert: cliOpts.cert,
key: cliOpts.key
} : void 0,
error: (error) => {
console.error(error);
return renderError(cliOpts, error);
},
fetch: loaded.fetch || (() => renderError(cliOpts, loaded.notFound ? "Server Entry Not Found" : "No Fetch Handler Exported", 501)),
middleware: [
log(),
cliOpts.static ? serveStatic({ dir: cliOpts.static }) : void 0,
...serverOptions.middleware || []
].filter(Boolean)
})).ready();
} catch (error) {
console.error(error);
process.exit(1);
}
}
function renderError(cliOpts, error, status = 500, title = "Server Error") {
let html = `<!DOCTYPE html><html><head><title>${title}</title></head><body>`;
if (cliOpts.prod) html += `<h1>${title}</h1><p>Something went wrong while processing your request.</p>`;
else html += `
<style>
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background: #f8f9fa; color: #333; }
h1 { color: #dc3545; }
pre { background: #fff; padding: 10px; border-radius: 5px; overflow: auto; }
code { font-family: monospace; }
#error { display: flex; flex-direction: column; justify-content: center; align-items: center; height: 100vh; }
</style>
<div id="error"><h1>${title}</h1><pre>${error instanceof Error ? error.stack || error.message : String(error)}</pre></div>
`;
return new Response(html, {
status,
headers: { "Content-Type": "text/html; charset=utf-8" }
});
}
function printInfo(cliOpts, loaded) {
let entryInfo;
if (loaded.notFound) entryInfo = gray(`(create ${bold(`server.ts`)})`);
else entryInfo = loaded.fetch ? cyan("./" + relative(".", fileURLToPath(loaded.url))) : red(`No fetch handler exported from ${loaded.url}`);
console.log(gray(`${bold(gray("◆"))} Server handler: ${entryInfo}`));
let staticInfo;
if (cliOpts.static) staticInfo = cyan("./" + relative(".", cliOpts.static) + "/");
else staticInfo = gray(`(create ${bold("public/")} dir)`);
console.log(gray(`${bold(gray("◇"))} Static files: ${staticInfo}`));
console.log("");
}
async function cliFetch(cliOpts) {
const stdin = cliOpts.stdin || process.stdin;
const stdout = cliOpts.stdout || process.stdout;
const stderr = cliOpts.stderr || process.stderr;
let fetchHandler = globalThis.fetch;
let inputURL = cliOpts.url || "/";
if (inputURL[0] === "/") {
const loaded = await loadServerEntry({
dir: cliOpts.dir,
entry: cliOpts.entry,
...cliOpts?.loader
});
if (cliOpts.verbose && loaded.url) {
stderr.write(`* Entry: ${fileURLToPath(loaded.url)}\n`);
if (loaded.nodeCompat) stderr.write(`* Using node compat mode\n`);
}
if (loaded.notFound) throw new Error(`Server entry file not found in ${resolve(cliOpts.dir || ".")}`, { cause: {
dir: cliOpts.dir || process.cwd(),
entry: cliOpts.entry || void 0
} });
else if (!loaded.fetch) throw new Error("No fetch handler exported", { cause: {
dir: cliOpts.dir || process.cwd(),
entry: cliOpts.entry || void 0,
loaded
} });
fetchHandler = loaded.fetch;
} else {
stderr.write(`* Fetching remote URL: ${inputURL}\n`);
if (!URL?.canParse(inputURL)) inputURL = `http${cliOpts.tls ? "s" : ""}://${inputURL}`;
fetchHandler = globalThis.fetch;
}
const headers = new Headers();
if (cliOpts.header) for (const header of cliOpts.header) {
const colonIndex = header.indexOf(":");
if (colonIndex > 0) {
const name = header.slice(0, colonIndex).trim();
const value = header.slice(colonIndex + 1).trim();
headers.append(name, value);
}
}
if (!headers.has("User-Agent")) headers.set("User-Agent", "srvx (curl)");
if (!headers.has("Accept")) headers.set("Accept", "text/markdown, application/json;q=0.9, text/plain;q=0.8, text/html;q=0.7, text/*;q=0.6, */*;q=0.5");
let body;
if (cliOpts.data !== void 0) if (cliOpts.data === "@-") body = new ReadableStream({ async start(controller) {
for await (const chunk of stdin) controller.enqueue(chunk);
controller.close();
} });
else if (cliOpts.data.startsWith("@")) body = Readable.toWeb(createReadStream(cliOpts.data.slice(1)));
else body = cliOpts.data;
const method = cliOpts.method || (body === void 0 ? "GET" : "POST");
const url = new URL(inputURL, `http${cliOpts.tls ? "s" : ""}://${cliOpts.host || cliOpts.hostname || "localhost"}`);
const req = new Request(url, {
method,
headers,
body
});
if (cliOpts.verbose) {
const parsedUrl = new URL(url);
stderr.write(`> ${method} ${parsedUrl.pathname}${parsedUrl.search} HTTP/1.1\n`);
stderr.write(`> Host: ${parsedUrl.host}\n`);
for (const [name, value] of headers) stderr.write(`> ${name}: ${value}\n`);
stderr.write(">\n");
}
const res = await fetchHandler(req);
if (cliOpts.verbose) {
stderr.write(`< HTTP/1.1 ${res.status} ${res.statusText}\n`);
for (const [name, value] of res.headers) stderr.write(`< ${name}: ${value}\n`);
stderr.write("<\n");
}
if (res.body) {
const { isBinary, encoding } = getResponseFormat(res);
if (isBinary) for await (const chunk of res.body) stdout.write(chunk);
else {
const decoder = new TextDecoder(encoding);
for await (const chunk of res.body) stdout.write(decoder.decode(chunk, { stream: true }));
const remaining = decoder.decode();
if (remaining) stdout.write(remaining);
if (stdout.isTTY) stdout.write("\n");
}
}
return res;
}
function getResponseFormat(res) {
const contentType = res.headers.get("content-type") || "";
return {
isBinary: contentType.startsWith("application/octet-stream") || contentType.startsWith("image/") || contentType.startsWith("audio/") || contentType.startsWith("video/") || contentType.startsWith("application/pdf") || contentType.startsWith("application/zip") || contentType.startsWith("application/gzip"),
encoding: contentType.includes("charset=") ? contentType.split("charset=")[1].split(";")[0].trim() : "utf8"
};
}
const srvxMeta = {
name: "srvx",
version: "0.11.3",
description: "Universal Server."
};
function usage(mainOpts) {
const command = mainOpts.usage?.command || "srvx";
const name = mainOpts.meta?.name || srvxMeta.name;
const ver = mainOpts.meta?.version || srvxMeta.version;
const desc = mainOpts.meta?.description || srvxMeta.description;
return `
${cyan(name)}${gray(`${ver ? ` ${ver}` : ""} ${desc ? `- ${desc}` : ""}`)}
${bold("SERVE MODE")}
${bold(green(`# ${command} serve [options]`))}
${gray("$")} ${cyan(command)} serve --entry ${gray("./server.ts")} ${gray("# Start development server")}
${gray("$")} ${cyan(command)} serve --prod ${gray("# Start production server")}
${gray("$")} ${cyan(command)} serve --port=8080 ${gray("# Listen on port 8080")}
${gray("$")} ${cyan(command)} serve --host=localhost ${gray("# Bind to localhost only")}
${gray("$")} ${cyan(command)} serve --import=jiti/register ${gray(`# Enable ${url("jiti", "https://github.com/unjs/jiti")} loader`)}
${gray("$")} ${cyan(command)} serve --tls --cert=cert.pem --key=key.pem ${gray("# Enable TLS (HTTPS/HTTP2)")}
${bold("FETCH MODE")}
${bold(green(`# ${command} fetch|curl [options] [url]`))}
${gray("$")} ${cyan(command)} fetch ${gray("# Fetch from default entry")}
${gray("$")} ${cyan(command)} fetch /api/users ${gray("# Fetch a specific URL/path")}
${gray("$")} ${cyan(command)} fetch --entry ./server.ts /api/users ${gray("# Fetch using a specific entry")}
${gray("$")} ${cyan(command)} fetch -X POST /api/users ${gray("# POST request")}
${gray("$")} ${cyan(command)} fetch -H "Content-Type: application/json" /api ${gray("# With headers")}
${gray("$")} ${cyan(command)} fetch -d '{"name":"foo"}' /api ${gray("# With request body")}
${gray("$")} ${cyan(command)} fetch -v /api/users ${gray("# Verbose output (show headers)")}
${gray("$")} echo '{"name":"foo"}' | ${cyan(command)} fetch -d @- /api ${gray("# Body from stdin")}
${bold("COMMON OPTIONS")}
${green("--entry")} ${yellow("<file>")} Server entry file to use
${green("--dir")} ${yellow("<dir>")} Working directory for resolving entry file
${green("-h, --help")} Show this help message
${green("--version")} Show server and runtime versions
${bold("SERVE OPTIONS")}
${green("-p, --port")} ${yellow("<port>")} Port to listen on (default: ${yellow("3000")})
${green("--host")} ${yellow("<host>")} Host to bind to (default: all interfaces)
${green("-s, --static")} ${yellow("<dir>")} Serve static files from the specified directory (default: ${yellow("public")})
${green("--prod")} Run in production mode (no watch, no debug)
${green("--import")} ${yellow("<loader>")} ES module to preload
${green("--tls")} Enable TLS (HTTPS/HTTP2)
${green("--cert")} ${yellow("<file>")} TLS certificate file
${green("--key")} ${yellow("<file>")} TLS private key file
${bold("FETCH OPTIONS")}
${green("-X, --request")} ${yellow("<method>")} HTTP method (default: ${yellow("GET")}, or ${yellow("POST")} if body is provided)
${green("-H, --header")} ${yellow("<header>")} Add header (format: "Name: Value", can be used multiple times)
${green("-d, --data")} ${yellow("<data>")} Request body (use ${yellow("@-")} for stdin, ${yellow("@file")} for file)
${green("-v, --verbose")} Show request and response headers
${bold("ENVIRONMENT")}
${green("PORT")} Override port
${green("HOST")} Override host
${green("NODE_ENV")} Set to ${yellow("production")} for production mode.
${mainOpts.usage?.docs ? `${url("Documentation", mainOpts.usage.docs)}` : ""}
${mainOpts.usage?.issues ? `${url("Report issues", mainOpts.usage.issues)}` : ""}
`.trim();
}
async function main(mainOpts) {
const args = process.argv.slice(2);
const cliOpts = parseArgs$1(args);
if (cliOpts.version) {
process.stdout.write(versions(mainOpts).join("\n") + "\n");
process.exit(0);
}
if (cliOpts.help) {
console.log(usage(mainOpts));
process.exit(cliOpts.help ? 0 : 1);
}
if (cliOpts.mode === "fetch") try {
const res = await cliFetch(cliOpts);
process.exit(res.ok ? 0 : 22);
} catch (error) {
console.error(error);
process.exit(1);
}
if (process.send) return startServer(cliOpts);
console.log(gray([...versions(mainOpts), cliOpts.prod ? "prod" : "dev"].join(" · ")));
const envFiles = [".env", cliOpts.prod ? ".env.production" : ".env.local"].filter((f) => existsSync(f));
if (envFiles.length > 0) console.log(`${gray(`Loading environment variables from ${magenta(envFiles.join(", "))}`)}`);
if (cliOpts.prod && !cliOpts.import) {
for (const envFile of [...envFiles].reverse()) process.loadEnvFile?.(envFile);
await startServer(cliOpts);
return;
}
const isBun = !!process.versions.bun;
const isDeno = !!process.versions.deno;
const isNode = !isBun && !isDeno;
const runtimeArgs = [];
runtimeArgs.push(...envFiles.map((f) => `--env-file=${f}`));
if (!cliOpts.prod) runtimeArgs.push("--watch");
if (cliOpts.import && (isNode || isBun)) runtimeArgs.push(`--import=${cliOpts.import}`);
await forkCLI(args, runtimeArgs);
}
function parseArgs$1(args) {
const pArg0 = args.find((a) => !a.startsWith("-"));
const mode = pArg0 === "fetch" || pArg0 === "curl" ? "fetch" : "serve";
const commonArgs = {
help: { type: "boolean" },
version: { type: "boolean" },
dir: { type: "string" },
entry: { type: "string" },
host: { type: "string" },
hostname: { type: "string" },
tls: { type: "boolean" }
};
if (mode === "serve") {
const { values, positionals } = parseArgs({
args,
allowPositionals: true,
options: {
...commonArgs,
url: { type: "string" },
prod: { type: "boolean" },
port: {
type: "string",
short: "p"
},
static: {
type: "string",
short: "s"
},
import: { type: "string" },
cert: { type: "string" },
key: { type: "string" }
}
});
if (positionals[0] === "serve") positionals.shift();
const maybeEntryOrDir = positionals[0];
if (maybeEntryOrDir) {
if (values.entry || values.dir) throw new Error("Cannot specify entry or dir as positional argument when --entry or --dir is used!");
if (statSync(maybeEntryOrDir).isDirectory()) values.dir = maybeEntryOrDir;
else values.entry = maybeEntryOrDir;
}
return {
mode,
...values
};
}
const { values, positionals } = parseArgs({
args,
allowPositionals: true,
options: {
...commonArgs,
url: { type: "string" },
method: {
type: "string",
short: "X"
},
request: { type: "string" },
header: {
type: "string",
multiple: true,
short: "H"
},
verbose: {
type: "boolean",
short: "v"
},
data: {
type: "string",
short: "d"
}
}
});
if (positionals[0] === "fetch" || positionals[0] === "curl") positionals.shift();
const method = values.method || values.request;
const url = values.url || positionals[0] || "/";
return {
mode,
...values,
url,
method
};
}
async function startServer(cliOpts) {
setupProcessErrorHandlers();
await cliServe(cliOpts);
}
async function forkCLI(args, runtimeArgs) {
const child = fork(fileURLToPath(globalThis.__SRVX_BIN__ || new URL("../bin/srvx.mjs", import.meta.url)), [...args], { execArgv: [...process.execArgv, ...runtimeArgs].filter(Boolean) });
child.on("error", (error) => {
console.error("Error in child process:", error);
process.exit(1);
});
child.on("exit", (code) => {
if (code !== 0) {
console.error(`Child process exited with code ${code}`);
process.exit(code);
}
});
child.on("message", (msg) => {
if (msg && msg.error === "no-entry") {
console.error("\n" + red(NO_ENTRY_ERROR) + "\n");
process.exit(3);
}
});
let cleanupCalled = false;
const cleanup = (signal, exitCode) => {
if (cleanupCalled) return;
cleanupCalled = true;
try {
child.kill(signal || "SIGTERM");
} catch (error) {
console.error("Error killing child process:", error);
}
if (exitCode !== void 0) process.exit(exitCode);
};
process.on("exit", () => cleanup("SIGTERM"));
process.on("SIGTERM", () => cleanup("SIGTERM", 143));
if (args.includes("--watch")) process.on("SIGINT", () => cleanup("SIGINT", 130));
}
function setupProcessErrorHandlers() {
process.on("uncaughtException", (error) => {
console.error("Uncaught exception:", error);
process.exit(1);
});
process.on("unhandledRejection", (reason) => {
console.error("Unhandled rejection:", reason);
process.exit(1);
});
}
function versions(mainOpts) {
const versions = [];
if (mainOpts.meta?.name) versions.push(`${mainOpts.meta.name} ${mainOpts.meta.version || ""}`.trim());
versions.push(`${srvxMeta.name} ${srvxMeta.version}`);
versions.push(runtime());
return versions;
}
function runtime() {
if (process.versions.bun) return `bun ${process.versions.bun}`;
else if (process.versions.deno) return `deno ${process.versions.deno}`;
else return `node ${process.versions.node}`;
}
export { cliFetch, main };