mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 07:48:14 +00:00
feat: ditch hono
This commit is contained in:
+203
@@ -0,0 +1,203 @@
|
||||
// MARK: HTTP Runtime
|
||||
//
|
||||
// Bare Node http(s) runtime that hosts a `RequestListener` (typically the
|
||||
// React Router request listener from `@react-router/node`) and serves
|
||||
// static assets out of a directory.
|
||||
import { createReadStream } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
import {
|
||||
type IncomingMessage,
|
||||
type RequestListener,
|
||||
type Server,
|
||||
createServer as createHttpServer,
|
||||
} from "node:http";
|
||||
import type { ServerResponse } from "node:http";
|
||||
import {
|
||||
type ServerOptions as HttpsServerOptions,
|
||||
createServer as createHttpsServer,
|
||||
} from "node:https";
|
||||
import { extname, normalize, resolve, sep } from "node:path";
|
||||
|
||||
import mime from "mime";
|
||||
|
||||
export interface Logger {
|
||||
info: (msg: string, ...args: unknown[]) => void;
|
||||
error: (msg: string, ...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
// TODO: Replace with Pino!
|
||||
const defaultLogger: Logger = {
|
||||
info: (msg, ...args) => console.log(`[runtime] ${msg}`, ...args),
|
||||
error: (msg, ...args) => console.error(`[runtime] ${msg}`, ...args),
|
||||
};
|
||||
|
||||
export interface StaticOptions {
|
||||
root: string;
|
||||
basename: string;
|
||||
assetsDir: string;
|
||||
immutableAssets: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a static-file middleware. The callback resolves to `true` when
|
||||
* the request was served, `false` when the caller should fall through to
|
||||
* the next handler (e.g. the React Router request listener).
|
||||
*/
|
||||
function createStaticHandler(opts: StaticOptions) {
|
||||
const root = resolve(opts.root);
|
||||
const prefix = opts.basename.endsWith("/") ? opts.basename : `${opts.basename}/`;
|
||||
const assetsPrefix = `${prefix}${opts.assetsDir}/`;
|
||||
|
||||
return async function serveStatic(req: IncomingMessage, res: ServerResponse) {
|
||||
if (req.method !== "GET" && req.method !== "HEAD") return false;
|
||||
if (!req.url) return false;
|
||||
|
||||
let pathname: string;
|
||||
try {
|
||||
pathname = decodeURIComponent(new URL(req.url, "http://localhost").pathname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!pathname.startsWith(prefix)) return false;
|
||||
const rel = pathname.slice(prefix.length);
|
||||
if (!rel || rel.endsWith("/")) return false;
|
||||
|
||||
// Resolve and confine to root to prevent path traversal.
|
||||
const file = resolve(root, normalize(rel));
|
||||
if (file !== root && !file.startsWith(root + sep)) return false;
|
||||
|
||||
let st;
|
||||
try {
|
||||
st = await stat(file);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!st.isFile()) return false;
|
||||
|
||||
const isAsset = pathname.startsWith(assetsPrefix);
|
||||
res.setHeader(
|
||||
"Cache-Control",
|
||||
isAsset && opts.immutableAssets
|
||||
? "public, max-age=31536000, immutable"
|
||||
: "public, max-age=3600",
|
||||
);
|
||||
|
||||
const mimeType = mime.getType(extname(file)) ?? "application/octet-stream";
|
||||
res.setHeader("Content-Type", mimeType);
|
||||
res.setHeader("Content-Length", String(st.size));
|
||||
res.setHeader("Last-Modified", st.mtime.toUTCString());
|
||||
res.statusCode = 200;
|
||||
|
||||
if (req.method === "HEAD") {
|
||||
res.end();
|
||||
return true;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve_, reject) => {
|
||||
const stream = createReadStream(file);
|
||||
stream.on("error", reject);
|
||||
stream.on("end", () => resolve_());
|
||||
stream.pipe(res);
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ListenerOptions {
|
||||
basename: string;
|
||||
staticRoot?: string;
|
||||
assetsDir?: string;
|
||||
immutableAssets?: boolean;
|
||||
requestListener: RequestListener;
|
||||
logger?: Logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composes the full Node `RequestListener` chain:
|
||||
* 1. `${basename}` → 302 to `${basename}/`
|
||||
* 2. Static asset serving (optional)
|
||||
* 3. Delegate to the downstream request listener
|
||||
*/
|
||||
export function composeListener(opts: ListenerOptions): RequestListener {
|
||||
const log = opts.logger ?? defaultLogger;
|
||||
const basename = opts.basename;
|
||||
const serveStatic = opts.staticRoot
|
||||
? createStaticHandler({
|
||||
root: opts.staticRoot,
|
||||
basename,
|
||||
assetsDir: opts.assetsDir ?? "assets",
|
||||
immutableAssets: opts.immutableAssets ?? true,
|
||||
})
|
||||
: null;
|
||||
|
||||
return (req, res) => {
|
||||
if (req.url) {
|
||||
try {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
if (url.pathname === basename) {
|
||||
res.statusCode = 302;
|
||||
res.setHeader("Location", `${basename}/${url.search}`);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!serveStatic) {
|
||||
opts.requestListener(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
serveStatic(req, res)
|
||||
.then((handled) => {
|
||||
if (!handled) opts.requestListener(req, res);
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error("Static handler failed: %s", err);
|
||||
if (!res.headersSent) {
|
||||
res.statusCode = 500;
|
||||
res.end("Internal Server Error");
|
||||
} else {
|
||||
res.destroy(err as Error);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export interface StartOptions {
|
||||
host: string;
|
||||
port: number;
|
||||
listener: RequestListener;
|
||||
tls?: HttpsServerOptions;
|
||||
logger?: Logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and starts an http(s) server. Wires up SIGINT/SIGTERM for
|
||||
* graceful shutdown.
|
||||
*/
|
||||
export function startHttpServer(opts: StartOptions): Server {
|
||||
const log = opts.logger ?? defaultLogger;
|
||||
const server = opts.tls
|
||||
? createHttpsServer(opts.tls, opts.listener)
|
||||
: createHttpServer(opts.listener);
|
||||
|
||||
server.listen(opts.port, opts.host, () => {
|
||||
const proto = opts.tls ? "https" : "http";
|
||||
log.info("Listening on %s://%s:%s", proto, opts.host, opts.port);
|
||||
});
|
||||
|
||||
const shutdown = (signal: string) => {
|
||||
log.info("Received %s, shutting down...", signal);
|
||||
server.close(() => process.exit(0));
|
||||
// Force exit if connections don't drain in time.
|
||||
setTimeout(() => process.exit(0), 5_000).unref();
|
||||
};
|
||||
|
||||
process.once("SIGINT", () => shutdown("SIGINT"));
|
||||
process.once("SIGTERM", () => shutdown("SIGTERM"));
|
||||
|
||||
return server;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// MARK: Vite Plugin
|
||||
//
|
||||
// In development we want `react-router dev` (Vite) to host the entire
|
||||
// app, so we register a single connect-style middleware that:
|
||||
// 1. loads the SSR entry through Vite's `ssrLoadModule` (HMR-aware),
|
||||
// 2. dispatches every request through a `composeListener` chain
|
||||
// (basename redirect → static asset fallback → app handler).
|
||||
|
||||
import type { RequestListener } from "node:http";
|
||||
|
||||
import type { Plugin } from "vite";
|
||||
|
||||
import { composeListener } from "./http";
|
||||
|
||||
export interface DevServerOptions {
|
||||
entry: string;
|
||||
basename: string;
|
||||
publicDir: string;
|
||||
}
|
||||
|
||||
export function headplaneDevServer(options: DevServerOptions): Plugin {
|
||||
return {
|
||||
name: "headplane:dev-server",
|
||||
apply: "serve",
|
||||
configureServer(server) {
|
||||
// Lazy reference to the loaded entry; recomputed per request so
|
||||
// Vite's HMR picks up changes via `ssrLoadModule`.
|
||||
let appListener: RequestListener = (_req, res) => {
|
||||
res.statusCode = 503;
|
||||
res.end("Server entry not loaded yet");
|
||||
};
|
||||
|
||||
const composed = composeListener({
|
||||
basename: options.basename,
|
||||
staticRoot: options.publicDir,
|
||||
immutableAssets: false,
|
||||
requestListener: (req, res) => appListener(req, res),
|
||||
});
|
||||
|
||||
// Defer registration so our middleware runs AFTER Vite's
|
||||
// transform/asset middlewares (those serve `/@vite/...`,
|
||||
// `/node_modules/...`, module transforms, etc.).
|
||||
return () => {
|
||||
server.middlewares.use(async (req, res, next) => {
|
||||
try {
|
||||
const mod = (await server.ssrLoadModule(options.entry)) as {
|
||||
default: RequestListener;
|
||||
};
|
||||
appListener = mod.default;
|
||||
composed(req, res);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) server.ssrFixStacktrace(err);
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user