hadars 0.1.10 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/cli-lib.ts +1 -1
- package/cli.ts +2 -2
- package/dist/cli.js +126 -38
- package/dist/index.d.ts +23 -0
- package/dist/ssr-render-worker.js +2 -2
- package/dist/template.html +2 -2
- package/dist/utils/Head.tsx +1 -1
- package/dist/utils/clientScript.tsx +1 -1
- package/index.ts +12 -12
- package/package.json +14 -18
- package/src/build.ts +158 -34
- package/src/index.tsx +1 -1
- package/src/ssr-render-worker.ts +2 -2
- package/src/types/{ninety.ts → hadars.ts} +18 -0
- package/src/utils/Head.tsx +1 -1
- package/src/utils/clientScript.tsx +1 -1
- package/src/utils/proxyHandler.tsx +1 -1
- package/src/utils/request.tsx +1 -1
- package/src/utils/response.tsx +3 -3
- package/src/utils/rspack.ts +1 -1
- package/src/utils/staticFile.ts +1 -1
- package/src/utils/template.html +2 -2
- package/src/utils/upgradeRequest.tsx +1 -1
package/LICENSE
CHANGED
package/cli-lib.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'
|
|
|
2
2
|
import { mkdir, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { resolve, join } from 'node:path'
|
|
4
4
|
import * as Hadars from './src/build'
|
|
5
|
-
import type { HadarsOptions } from './src/types/
|
|
5
|
+
import type { HadarsOptions } from './src/types/hadars'
|
|
6
6
|
|
|
7
7
|
const SUPPORTED = ['hadars.config.js', 'hadars.config.mjs', 'hadars.config.cjs', 'hadars.config.ts']
|
|
8
8
|
|
package/cli.ts
CHANGED
|
@@ -7,12 +7,12 @@ import { runCli } from './cli-lib'
|
|
|
7
7
|
// (native Bun.serve, WebSocket support, etc.).
|
|
8
8
|
// Falls back to Node.js silently if bun is not in PATH.
|
|
9
9
|
if (typeof (globalThis as any).Bun === 'undefined' && typeof (globalThis as any).Deno === 'undefined') {
|
|
10
|
-
const child = spawn('bun', [process.argv[1]
|
|
10
|
+
const child = spawn('bun', [process.argv[1]!, ...process.argv.slice(2)], {
|
|
11
11
|
stdio: 'inherit',
|
|
12
12
|
env: process.env,
|
|
13
13
|
});
|
|
14
14
|
const sigs = ['SIGINT', 'SIGTERM', 'SIGHUP'] as const;
|
|
15
|
-
const fwd = (sig:
|
|
15
|
+
const fwd = (sig: NodeJS.Signals) => () => { try { child.kill(sig); } catch {} };
|
|
16
16
|
for (const sig of sigs) process.on(sig, fwd(sig));
|
|
17
17
|
child.on('error', (err: any) => {
|
|
18
18
|
for (const sig of sigs) process.removeAllListeners(sig);
|
package/dist/cli.js
CHANGED
|
@@ -150,8 +150,8 @@ async function getStaticMarkupRenderer() {
|
|
|
150
150
|
return _renderToStaticMarkup;
|
|
151
151
|
}
|
|
152
152
|
var ESC = { "&": "&", "<": "<", ">": ">", '"': """ };
|
|
153
|
-
var escAttr = (s) => s.replace(/[&<>"]/g, (c) => ESC[c]);
|
|
154
|
-
var escText = (s) => s.replace(/[&<>]/g, (c) => ESC[c]);
|
|
153
|
+
var escAttr = (s) => s.replace(/[&<>"]/g, (c) => ESC[c] ?? c);
|
|
154
|
+
var escText = (s) => s.replace(/[&<>]/g, (c) => ESC[c] ?? c);
|
|
155
155
|
var ATTR = {
|
|
156
156
|
className: "class",
|
|
157
157
|
htmlFor: "for",
|
|
@@ -766,7 +766,7 @@ async function tryServeFile(filePath) {
|
|
|
766
766
|
const data = await readFile(filePath);
|
|
767
767
|
const ext = filePath.split(".").pop()?.toLowerCase() ?? "";
|
|
768
768
|
const contentType = MIME[ext] ?? "application/octet-stream";
|
|
769
|
-
return new Response(data, { headers: { "Content-Type": contentType } });
|
|
769
|
+
return new Response(data.buffer, { headers: { "Content-Type": contentType } });
|
|
770
770
|
} catch {
|
|
771
771
|
return null;
|
|
772
772
|
}
|
|
@@ -784,8 +784,8 @@ import os from "node:os";
|
|
|
784
784
|
import { spawn } from "node:child_process";
|
|
785
785
|
import cluster from "node:cluster";
|
|
786
786
|
var encoder = new TextEncoder();
|
|
787
|
-
var HEAD_MARKER = '<meta name="
|
|
788
|
-
var BODY_MARKER = '<meta name="
|
|
787
|
+
var HEAD_MARKER = '<meta name="HADARS_HEAD">';
|
|
788
|
+
var BODY_MARKER = '<meta name="HADARS_BODY">';
|
|
789
789
|
var _renderToString = null;
|
|
790
790
|
async function getRenderToString() {
|
|
791
791
|
if (!_renderToString) {
|
|
@@ -956,6 +956,86 @@ var makePrecontentHtmlGetter = (htmlFilePromise) => {
|
|
|
956
956
|
return [preHead + headHtml + postHead, postContent];
|
|
957
957
|
};
|
|
958
958
|
};
|
|
959
|
+
async function transformStream(data, stream) {
|
|
960
|
+
const writer = stream.writable.getWriter();
|
|
961
|
+
writer.write(data);
|
|
962
|
+
writer.close();
|
|
963
|
+
const chunks = [];
|
|
964
|
+
const reader = stream.readable.getReader();
|
|
965
|
+
while (true) {
|
|
966
|
+
const { done, value } = await reader.read();
|
|
967
|
+
if (done)
|
|
968
|
+
break;
|
|
969
|
+
chunks.push(value);
|
|
970
|
+
}
|
|
971
|
+
const total = chunks.reduce((n, c) => n + c.length, 0);
|
|
972
|
+
const out = new Uint8Array(total);
|
|
973
|
+
let offset = 0;
|
|
974
|
+
for (const c of chunks) {
|
|
975
|
+
out.set(c, offset);
|
|
976
|
+
offset += c.length;
|
|
977
|
+
}
|
|
978
|
+
return out;
|
|
979
|
+
}
|
|
980
|
+
var gzipCompress = (d) => transformStream(d, new globalThis.CompressionStream("gzip"));
|
|
981
|
+
var gzipDecompress = (d) => transformStream(d, new globalThis.DecompressionStream("gzip"));
|
|
982
|
+
async function buildCacheEntry(res, ttl) {
|
|
983
|
+
const buf = await res.arrayBuffer();
|
|
984
|
+
const body = await gzipCompress(new Uint8Array(buf));
|
|
985
|
+
const headers = [];
|
|
986
|
+
res.headers.forEach((v, k) => {
|
|
987
|
+
if (k.toLowerCase() !== "content-encoding" && k.toLowerCase() !== "content-length") {
|
|
988
|
+
headers.push([k, v]);
|
|
989
|
+
}
|
|
990
|
+
});
|
|
991
|
+
headers.push(["content-encoding", "gzip"]);
|
|
992
|
+
return { body, status: res.status, headers, expiresAt: ttl != null ? Date.now() + ttl : null };
|
|
993
|
+
}
|
|
994
|
+
async function serveFromEntry(entry, req) {
|
|
995
|
+
const accept = req.headers.get("Accept-Encoding") ?? "";
|
|
996
|
+
if (accept.includes("gzip")) {
|
|
997
|
+
return new Response(entry.body.buffer, { status: entry.status, headers: entry.headers });
|
|
998
|
+
}
|
|
999
|
+
const plain = await gzipDecompress(entry.body);
|
|
1000
|
+
const headers = entry.headers.filter(([k]) => k.toLowerCase() !== "content-encoding");
|
|
1001
|
+
return new Response(plain.buffer, { status: entry.status, headers });
|
|
1002
|
+
}
|
|
1003
|
+
function createRenderCache(opts, handler) {
|
|
1004
|
+
const store = /* @__PURE__ */ new Map();
|
|
1005
|
+
const inFlight = /* @__PURE__ */ new Map();
|
|
1006
|
+
return async (req, ctx) => {
|
|
1007
|
+
const hadarsReq = parseRequest(req);
|
|
1008
|
+
const cacheOpts = await opts(hadarsReq);
|
|
1009
|
+
const key = cacheOpts?.key ?? null;
|
|
1010
|
+
if (key != null) {
|
|
1011
|
+
const entry = store.get(key);
|
|
1012
|
+
if (entry) {
|
|
1013
|
+
const expired = entry.expiresAt != null && Date.now() >= entry.expiresAt;
|
|
1014
|
+
if (!expired) {
|
|
1015
|
+
return serveFromEntry(entry, req);
|
|
1016
|
+
}
|
|
1017
|
+
store.delete(key);
|
|
1018
|
+
}
|
|
1019
|
+
let flight = inFlight.get(key);
|
|
1020
|
+
if (!flight) {
|
|
1021
|
+
const ttl = cacheOpts?.ttl;
|
|
1022
|
+
flight = handler(new Request(req), ctx).then(async (res) => {
|
|
1023
|
+
if (!res || res.status < 200 || res.status >= 300 || res.headers.has("set-cookie")) {
|
|
1024
|
+
return null;
|
|
1025
|
+
}
|
|
1026
|
+
const newEntry2 = await buildCacheEntry(res, ttl);
|
|
1027
|
+
store.set(key, newEntry2);
|
|
1028
|
+
return newEntry2;
|
|
1029
|
+
}).catch(() => null).finally(() => inFlight.delete(key));
|
|
1030
|
+
inFlight.set(key, flight);
|
|
1031
|
+
}
|
|
1032
|
+
const newEntry = await flight;
|
|
1033
|
+
if (newEntry)
|
|
1034
|
+
return serveFromEntry(newEntry, req);
|
|
1035
|
+
}
|
|
1036
|
+
return handler(req, ctx);
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
959
1039
|
var SSR_FILENAME = "index.ssr.js";
|
|
960
1040
|
var __dirname2 = process.cwd();
|
|
961
1041
|
var getSuffix = (mode) => mode === "development" ? `?v=${Date.now()}` : "";
|
|
@@ -1044,11 +1124,11 @@ var dev = async (options) => {
|
|
|
1044
1124
|
allowedHosts: "all"
|
|
1045
1125
|
}, clientCompiler);
|
|
1046
1126
|
console.log(`Starting HMR dev server on port ${hmrPort}`);
|
|
1047
|
-
|
|
1048
|
-
|
|
1127
|
+
let clientResolved = false;
|
|
1128
|
+
const clientBuildDone = new Promise((resolve2, reject) => {
|
|
1049
1129
|
clientCompiler.hooks.done.tap("initial-build", (stats) => {
|
|
1050
|
-
if (!
|
|
1051
|
-
|
|
1130
|
+
if (!clientResolved) {
|
|
1131
|
+
clientResolved = true;
|
|
1052
1132
|
console.log(stats.toString({ colors: true }));
|
|
1053
1133
|
resolve2();
|
|
1054
1134
|
}
|
|
@@ -1088,37 +1168,40 @@ var dev = async (options) => {
|
|
|
1088
1168
|
const marker = "ssr-watch: initial-build-complete";
|
|
1089
1169
|
const rebuildMarker = "ssr-watch: SSR rebuilt";
|
|
1090
1170
|
const decoder = new TextDecoder();
|
|
1091
|
-
let gotMarker = false;
|
|
1092
1171
|
let stdoutReader = null;
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
const
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1172
|
+
const ssrBuildDone = (async () => {
|
|
1173
|
+
let gotMarker = false;
|
|
1174
|
+
try {
|
|
1175
|
+
stdoutReader = stdoutWebStream.getReader();
|
|
1176
|
+
let buf = "";
|
|
1177
|
+
const start = Date.now();
|
|
1178
|
+
const timeoutMs = 2e4;
|
|
1179
|
+
while (Date.now() - start < timeoutMs) {
|
|
1180
|
+
const { done, value } = await stdoutReader.read();
|
|
1181
|
+
if (done) {
|
|
1182
|
+
stdoutReader = null;
|
|
1183
|
+
break;
|
|
1184
|
+
}
|
|
1185
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
1186
|
+
buf += chunk;
|
|
1187
|
+
try {
|
|
1188
|
+
process.stdout.write(chunk);
|
|
1189
|
+
} catch (e) {
|
|
1190
|
+
}
|
|
1191
|
+
if (buf.includes(marker)) {
|
|
1192
|
+
gotMarker = true;
|
|
1193
|
+
break;
|
|
1194
|
+
}
|
|
1109
1195
|
}
|
|
1110
|
-
if (
|
|
1111
|
-
|
|
1112
|
-
break;
|
|
1196
|
+
if (!gotMarker) {
|
|
1197
|
+
console.warn("SSR worker did not signal initial build completion within timeout");
|
|
1113
1198
|
}
|
|
1199
|
+
} catch (err) {
|
|
1200
|
+
console.error("Error reading SSR worker output", err);
|
|
1201
|
+
stdoutReader = null;
|
|
1114
1202
|
}
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
}
|
|
1118
|
-
} catch (err) {
|
|
1119
|
-
console.error("Error reading SSR worker output", err);
|
|
1120
|
-
stdoutReader = null;
|
|
1121
|
-
}
|
|
1203
|
+
})();
|
|
1204
|
+
await Promise.all([clientBuildDone, ssrBuildDone]);
|
|
1122
1205
|
if (stdoutReader) {
|
|
1123
1206
|
const reader = stdoutReader;
|
|
1124
1207
|
(async () => {
|
|
@@ -1295,7 +1378,7 @@ var run = async (options) => {
|
|
|
1295
1378
|
const getPrecontentHtml = makePrecontentHtmlGetter(
|
|
1296
1379
|
fs.readFile(pathMod3.join(__dirname2, StaticPath, "out.html"), "utf-8")
|
|
1297
1380
|
);
|
|
1298
|
-
|
|
1381
|
+
const runHandler = async (req, ctx) => {
|
|
1299
1382
|
const request = parseRequest(req);
|
|
1300
1383
|
if (handler) {
|
|
1301
1384
|
const res = await handler(request);
|
|
@@ -1363,7 +1446,12 @@ var run = async (options) => {
|
|
|
1363
1446
|
console.error("[hadars] SSR render error:", err);
|
|
1364
1447
|
return new Response("Internal Server Error", { status: 500 });
|
|
1365
1448
|
}
|
|
1366
|
-
}
|
|
1449
|
+
};
|
|
1450
|
+
await serve(
|
|
1451
|
+
port,
|
|
1452
|
+
options.cache ? createRenderCache(options.cache, runHandler) : runHandler,
|
|
1453
|
+
options.websocket
|
|
1454
|
+
);
|
|
1367
1455
|
};
|
|
1368
1456
|
|
|
1369
1457
|
// cli-lib.ts
|
package/dist/index.d.ts
CHANGED
|
@@ -92,6 +92,29 @@ interface HadarsOptions {
|
|
|
92
92
|
* Has no effect on the SSR bundle or dev mode.
|
|
93
93
|
*/
|
|
94
94
|
optimization?: Record<string, unknown>;
|
|
95
|
+
/**
|
|
96
|
+
* SSR response cache for `run()` mode. Has no effect in `dev()` mode.
|
|
97
|
+
*
|
|
98
|
+
* Receives the incoming request and should return `{ key, ttl? }` to cache
|
|
99
|
+
* the response, or `null`/`undefined` to skip caching for that request.
|
|
100
|
+
* `ttl` is the time-to-live in milliseconds; omit for entries that never expire.
|
|
101
|
+
* The function may be async.
|
|
102
|
+
*
|
|
103
|
+
* @example
|
|
104
|
+
* // Cache every page by pathname (no per-user personalisation):
|
|
105
|
+
* cache: (req) => ({ key: req.pathname })
|
|
106
|
+
*
|
|
107
|
+
* @example
|
|
108
|
+
* // Cache with a per-route TTL, skip authenticated requests:
|
|
109
|
+
* cache: (req) => req.cookies.session ? null : { key: req.pathname, ttl: 60_000 }
|
|
110
|
+
*/
|
|
111
|
+
cache?: (req: HadarsRequest) => {
|
|
112
|
+
key: string;
|
|
113
|
+
ttl?: number;
|
|
114
|
+
} | null | undefined | Promise<{
|
|
115
|
+
key: string;
|
|
116
|
+
ttl?: number;
|
|
117
|
+
} | null | undefined>;
|
|
95
118
|
}
|
|
96
119
|
type SwcPluginItem = string | [string, Record<string, unknown>] | {
|
|
97
120
|
path: string;
|
|
@@ -11,7 +11,7 @@ var _ssrMod = null;
|
|
|
11
11
|
async function init() {
|
|
12
12
|
if (_React && _ssrMod)
|
|
13
13
|
return;
|
|
14
|
-
const req = createRequire(pathMod.resolve(process.cwd(), "
|
|
14
|
+
const req = createRequire(pathMod.resolve(process.cwd(), "__hadars_fake__.js"));
|
|
15
15
|
if (!_React) {
|
|
16
16
|
const reactPath = pathToFileURL(req.resolve("react")).href;
|
|
17
17
|
const reactMod = await import(reactPath);
|
|
@@ -30,7 +30,7 @@ async function init() {
|
|
|
30
30
|
function deserializeRequest(s) {
|
|
31
31
|
const init2 = { method: s.method, headers: new Headers(s.headers) };
|
|
32
32
|
if (s.body)
|
|
33
|
-
init2.body = s.body;
|
|
33
|
+
init2.body = s.body.buffer;
|
|
34
34
|
const req = new Request(s.url, init2);
|
|
35
35
|
Object.assign(req, {
|
|
36
36
|
pathname: s.pathname,
|
package/dist/template.html
CHANGED
package/dist/utils/Head.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import type { AppContext, AppUnsuspend, LinkProps, MetaProps, ScriptProps, StyleProps } from '../types/
|
|
2
|
+
import type { AppContext, AppUnsuspend, LinkProps, MetaProps, ScriptProps, StyleProps } from '../types/hadars'
|
|
3
3
|
|
|
4
4
|
interface InnerContext {
|
|
5
5
|
setTitle: (title: string) => void;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { hydrateRoot, createRoot } from 'react-dom/client';
|
|
3
|
-
import type { HadarsEntryModule } from '../types/
|
|
3
|
+
import type { HadarsEntryModule } from '../types/hadars';
|
|
4
4
|
import { initServerDataCache } from 'hadars';
|
|
5
5
|
import * as _appMod from '$_MOD_PATH$';
|
|
6
6
|
|
package/index.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
// Bun entry point — re-exports the full public API from source so that
|
|
2
|
-
//
|
|
2
|
+
// hadars running TypeScript directly gets the same exports as
|
|
3
3
|
// the compiled `dist/index.js` used by Node.js / Deno.
|
|
4
4
|
export type {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
} from "./src/types/
|
|
15
|
-
export {
|
|
5
|
+
HadarsOptions,
|
|
6
|
+
HadarsProps,
|
|
7
|
+
HadarsRequest,
|
|
8
|
+
HadarsGetAfterRenderProps,
|
|
9
|
+
HadarsGetFinalProps,
|
|
10
|
+
HadarsGetInitialProps,
|
|
11
|
+
HadarsGetClientProps,
|
|
12
|
+
HadarsEntryModule,
|
|
13
|
+
HadarsApp,
|
|
14
|
+
} from "./src/types/hadars";
|
|
15
|
+
export { HadarsHead, HadarsContext, loadModule } 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.12",
|
|
4
4
|
"description": "Minimal SSR framework for React — rspack, HMR, TypeScript, Bun/Node/Deno",
|
|
5
5
|
"module": "./dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -52,8 +52,8 @@
|
|
|
52
52
|
"node": ">=18.0.0"
|
|
53
53
|
},
|
|
54
54
|
"peerDependencies": {
|
|
55
|
-
"react": "^19.
|
|
56
|
-
"react-dom": "^19.
|
|
55
|
+
"react": "^19.1.1",
|
|
56
|
+
"react-dom": "^19.1.1",
|
|
57
57
|
"typescript": "^5"
|
|
58
58
|
},
|
|
59
59
|
"peerDependenciesMeta": {
|
|
@@ -63,28 +63,24 @@
|
|
|
63
63
|
},
|
|
64
64
|
"optionalDependencies": {
|
|
65
65
|
"@rspack/core": "1.4.9",
|
|
66
|
-
"@rspack/dev-server": "^1.1
|
|
67
|
-
"@rspack/plugin-react-refresh": "^1.
|
|
68
|
-
"@swc/core": "^1.
|
|
66
|
+
"@rspack/dev-server": "^1.2.1",
|
|
67
|
+
"@rspack/plugin-react-refresh": "^1.6.1",
|
|
68
|
+
"@swc/core": "^1.15.18",
|
|
69
69
|
"@types/bun": "latest",
|
|
70
|
-
"
|
|
71
|
-
"react-refresh": "^0.17.0",
|
|
72
|
-
"typescript": "^5"
|
|
70
|
+
"react-refresh": "^0.17.0"
|
|
73
71
|
},
|
|
74
72
|
"devDependencies": {
|
|
75
|
-
"@types/react": "^19.
|
|
76
|
-
"@types/react-dom": "^19.
|
|
77
|
-
"esbuild": "^0.19.
|
|
78
|
-
"tsup": "^6.
|
|
73
|
+
"@types/react": "^19.2.14",
|
|
74
|
+
"@types/react-dom": "^19.2.3",
|
|
75
|
+
"esbuild": "^0.19.12",
|
|
76
|
+
"tsup": "^6.7.0",
|
|
77
|
+
"typescript": "^5.9.3"
|
|
79
78
|
},
|
|
80
79
|
"dependencies": {
|
|
81
80
|
"@mdx-js/loader": "^3.1.1",
|
|
82
|
-
"@mdx-js/react": "^3.1.1",
|
|
83
81
|
"@svgr/webpack": "^8.1.0",
|
|
84
|
-
"
|
|
85
|
-
"postcss": "^8.
|
|
86
|
-
"postcss-loader": "^8.2.0",
|
|
87
|
-
"tailwindcss": "^4.1.12"
|
|
82
|
+
"postcss": "^8.5.8",
|
|
83
|
+
"postcss-loader": "^8.2.1"
|
|
88
84
|
},
|
|
89
85
|
"license": "MIT",
|
|
90
86
|
"repository": {
|
package/src/build.ts
CHANGED
|
@@ -16,11 +16,11 @@ import { existsSync } from 'node:fs';
|
|
|
16
16
|
import os from 'node:os';
|
|
17
17
|
import { spawn } from 'node:child_process';
|
|
18
18
|
import cluster from 'node:cluster';
|
|
19
|
-
import type { HadarsEntryModule, HadarsOptions, HadarsProps } from "./types/
|
|
19
|
+
import type { HadarsEntryModule, HadarsOptions, HadarsProps } from "./types/hadars";
|
|
20
20
|
const encoder = new TextEncoder();
|
|
21
21
|
|
|
22
|
-
const HEAD_MARKER = '<meta name="
|
|
23
|
-
const BODY_MARKER = '<meta name="
|
|
22
|
+
const HEAD_MARKER = '<meta name="HADARS_HEAD">';
|
|
23
|
+
const BODY_MARKER = '<meta name="HADARS_BODY">';
|
|
24
24
|
|
|
25
25
|
// Resolve renderToString from react-dom/server in the project's node_modules.
|
|
26
26
|
let _renderToString: ((element: any) => string) | null = null;
|
|
@@ -230,6 +230,116 @@ const makePrecontentHtmlGetter = (htmlFilePromise: Promise<string>) => {
|
|
|
230
230
|
};
|
|
231
231
|
};
|
|
232
232
|
|
|
233
|
+
// ── SSR response cache ────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
interface CacheEntry {
|
|
236
|
+
/** Gzip-compressed response body — much cheaper to keep in RAM than raw HTML. */
|
|
237
|
+
body: Uint8Array;
|
|
238
|
+
status: number;
|
|
239
|
+
/** Headers with Content-Encoding: gzip already set. */
|
|
240
|
+
headers: [string, string][];
|
|
241
|
+
expiresAt: number | null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
type CacheFetchHandler = (req: Request, ctx: any) => Promise<Response | undefined>;
|
|
245
|
+
|
|
246
|
+
async function transformStream(data: Uint8Array, stream: { writable: WritableStream; readable: ReadableStream<Uint8Array> }): Promise<Uint8Array> {
|
|
247
|
+
const writer = stream.writable.getWriter();
|
|
248
|
+
writer.write(data);
|
|
249
|
+
writer.close();
|
|
250
|
+
const chunks: Uint8Array[] = [];
|
|
251
|
+
const reader = stream.readable.getReader();
|
|
252
|
+
while (true) {
|
|
253
|
+
const { done, value } = await reader.read();
|
|
254
|
+
if (done) break;
|
|
255
|
+
chunks.push(value);
|
|
256
|
+
}
|
|
257
|
+
const total = chunks.reduce((n, c) => n + c.length, 0);
|
|
258
|
+
const out = new Uint8Array(total);
|
|
259
|
+
let offset = 0;
|
|
260
|
+
for (const c of chunks) { out.set(c, offset); offset += c.length; }
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const gzipCompress = (d: Uint8Array) => transformStream(d, new (globalThis as any).CompressionStream('gzip'));
|
|
265
|
+
const gzipDecompress = (d: Uint8Array) => transformStream(d, new (globalThis as any).DecompressionStream('gzip'));
|
|
266
|
+
|
|
267
|
+
async function buildCacheEntry(res: Response, ttl: number | undefined): Promise<CacheEntry> {
|
|
268
|
+
const buf = await res.arrayBuffer();
|
|
269
|
+
const body = await gzipCompress(new Uint8Array(buf));
|
|
270
|
+
const headers: [string, string][] = [];
|
|
271
|
+
res.headers.forEach((v, k) => {
|
|
272
|
+
if (k.toLowerCase() !== 'content-encoding' && k.toLowerCase() !== 'content-length') {
|
|
273
|
+
headers.push([k, v]);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
headers.push(['content-encoding', 'gzip']);
|
|
277
|
+
return { body, status: res.status, headers, expiresAt: ttl != null ? Date.now() + ttl : null };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function serveFromEntry(entry: CacheEntry, req: Request): Promise<Response> {
|
|
281
|
+
const accept = req.headers.get('Accept-Encoding') ?? '';
|
|
282
|
+
if (accept.includes('gzip')) {
|
|
283
|
+
return new Response(entry.body.buffer as ArrayBuffer, { status: entry.status, headers: entry.headers });
|
|
284
|
+
}
|
|
285
|
+
// Client doesn't support gzip — decompress before serving
|
|
286
|
+
const plain = await gzipDecompress(entry.body);
|
|
287
|
+
const headers = entry.headers.filter(([k]) => k.toLowerCase() !== 'content-encoding');
|
|
288
|
+
return new Response(plain.buffer as ArrayBuffer, { status: entry.status, headers });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function createRenderCache(
|
|
292
|
+
opts: NonNullable<HadarsOptions['cache']>,
|
|
293
|
+
handler: CacheFetchHandler,
|
|
294
|
+
): CacheFetchHandler {
|
|
295
|
+
const store = new Map<string, CacheEntry>();
|
|
296
|
+
// Single-flight map: coalesces concurrent misses for the same key onto one render.
|
|
297
|
+
const inFlight = new Map<string, Promise<CacheEntry | null>>();
|
|
298
|
+
|
|
299
|
+
return async (req, ctx) => {
|
|
300
|
+
const hadarsReq = parseRequest(req);
|
|
301
|
+
const cacheOpts = await opts(hadarsReq);
|
|
302
|
+
const key = cacheOpts?.key ?? null;
|
|
303
|
+
|
|
304
|
+
if (key != null) {
|
|
305
|
+
const entry = store.get(key);
|
|
306
|
+
if (entry) {
|
|
307
|
+
const expired = entry.expiresAt != null && Date.now() >= entry.expiresAt;
|
|
308
|
+
if (!expired) {
|
|
309
|
+
return serveFromEntry(entry, req);
|
|
310
|
+
}
|
|
311
|
+
store.delete(key);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Single-flight: if a render for this key is already in progress, await
|
|
315
|
+
// it instead of starting a duplicate render (thundering herd prevention).
|
|
316
|
+
let flight = inFlight.get(key);
|
|
317
|
+
if (!flight) {
|
|
318
|
+
const ttl = cacheOpts?.ttl;
|
|
319
|
+
flight = handler(new Request(req), ctx)
|
|
320
|
+
.then(async (res) => {
|
|
321
|
+
if (!res || res.status < 200 || res.status >= 300 || res.headers.has('set-cookie')) {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
const newEntry = await buildCacheEntry(res, ttl);
|
|
325
|
+
store.set(key, newEntry);
|
|
326
|
+
return newEntry;
|
|
327
|
+
})
|
|
328
|
+
.catch(() => null)
|
|
329
|
+
.finally(() => inFlight.delete(key));
|
|
330
|
+
inFlight.set(key, flight);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const newEntry = await flight;
|
|
334
|
+
if (newEntry) return serveFromEntry(newEntry, req);
|
|
335
|
+
// Render was uncacheable (error, Set-Cookie, etc.) — fall through to a
|
|
336
|
+
// fresh independent render for this request so it still gets a response.
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return handler(req, ctx);
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
233
343
|
interface HadarsRuntimeOptions extends HadarsOptions {
|
|
234
344
|
mode: "development" | "production";
|
|
235
345
|
}
|
|
@@ -367,11 +477,13 @@ export const dev = async (options: HadarsRuntimeOptions) => {
|
|
|
367
477
|
}, clientCompiler as any);
|
|
368
478
|
|
|
369
479
|
console.log(`Starting HMR dev server on port ${hmrPort}`);
|
|
370
|
-
|
|
371
|
-
|
|
480
|
+
|
|
481
|
+
// Kick off client build — does NOT await here so SSR worker can start in parallel.
|
|
482
|
+
let clientResolved = false;
|
|
483
|
+
const clientBuildDone = new Promise<void>((resolve, reject) => {
|
|
372
484
|
(clientCompiler as any).hooks.done.tap('initial-build', (stats: any) => {
|
|
373
|
-
if (!
|
|
374
|
-
|
|
485
|
+
if (!clientResolved) {
|
|
486
|
+
clientResolved = true;
|
|
375
487
|
console.log(stats.toString({ colors: true }));
|
|
376
488
|
resolve();
|
|
377
489
|
}
|
|
@@ -382,6 +494,7 @@ export const dev = async (options: HadarsRuntimeOptions) => {
|
|
|
382
494
|
// Start SSR watcher in a separate process to avoid creating two rspack
|
|
383
495
|
// compiler instances in the same process. We use node:child_process.spawn
|
|
384
496
|
// which works on Bun, Node.js, and Deno (via compatibility layer).
|
|
497
|
+
// Spawned immediately so it compiles in parallel with the client build above.
|
|
385
498
|
const workerCmd = resolveWorkerCmd(packageDir);
|
|
386
499
|
console.log('Spawning SSR worker:', workerCmd.join(' '), 'entry:', entry);
|
|
387
500
|
|
|
@@ -411,36 +524,41 @@ export const dev = async (options: HadarsRuntimeOptions) => {
|
|
|
411
524
|
const marker = 'ssr-watch: initial-build-complete';
|
|
412
525
|
const rebuildMarker = 'ssr-watch: SSR rebuilt';
|
|
413
526
|
const decoder = new TextDecoder();
|
|
414
|
-
let gotMarker = false;
|
|
415
527
|
// Hoist so the async continuation loop below can keep using it.
|
|
416
528
|
let stdoutReader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
529
|
+
const ssrBuildDone = (async () => {
|
|
530
|
+
let gotMarker = false;
|
|
531
|
+
try {
|
|
532
|
+
stdoutReader = stdoutWebStream.getReader();
|
|
533
|
+
let buf = '';
|
|
534
|
+
const start = Date.now();
|
|
535
|
+
const timeoutMs = 20000;
|
|
536
|
+
while (Date.now() - start < timeoutMs) {
|
|
537
|
+
const { done, value } = await stdoutReader.read();
|
|
538
|
+
if (done) { stdoutReader = null; break; }
|
|
539
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
540
|
+
buf += chunk;
|
|
541
|
+
try { process.stdout.write(chunk); } catch (e) { /* ignore */ }
|
|
542
|
+
if (buf.includes(marker)) {
|
|
543
|
+
gotMarker = true;
|
|
544
|
+
break;
|
|
545
|
+
}
|
|
431
546
|
}
|
|
547
|
+
if (!gotMarker) {
|
|
548
|
+
console.warn('SSR worker did not signal initial build completion within timeout');
|
|
549
|
+
}
|
|
550
|
+
} catch (err) {
|
|
551
|
+
console.error('Error reading SSR worker output', err);
|
|
552
|
+
stdoutReader = null;
|
|
432
553
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
console.error('Error reading SSR worker output', err);
|
|
438
|
-
stdoutReader = null;
|
|
439
|
-
}
|
|
554
|
+
})();
|
|
555
|
+
|
|
556
|
+
// Wait for both client and SSR builds to finish in parallel.
|
|
557
|
+
await Promise.all([clientBuildDone, ssrBuildDone]);
|
|
440
558
|
|
|
441
559
|
// Continue reading stdout to forward logs and pick up SSR rebuild signals.
|
|
442
560
|
if (stdoutReader) {
|
|
443
|
-
const reader = stdoutReader
|
|
561
|
+
const reader = stdoutReader as ReadableStreamDefaultReader<Uint8Array>;
|
|
444
562
|
(async () => {
|
|
445
563
|
try {
|
|
446
564
|
while (true) {
|
|
@@ -487,7 +605,7 @@ export const dev = async (options: HadarsRuntimeOptions) => {
|
|
|
487
605
|
const url = new URL(request.url);
|
|
488
606
|
const path = url.pathname;
|
|
489
607
|
|
|
490
|
-
// static files in the
|
|
608
|
+
// static files in the hadars output folder
|
|
491
609
|
const staticRes = await tryServeFile(pathMod.join(__dirname, StaticPath, path));
|
|
492
610
|
if (staticRes) return staticRes;
|
|
493
611
|
|
|
@@ -639,7 +757,7 @@ export const run = async (options: HadarsRuntimeOptions) => {
|
|
|
639
757
|
fs.readFile(pathMod.join(__dirname, StaticPath, 'out.html'), 'utf-8')
|
|
640
758
|
);
|
|
641
759
|
|
|
642
|
-
|
|
760
|
+
const runHandler: CacheFetchHandler = async (req, ctx) => {
|
|
643
761
|
const request = parseRequest(req);
|
|
644
762
|
if (handler) {
|
|
645
763
|
const res = await handler(request);
|
|
@@ -653,7 +771,7 @@ export const run = async (options: HadarsRuntimeOptions) => {
|
|
|
653
771
|
const url = new URL(request.url);
|
|
654
772
|
const path = url.pathname;
|
|
655
773
|
|
|
656
|
-
// static files in the
|
|
774
|
+
// static files in the hadars output folder
|
|
657
775
|
const staticRes = await tryServeFile(pathMod.join(__dirname, StaticPath, path));
|
|
658
776
|
if (staticRes) return staticRes;
|
|
659
777
|
|
|
@@ -715,5 +833,11 @@ export const run = async (options: HadarsRuntimeOptions) => {
|
|
|
715
833
|
console.error('[hadars] SSR render error:', err);
|
|
716
834
|
return new Response('Internal Server Error', { status: 500 });
|
|
717
835
|
}
|
|
718
|
-
}
|
|
836
|
+
};
|
|
837
|
+
|
|
838
|
+
await serve(
|
|
839
|
+
port,
|
|
840
|
+
options.cache ? createRenderCache(options.cache, runHandler) : runHandler,
|
|
841
|
+
options.websocket,
|
|
842
|
+
);
|
|
719
843
|
};
|
package/src/index.tsx
CHANGED
|
@@ -8,7 +8,7 @@ export type {
|
|
|
8
8
|
HadarsGetClientProps,
|
|
9
9
|
HadarsEntryModule,
|
|
10
10
|
HadarsApp,
|
|
11
|
-
} from "./types/
|
|
11
|
+
} from "./types/hadars";
|
|
12
12
|
export { Head as HadarsHead, useServerData, initServerDataCache } from './utils/Head';
|
|
13
13
|
import { AppProviderSSR, AppProviderCSR } from "./utils/Head";
|
|
14
14
|
|
package/src/ssr-render-worker.ts
CHANGED
|
@@ -30,7 +30,7 @@ let _ssrMod: any = null;
|
|
|
30
30
|
async function init() {
|
|
31
31
|
if (_React && _ssrMod) return;
|
|
32
32
|
|
|
33
|
-
const req = createRequire(pathMod.resolve(process.cwd(), '
|
|
33
|
+
const req = createRequire(pathMod.resolve(process.cwd(), '__hadars_fake__.js'));
|
|
34
34
|
|
|
35
35
|
if (!_React) {
|
|
36
36
|
const reactPath = pathToFileURL(req.resolve('react')).href;
|
|
@@ -63,7 +63,7 @@ export type SerializableRequest = {
|
|
|
63
63
|
|
|
64
64
|
function deserializeRequest(s: SerializableRequest): any {
|
|
65
65
|
const init: RequestInit = { method: s.method, headers: new Headers(s.headers) };
|
|
66
|
-
if (s.body) init.body = s.body;
|
|
66
|
+
if (s.body) init.body = s.body.buffer as ArrayBuffer;
|
|
67
67
|
const req = new Request(s.url, init);
|
|
68
68
|
Object.assign(req, {
|
|
69
69
|
pathname: s.pathname,
|
|
@@ -93,6 +93,24 @@ export interface HadarsOptions {
|
|
|
93
93
|
* Has no effect on the SSR bundle or dev mode.
|
|
94
94
|
*/
|
|
95
95
|
optimization?: Record<string, unknown>;
|
|
96
|
+
/**
|
|
97
|
+
* SSR response cache for `run()` mode. Has no effect in `dev()` mode.
|
|
98
|
+
*
|
|
99
|
+
* Receives the incoming request and should return `{ key, ttl? }` to cache
|
|
100
|
+
* the response, or `null`/`undefined` to skip caching for that request.
|
|
101
|
+
* `ttl` is the time-to-live in milliseconds; omit for entries that never expire.
|
|
102
|
+
* The function may be async.
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* // Cache every page by pathname (no per-user personalisation):
|
|
106
|
+
* cache: (req) => ({ key: req.pathname })
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* // Cache with a per-route TTL, skip authenticated requests:
|
|
110
|
+
* cache: (req) => req.cookies.session ? null : { key: req.pathname, ttl: 60_000 }
|
|
111
|
+
*/
|
|
112
|
+
cache?: (req: HadarsRequest) => { key: string; ttl?: number } | null | undefined
|
|
113
|
+
| Promise<{ key: string; ttl?: number } | null | undefined>;
|
|
96
114
|
}
|
|
97
115
|
|
|
98
116
|
|
package/src/utils/Head.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import type { AppContext, AppUnsuspend, LinkProps, MetaProps, ScriptProps, StyleProps } from '../types/
|
|
2
|
+
import type { AppContext, AppUnsuspend, LinkProps, MetaProps, ScriptProps, StyleProps } from '../types/hadars'
|
|
3
3
|
|
|
4
4
|
interface InnerContext {
|
|
5
5
|
setTitle: (title: string) => void;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { hydrateRoot, createRoot } from 'react-dom/client';
|
|
3
|
-
import type { HadarsEntryModule } from '../types/
|
|
3
|
+
import type { HadarsEntryModule } from '../types/hadars';
|
|
4
4
|
import { initServerDataCache } from 'hadars';
|
|
5
5
|
import * as _appMod from '$_MOD_PATH$';
|
|
6
6
|
|
package/src/utils/request.tsx
CHANGED
package/src/utils/response.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import type React from "react";
|
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import pathMod from "node:path";
|
|
4
4
|
import { pathToFileURL } from "node:url";
|
|
5
|
-
import type { AppHead, AppUnsuspend, HadarsRequest, HadarsEntryBase, HadarsEntryModule, HadarsProps, AppContext } from "../types/
|
|
5
|
+
import type { AppHead, AppUnsuspend, HadarsRequest, HadarsEntryBase, HadarsEntryModule, HadarsProps, AppContext } from "../types/hadars";
|
|
6
6
|
|
|
7
7
|
// Resolve react-dom/server from the *project's* node_modules (process.cwd()) so
|
|
8
8
|
// the same React instance is used here as in the SSR bundle. Without this,
|
|
@@ -33,8 +33,8 @@ interface ReactResponseOptions {
|
|
|
33
33
|
// ── Head HTML serialisation (no React render needed) ─────────────────────────
|
|
34
34
|
|
|
35
35
|
const ESC: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"' };
|
|
36
|
-
const escAttr = (s: string) => s.replace(/[&<>"]/g, c => ESC[c]);
|
|
37
|
-
const escText = (s: string) => s.replace(/[&<>]/g, c => ESC[c]);
|
|
36
|
+
const escAttr = (s: string) => s.replace(/[&<>"]/g, c => ESC[c] ?? c);
|
|
37
|
+
const escText = (s: string) => s.replace(/[&<>]/g, c => ESC[c] ?? c);
|
|
38
38
|
|
|
39
39
|
// React prop → HTML attribute name for the subset used in head tags.
|
|
40
40
|
const ATTR: Record<string, string> = {
|
package/src/utils/rspack.ts
CHANGED
|
@@ -2,7 +2,7 @@ import rspack from "@rspack/core";
|
|
|
2
2
|
import type { Configuration, RuleSetLoaderWithOptions, RuleSetRule } from "@rspack/core";
|
|
3
3
|
import ReactRefreshPlugin from '@rspack/plugin-react-refresh';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import type { SwcPluginList } from '../types/
|
|
5
|
+
import type { SwcPluginList } from '../types/hadars';
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import pathMod from "node:path";
|
|
8
8
|
import { existsSync } from "node:fs";
|
package/src/utils/staticFile.ts
CHANGED
|
@@ -41,7 +41,7 @@ export async function tryServeFile(filePath: string): Promise<Response | null> {
|
|
|
41
41
|
const data = await readFile(filePath);
|
|
42
42
|
const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
|
|
43
43
|
const contentType = MIME[ext] ?? 'application/octet-stream';
|
|
44
|
-
return new Response(data, { headers: { 'Content-Type': contentType } });
|
|
44
|
+
return new Response(data.buffer as ArrayBuffer, { headers: { 'Content-Type': contentType } });
|
|
45
45
|
} catch {
|
|
46
46
|
return null;
|
|
47
47
|
}
|
package/src/utils/template.html
CHANGED