// 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: "", blob: bodyBlob }); let carry = ""; const decoder = new TextDecoder("utf-8"); const feed = (text: string): void => { let buf = carry + text; carry = ""; while (pending.length > 0) { const next = pending[0]; const idx = buf.indexOf(next.marker); if (idx < 0) break; sendChunk(buf.slice(0, idx)); sendChunk(next.blob); sendChunk(next.marker); buf = buf.slice(idx + next.marker.length); pending.shift(); } if (pending.length === 0) { if (buf) sendChunk(buf); return; } // Withhold len(marker) − 1 so a marker straddling a chunk boundary is // still matched once the next read arrives. const keep = pending[0].marker.length - 1; if (buf.length > keep) { sendChunk(buf.slice(0, buf.length - keep)); carry = buf.slice(buf.length - keep); } else { carry = buf; } }; for (;;) { const { value, done } = await reader.read(); if (done) break; if (!value || value.byteLength === 0) continue; const text = decoder.decode(value, { stream: true }); // A chunk that ended mid-character decodes to "" — the bytes are held // in the decoder until the rest arrives. Nothing to emit yet. if (!text) continue; feed(text); } // Flush the decoder. A stream that ends mid-character is genuinely // truncated input, and this is where it becomes a replacement character // rather than silently vanishing. const tail = decoder.decode(); if (tail) feed(tail); // A document with no `` (fragment renders, truncated streams) leaves // its injection unconsumed. Emit the held-back bytes as-is rather than // forcing a badge into markup with nowhere to put it. if (carry) sendChunk(carry); } /** * Dev-only browser live-reload client, injected at the end of every SSR * page when PYLON_DEV_MODE is set. Subscribes to the runtime's * `/_pylon/dev/live` Server-Sent-Events endpoint (see frontend.rs * `serve_dev_live_reload`), which streams this process's boot id in a * `hello` event. EventSource auto-reconnects when `pylon dev` restarts; the * fresh process advertises a new boot id, so a changed id ⇒ the tab reloads. * No-ops in browsers without EventSource (none in practice). */ const DEV_LIVE_RELOAD_SNIPPET = ""; /** * The dev HUD (a floating bottom-left overlay, dev-only): surfaces the framework * decisions a dev otherwise can't see — the cache verdict + WHY a page isn't * cached, render mode + timing, sync connection + offline-outbox depth, /api * activity + policy denials, and client errors. The cache / api / errors rows * expand a detail drawer (click). Written as a plain function and embedded via * `.toString()` (Bun strips the TS annotations), so it ships as self-contained * browser JS that closes over nothing. Browser globals go through `g` so it * typechecks without a DOM lib. Mirrors Next's dev indicator, tuned to Pylon. */ function pylonDevHud() { const g: any = globalThis; const d: any = g.document; if (!d || g.__pylonHudMounted) return; g.__pylonHudMounted = true; let info: any = {}; try { const el = d.getElementById("__PYLON_DEV__"); if (el) info = JSON.parse(el.textContent || "{}"); } catch (_e) {} // Marker the sync engine checks before publishing its dev status probe. g.__PYLON_DEV__ = info; // Build failure banner: when the build degraded the page (e.g. the // Tailwind compile failed and the page is serving unstyled), paint an // unmissable fixed banner. This is the loud path for failures that // would otherwise masquerade as app bugs. if (info.buildWarning) { const b = d.createElement("div"); b.textContent = "⚠ " + String(info.buildWarning); b.style.cssText = "position:fixed;top:0;left:0;right:0;z-index:2147483647;" + "background:#dc2626;color:#fff;padding:8px 14px;" + "font:13px/1.4 ui-monospace,monospace;white-space:pre-wrap;"; d.body.appendChild(b); } // Client errors. const errs: string[] = []; const onErr = (m: any) => { errs.push(String(m)); if (errs.length > 50) errs.shift(); }; if (g.addEventListener) { g.addEventListener("error", (e: any) => onErr((e && (e.message || e.error)) || e)); g.addEventListener("unhandledrejection", (e: any) => onErr((e && e.reason) || e)); } // /api activity: wrap fetch ONCE to observe Pylon API calls (method, path, // status, ms, and a policy/error reason on non-2xx). Transparent — returns the // ORIGINAL promise untouched; the body is read from a clone only on errors. const api: any[] = []; if (g.fetch && !g.__pylonFetchWrapped) { g.__pylonFetchWrapped = true; const orig = g.fetch.bind(g); g.fetch = function (input: any, init: any) { let url = ""; try { url = typeof input === "string" ? input : (input && input.url) || ""; } catch (_e) {} const method = String( (init && init.method) || (input && input.method) || "GET", ).toUpperCase(); const t0 = g.performance && g.performance.now ? g.performance.now() : Date.now(); const p = orig(input, init); if (url.indexOf("/api/") !== -1 && p && p.then) { const path = url.replace(/^https?:\/\/[^/]+/, "").split("?")[0]; const done = (status: number, reason: string) => { const t1 = g.performance && g.performance.now ? g.performance.now() : Date.now(); api.push({ method, path, status, ms: Math.round((t1 - t0) * 10) / 10, reason, denied: status === 403, }); if (api.length > 40) api.shift(); }; p.then( (res: any) => { if (!res.ok && res.clone) { res .clone() .json() .then((b: any) => done( res.status, (b && b.error && (b.error.message || b.error.code)) || (b && b.message) || "", ), ) .catch(() => done(res.status, "")); } else { done(res.status, ""); } }, () => done(0, "network error"), ); } return p; }; } const C = { ok: "#3fb950", warn: "#d29922", bad: "#f85149", dim: "#6e7681", txt: "#e6edf3", }; const make = (tag: string, css: string, text?: string) => { const n = d.createElement(tag); n.style.cssText = css; if (text != null) n.textContent = text; return n; }; const dot = (color: string) => make( "span", "display:inline-block;width:7px;height:7px;border-radius:50%;flex:0 0 auto;background:" + color, ); const box = make( "div", "position:fixed;left:12px;bottom:12px;z-index:2147483646;font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace", ); const panel = make( "div", "background:#0d1117;border:1px solid #30363d;border-radius:8px;padding:9px 11px;margin-bottom:6px;min-width:260px;max-width:380px;box-shadow:0 8px 28px rgba(0,0,0,.45)", ); const pill = make( "button", "display:flex;align-items:center;gap:6px;background:#161b22;border:1px solid #30363d;border-radius:999px;padding:5px 11px;color:#e6edf3;cursor:pointer;font:inherit;box-shadow:0 2px 10px rgba(0,0,0,.35)", ); // The expandable detail area below the rows. let activeDrawer: string | null = null; const drawer = make( "div", "display:none;margin-top:7px;padding-top:7px;border-top:1px solid #21262d;max-height:200px;overflow:auto", ); const drawerLine = (label: string, value: string, color?: string) => { const r = make("div", "display:flex;gap:8px;margin:2px 0"); r.appendChild(make("span", "color:#8b949e;flex:0 0 110px", label)); r.appendChild(make("span", "color:" + (color || C.txt) + ";flex:1;word-break:break-word", value)); drawer.appendChild(r); }; const renderDrawer = () => { if (!activeDrawer) { drawer.style.display = "none"; return; } drawer.style.display = "block"; drawer.textContent = ""; if (activeDrawer === "cache") { const f = (info.cache && info.cache.flags) || {}; const optIn = f.bucketOptIn ? "auth-bucketed" : f.revalidateSecs != null ? "revalidate=" + f.revalidateSecs + "s" : "none"; drawerLine("opt-in", optIn, optIn === "none" ? C.warn : C.ok); drawerLine("props.auth", f.authTouched ? "read ✗" : "untouched ✓", f.authTouched ? C.bad : C.ok); drawerLine( "headers/cookies", f.dynamicTouched ? "read ✗" : "untouched ✓", f.dynamicTouched ? C.bad : C.ok, ); drawerLine("props.session", f.sessionTouched ? "read (bucket bit)" : "untouched", C.dim); drawerLine("set-cookie", String(f.cookieCount || 0), f.cookieCount ? C.bad : C.ok); drawerLine("strict policies", f.strictPolicies ? "on ✗" : "off ✓", f.strictPolicies ? C.bad : C.ok); drawerLine("streaming", f.wantsStream ? "yes" : "no", C.dim); drawerLine("status", String(f.status != null ? f.status : "—"), f.status === 200 ? C.ok : C.warn); } else if (activeDrawer === "api") { if (!api.length) { drawerLine("", "no /api calls yet", C.dim); } else { for (let i = api.length - 1; i >= 0; i--) { const a = api[i]; const col = a.status === 0 || a.status >= 500 || a.denied ? C.bad : a.status >= 400 ? C.warn : C.ok; drawer.appendChild( make( "div", "margin:2px 0;color:" + col + ";word-break:break-word", a.method + " " + a.path + " " + (a.status || "ERR") + " · " + a.ms + "ms" + (a.reason ? " — " + a.reason : ""), ), ); } } } else if (activeDrawer === "errors") { if (!errs.length) { drawerLine("", "no errors", C.dim); } else { for (let i = errs.length - 1; i >= 0; i--) { drawer.appendChild( make("div", "margin:2px 0;color:" + C.bad + ";word-break:break-word", errs[i]), ); } } } }; // A row; pass `key` to make it click-to-expand the matching drawer section. // Each row hover-explains itself via a native `title` (the terse labels are // cryptic otherwise). `key` makes the row click-to-expand its drawer. const rowEl = (label: string, tip: string, key?: string) => { const r = make( "div", "display:flex;align-items:flex-start;gap:8px;margin:3px 0" + (key ? ";cursor:pointer" : ""), ); r.title = key ? tip + " (click to expand)" : tip; r.appendChild(make("span", "color:#8b949e;flex:0 0 60px", key ? label + " ▸" : label)); const v = make("span", "color:#e6edf3;word-break:break-word;flex:1"); r.appendChild(v); if (key) { r.onclick = () => { activeDrawer = activeDrawer === key ? null : key; renderDrawer(); }; } panel.appendChild(r); return v; }; const cache = info.cache || {}; const cacheLabel = cache.verdict === "dynamic" ? "dynamic" : cache.verdict + (cache.secs ? " · " + cache.secs + "s" : ""); rowEl("route", "The request path being server-rendered.").textContent = info.route || "—"; rowEl("page", "The page component (app/…/page.tsx) that matched this route.").textContent = info.component || "—"; const cacheV = rowEl( "cache", "SSR output-cache verdict — dynamic = re-rendered every request; cacheable = shared anonymous cache; bucketed = cached per signed-in/out shell. Shows the single reason it's dynamic.", "cache", ); cacheV.textContent = cacheLabel + (cache.reason ? " · " + cache.reason : ""); cacheV.style.color = cache.verdict === "dynamic" ? C.warn : C.ok; rowEl( "render", "How the page rendered (buffered vs streaming) and the server render time, end to end.", ).textContent = (info.renderMode || "ssr") + (info.renderMs != null ? " · " + info.renderMs + "ms" : ""); const syncV = rowEl( "sync", "Local-first sync engine — connection status · queued offline writes (outbox) · rows in the local replica.", ); const apiV = rowEl( "api", "Recent /api/* calls this page made: status + timing. 403s show the policy reason.", "api", ); const errV = rowEl( "errors", "Uncaught client errors + unhandled promise rejections captured on this page.", "errors", ); panel.appendChild(drawer); pill.title = "Pylon dev HUD — cache verdict, render timing, sync + /api activity, errors. Click to expand."; pill.appendChild(dot(cache.verdict === "dynamic" ? C.warn : C.ok)); pill.appendChild(make("span", "font-weight:600;color:#e6edf3", "pylon")); const pillSyncDot = dot(C.dim); pill.appendChild(pillSyncDot); let open = false; try { open = g.localStorage && g.localStorage.getItem("pylon.hud.open") === "1"; } catch (_e) {} panel.style.display = open ? "block" : "none"; pill.onclick = () => { open = !open; panel.style.display = open ? "block" : "none"; try { g.localStorage && g.localStorage.setItem("pylon.hud.open", open ? "1" : "0"); } catch (_e) {} }; const refresh = () => { const s = g.__pylonDevSync; if (s) { let st = "?"; let pend = 0; let rows = 0; try { st = s.status(); pend = s.pending(); rows = s.rows(); } catch (_e) {} syncV.textContent = st + " · " + pend + " pending · " + rows + " rows"; const col = st === "connected" ? C.ok : st === "offline" ? C.bad : C.warn; syncV.style.color = col; pillSyncDot.style.background = col; } else { const online = g.navigator ? g.navigator.onLine : true; syncV.textContent = "no sync engine · " + (online ? "online" : "offline"); syncV.style.color = C.dim; pillSyncDot.style.background = online ? C.dim : C.bad; } const failed = api.filter((a) => a.denied || a.status === 0 || a.status >= 400).length; apiV.textContent = api.length === 0 ? "—" : api.length + " calls" + (failed ? " · " + failed + " failed" : ""); apiV.style.color = failed ? C.bad : api.length ? C.ok : C.dim; errV.textContent = String(errs.length); errV.style.color = errs.length ? C.bad : C.dim; // Keep the live sections (api / errors) fresh while open. if (activeDrawer === "api" || activeDrawer === "errors") renderDrawer(); }; box.appendChild(panel); box.appendChild(pill); const mount = () => (d.body || d.documentElement).appendChild(box); if (d.body) mount(); else if (g.addEventListener) g.addEventListener("DOMContentLoaded", mount); refresh(); if (g.setInterval) g.setInterval(refresh, 1000); } /** * Dev-only tail chunk: the `__PYLON_DEV__` info blob (cache verdict, render * mode/timing, route) + the HUD bootstrap. Embedded after the page tail so the * marker + probe are in place before the deferred client entry boots the sync * engine. `<` is escaped so the JSON can't break out of the script. */ /** * Escape a JSON string so it can't break out of a `` (or `` pending markers, no // hidden fallback segments, no `$RC` reveal scripts. This is what makes // `serverData` + `use()` + hydrate cleanly: the client // hydrates a RESOLVED boundary against the SSR'd content (resolved from // `ssrData`), instead of fighting React's streaming-reveal scripts + // whole-document hydration (which leaves the boundary stuck on its // fallback). Pages with no async data have no boundaries, so `allReady` // resolves immediately — zero cost for the common case. // // EXCEPTION (#278): a STREAMING render (loading.tsx route-level boundary, // or `export const streaming = true` for inner boundaries) DELIBERATELY // skips the buffer — the shell + each fallback flush first, then // React reveals each boundary's real content + its reveal script as that // boundary's `use()` resolves. Hydration stays clean for ANY number of // boundaries because Pylon runs hydrateRoot ONCE, post-EOF: the entry // + U+2028/2029 // escaping. The CSS/modulepreload links were already injected into . // A design render never hydrates: the canvas owns the DOM. const wantsHydration = !designRender && (!isBoundaryComponent || !!preloadManifestRoute); if (wantsHydration) { const tail = buildHydrationTail({ component: msg.component, layouts: msg.layouts ?? [], props: tailProps, ssrData: ssrValueCache, manifestRoute: preloadManifestRoute, publicPrefix: preloadPublicPrefix, manifestErr: preloadManifestErr, kind: isBoundaryComponent ? /(^|\/)error$/.test(msg.component) ? "error" : "not-found" : undefined, // A bucketed render is stored shared → its tail must carry ONLY the // binary signed-in bit, never this request's real identity. bucketAuth: bucketable ? { signedIn: msg.session_present === true } : undefined, dataOnly: navRender, }); sendChunk(tail); } // Dev HUD (dev only): append the cache-verdict + render-timing blob + the // floating overlay, and emit ONE structured log line. After the page tail so // the HUD marker/probe are in place before the deferred client entry boots the // sync engine. `devVerdict` was computed once above (reused here + in the // x-pylon-dev header). The log rides the runtime's inherited stderr, so an // agent running `pylon dev` sees the verdict without any extra call. // // Never on a navigation response: there is no document for the overlay to // attach to, and its blob would dwarf the payload it rode in on. if (devVerdict && !navRender && !designRender) { const renderMs = Math.round((performance.now() - renderStart) * 10) / 10; // Build-level failures that degraded this page (currently: the // Tailwind compile). Dev-only and unmissable — the HUD paints a // banner, because an unstyled page never says "go read the log". let buildWarning: string | undefined; try { const { getManifest } = await import("./ssr-client-bundler"); const m: any = await getManifest(); if (m?.css_error) buildWarning = `Tailwind compile failed — serving without styles: ${m.css_error}`; } catch {} sendChunk( buildDevHudChunk({ buildWarning, route: msg.url, component: msg.component, renderMode, renderMs, // verdict + reason + the raw gate flags, so the HUD's cache drawer can // show the full per-gate breakdown (which check vetoed caching). cache: { ...devVerdict, flags: { bucketOptIn, revalidateSecs, authTouched, dynamicTouched, sessionTouched, cookieCount: responseState.cookies.length, strictPolicies, wantsStream, status: responseState.status, }, }, }), ); // The structured dev-log line is emitted host-side (Rust tracing) from the // x-pylon-dev header, so it rides the same log stream as every other // [pylon] line — no duplicate console.error here. } if (navRender) sendChunk(""); send({ type: "render_done", call_id: msg.call_id }); } catch (err: any) { // A page/layout called response.redirect()/response.notFound(), or // `notFound()` from @pylonsync/react, during render → short-circuit to a // 3xx + Location or a 404 instead of a body. Page-set cookies/headers // still ride along. const ctrl = asRouteControl(err); if (ctrl) { if (ctrl.kind === "redirect") { send({ type: "response_start", call_id: msg.call_id, status: ctrl.redirectStatus ?? 307, headers: finalizeHeaders(responseState, { location: ctrl.url ?? "/", }), }); send({ type: "render_done", call_id: msg.call_id }); return; } // notFound() → look for the nearest not-found.tsx walking up from the // page's directory; render it (wrapped in the route's layouts) at 404. // Falls back to a minimal framework body if none is defined. if ( await tryRenderBoundary({ React, renderToReadableStream, cwd, componentPath: msg.component, fileName: "not-found", layouts: msg.layouts, props, send, callId: msg.call_id, status: 404, headers: finalizeHeaders(responseState), design: msg.design === true ? { headers: msg.headers } : undefined, }) ) { return; } const body404 = '404 — Not Found

