// SSR handler — invoked by runtime.ts when the host sends a
// "render_route" message. Dynamically imports the page module, calls
// react-dom/server.renderToReadableStream, base64-encodes chunks back
// over the NDJSON pipe.
//
// The whole module is loaded lazily (handleRenderRoute is awaited from
// the dispatch arm) so projects without SSR routes pay nothing — no
// react-dom dependency requirement, no startup cost.
// Bun runtime global. This module runs under Bun but is type-checked by
// consuming apps under node/DOM (no `Bun` global) — declare the surface used.
declare const Bun: {
resolveSync?(specifier: string, from: string): string;
};
/**
* Is the runtime in dev mode? MUST match the Rust host's `is_dev_mode()`
* (crates/runtime/src/frontend.rs): `PYLON_DEV_MODE` is on ONLY for the exact
* strings "1" or "true" (case-insensitive). A bare `if (process.env.PYLON_DEV_MODE)`
* is WRONG — the string "false"/"0" is truthy in JS, so an explicit
* `PYLON_DEV_MODE=false` on a PROD machine would wrongly enable dev behavior
* (e.g. the live-reload ``), no matter how the renderer treats raw-text children.
// JSON.parse decodes these back, so the structured data stays valid. This
// is why no dangerouslySetInnerHTML is needed: the text child is inert.
const safe = json
.replace(//g, "\\u003e")
.replace(/&/g, "\\u0026");
kids.push(
React.createElement(
"script",
{ key: `ld${i}`, type: "application/ld+json", "data-pylon-meta": "" },
safe,
),
);
});
}
return kids.length > 0 ? el(React.Fragment, null, ...kids) : null;
}
const MODULE_EXTS = [".tsx", ".ts", ".jsx", ".js"];
/** Import a project-relative module, trying each common extension. */
export async function importModule(cwd: string, relPath: string): Promise {
const base = `${cwd}/${relPath}`;
let lastErr: unknown = null;
for (const ext of MODULE_EXTS) {
try {
return await import(`${base}${ext}`);
} catch (e) {
lastErr = e;
}
}
throw lastErr ?? new Error(`could not import module "${relPath}"`);
}
/**
* Wrap a leaf element in its layout chain (leaf → root). Resolves ALL
* layouts first so a missing one fails before any chunk is emitted. Reused
* by the page render and by the not-found / error boundary render.
*/
async function buildLayoutTree(
cwd: string,
leaf: any,
layouts: string[] | undefined,
props: any,
React: any,
): Promise {
if (!layouts || layouts.length === 0) return leaf;
const layoutComps: any[] = [];
for (const layoutPath of layouts) {
let lMod: any;
try {
lMod = await importModule(cwd, layoutPath);
} catch {
throw new Error(
`could not import layout "${layoutPath}" — checked .tsx / .ts / .jsx / .js`,
);
}
const LayoutComp = lMod.default ?? lMod.Layout ?? lMod.layout;
if (typeof LayoutComp !== "function") {
throw new Error(
`layout "${layoutPath}" has no default export (or named export "Layout")`,
);
}
layoutComps.push(LayoutComp);
}
let tree = leaf;
for (let i = layoutComps.length - 1; i >= 0; i--) {
tree = React.createElement(layoutComps[i], props, tree);
}
return tree;
}
/**
* Walk up from a page's directory to the nearest boundary file
* (not-found / error) — the same render-time, filesystem-resolved model
* the page + layouts already use, so no build-time manifest threading.
* Returns the project-relative path (no extension) or null.
*/
function findBoundary(componentPath: string, fileName: string): string | null {
return findBoundaryIn(
require("node:fs"),
require("node:path"),
process.cwd(),
componentPath,
fileName,
);
}
/**
* `findBoundary` with its filesystem and root injected, so the walk is
* testable against a fixture without `chdir` — which races every other test
* in the process.
*/
export function findBoundaryIn(
fs: any,
path: any,
cwd: string,
componentPath: string,
fileName: string,
): string | null {
// Component paths use "/" — walk up directory by directory.
let dir = componentPath.replace(/\\/g, "/");
dir = dir.includes("/") ? dir.slice(0, dir.lastIndexOf("/")) : "";
while (dir && dir !== "." && dir !== "/") {
const hit = boundaryInDirOrGroups(fs, path, cwd, dir, fileName);
if (hit) return hit;
const slash = dir.lastIndexOf("/");
dir = slash >= 0 ? dir.slice(0, slash) : "";
}
return null;
}
/**
* The boundary for one URL-space level: `/`, or the same file
* inside a route group under it.
*
* A route group contributes no URL segment, so `app/(marketing)/not-found` is
* the boundary for `/` exactly as `app/not-found` is — and an app can't ship
* both, because the duplicate-path check rejects two routes claiming `/`.
* Without this, putting the file in a group left `/` with no boundary at all
* while the build insisted the group's copy owned it.
*
* Groups nest, so this recurses. The directory's own file wins over a group's,
* and groups are searched in name order so the answer never depends on
* readdir ordering.
*/
function boundaryInDirOrGroups(
fs: any,
path: any,
cwd: string,
dir: string,
fileName: string,
): string | null {
for (const ext of MODULE_EXTS) {
if (fs.existsSync(path.join(cwd, dir, `${fileName}${ext}`))) {
return `${dir}/${fileName}`;
}
}
let entries: any[];
try {
entries = fs.readdirSync(path.join(cwd, dir), { withFileTypes: true });
} catch {
return null;
}
const groups = entries
.filter(
(e: any) =>
e.isDirectory() && e.name.startsWith("(") && e.name.endsWith(")"),
)
.map((e: any) => e.name as string)
.sort();
for (const g of groups) {
const hit = boundaryInDirOrGroups(fs, path, cwd, `${dir}/${g}`, fileName);
if (hit) return hit;
}
return null;
}
// ---------------------------------------------------------------------------
// Social-card image file convention (Next-style `opengraph-image.png` /
// `twitter-image.png` colocated with a `page.tsx`). Drop the file in a
// route folder and Pylon auto-emits the `` (absolute URL,
// dimensions, type) pointing at the `/_pylon/og` asset endpoint — no
// metadata wiring required. An explicit `metadata.openGraph.image` always
// wins. Resolved fresh per render off the filesystem (same model as
// layouts / boundaries) so dropping a new image is picked up without a
// restart.
// ---------------------------------------------------------------------------
const SOCIAL_IMAGE_EXTS = [".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif"];
/** Walk up from a page's directory to the nearest colocated
* `.` (Next inheritance: a closer file overrides an
* ancestor's). Returns the cwd-relative path WITH extension, or null. */
function findColocatedImage(
componentPath: string,
base: string,
exts: string[] = SOCIAL_IMAGE_EXTS,
): string | null {
const fs = require("node:fs");
const path = require("node:path");
const cwd = process.cwd();
let dir = componentPath.replace(/\\/g, "/");
dir = dir.includes("/") ? dir.slice(0, dir.lastIndexOf("/")) : "";
while (dir && dir !== "." && dir !== "/") {
for (const ext of exts) {
if (fs.existsSync(path.join(cwd, dir, `${base}${ext}`))) {
return `${dir}/${base}${ext}`;
}
}
const slash = dir.lastIndexOf("/");
dir = slash >= 0 ? dir.slice(0, slash) : "";
}
return null;
}
const OG_IMAGE_CODE_EXTS = [".tsx", ".ts", ".jsx", ".js"];
/**
* Walk up from a page's directory to the nearest colocated dynamic OG
* module (`opengraph-image.{tsx,ts,jsx,js}`) and return the CONCRETE
* request path that renders it (origin-less), or null.
*
* The path is derived from the live request URL (`url`), not the file path,
* so dynamic params are already substituted:
* - colocated with the page → `${url}/opengraph-image`
* - an ancestor at depth n → first n concrete URL segments + `/opengraph-image`
* (root → `/opengraph-image`). Route groups `(x)` don't count toward
* depth; a trailing catch-all only lives at the page's own segment, so
* ancestor slicing stays 1:1 with the URL.
* Mirrors `findColocatedImage`'s inheritance walk (closer file wins).
*/
function findColocatedOgImageRoute(
componentPath: string,
url: string,
/**
* True when `componentPath` is a not-found / error boundary rather than a
* page. A page's URL depth matches its directory depth, so when the image
* sits in the page's own directory the request path IS the route path —
* including the tail a catch-all consumed, which is why that shortcut
* exists. A boundary is dispatched at whatever URL failed, so the same
* shortcut advertised a card under the 404'd path: mostly a 404 of its own,
* but at a depth where a dynamic route really does define one, a 200
* serving an unrelated page's card.
*/
isBoundary = false,
): string | null {
const fs = require("node:fs");
const path = require("node:path");
const cwd = process.cwd();
let dir = componentPath.replace(/\\/g, "/");
dir = dir.includes("/") ? dir.slice(0, dir.lastIndexOf("/")) : "";
const pageDir = dir;
const urlSegs = url.split("/").filter(Boolean);
// Non-route-group segment depth of a cwd-relative dir below `app/`.
const nonGroupDepth = (d: string): number => {
const segs = d.split("/").filter(Boolean);
const appIdx = segs.indexOf("app");
const below = appIdx >= 0 ? segs.slice(appIdx + 1) : segs;
return below.filter((s) => !(s.startsWith("(") && s.endsWith(")"))).length;
};
while (dir && dir !== "." && dir !== "/") {
for (const ext of OG_IMAGE_CODE_EXTS) {
if (fs.existsSync(path.join(cwd, dir, `opengraph-image${ext}`))) {
const prefix =
dir === pageDir && !isBoundary
? urlSegs
: urlSegs.slice(0, nonGroupDepth(dir));
return "/" + [...prefix, "opengraph-image"].join("/");
}
}
const slash = dir.lastIndexOf("/");
dir = slash >= 0 ? dir.slice(0, slash) : "";
}
return null;
}
/** Best-effort JPEG dimensions: scan SOF markers in the first 128KB. */
function readJpegSize(fs: any, fd: number): { w: number; h: number } | null {
const CAP = 128 * 1024;
const buf = Buffer.alloc(CAP);
const n = fs.readSync(fd, buf, 0, CAP, 0);
if (n < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return null;
let i = 2;
while (i + 9 < n) {
if (buf[i] !== 0xff) {
i++;
continue;
}
let marker = buf[i + 1];
while (marker === 0xff && i + 1 < n) {
i++;
marker = buf[i + 1];
}
const seg = i + 2;
if (seg + 2 > n) break;
const len = buf.readUInt16BE(seg);
const isSOF =
marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
if (isSOF) {
if (seg + 7 <= n) {
return { h: buf.readUInt16BE(seg + 3), w: buf.readUInt16BE(seg + 5) };
}
return null;
}
if (marker === 0xd9 || marker === 0xda) break; // EOI / SOS
i = seg + len;
}
return null;
}
/** Content-type + pixel dimensions + mtime for a colocated image. Dims
* are best-effort (PNG/GIF from the header, JPEG via SOF scan). */
function readSocialImageMeta(relPath: string): {
type: string;
width?: number;
height?: number;
v: number;
} {
const fs = require("node:fs");
const path = require("node:path");
const ext = relPath.slice(relPath.lastIndexOf(".")).toLowerCase();
const type =
ext === ".png" ? "image/png"
: ext === ".jpg" || ext === ".jpeg" ? "image/jpeg"
: ext === ".webp" ? "image/webp"
: ext === ".gif" ? "image/gif"
: ext === ".avif" ? "image/avif"
: ext === ".svg" ? "image/svg+xml"
: ext === ".ico" ? "image/x-icon"
: "application/octet-stream";
let width: number | undefined;
let height: number | undefined;
let v = 0;
try {
const abs = path.join(process.cwd(), relPath);
v = Math.floor(fs.statSync(abs).mtimeMs);
const fd = fs.openSync(abs, "r");
try {
const head = Buffer.alloc(32);
fs.readSync(fd, head, 0, 32, 0);
if (ext === ".png" && head.toString("latin1", 1, 4) === "PNG") {
width = head.readUInt32BE(16); // IHDR: 8 sig + 4 len + 4 "IHDR"
height = head.readUInt32BE(20);
} else if (ext === ".gif" && head.toString("latin1", 0, 3) === "GIF") {
width = head.readUInt16LE(6);
height = head.readUInt16LE(8);
} else if (ext === ".jpg" || ext === ".jpeg") {
const d = readJpegSize(fs, fd);
if (d) {
width = d.w;
height = d.h;
}
}
} finally {
fs.closeSync(fd);
}
} catch {
/* dims/mtime are best-effort */
}
return { type, width, height, v };
}
const LOOPBACK_HOST = /^(localhost|127\.|\[?::1|0\.0\.0\.0)/;
/** Normalize a bare host or a full URL down to a lowercase `host` (host:port).
* Returns "" for unparseable input. */
function hostOf(value: string): string {
const t = (value || "").trim();
if (!t) return "";
try {
return (t.includes("://") ? new URL(t).host : t.replace(/^\/+|\/+$/g, "")).toLowerCase();
} catch {
return "";
}
}
/** Pure origin resolution (exported for tests).
*
* SECURITY: the request `Host` (and `X-Forwarded-Proto`) is attacker-
* controlled. It's only trusted to build the absolute origin baked into
* `og:image` / canonical URLs when it's in the allowlist — the configured
* public/canonical host, an explicit `PYLON_TRUSTED_HOSTS` entry, or
* loopback. An untrusted (or absent) Host falls back to the configured
* public origin. Without this, `Host: evil.com` on a cacheable
* (force-static / `revalidate`) render bakes `https://evil.com/_pylon/og…`
* into the HTML, which is then teed into the shared ISR/CDN cache and
* served to every subsequent visitor (cache poisoning). */
export function resolveOrigin(opts: {
host?: string;
forwardedProto?: string;
publicUrl?: string;
canonicalHost?: string;
trustedHostsCsv?: string;
}): string {
const publicUrl = (opts.publicUrl || "").trim().replace(/\/+$/, "");
const host = opts.host?.trim().toLowerCase();
if (host) {
const allow = new Set();
const add = (v: string) => {
const h = hostOf(v);
if (h) allow.add(h);
};
add(opts.publicUrl || "");
add(opts.canonicalHost || "");
for (const x of (opts.trustedHostsCsv || "").split(",")) add(x);
const isLoopback = LOOPBACK_HOST.test(host);
if (isLoopback || allow.has(host)) {
// Off-loopback (prod) we ALWAYS use https and never honor the request's
// X-Forwarded-Proto. The SSR cache is keyed only by host (not proto), so
// honoring a client-supplied `http` would poison the cached canonical/OG
// URL with a downgraded scheme for every subsequent visitor. Loopback
// (dev) may be plain http. (A genuinely non-https prod origin should set
// PYLON_PUBLIC_URL explicitly, which takes precedence above.)
const proto = isLoopback
? opts.forwardedProto === "https"
? "https"
: "http"
: "https";
return `${proto}://${host}`;
}
}
// Untrusted / absent Host → the configured canonical origin. (Prefer the
// full public URL; fall back to the canonical host as https.)
if (publicUrl) return publicUrl;
const canon = hostOf(opts.canonicalHost || "");
return canon ? `https://${canon}` : "";
}
/** Absolute origin for OG URLs (crawlers require absolute). Trusts the
* request Host only when it's allowlisted; otherwise uses PYLON_PUBLIC_URL.
* See `resolveOrigin` for the security rationale. */
function resolveRequestOrigin(headers: Record | undefined): string {
const env = (globalThis as any).process?.env ?? {};
return resolveOrigin({
host: headers?.["host"],
forwardedProto: headers?.["x-forwarded-proto"],
publicUrl: env.PYLON_PUBLIC_URL,
canonicalHost: env.PYLON_CANONICAL_HOST,
trustedHostsCsv: env.PYLON_TRUSTED_HOSTS,
});
}
/** SECURITY: validate a `response.redirect()` target to prevent OPEN REDIRECTS.
* Mirrors the OAuth-layer `validate_trusted_redirect` (crates/auth). A relative
* same-site path is always allowed; an absolute URL only when it's http(s) to a
* trusted host — the app's public/canonical origin, a `PYLON_TRUSTED_HOSTS`
* entry, or loopback. Everything else is rejected: protocol-relative
* `//evil.com`, backslash tricks (`/\evil.com` — browsers normalize `\`→`/`),
* other-origin absolutes, and `javascript:`/`data:` schemes. So the natural
* `response.redirect(searchParams.get("next"))` can't be turned into an
* off-site redirect by attacker-supplied input. Exported for tests. */
export function isSafeRedirect(
url: string,
opts: { publicUrl?: string; canonicalHost?: string; trustedHostsCsv?: string },
): boolean {
// Relative same-site path: exactly one leading slash. Reject protocol-
// relative (`//host`) and backslash variants that resolve cross-origin.
if (url.startsWith("/")) {
return url.length < 2 || (url[1] !== "/" && url[1] !== "\\");
}
if (url.startsWith("\\")) return false; // `\\host` / `\/host`
// Absolute: only http(s) to a trusted host. A bare-relative ("dashboard"),
// opaque, or unparseable target throws here → rejected (fail closed).
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return false;
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
const host = parsed.host.toLowerCase();
if (LOOPBACK_HOST.test(host)) return true;
const allow = new Set();
const add = (v: string) => {
const h = hostOf(v);
if (h) allow.add(h);
};
add(opts.publicUrl || "");
add(opts.canonicalHost || "");
for (const x of (opts.trustedHostsCsv || "").split(",")) add(x);
return allow.has(host);
}
/** Merge auto-discovered social-card images into a page's metadata. An
* explicit `openGraph.image` / `twitter.image` always wins; otherwise a
* colocated `opengraph-image.*` (and `twitter-image.*`, falling back to
* the og file) is wired in with absolute URL + dimensions. */
// Icon file conventions. `icon.*` → ; `apple-icon.*` →
// ; `favicon.ico` is the legacy fallback for
// the icon link. Unlike og:image, icon links use a RELATIVE URL (resolved
// same-origin by the browser) so no request origin is needed.
const ICON_EXTS = [".png", ".svg", ".ico", ".jpg", ".jpeg"];
const APPLE_ICON_EXTS = [".png", ".jpg", ".jpeg"];
/** `sizes` attribute for an icon link: "any" for vector SVG, "WxH" for a
* raster with known dimensions, omitted for .ico (multi-size). */
function iconSizes(rel: string, m: { width?: number; height?: number }): string | undefined {
if (rel.toLowerCase().endsWith(".svg")) return "any";
if (m.width && m.height) return `${m.width}x${m.height}`;
return undefined;
}
/** Merge auto-discovered favicons (icon.* / apple-icon.* / favicon.ico)
* into a page's metadata. Explicit `metadata.icons.*` wins. */
export function applyAutoIcons(
component: string,
metadata: SsrMetadata | undefined,
): SsrMetadata | undefined {
const hasIcon = !!metadata?.icons?.icon;
const hasApple = !!metadata?.icons?.apple;
if (hasIcon && hasApple) return metadata;
const iconFile = hasIcon
? null
: findColocatedImage(component, "icon", ICON_EXTS) ??
findColocatedImage(component, "favicon", [".ico"]);
const appleFile = hasApple
? null
: findColocatedImage(component, "apple-icon", APPLE_ICON_EXTS);
if (!iconFile && !appleFile) return metadata;
const linkFor = (rel: string, v: number): string =>
`/_pylon/og?src=${encodeURIComponent(rel)}${v ? `&v=${v}` : ""}`;
const out: SsrMetadata = { ...(metadata ?? {}) };
out.icons = { ...(out.icons ?? {}) };
if (iconFile && !hasIcon) {
const m = readSocialImageMeta(iconFile);
const sizes = iconSizes(iconFile, m);
out.icons.icon = {
url: linkFor(iconFile, m.v),
type: m.type,
...(sizes ? { sizes } : {}),
};
}
if (appleFile && !hasApple) {
const m = readSocialImageMeta(appleFile);
out.icons.apple = {
url: linkFor(appleFile, m.v),
type: m.type,
...(m.width && m.height ? { sizes: `${m.width}x${m.height}` } : {}),
};
}
return out;
}
/**
* Resolve a possibly-relative URL against the request's absolute origin.
*
* Returns the input unchanged when it's already absolute (`https:`,
* `data:`, …), when there's no trustworthy origin to resolve against, or
* when it doesn't parse. A protocol-relative `//cdn/x.png` picks up the
* origin's scheme, which is what makes it valid to a crawler.
*/
function absolutizeUrl(
value: string,
origin: string,
requestPath: string,
): string {
if (!value || !origin) return value;
// Already absolute — an explicit CDN or a fully-qualified URL.
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return value;
try {
const base = requestPath ? new URL(requestPath, origin).href : origin;
return new URL(value, base).href;
} catch {
return value;
}
}
/**
* Make every crawler-facing URL in a page's metadata absolute.
*
* Covers `og:image` (single + list), `og:url`, `twitter:image`, and the
* canonical link — the tags where a relative value is accepted by some
* consumers and silently dropped by others.
*/
function absolutizeMetadataUrls(
metadata: SsrMetadata | undefined,
headers: Record | undefined,
requestUrl?: string,
): SsrMetadata | undefined {
if (!metadata) return metadata;
const origin = resolveRequestOrigin(headers);
if (!origin) return metadata;
const path = (requestUrl ?? "/").split("?")[0] || "/";
const abs = (v: string | undefined): string | undefined =>
v == null ? v : absolutizeUrl(v, origin, path);
const out: SsrMetadata = { ...metadata };
const og = out.openGraph;
if (og) {
const nextOg = { ...og };
if (nextOg.image) {
nextOg.image = abs(nextOg.image);
// og:image:secure_url must be the https form. If the author gave a
// relative path and the origin is https, the absolute URL *is* it.
if (!nextOg.imageSecureUrl && nextOg.image?.startsWith("https:")) {
nextOg.imageSecureUrl = nextOg.image;
} else if (nextOg.imageSecureUrl) {
nextOg.imageSecureUrl = abs(nextOg.imageSecureUrl);
}
}
if (Array.isArray(nextOg.images)) {
nextOg.images = nextOg.images.map((img) =>
img?.url
? {
...img,
url: abs(img.url) as string,
...(img.secureUrl ? { secureUrl: abs(img.secureUrl) } : {}),
}
: img,
);
}
if (nextOg.url) nextOg.url = abs(nextOg.url);
out.openGraph = nextOg;
}
if (out.twitter?.image) {
out.twitter = { ...out.twitter, image: abs(out.twitter.image) };
}
if (out.canonical) out.canonical = abs(out.canonical);
if (out.alternates?.canonical) {
out.alternates = {
...out.alternates,
canonical: abs(out.alternates.canonical),
};
}
return out;
}
export function applyAutoSocialImages(
component: string,
headers: Record | undefined,
metadata: SsrMetadata | undefined,
requestUrl?: string,
/** True for a not-found / error boundary — see findColocatedOgImageRoute. */
isBoundary = false,
): SsrMetadata | undefined {
// A relative image is the single most common way a share card breaks:
// the page ships `og:image="/og.png"`, Facebook and Slack resolve it,
// Twitter does not, and the post goes out with an empty grey box. The
// origin is already resolved here for the auto-injected images, so
// resolve the author's URLs against it too.
metadata = absolutizeMetadataUrls(metadata, headers, requestUrl);
const hasOg = !!metadata?.openGraph?.image;
const hasTw = !!metadata?.twitter?.image;
if (hasOg && hasTw) return metadata;
const ogFile = hasOg ? null : findColocatedImage(component, "opengraph-image");
const twFile = hasTw
? null
: findColocatedImage(component, "twitter-image") ?? ogFile;
// Dynamic (code) OG image: `app/**/opengraph-image.{tsx,ts,jsx,js}`. Only a
// fallback when there's no explicit metadata image and no static raster —
// a static file colocated at the same/closer level wins (Next parity).
const ogRoute =
hasOg || ogFile || requestUrl == null
? null
: findColocatedOgImageRoute(component, requestUrl, isBoundary);
if (!ogFile && !twFile && !ogRoute) return metadata;
const origin = resolveRequestOrigin(headers);
const urlFor = (rel: string, v: number): string =>
`${origin}/_pylon/og?src=${encodeURIComponent(rel)}${v ? `&v=${v}` : ""}`;
const out: SsrMetadata = { ...(metadata ?? {}) };
if (ogFile && !hasOg) {
const m = readSocialImageMeta(ogFile);
const url = urlFor(ogFile, m.v);
out.openGraph = {
...(out.openGraph ?? {}),
image: url,
imageType: m.type,
...(m.width ? { imageWidth: m.width } : {}),
...(m.height ? { imageHeight: m.height } : {}),
...(url.startsWith("https:") ? { imageSecureUrl: url } : {}),
};
} else if (ogRoute && !hasOg) {
const url = `${origin}${ogRoute}`;
out.openGraph = {
...(out.openGraph ?? {}),
image: url,
imageType: "image/png",
imageWidth: 1200,
imageHeight: 630,
...(url.startsWith("https:") ? { imageSecureUrl: url } : {}),
};
}
// Twitter falls back to the OG image (static file first, then dynamic route).
if (!hasTw) {
if (twFile) {
const m = readSocialImageMeta(twFile);
out.twitter = {
card: "summary_large_image",
...(out.twitter ?? {}),
image: urlFor(twFile, m.v),
};
} else if (ogRoute) {
out.twitter = {
card: "summary_large_image",
...(out.twitter ?? {}),
image: `${origin}${ogRoute}`,
};
}
}
return out;
}
/**
* Drain a `renderToReadableStream` reader, injecting `headBlob` immediately
* before the first `` (or, if the document has none, the blob is
* never emitted — fragment renders have no head). `` can straddle a
* chunk boundary, so a small carry buffer (len("") − 1 bytes) is
* withheld at each chunk's tail until the next read confirms the match.
* Each emitted slice is handed to `sendChunk` as utf-8 text.
*
* Shared by the page render and the boundary render so head injection has
* exactly one implementation.
*
* Decoding uses ONE `TextDecoder` with `{stream: true}` for the whole
* reader, never a per-chunk decode. React splits the byte stream at
* arbitrary offsets, so a multi-byte character can land across two chunks;
* decoding each chunk independently turns the orphaned bytes into one
* U+FFFD apiece — `…` (e2 80 a6) arrives as three replacement characters.
* The failure is silent and position-dependent: markup added anywhere
* earlier shifts the boundary, so a page can render correctly for months
* and corrupt on an unrelated CSS change. A streaming decoder holds the
* partial sequence back until the bytes that complete it arrive.
*/
export async function streamWithHeadInjection(
reader: ReadableStreamDefaultReader,
headBlob: string,
sendChunk: (text: string) => void,
bodyBlob = "",
): Promise {
// Injections in document order, each consumed the first time its marker is
// seen. Once the list empties the reader is a straight pass-through, which
// is every chunk after the on a normal page.
const pending: Array<{ marker: string; blob: string }> = [];
if (headBlob.length > 0) pending.push({ marker: "", blob: headBlob });
if (bodyBlob.length > 0) pending.push({ marker: "