diff --git a/AGENTS.md b/AGENTS.md index 3889be2..330eaf5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,7 +86,7 @@ This project serves four product subdomains (`nessa.freno.me`, `lineage.freno.me`, `gaze.freno.me`, `inputhalo.freno.me`) plus the personal site on `freno.me`. See `docs/subdomain-setup.md` for DNS/Vercel configuration. -- **Route placement:** Subdomain pages live under `src/routes//*` (e.g. `src/routes/nessa/...`). The `vercel.json` host-based rewrites map each subdomain to its prefix. +- **Route placement:** Subdomain pages live under `src/routes//*` (e.g. `src/routes/nessa/...`). An in-app middleware (`src/middleware.ts`, registered via `app.config.ts` → `middleware`) rewrites the request path based on the `Host` header so `lineage.freno.me/privacy` resolves to the `/lineage/privacy` file route. (The `vercel.json` host `rewrites` are declared but **not applied** by Vercel — the Nitro `vercel` preset emits a Build Output API `config.json` whose `routes` array fully replaces `vercel.json` rewrites/redirects/headers, so the in-app middleware is what actually does the work.) - **Site context:** Use `useSite()` (SolidJS) or `getSiteFromEvent`/`getSiteFromRequest` (server) from `src/lib/site-context.ts` to detect the current site. Never host-snoop in route files — SolidStart's router can't match on host. - **API routes:** `/api/*` is a shared pool — subdomain API requests pass through to existing routes via vercel.json pass-through rewrites (ordering matters). - **Auth:** Host-scoped only — no cookie domain broadening. diff --git a/app.config.ts b/app.config.ts index a38b709..7b2f273 100644 --- a/app.config.ts +++ b/app.config.ts @@ -3,6 +3,7 @@ import tailwindcss from "@tailwindcss/vite"; import { sentryVitePlugin as sentryPlugin } from "@sentry/vite-plugin"; export default defineConfig({ + middleware: "./src/middleware.ts", vite: { plugins: [ tailwindcss(), diff --git a/package.json b/package.json index 401594e..fa94c59 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,6 @@ "dev": "vinxi dev", "dev-flush": "vinxi dev --env-file=.env", "build": "vinxi build", - "vercel-build": "rm -rf .vinxi .output node_modules/.vite && vinxi build", "start": "NODE_OPTIONS='--import ./public/instrument.server.mjs' vinxi start", "test": "bun test", "test:security": "bun test src/server/security/", diff --git a/src/components/PageHead.test.ts b/src/components/PageHead.test.ts index 2730592..aa5936b 100644 --- a/src/components/PageHead.test.ts +++ b/src/components/PageHead.test.ts @@ -60,6 +60,42 @@ describe("resolvePageHeadMeta — canonical URL derivation", () => { expect(meta.canonical).toBe("https://nessa.freno.me/contact"); }); + // The in-app subdomain middleware (src/middleware.ts) rewrites the internal + // request path to the prefix form (/nessa/contact) before the SolidStart + // router matches it. useLocation() therefore reports the prefixed path, and + // resolvePageHeadMeta must strip the prefix so the canonical stays public. + it("nessa prefixed /nessa/contact → https://nessa.freno.me/contact", () => { + const meta = resolvePageHeadMeta( + BASE_PROPS, + SITE_CONFIG.nessa, + "/nessa/contact" + ); + expect(meta.canonical).toBe("https://nessa.freno.me/contact"); + }); + + it("lineage prefixed /lineage/privacy → https://lineage.freno.me/privacy", () => { + const meta = resolvePageHeadMeta( + BASE_PROPS, + SITE_CONFIG.lineage, + "/lineage/privacy" + ); + expect(meta.canonical).toBe("https://lineage.freno.me/privacy"); + }); + + it("nessa prefixed root /nessa/ → https://nessa.freno.me/", () => { + const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.nessa, "/nessa/"); + expect(meta.canonical).toBe("https://nessa.freno.me/"); + }); + + it("main path that happens to start with /nessa is NOT stripped (main site has no prefix)", () => { + const meta = resolvePageHeadMeta( + BASE_PROPS, + SITE_CONFIG.main, + "/nessa/contact" + ); + expect(meta.canonical).toBe("https://freno.me/nessa/contact"); + }); + it("lineage → canonical starts with https://lineage.freno.me", () => { const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.lineage, "/"); expect(meta.canonical.startsWith("https://lineage.freno.me")).toBe(true); diff --git a/src/components/page-head-meta.ts b/src/components/page-head-meta.ts index 6d05f70..1ba1282 100644 --- a/src/components/page-head-meta.ts +++ b/src/components/page-head-meta.ts @@ -9,11 +9,12 @@ * Rules: * - `title` → `props.title + site.titleSuffix` * - `canonical` → explicit `props.canonical` override wins; otherwise - * `https://${site.domain}${pathname}`. The pathname is the *browser* path - * (from `useLocation`), which is correct because vercel.json host rewrites - * target internal route prefixes (`/nessa`, `/lineage`, …) while leaving - * the public URL intact — so `nessa.freno.me/contact` reports pathname - * `/contact`, and the canonical is `https://nessa.freno.me/contact`. + * `https://${site.domain}${pathname}` where `pathname` is the *public* + * browser path. Because the subdomain prefix (`/lineage`, `/nessa`, …) is + * an internal-only rewrite (applied by `vercel.json` host rewrites or, in + * their absence, by `src/middleware.ts`), `useLocation()` reports the + * prefixed path (`/lineage/privacy`) and we strip the prefix back off so + * the canonical reflects the public URL (`https://lineage.freno.me/privacy`). * - `ogImage` → explicit `props.ogImage` wins; otherwise `site.ogDefaultImage`. * - `ogTitle` / `ogDescription` → explicit override wins; otherwise fall * back to the base title (no suffix) / description (existing behavior). @@ -54,7 +55,22 @@ export function resolvePageHeadMeta( pathname: string ): ResolvedPageHeadMeta { const title = `${props.title}${site.titleSuffix}`; - const canonical = props.canonical ?? `https://${site.domain}${pathname}`; + /** + * The canonical URL is the *public* browser URL, never the internal route + * prefix. SolidStart's file router is host-blind, so subdomain routes live + * under `src/routes//*` and are served either by `vercel.json` host + * rewrites OR by `src/middleware.ts` (the in-app host rewrite). Both append + * the prefix to the internal request path (`/privacy` → `/lineage/privacy`), + * so `useLocation()` reports the prefixed path — which we strip back off so + * the canonical stays `https://lineage.freno.me/privacy`. + */ + const publicPath = + site.baseRoutePrefix && + (pathname === site.baseRoutePrefix || + pathname.startsWith(site.baseRoutePrefix + "/")) + ? pathname.slice(site.baseRoutePrefix.length) || "/" + : pathname; + const canonical = props.canonical ?? `https://${site.domain}${publicPath}`; const ogTitle = props.ogTitle ?? props.title; const ogDescription = props.ogDescription ?? props.description; const ogImage = props.ogImage ?? site.ogDefaultImage; diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..25db9f9 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,102 @@ +// @refresh reload +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * In-app host-based subdomain path rewrite. + * + * ## Why this exists + * + * `vercel.json` declares host `rewrites` that map each subdomain onto its + * internal `src/routes//*` file route (e.g. + * `lineage.freno.me/privacy` → `/lineage/privacy`). When Vercel builds with + * the Nitro `vercel` preset, however, it emits a Build Output API + * `.vercel/output/config.json` with a `routes` array — and per Vercel's + * contract, **a `routes` array in `config.json` fully replaces + * `vercel.json` `rewrites`/`redirects`/`headers`**. The host rewrites were + * therefore silently never applied, which is why subdomain-only routes + * (`/privacy`, `/deletion`) 404'd on production while routes that also + * exist at the root (`/`, `/contact`, `/downloads`) appeared to work (they + * were actually served by the root route, not the subdomain-branded one). + * + * ## What this does + * + * This middleware (registered via `app.config.ts` → `middleware`) runs as + * an H3 `onRequest` hook BEFORE the SolidStart file router matches the path. + * For a request whose `Host` resolves to a subdomain `Site`, it prefixes the + * request URL with the site's `baseRoutePrefix` — e.g. `/privacy` on + * `lineage.freno.me` becomes `/lineage/privacy` internally, exactly as the + * `vercel.json` rewrite intended — so the file router resolves the correct + * subdomain route file (`src/routes/lineage/privacy.tsx`). + * + * The browser URL is untouched (this is an internal rewrite, not a + * redirect). `useLocation()` will report the prefixed internal path; the + * canonical-URL derivation in `src/components/page-head-meta.ts` strips the + * prefix so the public canonical stays `https://lineage.freno.me/privacy`. + * + * ## Scope / skip conditions + * + * - Main site / unknown host → no rewrite (no prefix to add). + * - `/api/*` → shared API pool (already routed correctly on all hosts; the + * `vercel.json` `/api/(.*)` rules are pure pass-throughs, so no prefix). + * - Paths already carrying a subdomain prefix (dev path-based URLs like + * `localhost:3000/lineage/privacy`, or a request already rewritten) → no + * double-prefix. + * - `/_build/*` build assets → served statically, not routed. + * + * ## Dev vs prod + * + * In dev, the Host is usually `localhost` (→ main → no rewrite); subdomain + * pages are reached via their path prefix (`localhost:3000/lineage/privacy`) + * which the skip-condition above leaves untouched. Dev subdomains reached + * via `lineage.localhost:3000/privacy` (browsers resolve `*.localhost`) ARE + * rewritten, keeping dev and prod behavior consistent. + */ +import { resolveSiteFromHost } from "~/lib/site-context"; + +/** + * Minimal H3-event shape this middleware touches. Avoids importing + * `vinxi/http` (a type-only subpath that some environments can't resolve) — + * the real H3Event satisfies this structure at runtime. + */ +interface RewriteEvent { + node?: { + req?: { + url?: string; + originalUrl?: string; + headers?: { host?: string }; + }; + }; + _path?: string; +} + +export default { + onRequest(event: RewriteEvent) { + const req = event?.node?.req; + if (!req?.headers) return; + + const site = resolveSiteFromHost(req.headers.host); + // No prefix to add for the main site / unknown host. + if (!site.baseRoutePrefix) return; + + const url = req.url || "/"; + // Shared API pool — pass through unchanged on every host. + if (url === "/api" || url.startsWith("/api/")) return; + // Already prefixed (dev path-based URLs, or a request already rewritten). + if ( + url === site.baseRoutePrefix || + url.startsWith(site.baseRoutePrefix + "/") + ) { + return; + } + // Static build assets are served as files, not via the router. + if (url.startsWith("/_build/")) return; + + // Internal rewrite: /privacy → /lineage/privacy (browser URL unchanged). + // H3's toWebRequest derives the URL from `originalUrl ?? event.path`, so + // we must set originalUrl too (not just _path/req.url) or the SolidStart + // router keeps seeing the unprefixed browser path. + const rewritten = site.baseRoutePrefix + url; + req.originalUrl = rewritten; + req.url = rewritten; + event._path = rewritten; + } +};