UNPKG

@raven-js/reflex

Version:

Universal reactive signals for modern JavaScript - zero dependencies, SSR/hydration, DOM updates

443 lines (396 loc) 14.8 kB
/** * @author Anonyfox <max@anonyfox.com> * @license MIT * @see {@link https://github.com/Anonyfox/ravenjs} * @see {@link https://ravenjs.dev} * @see {@link https://anonyfox.com} */ /** * @file Server-side rendering with component-scoped hydration, automatic fetch caching, and multi-pass stability for complex async templates. */ import { __getWriteVersion, afterFlush, withTemplateContext } from "../index.js"; // Get the same global singleton used by the core. const __g = /** @type {any} */ (globalThis); if (!__g.__REFLEX_CONTEXT_STACK__) __g.__REFLEX_CONTEXT_STACK__ = []; const contextStack = /** @type {Array<{promises:Set<Promise<any>>, track(p:Promise<any>):void}>} */ ( __g.__REFLEX_CONTEXT_STACK__ ); /* ---------------- cache key ---------------- */ /** * Generate canonical cache key ensuring server-client cache coherence. * * Normalizes URLs to absolute form, sorts headers, and includes method and body hash * for deterministic caching across server and client hydration phases. * * @example * // Basic usage * createCacheKey('/api/data'); * // → '{"url":"https://example.com/api/data","method":"GET","headers":{},"bodyHash":null}' * * @example * // With request options * createCacheKey('/api/users', { * method: 'POST', * headers: { 'Content-Type': 'application/json' }, * body: '{"name":"Alice"}' * }); * * @param {string|URL|Request} url * @param {RequestInit} [opts] * @returns {string} */ export function createCacheKey(url, opts = {}) { let urlString; if (url instanceof URL) urlString = url.toString(); else if (typeof url === "object" && /** @type {any} */ (url).url) urlString = /** @type {any} */ (url).url; else urlString = String(url); const method = (opts.method || "GET").toUpperCase(); /** @type {Record<string,string>} */ const headers = {}; if (opts.headers) { if (opts.headers instanceof Headers) { for (const [k, v] of opts.headers.entries()) headers[k.toLowerCase()] = v; } else { for (const [k, v] of Object.entries(opts.headers)) headers[k.toLowerCase()] = String(v); } } let bodyHash = null; if (opts.body && method !== "GET") { bodyHash = typeof opts.body === "string" ? `${opts.body.length}:${opts.body.slice(0, 100)}` : "[object]"; } return JSON.stringify({ url: urlString, method, headers, bodyHash }); } /* ---------------- helpers ---------------- */ /** * Escape JSON for safe inline script embedding with XSS protection. * @param {any} data * @returns {string} */ function escapeJson(data) { return JSON.stringify(data) .replace(/</g, "\\u003c") .replace(/>/g, "\\u003e") .replace(/\u2028/g, "\\u2028") .replace(/\u2029/g, "\\u2029"); } /** * Inline component-scoped SSR data right after component HTML with size limits. * @param {string} html * @param {{fetch:Record<string, any>}} ssrData * @param {string} cid * @returns {string} */ function injectSSRData(html, ssrData, cid) { if (!ssrData || Object.keys(ssrData).length === 0) return html; const json = escapeJson(ssrData); if (json.length > 512 * 1024) return html; // cap at 512KB const nonce = /** @type {any} */ (globalThis).__REFLEX_CSP_NONCE__; const nonceAttr = nonce ? ` nonce="${nonce}"` : ""; return `${html}<script${nonceAttr}>window.__SSR_DATA__${cid}=${json};</script>`; } /** * Wait for all tracked promises to settle with exponential backoff and per-promise timeouts. * @param {{promises:Set<Promise<any>>}} ctx * @param {number} timeoutTotal * @param {number} maxAttempts */ async function settleAllPromises(ctx, timeoutTotal, maxAttempts) { const start = Date.now(); let attempts = 0; while (ctx.promises.size > 0 && attempts < maxAttempts) { const elapsed = Date.now() - start; if (elapsed > timeoutTotal) { // eslint-disable-next-line no-console console.warn(`SSR timeout: ${ctx.promises.size} pending after ${timeoutTotal}ms`); break; } const batch = Array.from(ctx.promises); await Promise.allSettled( batch.map((p) => { // soft per-promise timeout (max 5s or remaining time) const per = Math.min(5000, timeoutTotal - elapsed); return Promise.race([ p, new Promise((_, rej) => setTimeout(() => rej(new Error("promise timeout")), per)), ]).catch(() => {}); }) ); attempts++; if (ctx.promises.size > 0) { await new Promise((r) => setTimeout(r, Math.min(100, 1 << attempts))); // backoff } } } /* ---------------- ssr(fn) ---------------- */ /** * Wrap a function for SSR with component-scoped hydration and automatic fetch caching. * * Server runs multi-pass rendering until output stabilizes, caches GET requests per component, * and injects component-scoped data inline with HTML. Client hydrates using cached responses * then restores normal fetch behavior after effects complete. * * @example * // Basic usage * const UserWidget = ssr(async () => { * const response = await fetch('/api/user'); * const user = await response.json(); * return `<span>Hello ${user.name}!</span>`; * }); * * @example * // Complex reactive template * const TodoApp = ssr(async () => { * const todos = signal([]); * const response = await fetch('/api/todos'); * todos.set(await response.json()); * * return withTemplateContext(() => ` * <div> * <h1>Todos (${todos().length})</h1> * <ul>${todos().map(t => `<li>${t.title}</li>`).join('')}</ul> * </div> * `); * }); * * @example * // Custom timeout and passes * const HeavyWidget = ssr(async () => { * // Complex async logic * }, { timeout: 15000, maxPasses: 12 }); * * @template T * @param {(...a:any) => T|Promise<T>} fn * @param {{ * timeout?: number, * maxSettleAttempts?: number, * maxPasses?: number, * _testComponentId?: string * }} [options] * @returns {(...a:any) => Promise<T>} */ export function ssr(fn, options = {}) { if (/** @type {any} */ (fn)._ssrWrapped) return /** @type {any} */ (fn); const { timeout = 10000, maxSettleAttempts = 100, maxPasses = 8, _testComponentId } = options; const cid = _testComponentId || (typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID().replace(/-/g, "").slice(0, 8) : (Date.now().toString(36) + Math.random().toString(36).slice(2, 8)).slice(-8)); const wrapped = async function (/** @type {...any} */ ...args) { const isServer = typeof globalThis.window === "undefined" || (typeof globalThis.window !== "undefined" && globalThis.window?.navigator?.userAgent === "Node.js"); const isClient = typeof globalThis.window !== "undefined" && (!globalThis.window?.navigator || globalThis.window?.navigator.userAgent !== "Node.js"); const isRoot = contextStack.length === 0; // Hydration: serve from component-scoped cache on the client, then restore fetch. if ( isClient && isRoot && typeof globalThis.window !== "undefined" && globalThis.window && Object.keys(globalThis.window).some((k) => k.startsWith("__SSR_DATA__")) ) { const orig = globalThis.fetch; // find a cached entry across any component-scoped SSR blob /** @param {string} cacheKey */ function findCached(cacheKey) { const w = /** @type {any} */ (globalThis.window); for (const k of Object.keys(w)) { if (!k.startsWith("__SSR_DATA__")) continue; const bag = w[k]; const entry = bag?.fetch?.[cacheKey]; if (entry) return { bag, key: k, entry }; } return null; } globalThis.fetch = async (url, opts = {}) => { const key = createCacheKey(url, opts); const hit = findCached(key); if (hit) { // consume this entry delete hit.bag.fetch[key]; if (Object.keys(hit.bag.fetch).length === 0) { delete (/** @type {any} */ (globalThis.window)[hit.key]); } const cached = hit.entry; return /** @type {Response} */ ({ ok: cached.ok, status: cached.status, statusText: cached.statusText, headers: new Headers(cached.headers), url: String( url && typeof url === "object" && /** @type {any} */ (url).url ? /** @type {any} */ (url).url : url ), redirected: false, type: "basic", body: null, bodyUsed: false, json: () => Promise.resolve(cached.json), text: () => Promise.resolve(cached.text), arrayBuffer: () => Promise.resolve(cached.arrayBuffer), blob: () => Promise.resolve(cached.blob), clone() { return this; }, formData: () => Promise.reject(new Error("formData not available in SSR cache")), bytes: () => Promise.resolve(cached.arrayBuffer || new ArrayBuffer(0)), }); } return orig(url, opts); }; // Don't restore fetch immediately - let deferred effects run first try { const result = /** @type {any} */ (await fn.apply(this, args)); // Wait for the next flush cycle to complete before restoring fetch // This ensures deferred effects get to use the SSR cache afterFlush().then(() => { globalThis.fetch = orig; }); return result; } catch (error) { globalThis.fetch = orig; throw error; } } // Server root: do a settle loop if (isServer && isRoot) { const ctx = { promises: new Set(), fetchCache: new Map(), ssrData: /** @type {{fetch:Record<string,any>}|null} */ (null), track(/** @type {Promise<any>} */ p) { if (p && typeof p.then === "function") { this.promises.add(p); p.finally(() => this.promises.delete(p)).catch(() => {}); } }, }; contextStack.push(ctx); // Save the original fetch (might be localfetch enhanced) const orig = globalThis.fetch; // Create SSR wrapper that calls the original const ssrFetch = async (/** @type {any} */ url, /** @type {RequestInit} */ opts = {}) => { const method = (opts.method || "GET").toUpperCase(); const key = createCacheKey(url, opts); if (method === "GET" && ctx.fetchCache.has(key)) return ctx.fetchCache.get(key); const p = orig(url, opts).then(async (resp) => { const data = { ok: resp.ok, status: resp.status, statusText: resp.statusText, headers: Array.from(resp.headers.entries()), json: /** @type {any} */ (null), text: /** @type {any} */ (null), arrayBuffer: /** @type {any} */ (null), blob: /** @type {any} */ (null), }; // Pre-read the body for caching (consumes the original response) try { const ct = resp.headers.get("content-type") || ""; if (ct.includes("application/json")) { data.json = await resp.json(); } else if (ct.includes("text/")) { data.text = await resp.text(); } else { data.arrayBuffer = await resp.arrayBuffer(); } } catch { try { data.text = await resp.text(); } catch {} } // Create a mock response that returns cached data const proxy = { ok: resp.ok, status: resp.status, statusText: resp.statusText, headers: resp.headers, url: resp.url, redirected: resp.redirected, type: resp.type, bodyUsed: true, json: () => { ctx.track(Promise.resolve(data.json)); return Promise.resolve(data.json); }, text: () => { ctx.track(Promise.resolve(data.text)); return Promise.resolve(data.text); }, arrayBuffer: () => { ctx.track(Promise.resolve(data.arrayBuffer)); return Promise.resolve(data.arrayBuffer); }, blob: () => { ctx.track(Promise.resolve(data.blob)); return Promise.resolve(data.blob); }, clone() { return this; }, formData: () => Promise.reject(new Error("formData not available in SSR")), }; if (method === "GET") { if (!ctx.ssrData) ctx.ssrData = { fetch: {} }; ctx.ssrData.fetch[key] = data; } return proxy; }); ctx.track(p); if (method === "GET") ctx.fetchCache.set(key, p); return p; }; // Set our wrapper globalThis.fetch = ssrFetch; // stable render scope across passes (prevents instance churn) const scope = { slots: /** @type {any[]} */ ([]), cursor: 0 }; let html = ""; let prevHtml = ""; let prevVer = __getWriteVersion(); let pass = 0; try { while (pass++ < maxPasses) { const out = withTemplateContext(() => fn.apply(this, args), scope); html = await Promise.resolve(out); // 1) run deferred effects (handled by withTemplateContext root) // 2) flush graph await afterFlush(); // 3) settle async await settleAllPromises(ctx, timeout, maxSettleAttempts); // 4) final flush await afterFlush(); const stable = ctx.promises.size === 0 && __getWriteVersion() === prevVer && html === prevHtml; // Debug hook for tracing SSR behavior if (/** @type {any} */ (globalThis).__REFLEX_DEBUG__) { // eslint-disable-next-line no-console console.log("[SSR] pass", pass, { promises: ctx.promises.size, writeVersion: __getWriteVersion(), htmlChanged: html !== prevHtml, stable, }); } if (stable) break; prevVer = __getWriteVersion(); prevHtml = html; } } finally { globalThis.fetch = orig; contextStack.pop(); } if (typeof html === "string" && ctx.ssrData) { return /** @type {any} */ (injectSSRData(html, ctx.ssrData, cid)); } return /** @type {any} */ (html); } // Fallback (non-root / non-SSR) const res = await fn.apply(this, args); const parent = contextStack[contextStack.length - 1]; if (parent && res && typeof res.then === "function") parent.track(res); return /** @type {any} */ (res); }; /** @type {any} */ (wrapped)._ssrWrapped = true; return wrapped; }