hadars 0.1.1
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 +21 -0
- package/README.md +118 -0
- package/cli-bun.ts +13 -0
- package/cli-lib.ts +203 -0
- package/cli.ts +13 -0
- package/dist/cli.js +1441 -0
- package/dist/index.cjs +303 -0
- package/dist/index.d.ts +160 -0
- package/dist/index.js +263 -0
- package/dist/loader.cjs +34 -0
- package/dist/ssr-render-worker.js +92 -0
- package/dist/ssr-watch.js +345 -0
- package/dist/template.html +11 -0
- package/dist/utils/clientScript.tsx +58 -0
- package/index.ts +15 -0
- package/package.json +99 -0
- package/src/build.ts +716 -0
- package/src/index.tsx +41 -0
- package/src/ssr-render-worker.ts +138 -0
- package/src/ssr-watch.ts +56 -0
- package/src/types/global.d.ts +5 -0
- package/src/types/ninety.ts +116 -0
- package/src/utils/Head.tsx +357 -0
- package/src/utils/clientScript.tsx +58 -0
- package/src/utils/cookies.ts +16 -0
- package/src/utils/loadModule.ts +4 -0
- package/src/utils/loader.ts +41 -0
- package/src/utils/proxyHandler.tsx +101 -0
- package/src/utils/request.tsx +9 -0
- package/src/utils/response.tsx +198 -0
- package/src/utils/rspack.ts +359 -0
- package/src/utils/runtime.ts +19 -0
- package/src/utils/serve.ts +140 -0
- package/src/utils/staticFile.ts +48 -0
- package/src/utils/template.html +11 -0
- package/src/utils/upgradeRequest.tsx +19 -0
package/src/build.ts
ADDED
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
import { createProxyHandler } from "./utils/proxyHandler";
|
|
2
|
+
import { parseRequest } from "./utils/request";
|
|
3
|
+
import { upgradeHandler } from "./utils/upgradeRequest";
|
|
4
|
+
import { getReactResponse } from "./utils/response";
|
|
5
|
+
import { createClientCompiler, compileEntry } from "./utils/rspack";
|
|
6
|
+
import { serve, nodeReadableToWebStream } from "./utils/serve";
|
|
7
|
+
import { tryServeFile } from "./utils/staticFile";
|
|
8
|
+
import { isBun, isDeno, isNode } from "./utils/runtime";
|
|
9
|
+
import { RspackDevServer } from "@rspack/dev-server";
|
|
10
|
+
import pathMod from "node:path";
|
|
11
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
import crypto from 'node:crypto';
|
|
14
|
+
import fs from 'node:fs/promises';
|
|
15
|
+
import { existsSync } from 'node:fs';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
import cluster from 'node:cluster';
|
|
19
|
+
import type { HadarsEntryModule, HadarsOptions, HadarsProps } from "./types/ninety";
|
|
20
|
+
|
|
21
|
+
const encoder = new TextEncoder();
|
|
22
|
+
|
|
23
|
+
const HEAD_MARKER = '<meta name="NINETY_HEAD">';
|
|
24
|
+
const BODY_MARKER = '<meta name="NINETY_BODY">';
|
|
25
|
+
|
|
26
|
+
// Resolve react-dom/server.browser from the *project's* node_modules (process.cwd())
|
|
27
|
+
// rather than from hadars's own install location. This guarantees the same React
|
|
28
|
+
// instance is used here and in the SSR bundle (which is also built relative to cwd),
|
|
29
|
+
// preventing "Invalid hook call" errors when hadars is installed as a file: symlink.
|
|
30
|
+
let _renderToReadableStream: ((element: any, options?: any) => Promise<ReadableStream<Uint8Array>>) | null = null;
|
|
31
|
+
async function getReadableStreamRenderer(): Promise<(element: any, options?: any) => Promise<ReadableStream<Uint8Array>>> {
|
|
32
|
+
if (!_renderToReadableStream) {
|
|
33
|
+
const req = createRequire(pathMod.resolve(process.cwd(), '__hadars_fake__.js'));
|
|
34
|
+
const resolved = req.resolve('react-dom/server.browser');
|
|
35
|
+
const mod = await import(pathToFileURL(resolved).href);
|
|
36
|
+
_renderToReadableStream = mod.renderToReadableStream;
|
|
37
|
+
}
|
|
38
|
+
return _renderToReadableStream!;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Resolve renderToString from react-dom/server in the project's node_modules.
|
|
42
|
+
// Used when streaming is disabled via `streaming: false` in hadars config.
|
|
43
|
+
let _renderToString: ((element: any) => string) | null = null;
|
|
44
|
+
async function getRenderToString(): Promise<(element: any) => string> {
|
|
45
|
+
if (!_renderToString) {
|
|
46
|
+
const req = createRequire(pathMod.resolve(process.cwd(), '__hadars_fake__.js'));
|
|
47
|
+
const resolved = req.resolve('react-dom/server');
|
|
48
|
+
const mod = await import(pathToFileURL(resolved).href);
|
|
49
|
+
_renderToString = mod.renderToString;
|
|
50
|
+
}
|
|
51
|
+
return _renderToString!;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Round-robin thread pool for SSR rendering — used on Bun/Deno where
|
|
55
|
+
// node:cluster is not available but node:worker_threads is.
|
|
56
|
+
// Supports three render modes matching the worker's message protocol:
|
|
57
|
+
// staticMarkup — renderToStaticMarkup for lifecycle passes in getReactResponse
|
|
58
|
+
// renderString — renderToString for non-streaming responses
|
|
59
|
+
// renderStream — renderToReadableStream chunks forwarded as a ReadableStream
|
|
60
|
+
|
|
61
|
+
type PendingRenderString = {
|
|
62
|
+
kind: 'renderString';
|
|
63
|
+
resolve: (html: string) => void;
|
|
64
|
+
reject: (err: Error) => void;
|
|
65
|
+
};
|
|
66
|
+
type PendingRenderStream = {
|
|
67
|
+
kind: 'renderStream';
|
|
68
|
+
controller: ReadableStreamDefaultController<Uint8Array>;
|
|
69
|
+
};
|
|
70
|
+
type PendingEntry = PendingRenderString | PendingRenderStream;
|
|
71
|
+
|
|
72
|
+
class RenderWorkerPool {
|
|
73
|
+
private workers: any[] = [];
|
|
74
|
+
private pending = new Map<number, PendingEntry>();
|
|
75
|
+
private nextId = 0;
|
|
76
|
+
private rrIndex = 0;
|
|
77
|
+
|
|
78
|
+
constructor(workerPath: string, size: number, ssrBundlePath: string) {
|
|
79
|
+
// Dynamically import Worker so this class can be defined at module load
|
|
80
|
+
// time without a top-level await.
|
|
81
|
+
this._init(workerPath, size, ssrBundlePath);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private _init(workerPath: string, size: number, ssrBundlePath: string) {
|
|
85
|
+
import('node:worker_threads').then(({ Worker }) => {
|
|
86
|
+
for (let i = 0; i < size; i++) {
|
|
87
|
+
const w = new Worker(workerPath, { workerData: { ssrBundlePath } });
|
|
88
|
+
w.on('message', (msg: any) => {
|
|
89
|
+
const { id, type, html, error, chunk } = msg;
|
|
90
|
+
const p = this.pending.get(id);
|
|
91
|
+
if (!p) return;
|
|
92
|
+
|
|
93
|
+
if (p.kind === 'renderStream') {
|
|
94
|
+
if (type === 'chunk') {
|
|
95
|
+
p.controller.enqueue(chunk as Uint8Array);
|
|
96
|
+
return; // keep entry until 'done'
|
|
97
|
+
}
|
|
98
|
+
this.pending.delete(id);
|
|
99
|
+
if (type === 'done') p.controller.close();
|
|
100
|
+
else p.controller.error(new Error(error ?? 'Stream error'));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// renderString
|
|
105
|
+
this.pending.delete(id);
|
|
106
|
+
if (error) p.reject(new Error(error));
|
|
107
|
+
else p.resolve(html);
|
|
108
|
+
});
|
|
109
|
+
w.on('error', (err: Error) => {
|
|
110
|
+
console.error('[hadars] Render worker error:', err);
|
|
111
|
+
});
|
|
112
|
+
this.workers.push(w);
|
|
113
|
+
}
|
|
114
|
+
}).catch(err => {
|
|
115
|
+
console.error('[hadars] Failed to initialise render worker pool:', err);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private nextWorker() {
|
|
120
|
+
const w = this.workers[this.rrIndex % this.workers.length];
|
|
121
|
+
this.rrIndex++;
|
|
122
|
+
return w;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Offload a full renderToString call. Returns the HTML string. */
|
|
126
|
+
renderString(appProps: Record<string, unknown>, clientProps: Record<string, unknown>): Promise<string> {
|
|
127
|
+
return new Promise((resolve, reject) => {
|
|
128
|
+
const id = this.nextId++;
|
|
129
|
+
this.pending.set(id, { kind: 'renderString', resolve, reject });
|
|
130
|
+
this.nextWorker().postMessage({ id, type: 'renderString', appProps, clientProps });
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Offload a renderToReadableStream call. Returns a ReadableStream fed by
|
|
135
|
+
* worker chunk messages. */
|
|
136
|
+
renderStream(appProps: Record<string, unknown>, clientProps: Record<string, unknown>): ReadableStream<Uint8Array> {
|
|
137
|
+
let controller!: ReadableStreamDefaultController<Uint8Array>;
|
|
138
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
139
|
+
start: (ctrl) => { controller = ctrl; },
|
|
140
|
+
});
|
|
141
|
+
const id = this.nextId++;
|
|
142
|
+
// Store controller before postMessage so the handler is ready when
|
|
143
|
+
// the first chunk arrives.
|
|
144
|
+
this.pending.set(id, { kind: 'renderStream', controller });
|
|
145
|
+
this.nextWorker().postMessage({ id, type: 'renderStream', appProps, clientProps });
|
|
146
|
+
return stream;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async terminate(): Promise<void> {
|
|
150
|
+
await Promise.all(this.workers.map((w: any) => w.terminate()));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function buildSsrResponse(
|
|
155
|
+
ReactPage: any,
|
|
156
|
+
headHtml: string,
|
|
157
|
+
status: number,
|
|
158
|
+
getPrecontentHtml: (headHtml: string) => Promise<[string, string]>,
|
|
159
|
+
streaming: boolean,
|
|
160
|
+
renderPool?: RenderWorkerPool,
|
|
161
|
+
renderPayload?: { appProps: Record<string, unknown>; clientProps: Record<string, unknown> },
|
|
162
|
+
): Promise<Response> {
|
|
163
|
+
// Resolve renderers before touching globalThis.__hadarsUnsuspend.
|
|
164
|
+
// Any await after setting the global would create a window where a concurrent
|
|
165
|
+
// request on the same thread could overwrite it before the render call runs.
|
|
166
|
+
// Loading the renderer first ensures the set→call→clear sequence is synchronous.
|
|
167
|
+
const renderToString = (!streaming && !renderPool) ? await getRenderToString() : null;
|
|
168
|
+
const renderReadableStream = (streaming && !renderPool) ? await getReadableStreamRenderer() : null;
|
|
169
|
+
|
|
170
|
+
// Extract the unsuspend cache from renderPayload so non-pool renders can expose
|
|
171
|
+
// resolved useServerData() values to the SSR bundle via globalThis.__hadarsUnsuspend.
|
|
172
|
+
const unsuspendForRender = (renderPayload?.appProps?.context as any)?._unsuspend ?? null;
|
|
173
|
+
|
|
174
|
+
if (!streaming) {
|
|
175
|
+
const [precontentHtml, postContent] = await getPrecontentHtml(headHtml);
|
|
176
|
+
let bodyHtml: string;
|
|
177
|
+
if (renderPool && renderPayload) {
|
|
178
|
+
bodyHtml = await renderPool.renderString(renderPayload.appProps, renderPayload.clientProps);
|
|
179
|
+
} else {
|
|
180
|
+
// set → call (synchronous) → clear: no await in between, safe under concurrency
|
|
181
|
+
try {
|
|
182
|
+
(globalThis as any).__hadarsUnsuspend = unsuspendForRender;
|
|
183
|
+
bodyHtml = renderToString!(ReactPage);
|
|
184
|
+
} finally {
|
|
185
|
+
(globalThis as any).__hadarsUnsuspend = null;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return new Response(precontentHtml + bodyHtml + postContent, {
|
|
189
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
190
|
+
status,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const responseStream = new ReadableStream({
|
|
195
|
+
async start(controller) {
|
|
196
|
+
const [precontentHtml, postContent] = await getPrecontentHtml(headHtml);
|
|
197
|
+
controller.enqueue(encoder.encode(precontentHtml));
|
|
198
|
+
|
|
199
|
+
let bodyStream: ReadableStream<Uint8Array>;
|
|
200
|
+
if (renderPool && renderPayload) {
|
|
201
|
+
bodyStream = renderPool.renderStream(renderPayload.appProps, renderPayload.clientProps);
|
|
202
|
+
} else {
|
|
203
|
+
// React's renderToReadableStream starts rendering synchronously on call,
|
|
204
|
+
// so hooks fire before the returned Promise settles. Set the global
|
|
205
|
+
// immediately before the call and clear right after — no await in between.
|
|
206
|
+
let streamPromise: Promise<ReadableStream<Uint8Array>>;
|
|
207
|
+
try {
|
|
208
|
+
(globalThis as any).__hadarsUnsuspend = unsuspendForRender;
|
|
209
|
+
streamPromise = renderReadableStream!(ReactPage);
|
|
210
|
+
} finally {
|
|
211
|
+
(globalThis as any).__hadarsUnsuspend = null;
|
|
212
|
+
}
|
|
213
|
+
bodyStream = await streamPromise;
|
|
214
|
+
}
|
|
215
|
+
const reader = bodyStream.getReader();
|
|
216
|
+
while (true) {
|
|
217
|
+
const { done, value } = await reader.read();
|
|
218
|
+
if (done) break;
|
|
219
|
+
controller.enqueue(value);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
controller.enqueue(encoder.encode(postContent));
|
|
223
|
+
controller.close();
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
return new Response(responseStream, {
|
|
228
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
229
|
+
status,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Returns a function that parses `out.html` into pre-head, post-head, and
|
|
235
|
+
* post-content segments and caches the result. Call the returned function with
|
|
236
|
+
* the per-request headHtml to produce the full HTML prefix and suffix.
|
|
237
|
+
*/
|
|
238
|
+
const makePrecontentHtmlGetter = (htmlFilePromise: Promise<string>) => {
|
|
239
|
+
let preHead: string | null = null;
|
|
240
|
+
let postHead: string | null = null;
|
|
241
|
+
let postContent: string | null = null;
|
|
242
|
+
return async (headHtml: string): Promise<[string, string]> => {
|
|
243
|
+
if (preHead === null || postHead === null || postContent === null) {
|
|
244
|
+
const html = await htmlFilePromise;
|
|
245
|
+
const headEnd = html.indexOf(HEAD_MARKER);
|
|
246
|
+
const contentStart = html.indexOf(BODY_MARKER);
|
|
247
|
+
preHead = html.slice(0, headEnd);
|
|
248
|
+
postHead = html.slice(headEnd + HEAD_MARKER.length, contentStart);
|
|
249
|
+
postContent = html.slice(contentStart + BODY_MARKER.length);
|
|
250
|
+
}
|
|
251
|
+
return [preHead! + headHtml + postHead!, postContent!];
|
|
252
|
+
};
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
interface HadarsRuntimeOptions extends HadarsOptions {
|
|
256
|
+
mode: "development" | "production";
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const SSR_FILENAME = 'index.ssr.js';
|
|
260
|
+
const __dirname = process.cwd();
|
|
261
|
+
|
|
262
|
+
type Mode = "development" | "production";
|
|
263
|
+
|
|
264
|
+
const getSuffix = (mode: Mode) => mode === 'development' ? `?v=${Date.now()}` : '';
|
|
265
|
+
|
|
266
|
+
const HadarsFolder = './.hadars';
|
|
267
|
+
const StaticPath = `${HadarsFolder}/static`;
|
|
268
|
+
|
|
269
|
+
const validateOptions = (options: HadarsRuntimeOptions) => {
|
|
270
|
+
if (!options.entry) {
|
|
271
|
+
throw new Error("Entry file is required");
|
|
272
|
+
}
|
|
273
|
+
if (options.mode !== 'development' && options.mode !== 'production') {
|
|
274
|
+
throw new Error("Mode must be either 'development' or 'production'");
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Resolves the SSR worker script and the command used to run it.
|
|
280
|
+
*
|
|
281
|
+
* Four modes:
|
|
282
|
+
* 1. Bun (source) — `bun ssr-watch.ts`
|
|
283
|
+
* 2. Deno (source) — `deno run --allow-all ssr-watch.ts`
|
|
284
|
+
* 3. Node.js (source) — tsx/ts-node detected via execArgv; used when the
|
|
285
|
+
* caller itself was launched by a TS runner (e.g. `npx tsx cli.ts dev`)
|
|
286
|
+
* 4. Node.js (compiled) — `node ssr-watch.js` (post `npm run build:cli`)
|
|
287
|
+
*/
|
|
288
|
+
const resolveWorkerCmd = (packageDir: string): string[] => {
|
|
289
|
+
const tsPath = pathMod.resolve(packageDir, 'ssr-watch.ts');
|
|
290
|
+
const jsPath = pathMod.resolve(packageDir, 'ssr-watch.js');
|
|
291
|
+
|
|
292
|
+
if (isBun && existsSync(tsPath)) {
|
|
293
|
+
return ['bun', tsPath];
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (isDeno && existsSync(tsPath)) {
|
|
297
|
+
return ['deno', 'run', '--allow-all', tsPath];
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Detect if the current process was launched by a Node.js TypeScript runner
|
|
301
|
+
// (tsx, ts-node). Modern tsx injects itself via --import into execArgv;
|
|
302
|
+
// older versions appear in argv[1]. ts-node works similarly.
|
|
303
|
+
if (existsSync(tsPath)) {
|
|
304
|
+
const allArgs = [...process.execArgv, process.argv[1] ?? ''];
|
|
305
|
+
const hasTsx = allArgs.some(a => a.includes('tsx'));
|
|
306
|
+
const hasTsNode = allArgs.some(a => a.includes('ts-node'));
|
|
307
|
+
if (hasTsx) return ['tsx', tsPath];
|
|
308
|
+
if (hasTsNode) return ['ts-node', tsPath];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (existsSync(jsPath)) {
|
|
312
|
+
return ['node', jsPath];
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
throw new Error(
|
|
316
|
+
`[hadars] SSR worker not found. Expected:\n` +
|
|
317
|
+
` ${jsPath}\n` +
|
|
318
|
+
`Run "npm run build:cli" to compile it, or launch hadars via a TypeScript runner:\n` +
|
|
319
|
+
` npx tsx cli.ts dev`
|
|
320
|
+
);
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
export const dev = async (options: HadarsRuntimeOptions) => {
|
|
324
|
+
|
|
325
|
+
// clean .hadars
|
|
326
|
+
await fs.rm(HadarsFolder, { recursive: true, force: true });
|
|
327
|
+
|
|
328
|
+
let { port = 9090, baseURL = '' } = options;
|
|
329
|
+
|
|
330
|
+
console.log(`Starting Hadars on port ${port}`);
|
|
331
|
+
|
|
332
|
+
validateOptions(options);
|
|
333
|
+
const handleProxy = createProxyHandler(options);
|
|
334
|
+
const handleWS = upgradeHandler(options);
|
|
335
|
+
const handler = options.fetch;
|
|
336
|
+
|
|
337
|
+
const entry = pathMod.resolve(__dirname, options.entry);
|
|
338
|
+
const hmrPort = options.hmrPort ?? port + 1;
|
|
339
|
+
|
|
340
|
+
// prepare client script once (we will compile into StaticPath)
|
|
341
|
+
const packageDir = pathMod.dirname(fileURLToPath(import.meta.url));
|
|
342
|
+
const clientScriptPath = pathMod.resolve(packageDir, 'utils', 'clientScript.tsx');
|
|
343
|
+
|
|
344
|
+
const headPath = pathMod.resolve(packageDir, 'utils', 'Head');
|
|
345
|
+
|
|
346
|
+
let clientScript = '';
|
|
347
|
+
try {
|
|
348
|
+
clientScript = (await fs.readFile(clientScriptPath, 'utf-8'))
|
|
349
|
+
.replace('$_MOD_PATH$', entry + getSuffix(options.mode))
|
|
350
|
+
.replace('$_HEAD_PATH$', headPath);
|
|
351
|
+
}
|
|
352
|
+
catch (err) {
|
|
353
|
+
console.error("Failed to read client script from package dist, falling back to src", err);
|
|
354
|
+
throw err;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const tmpFilePath = pathMod.join(os.tmpdir(), `hadars-client-${Date.now()}.tsx`);
|
|
358
|
+
await fs.writeFile(tmpFilePath, clientScript);
|
|
359
|
+
|
|
360
|
+
// SSR live-reload id to force re-import
|
|
361
|
+
let ssrBuildId = Date.now();
|
|
362
|
+
|
|
363
|
+
// Start rspack-dev-server for the client bundle. It provides true React
|
|
364
|
+
// Fast Refresh HMR: the browser's HMR runtime connects directly to the
|
|
365
|
+
// dev server's WebSocket on hmrPort and receives module-level patches
|
|
366
|
+
// without full page reloads. writeToDisk lets the server serve the
|
|
367
|
+
// initial index.js and out.html from disk.
|
|
368
|
+
const clientCompiler = createClientCompiler(tmpFilePath, {
|
|
369
|
+
target: 'web',
|
|
370
|
+
output: {
|
|
371
|
+
filename: "index.js",
|
|
372
|
+
path: pathMod.resolve(__dirname, StaticPath),
|
|
373
|
+
},
|
|
374
|
+
base: baseURL,
|
|
375
|
+
mode: 'development',
|
|
376
|
+
swcPlugins: options.swcPlugins,
|
|
377
|
+
define: options.define,
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const devServer = new RspackDevServer({
|
|
381
|
+
port: hmrPort,
|
|
382
|
+
hot: true,
|
|
383
|
+
liveReload: false,
|
|
384
|
+
client: {
|
|
385
|
+
webSocketURL: `ws://localhost:${hmrPort}/ws`,
|
|
386
|
+
},
|
|
387
|
+
devMiddleware: {
|
|
388
|
+
writeToDisk: true,
|
|
389
|
+
},
|
|
390
|
+
headers: { 'Access-Control-Allow-Origin': '*' },
|
|
391
|
+
allowedHosts: 'all',
|
|
392
|
+
}, clientCompiler as any);
|
|
393
|
+
|
|
394
|
+
console.log(`Starting HMR dev server on port ${hmrPort}`);
|
|
395
|
+
await new Promise<void>((resolve, reject) => {
|
|
396
|
+
let resolved = false;
|
|
397
|
+
(clientCompiler as any).hooks.done.tap('initial-build', (stats: any) => {
|
|
398
|
+
if (!resolved) {
|
|
399
|
+
resolved = true;
|
|
400
|
+
console.log(stats.toString({ colors: true }));
|
|
401
|
+
resolve();
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
devServer.start().catch(reject);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// Start SSR watcher in a separate process to avoid creating two rspack
|
|
408
|
+
// compiler instances in the same process. We use node:child_process.spawn
|
|
409
|
+
// which works on Bun, Node.js, and Deno (via compatibility layer).
|
|
410
|
+
const workerCmd = resolveWorkerCmd(packageDir);
|
|
411
|
+
console.log('Spawning SSR worker:', workerCmd.join(' '), 'entry:', entry);
|
|
412
|
+
|
|
413
|
+
const child = spawn(workerCmd[0]!, [
|
|
414
|
+
...workerCmd.slice(1),
|
|
415
|
+
`--entry=${entry}`,
|
|
416
|
+
`--outDir=${HadarsFolder}`,
|
|
417
|
+
`--outFile=${SSR_FILENAME}`,
|
|
418
|
+
`--base=${baseURL}`,
|
|
419
|
+
], { stdio: 'pipe' });
|
|
420
|
+
child.stdin?.end();
|
|
421
|
+
|
|
422
|
+
// Convert Node.js Readable streams to Web ReadableStream so the rest of
|
|
423
|
+
// the logic works identically across all runtimes.
|
|
424
|
+
const stdoutWebStream = nodeReadableToWebStream(child.stdout!);
|
|
425
|
+
const stderrWebStream = nodeReadableToWebStream(child.stderr!);
|
|
426
|
+
|
|
427
|
+
// Wait for worker to emit the initial build completion marker.
|
|
428
|
+
const marker = 'ssr-watch: initial-build-complete';
|
|
429
|
+
const rebuildMarker = 'ssr-watch: SSR rebuilt';
|
|
430
|
+
const decoder = new TextDecoder();
|
|
431
|
+
let gotMarker = false;
|
|
432
|
+
// Hoist so the async continuation loop below can keep using it.
|
|
433
|
+
let stdoutReader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
|
434
|
+
try {
|
|
435
|
+
stdoutReader = stdoutWebStream.getReader();
|
|
436
|
+
let buf = '';
|
|
437
|
+
const start = Date.now();
|
|
438
|
+
const timeoutMs = 20000;
|
|
439
|
+
while (Date.now() - start < timeoutMs) {
|
|
440
|
+
const { done, value } = await stdoutReader.read();
|
|
441
|
+
if (done) { stdoutReader = null; break; }
|
|
442
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
443
|
+
buf += chunk;
|
|
444
|
+
try { process.stdout.write(chunk); } catch (e) { /* ignore */ }
|
|
445
|
+
if (buf.includes(marker)) {
|
|
446
|
+
gotMarker = true;
|
|
447
|
+
break;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (!gotMarker) {
|
|
451
|
+
console.warn('SSR worker did not signal initial build completion within timeout');
|
|
452
|
+
}
|
|
453
|
+
} catch (err) {
|
|
454
|
+
console.error('Error reading SSR worker output', err);
|
|
455
|
+
stdoutReader = null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Continue reading stdout to forward logs and pick up SSR rebuild signals.
|
|
459
|
+
if (stdoutReader) {
|
|
460
|
+
const reader = stdoutReader;
|
|
461
|
+
(async () => {
|
|
462
|
+
try {
|
|
463
|
+
while (true) {
|
|
464
|
+
const { done, value } = await reader.read();
|
|
465
|
+
if (done) break;
|
|
466
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
467
|
+
try { process.stdout.write(chunk); } catch (e) { }
|
|
468
|
+
if (chunk.includes(rebuildMarker)) {
|
|
469
|
+
ssrBuildId = Date.now();
|
|
470
|
+
console.log('[hadars] SSR bundle updated, build id:', ssrBuildId);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
} catch (e) { }
|
|
474
|
+
})();
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Forward stderr asynchronously
|
|
478
|
+
(async () => {
|
|
479
|
+
try {
|
|
480
|
+
const r = stderrWebStream.getReader();
|
|
481
|
+
while (true) {
|
|
482
|
+
const { done, value } = await r.read();
|
|
483
|
+
if (done) break;
|
|
484
|
+
try { process.stderr.write(decoder.decode(value)); } catch (e) { }
|
|
485
|
+
}
|
|
486
|
+
} catch (e) { }
|
|
487
|
+
})();
|
|
488
|
+
|
|
489
|
+
const getPrecontentHtml = makePrecontentHtmlGetter(
|
|
490
|
+
fs.readFile(pathMod.join(__dirname, StaticPath, 'out.html'), 'utf-8')
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
await serve(port, async (req, ctx) => {
|
|
494
|
+
const request = parseRequest(req);
|
|
495
|
+
if (handler) {
|
|
496
|
+
const res = await handler(request);
|
|
497
|
+
if (res) return res;
|
|
498
|
+
}
|
|
499
|
+
if (handleWS && handleWS(request, ctx)) return undefined;
|
|
500
|
+
|
|
501
|
+
const proxied = await handleProxy(request);
|
|
502
|
+
if (proxied) return proxied;
|
|
503
|
+
|
|
504
|
+
const url = new URL(request.url);
|
|
505
|
+
const path = url.pathname;
|
|
506
|
+
|
|
507
|
+
// static files in the ninety output folder
|
|
508
|
+
const staticRes = await tryServeFile(pathMod.join(__dirname, StaticPath, path));
|
|
509
|
+
if (staticRes) return staticRes;
|
|
510
|
+
|
|
511
|
+
// project-level static/ directory
|
|
512
|
+
const projectStaticPath = pathMod.resolve(process.cwd(), 'static');
|
|
513
|
+
if (path === '/' || path === '') {
|
|
514
|
+
const indexRes = await tryServeFile(pathMod.join(projectStaticPath, 'index.html'));
|
|
515
|
+
if (indexRes) return indexRes;
|
|
516
|
+
}
|
|
517
|
+
const projectRes = await tryServeFile(pathMod.join(projectStaticPath, path));
|
|
518
|
+
if (projectRes) return projectRes;
|
|
519
|
+
|
|
520
|
+
const ssrComponentPath = pathMod.join(__dirname, HadarsFolder, SSR_FILENAME);
|
|
521
|
+
// Use a file: URL so the ?t= suffix is treated as a URL query string
|
|
522
|
+
// (cache-busting key) rather than a literal filename character on Linux.
|
|
523
|
+
const importPath = pathToFileURL(ssrComponentPath).href + `?t=${ssrBuildId}`;
|
|
524
|
+
|
|
525
|
+
const {
|
|
526
|
+
default: Component,
|
|
527
|
+
getInitProps,
|
|
528
|
+
getAfterRenderProps,
|
|
529
|
+
getFinalProps,
|
|
530
|
+
} = (await import(importPath)) as HadarsEntryModule<any>;
|
|
531
|
+
|
|
532
|
+
const { ReactPage, status, headHtml } = await getReactResponse(request, {
|
|
533
|
+
document: {
|
|
534
|
+
body: Component as React.FC<HadarsProps<object>>,
|
|
535
|
+
lang: 'en',
|
|
536
|
+
getInitProps,
|
|
537
|
+
getAfterRenderProps,
|
|
538
|
+
getFinalProps,
|
|
539
|
+
},
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, options.streaming === true); // no pool in dev
|
|
543
|
+
}, options.websocket);
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
export const build = async (options: HadarsRuntimeOptions) => {
|
|
547
|
+
validateOptions(options);
|
|
548
|
+
|
|
549
|
+
const entry = pathMod.resolve(__dirname, options.entry);
|
|
550
|
+
|
|
551
|
+
// prepare client script
|
|
552
|
+
const packageDir = pathMod.dirname(fileURLToPath(import.meta.url));
|
|
553
|
+
const clientScriptPath = pathMod.resolve(packageDir, 'utils', 'clientScript.js');
|
|
554
|
+
const headPath = pathMod.resolve(packageDir, 'utils', 'Head');
|
|
555
|
+
let clientScript = '';
|
|
556
|
+
try {
|
|
557
|
+
clientScript = (await fs.readFile(clientScriptPath, 'utf-8'))
|
|
558
|
+
.replace('$_MOD_PATH$', entry + getSuffix(options.mode))
|
|
559
|
+
.replace('$_HEAD_PATH$', headPath);
|
|
560
|
+
} catch (err) {
|
|
561
|
+
const srcClientPath = pathMod.resolve(packageDir, 'utils', 'clientScript.tsx');
|
|
562
|
+
clientScript = (await fs.readFile(srcClientPath, 'utf-8'))
|
|
563
|
+
.replace('$_MOD_PATH$', entry + `?v=${Date.now()}`)
|
|
564
|
+
.replace('$_HEAD_PATH$', pathMod.resolve(packageDir, 'utils', 'Head'));
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const tmpFilePath = pathMod.join(os.tmpdir(), `hadars-client-${Date.now()}.tsx`);
|
|
568
|
+
await fs.writeFile(tmpFilePath, clientScript);
|
|
569
|
+
|
|
570
|
+
const randomStr = crypto.randomBytes(6).toString('hex');
|
|
571
|
+
|
|
572
|
+
// Compile client and SSR bundles in parallel — they write to different
|
|
573
|
+
// output directories and use different entry files, so they are fully
|
|
574
|
+
// independent and safe to run concurrently.
|
|
575
|
+
console.log("Building client and server bundles in parallel...");
|
|
576
|
+
await Promise.all([
|
|
577
|
+
compileEntry(tmpFilePath, {
|
|
578
|
+
target: 'web',
|
|
579
|
+
output: {
|
|
580
|
+
filename: `index-${randomStr}.js`,
|
|
581
|
+
path: pathMod.resolve(__dirname, StaticPath),
|
|
582
|
+
},
|
|
583
|
+
base: options.baseURL,
|
|
584
|
+
mode: 'production',
|
|
585
|
+
swcPlugins: options.swcPlugins,
|
|
586
|
+
define: options.define,
|
|
587
|
+
}),
|
|
588
|
+
compileEntry(pathMod.resolve(__dirname, options.entry), {
|
|
589
|
+
output: {
|
|
590
|
+
iife: false,
|
|
591
|
+
filename: SSR_FILENAME,
|
|
592
|
+
path: pathMod.resolve(__dirname, HadarsFolder),
|
|
593
|
+
publicPath: '',
|
|
594
|
+
library: { type: 'module' },
|
|
595
|
+
},
|
|
596
|
+
base: options.baseURL,
|
|
597
|
+
target: 'node',
|
|
598
|
+
mode: 'production',
|
|
599
|
+
swcPlugins: options.swcPlugins,
|
|
600
|
+
define: options.define,
|
|
601
|
+
}),
|
|
602
|
+
]);
|
|
603
|
+
await fs.rm(tmpFilePath);
|
|
604
|
+
|
|
605
|
+
await fs.writeFile(
|
|
606
|
+
pathMod.join(__dirname, HadarsFolder, 'hadars.json'),
|
|
607
|
+
JSON.stringify({ buildId: randomStr }),
|
|
608
|
+
);
|
|
609
|
+
console.log("Build complete.");
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
export const run = async (options: HadarsRuntimeOptions) => {
|
|
613
|
+
validateOptions(options);
|
|
614
|
+
|
|
615
|
+
let { port = 9090, workers = 1 } = options;
|
|
616
|
+
|
|
617
|
+
// On Node.js, fork worker processes so every CPU core handles requests.
|
|
618
|
+
// The primary process only manages the cluster; workers fall through to
|
|
619
|
+
// the serve() call below. On Bun/Deno this is skipped — Bun has its own
|
|
620
|
+
// multi-threaded I/O model and doesn't need OS-level process forking.
|
|
621
|
+
if (isNode && workers > 1 && cluster.isPrimary) {
|
|
622
|
+
console.log(`[hadars] Starting ${workers} worker processes on port ${port}`);
|
|
623
|
+
for (let i = 0; i < workers; i++) {
|
|
624
|
+
cluster.fork();
|
|
625
|
+
}
|
|
626
|
+
cluster.on('exit', (worker, code, signal) => {
|
|
627
|
+
console.warn(`[hadars] Worker ${worker.process.pid} exited (${signal ?? code}), restarting...`);
|
|
628
|
+
cluster.fork();
|
|
629
|
+
});
|
|
630
|
+
await new Promise(() => {}); // keep primary alive; workers handle requests
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const handleProxy = createProxyHandler(options);
|
|
635
|
+
const handleWS = upgradeHandler(options);
|
|
636
|
+
const handler = options.fetch;
|
|
637
|
+
|
|
638
|
+
console.log(`Starting Hadars (run) on port ${port}`);
|
|
639
|
+
|
|
640
|
+
// On Bun/Deno, node:cluster is unavailable, so we use a worker_threads
|
|
641
|
+
// render pool to parallelize the synchronous renderToString step instead.
|
|
642
|
+
let renderPool: RenderWorkerPool | undefined;
|
|
643
|
+
if (!isNode && workers > 1) {
|
|
644
|
+
const packageDir = pathMod.dirname(fileURLToPath(import.meta.url));
|
|
645
|
+
const workerJs = pathMod.resolve(packageDir, 'ssr-render-worker.js');
|
|
646
|
+
const workerTs = pathMod.resolve(packageDir, 'src', 'ssr-render-worker.ts');
|
|
647
|
+
const workerFile = existsSync(workerJs) ? workerJs : workerTs;
|
|
648
|
+
const ssrBundlePath = pathMod.resolve(__dirname, HadarsFolder, SSR_FILENAME);
|
|
649
|
+
renderPool = new RenderWorkerPool(workerFile, workers, ssrBundlePath);
|
|
650
|
+
console.log(`[hadars] SSR render pool: ${workers} worker threads`);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const getPrecontentHtml = makePrecontentHtmlGetter(
|
|
654
|
+
fs.readFile(pathMod.join(__dirname, StaticPath, 'out.html'), 'utf-8')
|
|
655
|
+
);
|
|
656
|
+
|
|
657
|
+
await serve(port, async (req, ctx) => {
|
|
658
|
+
const request = parseRequest(req);
|
|
659
|
+
if (handler) {
|
|
660
|
+
const res = await handler(request);
|
|
661
|
+
if (res) return res;
|
|
662
|
+
}
|
|
663
|
+
if (handleWS && handleWS(request, ctx)) return undefined;
|
|
664
|
+
|
|
665
|
+
const proxied = await handleProxy(request);
|
|
666
|
+
if (proxied) return proxied;
|
|
667
|
+
|
|
668
|
+
const url = new URL(request.url);
|
|
669
|
+
const path = url.pathname;
|
|
670
|
+
|
|
671
|
+
// static files in the ninety output folder
|
|
672
|
+
const staticRes = await tryServeFile(pathMod.join(__dirname, StaticPath, path));
|
|
673
|
+
if (staticRes) return staticRes;
|
|
674
|
+
|
|
675
|
+
if (path === '/' || path === '') {
|
|
676
|
+
const indexRes = await tryServeFile(pathMod.join(__dirname, StaticPath, 'index.html'));
|
|
677
|
+
if (indexRes) return indexRes;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// project-level static/ directory
|
|
681
|
+
const projectStaticPath = pathMod.resolve(process.cwd(), 'static');
|
|
682
|
+
const projectRes = await tryServeFile(pathMod.join(projectStaticPath, path));
|
|
683
|
+
if (projectRes) return projectRes;
|
|
684
|
+
|
|
685
|
+
// route-based fallback: try <path>/index.html
|
|
686
|
+
const routeClean = path.replace(/(^\/|\/$)/g, '');
|
|
687
|
+
if (routeClean) {
|
|
688
|
+
const routeRes = await tryServeFile(
|
|
689
|
+
pathMod.join(__dirname, StaticPath, routeClean, 'index.html')
|
|
690
|
+
);
|
|
691
|
+
if (routeRes) return routeRes;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const componentPath = pathToFileURL(
|
|
695
|
+
pathMod.resolve(__dirname, HadarsFolder, SSR_FILENAME)
|
|
696
|
+
).href;
|
|
697
|
+
const {
|
|
698
|
+
default: Component,
|
|
699
|
+
getInitProps,
|
|
700
|
+
getAfterRenderProps,
|
|
701
|
+
getFinalProps,
|
|
702
|
+
} = (await import(componentPath)) as HadarsEntryModule<any>;
|
|
703
|
+
|
|
704
|
+
const { ReactPage, status, headHtml, renderPayload } = await getReactResponse(request, {
|
|
705
|
+
document: {
|
|
706
|
+
body: Component as React.FC<HadarsProps<object>>,
|
|
707
|
+
lang: 'en',
|
|
708
|
+
getInitProps,
|
|
709
|
+
getAfterRenderProps,
|
|
710
|
+
getFinalProps,
|
|
711
|
+
},
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
return buildSsrResponse(ReactPage, headHtml, status, getPrecontentHtml, options.streaming === true, renderPool, renderPayload);
|
|
715
|
+
}, options.websocket);
|
|
716
|
+
};
|