From 92890ce259d2c9c208f35a693a15206e1a680f18 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Thu, 23 Jul 2026 09:18:55 -0400 Subject: [PATCH] feat: add subdomain-based site context with SSR resolution Resolve the active site from the request Host header on the server and expose it through a SiteContext provider so the app can vary rendering per subdomain. Serialize the resolved site into the document shell (data-site attribute + window.__SITE__) so client hydration matches the server render. Add host-based Vercel rewrites mapping each subdomain to its site root, and include unit tests for the site resolution logic. --- src/app.tsx | 25 +++-- src/context/SiteContext.tsx | 104 ++++++++++++++++++ src/entry-server.tsx | 61 ++++++----- src/lib/site-context.test.ts | 99 +++++++++++++++++ src/lib/site-context.ts | 171 ++++++++++++++++++++++++++++++ src/server/site-context-server.ts | 101 ++++++++++++++++++ vercel.json | 26 +++++ 7 files changed, 552 insertions(+), 35 deletions(-) create mode 100644 src/context/SiteContext.tsx create mode 100644 src/lib/site-context.test.ts create mode 100644 src/lib/site-context.ts create mode 100644 src/server/site-context-server.ts diff --git a/src/app.tsx b/src/app.tsx index 34fd7b4..06e1210 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -15,6 +15,7 @@ import ErrorBoundaryFallback from "./components/ErrorBoundaryFallback"; import { BarsProvider, useBars } from "./context/bars"; import { DarkModeProvider } from "./context/darkMode"; import { AuthProvider } from "./context/auth"; +import { SiteProvider } from "./context/SiteContext"; import { createWindowWidth, isMobile } from "~/lib/resize-utils"; import { MOBILE_CONFIG } from "./config"; import CustomScrollbar from "./components/CustomScrollbar"; @@ -203,17 +204,19 @@ export default function App() { )} > - - ( - - {props.children} - - )} - > - - - + + + ( + + {props.children} + + )} + > + + + + diff --git a/src/context/SiteContext.tsx b/src/context/SiteContext.tsx new file mode 100644 index 0000000..d6082aa --- /dev/null +++ b/src/context/SiteContext.tsx @@ -0,0 +1,104 @@ +/** + * SiteContext — SolidJS provider exposing the active `Site` to the component + * tree (task 01 keystone). + * + * Resolution strategy: + * - Server (SSR): reads the module-level value bound by `setServerSite()`, + * which `entry-server.tsx` calls per-request via `getSiteFromEvent(event)`. + * - Client (hydration): reads the SSR-injected `window.__SITE__` id (written + * into the document shell by `entry-server.tsx`) so the post-hydration + * value matches the `data-site` attribute on ``. Falls back to + * `resolveSiteFromHost(window.location.hostname)` if the injected id is + * missing (e.g. client-side navigation / hard refresh quirks). + * + * NOTE on race-safety: SSR of a personal site is single-render-per-request in + * practice; the module-level holder is adequate here. Server functions that + * need authoritative per-request site resolution MUST use + * `getSiteFromEvent` / `getSiteFromRequest` directly rather than reading + * the provider — do not rely on `useSite()` for authorization decisions. + */ +import { + createContext, + useContext, + onMount, + createSignal, + type Accessor, + type ParentComponent +} from "solid-js"; +import { isServer } from "solid-js/web"; +import { + resolveSiteFromHost, + resolveSiteFromLocation, + SITE_CONFIG, + MAIN_SITE, + type Site, + type SiteId +} from "~/lib/site-context"; + +// ── SSR binding ────────────────────────────────────────────────────────── +let serverSite: Site = MAIN_SITE; + +/** + * SSR-only. Called by `entry-server.tsx` immediately before rendering so the + * component tree reads the correct site during the initial SSR pass. + */ +export function setServerSite(site: Site): void { + if (!isServer) return; + serverSite = site; +} + +// ── Client hydration data ──────────────────────────────────────────────── +declare global { + interface Window { + __SITE__?: SiteId; + } +} + +/** Resolve the client-side active site, preferring the SSR-injected id. */ +export function resolveClientSite(): Site { + if (typeof window === "undefined") return MAIN_SITE; + const injected = window.__SITE__; + if (injected && SITE_CONFIG[injected]) return SITE_CONFIG[injected]; + return resolveSiteFromLocation(window.location.hostname); +} + +// ── Context ────────────────────────────────────────────────────────────── +const SiteContext = createContext>(() => MAIN_SITE); + +export const SiteProvider: ParentComponent = (props) => { + const initial: Site = isServer ? serverSite : resolveClientSite(); + const [site, setSite] = createSignal(initial); + + // Reconcile after mount: covers the rare case where the injected id was + // unavailable during the synchronous init or the host changed via + // client-side navigation. + onMount(() => { + const resolved = resolveClientSite(); + if (resolved.id !== site().id) setSite(resolved); + }); + + return ( + {props.children} + ); +}; + +/** + * Access the active `Site` config anywhere in the tree. + * + * Returns an accessor (`() => Site`) consistent with the rest of the app's + * context / signal conventions. Use it for branding (PageHead titleSuffix, + * brand color, OG image, favicon), per-site navigation, and canonical URLs. + * + * @example + * ```tsx + * const site = useSite(); + * return {`Blog${site().titleSuffix}`}; + * ``` + */ +export function useSite(): Accessor { + return useContext(SiteContext); +} + +export { SITE_CONFIG, MAIN_SITE }; +export type { Site, SiteId }; +export { resolveSiteFromHost, resolveSiteFromLocation }; diff --git a/src/entry-server.tsx b/src/entry-server.tsx index c46d032..99d7640 100644 --- a/src/entry-server.tsx +++ b/src/entry-server.tsx @@ -1,19 +1,29 @@ // @refresh reload import { createHandler, StartServer } from "@solidjs/start/server"; +import { getSiteFromEvent } from "~/server/site-context-server"; +import { setServerSite } from "~/context/SiteContext"; -export default createHandler(() => ( - ( - - - - - - - {assets} - - -
{children}
- {scripts} - - - )} - /> -)); + + {/* Hydration data for SiteContext — must run before the app bundle. */} + + {assets} + + +
{children}
+ {scripts} + + + )} + /> + ); +}); diff --git a/src/lib/site-context.test.ts b/src/lib/site-context.test.ts new file mode 100644 index 0000000..4f29a02 --- /dev/null +++ b/src/lib/site-context.test.ts @@ -0,0 +1,99 @@ +/** + * Unit tests for the shared site-context resolver (task 01). + * + * `resolveSiteFromHost` is pure — no env / no I/O — so the cases below are + * straightforward synchronous assertions mirroring the acceptance matrix in + * the task spec. + */ +import { describe, it, expect } from "bun:test"; +import { + resolveSiteFromHost, + SITE_CONFIG, + type SiteId +} from "./site-context"; + +function expectSite(host: string, id: SiteId) { + expect(resolveSiteFromHost(host).id).toBe(id); +} + +describe("resolveSiteFromHost", () => { + it("maps each product subdomain to its own site config", () => { + expectSite("nessa.freno.me", "nessa"); + expectSite("lineage.freno.me", "lineage"); + expectSite("gaze.freno.me", "gaze"); + expectSite("inputhalo.freno.me", "inputhalo"); + }); + + it("maps the apex and www hosts to main", () => { + expectSite("freno.me", "main"); + expectSite("www.freno.me", "main"); + }); + + it("falls back to main for unknown subdomains", () => { + expectSite("unknown.freno.me", "main"); + expectSite("blog.freno.me", "main"); + }); + + it("handles ports", () => { + expectSite("freno.me:3000", "main"); + expectSite("nessa.freno.me:8787", "nessa"); + expectSite("www.freno.me:443", "main"); + }); + + it("handles localhost dev hosts", () => { + expectSite("localhost", "main"); + expectSite("localhost:3000", "main"); + expectSite("nessa.localhost:3000", "nessa"); + expectSite("nessa.localhost", "nessa"); + expectSite("gaze.localhost", "gaze"); + expectSite("lineage.localhost", "lineage"); + expectSite("inputhalo.localhost", "inputhalo"); + }); + + it("treats unknown *.localhost as main", () => { + expectSite("wat.localhost", "main"); + }); + + it("handles empty / null / undefined hosts by returning main", () => { + expect(resolveSiteFromHost("").id).toBe("main"); + expect(resolveSiteFromHost(null).id).toBe("main"); + expect(resolveSiteFromHost(undefined).id).toBe("main"); + expect(resolveSiteFromHost(" ").id).toBe("main"); + }); + + it("case-insensitively normalizes hosts", () => { + expectSite("NeSsA.Freno.Me", "nessa"); + expectSite("WWW.Freno.Me", "main"); + expectSite("Gaze.LOCALHOST:3000", "gaze"); + }); + + it("preserves exact dot-match semantics (no prefix bleed)", () => { + // `x-nessa.freno.me` must NOT match `nessa.freno.me`. + expectSite("x-nessa.freno.me", "main"); + expectSite("notgaze.freno.me", "main"); + }); + + it("returns the matching SITE_CONFIG entry (full object, not just id)", () => { + expect(resolveSiteFromHost("nessa.freno.me")).toEqual(SITE_CONFIG.nessa); + expect(resolveSiteFromHost("gaze.freno.me")).toEqual(SITE_CONFIG.gaze); + expect(resolveSiteFromHost("freno.me")).toEqual(SITE_CONFIG.main); + }); + + it("every SITE_CONFIG entry has a non-empty baseRoutePrefix for subdomains", () => { + for (const id of [ + "nessa", + "lineage", + "gaze", + "inputhalo" + ] as SiteId[]) { + expect(SITE_CONFIG[id].baseRoutePrefix).toBe(`/${id}`); + expect(SITE_CONFIG[id].subdomain).toBe(id); + expect(SITE_CONFIG[id].titleSuffix).toBe(` | ${SITE_CONFIG[id].displayName}`); + } + }); + + it("main has empty subdomain and empty baseRoutePrefix", () => { + expect(SITE_CONFIG.main.subdomain).toBe(""); + expect(SITE_CONFIG.main.baseRoutePrefix).toBe(""); + }); +}); diff --git a/src/lib/site-context.ts b/src/lib/site-context.ts new file mode 100644 index 0000000..df89d4a --- /dev/null +++ b/src/lib/site-context.ts @@ -0,0 +1,171 @@ +/** + * Shared site definitions and host-to-site resolver. + * + * Pure module — intentionally imports NO env / server-only code — so it is + * safe to import from both server and client (and from unit tests). + * + * This is the keystone of the subdomain-routing feature (task 01). Every + * content task (05-11) consumes `SITE_CONFIG` metadata via `useSite()`, + * and the server-side host detection in + * `src/server/site-context-server.ts` builds on `resolveSiteFromHost`. + */ + +export type SiteId = "main" | "nessa" | "lineage" | "gaze" | "inputhalo"; + +export interface Site { + /** Canonical id, also serialized into `` and `window.__SITE__`. */ + id: SiteId; + /** Subdomain label, e.g. `"nessa"`. Empty string for the main site. */ + subdomain: string; + /** Fully-qualified domain, e.g. `"nessa.freno.me"`. `"freno.me"` for main. */ + domain: string; + /** + * Internal route prefix the vercel.json host rewrite targets. SolidStart + * file-routing places subdomain pages under `src/routes//*`. + * Empty string for main. + */ + baseRoutePrefix: string; + /** Human-friendly brand / product name. */ + displayName: string; + /** Appended to page titles, e.g. `" | Nessa"`. */ + titleSuffix: string; + /** Hex brand color used for theming accents / OG image backgrounds. */ + brandColor: string; + /** Default OpenGraph image path (resolved against the site root). */ + ogDefaultImage: string; + /** Favicon path for this site. */ + faviconPath: string; +} + +export const SITE_CONFIG: Record = { + main: { + id: "main", + subdomain: "", + domain: "freno.me", + baseRoutePrefix: "", + displayName: "Michael Freno", + titleSuffix: " | Michael Freno", + brandColor: "#89b4fa", + ogDefaultImage: "/blueprint.jpg", + faviconPath: "/favicon.ico" + }, + nessa: { + id: "nessa", + subdomain: "nessa", + domain: "nessa.freno.me", + baseRoutePrefix: "/nessa", + displayName: "Nessa", + titleSuffix: " | Nessa", + brandColor: "#cba6f7", + ogDefaultImage: "/nessa/og-default.png", + faviconPath: "/nessa/favicon.ico" + }, + lineage: { + id: "lineage", + subdomain: "lineage", + domain: "lineage.freno.me", + baseRoutePrefix: "/lineage", + displayName: "Life and Lineage", + titleSuffix: " | Life and Lineage", + brandColor: "#a6e3a1", + ogDefaultImage: "/lineage/og-default.png", + faviconPath: "/lineage/favicon.ico" + }, + gaze: { + id: "gaze", + subdomain: "gaze", + domain: "gaze.freno.me", + baseRoutePrefix: "/gaze", + displayName: "Gaze", + titleSuffix: " | Gaze", + brandColor: "#f9e2af", + ogDefaultImage: "/gaze/og-default.png", + faviconPath: "/gaze/favicon.ico" + }, + inputhalo: { + id: "inputhalo", + subdomain: "inputhalo", + domain: "inputhalo.freno.me", + baseRoutePrefix: "/inputhalo", + displayName: "InputHalo", + titleSuffix: " | InputHalo", + brandColor: "#f38ba8", + ogDefaultImage: "/inputhalo/og-default.png", + faviconPath: "/inputhalo/favicon.ico" + } +}; + +/** Ordered subdomain sites used for host matching. */ +const SUBDOMAIN_SITES: ReadonlyArray = [ + SITE_CONFIG.nessa, + SITE_CONFIG.lineage, + SITE_CONFIG.gaze, + SITE_CONFIG.inputhalo +]; + +const BASE_DOMAIN = "freno.me"; + +/** Matches `.localhost` and `.localhost:` (dev only). */ +const DEV_HOST_RE = /^([a-z0-9-]+)\.localhost$/i; + +export const MAIN_SITE: Site = SITE_CONFIG.main; + +/** + * Resolve a `Site` from a raw `Host` header value (or hostname). + * + * Handles: + * - exact product subdomains (`nessa.freno.me` → nessa) + * - `www.` prefix (`www.freno.me` → main) + * - the bare apex (`freno.me` → main) + * - port suffixes (`freno.me:3000` → main) + * - localhost dev (`localhost` / `localhost:3000` → main) + * - subdomain dev (`nessa.localhost` / `nessa.localhost:3000` → nessa) + * - unknown hosts / unknown subdomains → main (fail-safe default) + * + * Pure & synchronous — no I/O, no env access. + */ +export function resolveSiteFromHost(host: string | null | undefined): Site { + if (!host) return MAIN_SITE; + + // Normalize: trim, lowercase, strip optional `:port` suffix. + const normalized = host.trim().toLowerCase().replace(/:\d+$/, ""); + if (!normalized) return MAIN_SITE; + + // Strip a leading `www.` so `www.freno.me` behaves like `freno.me`. + const withoutWww = normalized.replace(/^www\./, ""); + + if (withoutWww === BASE_DOMAIN) return MAIN_SITE; + + // Exact subdomain. match. + for (const site of SUBDOMAIN_SITES) { + if (withoutWww === `${site.subdomain}.${BASE_DOMAIN}`) return site; + } + + // Dev pattern: .localhost[:port] (browsers resolve `*.localhost`). + const devMatch = normalized.match(DEV_HOST_RE); + if (devMatch) { + const sub = devMatch[1]!.toLowerCase(); + for (const site of SUBDOMAIN_SITES) { + if (sub === site.subdomain) return site; + } + // `localhost` alone or unknown `.localhost` → main. + return MAIN_SITE; + } + + // Unknown `*.freno.me` (e.g. a future subdomain not yet configured) → main. + if (withoutWww.endsWith(`.${BASE_DOMAIN}`)) return MAIN_SITE; + + // Anything else entirely (IPs, foreign hosts) → main as a safe default. + return MAIN_SITE; +} + +/** + * Resolve the active site from a client `window.location`, used by the + * SolidJS `SiteContext` provider during hydration. Server codepaths should + * use `getSiteFromEvent` / `getSiteFromRequest` instead. + */ +export function resolveSiteFromLocation( + hostname: string | null | undefined +): Site { + return resolveSiteFromHost(hostname); +} diff --git a/src/server/site-context-server.ts b/src/server/site-context-server.ts new file mode 100644 index 0000000..32717d3 --- /dev/null +++ b/src/server/site-context-server.ts @@ -0,0 +1,101 @@ +// ─────────────────────────────────────────────────────────────────────── +// Server-side site-context extraction. +// +// Reads the request `Host` header from a SolidStart FetchEvent (APIEvent / +// PageEvent) or a raw vinxi/nitro H3Event and resolves the active `Site` +// via the shared pure resolver in `~/lib/site-context`. +// +// Importing this module is only valid server-side. It relies on +// `event.request` and `event.nativeEvent.node.req` which do not exist in +// the browser. +// ─────────────────────────────────────────────────────────────────────── + +import type { APIEvent } from "@solidjs/start/server"; +import type { H3Event } from "vinxi/http"; +import { + resolveSiteFromHost, + type Site, + MAIN_SITE +} from "~/lib/site-context"; + +/** + * Structural shape accepted by {@link getSiteFromEvent}. Compatible with + * SolidStart `APIEvent` / `PageEvent` and vinxi/nitro `H3Event`. We keep it + * structural (rather than a union of those named types) so the SSR document + * handler can pass its `PageEvent` without a TS widening error. + */ +export interface ServerSiteEvent { + request?: Request; + nativeEvent?: unknown; + node?: unknown; +} + +/** + * Best-effort extraction of the `Host` header from any request-shaped event. + * + * SolidStart's FetchEvent exposes `event.request.headers`; nitro/H3 exposes + * the raw Node request via `event.nativeEvent.node.req.headers`. We try + * both so this works for API route handlers (`APIEvent`), the SSR document + * handler (`PageEvent`), and tRPC procedures operating on the underlying + * `H3Event`. + */ +function hostFromEventLike(event: ServerSiteEvent | unknown): string | null { + // 1) Web FetchEvent / APIEvent / PageEvent → standard `Request` headers. + try { + const req = (event as { request?: Request } | null)?.request; + const h = req?.headers?.get?.("host"); + if (h) return h; + } catch { + /* noop */ + } + + // 2) vinxi/nitro H3Event → raw Node request. + try { + const nodeReq = ( + event as { + nativeEvent?: { node?: { req?: { headers?: Record } } }; + } | null + )?.nativeEvent?.node?.req; + const raw = nodeReq?.headers?.host; + if (typeof raw === "string" && raw) return raw; + } catch { + /* noop */ + } + + // 3) Some H3 shapes expose `event.node.req` directly. + try { + const nodeReq = ( + event as { + node?: { req?: { headers?: Record } }; + } | null + )?.node?.req; + const raw = nodeReq?.headers?.host; + if (typeof raw === "string" && raw) return raw; + } catch { + /* noop */ + } + + return null; +} + +/** + * Resolve the active `Site` from a SolidStart APIEvent / PageEvent or a + * vinxi/nitro H3Event. Accepts the structural {@link ServerSiteEvent} shape, + * so the SSR document handler can pass its `PageEvent` directly. Falls back + * to `main` if no host can be determined. + */ +export function getSiteFromEvent( + event: APIEvent | H3Event | ServerSiteEvent +): Site { + return resolveSiteFromHost(hostFromEventLike(event)); +} + +/** + * Resolve the active `Site` from a standard `Request` (e.g. inside server + * functions that receive a `Request` directly). Falls back to `main`. + */ +export function getSiteFromRequest(request: Request): Site { + return resolveSiteFromHost(request.headers.get("host")); +} + +export { MAIN_SITE }; diff --git a/vercel.json b/vercel.json index a69bfba..c7b0dec 100644 --- a/vercel.json +++ b/vercel.json @@ -1,4 +1,30 @@ { + "rewrites": [ + { + "source": "/api/(.*)", + "has": [{ "type": "host", "value": "gaze.freno.me" }], + "destination": "/api/$1" + }, + { + "source": "/api/(.*)", + "has": [{ "type": "host", "value": "inputhalo.freno.me" }], + "destination": "/api/$1" + }, + { + "source": "/api/(.*)", + "has": [{ "type": "host", "value": "nessa.freno.me" }], + "destination": "/api/$1" + }, + { + "source": "/api/(.*)", + "has": [{ "type": "host", "value": "lineage.freno.me" }], + "destination": "/api/$1" + }, + { "source": "/(.*)", "has": [{ "type": "host", "value": "nessa.freno.me" }], "destination": "/nessa/$1" }, + { "source": "/(.*)", "has": [{ "type": "host", "value": "lineage.freno.me" }], "destination": "/lineage/$1" }, + { "source": "/(.*)", "has": [{ "type": "host", "value": "gaze.freno.me" }], "destination": "/gaze/$1" }, + { "source": "/(.*)", "has": [{ "type": "host", "value": "inputhalo.freno.me" }], "destination": "/inputhalo/$1" } + ], "headers": [ { "source": "/",