fix: subdomain capture
This commit is contained in:
@@ -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.
|
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/<prefix>/*` (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/<prefix>/*` (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.
|
- **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).
|
- **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.
|
- **Auth:** Host-scoped only — no cookie domain broadening.
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
"dev": "vinxi dev",
|
"dev": "vinxi dev",
|
||||||
"dev-flush": "vinxi dev --env-file=.env",
|
"dev-flush": "vinxi dev --env-file=.env",
|
||||||
"build": "vinxi build",
|
"build": "vinxi build",
|
||||||
"vercel-build": "rm -rf .vinxi .output node_modules/.vite && vinxi build",
|
|
||||||
"start": "NODE_OPTIONS='--import ./public/instrument.server.mjs' vinxi start",
|
"start": "NODE_OPTIONS='--import ./public/instrument.server.mjs' vinxi start",
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"test:security": "bun test src/server/security/",
|
"test:security": "bun test src/server/security/",
|
||||||
|
|||||||
@@ -60,6 +60,42 @@ describe("resolvePageHeadMeta — canonical URL derivation", () => {
|
|||||||
expect(meta.canonical).toBe("https://nessa.freno.me/contact");
|
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", () => {
|
it("lineage → canonical starts with https://lineage.freno.me", () => {
|
||||||
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.lineage, "/");
|
const meta = resolvePageHeadMeta(BASE_PROPS, SITE_CONFIG.lineage, "/");
|
||||||
expect(meta.canonical.startsWith("https://lineage.freno.me")).toBe(true);
|
expect(meta.canonical.startsWith("https://lineage.freno.me")).toBe(true);
|
||||||
|
|||||||
@@ -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
102
src/middleware.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user