hadars 0.1.16 → 0.1.18
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-TGSIYGY2.js +40 -0
- package/dist/cli.js +695 -165
- 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 +874 -0
- package/dist/slim-react/index.d.ts +180 -0
- package/dist/slim-react/index.js +784 -0
- package/dist/slim-react/jsx-runtime.cjs +51 -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 +619 -108
- package/dist/ssr-watch.js +37 -13
- package/dist/utils/Head.tsx +3 -6
- package/index.ts +1 -1
- package/package.json +2 -2
- package/src/build.ts +55 -49
- 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 +686 -0
- package/src/slim-react/renderContext.ts +105 -0
- package/src/slim-react/types.ts +27 -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 +45 -15
- package/src/utils/segmentCache.ts +87 -0
package/dist/ssr-watch.js
CHANGED
|
@@ -171,25 +171,21 @@ var buildCompilerConfig = (entry2, opts, includeHotPlugin) => {
|
|
|
171
171
|
const isServerBuild = Boolean(
|
|
172
172
|
opts.output && typeof opts.output === "object" && (opts.output.library || String(opts.output.filename || "").includes("ssr"))
|
|
173
173
|
);
|
|
174
|
+
const slimReactIndex = pathMod.resolve(packageDir, "slim-react", "index.js");
|
|
175
|
+
const slimReactJsx = pathMod.resolve(packageDir, "slim-react", "jsx-runtime.js");
|
|
174
176
|
const resolveAliases = isServerBuild ? {
|
|
175
|
-
//
|
|
176
|
-
react:
|
|
177
|
-
"react-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
"react/jsx-dev-runtime": path.resolve(process.cwd(), "node_modules", "react", "jsx-dev-runtime.js"),
|
|
181
|
-
// ensure emotion packages resolve to the project's node_modules so we don't pick up a browser-specific entry
|
|
177
|
+
// Route all React imports to slim-react for SSR.
|
|
178
|
+
react: slimReactIndex,
|
|
179
|
+
"react/jsx-runtime": slimReactJsx,
|
|
180
|
+
"react/jsx-dev-runtime": slimReactJsx,
|
|
181
|
+
// Keep emotion on the project's node_modules (server-safe entry).
|
|
182
182
|
"@emotion/react": path.resolve(process.cwd(), "node_modules", "@emotion", "react"),
|
|
183
183
|
"@emotion/server": path.resolve(process.cwd(), "node_modules", "@emotion", "server"),
|
|
184
184
|
"@emotion/cache": path.resolve(process.cwd(), "node_modules", "@emotion", "cache"),
|
|
185
185
|
"@emotion/styled": path.resolve(process.cwd(), "node_modules", "@emotion", "styled")
|
|
186
186
|
} : void 0;
|
|
187
187
|
const externals = isServerBuild ? [
|
|
188
|
-
|
|
189
|
-
"react-dom",
|
|
190
|
-
// keep common aliases external as well
|
|
191
|
-
"react/jsx-runtime",
|
|
192
|
-
"react/jsx-dev-runtime",
|
|
188
|
+
// react / react-dom are replaced by slim-react via alias above — not external.
|
|
193
189
|
// emotion should be external on server builds to avoid client/browser code
|
|
194
190
|
"@emotion/react",
|
|
195
191
|
"@emotion/server",
|
|
@@ -233,6 +229,9 @@ var buildCompilerConfig = (entry2, opts, includeHotPlugin) => {
|
|
|
233
229
|
clean: false
|
|
234
230
|
},
|
|
235
231
|
mode: opts.mode,
|
|
232
|
+
// Persist transformed modules to disk — subsequent starts only recompile
|
|
233
|
+
// changed files, making repeat dev starts significantly faster.
|
|
234
|
+
cache: true,
|
|
236
235
|
externals,
|
|
237
236
|
...optimization !== void 0 ? { optimization } : {},
|
|
238
237
|
plugins: [
|
|
@@ -241,8 +240,33 @@ var buildCompilerConfig = (entry2, opts, includeHotPlugin) => {
|
|
|
241
240
|
template: opts.htmlTemplate ? pathMod.resolve(process.cwd(), opts.htmlTemplate) : clientScriptPath,
|
|
242
241
|
scriptLoading: "module",
|
|
243
242
|
filename: "out.html",
|
|
244
|
-
inject: "
|
|
243
|
+
inject: "head",
|
|
244
|
+
minify: opts.mode === "production"
|
|
245
245
|
}),
|
|
246
|
+
// Add `async` to the emitted module script so DOMContentLoaded fires
|
|
247
|
+
// as soon as HTML is parsed — without waiting for the bundle to execute.
|
|
248
|
+
// `<script type="module" async>` is valid: it downloads in parallel and
|
|
249
|
+
// executes without blocking DOMContentLoaded, while retaining module
|
|
250
|
+
// semantics (strict mode, ES imports, etc.).
|
|
251
|
+
{
|
|
252
|
+
apply(compiler) {
|
|
253
|
+
compiler.hooks.emit.tapAsync("HadarsAsyncModuleScript", (compilation, cb) => {
|
|
254
|
+
const asset = compilation.assets["out.html"];
|
|
255
|
+
if (asset) {
|
|
256
|
+
const html = asset.source();
|
|
257
|
+
const updated = html.replace(
|
|
258
|
+
/(<script\b[^>]*\btype="module"[^>]*)(>)/g,
|
|
259
|
+
(match, before, end) => before.includes("async") ? match : `${before} async${end}`
|
|
260
|
+
);
|
|
261
|
+
compilation.assets["out.html"] = {
|
|
262
|
+
source: () => updated,
|
|
263
|
+
size: () => Buffer.byteLength(updated)
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
cb();
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
},
|
|
246
270
|
isDev && new ReactRefreshPlugin(),
|
|
247
271
|
includeHotPlugin && isDev && new rspack.HotModuleReplacementPlugin(),
|
|
248
272
|
...extraPlugins
|
package/dist/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/index.ts
CHANGED
|
@@ -12,4 +12,4 @@ export type {
|
|
|
12
12
|
HadarsEntryModule,
|
|
13
13
|
HadarsApp,
|
|
14
14
|
} from "./src/types/hadars";
|
|
15
|
-
export { HadarsHead, HadarsContext, loadModule } from "./src/index";
|
|
15
|
+
export { HadarsHead, HadarsContext, loadModule, CacheSegment, deleteSegment, clearSegments } from "./src/index";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hadars",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.18",
|
|
4
4
|
"description": "Minimal SSR framework for React — rspack, HMR, TypeScript, Bun/Node/Deno",
|
|
5
5
|
"module": "./dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
}
|
|
30
30
|
},
|
|
31
31
|
"scripts": {
|
|
32
|
-
"build:lib": "tsup src/index.tsx --format esm,cjs --dts --out-dir dist --clean --external '@rspack/*' --external '@rspack/binding'",
|
|
32
|
+
"build:lib": "tsup src/index.tsx src/slim-react/index.ts src/slim-react/jsx-runtime.ts --format esm,cjs --dts --out-dir dist --clean --external '@rspack/*' --external '@rspack/binding'",
|
|
33
33
|
"build:cli": "node build-scripts/build-cli.mjs",
|
|
34
34
|
"build:all": "npm run build:lib && npm run build:cli",
|
|
35
35
|
"test": "bun test test/ssr.test.ts",
|
package/src/build.ts
CHANGED
|
@@ -9,7 +9,6 @@ import { isBun, isDeno, isNode } from "./utils/runtime";
|
|
|
9
9
|
import { RspackDevServer } from "@rspack/dev-server";
|
|
10
10
|
import pathMod from "node:path";
|
|
11
11
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
12
|
-
import { createRequire } from 'node:module';
|
|
13
12
|
import crypto from 'node:crypto';
|
|
14
13
|
import fs from 'node:fs/promises';
|
|
15
14
|
import { existsSync } from 'node:fs';
|
|
@@ -17,6 +16,7 @@ import os from 'node:os';
|
|
|
17
16
|
import { spawn } from 'node:child_process';
|
|
18
17
|
import cluster from 'node:cluster';
|
|
19
18
|
import type { HadarsEntryModule, HadarsOptions, HadarsProps } from "./types/hadars";
|
|
19
|
+
import { processSegmentCache } from "./utils/segmentCache";
|
|
20
20
|
const encoder = new TextEncoder();
|
|
21
21
|
|
|
22
22
|
/**
|
|
@@ -36,6 +36,16 @@ async function processHtmlTemplate(templatePath: string): Promise<string> {
|
|
|
36
36
|
}
|
|
37
37
|
if (matches.length === 0) return templatePath;
|
|
38
38
|
|
|
39
|
+
await ensureHadarsTmpDir();
|
|
40
|
+
|
|
41
|
+
// Cache by content hash — same template content → skip Tailwind re-scan on restart.
|
|
42
|
+
const sourceHash = crypto.createHash('md5').update(html).digest('hex').slice(0, 8);
|
|
43
|
+
const cachedPath = pathMod.join(HADARS_TMP_DIR, `template-${sourceHash}.html`);
|
|
44
|
+
try {
|
|
45
|
+
await fs.access(cachedPath);
|
|
46
|
+
return cachedPath; // cache hit
|
|
47
|
+
} catch { /* cache miss — process below */ }
|
|
48
|
+
|
|
39
49
|
const { default: postcss } = await import('postcss');
|
|
40
50
|
let plugins: any[] = [];
|
|
41
51
|
try {
|
|
@@ -56,25 +66,14 @@ async function processHtmlTemplate(templatePath: string): Promise<string> {
|
|
|
56
66
|
}
|
|
57
67
|
}
|
|
58
68
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
return tmpPath;
|
|
69
|
+
await fs.writeFile(cachedPath, processedHtml);
|
|
70
|
+
return cachedPath;
|
|
62
71
|
}
|
|
63
72
|
|
|
64
73
|
const HEAD_MARKER = '<meta name="HADARS_HEAD">';
|
|
65
74
|
const BODY_MARKER = '<meta name="HADARS_BODY">';
|
|
66
75
|
|
|
67
|
-
|
|
68
|
-
let _renderToString: ((element: any) => string) | null = null;
|
|
69
|
-
async function getRenderToString(): Promise<(element: any) => string> {
|
|
70
|
-
if (!_renderToString) {
|
|
71
|
-
const req = createRequire(pathMod.resolve(process.cwd(), '__hadars_fake__.js'));
|
|
72
|
-
const resolved = req.resolve('react-dom/server');
|
|
73
|
-
const mod = await import(pathToFileURL(resolved).href);
|
|
74
|
-
_renderToString = mod.renderToString;
|
|
75
|
-
}
|
|
76
|
-
return _renderToString!;
|
|
77
|
-
}
|
|
76
|
+
import { renderToString as slimRenderToString } from './slim-react/index';
|
|
78
77
|
|
|
79
78
|
// Round-robin thread pool for SSR rendering — used on Bun/Deno where
|
|
80
79
|
// node:cluster is not available but node:worker_threads is.
|
|
@@ -201,26 +200,21 @@ async function buildSsrResponse(
|
|
|
201
200
|
getPrecontentHtml: (headHtml: string) => Promise<[string, string]>,
|
|
202
201
|
unsuspendForRender: any,
|
|
203
202
|
): Promise<Response> {
|
|
204
|
-
// Pre-load renderer before starting the stream so the set→call→clear
|
|
205
|
-
// sequence around __hadarsUnsuspend is fully synchronous (no await between them).
|
|
206
|
-
const renderToString = await getRenderToString();
|
|
207
|
-
|
|
208
203
|
const responseStream = new ReadableStream({
|
|
209
204
|
async start(controller) {
|
|
210
205
|
const [precontentHtml, postContent] = await getPrecontentHtml(headHtml);
|
|
211
206
|
// Flush the shell (precontentHtml) immediately so the browser can
|
|
212
207
|
// start loading CSS/fonts before renderToString blocks the thread.
|
|
213
208
|
controller.enqueue(encoder.encode(precontentHtml));
|
|
214
|
-
await Promise.resolve(); // yield to let the runtime flush the shell chunk
|
|
215
209
|
|
|
216
|
-
// set → call (synchronous) → clear: no await in between, safe under concurrency
|
|
217
210
|
let bodyHtml: string;
|
|
218
211
|
try {
|
|
219
212
|
(globalThis as any).__hadarsUnsuspend = unsuspendForRender;
|
|
220
|
-
bodyHtml =
|
|
213
|
+
bodyHtml = await slimRenderToString(ReactPage);
|
|
221
214
|
} finally {
|
|
222
215
|
(globalThis as any).__hadarsUnsuspend = null;
|
|
223
216
|
}
|
|
217
|
+
bodyHtml = processSegmentCache(bodyHtml);
|
|
224
218
|
controller.enqueue(encoder.encode(bodyHtml + postContent));
|
|
225
219
|
controller.close();
|
|
226
220
|
},
|
|
@@ -395,6 +389,11 @@ const getSuffix = (mode: Mode) => mode === 'development' ? `?v=${Date.now()}` :
|
|
|
395
389
|
|
|
396
390
|
const HadarsFolder = './.hadars';
|
|
397
391
|
const StaticPath = `${HadarsFolder}/static`;
|
|
392
|
+
// Dedicated temp directory — keeps all hadars temp files out of the root of
|
|
393
|
+
// os.tmpdir() so rspack's file watcher doesn't traverse unrelated system files
|
|
394
|
+
// (e.g. Steam/Chrome shared-memory device files) in that directory.
|
|
395
|
+
const HADARS_TMP_DIR = pathMod.join(os.tmpdir(), 'hadars');
|
|
396
|
+
const ensureHadarsTmpDir = () => fs.mkdir(HADARS_TMP_DIR, { recursive: true });
|
|
398
397
|
|
|
399
398
|
const validateOptions = (options: HadarsRuntimeOptions) => {
|
|
400
399
|
if (!options.entry) {
|
|
@@ -481,7 +480,8 @@ export const dev = async (options: HadarsRuntimeOptions) => {
|
|
|
481
480
|
throw err;
|
|
482
481
|
}
|
|
483
482
|
|
|
484
|
-
|
|
483
|
+
await ensureHadarsTmpDir();
|
|
484
|
+
const tmpFilePath = pathMod.join(HADARS_TMP_DIR, `client-${Date.now()}.tsx`);
|
|
485
485
|
await fs.writeFile(tmpFilePath, clientScript);
|
|
486
486
|
|
|
487
487
|
// SSR live-reload id to force re-import
|
|
@@ -601,27 +601,32 @@ export const dev = async (options: HadarsRuntimeOptions) => {
|
|
|
601
601
|
}
|
|
602
602
|
})();
|
|
603
603
|
|
|
604
|
-
//
|
|
605
|
-
await
|
|
606
|
-
|
|
607
|
-
//
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
604
|
+
// Both builds run in parallel — this promise resolves when they're both done.
|
|
605
|
+
// We do NOT await it here; the server starts immediately below so that the
|
|
606
|
+
// port is bound right away. Incoming requests await this promise before
|
|
607
|
+
// processing, so they hold in-flight and all resolve together once ready.
|
|
608
|
+
const readyPromise = Promise.all([clientBuildDone, ssrBuildDone]);
|
|
609
|
+
|
|
610
|
+
readyPromise.then(() => {
|
|
611
|
+
// Continue reading stdout to forward logs and pick up SSR rebuild signals.
|
|
612
|
+
if (stdoutReader) {
|
|
613
|
+
const reader = stdoutReader as ReadableStreamDefaultReader<Uint8Array>;
|
|
614
|
+
(async () => {
|
|
615
|
+
try {
|
|
616
|
+
while (true) {
|
|
617
|
+
const { done, value } = await reader.read();
|
|
618
|
+
if (done) break;
|
|
619
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
620
|
+
try { process.stdout.write(chunk); } catch (e) { }
|
|
621
|
+
if (chunk.includes(rebuildMarker)) {
|
|
622
|
+
ssrBuildId = crypto.randomBytes(4).toString('hex');
|
|
623
|
+
console.log('[hadars] SSR bundle updated, build id:', ssrBuildId);
|
|
624
|
+
}
|
|
620
625
|
}
|
|
621
|
-
}
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
}
|
|
626
|
+
} catch (e) { }
|
|
627
|
+
})();
|
|
628
|
+
}
|
|
629
|
+
});
|
|
625
630
|
|
|
626
631
|
// Forward stderr asynchronously
|
|
627
632
|
(async () => {
|
|
@@ -636,10 +641,12 @@ export const dev = async (options: HadarsRuntimeOptions) => {
|
|
|
636
641
|
})();
|
|
637
642
|
|
|
638
643
|
const getPrecontentHtml = makePrecontentHtmlGetter(
|
|
639
|
-
fs.readFile(pathMod.join(__dirname, StaticPath, 'out.html'), 'utf-8')
|
|
644
|
+
readyPromise.then(() => fs.readFile(pathMod.join(__dirname, StaticPath, 'out.html'), 'utf-8'))
|
|
640
645
|
);
|
|
641
646
|
|
|
642
647
|
await serve(port, async (req, ctx) => {
|
|
648
|
+
// Hold requests until both builds are ready. Once resolved this is a no-op.
|
|
649
|
+
await readyPromise;
|
|
643
650
|
const request = parseRequest(req);
|
|
644
651
|
if (handler) {
|
|
645
652
|
const res = await handler(request);
|
|
@@ -675,7 +682,7 @@ export const dev = async (options: HadarsRuntimeOptions) => {
|
|
|
675
682
|
getFinalProps,
|
|
676
683
|
} = (await import(importPath)) as HadarsEntryModule<any>;
|
|
677
684
|
|
|
678
|
-
const { ReactPage, status, headHtml
|
|
685
|
+
const { ReactPage, unsuspend, status, headHtml } = await getReactResponse(request, {
|
|
679
686
|
document: {
|
|
680
687
|
body: Component as React.FC<HadarsProps<object>>,
|
|
681
688
|
lang: 'en',
|
|
@@ -685,7 +692,6 @@ export const dev = async (options: HadarsRuntimeOptions) => {
|
|
|
685
692
|
},
|
|
686
693
|
});
|
|
687
694
|
|
|
688
|
-
const unsuspend = (renderPayload.appProps.context as any)?._unsuspend ?? null;
|
|
689
695
|
return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, unsuspend);
|
|
690
696
|
} catch (err: any) {
|
|
691
697
|
console.error('[hadars] SSR render error:', err);
|
|
@@ -716,7 +722,8 @@ export const build = async (options: HadarsRuntimeOptions) => {
|
|
|
716
722
|
.replace('$_MOD_PATH$', entry + `?v=${Date.now()}`);
|
|
717
723
|
}
|
|
718
724
|
|
|
719
|
-
|
|
725
|
+
await ensureHadarsTmpDir();
|
|
726
|
+
const tmpFilePath = pathMod.join(HADARS_TMP_DIR, `client-${Date.now()}.tsx`);
|
|
720
727
|
await fs.writeFile(tmpFilePath, clientScript);
|
|
721
728
|
|
|
722
729
|
// Pre-process the HTML template's <style> blocks through PostCSS (e.g. Tailwind).
|
|
@@ -862,7 +869,7 @@ export const run = async (options: HadarsRuntimeOptions) => {
|
|
|
862
869
|
});
|
|
863
870
|
}
|
|
864
871
|
|
|
865
|
-
const { ReactPage, status, headHtml
|
|
872
|
+
const { ReactPage, unsuspend, status, headHtml } = await getReactResponse(request, {
|
|
866
873
|
document: {
|
|
867
874
|
body: Component as React.FC<HadarsProps<object>>,
|
|
868
875
|
lang: 'en',
|
|
@@ -872,7 +879,6 @@ export const run = async (options: HadarsRuntimeOptions) => {
|
|
|
872
879
|
},
|
|
873
880
|
});
|
|
874
881
|
|
|
875
|
-
const unsuspend = (renderPayload.appProps.context as any)?._unsuspend ?? null;
|
|
876
882
|
return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, unsuspend);
|
|
877
883
|
} catch (err: any) {
|
|
878
884
|
console.error('[hadars] SSR render error:', err);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { getSegment, CACHE_TAG } from '../utils/segmentCache';
|
|
3
|
+
|
|
4
|
+
interface CacheSegmentProps {
|
|
5
|
+
/**
|
|
6
|
+
* Unique cache key for this segment. Use a key that encodes all values
|
|
7
|
+
* the output depends on, e.g. `"product-" + product.id`.
|
|
8
|
+
*/
|
|
9
|
+
cacheKey: string;
|
|
10
|
+
/**
|
|
11
|
+
* Time-to-live in milliseconds. Omit for entries that never expire.
|
|
12
|
+
*/
|
|
13
|
+
ttl?: number;
|
|
14
|
+
children: React.ReactNode;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Caches the server-rendered HTML of its children across requests.
|
|
19
|
+
*
|
|
20
|
+
* **Server (SSR):**
|
|
21
|
+
* - Cache miss — children are rendered normally as part of the main
|
|
22
|
+
* `renderToString` call, so React context propagates correctly. The output
|
|
23
|
+
* is wrapped in a `<hadars-c>` marker that `processSegmentCache` uses to
|
|
24
|
+
* extract and store the HTML. The marker is stripped before the response
|
|
25
|
+
* is sent; the browser never sees it.
|
|
26
|
+
* - Cache hit — children are **not** rendered at all. The cached HTML is
|
|
27
|
+
* injected directly, saving the entire subtree render cost.
|
|
28
|
+
*
|
|
29
|
+
* **Client:** renders children normally (no caching). Because the server
|
|
30
|
+
* strips the marker wrapper, the client output matches the server HTML and
|
|
31
|
+
* React hydration succeeds without warnings for deterministic components.
|
|
32
|
+
*
|
|
33
|
+
* **Note:** components that rely on request-specific data (cookies, auth,
|
|
34
|
+
* personalisation) must not be wrapped in `CacheSegment` unless the cache
|
|
35
|
+
* key encodes that data — otherwise a cached response for one user could be
|
|
36
|
+
* served to another.
|
|
37
|
+
*/
|
|
38
|
+
export function CacheSegment({ cacheKey, ttl, children }: CacheSegmentProps) {
|
|
39
|
+
// Client: render children normally — no server cache on the client.
|
|
40
|
+
if (typeof window !== 'undefined') {
|
|
41
|
+
return <>{children}</>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const cached = getSegment(cacheKey);
|
|
45
|
+
|
|
46
|
+
if (cached !== null) {
|
|
47
|
+
// Cache hit: skip rendering children entirely.
|
|
48
|
+
// The <hadars-c> wrapper is stripped by processSegmentCache before
|
|
49
|
+
// the response is sent to the browser.
|
|
50
|
+
return React.createElement(CACHE_TAG as any, {
|
|
51
|
+
'data-key': cacheKey,
|
|
52
|
+
'data-cache': 'hit',
|
|
53
|
+
dangerouslySetInnerHTML: { __html: cached },
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Cache miss: render children as normal React elements so that React
|
|
58
|
+
// context (providers, etc.) propagates correctly into the subtree.
|
|
59
|
+
// processSegmentCache will extract the rendered HTML and store it.
|
|
60
|
+
const props: Record<string, unknown> = {
|
|
61
|
+
'data-key': cacheKey,
|
|
62
|
+
'data-cache': 'miss',
|
|
63
|
+
};
|
|
64
|
+
if (ttl != null) props['data-ttl'] = ttl;
|
|
65
|
+
|
|
66
|
+
return React.createElement(CACHE_TAG as any, props, children);
|
|
67
|
+
}
|
package/src/index.tsx
CHANGED
|
@@ -10,6 +10,8 @@ export type {
|
|
|
10
10
|
HadarsApp,
|
|
11
11
|
} from "./types/hadars";
|
|
12
12
|
export { Head as HadarsHead, useServerData, initServerDataCache } from './utils/Head';
|
|
13
|
+
export { CacheSegment } from './components/CacheSegment';
|
|
14
|
+
export { deleteSegment, clearSegments } from './utils/segmentCache';
|
|
13
15
|
import { AppProviderSSR, AppProviderCSR } from "./utils/Head";
|
|
14
16
|
|
|
15
17
|
export const HadarsContext = typeof window === 'undefined' ? AppProviderSSR : AppProviderCSR;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { SlimNode } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal Context implementation for SSR.
|
|
5
|
+
*
|
|
6
|
+
* Because SSR is single-pass and synchronous within each component,
|
|
7
|
+
* we just track the "current" value on the context object and
|
|
8
|
+
* save / restore around Provider renders (handled by the renderer).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface Context<T> {
|
|
12
|
+
_currentValue: T;
|
|
13
|
+
Provider: ContextProvider<T>;
|
|
14
|
+
Consumer: (props: { children: (value: T) => SlimNode }) => SlimNode;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type ContextProvider<T> = ((props: {
|
|
18
|
+
value: T;
|
|
19
|
+
children?: SlimNode;
|
|
20
|
+
}) => SlimNode) & {
|
|
21
|
+
_context: Context<T>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function createContext<T>(defaultValue: T): Context<T> {
|
|
25
|
+
const context: Context<T> = {
|
|
26
|
+
_currentValue: defaultValue,
|
|
27
|
+
Provider: null!,
|
|
28
|
+
Consumer: null!,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Provider is a function component recognised by the renderer.
|
|
32
|
+
// The `_context` tag tells the renderer to push / pop the value.
|
|
33
|
+
const Provider = function ContextProvider({
|
|
34
|
+
children,
|
|
35
|
+
}: {
|
|
36
|
+
value: T;
|
|
37
|
+
children?: SlimNode;
|
|
38
|
+
}): SlimNode {
|
|
39
|
+
return children ?? null;
|
|
40
|
+
} as unknown as ContextProvider<T>;
|
|
41
|
+
|
|
42
|
+
Provider._context = context;
|
|
43
|
+
context.Provider = Provider;
|
|
44
|
+
|
|
45
|
+
context.Consumer = ({ children }) => {
|
|
46
|
+
return (children as unknown as (value: T) => SlimNode)(
|
|
47
|
+
context._currentValue,
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return context;
|
|
52
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSR hook implementations.
|
|
3
|
+
*
|
|
4
|
+
* On the server every hook is either a no-op or returns the initial /
|
|
5
|
+
* snapshot value. This is enough for the vast majority of React-
|
|
6
|
+
* compatible libraries to work during server-side rendering.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { makeId } from "./renderContext";
|
|
10
|
+
|
|
11
|
+
// ---- useState ----
|
|
12
|
+
export function useState<T>(
|
|
13
|
+
initialState: T | (() => T),
|
|
14
|
+
): [T, (value: T | ((prev: T) => T)) => void] {
|
|
15
|
+
const value =
|
|
16
|
+
typeof initialState === "function"
|
|
17
|
+
? (initialState as () => T)()
|
|
18
|
+
: initialState;
|
|
19
|
+
return [value, () => {}];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ---- useReducer ----
|
|
23
|
+
export function useReducer<S, A>(
|
|
24
|
+
_reducer: (state: S, action: A) => S,
|
|
25
|
+
initialState: S,
|
|
26
|
+
): [S, (action: A) => void] {
|
|
27
|
+
return [initialState, () => {}];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ---- useEffect / useLayoutEffect / useInsertionEffect ----
|
|
31
|
+
export function useEffect(
|
|
32
|
+
_effect: () => void | (() => void),
|
|
33
|
+
_deps?: any[],
|
|
34
|
+
) {}
|
|
35
|
+
export function useLayoutEffect(
|
|
36
|
+
_effect: () => void | (() => void),
|
|
37
|
+
_deps?: any[],
|
|
38
|
+
) {}
|
|
39
|
+
export function useInsertionEffect(
|
|
40
|
+
_effect: () => void | (() => void),
|
|
41
|
+
_deps?: any[],
|
|
42
|
+
) {}
|
|
43
|
+
|
|
44
|
+
// ---- useRef ----
|
|
45
|
+
export function useRef<T>(initialValue: T): { current: T } {
|
|
46
|
+
return { current: initialValue };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---- useMemo / useCallback ----
|
|
50
|
+
export function useMemo<T>(factory: () => T, _deps?: any[]): T {
|
|
51
|
+
return factory();
|
|
52
|
+
}
|
|
53
|
+
export function useCallback<T extends Function>(callback: T, _deps?: any[]): T {
|
|
54
|
+
return callback;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---- useId ----
|
|
58
|
+
export function useId(): string {
|
|
59
|
+
return makeId();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---- useDebugValue ----
|
|
63
|
+
export function useDebugValue(_value: any, _format?: (v: any) => any) {}
|
|
64
|
+
|
|
65
|
+
// ---- useImperativeHandle ----
|
|
66
|
+
export function useImperativeHandle(
|
|
67
|
+
_ref: any,
|
|
68
|
+
_createHandle: () => any,
|
|
69
|
+
_deps?: any[],
|
|
70
|
+
) {}
|
|
71
|
+
|
|
72
|
+
// ---- useSyncExternalStore ----
|
|
73
|
+
export function useSyncExternalStore<T>(
|
|
74
|
+
_subscribe: (onStoreChange: () => void) => () => void,
|
|
75
|
+
getSnapshot: () => T,
|
|
76
|
+
getServerSnapshot?: () => T,
|
|
77
|
+
): T {
|
|
78
|
+
return (getServerSnapshot || getSnapshot)();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---- useTransition ----
|
|
82
|
+
export function useTransition(): [boolean, (callback: () => void) => void] {
|
|
83
|
+
return [false, (cb) => cb()];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---- useDeferredValue ----
|
|
87
|
+
export function useDeferredValue<T>(value: T): T {
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---- useOptimistic (React 19) ----
|
|
92
|
+
export function useOptimistic<T>(passthrough: T): [T, () => void] {
|
|
93
|
+
return [passthrough, () => {}];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---- useFormStatus (React 19) ----
|
|
97
|
+
export function useFormStatus() {
|
|
98
|
+
return { pending: false, data: null, method: null, action: null };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---- useActionState (React 19) ----
|
|
102
|
+
export function useActionState<S>(
|
|
103
|
+
_action: (state: S, payload: any) => S | Promise<S>,
|
|
104
|
+
initialState: S,
|
|
105
|
+
_permalink?: string,
|
|
106
|
+
): [S, (payload: any) => void, boolean] {
|
|
107
|
+
return [initialState, () => {}, false];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---- use (React 19 – Suspense integration) ----
|
|
111
|
+
export function use<T>(
|
|
112
|
+
usable: (Promise<T> & { status?: string; value?: T; reason?: any }) | { _currentValue: T },
|
|
113
|
+
): T {
|
|
114
|
+
// Context object
|
|
115
|
+
if (
|
|
116
|
+
typeof usable === "object" &&
|
|
117
|
+
usable !== null &&
|
|
118
|
+
"_currentValue" in usable
|
|
119
|
+
) {
|
|
120
|
+
return (usable as { _currentValue: T })._currentValue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Promise – Suspense protocol
|
|
124
|
+
const promise = usable as Promise<T> & {
|
|
125
|
+
status?: string;
|
|
126
|
+
value?: T;
|
|
127
|
+
reason?: any;
|
|
128
|
+
};
|
|
129
|
+
if (promise.status === "fulfilled") return promise.value!;
|
|
130
|
+
if (promise.status === "rejected") throw promise.reason;
|
|
131
|
+
throw promise; // caught by the nearest Suspense boundary
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---- startTransition ----
|
|
135
|
+
export function startTransition(callback: () => void) {
|
|
136
|
+
callback();
|
|
137
|
+
}
|