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/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
+ };