@li3/ssr 0.1.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/README.md ADDED
@@ -0,0 +1,273 @@
1
+ # @li3/ssr
2
+
3
+ Server-side rendering for `@li3/web` applications. It accepts a complete HTML page, creates a virtual
4
+ server DOM with `jsdom`, runs the normal Lithium template/component runtime, waits for reactive bindings
5
+ to settle, and returns serialized HTML.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ pnpm add @li3/ssr @li3/web
11
+ ```
12
+
13
+ ## Basic usage
14
+
15
+ The Node server owns routing and page loading. `@li3/ssr` is the HTML-in/HTML-out rendering step:
16
+
17
+ ```js
18
+ import { readFile } from 'node:fs/promises';
19
+ import { renderPage } from '@li3/ssr';
20
+
21
+ const page = await readFile('./pages/home.html', 'utf8');
22
+ const { html } = await renderPage({
23
+ html: page,
24
+ url: 'https://example.com/home',
25
+ });
26
+
27
+ res.type('html').send(html);
28
+ ```
29
+
30
+ The input page can contain every `@li3/web` feature: interpolations, events, properties, attributes,
31
+ classes, styles, `<template if>`, `<template for>`, `<template component>`, and `<template app>`.
32
+
33
+ ## Example page
34
+
35
+ ```html
36
+ <!doctype html>
37
+ <html>
38
+ <head><title>Counter</title></head>
39
+ <body>
40
+ <template app>
41
+ <h1>{{ title }}</h1>
42
+ <p>Count: {{ count }}</p>
43
+
44
+ <template if="count > 0">
45
+ <p>You have clicked {{ count }} times.</p>
46
+ </template>
47
+
48
+ <ul>
49
+ <template for="item of items">
50
+ <li>{{ item }}</li>
51
+ </template>
52
+ </ul>
53
+
54
+ <button on-click="increment()">Increment</button>
55
+
56
+ <script setup>
57
+ import { ref } from '@li3/web';
58
+
59
+ export default function () {
60
+ const title = ref('Server-rendered counter');
61
+ const count = ref(0);
62
+ const items = ref(['first', 'second']);
63
+ const increment = () => count.value++;
64
+
65
+ return { title, count, items, increment };
66
+ }
67
+ </script>
68
+ </template>
69
+ </body>
70
+ </html>
71
+ ```
72
+
73
+ The returned HTML contains the rendered heading, count, conditional paragraph, and list rows before the
74
+ browser loads JavaScript.
75
+
76
+ ## Render options
77
+
78
+ ```ts
79
+ type RenderOptions = {
80
+ html: string;
81
+ url?: string;
82
+ components?: string[];
83
+ settle?: number;
84
+ hydrate?: 'hydrate' | 'static' | 'none';
85
+ state?: Record<string, unknown>[];
86
+ };
87
+ ```
88
+
89
+ - `html` is the complete page source.
90
+ - `url` defaults to `http://localhost/` and is used to resolve relative component/setup/style URLs.
91
+ - `components` optionally lists component HTML files to load before rendering. Each URL is resolved
92
+ relative to `url`.
93
+ - `settle` adds a delay after Lithium's normal reactive flush. Use it when setup code starts asynchronous
94
+ work that must affect the initial response. It defaults to `0`; the renderer always waits for the normal
95
+ binding and `if`/`for` timers.
96
+ - `state` overrides automatic state collection and supplies snapshots to embed in the output.
97
+ - `hydrate` controls the returned page:
98
+ - `hydrate` (default) keeps the source templates, marks app projection roots, embeds state, and adds a
99
+ small module bootstrap. The browser re-renders into the existing projection roots without creating
100
+ duplicate app containers.
101
+ - `static` removes component/app source templates and root markers. Use this for a no-JavaScript static
102
+ response.
103
+ - `none` keeps the source templates but adds no bootstrap. Only use this when application code owns the
104
+ client boot process.
105
+
106
+ ## Hydration contract
107
+
108
+ Lithium's current client runtime does **not** reconcile server-rendered text, attributes, `if` blocks, or
109
+ `for` rows. The server and client both execute the templates:
110
+
111
+ 1. The server executes all bindings, including `if` and `for`, so the initial HTML is complete.
112
+ 2. The server leaves the `<template app>` source available as the client's rendering blueprint.
113
+ 3. The server marks the generated projection div with `data-li3-root`.
114
+ 4. The browser's SSR bootstrap enables the `ssr` feature flag. `findApps()` reuses that projection div,
115
+ then `mount()` clears and renders its contents again from the source template.
116
+
117
+ This is intentional. `if`/`for` rows contain live sub-contexts created during the server render, so trying
118
+ to infer and adopt them would be unreliable. They are rendered on the server for first paint and rebuilt
119
+ client-side for live bindings. Only the empty projection container is adopted, preventing duplicate app
120
+ roots.
121
+
122
+ ## State snapshots
123
+
124
+ In hydrate mode, each app gets a JSON snapshot:
125
+
126
+ ```html
127
+ <script type="application/json" data-li3-ssr="0">
128
+ {"title":"Server-rendered counter","count":0,"items":["first","second"]}
129
+ </script>
130
+ ```
131
+
132
+ `renderPage()` also returns these snapshots as `result.state`. Signals are unwrapped; functions, internal
133
+ framework values, and DOM nodes are omitted. Use `serializeState()` and `readState()` for low-level access:
134
+
135
+ ```js
136
+ import { readState } from '@li3/ssr';
137
+
138
+ const snapshots = readState(document);
139
+ ```
140
+
141
+ State is embedded safely: closing script sequences are escaped before insertion into JSON script tags.
142
+
143
+ The current `@li3/web` setup API does not automatically consume `data-li3-ssr` snapshots. Setup code must
144
+ remain deterministic or explicitly read state supplied by the application. The snapshot is available for
145
+ application bootstrapping and future hydration improvements.
146
+
147
+ ## Components
148
+
149
+ Components declared in the input page are registered and rendered normally:
150
+
151
+ ```html
152
+ <template component="user-card">
153
+ <article>
154
+ <h2>{{ name }}</h2>
155
+ </article>
156
+
157
+ <script setup>
158
+ import { defineProp } from '@li3/web';
159
+ export default function () {
160
+ return { name: defineProp('name', { default: 'Anonymous' }) };
161
+ }
162
+ </script>
163
+ </template>
164
+
165
+ <template app>
166
+ <user-card name="Ada"></user-card>
167
+ </template>
168
+ ```
169
+
170
+ Use `components` when component templates are stored in separate HTML files:
171
+
172
+ ```js
173
+ await renderPage({
174
+ html: await readFile('./pages/home.html', 'utf8'),
175
+ url: 'https://example.com/home',
176
+ components: ['./components/ui-kit.html'],
177
+ });
178
+ ```
179
+
180
+ `<link rel="component" href="./components/ui-kit.html">` in the page also works through the regular
181
+ `@li3/web` loader.
182
+
183
+ ## Virtual DOM API
184
+
185
+ Use `createDom()` when integrating the runtime manually:
186
+
187
+ ```js
188
+ import { createDom } from '@li3/ssr';
189
+
190
+ const virtual = createDom('<!doctype html><html><body></body></html>');
191
+ try {
192
+ // @li3/web APIs can run here against virtual.document.
193
+ console.log(virtual.document.body.innerHTML);
194
+ } finally {
195
+ virtual.restore();
196
+ }
197
+ ```
198
+
199
+ `withDom(html, url, callback)` performs the same setup and always restores Node globals:
200
+
201
+ ```js
202
+ import { withDom } from '@li3/ssr';
203
+
204
+ await withDom(page, 'https://example.com/', async ({ document }) => {
205
+ // work with the virtual document
206
+ });
207
+ ```
208
+
209
+ Always restore the virtual DOM when using `createDom()` directly. The renderer does this automatically
210
+ after serializing the result.
211
+
212
+ ## Output modes
213
+
214
+ ### Hydrated HTML (default)
215
+
216
+ Use for interactive pages. The output includes:
217
+
218
+ - server-rendered app content,
219
+ - `data-li3-root` on each app projection div,
220
+ - serialized app state,
221
+ - a module script that enables SSR root adoption and starts `autoInitialize()`.
222
+
223
+ ### Static HTML
224
+
225
+ ```js
226
+ const { html } = await renderPage({ html: page, hydrate: 'static' });
227
+ ```
228
+
229
+ The output contains server-rendered content but no Lithium source templates or SSR root markers. It is
230
+ appropriate for pages that do not need client interactivity.
231
+
232
+ ## Async setup
233
+
234
+ Setup functions are called by the existing `@li3/web` runtime. The renderer waits for its normal reactive
235
+ queue and structural rendering timers. If setup code needs additional time to update state, specify a
236
+ settling delay:
237
+
238
+ ```js
239
+ await renderPage({
240
+ html: page,
241
+ settle: 100,
242
+ });
243
+ ```
244
+
245
+ For request-specific data, prefer resolving data in the Node server before creating the page, then place
246
+ it in `<script state type="application/json">` or generate it in the setup module.
247
+
248
+ ## Exports
249
+
250
+ ```ts
251
+ createDom(html?, url?)
252
+ withDom(html, url, callback)
253
+ renderPage(options)
254
+ collectState(document)
255
+ embedState(document, states)
256
+ readState(document)
257
+ serializeState(state)
258
+ snapshotOrDefault(snapshots, index, key, fallback)
259
+ findAppRoots(document)
260
+ importModuleFromFile(sourceText, origin?)
261
+ ```
262
+
263
+ ## Limitations in v0.1.0
264
+
265
+ - Rendering uses `jsdom`; it is intended for Node servers, not browser bundles.
266
+ - Server rendering and client startup both evaluate bindings. There is no DOM diff/hydration algorithm yet.
267
+ - `if` and `for` rows are rebuilt on the client; their server DOM is not adopted.
268
+ - Setup modules are imported from temporary files on the server. Bare `@li3/web` imports are rewritten to
269
+ the installed package entry so setup modules share the server runtime.
270
+ - CSS stylesheets are not adopted into the virtual DOM. CSS can still be emitted or handled by the server's
271
+ normal asset pipeline.
272
+ - Multiple concurrent render requests should use isolated render workers or a request-safe runtime until
273
+ the global virtual-DOM installation is replaced with an async-local context.
@@ -0,0 +1,30 @@
1
+ import { JSDOM } from 'jsdom';
2
+ export type VirtualDom = {
3
+ /** The underlying JSDOM instance. */
4
+ dom: JSDOM;
5
+ /** The jsdom `window`, also installed as `globalThis.window`. */
6
+ window: JSDOM['window'];
7
+ /** The jsdom `document`, also installed as `globalThis.document`. */
8
+ document: Document;
9
+ /** Restores the previous global environment. Always call this when done. */
10
+ restore: () => void;
11
+ };
12
+ /**
13
+ * File-based loader for <script setup>/<script state> sources, installed via
14
+ * @li3/web's setModuleLoader(). blob: URLs are not importable in Node, so the
15
+ * source is written to a temp .mjs file and imported by file:// URL. Bare
16
+ * "@li3/web" imports are rewritten to the absolute file URL of the installed
17
+ * package so components share the SSR process's single @li3/web instance.
18
+ */
19
+ export declare function importModuleFromFile(sourceText: string, _origin?: string): Promise<any>;
20
+ /**
21
+ * Creates a virtual server DOM for a page and installs it as the global
22
+ * environment, so `@li3/web` can run on the server exactly like in a browser.
23
+ *
24
+ * Call BEFORE importing `@li3/web` in the process — the library reads
25
+ * globals like `document` lazily, but `window.name` feature flags and the
26
+ * CSS/server detection read the environment around import time.
27
+ */
28
+ export declare function createDom(html?: string, url?: string): VirtualDom;
29
+ /** Helper around createDom: installs the DOM, runs fn, always restores. */
30
+ export declare function withDom<T>(html: string, url: string, fn: (dom: VirtualDom) => T | Promise<T>): Promise<T>;
@@ -0,0 +1,3 @@
1
+ export { createDom, withDom, type VirtualDom } from './dom.js';
2
+ export { renderPage, collectState, type RenderOptions, type RenderResult, } from './render.js';
3
+ export { embedState, readState, serializeState, snapshotOrDefault, findAppRoots, type AppState, } from './state.js';
@@ -0,0 +1,56 @@
1
+ import { type VirtualDom } from './dom.js';
2
+ import { type AppState } from './state.js';
3
+ export type RenderOptions = {
4
+ /** Full page HTML containing <template app> / <template component> blocks. */
5
+ html: string;
6
+ /** Page URL. Relative <script setup src> / <link rel="component"> resolve against it. Default: http://localhost/ */
7
+ url?: string;
8
+ /**
9
+ * Component files to load and register before rendering (like
10
+ * <link rel="component">, but resolved by the server). URLs resolve
11
+ * against `url`.
12
+ */
13
+ components?: string[];
14
+ /**
15
+ * Extra wait time (ms) after the initial render flush, for async setup
16
+ * work (fetches, timers). Default: 0.
17
+ */
18
+ settle?: number;
19
+ /**
20
+ * How the rendered page should boot in the browser:
21
+ * - 'hydrate' (default): keep <template> sources and app roots marked with
22
+ * data-li3-root, embed state snapshots, and add a bootstrap script that
23
+ * re-mounts every app into its existing root (no duplicate roots).
24
+ * - 'static': strip <template app>/<template component> sources and root
25
+ * markers — pure static HTML, no client-side Lithium bootstrap.
26
+ * - 'none': keep templates, add nothing (client boots @li3/web normally,
27
+ * which would create a second root — only use if you wire your own boot).
28
+ */
29
+ hydrate?: 'hydrate' | 'static' | 'none';
30
+ /** State snapshots to embed for hydration. Defaults to collectState(document). */
31
+ state?: AppState[];
32
+ };
33
+ export type RenderResult = {
34
+ /** The full serialized page, ready to be served. */
35
+ html: string;
36
+ /** The state collected from mounted apps (context values, unwrapped). */
37
+ state: AppState[];
38
+ /** The virtual DOM used for rendering (already restored). */
39
+ dom: VirtualDom;
40
+ };
41
+ /**
42
+ * Renders a Lithium page on the server: mounts every <template app> with all
43
+ * components defined in the page (or passed via `components`), waits for
44
+ * reactive bindings to settle, and serializes the resulting HTML.
45
+ *
46
+ * The returned HTML still contains the original <template app> sources and
47
+ * keeps the rendered app roots (marked with data-li3-root), so the browser
48
+ * re-renders them in place — the page is visible before JavaScript runs and
49
+ * becomes interactive after, without duplicating the app root.
50
+ */
51
+ export declare function renderPage(options: RenderOptions): Promise<RenderResult>;
52
+ /**
53
+ * Collects serializable state from every mounted app root: the merged
54
+ * component context (signals unwrapped, functions and DOM nodes dropped).
55
+ */
56
+ export declare function collectState(document: Document): AppState[];
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,29 @@
1
+ export type AppState = Record<string, any>;
2
+ /** Collects all mounted app roots (the <div style="display:contents"> hosts). */
3
+ export declare function findAppRoots(document: Document): Element[];
4
+ /**
5
+ * Serializes a plain value for embedding in a <script> tag.
6
+ * Escapes "</script" so the snapshot can never break out of its element.
7
+ */
8
+ export declare function serializeState(state: AppState): string;
9
+ /**
10
+ * Embeds a per-app state snapshot into the document, right after each
11
+ * <template app> (or at the end of <body> when there is none), as
12
+ * <script type="application/json" data-li3-ssr>...</script>
13
+ * Client-side code can read it back with `readState(document)`.
14
+ */
15
+ export declare function embedState(document: Document, states: AppState[]): void;
16
+ /** Reads all embedded state snapshots back, in document order. */
17
+ export declare function readState(document: Document): AppState[];
18
+ /**
19
+ * Setup helper for SSR-friendly apps: returns a ref-like initial value,
20
+ * preferring the embedded server snapshot over the given default.
21
+ *
22
+ * ```ts
23
+ * export default function () {
24
+ * const todos = useState('todos', []);
25
+ * return { todos };
26
+ * }
27
+ * ```
28
+ */
29
+ export declare function snapshotOrDefault<T>(snapshots: AppState[], index: number, key: string, fallback: T): T;
package/index.js ADDED
@@ -0,0 +1 @@
1
+ import{JSDOM as t}from"jsdom";import{mkdtempSync as e,writeFileSync as o}from"node:fs";import{tmpdir as r}from"node:os";import{join as n}from"node:path";import{pathToFileURL as i}from"node:url";const l=["window","document","navigator","location","customElements","HTMLElement","HTMLTemplateElement","Element","Node","Text","Comment","DocumentFragment","CSSStyleSheet","CustomEvent","Event","DOMParser","MutationObserver","getComputedStyle","requestAnimationFrame","cancelAnimationFrame"];let a=null,s=0,c=null;function u(){if(!c){const t=new URL("../../web/index.js",import.meta.url),e=new URL("../web/index.js",import.meta.url);c=import.meta.url.endsWith("/src/dom.ts")?t.href:e.href}return c}async function m(t,l){a||=e(n(r(),"li3-ssr-"));const c=t.includes("@li3/web")?t.replaceAll("'@li3/web'",`'${u()}'`).replaceAll('"@li3/web"',`"${u()}"`):t,m=n(a,`setup-${s++}.mjs`);return o(m,c),import(i(m).href)}function p(e="<!doctype html><html><body></body></html>",o="http://localhost/"){const r=new t(e,{url:o,pretendToBeVisual:!0}),{window:n}=r,i={};for(const t of l){const e=Object.getOwnPropertyDescriptor(globalThis,t);i[t]={descriptor:e};const o="window"===t?n:"document"===t?n.document:n[t];void 0!==o&&Object.defineProperty(globalThis,t,{value:o,configurable:!0,writable:!0})}let a=!1;return{dom:r,window:n,document:n.document,restore:function(){if(!a){a=!0;for(const t of l){const{descriptor:e}=i[t];e?Object.defineProperty(globalThis,t,e):delete globalThis[t]}n.close()}}}}async function d(t,e,o){const r=p(t,e);try{return await o(r)}finally{r.restore()}}const f="data-li3-ssr";function y(t){return Array.from(t.querySelectorAll('[style*="display: contents"], template[app]')).filter(t=>"TEMPLATE"!==t.nodeName)}function b(t){return JSON.stringify(t).replace(/<\//g,"<\\/")}function h(t,e){e.forEach((e,o)=>{const r=t.createElement("script");r.setAttribute("type","application/json"),r.setAttribute(f,String(o)),r.textContent=b(e);const n=Array.from(t.querySelectorAll("template[app]"))[o];n&&n.parentNode?n.parentNode.insertBefore(r,n.nextSibling):t.body.appendChild(r)})}function w(t){return Array.from(t.querySelectorAll(`script[${f}]`)).map(t=>{try{return JSON.parse(t.textContent||"{}")}catch{return{}}})}function A(t,e,o,r){const n=t[e];return n&&o in n?n[o]:r}async function g(t){const{html:e,url:o="http://localhost/",components:r=[],settle:n=0,hydrate:i="hydrate",state:l}=t,a=p(e,o);try{a.window.name="skipAutoInitialize";const t=await import("@li3/web");t.setFeatureFlag("skipAutoInitialize",!0),t.setFeatureFlag("ssr",!0),t.setModuleLoader(m);for(const e of r)await t.load(e,o);await t.autoInitialize(),await(c=30+n,new Promise(t=>setTimeout(t,c)));const e=l??S(a.document);for(const t of(s=a.document,Array.from(s.querySelectorAll("template[app]")).map(t=>t.previousElementSibling).filter(t=>Boolean(t&&"DIV"===t.nodeName))))t.setAttribute("data-li3-root","");if("static"===i){for(const t of Array.from(a.document.querySelectorAll("[data-li3-root]")))t.removeAttribute("data-li3-root");for(const t of Array.from(a.document.querySelectorAll("template[app], template[component]")))t.remove()}else if(e.length&&h(a.document,e),"hydrate"===i){const t=a.document.createElement("script");t.setAttribute("type","module"),t.setAttribute("data-li3-hydrate",""),t.textContent="import { setFeatureFlag, autoInitialize } from '@li3/web';\nsetFeatureFlag('ssr', true);\nautoInitialize();",a.document.body.appendChild(t)}const u=function(t){const e=t.dom.serialize();return e.startsWith("<!")?e:"<!doctype html>\n"+e}(a);return{html:u,state:e,dom:a}}finally{a.restore()}var s,c}function S(t){return Array.from(t.querySelectorAll("template[app]")).map(t=>{const e=t.previousElementSibling,o=e&&Object.getOwnPropertySymbols(e).find(t=>"#"===t.description);return function(t){const e={};if(!t||"object"!=typeof t)return e;for(const[o,r]of Object.entries(t)){if(o.startsWith("$")||"function"==typeof r)continue;const t=v(r);void 0!==t&&j(t)&&(e[o]=t)}return e}(o?e[o]:void 0)})}function v(t){return t&&"object"==typeof t&&"value"in t?t.value:t}function j(t){if(null===t)return!0;const e=typeof t;return"string"===e||"number"===e||"boolean"===e||(Array.isArray(t)?t.every(j):"object"===e&&Object.values(t).every(j))}export{S as collectState,p as createDom,h as embedState,y as findAppRoots,w as readState,g as renderPage,b as serializeState,A as snapshotOrDefault,d as withDom};
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@li3/ssr",
3
+ "version": "0.1.0",
4
+ "description": "Server-side rendering for @li3/web applications using a virtual server DOM",
5
+ "exports": {
6
+ ".": {
7
+ "types": "./src/index.ts",
8
+ "default": "./index.js"
9
+ }
10
+ },
11
+ "repository": {
12
+ "url": "https://github.com/apphorde/lithium"
13
+ },
14
+ "dependencies": {
15
+ "@li3/web": "0.2.37",
16
+ "jsdom": "^29.1.1"
17
+ },
18
+ "devDependencies": {
19
+ "vitest": "^4.1.7"
20
+ }
21
+ }
package/src/dom.ts ADDED
@@ -0,0 +1,124 @@
1
+ import { JSDOM } from 'jsdom';
2
+ import { mkdtempSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+
7
+ export type VirtualDom = {
8
+ /** The underlying JSDOM instance. */
9
+ dom: JSDOM;
10
+ /** The jsdom `window`, also installed as `globalThis.window`. */
11
+ window: JSDOM['window'];
12
+ /** The jsdom `document`, also installed as `globalThis.document`. */
13
+ document: Document;
14
+ /** Restores the previous global environment. Always call this when done. */
15
+ restore: () => void;
16
+ };
17
+
18
+ // Globals @li3/web (or user setup code) may touch. Restored after rendering.
19
+ const GLOBAL_KEYS = [
20
+ 'window',
21
+ 'document',
22
+ 'navigator',
23
+ 'location',
24
+ 'customElements',
25
+ 'HTMLElement',
26
+ 'HTMLTemplateElement',
27
+ 'Element',
28
+ 'Node',
29
+ 'Text',
30
+ 'Comment',
31
+ 'DocumentFragment',
32
+ 'CSSStyleSheet',
33
+ 'CustomEvent',
34
+ 'Event',
35
+ 'DOMParser',
36
+ 'MutationObserver',
37
+ 'getComputedStyle',
38
+ 'requestAnimationFrame',
39
+ 'cancelAnimationFrame',
40
+ ] as const;
41
+
42
+ let setupDir: string | null = null;
43
+ let setupCount = 0;
44
+
45
+ // Resolves the installed @li3/web entry lazily, relative to this package's own
46
+ // dependency graph, so setup modules can share the SSR process's instance.
47
+ let webEntry: string | null = null;
48
+ function webEntryHref(): string {
49
+ if (!webEntry) {
50
+ const sourceEntry = new URL('../../web/index.js', import.meta.url);
51
+ const builtEntry = new URL('../web/index.js', import.meta.url);
52
+ webEntry = import.meta.url.endsWith('/src/dom.ts') ? sourceEntry.href : builtEntry.href;
53
+ }
54
+ return webEntry;
55
+ }
56
+
57
+ /**
58
+ * File-based loader for <script setup>/<script state> sources, installed via
59
+ * @li3/web's setModuleLoader(). blob: URLs are not importable in Node, so the
60
+ * source is written to a temp .mjs file and imported by file:// URL. Bare
61
+ * "@li3/web" imports are rewritten to the absolute file URL of the installed
62
+ * package so components share the SSR process's single @li3/web instance.
63
+ */
64
+ export async function importModuleFromFile(sourceText: string, _origin?: string): Promise<any> {
65
+ setupDir ||= mkdtempSync(join(tmpdir(), 'li3-ssr-'));
66
+ const code = sourceText.includes('@li3/web')
67
+ ? sourceText.replaceAll(`'@li3/web'`, `'${webEntryHref()}'`).replaceAll('"@li3/web"', `"${webEntryHref()}"`)
68
+ : sourceText;
69
+ const file = join(setupDir, `setup-${setupCount++}.mjs`);
70
+ writeFileSync(file, code);
71
+ return import(pathToFileURL(file).href);
72
+ }
73
+
74
+ /**
75
+ * Creates a virtual server DOM for a page and installs it as the global
76
+ * environment, so `@li3/web` can run on the server exactly like in a browser.
77
+ *
78
+ * Call BEFORE importing `@li3/web` in the process — the library reads
79
+ * globals like `document` lazily, but `window.name` feature flags and the
80
+ * CSS/server detection read the environment around import time.
81
+ */
82
+ export function createDom(html = '<!doctype html><html><body></body></html>', url = 'http://localhost/'): VirtualDom {
83
+ const dom = new JSDOM(html, { url, pretendToBeVisual: true });
84
+ const { window } = dom;
85
+
86
+ const saved: Record<string, { descriptor?: PropertyDescriptor }> = {};
87
+ for (const key of GLOBAL_KEYS) {
88
+ const descriptor = Object.getOwnPropertyDescriptor(globalThis, key);
89
+ saved[key] = { descriptor };
90
+
91
+ const value = key === 'window' ? window : key === 'document' ? window.document : (window as any)[key];
92
+ if (value === undefined) continue;
93
+
94
+ // defineProperty so getter-only globals (e.g. Node's navigator) are replaceable
95
+ Object.defineProperty(globalThis, key, { value, configurable: true, writable: true });
96
+ }
97
+
98
+ let restored = false;
99
+ function restore() {
100
+ if (restored) return;
101
+ restored = true;
102
+ for (const key of GLOBAL_KEYS) {
103
+ const { descriptor } = saved[key];
104
+ if (descriptor) {
105
+ Object.defineProperty(globalThis, key, descriptor);
106
+ } else {
107
+ delete (globalThis as any)[key];
108
+ }
109
+ }
110
+ window.close();
111
+ }
112
+
113
+ return { dom, window, document: window.document as unknown as Document, restore };
114
+ }
115
+
116
+ /** Helper around createDom: installs the DOM, runs fn, always restores. */
117
+ export async function withDom<T>(html: string, url: string, fn: (dom: VirtualDom) => T | Promise<T>): Promise<T> {
118
+ const dom = createDom(html, url);
119
+ try {
120
+ return await fn(dom);
121
+ } finally {
122
+ dom.restore();
123
+ }
124
+ }
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ export { createDom, withDom, type VirtualDom } from './dom.js';
2
+ export {
3
+ renderPage,
4
+ collectState,
5
+ type RenderOptions,
6
+ type RenderResult,
7
+ } from './render.js';
8
+ export {
9
+ embedState,
10
+ readState,
11
+ serializeState,
12
+ snapshotOrDefault,
13
+ findAppRoots,
14
+ type AppState,
15
+ } from './state.js';
@@ -0,0 +1,124 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { renderPage } from './render.js';
3
+ import { readState } from './state.js';
4
+
5
+ const counterApp = `<!doctype html>
6
+ <html><body>
7
+ <template app>
8
+ <h1>{{ title }}</h1>
9
+ <p>Count: <strong>{{ count }}</strong></p>
10
+ <template if="count > 0">
11
+ <p>You've clicked {{ count }} times</p>
12
+ </template>
13
+ <button on-click="increment()">+1</button>
14
+
15
+ <script setup>
16
+ import { ref, computed } from '@li3/web';
17
+ export default function () {
18
+ const title = ref('Counter App');
19
+ const count = ref(3);
20
+ const doubled = computed(() => count.value * 2);
21
+ const increment = () => count.value++;
22
+ return { title, count, doubled, increment };
23
+ };
24
+ </script>
25
+ </template>
26
+ </body></html>`;
27
+
28
+ describe('renderPage', () => {
29
+ it('mounts <template app> and renders initial state', async () => {
30
+ const { html, state } = await renderPage({ html: counterApp });
31
+
32
+ expect(html).toContain('<h1>Counter App</h1>');
33
+ expect(html).toContain('<strong>3</strong>');
34
+ expect(html).toContain("You've clicked 3 times");
35
+ expect(state).toEqual([{ title: 'Counter App', count: 3, doubled: 6 }]);
36
+ });
37
+
38
+ it('keeps <template app> for hydration by default, with state + bootstrap script', async () => {
39
+ const { html } = await renderPage({ html: counterApp });
40
+
41
+ expect(html).toContain('<template app="">');
42
+ expect(html).toContain('data-li3-hydrate');
43
+ expect(html).toContain('data-li3-ssr');
44
+ });
45
+
46
+ it('strips templates in static mode', async () => {
47
+ const { html } = await renderPage({ html: counterApp, hydrate: 'static' });
48
+
49
+ expect(html).not.toContain('<template app>');
50
+ expect(html).not.toContain('data-li3-hydrate');
51
+ expect(html).not.toContain('data-li3-root');
52
+ expect(html).toContain('<h1>Counter App</h1>');
53
+ });
54
+
55
+ it('renders for-loops with per-row context', async () => {
56
+ const app = `<!doctype html><html><body>
57
+ <template app>
58
+ <ul><template for="[t, i] of todos"><li>{{ i }}: {{ t.title }}</li></template></ul>
59
+ <script setup>
60
+ import { ref } from '@li3/web';
61
+ export default function () {
62
+ return { todos: ref([{ title: 'a' }, { title: 'b' }]) };
63
+ };
64
+ </script>
65
+ </template>
66
+ </body></html>`;
67
+
68
+ const { html } = await renderPage({ html: app });
69
+ expect(html).toContain('<li>0: a</li>');
70
+ expect(html).toContain('<li>1: b</li>');
71
+ });
72
+
73
+ it('defines in-document components and renders them with props', async () => {
74
+ const app = `<!doctype html><html><body>
75
+ <template component="ui-badge">
76
+ <span class="badge">{{ label }}</span>
77
+ <script setup>
78
+ import { defineProp } from '@li3/web';
79
+ export default function () {
80
+ return { label: defineProp('label', { default: 'n/a' }) };
81
+ };
82
+ </script>
83
+ </template>
84
+
85
+ <template app>
86
+ <ui-badge label="New"></ui-badge>
87
+ </template>
88
+ </body></html>`;
89
+
90
+ const { html } = await renderPage({ html: app });
91
+ expect(html).toContain('<span class="badge">New</span>');
92
+ });
93
+
94
+ it('renders declarative <ref> and <script state> apps without setup code', async () => {
95
+ const app = `<!doctype html><html><body>
96
+ <template app>
97
+ <ref name="count" value="7"></ref>
98
+ <script state type="application/json">{ "name": "Ada" }</script>
99
+ <p>{{ name }}: {{ count }}</p>
100
+ </template>
101
+ </body></html>`;
102
+
103
+ const { html } = await renderPage({ html: app });
104
+ expect(html).toContain('<p>Ada: 7</p>');
105
+ });
106
+
107
+ it('state snapshots can be read back from the rendered HTML', async () => {
108
+ const { html } = await renderPage({ html: counterApp });
109
+
110
+ // re-parse the output in a fresh DOM and read embedded state
111
+ const { createDom } = await import('./dom.js');
112
+ const dom = createDom(html);
113
+ try {
114
+ expect(readState(dom.document)).toEqual([{ title: 'Counter App', count: 3, doubled: 6 }]);
115
+ } finally {
116
+ dom.restore();
117
+ }
118
+ });
119
+
120
+ it('escapes </script> in embedded state', async () => {
121
+ const { serializeState } = await import('./state.js');
122
+ expect(serializeState({ html: '</script><b>x</b>' })).toBe('{"html":"<\\/script><b>x<\\/b>"}');
123
+ });
124
+ });
package/src/render.ts ADDED
@@ -0,0 +1,188 @@
1
+ import { createDom, importModuleFromFile, type VirtualDom } from './dom.js';
2
+ import { embedState, type AppState } from './state.js';
3
+
4
+ export type RenderOptions = {
5
+ /** Full page HTML containing <template app> / <template component> blocks. */
6
+ html: string;
7
+ /** Page URL. Relative <script setup src> / <link rel="component"> resolve against it. Default: http://localhost/ */
8
+ url?: string;
9
+ /**
10
+ * Component files to load and register before rendering (like
11
+ * <link rel="component">, but resolved by the server). URLs resolve
12
+ * against `url`.
13
+ */
14
+ components?: string[];
15
+ /**
16
+ * Extra wait time (ms) after the initial render flush, for async setup
17
+ * work (fetches, timers). Default: 0.
18
+ */
19
+ settle?: number;
20
+ /**
21
+ * How the rendered page should boot in the browser:
22
+ * - 'hydrate' (default): keep <template> sources and app roots marked with
23
+ * data-li3-root, embed state snapshots, and add a bootstrap script that
24
+ * re-mounts every app into its existing root (no duplicate roots).
25
+ * - 'static': strip <template app>/<template component> sources and root
26
+ * markers — pure static HTML, no client-side Lithium bootstrap.
27
+ * - 'none': keep templates, add nothing (client boots @li3/web normally,
28
+ * which would create a second root — only use if you wire your own boot).
29
+ */
30
+ hydrate?: 'hydrate' | 'static' | 'none';
31
+ /** State snapshots to embed for hydration. Defaults to collectState(document). */
32
+ state?: AppState[];
33
+ };
34
+
35
+ export type RenderResult = {
36
+ /** The full serialized page, ready to be served. */
37
+ html: string;
38
+ /** The state collected from mounted apps (context values, unwrapped). */
39
+ state: AppState[];
40
+ /** The virtual DOM used for rendering (already restored). */
41
+ dom: VirtualDom;
42
+ };
43
+
44
+ // The framework runs its bootstrap ~10ms after import; bindings flush on a
45
+ // ~5ms queue; if/for insertions use setTimeout. Two long waits cover all of it.
46
+ const FLUSH_MS = 30;
47
+
48
+ function wait(ms: number) {
49
+ return new Promise((resolve) => setTimeout(resolve, ms));
50
+ }
51
+
52
+ // Boot script injected into the hydrated page. Setting FF.ssr makes findApps()
53
+ // adopt the server-rendered, data-li3-root-marked projection divs instead of
54
+ // creating duplicate app roots; contents are then re-rendered in place.
55
+ const HYDRATE_SCRIPT = `import { setFeatureFlag, autoInitialize } from '@li3/web';
56
+ setFeatureFlag('ssr', true);
57
+ autoInitialize();`;
58
+
59
+ /**
60
+ * Renders a Lithium page on the server: mounts every <template app> with all
61
+ * components defined in the page (or passed via `components`), waits for
62
+ * reactive bindings to settle, and serializes the resulting HTML.
63
+ *
64
+ * The returned HTML still contains the original <template app> sources and
65
+ * keeps the rendered app roots (marked with data-li3-root), so the browser
66
+ * re-renders them in place — the page is visible before JavaScript runs and
67
+ * becomes interactive after, without duplicating the app root.
68
+ */
69
+ export async function renderPage(options: RenderOptions): Promise<RenderResult> {
70
+ const { html, url = 'http://localhost/', components = [], settle = 0, hydrate = 'hydrate', state } = options;
71
+
72
+ const dom = createDom(html, url);
73
+ try {
74
+ // Feature flags must be set before importing @li3/web in this process:
75
+ // skipAutoInitialize gives us control over *when* rendering happens and
76
+ // avoids timers firing after the DOM was torn down.
77
+ (dom.window as any).name = 'skipAutoInitialize';
78
+
79
+ const web = await import('@li3/web');
80
+ web.setFeatureFlag('skipAutoInitialize', true);
81
+ web.setFeatureFlag('ssr', true);
82
+ // <script setup>/<script state> sources import via temp files (blob: URLs
83
+ // are not importable in Node), sharing this process's @li3/web instance.
84
+ web.setModuleLoader(importModuleFromFile);
85
+
86
+ // Load external components first so in-document components and app
87
+ // templates can use them when they mount.
88
+ for (const href of components) {
89
+ await web.load(href, url);
90
+ }
91
+
92
+ // Defines in-document <template component> blocks and mounts <template app>.
93
+ await web.autoInitialize();
94
+
95
+ // Flush the watch queue (~5ms), if/for insertions (setTimeout), and any
96
+ // optional async work in setup functions.
97
+ await wait(FLUSH_MS + settle);
98
+
99
+ const collectedState = state ?? collectState(dom.document);
100
+
101
+ // Mark every app projection root so the hydrating client reuses it.
102
+ for (const root of findProjectionRoots(dom.document)) {
103
+ root.setAttribute('data-li3-root', '');
104
+ }
105
+
106
+ if (hydrate === 'static') {
107
+ for (const root of Array.from(dom.document.querySelectorAll('[data-li3-root]'))) {
108
+ root.removeAttribute('data-li3-root');
109
+ }
110
+ for (const t of Array.from(dom.document.querySelectorAll('template[app], template[component]'))) {
111
+ t.remove();
112
+ }
113
+ } else {
114
+ if (collectedState.length) {
115
+ embedState(dom.document, collectedState);
116
+ }
117
+ if (hydrate === 'hydrate') {
118
+ const script = dom.document.createElement('script');
119
+ script.setAttribute('type', 'module');
120
+ script.setAttribute('data-li3-hydrate', '');
121
+ script.textContent = HYDRATE_SCRIPT;
122
+ dom.document.body.appendChild(script);
123
+ }
124
+ }
125
+
126
+ const out = serialize(dom);
127
+ return { html: out, state: collectedState, dom };
128
+ } finally {
129
+ dom.restore();
130
+ }
131
+ }
132
+
133
+ /**
134
+ * The projection divs findApps() inserts before each <template app>.
135
+ * Identified as the element sibling immediately preceding a template[app].
136
+ */
137
+ function findProjectionRoots(document: Document): HTMLElement[] {
138
+ return Array.from(document.querySelectorAll('template[app]'))
139
+ .map((t) => t.previousElementSibling as HTMLElement | null)
140
+ .filter((el): el is HTMLElement => Boolean(el && el.nodeName === 'DIV'));
141
+ }
142
+
143
+ /**
144
+ * Collects serializable state from every mounted app root: the merged
145
+ * component context (signals unwrapped, functions and DOM nodes dropped).
146
+ */
147
+ export function collectState(document: Document): AppState[] {
148
+ return Array.from(document.querySelectorAll('template[app]')).map((template) => {
149
+ const root = template.previousElementSibling as any;
150
+ const debugSymbol = root && Object.getOwnPropertySymbols(root).find((s) => s.description === '#');
151
+ return pickState(debugSymbol ? root[debugSymbol] : undefined);
152
+ });
153
+ }
154
+
155
+ function pickState(context: any): AppState {
156
+ const state: AppState = {};
157
+ if (!context || typeof context !== 'object') return state;
158
+
159
+ for (const [key, value] of Object.entries(context)) {
160
+ if (key.startsWith('$') || typeof value === 'function') continue;
161
+ const unwrapped = unwrapValue(value);
162
+ if (unwrapped !== undefined && isSerializable(unwrapped)) {
163
+ state[key] = unwrapped;
164
+ }
165
+ }
166
+ return state;
167
+ }
168
+
169
+ function unwrapValue(value: any): any {
170
+ if (value && typeof value === 'object' && 'value' in value) {
171
+ return value.value; // ref/computed
172
+ }
173
+ return value;
174
+ }
175
+
176
+ function isSerializable(value: any): boolean {
177
+ if (value === null) return true;
178
+ const t = typeof value;
179
+ if (t === 'string' || t === 'number' || t === 'boolean') return true;
180
+ if (Array.isArray(value)) return value.every(isSerializable);
181
+ if (t === 'object') return Object.values(value).every(isSerializable);
182
+ return false;
183
+ }
184
+
185
+ function serialize(dom: VirtualDom): string {
186
+ const serialized = dom.dom.serialize();
187
+ return serialized.startsWith('<!') ? serialized : '<!doctype html>\n' + serialized;
188
+ }
package/src/state.ts ADDED
@@ -0,0 +1,75 @@
1
+ // Server-side state snapshot for hydration.
2
+ //
3
+ // After rendering, the state of every mounted <template app> can be serialized
4
+ // into a <script type="application/json"> tag. In the browser, the same
5
+ // template boots again and — because setup functions read their defaults from
6
+ // this tag (via `useState` or plain `<script state>`) — renders identically,
7
+ // making hydration a plain re-mount.
8
+
9
+ export type AppState = Record<string, any>;
10
+
11
+ const ID_ATTR = 'data-li3-ssr';
12
+
13
+ /** Collects all mounted app roots (the <div style="display:contents"> hosts). */
14
+ export function findAppRoots(document: Document): Element[] {
15
+ return Array.from(document.querySelectorAll('[style*="display: contents"], template[app]'))
16
+ .filter((el) => el.nodeName !== 'TEMPLATE') as Element[];
17
+ }
18
+
19
+ /**
20
+ * Serializes a plain value for embedding in a <script> tag.
21
+ * Escapes "</script" so the snapshot can never break out of its element.
22
+ */
23
+ export function serializeState(state: AppState): string {
24
+ return JSON.stringify(state).replace(/<\//g, '<\\/');
25
+ }
26
+
27
+ /**
28
+ * Embeds a per-app state snapshot into the document, right after each
29
+ * <template app> (or at the end of <body> when there is none), as
30
+ * <script type="application/json" data-li3-ssr>...</script>
31
+ * Client-side code can read it back with `readState(document)`.
32
+ */
33
+ export function embedState(document: Document, states: AppState[]): void {
34
+ states.forEach((state, index) => {
35
+ const script = document.createElement('script');
36
+ script.setAttribute('type', 'application/json');
37
+ script.setAttribute(ID_ATTR, String(index));
38
+ script.textContent = serializeState(state);
39
+
40
+ const templates = Array.from(document.querySelectorAll('template[app]'));
41
+ const anchor = templates[index];
42
+ if (anchor && anchor.parentNode) {
43
+ anchor.parentNode.insertBefore(script, anchor.nextSibling);
44
+ } else {
45
+ document.body.appendChild(script);
46
+ }
47
+ });
48
+ }
49
+
50
+ /** Reads all embedded state snapshots back, in document order. */
51
+ export function readState(document: Document): AppState[] {
52
+ return Array.from(document.querySelectorAll(`script[${ID_ATTR}]`)).map((el) => {
53
+ try {
54
+ return JSON.parse(el.textContent || '{}');
55
+ } catch {
56
+ return {};
57
+ }
58
+ });
59
+ }
60
+
61
+ /**
62
+ * Setup helper for SSR-friendly apps: returns a ref-like initial value,
63
+ * preferring the embedded server snapshot over the given default.
64
+ *
65
+ * ```ts
66
+ * export default function () {
67
+ * const todos = useState('todos', []);
68
+ * return { todos };
69
+ * }
70
+ * ```
71
+ */
72
+ export function snapshotOrDefault<T>(snapshots: AppState[], index: number, key: string, fallback: T): T {
73
+ const state = snapshots[index];
74
+ return state && key in state ? (state[key] as T) : fallback;
75
+ }