dvorakmortonxd.dev/scripts/dev-server.mjs

115 lines
3.9 KiB
JavaScript

// Custom Next.js dev server that keeps live updates but drops the dev-only
// debug surface for anyone who is not on localhost.
//
// WHY THIS EXISTS
// `beta.dvorakmortonxd.dev` is an unauthenticated tunnel straight to the dev
// server, and a Next.js dev server is not meant to be public. It exposes a set
// of unauthenticated debug endpoints under `/__nextjs_*` (open-the-editor,
// stack-frame/source-map lookups, node debugger attach, server restart, …) that
// you really do not want on the internet. Next.js wires those endpoints in
// unconditionally, so `next.config.ts` cannot turn them off.
//
// This server sits in front of Next's request handler and returns 404 for
// `/__nextjs*` and source maps when the request did not arrive over loopback.
// Everything the browser needs for live updates still goes through:
// - normal app routes
// - `/_next/hmr` (the HMR / Fast Refresh WebSocket)
//
// So `localhost:3000` keeps the full dev experience, while the LAN/tunnel get
// a plain, live-reloading version of the site.
//
// Run with `npm run dev`. To bypass it entirely, use `npx next dev --turbopack`.
import { createServer } from "node:http";
import next from "next";
const dev = process.env.NODE_ENV !== "production";
const port = Number.parseInt(process.env.PORT || "3000", 10);
const hostname = process.env.HOSTNAME || "0.0.0.0";
// Next's dev-only internals all share this prefix:
// /__nextjs_launch-editor, /__nextjs_original-stack-frames,
// /__nextjs_source-map, /__nextjs_server_status,
// /__nextjs_attach-nodejs-inspector, /__nextjs_restart_dev, …
const DEV_DEBUG_PREFIX = "/__nextjs";
function isLoopback(socket) {
const address = socket.localAddress || "";
return (
address === "127.0.0.1" ||
address === "::1" ||
address === "::ffff:127.0.0.1" ||
address.startsWith("127.")
);
}
// A request that arrived through a proxy/tunnel (Cloudflare or otherwise) is
// never a local dev request, even if the proxy itself runs on this machine and
// therefore connects over loopback.
function hasForwardedHeaders(req) {
const headers = req.headers;
return Boolean(
headers["cf-connecting-ip"] ||
headers["cf-ray"] ||
headers["x-forwarded-for"] ||
headers["x-forwarded-proto"] ||
headers["forwarded"],
);
}
function isLocalDebugRequest(req) {
return isLoopback(req.socket) && !hasForwardedHeaders(req);
}
function isDevDebugRequest(req) {
let pathname = req.url || "/";
try {
pathname = new URL(req.url || "/", "http://localhost").pathname;
} catch {
// Fall back to the raw URL if it is not parseable.
}
return (
pathname.startsWith(DEV_DEBUG_PREFIX) || pathname.endsWith(".map")
);
}
let handle;
const server = createServer((req, res) => {
if (!isLocalDebugRequest(req) && isDevDebugRequest(req)) {
res.statusCode = 404;
res.setHeader("content-type", "text/plain; charset=utf-8");
res.setHeader("cache-control", "no-store");
res.end("Not found");
return;
}
// `handle` is assigned right after `getRequestHandler()` below; requests
// cannot arrive before `listen`, which happens after that assignment.
handle(req, res);
});
// Passing our HTTP server in lets Next attach its HMR upgrade handler to it.
const app = next({ dev, turbopack: true, hostname, port, httpServer: server });
handle = app.getRequestHandler();
await app.prepare();
server.listen(port, hostname, () => {
console.log(`> dev server ready on http://localhost:${port}`);
console.log(
"> dev debug endpoints are localhost-only (404 on the LAN/tunnel); HMR still works",
);
});
function shutdown() {
// Drop idle keep-alive sockets so Ctrl+C exits promptly instead of waiting
// on lingering connections.
server.closeAllConnections?.();
server.close(() => {
app.close().finally(() => process.exit(0));
});
// Hard exit if Next's cleanup hangs.
setTimeout(() => process.exit(0), 3000).unref();
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);