@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 +273 -0
- package/dist/src/dom.d.ts +30 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/render.d.ts +56 -0
- package/dist/src/render.test.d.ts +1 -0
- package/dist/src/state.d.ts +29 -0
- package/index.js +1 -0
- package/package.json +21 -0
- package/src/dom.ts +124 -0
- package/src/index.ts +15 -0
- package/src/render.test.ts +124 -0
- package/src/render.ts +188 -0
- package/src/state.ts +75 -0
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
|
+
}
|