@pyreon/server 0.13.1 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +1 -2
- package/lib/index.js.map +1 -1
- package/package.json +10 -7
- package/src/manifest.ts +138 -0
- package/src/tests/manifest-snapshot.test.ts +28 -0
|
@@ -5386,7 +5386,7 @@ var drawChart = (function (exports) {
|
|
|
5386
5386
|
</script>
|
|
5387
5387
|
<script>
|
|
5388
5388
|
/*<!--*/
|
|
5389
|
-
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"home/runner/work/pyreon/pyreon/packages/core/head/lib/ssr.js","uid":"
|
|
5389
|
+
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"home/runner/work/pyreon/pyreon/packages/core/head/lib/ssr.js","uid":"e7d677ae-1"},{"name":"src","children":[{"uid":"e7d677ae-3","name":"html.ts"},{"uid":"e7d677ae-5","name":"middleware.ts"},{"uid":"e7d677ae-7","name":"handler.ts"},{"uid":"e7d677ae-9","name":"island.ts"},{"uid":"e7d677ae-11","name":"ssg.ts"},{"uid":"e7d677ae-13","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"e7d677ae-1":{"renderedLength":2713,"gzipLength":1078,"brotliLength":0,"metaUid":"e7d677ae-0"},"e7d677ae-3":{"renderedLength":2790,"gzipLength":1162,"brotliLength":0,"metaUid":"e7d677ae-2"},"e7d677ae-5":{"renderedLength":1738,"gzipLength":835,"brotliLength":0,"metaUid":"e7d677ae-4"},"e7d677ae-7":{"renderedLength":3104,"gzipLength":1337,"brotliLength":0,"metaUid":"e7d677ae-6"},"e7d677ae-9":{"renderedLength":1401,"gzipLength":664,"brotliLength":0,"metaUid":"e7d677ae-8"},"e7d677ae-11":{"renderedLength":2806,"gzipLength":1214,"brotliLength":0,"metaUid":"e7d677ae-10"},"e7d677ae-13":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"e7d677ae-12"}},"nodeMetas":{"e7d677ae-0":{"id":"/home/runner/work/pyreon/pyreon/packages/core/head/lib/ssr.js","moduleParts":{"index.js":"e7d677ae-1"},"imported":[{"uid":"e7d677ae-14"},{"uid":"e7d677ae-16"}],"importedBy":[{"uid":"e7d677ae-6"}]},"e7d677ae-2":{"id":"/src/html.ts","moduleParts":{"index.js":"e7d677ae-3"},"imported":[],"importedBy":[{"uid":"e7d677ae-12"},{"uid":"e7d677ae-6"}]},"e7d677ae-4":{"id":"/src/middleware.ts","moduleParts":{"index.js":"e7d677ae-5"},"imported":[{"uid":"e7d677ae-14"}],"importedBy":[{"uid":"e7d677ae-12"},{"uid":"e7d677ae-6"}]},"e7d677ae-6":{"id":"/src/handler.ts","moduleParts":{"index.js":"e7d677ae-7"},"imported":[{"uid":"e7d677ae-14"},{"uid":"e7d677ae-0"},{"uid":"e7d677ae-15"},{"uid":"e7d677ae-16"},{"uid":"e7d677ae-2"},{"uid":"e7d677ae-4"}],"importedBy":[{"uid":"e7d677ae-12"}]},"e7d677ae-8":{"id":"/src/island.ts","moduleParts":{"index.js":"e7d677ae-9"},"imported":[{"uid":"e7d677ae-14"}],"importedBy":[{"uid":"e7d677ae-12"}]},"e7d677ae-10":{"id":"/src/ssg.ts","moduleParts":{"index.js":"e7d677ae-11"},"imported":[{"uid":"e7d677ae-17"},{"uid":"e7d677ae-18"}],"importedBy":[{"uid":"e7d677ae-12"}]},"e7d677ae-12":{"id":"/src/index.ts","moduleParts":{"index.js":"e7d677ae-13"},"imported":[{"uid":"e7d677ae-6"},{"uid":"e7d677ae-2"},{"uid":"e7d677ae-8"},{"uid":"e7d677ae-4"},{"uid":"e7d677ae-10"}],"importedBy":[],"isEntry":true},"e7d677ae-14":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"e7d677ae-6"},{"uid":"e7d677ae-8"},{"uid":"e7d677ae-4"},{"uid":"e7d677ae-0"}]},"e7d677ae-15":{"id":"@pyreon/router","moduleParts":{},"imported":[],"importedBy":[{"uid":"e7d677ae-6"}]},"e7d677ae-16":{"id":"@pyreon/runtime-server","moduleParts":{},"imported":[],"importedBy":[{"uid":"e7d677ae-6"},{"uid":"e7d677ae-0"}]},"e7d677ae-17":{"id":"node:fs/promises","moduleParts":{},"imported":[],"importedBy":[{"uid":"e7d677ae-10"}]},"e7d677ae-18":{"id":"node:path","moduleParts":{},"imported":[],"importedBy":[{"uid":"e7d677ae-10"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
|
|
5390
5390
|
|
|
5391
5391
|
const run = () => {
|
|
5392
5392
|
const width = window.innerWidth;
|
package/lib/index.js
CHANGED
|
@@ -342,10 +342,9 @@ function island(loader, options) {
|
|
|
342
342
|
const wrapper = async function IslandWrapper(props) {
|
|
343
343
|
const mod = await loader();
|
|
344
344
|
const Comp = typeof mod === "function" ? mod : mod.default;
|
|
345
|
-
const serializedProps = serializeIslandProps(props);
|
|
346
345
|
return h("pyreon-island", {
|
|
347
346
|
"data-component": name,
|
|
348
|
-
"data-props":
|
|
347
|
+
"data-props": serializeIslandProps(props),
|
|
349
348
|
"data-hydrate": hydrate
|
|
350
349
|
}, h(Comp, props));
|
|
351
350
|
};
|
package/lib/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../../head/lib/ssr.js","../src/html.ts","../src/middleware.ts","../src/handler.ts","../src/island.ts","../src/ssg.ts"],"sourcesContent":["import { createContext, h, pushContext } from \"@pyreon/core\";\nimport { renderToString } from \"@pyreon/runtime-server\";\n\n//#region src/context.ts\nfunction createHeadContext() {\n\tconst map = /* @__PURE__ */ new Map();\n\tlet dirty = true;\n\tlet cachedTags = [];\n\tlet cachedTitleTemplate;\n\tlet cachedHtmlAttrs = {};\n\tlet cachedBodyAttrs = {};\n\tfunction rebuild() {\n\t\tif (!dirty) return;\n\t\tdirty = false;\n\t\tconst keyed = /* @__PURE__ */ new Map();\n\t\tconst unkeyed = [];\n\t\tlet titleTemplate;\n\t\tconst htmlAttrs = {};\n\t\tconst bodyAttrs = {};\n\t\tfor (const entry of map.values()) {\n\t\t\tfor (const tag of entry.tags) if (tag.key) keyed.set(tag.key, tag);\n\t\t\telse unkeyed.push(tag);\n\t\t\tif (entry.titleTemplate !== void 0) titleTemplate = entry.titleTemplate;\n\t\t\tif (entry.htmlAttrs) Object.assign(htmlAttrs, entry.htmlAttrs);\n\t\t\tif (entry.bodyAttrs) Object.assign(bodyAttrs, entry.bodyAttrs);\n\t\t}\n\t\tcachedTags = [...keyed.values(), ...unkeyed];\n\t\tcachedTitleTemplate = titleTemplate;\n\t\tcachedHtmlAttrs = htmlAttrs;\n\t\tcachedBodyAttrs = bodyAttrs;\n\t}\n\treturn {\n\t\tadd(id, entry) {\n\t\t\tmap.set(id, entry);\n\t\t\tdirty = true;\n\t\t},\n\t\tremove(id) {\n\t\t\tmap.delete(id);\n\t\t\tdirty = true;\n\t\t},\n\t\tresolve() {\n\t\t\trebuild();\n\t\t\treturn cachedTags;\n\t\t},\n\t\tresolveTitleTemplate() {\n\t\t\trebuild();\n\t\t\treturn cachedTitleTemplate;\n\t\t},\n\t\tresolveHtmlAttrs() {\n\t\t\trebuild();\n\t\t\treturn cachedHtmlAttrs;\n\t\t},\n\t\tresolveBodyAttrs() {\n\t\t\trebuild();\n\t\t\treturn cachedBodyAttrs;\n\t\t}\n\t};\n}\nconst HeadContext = createContext(null);\n\n//#endregion\n//#region src/ssr.ts\nconst VOID_TAGS = new Set([\n\t\"meta\",\n\t\"link\",\n\t\"base\"\n]);\nasync function renderWithHead(app) {\n\tconst ctx = createHeadContext();\n\tfunction HeadInjector() {\n\t\tpushContext(new Map([[HeadContext.id, ctx]]));\n\t\treturn app;\n\t}\n\tconst html = await renderToString(h(HeadInjector, null));\n\tconst titleTemplate = ctx.resolveTitleTemplate();\n\treturn {\n\t\thtml,\n\t\thead: ctx.resolve().map((tag) => serializeTag(tag, titleTemplate)).join(\"\\n \"),\n\t\thtmlAttrs: ctx.resolveHtmlAttrs(),\n\t\tbodyAttrs: ctx.resolveBodyAttrs()\n\t};\n}\nfunction serializeTag(tag, titleTemplate) {\n\tif (tag.tag === \"title\") {\n\t\tconst raw = tag.children || \"\";\n\t\treturn `<title>${esc(titleTemplate ? typeof titleTemplate === \"function\" ? titleTemplate(raw) : titleTemplate.replace(/%s/g, raw) : raw)}</title>`;\n\t}\n\tconst props = tag.props;\n\tconst attrs = props ? Object.entries(props).map(([k, v]) => `${k}=\"${esc(v)}\"`).join(\" \") : \"\";\n\tconst open = attrs ? `<${tag.tag} ${attrs}` : `<${tag.tag}`;\n\tif (VOID_TAGS.has(tag.tag)) return `${open} />`;\n\treturn `${open}>${(tag.children || \"\").replace(/<\\/(script|style|noscript)/gi, \"<\\\\/$1\").replace(/<!--/g, \"<\\\\!--\")}</${tag.tag}>`;\n}\nconst ESC_RE = /[&<>\"]/g;\nconst ESC_MAP = {\n\t\"&\": \"&\",\n\t\"<\": \"<\",\n\t\">\": \">\",\n\t\"\\\"\": \""\"\n};\nfunction esc(s) {\n\treturn ESC_RE.test(s) ? s.replace(ESC_RE, (ch) => ESC_MAP[ch]) : s;\n}\n\n//#endregion\nexport { renderWithHead };\n//# sourceMappingURL=ssr.js.map","/**\n * HTML template processing for SSR/SSG.\n *\n * Templates use comment placeholders:\n * <!--pyreon-head--> — replaced with <head> tags (title, meta, link, etc.)\n * <!--pyreon-app--> — replaced with rendered application HTML\n * <!--pyreon-scripts--> — replaced with client entry script + inline loader data\n */\n\nexport const DEFAULT_TEMPLATE = `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <!--pyreon-head-->\n</head>\n<body>\n <div id=\"app\"><!--pyreon-app--></div>\n <!--pyreon-scripts-->\n</body>\n</html>`\n\nexport interface TemplateData {\n head: string\n app: string\n scripts: string\n}\n\n/**\n * Pre-compiled template — splits the template string once so that\n * each request only concatenates 6 parts instead of scanning 3x with `.replace()`.\n */\nexport interface CompiledTemplate {\n /** [before-head, between-head-app, between-app-scripts, after-scripts] */\n parts: [string, string, string, string]\n}\n\nexport function compileTemplate(template: string): CompiledTemplate {\n if (!template.includes('<!--pyreon-app-->')) {\n throw new Error('[Pyreon] Template must contain <!--pyreon-app--> placeholder')\n }\n const [beforeHead, afterHead] = splitOnce(template, '<!--pyreon-head-->')\n const [betweenHeadApp, afterApp] = splitOnce(afterHead, '<!--pyreon-app-->')\n const [betweenAppScripts, afterScripts] = splitOnce(afterApp, '<!--pyreon-scripts-->')\n return { parts: [beforeHead, betweenHeadApp, betweenAppScripts, afterScripts] }\n}\n\nfunction splitOnce(str: string, delimiter: string): [string, string] {\n const idx = str.indexOf(delimiter)\n if (idx === -1) return [str, '']\n return [str.slice(0, idx), str.slice(idx + delimiter.length)]\n}\n\nexport function processTemplate(template: string, data: TemplateData): string {\n return template\n .replace('<!--pyreon-head-->', data.head)\n .replace('<!--pyreon-app-->', data.app)\n .replace('<!--pyreon-scripts-->', data.scripts)\n}\n\n/** Fast path using a pre-compiled template */\nexport function processCompiledTemplate(compiled: CompiledTemplate, data: TemplateData): string {\n const [p0, p1, p2, p3] = compiled.parts\n return p0 + data.head + p1 + data.app + p2 + data.scripts + p3\n}\n\n/**\n * Build the script tags for client hydration.\n *\n * Emits:\n * 1. Inline script with serialized loader data (if any)\n * 2. Module script tag pointing to the client entry\n */\nexport function buildScripts(\n clientEntry: string,\n loaderData: Record<string, unknown> | null,\n): string {\n const parts: string[] = []\n\n if (loaderData && Object.keys(loaderData).length > 0) {\n // Escape </script> inside JSON to prevent premature tag close\n const json = JSON.stringify(loaderData).replace(/<\\//g, '<\\\\/')\n parts.push(`<script>window.__PYREON_LOADER_DATA__=${json}</script>`)\n }\n\n parts.push(`<script type=\"module\" src=\"${clientEntry}\"></script>`)\n\n return parts.join('\\n ')\n}\n\n/** Pre-build the static client entry script tag (invariant across requests) */\nexport function buildClientEntryTag(clientEntry: string): string {\n return `<script type=\"module\" src=\"${clientEntry}\"></script>`\n}\n\n/** Fast path: build scripts with a pre-built client entry tag */\nexport function buildScriptsFast(\n clientEntryTag: string,\n loaderData: Record<string, unknown> | null,\n): string {\n if (loaderData && Object.keys(loaderData).length > 0) {\n const json = JSON.stringify(loaderData).replace(/<\\//g, '<\\\\/')\n return `<script>window.__PYREON_LOADER_DATA__=${json}</script>\\n ${clientEntryTag}`\n }\n return clientEntryTag\n}\n","/**\n * SSR middleware — simple request processing pipeline.\n *\n * Middleware runs before rendering. Return a Response to short-circuit\n * (e.g. for redirects, auth checks, or static file serving).\n * Return void / undefined to continue to the next middleware or rendering.\n *\n * @example\n * const authMiddleware: Middleware = async (ctx) => {\n * const token = ctx.req.headers.get(\"Authorization\")\n * if (!token) return new Response(\"Unauthorized\", { status: 401 })\n * ctx.locals.user = await verifyToken(token)\n * }\n *\n * const handler = createHandler({\n * App,\n * routes,\n * middleware: [authMiddleware],\n * })\n */\n\nimport { createContext, useContext, provide } from '@pyreon/core'\n\nexport interface MiddlewareContext {\n /** The incoming request */\n req: Request\n /** Parsed URL */\n url: URL\n /** Pathname + search (passed to router) */\n path: string\n /** Response headers — middleware can set custom headers */\n headers: Headers\n /** Arbitrary per-request data shared between middleware and components */\n locals: Record<string, unknown>\n}\n\n/**\n * Middleware function. Return a Response to short-circuit, or void to continue.\n */\nexport type Middleware = (ctx: MiddlewareContext) => Response | void | Promise<Response | void>\n\n/**\n * Context for per-request locals — populated by the SSR handler from\n * middleware `ctx.locals`. Components access it via `useRequestLocals()`.\n *\n * This bridges the middleware → component gap: middleware sets `ctx.locals`,\n * the handler provides it into the Pyreon context system, and components\n * read it without coupling to the middleware layer.\n */\nexport const RequestLocalsCtx = createContext<Record<string, unknown>>({})\n\n/**\n * Read per-request locals inside a component (SSR only).\n *\n * Returns the `ctx.locals` object populated by middleware.\n * On the client, returns an empty object.\n *\n * @example\n * ```tsx\n * import { useRequestLocals } from \"@pyreon/server\"\n *\n * function MyComponent() {\n * const locals = useRequestLocals()\n * const nonce = locals.cspNonce as string ?? ''\n * return <script nonce={nonce}>...</script>\n * }\n * ```\n */\nexport function useRequestLocals(): Record<string, unknown> {\n return useContext(RequestLocalsCtx)\n}\n\n/**\n * Provide request locals into the component tree.\n * Called by the SSR handler — not for direct use.\n * @internal\n */\nexport function provideRequestLocals(locals: Record<string, unknown>): void {\n provide(RequestLocalsCtx, locals)\n}\n","/**\n * SSR request handler.\n *\n * Creates a Web-standard `(Request) => Promise<Response>` handler that:\n * 1. Runs middleware (auth, redirects, headers, etc.)\n * 2. Creates a per-request router with the matched URL\n * 3. Prefetches loader data for matched routes\n * 4. Renders the app to HTML with head tag collection\n * 5. Injects everything into an HTML template\n * 6. Returns a Response\n *\n * Compatible with Bun.serve, Deno.serve, Cloudflare Workers,\n * Express (via adapter), and any Web-standard server.\n *\n * @example\n * import { createHandler } from \"@pyreon/server\"\n *\n * const handler = createHandler({\n * App,\n * routes,\n * template: await Bun.file(\"index.html\").text(),\n * })\n *\n * Bun.serve({ fetch: handler })\n */\n\nimport type { ComponentFn } from '@pyreon/core'\nimport { h } from '@pyreon/core'\nimport { renderWithHead } from '@pyreon/head/ssr'\nimport {\n createRouter,\n prefetchLoaderData,\n type RouteRecord,\n RouterProvider,\n serializeLoaderData,\n} from '@pyreon/router'\nimport { renderToStream, runWithRequestContext } from '@pyreon/runtime-server'\nimport {\n buildClientEntryTag,\n buildScriptsFast,\n type CompiledTemplate,\n compileTemplate,\n DEFAULT_TEMPLATE,\n processCompiledTemplate,\n} from './html'\nimport type { Middleware, MiddlewareContext } from './middleware'\nimport { provideRequestLocals } from './middleware'\n\nconst __DEV__ = typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'\n\nexport interface HandlerOptions {\n /** Root application component */\n App: ComponentFn\n /** Route definitions */\n routes: RouteRecord[]\n /**\n * HTML template with placeholders:\n * <!--pyreon-head--> — head tags (title, meta, link, etc.)\n * <!--pyreon-app--> — rendered app HTML\n * <!--pyreon-scripts--> — client entry + loader data\n *\n * Defaults to a minimal HTML5 template.\n */\n template?: string\n /** Path to the client entry module (default: \"/src/entry-client.ts\") */\n clientEntry?: string\n /** Middleware chain — runs before rendering */\n middleware?: Middleware[]\n /**\n * Rendering mode:\n * \"string\" (default) — full renderToString, complete HTML in one response\n * \"stream\" — progressive streaming via renderToStream (Suspense out-of-order)\n */\n mode?: 'string' | 'stream'\n /**\n * Collect CSS styles after rendering. Called after renderToString/renderWithHead.\n * Return a `<style>` tag string to inject into `<head>`.\n * Used by @pyreon/styler's sheet.getStyleTag() to prevent FOUC in SSG.\n *\n * @example\n * import { sheet } from '@pyreon/styler'\n * createHandler({\n * collectStyles: () => {\n * const tag = sheet.getStyleTag()\n * sheet.reset()\n * return tag\n * },\n * })\n */\n collectStyles?: () => string\n}\n\nexport function createHandler(options: HandlerOptions): (req: Request) => Promise<Response> {\n const {\n App,\n routes,\n template = DEFAULT_TEMPLATE,\n clientEntry = '/src/entry-client.ts',\n middleware = [],\n mode = 'string',\n collectStyles,\n } = options\n\n // Pre-compile once at handler creation — avoids 3x string scan per request\n const compiled = compileTemplate(template)\n const clientEntryTag = buildClientEntryTag(clientEntry)\n\n return async function handler(req: Request): Promise<Response> {\n const url = new URL(req.url)\n const path = url.pathname + url.search\n\n // ── Middleware pipeline ────────────────────────────────────────────────────\n const ctx: MiddlewareContext = {\n req,\n url,\n path,\n headers: new Headers({ 'Content-Type': 'text/html; charset=utf-8' }),\n locals: {},\n }\n\n for (const mw of middleware) {\n const result = await mw(ctx)\n if (result instanceof Response) return result\n }\n\n // ── Per-request router ────────────────────────────────────────────────────\n const router = createRouter({ routes, mode: 'history', url: path })\n\n return runWithRequestContext(async () => {\n try {\n // Bridge middleware locals → Pyreon context system so components\n // can access per-request data (CSP nonce, auth user, etc.)\n provideRequestLocals(ctx.locals)\n\n // Pre-run loaders so data is available during render\n await prefetchLoaderData(router as never, path)\n\n // Build the VNode tree\n const app = h(RouterProvider, { router }, h(App, null))\n\n if (mode === 'stream') {\n return renderStreamResponse(app, router, compiled, clientEntryTag, ctx.headers)\n }\n\n // ── String mode (default) ─────────────────────────────────────────────\n const { html: appHtml, head } = await renderWithHead(app)\n\n // Collect CSS-in-JS styles if a collector was provided.\n // The consumer passes collectStyles (e.g. sheet.getStyleTag from @pyreon/styler)\n // to inject scoped CSS into <head> and prevent FOUC in SSG pages.\n const styleTag = collectStyles ? collectStyles() : ''\n\n const loaderData = serializeLoaderData(router as never)\n const scripts = buildScriptsFast(clientEntryTag, loaderData)\n const headWithStyles = styleTag ? `${styleTag}\\n${head}` : head\n const fullHtml = processCompiledTemplate(compiled, { head: headWithStyles, app: appHtml, scripts })\n\n return new Response(fullHtml, { status: 200, headers: ctx.headers })\n } catch (err) {\n if (__DEV__) {\n console.error('[Pyreon Server] SSR render failed:', err)\n }\n return new Response('Internal Server Error', {\n status: 500,\n headers: { 'Content-Type': 'text/plain' },\n })\n }\n })\n }\n}\n\n/**\n * Streaming mode: shell is emitted immediately, app content streams progressively.\n *\n * Head tags from the initial synchronous render are included in the shell.\n * Suspense boundaries resolve out-of-order via inline <template> + swap scripts.\n */\nasync function renderStreamResponse(\n app: ReturnType<typeof h>,\n router: ReturnType<typeof createRouter>,\n compiled: CompiledTemplate,\n clientEntryTag: string,\n extraHeaders: Headers,\n): Promise<Response> {\n const loaderData = serializeLoaderData(router as never)\n const scripts = buildScriptsFast(clientEntryTag, loaderData)\n\n // Use pre-split parts: [before-head, between-head-app, between-app-scripts, after-scripts]\n const [p0, p1, p2, p3] = compiled.parts\n const shellHead = p0 + p1\n const shellTail = p2 + scripts + p3\n\n const appStream = renderToStream(app)\n const reader = appStream.getReader()\n\n const stream = new ReadableStream<Uint8Array>({\n async start(controller) {\n const encoder = new TextEncoder()\n const push = (s: string) => controller.enqueue(encoder.encode(s))\n\n try {\n push(shellHead)\n\n // Stream app content\n let done = false\n while (!done) {\n const result = await reader.read()\n done = result.done\n if (result.value) push(result.value)\n }\n\n push(shellTail)\n } catch (err) {\n if (__DEV__) {\n console.error('[Pyreon Server] Stream render failed:', err)\n }\n // Emit an inline error indicator — status code is already sent (200)\n push(`<script>console.error(\"[pyreon/server] Stream render failed\")</script>`)\n push(shellTail)\n } finally {\n controller.close()\n }\n },\n })\n\n return new Response(stream, {\n status: 200,\n headers: extraHeaders,\n })\n}\n","/**\n * Island architecture — partial hydration for content-heavy sites.\n *\n * Islands are interactive components embedded in otherwise-static HTML.\n * Only island components ship JavaScript to the client — the rest of the\n * page stays as zero-JS server-rendered HTML.\n *\n * ## Server side\n *\n * `island()` wraps an async component import and returns a ComponentFn.\n * During SSR, it renders the component output inside a `<pyreon-island>` element\n * with serialized props, so the client knows what to hydrate.\n *\n * ```tsx\n * import { island } from \"@pyreon/server\"\n *\n * const Counter = island(() => import(\"./Counter\"), { name: \"Counter\" })\n * const Search = island(() => import(\"./Search\"), { name: \"Search\" })\n *\n * function Page() {\n * return <div>\n * <h1>Static heading (no JS)</h1>\n * <Counter initial={5} /> // hydrated on client\n * <p>Static paragraph</p>\n * <Search /> // hydrated on client\n * </div>\n * }\n * ```\n *\n * ## Client side\n *\n * Use `hydrateIslands()` from `@pyreon/server/client` to hydrate all islands\n * on the page. Only the island components' JavaScript is loaded.\n *\n * ```ts\n * // entry-client.ts (island mode)\n * import { hydrateIslands } from \"@pyreon/server/client\"\n *\n * hydrateIslands({\n * Counter: () => import(\"./Counter\"),\n * Search: () => import(\"./Search\"),\n * })\n * ```\n *\n * ## Hydration strategies\n *\n * Control when an island hydrates via the `hydrate` option:\n * - \"load\" (default) — hydrate immediately on page load\n * - \"idle\" — hydrate when the browser is idle (requestIdleCallback)\n * - \"visible\" — hydrate when the island scrolls into the viewport\n * - \"media(query)\" — hydrate when a media query matches\n * - \"never\" — never hydrate (render-only, no client JS)\n */\n\nimport type { ComponentFn, Props, VNode } from '@pyreon/core'\nimport { h } from '@pyreon/core'\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\nexport type HydrationStrategy = 'load' | 'idle' | 'visible' | 'never' | `media(${string})`\n\nexport interface IslandOptions {\n /** Unique name — must match the key in the client-side hydrateIslands() registry */\n name: string\n /** When to hydrate on the client (default: \"load\") */\n hydrate?: HydrationStrategy\n}\n\nexport interface IslandMeta {\n readonly __island: true\n readonly name: string\n readonly hydrate: HydrationStrategy\n}\n\n// ─── Server-side island factory ──────────────────────────────────────────────\n\n/**\n * Create an island component.\n *\n * Returns an async ComponentFn that:\n * 1. Resolves the dynamic import\n * 2. Renders the component to VNodes\n * 3. Wraps the output in `<pyreon-island>` with serialized props + hydration strategy\n */\nexport function island<P extends Props = Props>(\n loader: () => Promise<{ default: ComponentFn<P> } | ComponentFn<P>>,\n options: IslandOptions,\n): ComponentFn<P> & IslandMeta {\n const { name, hydrate = 'load' } = options\n\n const IslandWrapper = async function IslandWrapper(props: P): Promise<VNode | null> {\n const mod = await loader()\n const Comp = typeof mod === 'function' ? mod : mod.default\n const serializedProps = serializeIslandProps(props)\n\n return h(\n 'pyreon-island',\n {\n 'data-component': name,\n 'data-props': serializedProps,\n 'data-hydrate': hydrate,\n },\n h(Comp, props),\n )\n }\n\n // Attach metadata so the Vite plugin can detect islands for code-splitting\n const wrapper = IslandWrapper as unknown as ComponentFn<P> & IslandMeta\n Object.defineProperties(wrapper, {\n __island: { value: true, enumerable: true },\n name: { value: name, enumerable: true, writable: false, configurable: true },\n hydrate: { value: hydrate, enumerable: true },\n })\n\n return wrapper\n}\n\n// ─── Helpers ─────────────────────────────────────────────────────────────────\n\n/**\n * Serialize component props to a JSON string for embedding in HTML attributes.\n * Strips non-serializable values (functions, symbols, children).\n */\nfunction serializeIslandProps(props: Record<string, unknown>): string {\n const clean: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(props)) {\n // Skip non-serializable or internal props\n if (key === 'children') continue\n if (typeof value === 'function') continue\n if (typeof value === 'symbol') continue\n if (value === undefined) continue\n clean[key] = value\n }\n // The SSR renderer's renderProp() already applies escapeHtml() to attribute\n // values, so the JSON is safe to embed in HTML attributes without double-escaping.\n return JSON.stringify(clean)\n}\n","/**\n * Static Site Generation — pre-render routes to HTML files at build time.\n *\n * @example\n * // ssg.ts (run with: bun run ssg.ts)\n * import { createHandler } from \"@pyreon/server\"\n * import { prerender } from \"@pyreon/server\"\n * import { App } from \"./src/App\"\n * import { routes } from \"./src/routes\"\n *\n * const handler = createHandler({ App, routes })\n *\n * await prerender({\n * handler,\n * paths: [\"/\", \"/about\", \"/blog\", \"/blog/hello-world\"],\n * outDir: \"dist\",\n * })\n *\n * @example\n * // Dynamic paths from a CMS or filesystem\n * await prerender({\n * handler,\n * paths: async () => {\n * const posts = await fetchAllPosts()\n * return [\"/\", \"/about\", ...posts.map(p => `/blog/${p.slug}`)]\n * },\n * outDir: \"dist\",\n * })\n */\n\nimport { mkdir, writeFile } from 'node:fs/promises'\nimport { dirname, join, resolve } from 'node:path'\n\nexport interface PrerenderOptions {\n /** SSR handler created by createHandler() */\n handler: (req: Request) => Promise<Response>\n /** Routes to pre-render — array of URL paths or async function that returns them */\n paths: string[] | (() => string[] | Promise<string[]>)\n /** Output directory for the generated HTML files */\n outDir: string\n /** Origin for constructing full URLs (default: \"http://localhost\") */\n origin?: string\n /**\n * Called after each page is rendered — use for logging or progress tracking.\n * Return false to skip writing this page.\n */\n onPage?: (path: string, html: string) => void | boolean | Promise<void | boolean>\n}\n\nexport interface PrerenderResult {\n /** Number of pages generated */\n pages: number\n /** Paths that failed to render */\n errors: { path: string; error: unknown }[]\n /** Total elapsed time in milliseconds */\n elapsed: number\n}\n\n/**\n * Pre-render a list of routes to static HTML files.\n *\n * For each path:\n * 1. Constructs a Request for the path\n * 2. Calls the SSR handler to render to HTML\n * 3. Writes the HTML to `outDir/<path>/index.html`\n *\n * The root path \"/\" becomes `outDir/index.html`.\n * Paths like \"/about\" become `outDir/about/index.html`.\n */\nexport async function prerender(options: PrerenderOptions): Promise<PrerenderResult> {\n const { handler, outDir, origin = 'http://localhost', onPage } = options\n\n const start = Date.now()\n\n // Resolve paths (may be async)\n const paths = typeof options.paths === 'function' ? await options.paths() : options.paths\n\n let pages = 0\n const errors: PrerenderResult['errors'] = []\n\n async function renderPage(path: string): Promise<void> {\n const url = new URL(path, origin)\n const req = new Request(url.href)\n const res = await Promise.race([\n handler(req),\n new Promise<never>((_, reject) =>\n setTimeout(() => reject(new Error(`Prerender timeout for \"${path}\" (30s)`)), 30_000),\n ),\n ])\n\n if (!res.ok) {\n errors.push({ path, error: new Error(`HTTP ${res.status}`) })\n return\n }\n\n const html = await res.text()\n\n if (onPage) {\n const result = await onPage(path, html)\n if (result === false) return\n }\n\n const filePath = resolveOutputPath(outDir, path)\n\n const resolvedOut = resolve(outDir)\n if (!resolve(filePath).startsWith(resolvedOut)) {\n errors.push({ path, error: new Error(`Path traversal detected: \"${path}\"`) })\n return\n }\n\n await mkdir(dirname(filePath), { recursive: true })\n await writeFile(filePath, html, 'utf-8')\n pages++\n }\n\n // Process paths concurrently (batch of 10 to avoid overwhelming)\n const BATCH_SIZE = 10\n for (let i = 0; i < paths.length; i += BATCH_SIZE) {\n const batch = paths.slice(i, i + BATCH_SIZE)\n await Promise.all(\n batch.map(async (path) => {\n try {\n await renderPage(path)\n } catch (error) {\n errors.push({ path, error })\n }\n }),\n )\n }\n\n return {\n pages,\n errors,\n elapsed: Date.now() - start,\n }\n}\n\nfunction resolveOutputPath(outDir: string, path: string): string {\n if (path === '/') return join(outDir, 'index.html')\n if (path.endsWith('.html')) return join(outDir, path)\n return join(outDir, path, 'index.html')\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../head/lib/ssr.js","../src/html.ts","../src/middleware.ts","../src/handler.ts","../src/island.ts","../src/ssg.ts"],"sourcesContent":["import { createContext, h, pushContext } from \"@pyreon/core\";\nimport { renderToString } from \"@pyreon/runtime-server\";\n\n//#region src/context.ts\nfunction createHeadContext() {\n\tconst map = /* @__PURE__ */ new Map();\n\tlet dirty = true;\n\tlet cachedTags = [];\n\tlet cachedTitleTemplate;\n\tlet cachedHtmlAttrs = {};\n\tlet cachedBodyAttrs = {};\n\tfunction rebuild() {\n\t\tif (!dirty) return;\n\t\tdirty = false;\n\t\tconst keyed = /* @__PURE__ */ new Map();\n\t\tconst unkeyed = [];\n\t\tlet titleTemplate;\n\t\tconst htmlAttrs = {};\n\t\tconst bodyAttrs = {};\n\t\tfor (const entry of map.values()) {\n\t\t\tfor (const tag of entry.tags) if (tag.key) keyed.set(tag.key, tag);\n\t\t\telse unkeyed.push(tag);\n\t\t\tif (entry.titleTemplate !== void 0) titleTemplate = entry.titleTemplate;\n\t\t\tif (entry.htmlAttrs) Object.assign(htmlAttrs, entry.htmlAttrs);\n\t\t\tif (entry.bodyAttrs) Object.assign(bodyAttrs, entry.bodyAttrs);\n\t\t}\n\t\tcachedTags = [...keyed.values(), ...unkeyed];\n\t\tcachedTitleTemplate = titleTemplate;\n\t\tcachedHtmlAttrs = htmlAttrs;\n\t\tcachedBodyAttrs = bodyAttrs;\n\t}\n\treturn {\n\t\tadd(id, entry) {\n\t\t\tmap.set(id, entry);\n\t\t\tdirty = true;\n\t\t},\n\t\tremove(id) {\n\t\t\tmap.delete(id);\n\t\t\tdirty = true;\n\t\t},\n\t\tresolve() {\n\t\t\trebuild();\n\t\t\treturn cachedTags;\n\t\t},\n\t\tresolveTitleTemplate() {\n\t\t\trebuild();\n\t\t\treturn cachedTitleTemplate;\n\t\t},\n\t\tresolveHtmlAttrs() {\n\t\t\trebuild();\n\t\t\treturn cachedHtmlAttrs;\n\t\t},\n\t\tresolveBodyAttrs() {\n\t\t\trebuild();\n\t\t\treturn cachedBodyAttrs;\n\t\t}\n\t};\n}\nconst HeadContext = createContext(null);\n\n//#endregion\n//#region src/ssr.ts\nconst VOID_TAGS = new Set([\n\t\"meta\",\n\t\"link\",\n\t\"base\"\n]);\nasync function renderWithHead(app) {\n\tconst ctx = createHeadContext();\n\tfunction HeadInjector() {\n\t\tpushContext(new Map([[HeadContext.id, ctx]]));\n\t\treturn app;\n\t}\n\tconst html = await renderToString(h(HeadInjector, null));\n\tconst titleTemplate = ctx.resolveTitleTemplate();\n\treturn {\n\t\thtml,\n\t\thead: ctx.resolve().map((tag) => serializeTag(tag, titleTemplate)).join(\"\\n \"),\n\t\thtmlAttrs: ctx.resolveHtmlAttrs(),\n\t\tbodyAttrs: ctx.resolveBodyAttrs()\n\t};\n}\nfunction serializeTag(tag, titleTemplate) {\n\tif (tag.tag === \"title\") {\n\t\tconst raw = tag.children || \"\";\n\t\treturn `<title>${esc(titleTemplate ? typeof titleTemplate === \"function\" ? titleTemplate(raw) : titleTemplate.replace(/%s/g, raw) : raw)}</title>`;\n\t}\n\tconst props = tag.props;\n\tconst attrs = props ? Object.entries(props).map(([k, v]) => `${k}=\"${esc(v)}\"`).join(\" \") : \"\";\n\tconst open = attrs ? `<${tag.tag} ${attrs}` : `<${tag.tag}`;\n\tif (VOID_TAGS.has(tag.tag)) return `${open} />`;\n\treturn `${open}>${(tag.children || \"\").replace(/<\\/(script|style|noscript)/gi, \"<\\\\/$1\").replace(/<!--/g, \"<\\\\!--\")}</${tag.tag}>`;\n}\nconst ESC_RE = /[&<>\"]/g;\nconst ESC_MAP = {\n\t\"&\": \"&\",\n\t\"<\": \"<\",\n\t\">\": \">\",\n\t\"\\\"\": \""\"\n};\nfunction esc(s) {\n\treturn ESC_RE.test(s) ? s.replace(ESC_RE, (ch) => ESC_MAP[ch]) : s;\n}\n\n//#endregion\nexport { renderWithHead };\n//# sourceMappingURL=ssr.js.map","/**\n * HTML template processing for SSR/SSG.\n *\n * Templates use comment placeholders:\n * <!--pyreon-head--> — replaced with <head> tags (title, meta, link, etc.)\n * <!--pyreon-app--> — replaced with rendered application HTML\n * <!--pyreon-scripts--> — replaced with client entry script + inline loader data\n */\n\nexport const DEFAULT_TEMPLATE = `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <!--pyreon-head-->\n</head>\n<body>\n <div id=\"app\"><!--pyreon-app--></div>\n <!--pyreon-scripts-->\n</body>\n</html>`\n\nexport interface TemplateData {\n head: string\n app: string\n scripts: string\n}\n\n/**\n * Pre-compiled template — splits the template string once so that\n * each request only concatenates 6 parts instead of scanning 3x with `.replace()`.\n */\nexport interface CompiledTemplate {\n /** [before-head, between-head-app, between-app-scripts, after-scripts] */\n parts: [string, string, string, string]\n}\n\nexport function compileTemplate(template: string): CompiledTemplate {\n if (!template.includes('<!--pyreon-app-->')) {\n throw new Error('[Pyreon] Template must contain <!--pyreon-app--> placeholder')\n }\n const [beforeHead, afterHead] = splitOnce(template, '<!--pyreon-head-->')\n const [betweenHeadApp, afterApp] = splitOnce(afterHead, '<!--pyreon-app-->')\n const [betweenAppScripts, afterScripts] = splitOnce(afterApp, '<!--pyreon-scripts-->')\n return { parts: [beforeHead, betweenHeadApp, betweenAppScripts, afterScripts] }\n}\n\nfunction splitOnce(str: string, delimiter: string): [string, string] {\n const idx = str.indexOf(delimiter)\n if (idx === -1) return [str, '']\n return [str.slice(0, idx), str.slice(idx + delimiter.length)]\n}\n\nexport function processTemplate(template: string, data: TemplateData): string {\n return template\n .replace('<!--pyreon-head-->', data.head)\n .replace('<!--pyreon-app-->', data.app)\n .replace('<!--pyreon-scripts-->', data.scripts)\n}\n\n/** Fast path using a pre-compiled template */\nexport function processCompiledTemplate(compiled: CompiledTemplate, data: TemplateData): string {\n const [p0, p1, p2, p3] = compiled.parts\n return p0 + data.head + p1 + data.app + p2 + data.scripts + p3\n}\n\n/**\n * Build the script tags for client hydration.\n *\n * Emits:\n * 1. Inline script with serialized loader data (if any)\n * 2. Module script tag pointing to the client entry\n */\nexport function buildScripts(\n clientEntry: string,\n loaderData: Record<string, unknown> | null,\n): string {\n const parts: string[] = []\n\n if (loaderData && Object.keys(loaderData).length > 0) {\n // Escape </script> inside JSON to prevent premature tag close\n const json = JSON.stringify(loaderData).replace(/<\\//g, '<\\\\/')\n parts.push(`<script>window.__PYREON_LOADER_DATA__=${json}</script>`)\n }\n\n parts.push(`<script type=\"module\" src=\"${clientEntry}\"></script>`)\n\n return parts.join('\\n ')\n}\n\n/** Pre-build the static client entry script tag (invariant across requests) */\nexport function buildClientEntryTag(clientEntry: string): string {\n return `<script type=\"module\" src=\"${clientEntry}\"></script>`\n}\n\n/** Fast path: build scripts with a pre-built client entry tag */\nexport function buildScriptsFast(\n clientEntryTag: string,\n loaderData: Record<string, unknown> | null,\n): string {\n if (loaderData && Object.keys(loaderData).length > 0) {\n const json = JSON.stringify(loaderData).replace(/<\\//g, '<\\\\/')\n return `<script>window.__PYREON_LOADER_DATA__=${json}</script>\\n ${clientEntryTag}`\n }\n return clientEntryTag\n}\n","/**\n * SSR middleware — simple request processing pipeline.\n *\n * Middleware runs before rendering. Return a Response to short-circuit\n * (e.g. for redirects, auth checks, or static file serving).\n * Return void / undefined to continue to the next middleware or rendering.\n *\n * @example\n * const authMiddleware: Middleware = async (ctx) => {\n * const token = ctx.req.headers.get(\"Authorization\")\n * if (!token) return new Response(\"Unauthorized\", { status: 401 })\n * ctx.locals.user = await verifyToken(token)\n * }\n *\n * const handler = createHandler({\n * App,\n * routes,\n * middleware: [authMiddleware],\n * })\n */\n\nimport { createContext, useContext, provide } from '@pyreon/core'\n\nexport interface MiddlewareContext {\n /** The incoming request */\n req: Request\n /** Parsed URL */\n url: URL\n /** Pathname + search (passed to router) */\n path: string\n /** Response headers — middleware can set custom headers */\n headers: Headers\n /** Arbitrary per-request data shared between middleware and components */\n locals: Record<string, unknown>\n}\n\n/**\n * Middleware function. Return a Response to short-circuit, or void to continue.\n */\nexport type Middleware = (ctx: MiddlewareContext) => Response | void | Promise<Response | void>\n\n/**\n * Context for per-request locals — populated by the SSR handler from\n * middleware `ctx.locals`. Components access it via `useRequestLocals()`.\n *\n * This bridges the middleware → component gap: middleware sets `ctx.locals`,\n * the handler provides it into the Pyreon context system, and components\n * read it without coupling to the middleware layer.\n */\nexport const RequestLocalsCtx = createContext<Record<string, unknown>>({})\n\n/**\n * Read per-request locals inside a component (SSR only).\n *\n * Returns the `ctx.locals` object populated by middleware.\n * On the client, returns an empty object.\n *\n * @example\n * ```tsx\n * import { useRequestLocals } from \"@pyreon/server\"\n *\n * function MyComponent() {\n * const locals = useRequestLocals()\n * const nonce = locals.cspNonce as string ?? ''\n * return <script nonce={nonce}>...</script>\n * }\n * ```\n */\nexport function useRequestLocals(): Record<string, unknown> {\n return useContext(RequestLocalsCtx)\n}\n\n/**\n * Provide request locals into the component tree.\n * Called by the SSR handler — not for direct use.\n * @internal\n */\nexport function provideRequestLocals(locals: Record<string, unknown>): void {\n provide(RequestLocalsCtx, locals)\n}\n","/**\n * SSR request handler.\n *\n * Creates a Web-standard `(Request) => Promise<Response>` handler that:\n * 1. Runs middleware (auth, redirects, headers, etc.)\n * 2. Creates a per-request router with the matched URL\n * 3. Prefetches loader data for matched routes\n * 4. Renders the app to HTML with head tag collection\n * 5. Injects everything into an HTML template\n * 6. Returns a Response\n *\n * Compatible with Bun.serve, Deno.serve, Cloudflare Workers,\n * Express (via adapter), and any Web-standard server.\n *\n * @example\n * import { createHandler } from \"@pyreon/server\"\n *\n * const handler = createHandler({\n * App,\n * routes,\n * template: await Bun.file(\"index.html\").text(),\n * })\n *\n * Bun.serve({ fetch: handler })\n */\n\nimport type { ComponentFn } from '@pyreon/core'\nimport { h } from '@pyreon/core'\nimport { renderWithHead } from '@pyreon/head/ssr'\nimport {\n createRouter,\n prefetchLoaderData,\n type RouteRecord,\n RouterProvider,\n serializeLoaderData,\n} from '@pyreon/router'\nimport { renderToStream, runWithRequestContext } from '@pyreon/runtime-server'\nimport {\n buildClientEntryTag,\n buildScriptsFast,\n type CompiledTemplate,\n compileTemplate,\n DEFAULT_TEMPLATE,\n processCompiledTemplate,\n} from './html'\nimport type { Middleware, MiddlewareContext } from './middleware'\nimport { provideRequestLocals } from './middleware'\n\nconst __DEV__ = typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'\n\nexport interface HandlerOptions {\n /** Root application component */\n App: ComponentFn\n /** Route definitions */\n routes: RouteRecord[]\n /**\n * HTML template with placeholders:\n * <!--pyreon-head--> — head tags (title, meta, link, etc.)\n * <!--pyreon-app--> — rendered app HTML\n * <!--pyreon-scripts--> — client entry + loader data\n *\n * Defaults to a minimal HTML5 template.\n */\n template?: string\n /** Path to the client entry module (default: \"/src/entry-client.ts\") */\n clientEntry?: string\n /** Middleware chain — runs before rendering */\n middleware?: Middleware[]\n /**\n * Rendering mode:\n * \"string\" (default) — full renderToString, complete HTML in one response\n * \"stream\" — progressive streaming via renderToStream (Suspense out-of-order)\n */\n mode?: 'string' | 'stream'\n /**\n * Collect CSS styles after rendering. Called after renderToString/renderWithHead.\n * Return a `<style>` tag string to inject into `<head>`.\n * Used by @pyreon/styler's sheet.getStyleTag() to prevent FOUC in SSG.\n *\n * @example\n * import { sheet } from '@pyreon/styler'\n * createHandler({\n * collectStyles: () => {\n * const tag = sheet.getStyleTag()\n * sheet.reset()\n * return tag\n * },\n * })\n */\n collectStyles?: () => string\n}\n\nexport function createHandler(options: HandlerOptions): (req: Request) => Promise<Response> {\n const {\n App,\n routes,\n template = DEFAULT_TEMPLATE,\n clientEntry = '/src/entry-client.ts',\n middleware = [],\n mode = 'string',\n collectStyles,\n } = options\n\n // Pre-compile once at handler creation — avoids 3x string scan per request\n const compiled = compileTemplate(template)\n const clientEntryTag = buildClientEntryTag(clientEntry)\n\n return async function handler(req: Request): Promise<Response> {\n const url = new URL(req.url)\n const path = url.pathname + url.search\n\n // ── Middleware pipeline ────────────────────────────────────────────────────\n const ctx: MiddlewareContext = {\n req,\n url,\n path,\n headers: new Headers({ 'Content-Type': 'text/html; charset=utf-8' }),\n locals: {},\n }\n\n for (const mw of middleware) {\n const result = await mw(ctx)\n if (result instanceof Response) return result\n }\n\n // ── Per-request router ────────────────────────────────────────────────────\n const router = createRouter({ routes, mode: 'history', url: path })\n\n return runWithRequestContext(async () => {\n try {\n // Bridge middleware locals → Pyreon context system so components\n // can access per-request data (CSP nonce, auth user, etc.)\n provideRequestLocals(ctx.locals)\n\n // Pre-run loaders so data is available during render\n await prefetchLoaderData(router as never, path)\n\n // Build the VNode tree\n const app = h(RouterProvider, { router }, h(App, null))\n\n if (mode === 'stream') {\n return renderStreamResponse(app, router, compiled, clientEntryTag, ctx.headers)\n }\n\n // ── String mode (default) ─────────────────────────────────────────────\n const { html: appHtml, head } = await renderWithHead(app)\n\n // Collect CSS-in-JS styles if a collector was provided.\n // The consumer passes collectStyles (e.g. sheet.getStyleTag from @pyreon/styler)\n // to inject scoped CSS into <head> and prevent FOUC in SSG pages.\n const styleTag = collectStyles ? collectStyles() : ''\n\n const loaderData = serializeLoaderData(router as never)\n const scripts = buildScriptsFast(clientEntryTag, loaderData)\n const headWithStyles = styleTag ? `${styleTag}\\n${head}` : head\n const fullHtml = processCompiledTemplate(compiled, { head: headWithStyles, app: appHtml, scripts })\n\n return new Response(fullHtml, { status: 200, headers: ctx.headers })\n } catch (err) {\n if (__DEV__) {\n console.error('[Pyreon Server] SSR render failed:', err)\n }\n return new Response('Internal Server Error', {\n status: 500,\n headers: { 'Content-Type': 'text/plain' },\n })\n }\n })\n }\n}\n\n/**\n * Streaming mode: shell is emitted immediately, app content streams progressively.\n *\n * Head tags from the initial synchronous render are included in the shell.\n * Suspense boundaries resolve out-of-order via inline <template> + swap scripts.\n */\nasync function renderStreamResponse(\n app: ReturnType<typeof h>,\n router: ReturnType<typeof createRouter>,\n compiled: CompiledTemplate,\n clientEntryTag: string,\n extraHeaders: Headers,\n): Promise<Response> {\n const loaderData = serializeLoaderData(router as never)\n const scripts = buildScriptsFast(clientEntryTag, loaderData)\n\n // Use pre-split parts: [before-head, between-head-app, between-app-scripts, after-scripts]\n const [p0, p1, p2, p3] = compiled.parts\n const shellHead = p0 + p1\n const shellTail = p2 + scripts + p3\n\n const appStream = renderToStream(app)\n const reader = appStream.getReader()\n\n const stream = new ReadableStream<Uint8Array>({\n async start(controller) {\n const encoder = new TextEncoder()\n const push = (s: string) => controller.enqueue(encoder.encode(s))\n\n try {\n push(shellHead)\n\n // Stream app content\n let done = false\n while (!done) {\n const result = await reader.read()\n done = result.done\n if (result.value) push(result.value)\n }\n\n push(shellTail)\n } catch (err) {\n if (__DEV__) {\n console.error('[Pyreon Server] Stream render failed:', err)\n }\n // Emit an inline error indicator — status code is already sent (200)\n push(`<script>console.error(\"[pyreon/server] Stream render failed\")</script>`)\n push(shellTail)\n } finally {\n controller.close()\n }\n },\n })\n\n return new Response(stream, {\n status: 200,\n headers: extraHeaders,\n })\n}\n","/**\n * Island architecture — partial hydration for content-heavy sites.\n *\n * Islands are interactive components embedded in otherwise-static HTML.\n * Only island components ship JavaScript to the client — the rest of the\n * page stays as zero-JS server-rendered HTML.\n *\n * ## Server side\n *\n * `island()` wraps an async component import and returns a ComponentFn.\n * During SSR, it renders the component output inside a `<pyreon-island>` element\n * with serialized props, so the client knows what to hydrate.\n *\n * ```tsx\n * import { island } from \"@pyreon/server\"\n *\n * const Counter = island(() => import(\"./Counter\"), { name: \"Counter\" })\n * const Search = island(() => import(\"./Search\"), { name: \"Search\" })\n *\n * function Page() {\n * return <div>\n * <h1>Static heading (no JS)</h1>\n * <Counter initial={5} /> // hydrated on client\n * <p>Static paragraph</p>\n * <Search /> // hydrated on client\n * </div>\n * }\n * ```\n *\n * ## Client side\n *\n * Use `hydrateIslands()` from `@pyreon/server/client` to hydrate all islands\n * on the page. Only the island components' JavaScript is loaded.\n *\n * ```ts\n * // entry-client.ts (island mode)\n * import { hydrateIslands } from \"@pyreon/server/client\"\n *\n * hydrateIslands({\n * Counter: () => import(\"./Counter\"),\n * Search: () => import(\"./Search\"),\n * })\n * ```\n *\n * ## Hydration strategies\n *\n * Control when an island hydrates via the `hydrate` option:\n * - \"load\" (default) — hydrate immediately on page load\n * - \"idle\" — hydrate when the browser is idle (requestIdleCallback)\n * - \"visible\" — hydrate when the island scrolls into the viewport\n * - \"media(query)\" — hydrate when a media query matches\n * - \"never\" — never hydrate (render-only, no client JS)\n */\n\nimport type { ComponentFn, Props, VNode } from '@pyreon/core'\nimport { h } from '@pyreon/core'\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\nexport type HydrationStrategy = 'load' | 'idle' | 'visible' | 'never' | `media(${string})`\n\nexport interface IslandOptions {\n /** Unique name — must match the key in the client-side hydrateIslands() registry */\n name: string\n /** When to hydrate on the client (default: \"load\") */\n hydrate?: HydrationStrategy\n}\n\nexport interface IslandMeta {\n readonly __island: true\n readonly name: string\n readonly hydrate: HydrationStrategy\n}\n\n// ─── Server-side island factory ──────────────────────────────────────────────\n\n/**\n * Create an island component.\n *\n * Returns an async ComponentFn that:\n * 1. Resolves the dynamic import\n * 2. Renders the component to VNodes\n * 3. Wraps the output in `<pyreon-island>` with serialized props + hydration strategy\n */\nexport function island<P extends Props = Props>(\n loader: () => Promise<{ default: ComponentFn<P> } | ComponentFn<P>>,\n options: IslandOptions,\n): ComponentFn<P> & IslandMeta {\n const { name, hydrate = 'load' } = options\n\n const IslandWrapper = async function IslandWrapper(props: P): Promise<VNode | null> {\n const mod = await loader()\n const Comp = typeof mod === 'function' ? mod : mod.default\n const serializedProps = serializeIslandProps(props)\n\n return h(\n 'pyreon-island',\n {\n 'data-component': name,\n 'data-props': serializedProps,\n 'data-hydrate': hydrate,\n },\n h(Comp, props),\n )\n }\n\n // Attach metadata so the Vite plugin can detect islands for code-splitting\n const wrapper = IslandWrapper as unknown as ComponentFn<P> & IslandMeta\n Object.defineProperties(wrapper, {\n __island: { value: true, enumerable: true },\n name: { value: name, enumerable: true, writable: false, configurable: true },\n hydrate: { value: hydrate, enumerable: true },\n })\n\n return wrapper\n}\n\n// ─── Helpers ─────────────────────────────────────────────────────────────────\n\n/**\n * Serialize component props to a JSON string for embedding in HTML attributes.\n * Strips non-serializable values (functions, symbols, children).\n */\nfunction serializeIslandProps(props: Record<string, unknown>): string {\n const clean: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(props)) {\n // Skip non-serializable or internal props\n if (key === 'children') continue\n if (typeof value === 'function') continue\n if (typeof value === 'symbol') continue\n if (value === undefined) continue\n clean[key] = value\n }\n // The SSR renderer's renderProp() already applies escapeHtml() to attribute\n // values, so the JSON is safe to embed in HTML attributes without double-escaping.\n return JSON.stringify(clean)\n}\n","/**\n * Static Site Generation — pre-render routes to HTML files at build time.\n *\n * @example\n * // ssg.ts (run with: bun run ssg.ts)\n * import { createHandler } from \"@pyreon/server\"\n * import { prerender } from \"@pyreon/server\"\n * import { App } from \"./src/App\"\n * import { routes } from \"./src/routes\"\n *\n * const handler = createHandler({ App, routes })\n *\n * await prerender({\n * handler,\n * paths: [\"/\", \"/about\", \"/blog\", \"/blog/hello-world\"],\n * outDir: \"dist\",\n * })\n *\n * @example\n * // Dynamic paths from a CMS or filesystem\n * await prerender({\n * handler,\n * paths: async () => {\n * const posts = await fetchAllPosts()\n * return [\"/\", \"/about\", ...posts.map(p => `/blog/${p.slug}`)]\n * },\n * outDir: \"dist\",\n * })\n */\n\nimport { mkdir, writeFile } from 'node:fs/promises'\nimport { dirname, join, resolve } from 'node:path'\n\nexport interface PrerenderOptions {\n /** SSR handler created by createHandler() */\n handler: (req: Request) => Promise<Response>\n /** Routes to pre-render — array of URL paths or async function that returns them */\n paths: string[] | (() => string[] | Promise<string[]>)\n /** Output directory for the generated HTML files */\n outDir: string\n /** Origin for constructing full URLs (default: \"http://localhost\") */\n origin?: string\n /**\n * Called after each page is rendered — use for logging or progress tracking.\n * Return false to skip writing this page.\n */\n onPage?: (path: string, html: string) => void | boolean | Promise<void | boolean>\n}\n\nexport interface PrerenderResult {\n /** Number of pages generated */\n pages: number\n /** Paths that failed to render */\n errors: { path: string; error: unknown }[]\n /** Total elapsed time in milliseconds */\n elapsed: number\n}\n\n/**\n * Pre-render a list of routes to static HTML files.\n *\n * For each path:\n * 1. Constructs a Request for the path\n * 2. Calls the SSR handler to render to HTML\n * 3. Writes the HTML to `outDir/<path>/index.html`\n *\n * The root path \"/\" becomes `outDir/index.html`.\n * Paths like \"/about\" become `outDir/about/index.html`.\n */\nexport async function prerender(options: PrerenderOptions): Promise<PrerenderResult> {\n const { handler, outDir, origin = 'http://localhost', onPage } = options\n\n const start = Date.now()\n\n // Resolve paths (may be async)\n const paths = typeof options.paths === 'function' ? await options.paths() : options.paths\n\n let pages = 0\n const errors: PrerenderResult['errors'] = []\n\n async function renderPage(path: string): Promise<void> {\n const url = new URL(path, origin)\n const req = new Request(url.href)\n const res = await Promise.race([\n handler(req),\n new Promise<never>((_, reject) =>\n setTimeout(() => reject(new Error(`Prerender timeout for \"${path}\" (30s)`)), 30_000),\n ),\n ])\n\n if (!res.ok) {\n errors.push({ path, error: new Error(`HTTP ${res.status}`) })\n return\n }\n\n const html = await res.text()\n\n if (onPage) {\n const result = await onPage(path, html)\n if (result === false) return\n }\n\n const filePath = resolveOutputPath(outDir, path)\n\n const resolvedOut = resolve(outDir)\n if (!resolve(filePath).startsWith(resolvedOut)) {\n errors.push({ path, error: new Error(`Path traversal detected: \"${path}\"`) })\n return\n }\n\n await mkdir(dirname(filePath), { recursive: true })\n await writeFile(filePath, html, 'utf-8')\n pages++\n }\n\n // Process paths concurrently (batch of 10 to avoid overwhelming)\n const BATCH_SIZE = 10\n for (let i = 0; i < paths.length; i += BATCH_SIZE) {\n const batch = paths.slice(i, i + BATCH_SIZE)\n await Promise.all(\n batch.map(async (path) => {\n try {\n await renderPage(path)\n } catch (error) {\n errors.push({ path, error })\n }\n }),\n )\n }\n\n return {\n pages,\n errors,\n elapsed: Date.now() - start,\n }\n}\n\nfunction resolveOutputPath(outDir: string, path: string): string {\n if (path === '/') return join(outDir, 'index.html')\n if (path.endsWith('.html')) return join(outDir, path)\n return join(outDir, path, 'index.html')\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "SSR handler, SSG prerender, and island architecture for Pyreon",
|
|
5
5
|
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/server#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -47,11 +47,14 @@
|
|
|
47
47
|
"prepublishOnly": "bun run build"
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@pyreon/core": "^0.
|
|
51
|
-
"@pyreon/head": "^0.
|
|
52
|
-
"@pyreon/reactivity": "^0.
|
|
53
|
-
"@pyreon/router": "^0.
|
|
54
|
-
"@pyreon/runtime-dom": "^0.
|
|
55
|
-
"@pyreon/runtime-server": "^0.
|
|
50
|
+
"@pyreon/core": "^0.14.0",
|
|
51
|
+
"@pyreon/head": "^0.14.0",
|
|
52
|
+
"@pyreon/reactivity": "^0.14.0",
|
|
53
|
+
"@pyreon/router": "^0.14.0",
|
|
54
|
+
"@pyreon/runtime-dom": "^0.14.0",
|
|
55
|
+
"@pyreon/runtime-server": "^0.14.0"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@pyreon/manifest": "0.13.1"
|
|
56
59
|
}
|
|
57
60
|
}
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { defineManifest } from '@pyreon/manifest'
|
|
2
|
+
|
|
3
|
+
export default defineManifest({
|
|
4
|
+
name: '@pyreon/server',
|
|
5
|
+
title: 'SSR / SSG / Islands',
|
|
6
|
+
tagline:
|
|
7
|
+
'SSR + SSG + island architecture — createHandler(), prerender(), island(), middleware chain',
|
|
8
|
+
description:
|
|
9
|
+
'`@pyreon/server` is the production HTTP entry point and SSG generator. `createHandler()` produces a `(req: Request) => Promise<Response>` that handles SSR for every request, with a precompiled template (one parse at handler-creation, not per request) and middleware-chain support that short-circuits on the first `Response`. `prerender()` turns the same handler into a static-site generator. `island()` wraps a lazy-loaded component in a `<pyreon-island>` boundary with a hydration strategy (`load` / `idle` / `visible` / `media` / `never`) — the rest of the page stays HTML-only.',
|
|
10
|
+
category: 'server',
|
|
11
|
+
features: [
|
|
12
|
+
'createHandler({ App, routes, template?, clientEntry?, middleware?, mode?, collectStyles? })',
|
|
13
|
+
'mode: "string" (renderToString) or "stream" (renderToStream with Suspense out-of-order)',
|
|
14
|
+
'Middleware chain — `(ctx) => Response | void | Promise<…>`, short-circuit on first Response',
|
|
15
|
+
'prerender({ handler, paths, outDir, origin?, onPage? }) — SSG with onPage callback',
|
|
16
|
+
'island(loader, { name, hydrate }) — lazy island with hydration strategy',
|
|
17
|
+
'Hydration strategies: "load" | "idle" | "visible" | "media(...)" | "never"',
|
|
18
|
+
'useRequestLocals() bridges middleware `ctx.locals` into the component tree',
|
|
19
|
+
'Loader-data inline-script escaping — `</script>` becomes `<\\/script>`',
|
|
20
|
+
],
|
|
21
|
+
longExample: `import { createHandler, prerender, island } from '@pyreon/server'
|
|
22
|
+
import { App } from './App'
|
|
23
|
+
import routes from './routes'
|
|
24
|
+
|
|
25
|
+
// SSR handler — one precompiled template, middleware chain, stream mode
|
|
26
|
+
const handler = createHandler({
|
|
27
|
+
App,
|
|
28
|
+
routes,
|
|
29
|
+
clientEntry: '/src/entry-client.ts',
|
|
30
|
+
mode: 'stream',
|
|
31
|
+
middleware: [
|
|
32
|
+
async (ctx) => {
|
|
33
|
+
ctx.locals.user = await getUser(ctx.headers.get('cookie'))
|
|
34
|
+
},
|
|
35
|
+
async (ctx) => {
|
|
36
|
+
if (ctx.path.startsWith('/admin') && !ctx.locals.user?.admin) {
|
|
37
|
+
return new Response('Forbidden', { status: 403 })
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
export default { fetch: handler }
|
|
44
|
+
|
|
45
|
+
// SSG — generate a static site from the same handler
|
|
46
|
+
await prerender({
|
|
47
|
+
handler,
|
|
48
|
+
paths: ['/', '/about', '/blog/1', '/blog/2'],
|
|
49
|
+
outDir: './dist',
|
|
50
|
+
onPage: (path, html) => console.log(\`generated \${path} (\${html.length} bytes)\`),
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// Islands — hydrate selectively
|
|
54
|
+
const SearchBar = island(
|
|
55
|
+
() => import('./SearchBar'),
|
|
56
|
+
{ name: 'SearchBar', hydrate: 'visible' },
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
// Inside a component:
|
|
60
|
+
function useUser() {
|
|
61
|
+
return useRequestLocals().user as User | null
|
|
62
|
+
}`,
|
|
63
|
+
api: [
|
|
64
|
+
{
|
|
65
|
+
name: 'createHandler',
|
|
66
|
+
kind: 'function',
|
|
67
|
+
signature: 'createHandler(options: HandlerOptions): (req: Request) => Promise<Response>',
|
|
68
|
+
summary:
|
|
69
|
+
'Build a production SSR handler from your `App`, `routes`, and optional template / client entry / middleware. The template is precompiled once at handler-creation (split into 4 parts to skip three string scans per request); a missing `<!--pyreon-app-->` placeholder throws at creation time, not per request. Middleware runs before render with `ctx.locals` for cross-middleware data passing — return a `Response` to short-circuit the chain. `mode: "stream"` uses `renderToStream` so Suspense boundaries flush out-of-order; `mode: "string"` uses `renderToString` (default).',
|
|
70
|
+
example: `import { createHandler } from "@pyreon/server"
|
|
71
|
+
|
|
72
|
+
export default createHandler({
|
|
73
|
+
App,
|
|
74
|
+
routes,
|
|
75
|
+
clientEntry: "/src/entry-client.ts",
|
|
76
|
+
mode: "stream", // or "string"
|
|
77
|
+
})`,
|
|
78
|
+
mistakes: [
|
|
79
|
+
'Omitting `<!--pyreon-app-->` from the custom template — throws at handler-creation, not per request',
|
|
80
|
+
'Returning a `Response` from middleware and expecting downstream middleware to still run — the chain short-circuits on the first `Response`',
|
|
81
|
+
'Reading `ctx.locals` from inside the component without `useRequestLocals()` — the component tree only sees locals when bridged through that hook',
|
|
82
|
+
'Forgetting to escape user data inserted into a custom template — `createHandler` only escapes its own loader-data injection (`</script>` → `<\\/script>`); your template content is your responsibility',
|
|
83
|
+
],
|
|
84
|
+
seeAlso: ['prerender', 'island', 'useRequestLocals'],
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'island',
|
|
88
|
+
kind: 'function',
|
|
89
|
+
signature:
|
|
90
|
+
'island(loader: () => Promise<ComponentFn>, options: { name: string; hydrate?: HydrationStrategy }): ComponentFn',
|
|
91
|
+
summary:
|
|
92
|
+
'Wrap a lazily-loaded component in a `<pyreon-island>` boundary with a hydration strategy. The rest of the page stays HTML-only; only the island fetches its JS bundle and hydrates. Strategies: `"load"` (immediate), `"idle"` (`requestIdleCallback`), `"visible"` (IntersectionObserver), `"media(query)"` (matchMedia), `"never"` (HTML-only, no JS). Props passed to islands are JSON-serialized — non-JSON values (functions, symbols, undefined, children) are stripped.',
|
|
93
|
+
example: `const SearchBar = island(
|
|
94
|
+
() => import("./SearchBar"),
|
|
95
|
+
{ name: "SearchBar", hydrate: "visible" }
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
// Hydration strategies: "load" | "idle" | "visible" | "media" | "never"`,
|
|
99
|
+
mistakes: [
|
|
100
|
+
'Passing function props (event handlers, callbacks) — silently stripped during JSON serialization, the island sees `undefined`',
|
|
101
|
+
'Passing children to an island — stripped; islands cannot render arbitrary descendant trees from props',
|
|
102
|
+
'Forgetting to call `hydrateIslands({ Name: () => import("./Path") })` on the client — islands render as HTML and never hydrate',
|
|
103
|
+
'Using a duplicate `name` across two islands — the client-side registry collapses them, only one loader will fire',
|
|
104
|
+
],
|
|
105
|
+
seeAlso: ['createHandler', 'hydrateIslands'],
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: 'prerender',
|
|
109
|
+
kind: 'function',
|
|
110
|
+
signature: 'prerender(options: PrerenderOptions): Promise<PrerenderResult>',
|
|
111
|
+
summary:
|
|
112
|
+
'Static-site generator built on `createHandler`. Walks the `paths` array (or async generator), invokes the handler for each path, and writes the rendered HTML to `outDir/<path>.html`. The `onPage(path, html)` callback fires per page so callers can post-process or stream output. Validates `outDir` against path traversal (`../` segments are rejected). Errors per-page are collected in the result, not thrown.',
|
|
113
|
+
example: `await prerender({
|
|
114
|
+
handler,
|
|
115
|
+
paths: ["/", "/about", "/blog/1", "/blog/2"],
|
|
116
|
+
outDir: "./dist",
|
|
117
|
+
})`,
|
|
118
|
+
mistakes: [
|
|
119
|
+
'Passing a relative `outDir` and being surprised when it resolves against `process.cwd()` — pass an absolute path for predictability',
|
|
120
|
+
'Expecting per-page errors to throw — they\\\'re collected in `result.errors`; check the array after `await`',
|
|
121
|
+
'Generating thousands of paths without batching — the function processes the array sequentially; if you need parallelism, batch the `paths` array yourself',
|
|
122
|
+
],
|
|
123
|
+
seeAlso: ['createHandler'],
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
gotchas: [
|
|
127
|
+
{
|
|
128
|
+
label: 'Stream mode',
|
|
129
|
+
note:
|
|
130
|
+
'`mode: "stream"` uses `renderToStream` with shell + app + tail emission; Suspense boundaries resolve out-of-order. The `<head>` collected via `@pyreon/head` is flushed in the shell before any Suspense boundaries resolve, so async-loaded data does not contribute to it.',
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
label: 'Loader-data escaping',
|
|
134
|
+
note:
|
|
135
|
+
'Inline script JSON is rewritten — `</script>` becomes `<\\/script>` to prevent breaking out of the wrapping `<script>` tag. User data in a custom template is your responsibility; only the loader-data injection point is escaped.',
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import {
|
|
2
|
+
renderApiReferenceEntries,
|
|
3
|
+
renderLlmsFullSection,
|
|
4
|
+
renderLlmsTxtLine,
|
|
5
|
+
} from '@pyreon/manifest'
|
|
6
|
+
import manifest from '../manifest'
|
|
7
|
+
|
|
8
|
+
describe('gen-docs — server snapshot', () => {
|
|
9
|
+
it('renders a llms.txt bullet starting with the package prefix', () => {
|
|
10
|
+
const line = renderLlmsTxtLine(manifest)
|
|
11
|
+
expect(line.startsWith('- @pyreon/server —')).toBe(true)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('renders a llms-full.txt section with the right header', () => {
|
|
15
|
+
const section = renderLlmsFullSection(manifest)
|
|
16
|
+
expect(section.startsWith('## @pyreon/server —')).toBe(true)
|
|
17
|
+
expect(section).toContain('```typescript')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('renders MCP api-reference entries for every api[] item', () => {
|
|
21
|
+
const record = renderApiReferenceEntries(manifest)
|
|
22
|
+
expect(Object.keys(record).sort()).toEqual([
|
|
23
|
+
'server/createHandler',
|
|
24
|
+
'server/island',
|
|
25
|
+
'server/prerender',
|
|
26
|
+
])
|
|
27
|
+
})
|
|
28
|
+
})
|