secure dev tunnel: strip /__nextjs debug endpoints while keeping HMR

This commit is contained in:
Martin Dvořák 2026-09-24 20:31:37 +00:00
commit f5a913201a
5 changed files with 181 additions and 17 deletions

View file

@ -86,10 +86,11 @@ then runs `npm install` and `npm run build`:
### Scripts
```bash
npm run dev # next dev --turbopack
npm run build # next build --turbopack
npm run start # next start (production)
npm run lint # eslint
npm run dev # node scripts/dev-server.mjs (dev + live updates)
npm run dev:next # next dev --turbopack (raw dev server, unprotected)
npm run build # next build --turbopack
npm run start # next start (production)
npm run lint # eslint
```
Handy checks before shipping: `npx tsc --noEmit`, `npx eslint <files>`, `npm run build`.
@ -101,6 +102,14 @@ on the local network and from the `beta.dvorakmortonxd.dev` tunnel. Without it N
rejects the dev asset requests *and* the HMR WebSocket, which leaves the page un-hydrated
(stacked icons, dead buttons).
A plain `next dev` server is meant for localhost and exposes unauthenticated debug endpoints
(`/__nextjs*` — open-a-file-in-editor, stack frames, source maps, debugger attach, restart)
that you don't want on a tunnel. `npm run dev` therefore runs `scripts/dev-server.mjs`, a
custom server that keeps the app and live updates (HMR / Fast Refresh) but returns `404` for
those endpoints and `*.map` for any request that isn't a direct localhost connection.
Localhost keeps the full dev experience; the LAN and tunnel get a plain, live-reloading
site. To force the raw server instead, use `npm run dev:next` (don't point a tunnel at it).
## 📡 API Routes
| Route | Method | What it does |
@ -140,6 +149,8 @@ src/app/
Clock.tsx → live clock
JokerLogo.tsx → logo with click easter egg
SlBackground.tsx → animated ASCII train background
scripts/
dev-server.mjs → custom dev server: live updates without the dev-only debug surface
public/ → fonts, wallpapers, icons, friend pages, /revival-inc game, llms.txt, robots.txt, sitemap.xml
```

View file

@ -1,18 +1,25 @@
import { NextConfig } from "next";
const nextConfig: NextConfig = {
// Next's on-screen dev indicator is a debug affordance, not a feature of the
// site, so it stays hidden. Compile/runtime errors are still reported.
devIndicators: false,
// Allow testing the dev server from other devices on the LAN (e.g. a phone).
// Without this Next.js blocks cross-origin requests to /_next/* dev assets
// with a 403, which stops the JS from loading and makes the page inert.
// The HMR WebSocket is checked against this list too: if the tunnel/beta host
// isn't listed, the socket is rejected and the dev client never hydrates
// (SSR-only page: stacked icons, dead buttons).
//
// Keep this list tight — private LAN ranges plus the one exact tunnel host.
// A `**.dvorakmortonxd.dev` wildcard would let any subdomain drive the dev
// server. Note this only gates assets/HMR; the dangerous `/__nextjs_*` debug
// endpoints are stripped by scripts/dev-server.mjs, not by this list.
allowedDevOrigins: [
"192.168.10.124",
"127.0.0.1",
"192.168.*.*",
"10.*.*.*",
"beta.dvorakmortonxd.dev",
"*.dvorakmortonxd.dev",
],
webpack(config) {
config.module.rules.push({

View file

@ -46,10 +46,11 @@ picks which one to show based on theme and device.
### Scripts
```bash
npm run dev # next dev --turbopack (a server may already be running on :3000)
npm run build # next build --turbopack
npm run start # next start
npm run lint # eslint
npm run dev # node scripts/dev-server.mjs (dev + HMR; strips dev-only debug for non-local access)
npm run dev:next # next dev --turbopack (raw, UNPROTECTED escape hatch — don't use behind the tunnel)
npm run build # next build --turbopack
npm run start # next start
npm run lint # eslint
```
Useful checks before handing work back: `npx tsc --noEmit`, `npx eslint <files>`,
`npm run build`.
@ -100,6 +101,8 @@ src/app/
Clock.tsx → live clock (useSyncExternalStore + interval)
JokerLogo.tsx → site logo with a click easter egg
SlBackground.tsx → animated ASCII train background (terminal theme)
scripts/
dev-server.mjs → custom dev server: live updates minus the dev-only debug surface
```
### How UI selection works
@ -233,12 +236,28 @@ list reordering, not free placement. Don't confuse the two.
`59` is duplicated in `skeuo.css` (`.win-window.maximized` height,
`.win-window.centered` bottom / max-height) — keep them in sync. Note
`useDesktopIcons.ts` has its **own** `TASKBAR_HEIGHT = 48` for the icon grid.
- Exposing the **dev** server over a tunnel needs the tunnel host in
`allowedDevOrigins` (`next.config.ts`). Without it Next dev rejects the
cross-origin HMR WebSocket, so the client never hydrates — the page is SSR
only: icons all stacked at `(0,0)`, buttons dead. Changing `next.config.ts`
needs a dev-server restart. For a public URL prefer a production build
(`npm run build && npm run start`): no HMR socket, so nothing to reject.
- **Serving the dev server through the tunnel: read this.** `npm run dev` does
**not** run `next dev` directly — it runs `node scripts/dev-server.mjs`, a
custom server that wraps Next's request handler. It keeps the live-update
path (`/_next/hmr`, Fast Refresh) but returns `404` for Next's dev-only debug
endpoints (`/__nextjs*`: launch-editor, original stack frames, source maps,
node-inspector attach, server restart, …) and for `*.map` whenever the
request did **not** arrive over loopback without proxy headers. Those
endpoints are unauthenticated, and Next wires them in unconditionally, so
`next.config.ts` cannot disable them — that is what makes a publicly
tunnelled dev server unsafe. Net effect:
`localhost:3000` keeps the full dev experience; the LAN/tunnel get a plain,
live-reloading site. Use `npm run dev:next` only if you explicitly want the
raw, unprotected server (never point the tunnel at it).
- `allowedDevOrigins` (`next.config.ts`) still matters: the tunnel host and any
LAN origin must be listed or Next rejects the HMR WebSocket with a 403 (the
client then never hydrates — SSR only, icons stacked at `(0,0)`, dead
buttons). Keep it tight (private ranges + the one exact tunnel host); a
`**.dvorakmortonxd.dev` wildcard would let any subdomain drive the dev
server. Changing `next.config.ts` needs a dev-server restart.
- `devIndicators: false` in `next.config.ts` hides Next's on-screen dev badge.
- For a fully public URL a production build (`npm run build && npm run start`)
is still the gold standard: no dev server, no debug endpoints, no HMR socket.
- The SMP service shortcut opens `SMP_APP` (in `apps.tsx`, registered in
`APP_BY_ID` only, like `ALACRITTY_APP`) — a Minecraft-style join menu that
reads live data from `/api/ping?url=minecraft://…`.
@ -257,6 +276,17 @@ list reordering, not free placement. Don't confuse the two.
- There is **no test suite**. Verification is `tsc --noEmit`, `eslint`, and
`next build`.
- A dev server may already be running on port 3000; check before starting one.
Two dev servers on this repo collide on `.next/dev/lock`, so stop the old one
(and delete the stale lock) before starting the custom one.
- Dev-server guard check: `curl -s -o /dev/null -w '%{http_code}' \
http://localhost:3000/__nextjs_server_status` must be `200` (localhost keeps
debug), while the same path through the LAN IP or `beta.dvorakmortonxd.dev`
must be `404`. Note a request from this host to its **own** LAN IP is routed
over loopback, so `localAddress` reads `127.0.0.1` — a same-host curl to the
LAN IP does NOT exercise the non-loopback branch. Test that branch through
the tunnel or from another machine. HMR is fine as long as a real
`/_next/hmr` WebSocket upgrade returns `101` and then sends a frame (the HMR
client appends `?id=self.__next_r` from the served HTML).
- The desktop layout is geometry-heavy. When changing grid maths, a small
standalone script mirroring `normalize()` / `posToCell()` is a fast way to
prove one-icon-per-cell holds for edge cases (offscreen drops, tiny

View file

@ -3,7 +3,8 @@
"version": "2.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"dev": "node scripts/dev-server.mjs",
"dev:next": "next dev --turbopack",
"build": "next build --turbopack",
"start": "next start",
"lint": "eslint"

115
scripts/dev-server.mjs Normal file
View file

@ -0,0 +1,115 @@
// 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);