diff --git a/app.config.ts b/app.config.ts index a38b709..570812a 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(), @@ -52,6 +53,6 @@ export default defineConfig({ } }, server: { - preset: "vercel" + preset: "node-server" } }); 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; + } +};