Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import type { NextConfig } from "next";
import createMDX from "@next/mdx";
import { CAMPAIGN_ROUTES } from "./src/lib/campaignRoutes";
const isProd = process.env.NODE_ENV === "production";
// The ?v= on the sandbox iframe URL, derived from the sandbox document itself.
//
// It used to be a number a human bumped by hand, and the hand forgot: the
// OKLab/OKLCH ramp modes (9413106) edited public/pattern-sandbox.html without
// touching lib/community/sandboxUrl.ts, and because this file is served
// immutable for a year, every browser that had ever loaded ?v=5 kept running
// the pre-OKLab runtime — which does not know the mode names, so it dropped
// every `// @ramp oklch…` annotation on the floor and painted those patterns
// with the default ramp instead. Desktop looked fine (a fresh cache), phones
// did not (a warm one). A hash cannot forget.
const PATTERN_SANDBOX_VERSION = crypto
.createHash("sha256")
.update(fs.readFileSync(path.join(process.cwd(), "public", "pattern-sandbox.html")))
.digest("hex")
.slice(0, 10);
const nextConfig: NextConfig = {
// Inlined into the client bundle at build time; read by
// lib/community/sandboxUrl.ts, which is the only thing that should use it.
env: { PATTERN_SANDBOX_VERSION },
pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"],
// Native module — must stay a runtime require, not a bundled dependency.
serverExternalPackages: ["better-sqlite3"],
// Nothing under public/ belongs inside a server function. Vercel serves it
// from the CDN and `next start` reads it from the checkout, so a function
// carrying a copy carries dead weight — and the file tracer hands out copies
// freely, because any fs path it cannot resolve becomes a glob. One
// `path.join(process.cwd(), "public", src)` in lib/journalImageMeta.ts put
// all of public/ — every journal photo, 100 MB — into each journal function;
// `path.join(…, "builds")` in the build queue matched public/builds; the
// variant-bin route's env-rooted path matched the whole project. That was
// ~220 MB of function bundle per deployment, and Vercel meters storage per
// retained deployment: in 2026-09 the Hobby team stood at 77 GB against a
// 10 GB Functions Storage allowance.
//
// Safe because the one reader of public/ is the journal image probe, and it
// only runs where public/ is on disk anyway: at build time on Vercel (those
// pages are prerendered and never revalidate) and from the checkout on the
// Pi. If it ever did run inside a function it falls back to default
// dimensions rather than throwing.
outputFileTracingExcludes: {
"/*": ["public/**/*"],
},
// Testing anything touch-shaped means opening `next dev` from a phone, at
// this machine's address on the network rather than at localhost. Next
// blocks cross-origin requests to /_next/* by default, and the symptom is
// not an error page: the HTML server-renders, the bundle loads, React never
// hydrates, and every pattern card sits at "rendering…" forever with no
// clue why. Private ranges are allowed here so that just works; add a
// tunnel host or anything else with NEXT_DEV_ORIGINS=a.example,b.example.
// Development only — this has no effect on a production build.
allowedDevOrigins: [
"192.168.*.*",
"10.*.*.*",
"172.16.*.*",
"100.*.*.*", // Tailscale
"*.local",
...(process.env.NEXT_DEV_ORIGINS ?? "")
.split(",")
.map((host) => host.trim())
.filter(Boolean),
],
async redirects() {
return [
// The shelf was /variants until 2026-09. The word the docs settled on is
// "edition" (docs/EDITIONS.md); the old URL is baked into every 3.8.0
// console page and printed in release notes, so it stays forever.
{ source: "/variants", destination: "/editions", permanent: true },
{
source: "/journal/:slug",
has: [{ type: "query", key: "lang", value: "en" }],
destination: "/journal/:slug/en",
permanent: true,
},
{
source: "/journal",
has: [{ type: "query", key: "lang", value: "en" }],
destination: "/journal/en",
permanent: true,
},
];
},
async rewrites() {
return CAMPAIGN_ROUTES.map(({ path, destination }) => ({
source: path,
destination,
}));
},
async headers() {
return [
{
// ── Baseline security headers ──
// The community has one-click destructive actions behind a session
// cookie — delete a pattern, remove somebody's comment, publish, work
// the moderation queue — and nothing stopped another site from
// framing those buttons under a transparent overlay and borrowing a
// logged-in visitor's clicks. frame-ancestors is the fix;
// X-Frame-Options is the same statement for anything that predates it.
//
// 'self' rather than 'none' because the pattern sandbox is an iframe
// this site serves to itself (see /pattern-sandbox.html). Embedding
// YouTube is unaffected: this controls who may frame US.
//
// Deliberately NOT a full CSP yet. script-src on a Next app needs
// nonces threaded through the framework's inline bootstrap, and a
// half-right script-src fails closed — a blank site. That is its own
// piece of work; these four are the ones that are safe today.
source: "/:path*",
headers: [
{ key: "Content-Security-Policy", value: "frame-ancestors 'self'" },
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
// Nothing here asks for these, and the YouTube embed is granted
// what it needs through its own allow= attribute.
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
],
},
{
// The flasher manifest names the image paths for a given release, so a
// cached copy keeps handing out the previous firmware long after a new
// one ships. The images themselves live at version-stamped paths and
// may be cached freely; only this pointer must always be fetched fresh.
//
// It is also the file a device's own console checks its version
// against. That page is served BY the device, from a LAN address, so
// the read is cross-origin and needs saying so — without this the
// browser blocks it and the "newer firmware exists" banner simply
// never appears. The manifest is public release metadata; there is
// nothing here to keep from anyone.
source: "/flash/manifest.json",
headers: [
{ key: "Cache-Control", value: "no-store, must-revalidate" },
{ key: "Access-Control-Allow-Origin", value: "*" },
],
},
{
// The release images, read cross-origin by the device's own /update
// page during a wireless update: the browser fetches the .bin from
// here and POSTs it to the board on the LAN, so the device never
// needs TLS (it has nowhere near the heap for a handshake). Paths
// carry the version, so they never change under a cached copy.
source: "/flash/bin/:path*",
headers: [
{ key: "Access-Control-Allow-Origin", value: "*" },
{ key: "Cache-Control", value: "public, max-age=31536000, immutable" },
],
},
{
// Pattern packs, read cross-origin by a device's own /patterns page:
// the browser fetches the .zip from here, unpacks it, and posts the
// modules to the board on the LAN. Same reason as the firmware images
// above — the device has nowhere near the heap for a TLS handshake, so
// the browser is what reaches the internet.
//
// Revalidated rather than cached hard, unlike /flash/bin: those paths
// carry a version and these do not. A pack sits at a stable address
// and is rebuilt in place when its patterns change, which is exactly
// the case `immutable` would get wrong.
source: "/packs/:path*",
headers: [
{ key: "Access-Control-Allow-Origin", value: "*" },
{ key: "Cache-Control", value: "public, max-age=0, must-revalidate" },
],
},
{
// Every pattern card boots its own sandboxed iframe from this one
// document, so the feed loads it dozens of times per visit. Without
// this header each load is a round trip to the origin (a Raspberry
// Pi), and the cards visibly warm up one by one. Safe to cache hard
// in production: the URL carries ?v=<hash of this very file>, so a
// changed document is a changed URL by construction.
//
// Not in development, though. The hash is computed once when the
// config loads, and editing public/ does not reload the config — so a
// year-long immutable copy of the file you are editing is exactly
// wrong. `next dev` revalidates instead.
source: "/pattern-sandbox.html",
headers: [
{
key: "Cache-Control",
value: isProd ? "public, max-age=31536000, immutable" : "no-store",
},
],
},
];
},
};
const withMDX = createMDX({
extension: /\.mdx?$/,
});
export default withMDX(nextConfig);
07e931b46This file is pinned to the selected release. Download the original to open it in your design tool.