diff --git a/README.md b/README.md index 1b2eb34..c909b05 100644 --- a/README.md +++ b/README.md @@ -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 `, `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 ``` diff --git a/next.config.ts b/next.config.ts index 1d41c65..d5e2047 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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({ diff --git a/opencode.md b/opencode.md index 97a14bf..7cee25a 100644 --- a/opencode.md +++ b/opencode.md @@ -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 `, `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 diff --git a/package.json b/package.json index 2a92539..0c02d90 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/dev-server.mjs b/scripts/dev-server.mjs new file mode 100644 index 0000000..0ca232b --- /dev/null +++ b/scripts/dev-server.mjs @@ -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);