@xoong/ssr 0.0.0-stage → 0.0.34

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 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
- # Temporary Holding Version
1
+ # @xoong/ssr
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.34",
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.34",
23
+ "@xoong/data": "0.0.34",
24
+ "@xoong/ui": "0.0.34",
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, '&amp;').replace(/"/g, '&quot;')}"`).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
+ }