fix: attempt to fix subdomain capture

This commit is contained in:
2026-07-24 22:11:47 -04:00
parent c66cd6d456
commit b6b2bc8ef6
3 changed files with 126 additions and 7 deletions

View File

@@ -3,6 +3,7 @@ import tailwindcss from "@tailwindcss/vite";
import { sentryVitePlugin as sentryPlugin } from "@sentry/vite-plugin"; import { sentryVitePlugin as sentryPlugin } from "@sentry/vite-plugin";
export default defineConfig({ export default defineConfig({
middleware: "./src/middleware.ts",
vite: { vite: {
plugins: [ plugins: [
tailwindcss(), tailwindcss(),
@@ -52,6 +53,6 @@ export default defineConfig({
} }
}, },
server: { server: {
preset: "vercel" preset: "node-server"
} }
}); });

View File

@@ -9,11 +9,12 @@
* Rules: * Rules:
* - `title` → `props.title + site.titleSuffix` * - `title` → `props.title + site.titleSuffix`
* - `canonical` → explicit `props.canonical` override wins; otherwise * - `canonical` → explicit `props.canonical` override wins; otherwise
* `https://${site.domain}${pathname}`. The pathname is the *browser* path * `https://${site.domain}${pathname}` where `pathname` is the *public*
* (from `useLocation`), which is correct because vercel.json host rewrites * browser path. Because the subdomain prefix (`/lineage`, `/nessa`, …) is
* target internal route prefixes (`/nessa`, `/lineage`, …) while leaving * an internal-only rewrite (applied by `vercel.json` host rewrites or, in
* the public URL intact — so `nessa.freno.me/contact` reports pathname * their absence, by `src/middleware.ts`), `useLocation()` reports the
* `/contact`, and the canonical is `https://nessa.freno.me/contact`. * 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`. * - `ogImage` → explicit `props.ogImage` wins; otherwise `site.ogDefaultImage`.
* - `ogTitle` / `ogDescription` → explicit override wins; otherwise fall * - `ogTitle` / `ogDescription` → explicit override wins; otherwise fall
* back to the base title (no suffix) / description (existing behavior). * back to the base title (no suffix) / description (existing behavior).
@@ -54,7 +55,22 @@ export function resolvePageHeadMeta(
pathname: string pathname: string
): ResolvedPageHeadMeta { ): ResolvedPageHeadMeta {
const title = `${props.title}${site.titleSuffix}`; 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/<prefix>/*` 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 ogTitle = props.ogTitle ?? props.title;
const ogDescription = props.ogDescription ?? props.description; const ogDescription = props.ogDescription ?? props.description;
const ogImage = props.ogImage ?? site.ogDefaultImage; const ogImage = props.ogImage ?? site.ogDefaultImage;

102
src/middleware.ts Normal file
View File

@@ -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/<prefix>/*` 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;
}
};