hadars 0.1.17 → 0.1.19
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/dist/chunk-OS3V4CPN.js +42 -0
- package/dist/cli.js +777 -143
- package/dist/index.cjs +61 -6
- package/dist/index.d.ts +40 -1
- package/dist/index.js +58 -6
- package/dist/jsx-runtime-97ca74a5.d.ts +18 -0
- package/dist/slim-react/index.cjs +1001 -0
- package/dist/slim-react/index.d.ts +180 -0
- package/dist/slim-react/index.js +911 -0
- package/dist/slim-react/jsx-runtime.cjs +52 -0
- package/dist/slim-react/jsx-runtime.d.ts +1 -0
- package/dist/slim-react/jsx-runtime.js +10 -0
- package/dist/ssr-render-worker.js +740 -108
- package/dist/ssr-watch.js +34 -13
- package/dist/utils/Head.tsx +3 -6
- package/index.ts +1 -1
- package/package.json +3 -3
- package/src/build.ts +6 -23
- package/src/components/CacheSegment.tsx +67 -0
- package/src/index.tsx +2 -0
- package/src/slim-react/context.ts +52 -0
- package/src/slim-react/hooks.ts +137 -0
- package/src/slim-react/index.ts +225 -0
- package/src/slim-react/jsx-runtime.ts +7 -0
- package/src/slim-react/jsx.ts +53 -0
- package/src/slim-react/render.ts +863 -0
- package/src/slim-react/renderContext.ts +105 -0
- package/src/slim-react/types.ts +33 -0
- package/src/ssr-render-worker.ts +83 -118
- package/src/utils/Head.tsx +3 -6
- package/src/utils/response.tsx +42 -105
- package/src/utils/rspack.ts +42 -15
- package/src/utils/segmentCache.ts +87 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render-time context for tree-position-based `useId`.
|
|
3
|
+
*
|
|
4
|
+
* State lives on `globalThis` rather than module-level variables so that
|
|
5
|
+
* multiple slim-react instances (the render worker's direct import and the
|
|
6
|
+
* SSR bundle's bundled copy) share the same context without any coordination.
|
|
7
|
+
* Safe because each worker processes one render at a time; `resetRenderState`
|
|
8
|
+
* is always called at the top of every `renderToString` / `renderToStream`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface TreeContext {
|
|
12
|
+
id: number;
|
|
13
|
+
overflow: string;
|
|
14
|
+
bits: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface RenderState {
|
|
18
|
+
currentTreeContext: TreeContext;
|
|
19
|
+
localIdCounter: number;
|
|
20
|
+
idPrefix: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const GLOBAL_KEY = "__slimReactRenderState";
|
|
24
|
+
const EMPTY: TreeContext = { id: 0, overflow: "", bits: 0 };
|
|
25
|
+
|
|
26
|
+
function s(): RenderState {
|
|
27
|
+
const g = globalThis as any;
|
|
28
|
+
if (!g[GLOBAL_KEY]) {
|
|
29
|
+
g[GLOBAL_KEY] = { currentTreeContext: { ...EMPTY }, localIdCounter: 0, idPrefix: "" };
|
|
30
|
+
}
|
|
31
|
+
return g[GLOBAL_KEY] as RenderState;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function resetRenderState() {
|
|
35
|
+
const st = s();
|
|
36
|
+
st.currentTreeContext = { ...EMPTY };
|
|
37
|
+
st.localIdCounter = 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function setIdPrefix(prefix: string) {
|
|
41
|
+
s().idPrefix = prefix;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function pushTreeContext(totalChildren: number, index: number): TreeContext {
|
|
45
|
+
const st = s();
|
|
46
|
+
const saved: TreeContext = { ...st.currentTreeContext };
|
|
47
|
+
const pendingBits = 32 - Math.clz32(totalChildren);
|
|
48
|
+
const slot = index + 1;
|
|
49
|
+
const totalBits = st.currentTreeContext.bits + pendingBits;
|
|
50
|
+
|
|
51
|
+
if (totalBits <= 30) {
|
|
52
|
+
st.currentTreeContext = {
|
|
53
|
+
id: (st.currentTreeContext.id << pendingBits) | slot,
|
|
54
|
+
overflow: st.currentTreeContext.overflow,
|
|
55
|
+
bits: totalBits,
|
|
56
|
+
};
|
|
57
|
+
} else {
|
|
58
|
+
let newOverflow = st.currentTreeContext.overflow;
|
|
59
|
+
if (st.currentTreeContext.bits > 0) newOverflow += st.currentTreeContext.id.toString(32);
|
|
60
|
+
st.currentTreeContext = { id: (1 << pendingBits) | slot, overflow: newOverflow, bits: pendingBits };
|
|
61
|
+
}
|
|
62
|
+
return saved;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function popTreeContext(saved: TreeContext) {
|
|
66
|
+
s().currentTreeContext = saved;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function pushComponentScope(): number {
|
|
70
|
+
const st = s();
|
|
71
|
+
const saved = st.localIdCounter;
|
|
72
|
+
st.localIdCounter = 0;
|
|
73
|
+
return saved;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function popComponentScope(saved: number) {
|
|
77
|
+
s().localIdCounter = saved;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function snapshotContext(): { tree: TreeContext; localId: number } {
|
|
81
|
+
const st = s();
|
|
82
|
+
return { tree: { ...st.currentTreeContext }, localId: st.localIdCounter };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function restoreContext(snap: { tree: TreeContext; localId: number }) {
|
|
86
|
+
const st = s();
|
|
87
|
+
st.currentTreeContext = { ...snap.tree };
|
|
88
|
+
st.localIdCounter = snap.localId;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function getTreeId(): string {
|
|
92
|
+
const { id, overflow, bits } = s().currentTreeContext;
|
|
93
|
+
return bits > 0 ? overflow + id.toString(32) : overflow;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function makeId(): string {
|
|
97
|
+
const st = s();
|
|
98
|
+
const treeId = getTreeId();
|
|
99
|
+
const n = st.localIdCounter++;
|
|
100
|
+
let id = ":" + st.idPrefix + "R";
|
|
101
|
+
if (treeId.length > 0) id += treeId;
|
|
102
|
+
id += ":";
|
|
103
|
+
if (n > 0) id += "H" + n.toString(32) + ":";
|
|
104
|
+
return id;
|
|
105
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// ---- Symbols ----
|
|
2
|
+
// Use the same symbols as React so elements produced here are wire-compatible
|
|
3
|
+
// with elements produced by the real React JSX runtime (e.g. when a library
|
|
4
|
+
// uses React.createElement directly). This means the SSR bundle can be aliased
|
|
5
|
+
// to slim-react without any element shape mismatch.
|
|
6
|
+
//
|
|
7
|
+
// React 19 introduced "react.transitional.element" as the canonical $$typeof for
|
|
8
|
+
// elements created by createElement / the jsx-runtime. We keep the old
|
|
9
|
+
// "react.element" as slim-react's own emission symbol (unchanged wire format for
|
|
10
|
+
// SSR HTML — it makes no difference) and accept both in the renderer.
|
|
11
|
+
export const SLIM_ELEMENT = Symbol.for("react.element");
|
|
12
|
+
export const REACT19_ELEMENT = Symbol.for("react.transitional.element");
|
|
13
|
+
export const FRAGMENT_TYPE = Symbol.for("react.fragment");
|
|
14
|
+
export const SUSPENSE_TYPE = Symbol.for("react.suspense");
|
|
15
|
+
|
|
16
|
+
// ---- Types ----
|
|
17
|
+
export type ComponentFunction = (props: any) => SlimNode;
|
|
18
|
+
|
|
19
|
+
export type SlimElement = {
|
|
20
|
+
$$typeof: typeof SLIM_ELEMENT;
|
|
21
|
+
type: string | ComponentFunction | symbol;
|
|
22
|
+
props: Record<string, any>;
|
|
23
|
+
key: string | number | null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type SlimNode =
|
|
27
|
+
| SlimElement
|
|
28
|
+
| string
|
|
29
|
+
| number
|
|
30
|
+
| boolean
|
|
31
|
+
| null
|
|
32
|
+
| undefined
|
|
33
|
+
| SlimNode[];
|
package/src/ssr-render-worker.ts
CHANGED
|
@@ -1,53 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* SSR render worker — runs in a node:worker_threads thread.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Uses slim-react (bundled with hadars) for rendering instead of react-dom/server.
|
|
5
|
+
* The SSR bundle is compiled with `react` aliased to slim-react, so both the
|
|
6
|
+
* worker and the bundle share the same slim-react instance (and its globalThis
|
|
7
|
+
* render state) without any extra coordination.
|
|
5
8
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* → renderToString(ReactPage)
|
|
9
|
-
* → postMessage({ id, html, headHtml, status })
|
|
10
|
-
*
|
|
11
|
-
* The SSR bundle path is passed once via workerData at thread creation time so
|
|
12
|
-
* the SSR module is only imported once per worker lifetime.
|
|
9
|
+
* Message: { type: 'renderFull', id, request: SerializableRequest }
|
|
10
|
+
* Reply: { id, html, headHtml, status } | { id, error }
|
|
13
11
|
*/
|
|
14
12
|
|
|
15
13
|
import { workerData, parentPort } from 'node:worker_threads';
|
|
16
|
-
import { createRequire } from 'node:module';
|
|
17
|
-
import pathMod from 'node:path';
|
|
18
14
|
import { pathToFileURL } from 'node:url';
|
|
15
|
+
import { processSegmentCache } from './utils/segmentCache';
|
|
16
|
+
import { renderToString, createElement, Fragment } from './slim-react/index';
|
|
19
17
|
|
|
20
18
|
const { ssrBundlePath } = workerData as { ssrBundlePath: string };
|
|
21
19
|
|
|
22
|
-
// Lazy-loaded singletons resolved from the *project's* node_modules so the
|
|
23
|
-
// same React instance is shared with the SSR bundle (prevents invalid hook calls).
|
|
24
|
-
let _React: any = null;
|
|
25
|
-
let _renderToStaticMarkup: ((element: any) => string) | null = null;
|
|
26
|
-
let _renderToString: ((element: any) => string) | null = null;
|
|
27
|
-
// Full SSR module — includes default (App component) + lifecycle exports.
|
|
28
20
|
let _ssrMod: any = null;
|
|
29
21
|
|
|
30
22
|
async function init() {
|
|
31
|
-
if (
|
|
32
|
-
|
|
33
|
-
const req = createRequire(pathMod.resolve(process.cwd(), '__hadars_fake__.js'));
|
|
34
|
-
|
|
35
|
-
if (!_React) {
|
|
36
|
-
const reactPath = pathToFileURL(req.resolve('react')).href;
|
|
37
|
-
const reactMod = await import(reactPath);
|
|
38
|
-
_React = reactMod.default ?? reactMod;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
if (!_renderToString || !_renderToStaticMarkup) {
|
|
42
|
-
const serverPath = pathToFileURL(req.resolve('react-dom/server')).href;
|
|
43
|
-
const serverMod = await import(serverPath);
|
|
44
|
-
_renderToString = serverMod.renderToString;
|
|
45
|
-
_renderToStaticMarkup = serverMod.renderToStaticMarkup;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
if (!_ssrMod) {
|
|
49
|
-
_ssrMod = await import(pathToFileURL(ssrBundlePath).href);
|
|
50
|
-
}
|
|
23
|
+
if (_ssrMod) return;
|
|
24
|
+
_ssrMod = await import(pathToFileURL(ssrBundlePath).href);
|
|
51
25
|
}
|
|
52
26
|
|
|
53
27
|
export type SerializableRequest = {
|
|
@@ -65,61 +39,57 @@ function deserializeRequest(s: SerializableRequest): any {
|
|
|
65
39
|
const init: RequestInit = { method: s.method, headers: new Headers(s.headers) };
|
|
66
40
|
if (s.body) init.body = s.body.buffer as ArrayBuffer;
|
|
67
41
|
const req = new Request(s.url, init);
|
|
68
|
-
Object.assign(req, {
|
|
69
|
-
pathname: s.pathname,
|
|
70
|
-
search: s.search,
|
|
71
|
-
location: s.location,
|
|
72
|
-
cookies: s.cookies,
|
|
73
|
-
});
|
|
42
|
+
Object.assign(req, { pathname: s.pathname, search: s.search, location: s.location, cookies: s.cookies });
|
|
74
43
|
return req;
|
|
75
44
|
}
|
|
76
45
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
)
|
|
46
|
+
// ── Head HTML serialisation ────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
const ESC: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"' };
|
|
49
|
+
const escAttr = (s: string) => s.replace(/[&<>"]/g, c => ESC[c] ?? c);
|
|
50
|
+
const escText = (s: string) => s.replace(/[&<>]/g, c => ESC[c] ?? c);
|
|
51
|
+
|
|
52
|
+
const HEAD_ATTR: Record<string, string> = {
|
|
53
|
+
className: 'class', htmlFor: 'for', httpEquiv: 'http-equiv',
|
|
54
|
+
charSet: 'charset', crossOrigin: 'crossorigin',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function renderHeadTag(tag: string, id: string, opts: Record<string, unknown>, selfClose = false): string {
|
|
58
|
+
let a = ` id="${escAttr(id)}"`;
|
|
59
|
+
let inner = '';
|
|
60
|
+
for (const [k, v] of Object.entries(opts)) {
|
|
61
|
+
if (k === 'key' || k === 'children') continue;
|
|
62
|
+
if (k === 'dangerouslySetInnerHTML') { inner = (v as any).__html ?? ''; continue; }
|
|
63
|
+
const attr = HEAD_ATTR[k] ?? k;
|
|
64
|
+
if (v === true) a += ` ${attr}`;
|
|
65
|
+
else if (v !== false && v != null) a += ` ${attr}="${escAttr(String(v))}"`;
|
|
66
|
+
}
|
|
67
|
+
return selfClose ? `<${tag}${a}>` : `<${tag}${a}>${inner}</${tag}>`;
|
|
92
68
|
}
|
|
93
69
|
|
|
94
|
-
function
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
dangerouslySetInnerHTML: {
|
|
106
|
-
__html: JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, '\\u003c'),
|
|
107
|
-
},
|
|
108
|
-
}),
|
|
109
|
-
);
|
|
70
|
+
function buildHeadHtml(head: any): string {
|
|
71
|
+
let html = `<title>${escText(head.title ?? '')}</title>`;
|
|
72
|
+
for (const [id, opts] of Object.entries(head.meta ?? {}))
|
|
73
|
+
html += renderHeadTag('meta', id, opts as Record<string, unknown>, true);
|
|
74
|
+
for (const [id, opts] of Object.entries(head.link ?? {}))
|
|
75
|
+
html += renderHeadTag('link', id, opts as Record<string, unknown>, true);
|
|
76
|
+
for (const [id, opts] of Object.entries(head.style ?? {}))
|
|
77
|
+
html += renderHeadTag('style', id, opts as Record<string, unknown>);
|
|
78
|
+
for (const [id, opts] of Object.entries(head.script ?? {}))
|
|
79
|
+
html += renderHeadTag('script', id, opts as Record<string, unknown>);
|
|
80
|
+
return html;
|
|
110
81
|
}
|
|
111
82
|
|
|
83
|
+
// ── Full lifecycle ─────────────────────────────────────────────────────────
|
|
84
|
+
|
|
112
85
|
async function runFullLifecycle(serialReq: SerializableRequest) {
|
|
113
|
-
const R = _React;
|
|
114
86
|
const Component = _ssrMod.default;
|
|
115
87
|
const { getInitProps, getAfterRenderProps, getFinalProps } = _ssrMod;
|
|
116
88
|
|
|
117
89
|
const parsedReq = deserializeRequest(serialReq);
|
|
118
90
|
|
|
119
|
-
const unsuspend: any = { cache: new Map(), hasPending: false };
|
|
120
91
|
const context: any = {
|
|
121
92
|
head: { title: 'Hadars App', meta: {}, link: {}, style: {}, script: {}, status: 200 },
|
|
122
|
-
_unsuspend: unsuspend,
|
|
123
93
|
};
|
|
124
94
|
|
|
125
95
|
let props: any = {
|
|
@@ -128,82 +98,77 @@ async function runFullLifecycle(serialReq: SerializableRequest) {
|
|
|
128
98
|
context,
|
|
129
99
|
};
|
|
130
100
|
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
do {
|
|
135
|
-
unsuspend.hasPending = false;
|
|
136
|
-
try {
|
|
137
|
-
(globalThis as any).__hadarsUnsuspend = unsuspend;
|
|
138
|
-
html = _renderToStaticMarkup!(R.createElement(Component, props));
|
|
139
|
-
} finally {
|
|
140
|
-
(globalThis as any).__hadarsUnsuspend = null;
|
|
141
|
-
}
|
|
142
|
-
if (unsuspend.hasPending) {
|
|
143
|
-
const pending = [...unsuspend.cache.values()]
|
|
144
|
-
.filter((e: any) => e.status === 'pending')
|
|
145
|
-
.map((e: any) => e.promise);
|
|
146
|
-
await Promise.all(pending);
|
|
147
|
-
}
|
|
148
|
-
} while (unsuspend.hasPending && ++iters < 25);
|
|
149
|
-
if (unsuspend.hasPending) {
|
|
150
|
-
console.warn('[hadars] SSR render loop hit the 25-iteration cap — some useServerData values may not be resolved. Check for data dependencies that are never fulfilled.');
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
props = getAfterRenderProps ? await getAfterRenderProps(props, html) : props;
|
|
101
|
+
// Create per-request cache for useServerData, active for all renders.
|
|
102
|
+
const unsuspend = { cache: new Map<string, any>() };
|
|
103
|
+
(globalThis as any).__hadarsUnsuspend = unsuspend;
|
|
154
104
|
|
|
155
|
-
// Re-render to capture head changes from getAfterRenderProps.
|
|
156
105
|
try {
|
|
157
|
-
|
|
158
|
-
|
|
106
|
+
let html = await renderToString(createElement(Component, props));
|
|
107
|
+
|
|
108
|
+
if (getAfterRenderProps) {
|
|
109
|
+
props = await getAfterRenderProps(props, html);
|
|
110
|
+
await renderToString(
|
|
111
|
+
createElement(Component, { ...props, location: serialReq.location, context }),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
159
114
|
} finally {
|
|
160
115
|
(globalThis as any).__hadarsUnsuspend = null;
|
|
161
116
|
}
|
|
162
117
|
|
|
163
|
-
|
|
118
|
+
const { context: _ctx, ...restProps } = getFinalProps ? await getFinalProps(props) : props;
|
|
119
|
+
|
|
120
|
+
// Collect fulfilled useServerData values for client-side hydration.
|
|
164
121
|
const serverData: Record<string, unknown> = {};
|
|
165
|
-
for (const [
|
|
166
|
-
if (
|
|
167
|
-
if ((v as any).status === 'suspense-cached') serverData[k] = (v as any).value;
|
|
122
|
+
for (const [key, entry] of unsuspend.cache) {
|
|
123
|
+
if (entry.status === 'fulfilled') serverData[key] = entry.value;
|
|
168
124
|
}
|
|
169
|
-
|
|
170
|
-
const { context: _ctx, ...restProps } = getFinalProps ? await getFinalProps(props) : props;
|
|
171
125
|
const clientProps = {
|
|
172
126
|
...restProps,
|
|
173
127
|
location: serialReq.location,
|
|
174
128
|
...(Object.keys(serverData).length > 0 ? { __serverData: serverData } : {}),
|
|
175
129
|
};
|
|
176
130
|
|
|
177
|
-
const headHtml = buildHeadHtml(context.head);
|
|
178
|
-
const status: number = context.head.status ?? 200;
|
|
179
131
|
const finalAppProps = { ...props, location: serialReq.location, context };
|
|
180
|
-
|
|
181
|
-
return { finalAppProps, clientProps, headHtml, status, unsuspend };
|
|
132
|
+
return { finalAppProps, clientProps, unsuspend, headHtml: buildHeadHtml(context.head), status: context.head.status ?? 200 };
|
|
182
133
|
}
|
|
183
134
|
|
|
135
|
+
// ── Message handler ────────────────────────────────────────────────────────
|
|
136
|
+
|
|
184
137
|
parentPort!.on('message', async (msg: any) => {
|
|
185
138
|
const { id, type, request } = msg;
|
|
186
139
|
try {
|
|
187
140
|
await init();
|
|
188
|
-
|
|
189
141
|
if (type !== 'renderFull') return;
|
|
190
142
|
|
|
191
|
-
const { finalAppProps, clientProps, headHtml, status
|
|
143
|
+
const { finalAppProps, clientProps, unsuspend, headHtml, status } =
|
|
192
144
|
await runFullLifecycle(request as SerializableRequest);
|
|
193
145
|
|
|
194
|
-
const
|
|
146
|
+
const Component = _ssrMod.default;
|
|
195
147
|
|
|
148
|
+
const page = createElement(Fragment, null,
|
|
149
|
+
createElement('div', { id: 'app' }, createElement(Component, finalAppProps)),
|
|
150
|
+
createElement('script', {
|
|
151
|
+
id: 'hadars',
|
|
152
|
+
type: 'application/json',
|
|
153
|
+
dangerouslySetInnerHTML: {
|
|
154
|
+
__html: JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, '\\u003c'),
|
|
155
|
+
},
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
// Re-use the same cache so useServerData returns immediately (no re-fetch).
|
|
160
|
+
(globalThis as any).__hadarsUnsuspend = unsuspend;
|
|
196
161
|
let html: string;
|
|
197
162
|
try {
|
|
198
|
-
|
|
199
|
-
html = _renderToString!(ReactPage);
|
|
163
|
+
html = await renderToString(page);
|
|
200
164
|
} finally {
|
|
201
165
|
(globalThis as any).__hadarsUnsuspend = null;
|
|
202
166
|
}
|
|
167
|
+
html = processSegmentCache(html);
|
|
168
|
+
|
|
203
169
|
parentPort!.postMessage({ id, html, headHtml, status });
|
|
204
170
|
|
|
205
171
|
} catch (err: any) {
|
|
206
|
-
(globalThis as any).__hadarsUnsuspend = null;
|
|
207
172
|
parentPort!.postMessage({ id, error: err?.message ?? String(err) });
|
|
208
173
|
}
|
|
209
174
|
});
|
package/src/utils/Head.tsx
CHANGED
|
@@ -273,8 +273,7 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
|
|
|
273
273
|
() => { unsuspend.cache.set(cacheKey, { status: 'suspense-resolved' }); },
|
|
274
274
|
);
|
|
275
275
|
unsuspend.cache.set(cacheKey, { status: 'pending', promise: suspensePromise });
|
|
276
|
-
|
|
277
|
-
return undefined;
|
|
276
|
+
throw suspensePromise; // slim-react will await and retry
|
|
278
277
|
}
|
|
279
278
|
throw thrown;
|
|
280
279
|
}
|
|
@@ -294,12 +293,10 @@ export function useServerData<T>(key: string | string[], fn: () => Promise<T> |
|
|
|
294
293
|
reason => { unsuspend.cache.set(cacheKey, { status: 'rejected', reason }); },
|
|
295
294
|
);
|
|
296
295
|
unsuspend.cache.set(cacheKey, { status: 'pending', promise });
|
|
297
|
-
|
|
298
|
-
return undefined;
|
|
296
|
+
throw promise; // slim-react will await and retry
|
|
299
297
|
}
|
|
300
298
|
if (existing.status === 'pending') {
|
|
301
|
-
|
|
302
|
-
return undefined;
|
|
299
|
+
throw existing.promise; // slim-react will await and retry
|
|
303
300
|
}
|
|
304
301
|
if (existing.status === 'rejected') throw existing.reason;
|
|
305
302
|
return existing.value as T;
|
package/src/utils/response.tsx
CHANGED
|
@@ -1,23 +1,6 @@
|
|
|
1
1
|
import type React from "react";
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import { pathToFileURL } from "node:url";
|
|
5
|
-
import type { AppHead, AppUnsuspend, HadarsRequest, HadarsEntryBase, HadarsEntryModule, HadarsProps, AppContext } from "../types/hadars";
|
|
6
|
-
|
|
7
|
-
// Resolve react-dom/server from the *project's* node_modules (process.cwd()) so
|
|
8
|
-
// the same React instance is used here as in the SSR bundle. Without this,
|
|
9
|
-
// when hadars is installed as a file: symlink the renderer ends up on a
|
|
10
|
-
// different React than the component, breaking hook calls.
|
|
11
|
-
let _renderToStaticMarkup: ((element: any) => string) | null = null;
|
|
12
|
-
async function getStaticMarkupRenderer(): Promise<(element: any) => string> {
|
|
13
|
-
if (!_renderToStaticMarkup) {
|
|
14
|
-
const req = createRequire(pathMod.resolve(process.cwd(), '__hadars_fake__.js'));
|
|
15
|
-
const resolved = req.resolve('react-dom/server');
|
|
16
|
-
const mod = await import(pathToFileURL(resolved).href);
|
|
17
|
-
_renderToStaticMarkup = mod.renderToStaticMarkup;
|
|
18
|
-
}
|
|
19
|
-
return _renderToStaticMarkup!;
|
|
20
|
-
}
|
|
2
|
+
import type { AppHead, HadarsRequest, HadarsEntryBase, HadarsEntryModule, HadarsProps, AppContext } from "../types/hadars";
|
|
3
|
+
import { renderToString, createElement, Fragment } from '../slim-react/index';
|
|
21
4
|
|
|
22
5
|
interface ReactResponseOptions {
|
|
23
6
|
document: {
|
|
@@ -30,13 +13,12 @@ interface ReactResponseOptions {
|
|
|
30
13
|
}
|
|
31
14
|
}
|
|
32
15
|
|
|
33
|
-
// ── Head HTML serialisation
|
|
16
|
+
// ── Head HTML serialisation ────────────────────────────────────────────────
|
|
34
17
|
|
|
35
18
|
const ESC: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"' };
|
|
36
19
|
const escAttr = (s: string) => s.replace(/[&<>"]/g, c => ESC[c] ?? c);
|
|
37
20
|
const escText = (s: string) => s.replace(/[&<>]/g, c => ESC[c] ?? c);
|
|
38
21
|
|
|
39
|
-
// React prop → HTML attribute name for the subset used in head tags.
|
|
40
22
|
const ATTR: Record<string, string> = {
|
|
41
23
|
className: 'class', htmlFor: 'for', httpEquiv: 'http-equiv',
|
|
42
24
|
charSet: 'charset', crossOrigin: 'crossorigin', noModule: 'nomodule',
|
|
@@ -69,12 +51,12 @@ const getHeadHtml = (seoData: AppHead): string => {
|
|
|
69
51
|
return html;
|
|
70
52
|
};
|
|
71
53
|
|
|
72
|
-
|
|
73
54
|
export const getReactResponse = async (
|
|
74
55
|
req: HadarsRequest,
|
|
75
56
|
opts: ReactResponseOptions,
|
|
76
57
|
): Promise<{
|
|
77
|
-
ReactPage:
|
|
58
|
+
ReactPage: any,
|
|
59
|
+
unsuspend: { cache: Map<string, any> },
|
|
78
60
|
status: number,
|
|
79
61
|
headHtml: string,
|
|
80
62
|
renderPayload: {
|
|
@@ -82,113 +64,68 @@ export const getReactResponse = async (
|
|
|
82
64
|
clientProps: Record<string, unknown>;
|
|
83
65
|
};
|
|
84
66
|
}> => {
|
|
85
|
-
const App = opts.document.body
|
|
67
|
+
const App = opts.document.body;
|
|
86
68
|
const { getInitProps, getAfterRenderProps, getFinalProps } = opts.document;
|
|
87
69
|
|
|
88
|
-
const renderToStaticMarkup = await getStaticMarkupRenderer();
|
|
89
|
-
|
|
90
|
-
// Per-request unsuspend context — populated by useServerData() hooks during render.
|
|
91
|
-
// Lifecycle passes always run on the main thread so the cache is directly accessible.
|
|
92
|
-
// Kept as a plain AppUnsuspend (no methods) so it is serializable via structuredClone
|
|
93
|
-
// for postMessage to worker threads.
|
|
94
|
-
const unsuspend: AppUnsuspend = {
|
|
95
|
-
cache: new Map(),
|
|
96
|
-
hasPending: false,
|
|
97
|
-
};
|
|
98
|
-
const processUnsuspend = async () => {
|
|
99
|
-
const pending = [...unsuspend.cache.values()]
|
|
100
|
-
.filter((e): e is { status: 'pending'; promise: Promise<unknown> } => e.status === 'pending')
|
|
101
|
-
.map(e => e.promise);
|
|
102
|
-
await Promise.all(pending);
|
|
103
|
-
};
|
|
104
|
-
|
|
105
70
|
const context: AppContext = {
|
|
106
|
-
head: {
|
|
107
|
-
|
|
108
|
-
meta: {},
|
|
109
|
-
link: {},
|
|
110
|
-
style: {},
|
|
111
|
-
script: {},
|
|
112
|
-
status: 200,
|
|
113
|
-
},
|
|
114
|
-
_unsuspend: unsuspend,
|
|
115
|
-
}
|
|
71
|
+
head: { title: 'Hadars App', meta: {}, link: {}, style: {}, script: {}, status: 200 },
|
|
72
|
+
};
|
|
116
73
|
|
|
117
74
|
let props: HadarsEntryBase = {
|
|
118
75
|
...(getInitProps ? await getInitProps(req) : {}),
|
|
119
76
|
location: req.location,
|
|
120
77
|
context,
|
|
121
|
-
} as HadarsEntryBase
|
|
122
|
-
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
(globalThis as any).__hadarsUnsuspend = unsuspend;
|
|
134
|
-
html = renderToStaticMarkup(<App {...(props as any)} />);
|
|
135
|
-
} finally {
|
|
136
|
-
(globalThis as any).__hadarsUnsuspend = null;
|
|
78
|
+
} as HadarsEntryBase;
|
|
79
|
+
|
|
80
|
+
// Create per-request cache for useServerData, active for all renders.
|
|
81
|
+
const unsuspend = { cache: new Map<string, any>() };
|
|
82
|
+
(globalThis as any).__hadarsUnsuspend = unsuspend;
|
|
83
|
+
try {
|
|
84
|
+
let html = await renderToString(createElement(App as any, props as any));
|
|
85
|
+
if (getAfterRenderProps) {
|
|
86
|
+
props = await getAfterRenderProps(props, html);
|
|
87
|
+
await renderToString(
|
|
88
|
+
createElement(App as any, { ...props, location: req.location, context } as any),
|
|
89
|
+
);
|
|
137
90
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if (unsuspend.hasPending) {
|
|
141
|
-
console.warn('[hadars] SSR render loop hit the 25-iteration cap — some useServerData values may not be resolved. Check for data dependencies that are never fulfilled.');
|
|
91
|
+
} finally {
|
|
92
|
+
(globalThis as any).__hadarsUnsuspend = null;
|
|
142
93
|
}
|
|
143
94
|
|
|
144
|
-
|
|
145
|
-
props = await getAfterRenderProps(props, html);
|
|
146
|
-
// Re-render only when getAfterRenderProps is present — it may mutate
|
|
147
|
-
// props that affect head tags, so we need another pass to capture them.
|
|
148
|
-
try {
|
|
149
|
-
(globalThis as any).__hadarsUnsuspend = unsuspend;
|
|
150
|
-
renderToStaticMarkup(<App {...({ ...props, location: req.location, context })} />);
|
|
151
|
-
} finally {
|
|
152
|
-
(globalThis as any).__hadarsUnsuspend = null;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
95
|
+
const { context: _, ...restProps } = getFinalProps ? await getFinalProps(props) : props;
|
|
155
96
|
|
|
156
|
-
//
|
|
157
|
-
// The client bootstrap reads __serverData and pre-populates the hook cache
|
|
158
|
-
// before hydrateRoot so that useServerData() returns the same values CSR.
|
|
97
|
+
// Collect fulfilled useServerData values for client-side hydration.
|
|
159
98
|
const serverData: Record<string, unknown> = {};
|
|
160
|
-
for (const [
|
|
161
|
-
if (
|
|
99
|
+
for (const [key, entry] of unsuspend.cache) {
|
|
100
|
+
if (entry.status === 'fulfilled') serverData[key] = entry.value;
|
|
162
101
|
}
|
|
163
|
-
|
|
164
|
-
const { context: _, ...restProps } = getFinalProps ? await getFinalProps(props) : props;
|
|
165
102
|
const clientProps = {
|
|
166
103
|
...restProps,
|
|
167
104
|
location: req.location,
|
|
168
105
|
...(Object.keys(serverData).length > 0 ? { __serverData: serverData } : {}),
|
|
169
|
-
}
|
|
106
|
+
};
|
|
170
107
|
|
|
171
|
-
const ReactPage = (
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
)
|
|
108
|
+
const ReactPage = createElement(Fragment, null,
|
|
109
|
+
createElement('div', { id: 'app' },
|
|
110
|
+
createElement(App as any, { ...props, location: req.location, context } as any),
|
|
111
|
+
),
|
|
112
|
+
createElement('script', {
|
|
113
|
+
id: 'hadars',
|
|
114
|
+
type: 'application/json',
|
|
115
|
+
dangerouslySetInnerHTML: {
|
|
116
|
+
__html: JSON.stringify({ hadars: { props: clientProps } }).replace(/</g, '\\u003c'),
|
|
117
|
+
},
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
183
120
|
|
|
184
121
|
return {
|
|
185
122
|
ReactPage,
|
|
123
|
+
unsuspend,
|
|
186
124
|
status: context.head.status,
|
|
187
125
|
headHtml: getHeadHtml(context.head),
|
|
188
126
|
renderPayload: {
|
|
189
127
|
appProps: { ...props, location: req.location, context } as Record<string, unknown>,
|
|
190
128
|
clientProps: clientProps as Record<string, unknown>,
|
|
191
129
|
},
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
}
|
|
130
|
+
};
|
|
131
|
+
};
|