@xoong/ssr 0.0.0-stage → 0.0.35
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/LICENSE +21 -0
- package/README.md +24 -2
- package/package.json +38 -4
- package/src/render.js +98 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Duong Tran Xuan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @xoong/ssr
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Prerender xoong pages: runs the real `<xo-view>` runtime in Node (happy-dom), waits for the data and writes HTML. There is no hydration step: the browser re-renders from the saved template.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @xoong/ssr
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Use
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx xoong render page.html --api https://api.example.org --out page.prerendered.html
|
|
15
|
+
```
|
|
16
|
+
Public pages only: a page that needs the visitor's session is rendered in the browser.
|
|
17
|
+
|
|
18
|
+
## More
|
|
19
|
+
|
|
20
|
+
* Documentation and demos: https://github.com/fpt-duongtx/xoong#readme
|
|
21
|
+
* Source: https://github.com/fpt-duongtx/xoong/tree/main/packages/ssr
|
|
22
|
+
* All packages share one version number and are released together: https://github.com/fpt-duongtx/xoong/releases
|
|
23
|
+
* xoong is a working prototype (0.0.x): the HTML syntax is still Draft.
|
|
24
|
+
|
|
25
|
+
MIT licence.
|
package/package.json
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xoong/ssr",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.35",
|
|
4
|
+
"description": "Prerender xoong pages: run the real <xo-view> runtime in Node (happy-dom), wait for the data, write HTML. No hydration step: the browser re-renders from the saved template.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/render.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src"
|
|
11
|
+
],
|
|
12
|
+
"keywords": [
|
|
13
|
+
"xoong",
|
|
14
|
+
"ssr",
|
|
15
|
+
"prerender"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22.12"
|
|
19
|
+
},
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@xoong/core": "0.0.35",
|
|
23
|
+
"@xoong/data": "0.0.35",
|
|
24
|
+
"@xoong/ui": "0.0.35",
|
|
25
|
+
"happy-dom": "^21.0.4"
|
|
26
|
+
},
|
|
27
|
+
"author": "Duong Tran Xuan <duongtx1505@icloud.com>",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/fpt-duongtx/xoong.git",
|
|
31
|
+
"directory": "packages/ssr"
|
|
32
|
+
},
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/fpt-duongtx/xoong/issues"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/fpt-duongtx/xoong#readme",
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/render.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Prerender a page with data: the same <xo-view> runtime that runs in the browser runs here, on a DOM from happy-dom, so
|
|
4
|
+
* there is one implementation of the syntax, not two. The output is plain HTML that already shows the data. It is not
|
|
5
|
+
* hydrated: every top-level <xo-view> keeps its original markup in a <template data-xo-template>, and in the browser the
|
|
6
|
+
* view renders again from that template (same result, then live updates and actions work as usual).
|
|
7
|
+
*
|
|
8
|
+
* const html = await renderPage(pageHtml, { operations, baseUrl: 'http://localhost:8080' });
|
|
9
|
+
*
|
|
10
|
+
* Render pages one after another: the DOM globals are process-wide, so calls are queued.
|
|
11
|
+
*
|
|
12
|
+
* Two things to know: (1) the first render installs `window`, `document` and friends on `globalThis` for the rest of the process,
|
|
13
|
+
* so libraries that detect a browser by `typeof window` will think they run in one: prerender in a build script or a
|
|
14
|
+
* dedicated process, not inside a server that also runs such code. (2) A page whose API calls fail still renders (its
|
|
15
|
+
* `$error` branches show); pass `failOnError: true`, or collect them with `onError`, to treat that as a failure.
|
|
16
|
+
*/
|
|
17
|
+
import { Window } from 'happy-dom';
|
|
18
|
+
|
|
19
|
+
/** @type {Promise<{ configure: any }> | null} */
|
|
20
|
+
let booted = null;
|
|
21
|
+
let queue = Promise.resolve();
|
|
22
|
+
|
|
23
|
+
async function boot() {
|
|
24
|
+
const window = new Window({
|
|
25
|
+
url: 'http://xoong.local/',
|
|
26
|
+
settings: { disableJavaScriptFileLoading: true, disableCSSFileLoading: true, handleDisabledFileLoadingAsSuccess: true }, // scripts and styles of the page are not run or fetched here
|
|
27
|
+
});
|
|
28
|
+
const g = /** @type {any} */ (globalThis);
|
|
29
|
+
for (const name of ['window', 'document', 'customElements', 'HTMLElement', 'Element', 'Node', 'DocumentFragment', 'Text', 'Comment', 'Event', 'CustomEvent', 'HTMLTemplateElement', 'DOMParser']) {
|
|
30
|
+
if (name === 'window') g.window = window;
|
|
31
|
+
else Object.defineProperty(g, name, { value: /** @type {any} */ (window)[name], configurable: true, writable: true });
|
|
32
|
+
}
|
|
33
|
+
Object.defineProperty(g, 'location', { get: () => window.location, configurable: true }); // p-id="?id" reads it
|
|
34
|
+
await import('@xoong/data/view'); // registers <xo-view>
|
|
35
|
+
await import('@xoong/ui'); // registers <xo-table>, <xo-form>, ...
|
|
36
|
+
return /** @type {{ configure: any }} */ (await import('@xoong/data'));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {string} html the page
|
|
41
|
+
* @param {{ operations: any, baseUrl?: string, fetch?: typeof fetch, headers?: () => Record<string,string>, timeoutMs?: number, url?: string, onError?: (e: any) => void, failOnError?: boolean }} options
|
|
42
|
+
* `operations`: contents of .xoong/operations.json. `baseUrl`: where the API is reachable from the server.
|
|
43
|
+
* @returns {Promise<string>}
|
|
44
|
+
*/
|
|
45
|
+
export function renderPage(html, options) {
|
|
46
|
+
const run = queue.then(() => render(html, options));
|
|
47
|
+
queue = run.then(() => undefined, () => undefined);
|
|
48
|
+
return run;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function render(html, { operations, baseUrl = '', fetch: fetchImpl, headers, timeoutMs = 10_000, url = 'http://xoong.local/', onError, failOnError = false }) {
|
|
52
|
+
booted ??= boot();
|
|
53
|
+
/** @type {any} */ let data;
|
|
54
|
+
try { data = await booted; } catch (e) { booted = null; throw e; } // a failed boot (happy-dom missing) is not cached
|
|
55
|
+
const failures = [];
|
|
56
|
+
/** @type {any} */ (globalThis).window.happyDOM.setURL(url); // so p-id="?id" sees the request's query string
|
|
57
|
+
const client = data.configure({ api: operations, baseUrl, fetch: fetchImpl, headers, retry: 0, onError: (e) => { failures.push(e); onError?.(e); } });
|
|
58
|
+
|
|
59
|
+
const parsed = new /** @type {any} */ (globalThis).DOMParser().parseFromString(html, 'text/html');
|
|
60
|
+
// Remember each top-level view's original markup before it renders.
|
|
61
|
+
const views = [...parsed.body.querySelectorAll('xo-view')].filter((v) => !v.parentElement?.closest('xo-view'));
|
|
62
|
+
views.forEach((v, i) => v.setAttribute('data-xo-ssr', String(i)));
|
|
63
|
+
const originals = views.map((v) => v.innerHTML);
|
|
64
|
+
|
|
65
|
+
const body = /** @type {any} */ (globalThis).document.body;
|
|
66
|
+
body.innerHTML = parsed.body.innerHTML;
|
|
67
|
+
try {
|
|
68
|
+
const deadline = Date.now() + timeoutMs;
|
|
69
|
+
for (let calm = 0; calm < 3;) { // wait until nothing is loading for a few turns
|
|
70
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
71
|
+
calm = client.pending === 0 ? calm + 1 : 0;
|
|
72
|
+
if (Date.now() > deadline) throw new Error(`xoong ssr: the page did not finish loading its data within ${timeoutMs} ms`);
|
|
73
|
+
}
|
|
74
|
+
if (failOnError && failures.length) throw Object.assign(new Error(`xoong ssr: ${failures.length} API call(s) failed: ${failures.map((e) => e.operation ?? e.message).join(', ')}`), { errors: failures });
|
|
75
|
+
originals.forEach((markup, i) => {
|
|
76
|
+
const view = body.querySelector(`xo-view[data-xo-ssr="${i}"]`);
|
|
77
|
+
if (!view) return;
|
|
78
|
+
view.removeAttribute('data-xo-ssr');
|
|
79
|
+
const tpl = parsed.createElement('template');
|
|
80
|
+
tpl.setAttribute('data-xo-template', '');
|
|
81
|
+
tpl.innerHTML = markup;
|
|
82
|
+
view.prepend(tpl);
|
|
83
|
+
});
|
|
84
|
+
const live = /** @type {any} */ (globalThis).document; // a router (or anything else) may have set the title and description
|
|
85
|
+
if (live.title && live.title !== parsed.title) parsed.title = live.title;
|
|
86
|
+
const description = live.head.querySelector('meta[name="description"]')?.getAttribute('content');
|
|
87
|
+
if (description !== undefined && description !== null) {
|
|
88
|
+
let meta = parsed.head.querySelector('meta[name="description"]');
|
|
89
|
+
if (!meta) { meta = parsed.createElement('meta'); meta.setAttribute('name', 'description'); parsed.head.append(meta); }
|
|
90
|
+
meta.setAttribute('content', description);
|
|
91
|
+
}
|
|
92
|
+
const root = parsed.documentElement;
|
|
93
|
+
const attrs = (el) => [...el.attributes].map((a) => ` ${a.name}="${a.value.replace(/&/g, '&').replace(/"/g, '"')}"`).join('');
|
|
94
|
+
return `<!doctype html>\n<html${attrs(root)}>\n<head>${parsed.head.innerHTML}</head>\n<body${attrs(parsed.body)}>${body.innerHTML}</body>\n</html>\n`;
|
|
95
|
+
} finally {
|
|
96
|
+
body.innerHTML = ''; // disconnect the views so nothing keeps running
|
|
97
|
+
}
|
|
98
|
+
}
|