hadars 0.1.31 → 0.1.33
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 +10 -14
- package/dist/cli.js +62 -67
- package/dist/index.cjs +1 -30
- package/dist/index.d.ts +4 -15
- package/dist/index.js +1 -30
- package/dist/slim-react/index.cjs +13 -6
- package/dist/slim-react/index.js +13 -6
- package/dist/ssr-render-worker.js +29 -22
- package/dist/utils/Head.tsx +6 -51
- package/package.json +1 -1
- package/src/build.ts +7 -18
- package/src/slim-react/render.ts +14 -18
- package/src/ssr-render-worker.ts +21 -25
- package/src/types/hadars.ts +0 -3
- package/src/utils/Head.tsx +6 -51
- package/src/utils/response.tsx +7 -11
package/README.md
CHANGED
|
@@ -16,6 +16,10 @@ hadars is an alternative to Next.js for apps that just need SSR.
|
|
|
16
16
|
|
|
17
17
|
Bring your own router (or none), keep your components as plain React, and get SSR, HMR, and a production build from a single config file.
|
|
18
18
|
|
|
19
|
+
## Benchmarks
|
|
20
|
+
|
|
21
|
+
Benchmarks against an equivalent Next.js app show significantly faster server throughput (requests/second) and meaningfully better page load metrics (TTFB, FCP, DOMContentLoaded). Build times are also much lower due to rspack.
|
|
22
|
+
|
|
19
23
|
## Quick start
|
|
20
24
|
|
|
21
25
|
Scaffold a new project in seconds:
|
|
@@ -114,7 +118,6 @@ const UserCard = ({ userId }: { userId: string }) => {
|
|
|
114
118
|
- **`key`** - string or string array; must be stable and unique within the page
|
|
115
119
|
- **Server** - calls `fn()`, awaits the result across render iterations, returns `undefined` until resolved
|
|
116
120
|
- **Client** - reads the pre-resolved value from the hydration cache serialised by the server; `fn()` is never called in the browser
|
|
117
|
-
- **Suspense libraries** - also works when `fn()` throws a thenable (e.g. Relay `useLazyLoadQuery` with `suspense: true`); the thrown promise is awaited and the next render re-calls `fn()` synchronously
|
|
118
121
|
|
|
119
122
|
## Data lifecycle hooks
|
|
120
123
|
|
|
@@ -182,21 +185,14 @@ export default config;
|
|
|
182
185
|
|
|
183
186
|
## slim-react
|
|
184
187
|
|
|
185
|
-
hadars ships its own lightweight React-compatible SSR renderer called **slim-react** (`src/slim-react/`). It replaces `react-dom/server`
|
|
186
|
-
|
|
187
|
-
For server builds, rspack aliases `react` and `react/jsx-runtime` to slim-react, so your components and any libraries they import render through it automatically without any code changes.
|
|
188
|
-
|
|
189
|
-
**What it does:**
|
|
190
|
-
|
|
191
|
-
- Renders the full component tree to an HTML string with native `async/await` support - async components and hooks that return Promises are awaited directly without streaming workarounds
|
|
192
|
-
- Implements the React Suspense protocol: when a component throws a Promise (e.g. from `useServerData` or a Suspense-enabled data library), slim-react awaits it and retries the tree automatically
|
|
193
|
-
- Emits React-compatible hydration markers - `<!--$-->…<!--/$-->` for resolved Suspense boundaries, `<!-- -->` separators between adjacent text nodes - so `hydrateRoot` on the client works without mismatches
|
|
194
|
-
- Supports `React.memo`, `React.forwardRef`, `React.lazy`, `Context.Provider`, `Context.Consumer`, and the React 18/19 element wire formats
|
|
195
|
-
- Covers the full hook surface needed for SSR: `useState`, `useReducer`, `useContext`, `useRef`, `useMemo`, `useCallback`, `useId`, `useSyncExternalStore`, `use`, and more - all as lightweight SSR stubs
|
|
188
|
+
hadars ships its own lightweight React-compatible SSR renderer called **slim-react** (`src/slim-react/`). It replaces `react-dom/server` on the server side entirely.
|
|
196
189
|
|
|
197
|
-
|
|
190
|
+
For server builds, rspack aliases `react` and `react/jsx-runtime` to slim-react, so your components and any libraries they import render through it automatically without code changes.
|
|
198
191
|
|
|
199
|
-
|
|
192
|
+
- Renders the full component tree to an HTML string with native `async/await` — async components are awaited directly
|
|
193
|
+
- Implements the React Suspense protocol: thrown Promises (e.g. from `useSuspenseQuery`) are awaited and the component retried automatically
|
|
194
|
+
- Compatible with `hydrateRoot` — output matches what React expects on the client
|
|
195
|
+
- Supports `React.memo`, `React.forwardRef`, `React.lazy`, `Context.Provider`, `Context.Consumer`, and the React 19 element format
|
|
200
196
|
|
|
201
197
|
## License
|
|
202
198
|
|
package/dist/cli.js
CHANGED
|
@@ -387,17 +387,23 @@ var VOID_ELEMENTS = /* @__PURE__ */ new Set([
|
|
|
387
387
|
"track",
|
|
388
388
|
"wbr"
|
|
389
389
|
]);
|
|
390
|
+
var HTML_ESC = { "&": "&", "<": "<", ">": ">", "'": "'" };
|
|
390
391
|
function escapeHtml(str) {
|
|
391
|
-
return str.replace(
|
|
392
|
+
return str.replace(/[&<>']/g, (c) => HTML_ESC[c]);
|
|
392
393
|
}
|
|
394
|
+
var ATTR_ESC = { "&": "&", '"': """, "<": "<", ">": ">" };
|
|
393
395
|
function escapeAttr(str) {
|
|
394
|
-
return str.replace(
|
|
396
|
+
return str.replace(/[&"<>]/g, (c) => ATTR_ESC[c]);
|
|
395
397
|
}
|
|
396
398
|
function styleObjectToString(style) {
|
|
397
|
-
|
|
399
|
+
let result = "";
|
|
400
|
+
for (const key in style) {
|
|
401
|
+
if (result)
|
|
402
|
+
result += ";";
|
|
398
403
|
const cssKey = key.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
|
|
399
|
-
|
|
400
|
-
}
|
|
404
|
+
result += cssKey + ":" + style[key];
|
|
405
|
+
}
|
|
406
|
+
return result;
|
|
401
407
|
}
|
|
402
408
|
var SVG_ATTR_MAP = {
|
|
403
409
|
// Presentation / geometry
|
|
@@ -532,7 +538,8 @@ var SVG_ATTR_MAP = {
|
|
|
532
538
|
};
|
|
533
539
|
function renderAttributes(props, isSvg) {
|
|
534
540
|
let attrs = "";
|
|
535
|
-
for (const
|
|
541
|
+
for (const key in props) {
|
|
542
|
+
const value = props[key];
|
|
536
543
|
if (key === "children" || key === "key" || key === "ref" || key === "dangerouslySetInnerHTML" || key === "suppressHydrationWarning" || key === "suppressContentEditableWarning")
|
|
537
544
|
continue;
|
|
538
545
|
if (key.startsWith("on") && key.length > 2 && key[2] === key[2].toUpperCase())
|
|
@@ -996,6 +1003,45 @@ async function renderToString(element, options) {
|
|
|
996
1003
|
}
|
|
997
1004
|
}
|
|
998
1005
|
|
|
1006
|
+
// src/utils/segmentCache.ts
|
|
1007
|
+
function getStore() {
|
|
1008
|
+
const g = globalThis;
|
|
1009
|
+
if (!g.__hadarsSegmentStore) {
|
|
1010
|
+
g.__hadarsSegmentStore = /* @__PURE__ */ new Map();
|
|
1011
|
+
}
|
|
1012
|
+
return g.__hadarsSegmentStore;
|
|
1013
|
+
}
|
|
1014
|
+
function setSegment(key, html, ttl) {
|
|
1015
|
+
getStore().set(key, {
|
|
1016
|
+
html,
|
|
1017
|
+
expiresAt: ttl != null ? Date.now() + ttl : null
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
function processSegmentCache(html) {
|
|
1021
|
+
let prev;
|
|
1022
|
+
do {
|
|
1023
|
+
prev = html;
|
|
1024
|
+
html = html.replace(
|
|
1025
|
+
/<hadars-c([^>]*)>([\s\S]*?)<\/hadars-c>/g,
|
|
1026
|
+
(match, attrs, content) => {
|
|
1027
|
+
const cacheM = /data-cache="([^"]+)"/.exec(attrs);
|
|
1028
|
+
const keyM = /data-key="([^"]+)"/.exec(attrs);
|
|
1029
|
+
const ttlM = /data-ttl="(\d+)"/.exec(attrs);
|
|
1030
|
+
if (!cacheM || !keyM)
|
|
1031
|
+
return match;
|
|
1032
|
+
if (cacheM[1] === "miss") {
|
|
1033
|
+
setSegment(keyM[1], content, ttlM ? Number(ttlM[1]) : void 0);
|
|
1034
|
+
return content;
|
|
1035
|
+
}
|
|
1036
|
+
if (cacheM[1] === "hit")
|
|
1037
|
+
return content;
|
|
1038
|
+
return match;
|
|
1039
|
+
}
|
|
1040
|
+
);
|
|
1041
|
+
} while (html !== prev);
|
|
1042
|
+
return html;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
999
1045
|
// src/utils/response.tsx
|
|
1000
1046
|
var ESC = { "&": "&", "<": "<", ">": ">", '"': """ };
|
|
1001
1047
|
var escAttr = (s2) => s2.replace(/[&<>"]/g, (c) => ESC[c] ?? c);
|
|
@@ -1053,11 +1099,12 @@ var getReactResponse = async (req, opts) => {
|
|
|
1053
1099
|
};
|
|
1054
1100
|
const unsuspend = { cache: /* @__PURE__ */ new Map() };
|
|
1055
1101
|
globalThis.__hadarsUnsuspend = unsuspend;
|
|
1102
|
+
let bodyHtml;
|
|
1056
1103
|
try {
|
|
1057
|
-
|
|
1104
|
+
bodyHtml = await renderToString(createElement(App, props));
|
|
1058
1105
|
if (getAfterRenderProps) {
|
|
1059
|
-
props = await getAfterRenderProps(props,
|
|
1060
|
-
await renderToString(
|
|
1106
|
+
props = await getAfterRenderProps(props, bodyHtml);
|
|
1107
|
+
bodyHtml = await renderToString(
|
|
1061
1108
|
createElement(App, { ...props, location: req.location, context })
|
|
1062
1109
|
);
|
|
1063
1110
|
}
|
|
@@ -1075,12 +1122,9 @@ var getReactResponse = async (req, opts) => {
|
|
|
1075
1122
|
location: req.location,
|
|
1076
1123
|
...Object.keys(serverData).length > 0 ? { __serverData: serverData } : {}
|
|
1077
1124
|
};
|
|
1078
|
-
const appProps = { ...props, location: req.location, context };
|
|
1079
1125
|
return {
|
|
1080
|
-
|
|
1081
|
-
appProps,
|
|
1126
|
+
bodyHtml: processSegmentCache(bodyHtml),
|
|
1082
1127
|
clientProps,
|
|
1083
|
-
unsuspend,
|
|
1084
1128
|
status: context.head.status,
|
|
1085
1129
|
headHtml: getHeadHtml(context.head)
|
|
1086
1130
|
};
|
|
@@ -1631,47 +1675,6 @@ import { existsSync as existsSync2 } from "node:fs";
|
|
|
1631
1675
|
import os from "node:os";
|
|
1632
1676
|
import { spawn } from "node:child_process";
|
|
1633
1677
|
import cluster from "node:cluster";
|
|
1634
|
-
|
|
1635
|
-
// src/utils/segmentCache.ts
|
|
1636
|
-
function getStore() {
|
|
1637
|
-
const g = globalThis;
|
|
1638
|
-
if (!g.__hadarsSegmentStore) {
|
|
1639
|
-
g.__hadarsSegmentStore = /* @__PURE__ */ new Map();
|
|
1640
|
-
}
|
|
1641
|
-
return g.__hadarsSegmentStore;
|
|
1642
|
-
}
|
|
1643
|
-
function setSegment(key, html, ttl) {
|
|
1644
|
-
getStore().set(key, {
|
|
1645
|
-
html,
|
|
1646
|
-
expiresAt: ttl != null ? Date.now() + ttl : null
|
|
1647
|
-
});
|
|
1648
|
-
}
|
|
1649
|
-
function processSegmentCache(html) {
|
|
1650
|
-
let prev;
|
|
1651
|
-
do {
|
|
1652
|
-
prev = html;
|
|
1653
|
-
html = html.replace(
|
|
1654
|
-
/<hadars-c([^>]*)>([\s\S]*?)<\/hadars-c>/g,
|
|
1655
|
-
(match, attrs, content) => {
|
|
1656
|
-
const cacheM = /data-cache="([^"]+)"/.exec(attrs);
|
|
1657
|
-
const keyM = /data-key="([^"]+)"/.exec(attrs);
|
|
1658
|
-
const ttlM = /data-ttl="(\d+)"/.exec(attrs);
|
|
1659
|
-
if (!cacheM || !keyM)
|
|
1660
|
-
return match;
|
|
1661
|
-
if (cacheM[1] === "miss") {
|
|
1662
|
-
setSegment(keyM[1], content, ttlM ? Number(ttlM[1]) : void 0);
|
|
1663
|
-
return content;
|
|
1664
|
-
}
|
|
1665
|
-
if (cacheM[1] === "hit")
|
|
1666
|
-
return content;
|
|
1667
|
-
return match;
|
|
1668
|
-
}
|
|
1669
|
-
);
|
|
1670
|
-
} while (html !== prev);
|
|
1671
|
-
return html;
|
|
1672
|
-
}
|
|
1673
|
-
|
|
1674
|
-
// src/build.ts
|
|
1675
1678
|
var encoder = new TextEncoder();
|
|
1676
1679
|
async function processHtmlTemplate(templatePath) {
|
|
1677
1680
|
const html = await fs.readFile(templatePath, "utf-8");
|
|
@@ -1816,19 +1819,11 @@ var RenderWorkerPool = class {
|
|
|
1816
1819
|
await Promise.all(this.workers.map((w) => w.terminate()));
|
|
1817
1820
|
}
|
|
1818
1821
|
};
|
|
1819
|
-
async function buildSsrResponse(
|
|
1822
|
+
async function buildSsrResponse(bodyHtml, clientProps, headHtml, status, getPrecontentHtml) {
|
|
1820
1823
|
const responseStream = new ReadableStream({
|
|
1821
1824
|
async start(controller) {
|
|
1822
1825
|
const [precontentHtml, postContent] = await getPrecontentHtml(headHtml);
|
|
1823
1826
|
controller.enqueue(encoder.encode(precontentHtml));
|
|
1824
|
-
let bodyHtml;
|
|
1825
|
-
try {
|
|
1826
|
-
globalThis.__hadarsUnsuspend = unsuspendForRender;
|
|
1827
|
-
bodyHtml = await renderToString(createElement(App, appProps));
|
|
1828
|
-
} finally {
|
|
1829
|
-
globalThis.__hadarsUnsuspend = null;
|
|
1830
|
-
}
|
|
1831
|
-
bodyHtml = processSegmentCache(bodyHtml);
|
|
1832
1827
|
const scriptContent = JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, "\\u003c");
|
|
1833
1828
|
controller.enqueue(encoder.encode(
|
|
1834
1829
|
`<div id="app">${bodyHtml}</div><script id="hadars" type="application/json">${scriptContent}</script>` + postContent
|
|
@@ -2202,7 +2197,7 @@ var dev = async (options) => {
|
|
|
2202
2197
|
getAfterRenderProps,
|
|
2203
2198
|
getFinalProps
|
|
2204
2199
|
} = await import(importPath);
|
|
2205
|
-
const {
|
|
2200
|
+
const { bodyHtml, clientProps, status, headHtml } = await getReactResponse(request, {
|
|
2206
2201
|
document: {
|
|
2207
2202
|
body: Component,
|
|
2208
2203
|
lang: "en",
|
|
@@ -2211,7 +2206,7 @@ var dev = async (options) => {
|
|
|
2211
2206
|
getFinalProps
|
|
2212
2207
|
}
|
|
2213
2208
|
});
|
|
2214
|
-
return buildSsrResponse(
|
|
2209
|
+
return buildSsrResponse(bodyHtml, clientProps, headHtml, status, getPrecontentHtml);
|
|
2215
2210
|
} catch (err) {
|
|
2216
2211
|
console.error("[hadars] SSR render error:", err);
|
|
2217
2212
|
const msg = (err?.stack ?? err?.message ?? String(err)).replace(/</g, "<");
|
|
@@ -2356,7 +2351,7 @@ var run = async (options) => {
|
|
|
2356
2351
|
status: wStatus
|
|
2357
2352
|
});
|
|
2358
2353
|
}
|
|
2359
|
-
const {
|
|
2354
|
+
const { bodyHtml, clientProps, status, headHtml } = await getReactResponse(request, {
|
|
2360
2355
|
document: {
|
|
2361
2356
|
body: Component,
|
|
2362
2357
|
lang: "en",
|
|
@@ -2365,7 +2360,7 @@ var run = async (options) => {
|
|
|
2365
2360
|
getFinalProps
|
|
2366
2361
|
}
|
|
2367
2362
|
});
|
|
2368
|
-
return buildSsrResponse(
|
|
2363
|
+
return buildSsrResponse(bodyHtml, clientProps, headHtml, status, getPrecontentHtml);
|
|
2369
2364
|
} catch (err) {
|
|
2370
2365
|
console.error("[hadars] SSR render error:", err);
|
|
2371
2366
|
return new Response("Internal Server Error", { status: 500 });
|
package/dist/index.cjs
CHANGED
|
@@ -189,37 +189,8 @@ function useServerData(key, fn) {
|
|
|
189
189
|
if (!unsuspend)
|
|
190
190
|
return void 0;
|
|
191
191
|
const existing = unsuspend.cache.get(cacheKey);
|
|
192
|
-
if (existing?.status === "suspense-resolved") {
|
|
193
|
-
try {
|
|
194
|
-
const value = fn();
|
|
195
|
-
unsuspend.cache.set(cacheKey, { status: "suspense-cached", value });
|
|
196
|
-
return value;
|
|
197
|
-
} catch {
|
|
198
|
-
return void 0;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
if (existing?.status === "suspense-cached") {
|
|
202
|
-
return existing.value;
|
|
203
|
-
}
|
|
204
192
|
if (!existing) {
|
|
205
|
-
|
|
206
|
-
try {
|
|
207
|
-
result = fn();
|
|
208
|
-
} catch (thrown) {
|
|
209
|
-
if (thrown !== null && typeof thrown === "object" && typeof thrown.then === "function") {
|
|
210
|
-
const suspensePromise = Promise.resolve(thrown).then(
|
|
211
|
-
() => {
|
|
212
|
-
unsuspend.cache.set(cacheKey, { status: "suspense-resolved" });
|
|
213
|
-
},
|
|
214
|
-
() => {
|
|
215
|
-
unsuspend.cache.set(cacheKey, { status: "suspense-resolved" });
|
|
216
|
-
}
|
|
217
|
-
);
|
|
218
|
-
unsuspend.cache.set(cacheKey, { status: "pending", promise: suspensePromise });
|
|
219
|
-
throw suspensePromise;
|
|
220
|
-
}
|
|
221
|
-
throw thrown;
|
|
222
|
-
}
|
|
193
|
+
const result = fn();
|
|
223
194
|
const isThenable = result !== null && typeof result === "object" && typeof result.then === "function";
|
|
224
195
|
if (!isThenable) {
|
|
225
196
|
const value = result;
|
package/dist/index.d.ts
CHANGED
|
@@ -28,11 +28,6 @@ type UnsuspendEntry = {
|
|
|
28
28
|
} | {
|
|
29
29
|
status: 'fulfilled';
|
|
30
30
|
value: unknown;
|
|
31
|
-
} | {
|
|
32
|
-
status: 'suspense-resolved';
|
|
33
|
-
} | {
|
|
34
|
-
status: 'suspense-cached';
|
|
35
|
-
value: unknown;
|
|
36
31
|
} | {
|
|
37
32
|
status: 'rejected';
|
|
38
33
|
reason: unknown;
|
|
@@ -40,7 +35,6 @@ type UnsuspendEntry = {
|
|
|
40
35
|
/** @internal Populated by the framework's render loop — use useServerData() instead. */
|
|
41
36
|
interface AppUnsuspend {
|
|
42
37
|
cache: Map<string, UnsuspendEntry>;
|
|
43
|
-
hasPending: boolean;
|
|
44
38
|
}
|
|
45
39
|
interface AppContext {
|
|
46
40
|
path?: string;
|
|
@@ -197,16 +191,11 @@ declare function initServerDataCache(data: Record<string, unknown>): void;
|
|
|
197
191
|
* across all SSR render passes and client hydration — it must be stable and
|
|
198
192
|
* unique within the page.
|
|
199
193
|
*
|
|
200
|
-
* `fn` may return a `Promise<T>` (
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
* - Synchronous return: value stored immediately, returned on the same pass.
|
|
204
|
-
* - Thrown thenable (e.g. `useQuery({ suspense: true })`): the thrown promise is
|
|
205
|
-
* awaited, the cache entry is then cleared so that the next render re-calls
|
|
206
|
-
* `fn()` — at that point the Suspense hook returns synchronously.
|
|
194
|
+
* `fn` may return a `Promise<T>` (async usage) or return `T` synchronously.
|
|
195
|
+
* The resolved value is serialised into `__serverData` and returned from cache
|
|
196
|
+
* during hydration.
|
|
207
197
|
*
|
|
208
|
-
* `fn` is **server-only**: it is never called in the browser.
|
|
209
|
-
* is serialised into `__serverData` and returned from cache during hydration.
|
|
198
|
+
* `fn` is **server-only**: it is never called in the browser.
|
|
210
199
|
*
|
|
211
200
|
* @example
|
|
212
201
|
* const user = useServerData('current_user', () => db.getUser(id));
|
package/dist/index.js
CHANGED
|
@@ -146,37 +146,8 @@ function useServerData(key, fn) {
|
|
|
146
146
|
if (!unsuspend)
|
|
147
147
|
return void 0;
|
|
148
148
|
const existing = unsuspend.cache.get(cacheKey);
|
|
149
|
-
if (existing?.status === "suspense-resolved") {
|
|
150
|
-
try {
|
|
151
|
-
const value = fn();
|
|
152
|
-
unsuspend.cache.set(cacheKey, { status: "suspense-cached", value });
|
|
153
|
-
return value;
|
|
154
|
-
} catch {
|
|
155
|
-
return void 0;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
if (existing?.status === "suspense-cached") {
|
|
159
|
-
return existing.value;
|
|
160
|
-
}
|
|
161
149
|
if (!existing) {
|
|
162
|
-
|
|
163
|
-
try {
|
|
164
|
-
result = fn();
|
|
165
|
-
} catch (thrown) {
|
|
166
|
-
if (thrown !== null && typeof thrown === "object" && typeof thrown.then === "function") {
|
|
167
|
-
const suspensePromise = Promise.resolve(thrown).then(
|
|
168
|
-
() => {
|
|
169
|
-
unsuspend.cache.set(cacheKey, { status: "suspense-resolved" });
|
|
170
|
-
},
|
|
171
|
-
() => {
|
|
172
|
-
unsuspend.cache.set(cacheKey, { status: "suspense-resolved" });
|
|
173
|
-
}
|
|
174
|
-
);
|
|
175
|
-
unsuspend.cache.set(cacheKey, { status: "pending", promise: suspensePromise });
|
|
176
|
-
throw suspensePromise;
|
|
177
|
-
}
|
|
178
|
-
throw thrown;
|
|
179
|
-
}
|
|
150
|
+
const result = fn();
|
|
180
151
|
const isThenable = result !== null && typeof result === "object" && typeof result.then === "function";
|
|
181
152
|
if (!isThenable) {
|
|
182
153
|
const value = result;
|
|
@@ -370,17 +370,23 @@ var VOID_ELEMENTS = /* @__PURE__ */ new Set([
|
|
|
370
370
|
"track",
|
|
371
371
|
"wbr"
|
|
372
372
|
]);
|
|
373
|
+
var HTML_ESC = { "&": "&", "<": "<", ">": ">", "'": "'" };
|
|
373
374
|
function escapeHtml(str) {
|
|
374
|
-
return str.replace(
|
|
375
|
+
return str.replace(/[&<>']/g, (c) => HTML_ESC[c]);
|
|
375
376
|
}
|
|
377
|
+
var ATTR_ESC = { "&": "&", '"': """, "<": "<", ">": ">" };
|
|
376
378
|
function escapeAttr(str) {
|
|
377
|
-
return str.replace(
|
|
379
|
+
return str.replace(/[&"<>]/g, (c) => ATTR_ESC[c]);
|
|
378
380
|
}
|
|
379
381
|
function styleObjectToString(style) {
|
|
380
|
-
|
|
382
|
+
let result = "";
|
|
383
|
+
for (const key in style) {
|
|
384
|
+
if (result)
|
|
385
|
+
result += ";";
|
|
381
386
|
const cssKey = key.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
|
|
382
|
-
|
|
383
|
-
}
|
|
387
|
+
result += cssKey + ":" + style[key];
|
|
388
|
+
}
|
|
389
|
+
return result;
|
|
384
390
|
}
|
|
385
391
|
var SVG_ATTR_MAP = {
|
|
386
392
|
// Presentation / geometry
|
|
@@ -515,7 +521,8 @@ var SVG_ATTR_MAP = {
|
|
|
515
521
|
};
|
|
516
522
|
function renderAttributes(props, isSvg) {
|
|
517
523
|
let attrs = "";
|
|
518
|
-
for (const
|
|
524
|
+
for (const key in props) {
|
|
525
|
+
const value = props[key];
|
|
519
526
|
if (key === "children" || key === "key" || key === "ref" || key === "dangerouslySetInnerHTML" || key === "suppressHydrationWarning" || key === "suppressContentEditableWarning")
|
|
520
527
|
continue;
|
|
521
528
|
if (key.startsWith("on") && key.length > 2 && key[2] === key[2].toUpperCase())
|
package/dist/slim-react/index.js
CHANGED
|
@@ -269,17 +269,23 @@ var VOID_ELEMENTS = /* @__PURE__ */ new Set([
|
|
|
269
269
|
"track",
|
|
270
270
|
"wbr"
|
|
271
271
|
]);
|
|
272
|
+
var HTML_ESC = { "&": "&", "<": "<", ">": ">", "'": "'" };
|
|
272
273
|
function escapeHtml(str) {
|
|
273
|
-
return str.replace(
|
|
274
|
+
return str.replace(/[&<>']/g, (c) => HTML_ESC[c]);
|
|
274
275
|
}
|
|
276
|
+
var ATTR_ESC = { "&": "&", '"': """, "<": "<", ">": ">" };
|
|
275
277
|
function escapeAttr(str) {
|
|
276
|
-
return str.replace(
|
|
278
|
+
return str.replace(/[&"<>]/g, (c) => ATTR_ESC[c]);
|
|
277
279
|
}
|
|
278
280
|
function styleObjectToString(style) {
|
|
279
|
-
|
|
281
|
+
let result = "";
|
|
282
|
+
for (const key in style) {
|
|
283
|
+
if (result)
|
|
284
|
+
result += ";";
|
|
280
285
|
const cssKey = key.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
|
|
281
|
-
|
|
282
|
-
}
|
|
286
|
+
result += cssKey + ":" + style[key];
|
|
287
|
+
}
|
|
288
|
+
return result;
|
|
283
289
|
}
|
|
284
290
|
var SVG_ATTR_MAP = {
|
|
285
291
|
// Presentation / geometry
|
|
@@ -414,7 +420,8 @@ var SVG_ATTR_MAP = {
|
|
|
414
420
|
};
|
|
415
421
|
function renderAttributes(props, isSvg) {
|
|
416
422
|
let attrs = "";
|
|
417
|
-
for (const
|
|
423
|
+
for (const key in props) {
|
|
424
|
+
const value = props[key];
|
|
418
425
|
if (key === "children" || key === "key" || key === "ref" || key === "dangerouslySetInnerHTML" || key === "suppressHydrationWarning" || key === "suppressContentEditableWarning")
|
|
419
426
|
continue;
|
|
420
427
|
if (key.startsWith("on") && key.length > 2 && key[2] === key[2].toUpperCase())
|
|
@@ -294,17 +294,23 @@ var VOID_ELEMENTS = /* @__PURE__ */ new Set([
|
|
|
294
294
|
"track",
|
|
295
295
|
"wbr"
|
|
296
296
|
]);
|
|
297
|
+
var HTML_ESC = { "&": "&", "<": "<", ">": ">", "'": "'" };
|
|
297
298
|
function escapeHtml(str) {
|
|
298
|
-
return str.replace(
|
|
299
|
+
return str.replace(/[&<>']/g, (c) => HTML_ESC[c]);
|
|
299
300
|
}
|
|
301
|
+
var ATTR_ESC = { "&": "&", '"': """, "<": "<", ">": ">" };
|
|
300
302
|
function escapeAttr(str) {
|
|
301
|
-
return str.replace(
|
|
303
|
+
return str.replace(/[&"<>]/g, (c) => ATTR_ESC[c]);
|
|
302
304
|
}
|
|
303
305
|
function styleObjectToString(style) {
|
|
304
|
-
|
|
306
|
+
let result = "";
|
|
307
|
+
for (const key in style) {
|
|
308
|
+
if (result)
|
|
309
|
+
result += ";";
|
|
305
310
|
const cssKey = key.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
|
|
306
|
-
|
|
307
|
-
}
|
|
311
|
+
result += cssKey + ":" + style[key];
|
|
312
|
+
}
|
|
313
|
+
return result;
|
|
308
314
|
}
|
|
309
315
|
var SVG_ATTR_MAP = {
|
|
310
316
|
// Presentation / geometry
|
|
@@ -439,7 +445,8 @@ var SVG_ATTR_MAP = {
|
|
|
439
445
|
};
|
|
440
446
|
function renderAttributes(props, isSvg) {
|
|
441
447
|
let attrs = "";
|
|
442
|
-
for (const
|
|
448
|
+
for (const key in props) {
|
|
449
|
+
const value = props[key];
|
|
443
450
|
if (key === "children" || key === "key" || key === "ref" || key === "dangerouslySetInnerHTML" || key === "suppressHydrationWarning" || key === "suppressContentEditableWarning")
|
|
444
451
|
continue;
|
|
445
452
|
if (key.startsWith("on") && key.length > 2 && key[2] === key[2].toUpperCase())
|
|
@@ -973,16 +980,18 @@ async function runFullLifecycle(serialReq) {
|
|
|
973
980
|
};
|
|
974
981
|
const unsuspend = { cache: /* @__PURE__ */ new Map() };
|
|
975
982
|
globalThis.__hadarsUnsuspend = unsuspend;
|
|
983
|
+
let prelimHtml;
|
|
976
984
|
try {
|
|
977
|
-
|
|
985
|
+
prelimHtml = await renderToString(createElement(Component, props));
|
|
978
986
|
if (getAfterRenderProps) {
|
|
979
|
-
props = await getAfterRenderProps(props,
|
|
987
|
+
props = await getAfterRenderProps(props, prelimHtml);
|
|
980
988
|
await renderToString(
|
|
981
989
|
createElement(Component, { ...props, location: serialReq.location, context })
|
|
982
990
|
);
|
|
983
991
|
}
|
|
984
|
-
}
|
|
992
|
+
} catch (e) {
|
|
985
993
|
globalThis.__hadarsUnsuspend = null;
|
|
994
|
+
throw e;
|
|
986
995
|
}
|
|
987
996
|
const { context: _ctx, ...restProps } = getFinalProps ? await getFinalProps(props) : props;
|
|
988
997
|
const serverData = {};
|
|
@@ -996,7 +1005,16 @@ async function runFullLifecycle(serialReq) {
|
|
|
996
1005
|
...Object.keys(serverData).length > 0 ? { __serverData: serverData } : {}
|
|
997
1006
|
};
|
|
998
1007
|
const finalAppProps = { ...props, location: serialReq.location, context };
|
|
999
|
-
|
|
1008
|
+
let appHtml;
|
|
1009
|
+
try {
|
|
1010
|
+
appHtml = await renderToString(createElement(Component, finalAppProps));
|
|
1011
|
+
} finally {
|
|
1012
|
+
globalThis.__hadarsUnsuspend = null;
|
|
1013
|
+
}
|
|
1014
|
+
appHtml = processSegmentCache(appHtml);
|
|
1015
|
+
const scriptContent = JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, "\\u003c");
|
|
1016
|
+
const html = `<div id="app">${appHtml}</div><script id="hadars" type="application/json">${scriptContent}</script>`;
|
|
1017
|
+
return { html, headHtml: buildHeadHtml(context.head), status: context.head.status ?? 200 };
|
|
1000
1018
|
}
|
|
1001
1019
|
parentPort.on("message", async (msg) => {
|
|
1002
1020
|
const { id, type, request } = msg;
|
|
@@ -1004,18 +1022,7 @@ parentPort.on("message", async (msg) => {
|
|
|
1004
1022
|
await init();
|
|
1005
1023
|
if (type !== "renderFull")
|
|
1006
1024
|
return;
|
|
1007
|
-
const {
|
|
1008
|
-
const Component = _ssrMod.default;
|
|
1009
|
-
globalThis.__hadarsUnsuspend = unsuspend;
|
|
1010
|
-
let appHtml;
|
|
1011
|
-
try {
|
|
1012
|
-
appHtml = await renderToString(createElement(Component, finalAppProps));
|
|
1013
|
-
} finally {
|
|
1014
|
-
globalThis.__hadarsUnsuspend = null;
|
|
1015
|
-
}
|
|
1016
|
-
appHtml = processSegmentCache(appHtml);
|
|
1017
|
-
const scriptContent = JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, "\\u003c");
|
|
1018
|
-
const html = `<div id="app">${appHtml}</div><script id="hadars" type="application/json">${scriptContent}</script>`;
|
|
1025
|
+
const { html, headHtml, status } = await runFullLifecycle(request);
|
|
1019
1026
|
parentPort.postMessage({ id, html, headHtml, status });
|
|
1020
1027
|
} catch (err) {
|
|
1021
1028
|
parentPort.postMessage({ id, error: err?.message ?? String(err) });
|
package/dist/utils/Head.tsx
CHANGED
|
@@ -196,16 +196,11 @@ export function initServerDataCache(data: Record<string, unknown>) {
|
|
|
196
196
|
* across all SSR render passes and client hydration — it must be stable and
|
|
197
197
|
* unique within the page.
|
|
198
198
|
*
|
|
199
|
-
* `fn` may return a `Promise<T>` (
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
* - Synchronous return: value stored immediately, returned on the same pass.
|
|
203
|
-
* - Thrown thenable (e.g. `useQuery({ suspense: true })`): the thrown promise is
|
|
204
|
-
* awaited, the cache entry is then cleared so that the next render re-calls
|
|
205
|
-
* `fn()` — at that point the Suspense hook returns synchronously.
|
|
199
|
+
* `fn` may return a `Promise<T>` (async usage) or return `T` synchronously.
|
|
200
|
+
* The resolved value is serialised into `__serverData` and returned from cache
|
|
201
|
+
* during hydration.
|
|
206
202
|
*
|
|
207
|
-
* `fn` is **server-only**: it is never called in the browser.
|
|
208
|
-
* is serialised into `__serverData` and returned from cache during hydration.
|
|
203
|
+
* `fn` is **server-only**: it is never called in the browser.
|
|
209
204
|
*
|
|
210
205
|
* @example
|
|
211
206
|
* const user = useServerData('current_user', () => db.getUser(id));
|
|
@@ -230,51 +225,11 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
|
|
|
230
225
|
|
|
231
226
|
const existing = unsuspend.cache.get(cacheKey);
|
|
232
227
|
|
|
233
|
-
// Suspense promise has resolved — re-call fn() so the hook returns its value
|
|
234
|
-
// synchronously from its own internal cache. Cache the result as
|
|
235
|
-
// 'suspense-cached' so later renders (e.g. the final renderToString in
|
|
236
|
-
// buildSsrResponse, which runs after getFinalProps may have cleared the
|
|
237
|
-
// user's QueryClient) can return the value without calling fn() again.
|
|
238
|
-
// NOT stored as 'fulfilled' so it is never included in serverData sent to
|
|
239
|
-
// the client — the Suspense library owns its own hydration.
|
|
240
|
-
if (existing?.status === 'suspense-resolved') {
|
|
241
|
-
try {
|
|
242
|
-
const value = fn() as T;
|
|
243
|
-
unsuspend.cache.set(cacheKey, { status: 'suspense-cached', value });
|
|
244
|
-
return value;
|
|
245
|
-
} catch {
|
|
246
|
-
return undefined;
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// Return the cached Suspense value on all subsequent renders.
|
|
251
|
-
if (existing?.status === 'suspense-cached') {
|
|
252
|
-
return existing.value as T;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
228
|
if (!existing) {
|
|
256
229
|
// First encounter — call fn(), which may:
|
|
257
|
-
// (a) return a Promise<T> —
|
|
230
|
+
// (a) return a Promise<T> — async usage (serialised for the client)
|
|
258
231
|
// (b) return T synchronously — e.g. a sync data source
|
|
259
|
-
|
|
260
|
-
let result: Promise<T> | T;
|
|
261
|
-
try {
|
|
262
|
-
result = fn();
|
|
263
|
-
} catch (thrown) {
|
|
264
|
-
// (c) Suspense protocol: fn() threw a thenable. Await it, then mark the
|
|
265
|
-
// entry as 'suspense-resolved' so the next render re-calls fn() to get
|
|
266
|
-
// the synchronously available value. Not stored as 'fulfilled' → not
|
|
267
|
-
// serialised to the client (the Suspense library handles its own hydration).
|
|
268
|
-
if (thrown !== null && typeof thrown === 'object' && typeof (thrown as any).then === 'function') {
|
|
269
|
-
const suspensePromise = Promise.resolve(thrown as Promise<unknown>).then(
|
|
270
|
-
() => { unsuspend.cache.set(cacheKey, { status: 'suspense-resolved' }); },
|
|
271
|
-
() => { unsuspend.cache.set(cacheKey, { status: 'suspense-resolved' }); },
|
|
272
|
-
);
|
|
273
|
-
unsuspend.cache.set(cacheKey, { status: 'pending', promise: suspensePromise });
|
|
274
|
-
throw suspensePromise; // slim-react will await and retry
|
|
275
|
-
}
|
|
276
|
-
throw thrown;
|
|
277
|
-
}
|
|
232
|
+
const result = fn();
|
|
278
233
|
|
|
279
234
|
const isThenable = result !== null && typeof result === 'object' && typeof (result as any).then === 'function';
|
|
280
235
|
|
package/package.json
CHANGED
package/src/build.ts
CHANGED
|
@@ -16,7 +16,6 @@ import os from 'node:os';
|
|
|
16
16
|
import { spawn } from 'node:child_process';
|
|
17
17
|
import cluster from 'node:cluster';
|
|
18
18
|
import type { HadarsEntryModule, HadarsOptions, HadarsProps } from "./types/hadars";
|
|
19
|
-
import { processSegmentCache } from "./utils/segmentCache";
|
|
20
19
|
const encoder = new TextEncoder();
|
|
21
20
|
|
|
22
21
|
/**
|
|
@@ -73,7 +72,7 @@ async function processHtmlTemplate(templatePath: string): Promise<string> {
|
|
|
73
72
|
const HEAD_MARKER = '<meta name="HADARS_HEAD">';
|
|
74
73
|
const BODY_MARKER = '<meta name="HADARS_BODY">';
|
|
75
74
|
|
|
76
|
-
|
|
75
|
+
|
|
77
76
|
|
|
78
77
|
// Round-robin thread pool for SSR rendering — used on Bun/Deno where
|
|
79
78
|
// node:cluster is not available but node:worker_threads is.
|
|
@@ -194,29 +193,19 @@ class RenderWorkerPool {
|
|
|
194
193
|
}
|
|
195
194
|
|
|
196
195
|
async function buildSsrResponse(
|
|
197
|
-
|
|
198
|
-
appProps: Record<string, unknown>,
|
|
196
|
+
bodyHtml: string,
|
|
199
197
|
clientProps: Record<string, unknown>,
|
|
200
198
|
headHtml: string,
|
|
201
199
|
status: number,
|
|
202
200
|
getPrecontentHtml: (headHtml: string) => Promise<[string, string]>,
|
|
203
|
-
unsuspendForRender: any,
|
|
204
201
|
): Promise<Response> {
|
|
205
202
|
const responseStream = new ReadableStream({
|
|
206
203
|
async start(controller) {
|
|
207
204
|
const [precontentHtml, postContent] = await getPrecontentHtml(headHtml);
|
|
208
205
|
// Flush the shell (precontentHtml) immediately so the browser can
|
|
209
|
-
// start loading CSS/fonts before
|
|
206
|
+
// start loading CSS/fonts before the body is assembled.
|
|
210
207
|
controller.enqueue(encoder.encode(precontentHtml));
|
|
211
208
|
|
|
212
|
-
let bodyHtml: string;
|
|
213
|
-
try {
|
|
214
|
-
(globalThis as any).__hadarsUnsuspend = unsuspendForRender;
|
|
215
|
-
bodyHtml = await slimRenderToString(createElement(App, appProps));
|
|
216
|
-
} finally {
|
|
217
|
-
(globalThis as any).__hadarsUnsuspend = null;
|
|
218
|
-
}
|
|
219
|
-
bodyHtml = processSegmentCache(bodyHtml);
|
|
220
209
|
const scriptContent = JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, '\\u003c');
|
|
221
210
|
controller.enqueue(encoder.encode(
|
|
222
211
|
`<div id="app">${bodyHtml}</div><script id="hadars" type="application/json">${scriptContent}</script>` + postContent
|
|
@@ -690,7 +679,7 @@ export const dev = async (options: HadarsRuntimeOptions) => {
|
|
|
690
679
|
getFinalProps,
|
|
691
680
|
} = (await import(importPath)) as HadarsEntryModule<any>;
|
|
692
681
|
|
|
693
|
-
const {
|
|
682
|
+
const { bodyHtml, clientProps, status, headHtml } = await getReactResponse(request, {
|
|
694
683
|
document: {
|
|
695
684
|
body: Component as React.FC<HadarsProps<object>>,
|
|
696
685
|
lang: 'en',
|
|
@@ -700,7 +689,7 @@ export const dev = async (options: HadarsRuntimeOptions) => {
|
|
|
700
689
|
},
|
|
701
690
|
});
|
|
702
691
|
|
|
703
|
-
return buildSsrResponse(
|
|
692
|
+
return buildSsrResponse(bodyHtml, clientProps, headHtml, status, getPrecontentHtml);
|
|
704
693
|
} catch (err: any) {
|
|
705
694
|
console.error('[hadars] SSR render error:', err);
|
|
706
695
|
const msg = (err?.stack ?? err?.message ?? String(err)).replace(/</g, '<');
|
|
@@ -880,7 +869,7 @@ export const run = async (options: HadarsRuntimeOptions) => {
|
|
|
880
869
|
});
|
|
881
870
|
}
|
|
882
871
|
|
|
883
|
-
const {
|
|
872
|
+
const { bodyHtml, clientProps, status, headHtml } = await getReactResponse(request, {
|
|
884
873
|
document: {
|
|
885
874
|
body: Component as React.FC<HadarsProps<object>>,
|
|
886
875
|
lang: 'en',
|
|
@@ -890,7 +879,7 @@ export const run = async (options: HadarsRuntimeOptions) => {
|
|
|
890
879
|
},
|
|
891
880
|
});
|
|
892
881
|
|
|
893
|
-
return buildSsrResponse(
|
|
882
|
+
return buildSsrResponse(bodyHtml, clientProps, headHtml, status, getPrecontentHtml);
|
|
894
883
|
} catch (err: any) {
|
|
895
884
|
console.error('[hadars] SSR render error:', err);
|
|
896
885
|
return new Response('Internal Server Error', { status: 500 });
|
package/src/slim-react/render.ts
CHANGED
|
@@ -61,30 +61,25 @@ const VOID_ELEMENTS = new Set([
|
|
|
61
61
|
"wbr",
|
|
62
62
|
]);
|
|
63
63
|
|
|
64
|
+
const HTML_ESC: Record<string, string> = { '&': '&', '<': '<', '>': '>', "'": ''' };
|
|
64
65
|
function escapeHtml(str: string): string {
|
|
65
|
-
return str
|
|
66
|
-
.replace(/&/g, "&")
|
|
67
|
-
.replace(/</g, "<")
|
|
68
|
-
.replace(/>/g, ">")
|
|
69
|
-
.replace(/'/g, "'");
|
|
66
|
+
return str.replace(/[&<>']/g, c => HTML_ESC[c]!);
|
|
70
67
|
}
|
|
71
68
|
|
|
69
|
+
const ATTR_ESC: Record<string, string> = { '&': '&', '"': '"', '<': '<', '>': '>' };
|
|
72
70
|
function escapeAttr(str: string): string {
|
|
73
|
-
return str
|
|
74
|
-
.replace(/&/g, "&")
|
|
75
|
-
.replace(/"/g, """)
|
|
76
|
-
.replace(/</g, "<")
|
|
77
|
-
.replace(/>/g, ">");
|
|
71
|
+
return str.replace(/[&"<>]/g, c => ATTR_ESC[c]!);
|
|
78
72
|
}
|
|
79
73
|
|
|
80
74
|
function styleObjectToString(style: Record<string, any>): string {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
75
|
+
let result = '';
|
|
76
|
+
for (const key in style) {
|
|
77
|
+
if (result) result += ';';
|
|
78
|
+
// camelCase → kebab-case
|
|
79
|
+
const cssKey = key.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
|
|
80
|
+
result += cssKey + ':' + style[key];
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
88
83
|
}
|
|
89
84
|
|
|
90
85
|
// ---------------------------------------------------------------------------
|
|
@@ -247,7 +242,8 @@ const SVG_ELEMENTS = new Set([
|
|
|
247
242
|
|
|
248
243
|
function renderAttributes(props: Record<string, any>, isSvg: boolean): string {
|
|
249
244
|
let attrs = "";
|
|
250
|
-
for (const
|
|
245
|
+
for (const key in props) {
|
|
246
|
+
const value = props[key];
|
|
251
247
|
// Skip internal / non-attribute props
|
|
252
248
|
if (
|
|
253
249
|
key === "children" ||
|
package/src/ssr-render-worker.ts
CHANGED
|
@@ -102,17 +102,19 @@ async function runFullLifecycle(serialReq: SerializableRequest) {
|
|
|
102
102
|
const unsuspend = { cache: new Map<string, any>() };
|
|
103
103
|
(globalThis as any).__hadarsUnsuspend = unsuspend;
|
|
104
104
|
|
|
105
|
+
let prelimHtml: string;
|
|
105
106
|
try {
|
|
106
|
-
|
|
107
|
+
prelimHtml = await renderToString(createElement(Component, props));
|
|
107
108
|
|
|
108
109
|
if (getAfterRenderProps) {
|
|
109
|
-
props = await getAfterRenderProps(props,
|
|
110
|
+
props = await getAfterRenderProps(props, prelimHtml);
|
|
110
111
|
await renderToString(
|
|
111
112
|
createElement(Component, { ...props, location: serialReq.location, context }),
|
|
112
113
|
);
|
|
113
114
|
}
|
|
114
|
-
}
|
|
115
|
+
} catch (e) {
|
|
115
116
|
(globalThis as any).__hadarsUnsuspend = null;
|
|
117
|
+
throw e;
|
|
116
118
|
}
|
|
117
119
|
|
|
118
120
|
const { context: _ctx, ...restProps } = getFinalProps ? await getFinalProps(props) : props;
|
|
@@ -129,7 +131,21 @@ async function runFullLifecycle(serialReq: SerializableRequest) {
|
|
|
129
131
|
};
|
|
130
132
|
|
|
131
133
|
const finalAppProps = { ...props, location: serialReq.location, context };
|
|
132
|
-
|
|
134
|
+
|
|
135
|
+
// Final render — __hadarsUnsuspend is still set; cache is fully populated so
|
|
136
|
+
// useServerData calls return cached values without any async work.
|
|
137
|
+
let appHtml: string;
|
|
138
|
+
try {
|
|
139
|
+
appHtml = await renderToString(createElement(Component, finalAppProps));
|
|
140
|
+
} finally {
|
|
141
|
+
(globalThis as any).__hadarsUnsuspend = null;
|
|
142
|
+
}
|
|
143
|
+
appHtml = processSegmentCache(appHtml);
|
|
144
|
+
|
|
145
|
+
const scriptContent = JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, '\\u003c');
|
|
146
|
+
const html = `<div id="app">${appHtml}</div><script id="hadars" type="application/json">${scriptContent}</script>`;
|
|
147
|
+
|
|
148
|
+
return { html, headHtml: buildHeadHtml(context.head), status: context.head.status ?? 200 };
|
|
133
149
|
}
|
|
134
150
|
|
|
135
151
|
// ── Message handler ────────────────────────────────────────────────────────
|
|
@@ -140,27 +156,7 @@ parentPort!.on('message', async (msg: any) => {
|
|
|
140
156
|
await init();
|
|
141
157
|
if (type !== 'renderFull') return;
|
|
142
158
|
|
|
143
|
-
const {
|
|
144
|
-
await runFullLifecycle(request as SerializableRequest);
|
|
145
|
-
|
|
146
|
-
const Component = _ssrMod.default;
|
|
147
|
-
|
|
148
|
-
// Render the Component as the direct root — matching hydrateRoot(div#app, <Component>)
|
|
149
|
-
// on the client. Wrapping in Fragment(div#app(...), script) would add an extra
|
|
150
|
-
// pushTreeContext(2,0) from the Fragment's child array, shifting all tree-position
|
|
151
|
-
// useId values by 2 bits and causing a hydration mismatch.
|
|
152
|
-
(globalThis as any).__hadarsUnsuspend = unsuspend;
|
|
153
|
-
let appHtml: string;
|
|
154
|
-
try {
|
|
155
|
-
appHtml = await renderToString(createElement(Component, finalAppProps));
|
|
156
|
-
} finally {
|
|
157
|
-
(globalThis as any).__hadarsUnsuspend = null;
|
|
158
|
-
}
|
|
159
|
-
appHtml = processSegmentCache(appHtml);
|
|
160
|
-
|
|
161
|
-
const scriptContent = JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, '\\u003c');
|
|
162
|
-
const html = `<div id="app">${appHtml}</div><script id="hadars" type="application/json">${scriptContent}</script>`;
|
|
163
|
-
|
|
159
|
+
const { html, headHtml, status } = await runFullLifecycle(request as SerializableRequest);
|
|
164
160
|
parentPort!.postMessage({ id, html, headHtml, status });
|
|
165
161
|
|
|
166
162
|
} catch (err: any) {
|
package/src/types/hadars.ts
CHANGED
|
@@ -26,14 +26,11 @@ export interface AppHead {
|
|
|
26
26
|
export type UnsuspendEntry =
|
|
27
27
|
| { status: 'pending'; promise: Promise<unknown> }
|
|
28
28
|
| { status: 'fulfilled'; value: unknown }
|
|
29
|
-
| { status: 'suspense-resolved' }
|
|
30
|
-
| { status: 'suspense-cached'; value: unknown }
|
|
31
29
|
| { status: 'rejected'; reason: unknown };
|
|
32
30
|
|
|
33
31
|
/** @internal Populated by the framework's render loop — use useServerData() instead. */
|
|
34
32
|
export interface AppUnsuspend {
|
|
35
33
|
cache: Map<string, UnsuspendEntry>;
|
|
36
|
-
hasPending: boolean;
|
|
37
34
|
}
|
|
38
35
|
|
|
39
36
|
export interface AppContext {
|
package/src/utils/Head.tsx
CHANGED
|
@@ -196,16 +196,11 @@ export function initServerDataCache(data: Record<string, unknown>) {
|
|
|
196
196
|
* across all SSR render passes and client hydration — it must be stable and
|
|
197
197
|
* unique within the page.
|
|
198
198
|
*
|
|
199
|
-
* `fn` may return a `Promise<T>` (
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
* - Synchronous return: value stored immediately, returned on the same pass.
|
|
203
|
-
* - Thrown thenable (e.g. `useQuery({ suspense: true })`): the thrown promise is
|
|
204
|
-
* awaited, the cache entry is then cleared so that the next render re-calls
|
|
205
|
-
* `fn()` — at that point the Suspense hook returns synchronously.
|
|
199
|
+
* `fn` may return a `Promise<T>` (async usage) or return `T` synchronously.
|
|
200
|
+
* The resolved value is serialised into `__serverData` and returned from cache
|
|
201
|
+
* during hydration.
|
|
206
202
|
*
|
|
207
|
-
* `fn` is **server-only**: it is never called in the browser.
|
|
208
|
-
* is serialised into `__serverData` and returned from cache during hydration.
|
|
203
|
+
* `fn` is **server-only**: it is never called in the browser.
|
|
209
204
|
*
|
|
210
205
|
* @example
|
|
211
206
|
* const user = useServerData('current_user', () => db.getUser(id));
|
|
@@ -230,51 +225,11 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
|
|
|
230
225
|
|
|
231
226
|
const existing = unsuspend.cache.get(cacheKey);
|
|
232
227
|
|
|
233
|
-
// Suspense promise has resolved — re-call fn() so the hook returns its value
|
|
234
|
-
// synchronously from its own internal cache. Cache the result as
|
|
235
|
-
// 'suspense-cached' so later renders (e.g. the final renderToString in
|
|
236
|
-
// buildSsrResponse, which runs after getFinalProps may have cleared the
|
|
237
|
-
// user's QueryClient) can return the value without calling fn() again.
|
|
238
|
-
// NOT stored as 'fulfilled' so it is never included in serverData sent to
|
|
239
|
-
// the client — the Suspense library owns its own hydration.
|
|
240
|
-
if (existing?.status === 'suspense-resolved') {
|
|
241
|
-
try {
|
|
242
|
-
const value = fn() as T;
|
|
243
|
-
unsuspend.cache.set(cacheKey, { status: 'suspense-cached', value });
|
|
244
|
-
return value;
|
|
245
|
-
} catch {
|
|
246
|
-
return undefined;
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
// Return the cached Suspense value on all subsequent renders.
|
|
251
|
-
if (existing?.status === 'suspense-cached') {
|
|
252
|
-
return existing.value as T;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
228
|
if (!existing) {
|
|
256
229
|
// First encounter — call fn(), which may:
|
|
257
|
-
// (a) return a Promise<T> —
|
|
230
|
+
// (a) return a Promise<T> — async usage (serialised for the client)
|
|
258
231
|
// (b) return T synchronously — e.g. a sync data source
|
|
259
|
-
|
|
260
|
-
let result: Promise<T> | T;
|
|
261
|
-
try {
|
|
262
|
-
result = fn();
|
|
263
|
-
} catch (thrown) {
|
|
264
|
-
// (c) Suspense protocol: fn() threw a thenable. Await it, then mark the
|
|
265
|
-
// entry as 'suspense-resolved' so the next render re-calls fn() to get
|
|
266
|
-
// the synchronously available value. Not stored as 'fulfilled' → not
|
|
267
|
-
// serialised to the client (the Suspense library handles its own hydration).
|
|
268
|
-
if (thrown !== null && typeof thrown === 'object' && typeof (thrown as any).then === 'function') {
|
|
269
|
-
const suspensePromise = Promise.resolve(thrown as Promise<unknown>).then(
|
|
270
|
-
() => { unsuspend.cache.set(cacheKey, { status: 'suspense-resolved' }); },
|
|
271
|
-
() => { unsuspend.cache.set(cacheKey, { status: 'suspense-resolved' }); },
|
|
272
|
-
);
|
|
273
|
-
unsuspend.cache.set(cacheKey, { status: 'pending', promise: suspensePromise });
|
|
274
|
-
throw suspensePromise; // slim-react will await and retry
|
|
275
|
-
}
|
|
276
|
-
throw thrown;
|
|
277
|
-
}
|
|
232
|
+
const result = fn();
|
|
278
233
|
|
|
279
234
|
const isThenable = result !== null && typeof result === 'object' && typeof (result as any).then === 'function';
|
|
280
235
|
|
package/src/utils/response.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type React from "react";
|
|
2
2
|
import type { AppHead, HadarsRequest, HadarsEntryBase, HadarsEntryModule, HadarsProps, AppContext } from "../types/hadars";
|
|
3
3
|
import { renderToString, createElement } from '../slim-react/index';
|
|
4
|
+
import { processSegmentCache } from './segmentCache';
|
|
4
5
|
|
|
5
6
|
interface ReactResponseOptions {
|
|
6
7
|
document: {
|
|
@@ -55,10 +56,8 @@ export const getReactResponse = async (
|
|
|
55
56
|
req: HadarsRequest,
|
|
56
57
|
opts: ReactResponseOptions,
|
|
57
58
|
): Promise<{
|
|
58
|
-
|
|
59
|
-
appProps: Record<string, unknown>,
|
|
59
|
+
bodyHtml: string,
|
|
60
60
|
clientProps: Record<string, unknown>,
|
|
61
|
-
unsuspend: { cache: Map<string, any> },
|
|
62
61
|
status: number,
|
|
63
62
|
headHtml: string,
|
|
64
63
|
}> => {
|
|
@@ -78,11 +77,12 @@ export const getReactResponse = async (
|
|
|
78
77
|
// Create per-request cache for useServerData, active for all renders.
|
|
79
78
|
const unsuspend = { cache: new Map<string, any>() };
|
|
80
79
|
(globalThis as any).__hadarsUnsuspend = unsuspend;
|
|
80
|
+
let bodyHtml: string;
|
|
81
81
|
try {
|
|
82
|
-
|
|
82
|
+
bodyHtml = await renderToString(createElement(App as any, props as any));
|
|
83
83
|
if (getAfterRenderProps) {
|
|
84
|
-
props = await getAfterRenderProps(props,
|
|
85
|
-
await renderToString(
|
|
84
|
+
props = await getAfterRenderProps(props, bodyHtml);
|
|
85
|
+
bodyHtml = await renderToString(
|
|
86
86
|
createElement(App as any, { ...props, location: req.location, context } as any),
|
|
87
87
|
);
|
|
88
88
|
}
|
|
@@ -103,13 +103,9 @@ export const getReactResponse = async (
|
|
|
103
103
|
...(Object.keys(serverData).length > 0 ? { __serverData: serverData } : {}),
|
|
104
104
|
};
|
|
105
105
|
|
|
106
|
-
const appProps = { ...props, location: req.location, context } as unknown as Record<string, unknown>;
|
|
107
|
-
|
|
108
106
|
return {
|
|
109
|
-
|
|
110
|
-
appProps,
|
|
107
|
+
bodyHtml: processSegmentCache(bodyHtml),
|
|
111
108
|
clientProps: clientProps as Record<string, unknown>,
|
|
112
|
-
unsuspend,
|
|
113
109
|
status: context.head.status,
|
|
114
110
|
headHtml: getHeadHtml(context.head),
|
|
115
111
|
};
|