404

This page could not be found.

'; send({ type: "response_start", call_id: msg.call_id, status: 404, headers: finalizeHeaders(responseState), }); send({ type: "render_chunk", call_id: msg.call_id, data: Buffer.from(body404, "utf8").toString("base64"), }); send({ type: "render_done", call_id: msg.call_id }); return; } // Real pre-first-chunk error → look for the nearest error.tsx walking up // from the page's directory; render it (wrapped in the route's layouts) // at 500 with the thrown error passed in props. Falls back to a host-level // 500 (type:"error") if none is defined or the boundary itself throws. if ( await tryRenderBoundary({ React, renderToReadableStream, cwd, componentPath: msg.component, fileName: "error", layouts: msg.layouts, props: props ? { ...props, error: err } : null, send, callId: msg.call_id, status: 500, headers: finalizeHeaders(responseState), design: msg.design === true ? { headers: msg.headers } : undefined, }) ) { return; } // In dev, send the full stack as the message so the host can paint a // useful error overlay instead of an opaque 500. In prod, send only the // message (the host shows a generic page; the stack stays in logs). const devMode = isDevMode(); send({ type: "error", call_id: msg.call_id, code: err?.code ?? "SSR_RENDER_FAILED", message: devMode && err?.stack ? String(err.stack) : err?.message ?? String(err), }); } finally { // Revoke this render's per-request proxies. Any reference a page stashed in // module-level state (props or props.auth/headers/cookies/session) now throws // on access from a LATER render — fail-closed, so a prior request's identity // can never silently enter a cached body without tripping read-tracking. for (const revoke of proxyRevokers) { try { revoke(); } catch { // best-effort } } } }