forked from Mike/freno-dev
Gap 1 — click and served share one label: trackDownload now takes a
stage ("click" | "served") carried on a new VisitorAnalytics.stage
column; the Nook door and the tRPC proxy record clicks, the DMG file
route records served. summariseDownloads() and the /analytics downloads
block are untouched — the stage is a separate column, so the path
grouping cannot change.
Gap 2 — checkout start records nothing: the checkout route records a
created Stripe session through the shared trackRequest writer (same
referrer, campaign, country dimensions as every door) under its own
path, and STAGES.checkout reads it instead of the /checkout page proxy.
Gap 3 — loopback addresses, no host dimension: every writer now
resolves the client address through resolveClientIp, which trusts
x-forwarded-for only when the direct peer is the loopback production
proxy (trust boundary stated in request-client.ts), and records the
host on each row through the same site-context extractor routing uses.
stage and host reach the table through VISITOR_ANALYTICS_ADDED_COLUMNS;
the weekly job applies the same bootstrap before reading so it renders
seven stages against a database no deploy has migrated yet.
The weekly read splits downloadClick/dmgServed, scopes visits to
nook.freno.me, and computes its caveats from the window: pre-instrumentation
rows are stated and excluded, and the loopback warning now fires only
when rows recorded since the instrumentation still carry it.
168 lines
5.8 KiB
TypeScript
168 lines
5.8 KiB
TypeScript
// ───────────────────────────────────────────────────────────────────────
|
|
// 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,
|
|
resolveSiteFromPath,
|
|
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`. Also used by the analytics writers to record the site dimension
|
|
* (`host` column) on every row (FRE-366).
|
|
*/
|
|
export 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;
|
|
}
|
|
|
|
/**
|
|
* Best-effort extraction of the URL pathname from any request-shaped event.
|
|
*
|
|
* SolidStart's FetchEvent exposes `event.request.url` (a full URL string);
|
|
* nitro/H3 exposes the raw Node request URL via `event.nativeEvent.node.req.url`.
|
|
* We try both so this works for API route handlers, the SSR document handler,
|
|
* and tRPC procedures operating on the underlying `H3Event`.
|
|
*/
|
|
function pathFromEventLike(event: ServerSiteEvent | unknown): string | null {
|
|
// 1) Web FetchEvent / APIEvent / PageEvent → standard Request URL.
|
|
try {
|
|
const req = (event as { request?: Request } | null)?.request;
|
|
const url = req?.url;
|
|
if (url) return new URL(url).pathname;
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
|
|
// 2) vinxi/nitro H3Event → raw Node request URL.
|
|
try {
|
|
const nodeReq = (
|
|
event as {
|
|
nativeEvent?: { node?: { req?: { url?: string } } };
|
|
} | null
|
|
)?.nativeEvent?.node?.req;
|
|
const raw = nodeReq?.url;
|
|
if (typeof raw === "string" && raw) {
|
|
// Node request URLs may be path-only (`/nessa/contact`) or full URLs.
|
|
return raw.startsWith("/") ? raw : new URL(raw).pathname;
|
|
}
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
|
|
// 3) Some H3 shapes expose `event.node.req` directly.
|
|
try {
|
|
const nodeReq = (
|
|
event as {
|
|
node?: { req?: { url?: string } };
|
|
} | null
|
|
)?.node?.req;
|
|
const raw = nodeReq?.url;
|
|
if (typeof raw === "string" && raw) {
|
|
return raw.startsWith("/") ? raw : new URL(raw).pathname;
|
|
}
|
|
} 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 or path can be determined.
|
|
*/
|
|
export function getSiteFromEvent(
|
|
event: APIEvent | H3Event | ServerSiteEvent
|
|
): Site {
|
|
const hostResult = resolveSiteFromHost(hostFromEventLike(event));
|
|
// Subdomain host (incl. `*.localhost` dev) → authoritative, use it.
|
|
if (hostResult.id !== "main") return hostResult;
|
|
|
|
// Host resolved to main — fall back to the URL path prefix. On localhost
|
|
// the vercel.json host rewrites don't run, so `localhost:3000/nessa/contact`
|
|
// has host `localhost` (→ main) but path `/nessa/contact` (→ nessa).
|
|
// Without this, every subdomain page in dev renders the main-site nav.
|
|
return resolveSiteFromPath(pathFromEventLike(event)) ?? hostResult;
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
const hostResult = resolveSiteFromHost(request.headers.get("host"));
|
|
if (hostResult.id !== "main") return hostResult;
|
|
return resolveSiteFromPath(new URL(request.url).pathname) ?? hostResult;
|
|
}
|
|
|
|
export { MAIN_SITE };
|