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.
@@ -0,0 +1,198 @@
1
+ import type React from "react";
2
+ import { createRequire } from "node:module";
3
+ import pathMod from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import type { AppHead, AppUnsuspend, HadarsRequest, HadarsEntryBase, HadarsEntryModule, HadarsProps, AppContext } from "../types/ninety";
6
+
7
+ // Resolve react-dom/server from the *project's* node_modules (process.cwd()) so
8
+ // the same React instance is used here as in the SSR bundle. Without this,
9
+ // when hadars is installed as a file: symlink the renderer ends up on a
10
+ // different React than the component, breaking hook calls.
11
+ let _renderToStaticMarkup: ((element: any) => string) | null = null;
12
+ async function getStaticMarkupRenderer(): Promise<(element: any) => string> {
13
+ if (!_renderToStaticMarkup) {
14
+ const req = createRequire(pathMod.resolve(process.cwd(), '__hadars_fake__.js'));
15
+ const resolved = req.resolve('react-dom/server');
16
+ const mod = await import(pathToFileURL(resolved).href);
17
+ _renderToStaticMarkup = mod.renderToStaticMarkup;
18
+ }
19
+ return _renderToStaticMarkup!;
20
+ }
21
+
22
+ interface ReactResponseOptions {
23
+ document: {
24
+ body: React.FC<HadarsProps<object>>;
25
+ head?: () => Promise<React.ReactNode>;
26
+ lang?: string;
27
+ getInitProps: HadarsEntryModule<HadarsEntryBase>['getInitProps'];
28
+ getAfterRenderProps: HadarsEntryModule<HadarsEntryBase>['getAfterRenderProps'];
29
+ getFinalProps: HadarsEntryModule<HadarsEntryBase>['getFinalProps'];
30
+ }
31
+ }
32
+
33
+ const getHeadHtml = (seoData: AppHead, renderToStaticMarkup: (el: any) => string): string => {
34
+ const metaEntries = Object.entries(seoData.meta)
35
+ const linkEntries = Object.entries(seoData.link)
36
+ const styleEntries = Object.entries(seoData.style)
37
+ const scriptEntries = Object.entries(seoData.script)
38
+
39
+ return renderToStaticMarkup(
40
+ <>
41
+ <title>{seoData.title}</title>
42
+ {
43
+ metaEntries.map( ([id, options]) => (
44
+ <meta
45
+ key={id}
46
+ id={ id }
47
+ { ...options }
48
+ />
49
+ ) )
50
+ }
51
+ {linkEntries.map( ([id, options]) => (
52
+ <link
53
+ key={id}
54
+ id={ id }
55
+ { ...options }
56
+ />
57
+ ))}
58
+ {styleEntries.map( ([id, options]) => (
59
+ <style
60
+ key={id}
61
+ id={id}
62
+ { ...options }
63
+ />
64
+ ))}
65
+ {scriptEntries.map( ([id, options]) => (
66
+ <script
67
+ key={id}
68
+ id={id}
69
+ { ...options }
70
+ />
71
+ ))}
72
+ </>
73
+ )
74
+ }
75
+
76
+
77
+ export const getReactResponse = async (
78
+ req: HadarsRequest,
79
+ opts: ReactResponseOptions,
80
+ ): Promise<{
81
+ ReactPage: React.ReactElement,
82
+ status: number,
83
+ headHtml: string,
84
+ renderPayload: {
85
+ appProps: Record<string, unknown>;
86
+ clientProps: Record<string, unknown>;
87
+ };
88
+ }> => {
89
+ const App = opts.document.body
90
+ const { getInitProps, getAfterRenderProps, getFinalProps } = opts.document;
91
+
92
+ const renderToStaticMarkup = await getStaticMarkupRenderer();
93
+
94
+ // Per-request unsuspend context — populated by useServerData() hooks during render.
95
+ // Lifecycle passes always run on the main thread so the cache is directly accessible.
96
+ // Kept as a plain AppUnsuspend (no methods) so it is serializable via structuredClone
97
+ // for postMessage to worker threads.
98
+ const unsuspend: AppUnsuspend = {
99
+ cache: new Map(),
100
+ hasPending: false,
101
+ };
102
+ const processUnsuspend = async () => {
103
+ const pending = [...unsuspend.cache.values()]
104
+ .filter((e): e is { status: 'pending'; promise: Promise<unknown> } => e.status === 'pending')
105
+ .map(e => e.promise);
106
+ await Promise.all(pending);
107
+ };
108
+
109
+ const context: AppContext = {
110
+ head: {
111
+ title: "Hadars App",
112
+ meta: {},
113
+ link: {},
114
+ style: {},
115
+ script: {},
116
+ status: 200,
117
+ },
118
+ _unsuspend: unsuspend,
119
+ }
120
+
121
+ let props: HadarsEntryBase = {
122
+ ...(getInitProps ? await getInitProps(req) : {}),
123
+ location: req.location,
124
+ context,
125
+ } as HadarsEntryBase
126
+
127
+ // ── First lifecycle pass: useServerData render loop ───────────────────────
128
+ // Render the component repeatedly until all useServerData() promises have
129
+ // resolved. Each iteration discovers new pending promises; awaiting them
130
+ // before the next pass ensures every hook returns its value by the final
131
+ // iteration. Capped at 25 iterations as a safety guard against infinite loops.
132
+ let html = '';
133
+ let iters = 0;
134
+ do {
135
+ unsuspend.hasPending = false;
136
+ try {
137
+ (globalThis as any).__hadarsUnsuspend = unsuspend;
138
+ html = renderToStaticMarkup(<App {...(props as any)} />);
139
+ } finally {
140
+ (globalThis as any).__hadarsUnsuspend = null;
141
+ }
142
+ if (unsuspend.hasPending) await processUnsuspend();
143
+ } while (unsuspend.hasPending && ++iters < 25);
144
+
145
+ props = getAfterRenderProps ? await getAfterRenderProps(props, html) : props;
146
+ // Re-render to capture any head changes introduced by getAfterRenderProps.
147
+ try {
148
+ (globalThis as any).__hadarsUnsuspend = unsuspend;
149
+ renderToStaticMarkup(
150
+ <App {...({
151
+ ...props,
152
+ location: req.location,
153
+ context,
154
+ })} />
155
+ );
156
+ } finally {
157
+ (globalThis as any).__hadarsUnsuspend = null;
158
+ }
159
+
160
+ // Serialize resolved useServerData() values for client hydration.
161
+ // The client bootstrap reads __serverData and pre-populates the hook cache
162
+ // before hydrateRoot so that useServerData() returns the same values CSR.
163
+ const serverData: Record<string, unknown> = {};
164
+ for (const [k, v] of unsuspend.cache) {
165
+ if (v.status === 'fulfilled') serverData[k] = v.value;
166
+ }
167
+
168
+ const { context: _, ...restProps } = getFinalProps ? await getFinalProps(props) : props;
169
+ const clientProps = {
170
+ ...restProps,
171
+ location: req.location,
172
+ ...(Object.keys(serverData).length > 0 ? { __serverData: serverData } : {}),
173
+ }
174
+
175
+ const ReactPage = (
176
+ <>
177
+ <div id="app">
178
+ <App {...({
179
+ ...props,
180
+ location: req.location,
181
+ context,
182
+ })} />
183
+ </div>
184
+ <script id="hadars" type="application/json" dangerouslySetInnerHTML={{ __html: JSON.stringify({ hadars: { props: clientProps } }) }}></script>
185
+ </>
186
+ )
187
+
188
+ return {
189
+ ReactPage,
190
+ status: context.head.status,
191
+ headHtml: getHeadHtml(context.head, renderToStaticMarkup),
192
+ renderPayload: {
193
+ appProps: { ...props, location: req.location, context } as Record<string, unknown>,
194
+ clientProps: clientProps as Record<string, unknown>,
195
+ },
196
+ }
197
+
198
+ }
@@ -0,0 +1,359 @@
1
+ import rspack from "@rspack/core";
2
+ import type { Configuration, RuleSetLoaderWithOptions, RuleSetRule } from "@rspack/core";
3
+ import ReactRefreshPlugin from '@rspack/plugin-react-refresh';
4
+ import path from 'node:path';
5
+ import type { SwcPluginList } from '../types/ninety';
6
+ import { fileURLToPath } from "node:url";
7
+ import pathMod from "node:path";
8
+ import { existsSync } from "node:fs";
9
+
10
+ const __dirname = process.cwd();
11
+ const packageDir = pathMod.dirname(fileURLToPath(import.meta.url));
12
+ const clientScriptPath = pathMod.resolve(packageDir, 'template.html');
13
+
14
+ // When running from compiled dist/cli.js the loader is pre-built as loader.cjs.
15
+ // The .cjs extension forces CommonJS regardless of the package "type": "module".
16
+ // When running from source (bun/tsx) it falls back to loader.ts.
17
+ const loaderPath = existsSync(pathMod.resolve(packageDir, 'loader.cjs'))
18
+ ? pathMod.resolve(packageDir, 'loader.cjs')
19
+ : pathMod.resolve(packageDir, 'loader.ts');
20
+
21
+ const getConfigBase = (mode: "development" | "production"): Omit<Configuration, "entry" | "output" | "plugins"> => {
22
+ const isDev = mode === 'development';
23
+ return {
24
+ experiments: {
25
+ css: true,
26
+ outputModule: true,
27
+ },
28
+ resolve: {
29
+ modules: [
30
+ path.resolve(__dirname, 'node_modules'),
31
+ // 'node_modules' (relative) enables the standard upward-traversal
32
+ // resolution so rspack can find transitive deps (e.g. webpack-dev-server)
33
+ // that live in a parent node_modules when running from a sub-project.
34
+ 'node_modules',
35
+ ],
36
+ tsConfig: path.resolve(__dirname, 'tsconfig.json'),
37
+ extensions: ['.tsx', '.ts', '.js', '.jsx'],
38
+ },
39
+ module: {
40
+ rules: [
41
+ {
42
+ test: /\.mdx?$/,
43
+ use: [
44
+ {
45
+ loader: '@mdx-js/loader',
46
+ options: {
47
+ },
48
+ },
49
+ ],
50
+ },
51
+ {
52
+ test: /\.css$/,
53
+ use: ["postcss-loader"],
54
+ type: "css",
55
+ },
56
+ {
57
+ test: /\.svg$/i,
58
+ issuer: /\.[jt]sx?$/,
59
+ use: ['@svgr/webpack'],
60
+ },
61
+ {
62
+ test: /\.m?jsx?$/,
63
+ resolve: {
64
+ fullySpecified: false,
65
+ },
66
+ exclude: [loaderPath],
67
+ use: [
68
+ // Transforms loadModule('./path') based on build target.
69
+ // Runs before swc-loader (loaders execute right-to-left).
70
+ {
71
+ loader: loaderPath,
72
+ },
73
+ {
74
+ loader: 'builtin:swc-loader',
75
+ options: {
76
+ jsc: {
77
+ parser: {
78
+ syntax: 'ecmascript',
79
+ jsx: true,
80
+ },
81
+ transform: {
82
+ react: {
83
+ runtime: "automatic",
84
+ development: isDev,
85
+ refresh: isDev,
86
+ },
87
+ },
88
+ },
89
+ },
90
+ },
91
+ ],
92
+ type: 'javascript/auto',
93
+ },
94
+ {
95
+ test: /\.tsx?$/,
96
+ resolve: {
97
+ fullySpecified: false,
98
+ },
99
+ exclude: [loaderPath],
100
+ use: [
101
+ {
102
+ loader: loaderPath,
103
+ },
104
+ {
105
+ loader: 'builtin:swc-loader',
106
+ options: {
107
+ jsc: {
108
+ parser: {
109
+ syntax: 'typescript',
110
+ tsx: true,
111
+ },
112
+ transform: {
113
+ react: {
114
+ runtime: "automatic",
115
+ development: isDev,
116
+ refresh: isDev,
117
+ },
118
+ },
119
+ },
120
+ },
121
+ },
122
+ ],
123
+ type: 'javascript/auto',
124
+ },
125
+ ],
126
+ },
127
+ }
128
+ }
129
+
130
+ type EntryOutput = Configuration["output"];
131
+
132
+ interface EntryOptions {
133
+ target: Configuration["target"],
134
+ output: EntryOutput,
135
+ mode: "development" | "production",
136
+ // optional swc plugins to pass to swc-loader
137
+ swcPlugins?: SwcPluginList,
138
+ // optional compile-time defines (e.g. { 'process.env.NODE_ENV': '"development"' })
139
+ define?: Record<string, string>;
140
+ base?: string;
141
+ }
142
+
143
+ const buildCompilerConfig = (
144
+ entry: string,
145
+ opts: EntryOptions,
146
+ includeHotPlugin: boolean,
147
+ ): Configuration => {
148
+ const Config = getConfigBase(opts.mode);
149
+ const { base } = opts;
150
+ const isDev = opts.mode === 'development';
151
+
152
+ // shallow-clone base config to avoid mutating shared Config while preserving RegExp and plugin instances
153
+ const localConfig: any = {
154
+ ...Config,
155
+ module: {
156
+ ...Config.module,
157
+ rules: (Config.module && Array.isArray(Config.module.rules) ? Config.module.rules : []).map((r: any) => {
158
+ // shallow copy each rule and its 'use' array/entries so we can mutate safely
159
+ const nr: any = { ...r };
160
+ if (r && Array.isArray(r.use)) {
161
+ nr.use = r.use.map((u: any) => ({ ...(typeof u === 'object' ? u : { loader: u }) }));
162
+ }
163
+ return nr;
164
+ }),
165
+ },
166
+ };
167
+
168
+ // if swc plugins are provided, inject them into swc-loader options for js/jsx and ts/tsx rules
169
+ if (opts.swcPlugins && Array.isArray(opts.swcPlugins) && opts.swcPlugins.length > 0) {
170
+ const rules = localConfig.module && localConfig.module.rules;
171
+ if (Array.isArray(rules)) {
172
+ for (const rule of rules) {
173
+ const ruleUse = rule as RuleSetRule;
174
+ if (ruleUse.use && Array.isArray(ruleUse.use)) {
175
+ for (const entry of ruleUse.use ) {
176
+ const useEntry = entry as RuleSetLoaderWithOptions;
177
+ if (useEntry && useEntry.loader && typeof useEntry.loader === 'string' && useEntry.loader.includes('swc-loader')) {
178
+ const options = ( useEntry.options || {} ) as Record<string, any>;
179
+ useEntry.options = options;
180
+ useEntry.options.jsc = useEntry.options.jsc || {};
181
+ useEntry.options.jsc.experimental = useEntry.options.jsc.experimental || {};
182
+ // ensure plugins run before other transforms (important for Relay plugin)
183
+ useEntry.options.jsc.experimental.runPluginFirst = true;
184
+ // existing plugins may be present under jsc.experimental.plugins; merge them with provided ones
185
+ const existingPlugins = Array.isArray(useEntry.options.jsc.experimental.plugins) ? useEntry.options.jsc.experimental.plugins : [];
186
+ const incomingPlugins = Array.isArray(opts.swcPlugins) ? opts.swcPlugins : [];
187
+ // simple dedupe by plugin name (first element of tuple) to avoid duplicates
188
+ const seen = new Set<string>();
189
+ const merged: any[] = [];
190
+ for (const p of existingPlugins.concat(incomingPlugins)) {
191
+ // plugin can be [name, options] or string; normalize
192
+ const name = Array.isArray(p) && p.length > 0 ? String(p[0]) : String(p);
193
+ if (!seen.has(name)) {
194
+ seen.add(name);
195
+ merged.push(p);
196
+ }
197
+ }
198
+ useEntry.options.jsc.experimental.plugins = merged;
199
+ }
200
+ }
201
+ }
202
+ }
203
+ }
204
+ }
205
+
206
+ // For server (SSR) builds we should avoid bundling react/react-dom so
207
+ // the runtime uses the same React instance as the host. If the output
208
+ // is a library/module (i.e. `opts.output.library` present or filename
209
+ // contains "ssr"), treat it as a server build and mark react/react-dom
210
+ // as externals and alias React imports to the project's node_modules.
211
+ const isServerBuild = Boolean(
212
+ (opts.output && typeof opts.output === 'object' && (opts.output.library || String(opts.output.filename || '').includes('ssr')))
213
+ );
214
+
215
+ const resolveAliases: Record<string, string> | undefined = isServerBuild ? {
216
+ // force all react imports to resolve to this project's react
217
+ react: path.resolve(process.cwd(), 'node_modules', 'react'),
218
+ 'react-dom': path.resolve(process.cwd(), 'node_modules', 'react-dom'),
219
+ // also map react/jsx-runtime to avoid duplicates when automatic runtime is used
220
+ 'react/jsx-runtime': path.resolve(process.cwd(), 'node_modules', 'react', 'jsx-runtime.js'),
221
+ 'react/jsx-dev-runtime': path.resolve(process.cwd(), 'node_modules', 'react', 'jsx-dev-runtime.js'),
222
+ // ensure emotion packages resolve to the project's node_modules so we don't pick up a browser-specific entry
223
+ '@emotion/react': path.resolve(process.cwd(), 'node_modules', '@emotion', 'react'),
224
+ '@emotion/server': path.resolve(process.cwd(), 'node_modules', '@emotion', 'server'),
225
+ '@emotion/cache': path.resolve(process.cwd(), 'node_modules', '@emotion', 'cache'),
226
+ '@emotion/styled': path.resolve(process.cwd(), 'node_modules', '@emotion', 'styled'),
227
+ } : undefined;
228
+
229
+ const externals = isServerBuild ? [
230
+ 'react',
231
+ 'react-dom',
232
+ // keep common aliases external as well
233
+ 'react/jsx-runtime',
234
+ 'react/jsx-dev-runtime',
235
+ // emotion should be external on server builds to avoid client/browser code
236
+ '@emotion/react',
237
+ '@emotion/server',
238
+ '@emotion/cache',
239
+ '@emotion/styled',
240
+ ] : undefined;
241
+
242
+ const extraPlugins: any[] = [];
243
+ if (opts.define && typeof opts.define === 'object') {
244
+ // rspack's DefinePlugin shape mirrors webpack's DefinePlugin
245
+ const DefinePlugin = (rspack as any).DefinePlugin || (rspack as any).plugins?.DefinePlugin;
246
+ if (DefinePlugin) {
247
+ extraPlugins.push(new DefinePlugin(opts.define));
248
+ } else {
249
+ // fallback: try to inject via plugin API name
250
+ extraPlugins.push({ name: 'DefinePlugin', value: opts.define });
251
+ }
252
+ }
253
+
254
+ const resolveConfig: any = {
255
+ extensions: ['.tsx', '.ts', '.js', '.jsx'],
256
+ alias: resolveAliases,
257
+ // for server builds prefer the package "main"/"module" fields and avoid "browser" so we don't pick browser-specific entrypoints
258
+ mainFields: isServerBuild ? ['main', 'module'] : ['browser', 'module', 'main'],
259
+ };
260
+
261
+ return {
262
+ entry,
263
+ resolve: resolveConfig,
264
+ output: {
265
+ ...opts.output,
266
+ clean: false,
267
+ },
268
+ mode: opts.mode,
269
+ externals,
270
+ plugins: [
271
+ new rspack.HtmlRspackPlugin({
272
+ publicPath: base || '/',
273
+ template: clientScriptPath,
274
+ scriptLoading: 'module',
275
+ filename: 'out.html',
276
+ inject: 'body',
277
+ }),
278
+ isDev && new ReactRefreshPlugin(),
279
+ includeHotPlugin && isDev && new rspack.HotModuleReplacementPlugin(),
280
+ ...extraPlugins,
281
+ ],
282
+ ...localConfig,
283
+ // HMR is not implemented for module chunk format, so disable outputModule
284
+ // for client builds. SSR builds still need it for dynamic import() of exports.
285
+ experiments: {
286
+ ...(localConfig.experiments || {}),
287
+ outputModule: isServerBuild,
288
+ },
289
+ };
290
+ };
291
+
292
+ /**
293
+ * Creates a configured rspack compiler for the client bundle without running it.
294
+ * Intended for use with RspackDevServer for proper HMR support.
295
+ * HotModuleReplacementPlugin is intentionally omitted — RspackDevServer adds it automatically.
296
+ */
297
+ export const createClientCompiler = (entry: string, opts: EntryOptions) => {
298
+ return rspack(buildCompilerConfig(entry, opts, false));
299
+ };
300
+
301
+ export const compileEntry = async (entry: string, opts: EntryOptions & { watch?: boolean, onChange?: (stats:any)=>void }) => {
302
+ const compiler = rspack(buildCompilerConfig(entry, opts, true));
303
+
304
+ // If watch mode is requested, start watching and invoke onChange for each rebuild.
305
+ // The returned promise resolves once the first build completes so callers can
306
+ // await initial build completion before starting their own server.
307
+ if (opts.watch) {
308
+ await new Promise((resolve, reject) => {
309
+ let first = true;
310
+ compiler.watch({}, (err: any, stats: any) => {
311
+ if (err) {
312
+ if (first) { first = false; reject(err); }
313
+ else { console.error('rspack watch error', err); }
314
+ return;
315
+ }
316
+
317
+ console.log(stats?.toString({
318
+ colors: true,
319
+ modules: true,
320
+ children: true,
321
+ chunks: true,
322
+ chunkModules: true,
323
+ }));
324
+
325
+ if (first) {
326
+ first = false;
327
+ resolve(stats);
328
+ } else {
329
+ try {
330
+ opts.onChange && opts.onChange(stats);
331
+ } catch (e) {
332
+ console.error('onChange handler error', e);
333
+ }
334
+ }
335
+ });
336
+ });
337
+ return;
338
+ }
339
+
340
+ // non-watch: do a single run and resolve when complete
341
+ await new Promise((resolve, reject) => {
342
+ compiler.run((err: any, stats: any) => {
343
+ if (err) {
344
+ reject(err);
345
+ return;
346
+ }
347
+
348
+ console.log(stats?.toString({
349
+ colors: true,
350
+ modules: true,
351
+ children: true,
352
+ chunks: true,
353
+ chunkModules: true,
354
+ }));
355
+
356
+ resolve(stats);
357
+ });
358
+ });
359
+ }
@@ -0,0 +1,19 @@
1
+ /** True when running inside Bun. */
2
+ export const isBun = typeof (globalThis as any).Bun !== 'undefined';
3
+
4
+ /** True when running inside Deno. */
5
+ export const isDeno = typeof (globalThis as any).Deno !== 'undefined';
6
+
7
+ /** True when running inside Node.js (not Bun, not Deno). */
8
+ export const isNode = !isBun && !isDeno;
9
+
10
+ /** Returns a human-readable runtime identifier. */
11
+ export const getRuntimeName = (): 'bun' | 'deno' | 'node' =>
12
+ isBun ? 'bun' : isDeno ? 'deno' : 'node';
13
+
14
+ /** Returns a version string for the current runtime, e.g. "Bun 1.1.0". */
15
+ export const getRuntimeVersion = (): string => {
16
+ if (isBun) return `Bun ${(globalThis as any).Bun.version}`;
17
+ if (isDeno) return `Deno ${(globalThis as any).Deno.version.deno}`;
18
+ return `Node.js ${process.version}`;
19
+ };