Merge branch 'subdomain-routing-and-site-context'
This commit is contained in:
25
src/app.tsx
25
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() {
|
||||
)}
|
||||
>
|
||||
<DarkModeProvider>
|
||||
<BarsProvider>
|
||||
<Router
|
||||
root={(props) => (
|
||||
<AuthProvider>
|
||||
<AppLayout>{props.children}</AppLayout>
|
||||
</AuthProvider>
|
||||
)}
|
||||
>
|
||||
<FileRoutes />
|
||||
</Router>
|
||||
</BarsProvider>
|
||||
<SiteProvider>
|
||||
<BarsProvider>
|
||||
<Router
|
||||
root={(props) => (
|
||||
<AuthProvider>
|
||||
<AppLayout>{props.children}</AppLayout>
|
||||
</AuthProvider>
|
||||
)}
|
||||
>
|
||||
<FileRoutes />
|
||||
</Router>
|
||||
</BarsProvider>
|
||||
</SiteProvider>
|
||||
</DarkModeProvider>
|
||||
</ErrorBoundary>
|
||||
</MetaProvider>
|
||||
|
||||
104
src/context/SiteContext.tsx
Normal file
104
src/context/SiteContext.tsx
Normal file
@@ -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 `<html>`. 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<Accessor<Site>>(() => MAIN_SITE);
|
||||
|
||||
export const SiteProvider: ParentComponent = (props) => {
|
||||
const initial: Site = isServer ? serverSite : resolveClientSite();
|
||||
const [site, setSite] = createSignal<Site>(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 (
|
||||
<SiteContext.Provider value={site}>{props.children}</SiteContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 <Title>{`Blog${site().titleSuffix}`}</Title>;
|
||||
* ```
|
||||
*/
|
||||
export function useSite(): Accessor<Site> {
|
||||
return useContext(SiteContext);
|
||||
}
|
||||
|
||||
export { SITE_CONFIG, MAIN_SITE };
|
||||
export type { Site, SiteId };
|
||||
export { resolveSiteFromHost, resolveSiteFromLocation };
|
||||
@@ -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(() => (
|
||||
<StartServer
|
||||
document={({ assets, children, scripts }) => (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1"
|
||||
/>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<script>
|
||||
{`
|
||||
export default createHandler((event) => {
|
||||
// Resolve the active site from the request Host header once per SSR pass,
|
||||
// then bind it so the SiteContext provider returns the right value during
|
||||
// the initial server render. Also serialized into the document shell
|
||||
// (`<html data-site>` + `window.__SITE__`) so client hydration matches.
|
||||
const site = getSiteFromEvent(event);
|
||||
setServerSite(site);
|
||||
|
||||
return (
|
||||
<StartServer
|
||||
document={({ assets, children, scripts }) => (
|
||||
<html lang="en" data-site={site.id}>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1"
|
||||
/>
|
||||
<link rel="icon" href={site.faviconPath} />
|
||||
<script>
|
||||
{`
|
||||
(function() {
|
||||
const STORAGE_KEY = 'theme-override';
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
@@ -22,14 +32,17 @@ export default createHandler(() => (
|
||||
document.documentElement.classList.add(isDark ? 'dark' : 'light');
|
||||
})();
|
||||
`}
|
||||
</script>
|
||||
{assets}
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">{children}</div>
|
||||
{scripts}
|
||||
</body>
|
||||
</html>
|
||||
)}
|
||||
/>
|
||||
));
|
||||
</script>
|
||||
{/* Hydration data for SiteContext — must run before the app bundle. */}
|
||||
<script>{`window.__SITE__=${JSON.stringify(site.id)};`}</script>
|
||||
{assets}
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">{children}</div>
|
||||
{scripts}
|
||||
</body>
|
||||
</html>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
99
src/lib/site-context.test.ts
Normal file
99
src/lib/site-context.test.ts
Normal file
@@ -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("");
|
||||
});
|
||||
});
|
||||
171
src/lib/site-context.ts
Normal file
171
src/lib/site-context.ts
Normal file
@@ -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 `<html data-site>` 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/<prefix>/*`.
|
||||
* 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<SiteId, Site> = {
|
||||
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> = [
|
||||
SITE_CONFIG.nessa,
|
||||
SITE_CONFIG.lineage,
|
||||
SITE_CONFIG.gaze,
|
||||
SITE_CONFIG.inputhalo
|
||||
];
|
||||
|
||||
const BASE_DOMAIN = "freno.me";
|
||||
|
||||
/** Matches `<sub>.localhost` and `<sub>.localhost:<port>` (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.<base> match.
|
||||
for (const site of SUBDOMAIN_SITES) {
|
||||
if (withoutWww === `${site.subdomain}.${BASE_DOMAIN}`) return site;
|
||||
}
|
||||
|
||||
// Dev pattern: <sub>.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 `<x>.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);
|
||||
}
|
||||
101
src/server/site-context-server.ts
Normal file
101
src/server/site-context-server.ts
Normal file
@@ -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<string, string | string[]> } } };
|
||||
} | 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<string, string | string[]> } };
|
||||
} | 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 };
|
||||
Reference in New Issue
Block a user