hadars 0.1.20 → 0.1.22

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/README.md CHANGED
@@ -2,23 +2,46 @@
2
2
 
3
3
  A minimal server-side rendering framework for React built on [rspack](https://rspack.dev). Runs on Bun, Node.js, and Deno.
4
4
 
5
- ## Install
5
+ ## Why hadars?
6
+
7
+ hadars is an alternative to Next.js for apps that just need SSR.
8
+
9
+ **Getting out is painful.** Next.js has its own router, image component, font loader, `<Link>`, and middleware API. These aren't just conveniences - they're load-bearing parts of your app. By the time you want to leave, you're not swapping a dependency, you're doing a rewrite.
10
+
11
+ **Server Components solved a problem I don't have.** The mental model is interesting, but the split between server-only and client-only trees, plus the serialisation boundary between them, adds real complexity for most apps. `getInitProps` + `useServerData` gets you server-rendered HTML with client hydration without any of that.
12
+
13
+ **A smaller attack surface is better.** Any framework that intercepts every request adds risk. Less code handling that path is a reasonable starting point - whether that translates to meaningful security differences is something only time and scrutiny will tell.
14
+
15
+ **Less overhead.** hadars skips RSC infrastructure, a built-in router, and edge runtime polyfills. It uses its own SSR renderer ([slim-react](#slim-react)) instead of react-dom/server. Whether that matters for your use case depends on your load, but the baseline is lighter.
16
+
17
+ Bring your own router (or none), keep your components as plain React, and get SSR, HMR, and a production build from a single config file.
18
+
19
+ ## Quick start
20
+
21
+ Scaffold a new project in seconds:
22
+
23
+ ```bash
24
+ npx hadars new my-app
25
+ cd my-app
26
+ npm install
27
+ npm run dev
28
+ ```
29
+
30
+ Or install manually:
6
31
 
7
32
  ```bash
8
33
  npm install hadars
9
34
  ```
10
35
 
11
- ## Quick start
36
+ ## Example
12
37
 
13
38
  **hadars.config.ts**
14
39
  ```ts
15
- import os from 'os';
16
40
  import type { HadarsOptions } from 'hadars';
17
41
 
18
42
  const config: HadarsOptions = {
19
43
  entry: 'src/App.tsx',
20
44
  port: 3000,
21
- workers: os.cpus().length, // multi-core production server (Node.js)
22
45
  };
23
46
 
24
47
  export default config;
@@ -49,9 +72,10 @@ export default App;
49
72
 
50
73
  ## CLI
51
74
 
52
- After installing hadars the `hadars` binary is available. It works on Node.js, Bun, and Deno — the runtime is auto-detected:
53
-
54
75
  ```bash
76
+ # Scaffold a new project
77
+ hadars new <project-name>
78
+
55
79
  # Development server with React Fast Refresh HMR
56
80
  hadars dev
57
81
 
@@ -59,19 +83,19 @@ hadars dev
59
83
  hadars build
60
84
 
61
85
  # Serve the production build
62
- hadars run # multi-core when workers > 1
86
+ hadars run
63
87
  ```
64
88
 
65
89
  ## Features
66
90
 
67
- - **React Fast Refresh** full HMR via rspack-dev-server, module-level patches
68
- - **True SSR** components render on the server with your data, then hydrate on the client
69
- - **Shell streaming** HTML shell is flushed immediately so browsers can start loading assets before the body arrives
70
- - **Code splitting** `loadModule('./Comp')` splits on the browser, bundles statically on the server
71
- - **Head management** `HadarsHead` controls `<title>`, `<meta>`, `<link>` on server and client
72
- - **Cross-runtime** Bun, Node.js, Deno; uses the standard Fetch API throughout
73
- - **Multi-core** `workers: os.cpus().length` forks a process per CPU core via `node:cluster`
74
- - **TypeScript-first** full types for props, lifecycle hooks, config, and the request object
91
+ - **React Fast Refresh** - full HMR via rspack-dev-server, module-level patches
92
+ - **True SSR** - components render on the server with your data, then hydrate on the client
93
+ - **Shell streaming** - HTML shell is flushed immediately so browsers can start loading assets before the body arrives
94
+ - **Code splitting** - `loadModule('./Comp')` splits on the browser, bundles statically on the server
95
+ - **Head management** - `HadarsHead` controls `<title>`, `<meta>`, `<link>` on server and client
96
+ - **Cross-runtime** - Bun, Node.js, Deno; uses the standard Fetch API throughout
97
+ - **Multi-core** - `workers: os.cpus().length` forks a process per CPU core via `node:cluster`
98
+ - **TypeScript-first** - full types for props, lifecycle hooks, config, and the request object
75
99
 
76
100
  ## useServerData
77
101
 
@@ -87,10 +111,10 @@ const UserCard = ({ userId }: { userId: string }) => {
87
111
  };
88
112
  ```
89
113
 
90
- - **`key`** string or string array; must be stable and unique within the page
91
- - **Server** calls `fn()`, awaits the result across render iterations, returns `undefined` until resolved
92
- - **Client** reads the pre-resolved value from the hydration cache serialised by the server; `fn()` is never called in the browser
93
- - **Suspense libraries** also works when `fn()` throws a thenable (e.g. Relay `useLazyLoadQuery` with `suspense: true`); the thrown promise is awaited and the next render re-calls `fn()` synchronously
114
+ - **`key`** - string or string array; must be stable and unique within the page
115
+ - **Server** - calls `fn()`, awaits the result across render iterations, returns `undefined` until resolved
116
+ - **Client** - reads the pre-resolved value from the hydration cache serialised by the server; `fn()` is never called in the browser
117
+ - **Suspense libraries** - also works when `fn()` throws a thenable (e.g. Relay `useLazyLoadQuery` with `suspense: true`); the thrown promise is awaited and the next render re-calls `fn()` synchronously
94
118
 
95
119
  ## Data lifecycle hooks
96
120
 
@@ -105,19 +129,74 @@ const UserCard = ({ userId }: { userId: string }) => {
105
129
 
106
130
  | Option | Type | Default | Description |
107
131
  |---|---|---|---|
108
- | `entry` | `string` | | Path to your page component **(required)** |
132
+ | `entry` | `string` | - | Path to your page component **(required)** |
109
133
  | `port` | `number` | `9090` | HTTP port |
110
134
  | `hmrPort` | `number` | `port + 1` | rspack HMR dev server port |
111
135
  | `baseURL` | `string` | `""` | Public base path, e.g. `"/app"` |
112
- | `workers` | `number` | `1` | Worker processes in `run()` mode (Node.js only) |
113
- | `proxy` | `Record / fn` | | Path-prefix proxy rules or a custom async function |
114
- | `proxyCORS` | `boolean` | | Inject CORS headers on proxied responses |
115
- | `define` | `Record` | | Compile-time constants for rspack's DefinePlugin |
116
- | `swcPlugins` | `array` | | Extra SWC plugins (e.g. Relay compiler) |
117
- | `fetch` | `function` | | Custom fetch handler; return a `Response` to short-circuit SSR |
118
- | `websocket` | `object` | | WebSocket handler (Bun only) |
136
+ | `workers` | `number` | `1` | Worker processes / threads in `run()` mode |
137
+ | `proxy` | `Record / fn` | - | Path-prefix proxy rules or a custom async function |
138
+ | `proxyCORS` | `boolean` | - | Inject CORS headers on proxied responses |
139
+ | `define` | `Record` | - | Compile-time constants for rspack's DefinePlugin |
140
+ | `swcPlugins` | `array` | - | Extra SWC plugins (e.g. Relay compiler) |
141
+ | `moduleRules` | `array` | - | Extra rspack module rules appended to the built-in set (client + SSR) |
142
+ | `fetch` | `function` | - | Custom fetch handler; return a `Response` to short-circuit SSR |
143
+ | `websocket` | `object` | - | WebSocket handler (Bun only) |
119
144
  | `wsPath` | `string` | `"/ws"` | Path that triggers WebSocket upgrade |
120
- | `optimization` | `object` | | Override rspack `optimization` for production client builds (merged on top of defaults) |
145
+ | `htmlTemplate` | `string` | - | Path to a custom HTML template with `HADARS_HEAD` / `HADARS_BODY` markers |
146
+ | `optimization` | `object` | - | Override rspack `optimization` for production client builds |
147
+ | `cache` | `function` | - | SSR response cache for `run()` mode; return `{ key, ttl? }` to cache a request |
148
+
149
+ ### moduleRules example
150
+
151
+ Add support for any loader not included by default:
152
+
153
+ ```ts
154
+ import type { HadarsOptions } from 'hadars';
155
+
156
+ const config: HadarsOptions = {
157
+ entry: 'src/App.tsx',
158
+ moduleRules: [
159
+ {
160
+ test: /\.mdx?$/,
161
+ use: [{ loader: '@mdx-js/loader' }],
162
+ },
163
+ ],
164
+ };
165
+
166
+ export default config;
167
+ ```
168
+
169
+ ### SSR cache example
170
+
171
+ ```ts
172
+ import type { HadarsOptions } from 'hadars';
173
+
174
+ const config: HadarsOptions = {
175
+ entry: 'src/App.tsx',
176
+ // Cache every page by pathname for 60 seconds, skip authenticated requests
177
+ cache: (req) => req.cookies.session ? null : { key: req.pathname, ttl: 60_000 },
178
+ };
179
+
180
+ export default config;
181
+ ```
182
+
183
+ ## slim-react
184
+
185
+ hadars ships its own lightweight React-compatible SSR renderer called **slim-react** (`src/slim-react/`). It replaces `react-dom/server` entirely on the server side - no `renderToStaticMarkup`, no `renderToPipeableStream`, no react-dom dependency at all.
186
+
187
+ For server builds, rspack aliases `react` and `react/jsx-runtime` to slim-react, so your components and any libraries they import render through it automatically without any code changes.
188
+
189
+ **What it does:**
190
+
191
+ - Renders the full component tree to an HTML string with native `async/await` support - async components and hooks that return Promises are awaited directly without streaming workarounds
192
+ - Implements the React Suspense protocol: when a component throws a Promise (e.g. from `useServerData` or a Suspense-enabled data library), slim-react awaits it and retries the tree automatically
193
+ - Emits React-compatible hydration markers - `<!--$-->…<!--/$-->` for resolved Suspense boundaries, `<!-- -->` separators between adjacent text nodes - so `hydrateRoot` on the client works without mismatches
194
+ - Supports `React.memo`, `React.forwardRef`, `React.lazy`, `Context.Provider`, `Context.Consumer`, and the React 18/19 element wire formats
195
+ - Covers the full hook surface needed for SSR: `useState`, `useReducer`, `useContext`, `useRef`, `useMemo`, `useCallback`, `useId`, `useSyncExternalStore`, `use`, and more - all as lightweight SSR stubs
196
+
197
+ **Why not react-dom/server?**
198
+
199
+ `react-dom/server` cannot `await` arbitrary Promises thrown during render - it only handles Suspense via streaming and requires components to use `React.lazy` or Relay-style Suspense resources. slim-react's retry loop makes `useServerData` (and any hook that throws a Promise) work without wrapping every async component in a `<Suspense>` boundary.
121
200
 
122
201
  ## License
123
202
 
package/cli-lib.ts CHANGED
@@ -99,17 +99,185 @@ dist/
99
99
  `import React from 'react';
100
100
  import { HadarsContext, HadarsHead, type HadarsApp } from 'hadars';
101
101
 
102
- const App: HadarsApp<{}> = ({ context }) => (
103
- <HadarsContext context={context}>
104
- <HadarsHead status={200}>
105
- <title>My App</title>
106
- </HadarsHead>
107
- <main>
108
- <h1>Hello from hadars!</h1>
109
- <p>Edit <code>src/App.tsx</code> to get started.</p>
110
- </main>
111
- </HadarsContext>
112
- );
102
+ const css = \`
103
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
104
+
105
+ body {
106
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
107
+ background: #0f0f13;
108
+ color: #e2e8f0;
109
+ min-height: 100vh;
110
+ }
111
+
112
+ .nav {
113
+ display: flex;
114
+ align-items: center;
115
+ justify-content: space-between;
116
+ padding: 1rem 2rem;
117
+ border-bottom: 1px solid #1e1e2e;
118
+ }
119
+ .nav-brand { font-weight: 700; font-size: 1.1rem; color: #a78bfa; letter-spacing: -0.02em; }
120
+ .nav-links { display: flex; gap: 1.5rem; }
121
+ .nav-links a { color: #94a3b8; text-decoration: none; font-size: 0.9rem; }
122
+ .nav-links a:hover { color: #e2e8f0; }
123
+
124
+ .hero {
125
+ text-align: center;
126
+ padding: 5rem 1rem 4rem;
127
+ max-width: 680px;
128
+ margin: 0 auto;
129
+ }
130
+ .hero-badge {
131
+ display: inline-block;
132
+ background: #1e1a2e;
133
+ border: 1px solid #4c1d95;
134
+ color: #a78bfa;
135
+ font-size: 0.75rem;
136
+ font-weight: 600;
137
+ letter-spacing: 0.05em;
138
+ text-transform: uppercase;
139
+ padding: 0.3rem 0.8rem;
140
+ border-radius: 999px;
141
+ margin-bottom: 1.5rem;
142
+ }
143
+ .hero h1 {
144
+ font-size: clamp(2rem, 5vw, 3.25rem);
145
+ font-weight: 800;
146
+ letter-spacing: -0.03em;
147
+ line-height: 1.15;
148
+ margin-bottom: 1rem;
149
+ }
150
+ .hero h1 span { color: #a78bfa; }
151
+ .hero p {
152
+ font-size: 1.1rem;
153
+ color: #94a3b8;
154
+ line-height: 1.7;
155
+ margin-bottom: 2.5rem;
156
+ }
157
+ .hero-actions { display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap; }
158
+ .btn {
159
+ display: inline-flex;
160
+ align-items: center;
161
+ gap: 0.4rem;
162
+ padding: 0.65rem 1.4rem;
163
+ border-radius: 8px;
164
+ font-size: 0.9rem;
165
+ font-weight: 600;
166
+ cursor: pointer;
167
+ border: none;
168
+ transition: opacity 0.15s, transform 0.1s;
169
+ text-decoration: none;
170
+ }
171
+ .btn:hover { opacity: 0.85; transform: translateY(-1px); }
172
+ .btn:active { transform: translateY(0); }
173
+ .btn-primary { background: #7c3aed; color: #fff; }
174
+ .btn-ghost { background: #1e1e2e; color: #e2e8f0; border: 1px solid #2d2d3e; }
175
+
176
+ .features {
177
+ display: grid;
178
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
179
+ gap: 1rem;
180
+ max-width: 900px;
181
+ margin: 0 auto 4rem;
182
+ padding: 0 1.5rem;
183
+ }
184
+ .card {
185
+ background: #16161f;
186
+ border: 1px solid #1e1e2e;
187
+ border-radius: 12px;
188
+ padding: 1.5rem;
189
+ }
190
+ .card-icon { font-size: 1.5rem; margin-bottom: 0.75rem; }
191
+ .card h3 { font-size: 0.95rem; font-weight: 700; margin-bottom: 0.4rem; }
192
+ .card p { font-size: 0.85rem; color: #64748b; line-height: 1.6; }
193
+
194
+ .demo {
195
+ max-width: 480px;
196
+ margin: 0 auto 4rem;
197
+ padding: 0 1.5rem;
198
+ text-align: center;
199
+ }
200
+ .demo-box {
201
+ background: #16161f;
202
+ border: 1px solid #1e1e2e;
203
+ border-radius: 12px;
204
+ padding: 2rem;
205
+ }
206
+ .demo-box h2 { font-size: 0.8rem; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 1.25rem; }
207
+ .counter { font-size: 3.5rem; font-weight: 800; color: #a78bfa; letter-spacing: -0.04em; margin-bottom: 1.25rem; }
208
+ .demo-actions { display: flex; gap: 0.75rem; justify-content: center; }
209
+
210
+ \`;
211
+
212
+ const App: HadarsApp<{}> = ({ context }) => {
213
+ const [count, setCount] = React.useState(0);
214
+
215
+ return (
216
+ <HadarsContext context={context}>
217
+ <HadarsHead status={200}>
218
+ <title>My App</title>
219
+ <meta id="viewport" name="viewport" content="width=device-width, initial-scale=1" />
220
+ <style id="app-styles" dangerouslySetInnerHTML={{ __html: css }} />
221
+ </HadarsHead>
222
+
223
+ <nav className="nav">
224
+ <span className="nav-brand">my-app</span>
225
+ <div className="nav-links">
226
+ <a href="https://github.com/dpostolachi/hadar" target="_blank" rel="noopener">github</a>
227
+ </div>
228
+ </nav>
229
+
230
+ <section className="hero">
231
+ <div className="hero-badge">built with hadars</div>
232
+ <h1>Ship <span>React apps</span><br />at full speed</h1>
233
+ <p>
234
+ SSR out of the box, zero config, instant hot-reload.
235
+ Edit <code>src/App.tsx</code> to get started.
236
+ </p>
237
+ <div className="hero-actions">
238
+ <button className="btn btn-primary" onClick={() => setCount(c => c + 1)}>
239
+ Try the counter ↓
240
+ </button>
241
+ </div>
242
+ </section>
243
+
244
+ <div className="features">
245
+ <div className="card">
246
+ <div className="card-icon">⚡</div>
247
+ <h3>Server-side rendering</h3>
248
+ <p>Pages render on the server and hydrate on the client — great for SEO and first paint.</p>
249
+ </div>
250
+ <div className="card">
251
+ <div className="card-icon">🔥</div>
252
+ <h3>Hot module reload</h3>
253
+ <p>Changes in <code>src/App.tsx</code> reflect instantly in the browser during development.</p>
254
+ </div>
255
+ <div className="card">
256
+ <div className="card-icon">📦</div>
257
+ <h3>Zero config</h3>
258
+ <p>One config file. Export a React component, run <code>hadars dev</code>, done.</p>
259
+ </div>
260
+ <div className="card">
261
+ <div className="card-icon">🗄️</div>
262
+ <h3>Server data hooks</h3>
263
+ <p>Use <code>useServerData</code> to fetch data on the server without extra round-trips.</p>
264
+ </div>
265
+ </div>
266
+
267
+ <div className="demo">
268
+ <div className="demo-box">
269
+ <h2>Client interactivity works</h2>
270
+ <div className="counter">{count}</div>
271
+ <div className="demo-actions">
272
+ <button className="btn btn-ghost" onClick={() => setCount(c => c - 1)}>− dec</button>
273
+ <button className="btn btn-primary" onClick={() => setCount(c => c + 1)}>+ inc</button>
274
+ </div>
275
+ </div>
276
+ </div>
277
+
278
+ </HadarsContext>
279
+ );
280
+ };
113
281
 
114
282
  export default App;
115
283
  `,
package/dist/cli.js CHANGED
@@ -892,15 +892,6 @@ var getConfigBase = (mode) => {
892
892
  },
893
893
  module: {
894
894
  rules: [
895
- {
896
- test: /\.mdx?$/,
897
- use: [
898
- {
899
- loader: "@mdx-js/loader",
900
- options: {}
901
- }
902
- ]
903
- },
904
895
  {
905
896
  test: /\.css$/,
906
897
  use: ["postcss-loader"],
@@ -935,7 +926,7 @@ var getConfigBase = (mode) => {
935
926
  react: {
936
927
  runtime: "automatic",
937
928
  development: isDev,
938
- refresh: isDev
929
+ refresh: isDev && !isServerBuild
939
930
  }
940
931
  }
941
932
  }
@@ -966,7 +957,7 @@ var getConfigBase = (mode) => {
966
957
  react: {
967
958
  runtime: "automatic",
968
959
  development: isDev,
969
- refresh: isDev
960
+ refresh: isDev && !isServerBuild
970
961
  }
971
962
  }
972
963
  }
@@ -1028,12 +1019,15 @@ var buildCompilerConfig = (entry, opts, includeHotPlugin) => {
1028
1019
  }
1029
1020
  }
1030
1021
  }
1031
- const isServerBuild = Boolean(
1022
+ if (opts.moduleRules && opts.moduleRules.length > 0) {
1023
+ localConfig.module.rules.push(...opts.moduleRules);
1024
+ }
1025
+ const isServerBuild2 = Boolean(
1032
1026
  opts.output && typeof opts.output === "object" && (opts.output.library || String(opts.output.filename || "").includes("ssr"))
1033
1027
  );
1034
1028
  const slimReactIndex = pathMod.resolve(packageDir, "slim-react", "index.js");
1035
1029
  const slimReactJsx = pathMod.resolve(packageDir, "slim-react", "jsx-runtime.js");
1036
- const resolveAliases = isServerBuild ? {
1030
+ const resolveAliases = isServerBuild2 ? {
1037
1031
  // Route all React imports to slim-react for SSR.
1038
1032
  react: slimReactIndex,
1039
1033
  "react/jsx-runtime": slimReactJsx,
@@ -1044,7 +1038,7 @@ var buildCompilerConfig = (entry, opts, includeHotPlugin) => {
1044
1038
  "@emotion/cache": path.resolve(process.cwd(), "node_modules", "@emotion", "cache"),
1045
1039
  "@emotion/styled": path.resolve(process.cwd(), "node_modules", "@emotion", "styled")
1046
1040
  } : void 0;
1047
- const externals = isServerBuild ? [
1041
+ const externals = isServerBuild2 ? [
1048
1042
  // react / react-dom are replaced by slim-react via alias above — not external.
1049
1043
  // emotion should be external on server builds to avoid client/browser code
1050
1044
  "@emotion/react",
@@ -1065,9 +1059,9 @@ var buildCompilerConfig = (entry, opts, includeHotPlugin) => {
1065
1059
  extensions: [".tsx", ".ts", ".js", ".jsx"],
1066
1060
  alias: resolveAliases,
1067
1061
  // for server builds prefer the package "main"/"module" fields and avoid "browser" so we don't pick browser-specific entrypoints
1068
- mainFields: isServerBuild ? ["main", "module"] : ["browser", "module", "main"]
1062
+ mainFields: isServerBuild2 ? ["main", "module"] : ["browser", "module", "main"]
1069
1063
  };
1070
- const optimization = !isServerBuild && !isDev ? {
1064
+ const optimization = !isServerBuild2 && !isDev ? {
1071
1065
  moduleIds: "deterministic",
1072
1066
  splitChunks: {
1073
1067
  chunks: "all",
@@ -1127,7 +1121,7 @@ var buildCompilerConfig = (entry, opts, includeHotPlugin) => {
1127
1121
  });
1128
1122
  }
1129
1123
  },
1130
- isDev && new ReactRefreshPlugin(),
1124
+ isDev && !isServerBuild2 && new ReactRefreshPlugin(),
1131
1125
  includeHotPlugin && isDev && new rspack.HotModuleReplacementPlugin(),
1132
1126
  ...extraPlugins
1133
1127
  ],
@@ -1143,7 +1137,7 @@ var buildCompilerConfig = (entry, opts, includeHotPlugin) => {
1143
1137
  // for client builds. SSR builds still need it for dynamic import() of exports.
1144
1138
  experiments: {
1145
1139
  ...localConfig.experiments || {},
1146
- outputModule: isServerBuild
1140
+ outputModule: isServerBuild2
1147
1141
  },
1148
1142
  // Prevent rspack from watching its own build output — without this the
1149
1143
  // SSR watcher writing .hadars/index.ssr.js triggers the client compiler
@@ -1790,6 +1784,7 @@ var dev = async (options) => {
1790
1784
  mode: "development",
1791
1785
  swcPlugins: options.swcPlugins,
1792
1786
  define: options.define,
1787
+ moduleRules: options.moduleRules,
1793
1788
  htmlTemplate: resolvedHtmlTemplate
1794
1789
  });
1795
1790
  const devServer = new RspackDevServer({
@@ -1826,7 +1821,8 @@ var dev = async (options) => {
1826
1821
  `--outFile=${SSR_FILENAME}`,
1827
1822
  `--base=${baseURL}`,
1828
1823
  ...options.swcPlugins ? [`--swcPlugins=${JSON.stringify(options.swcPlugins)}`] : [],
1829
- ...options.define ? [`--define=${JSON.stringify(options.define)}`] : []
1824
+ ...options.define ? [`--define=${JSON.stringify(options.define)}`] : [],
1825
+ ...options.moduleRules ? [`--moduleRules=${JSON.stringify(options.moduleRules)}`] : []
1830
1826
  ], { stdio: "pipe" });
1831
1827
  child.stdin?.end();
1832
1828
  const cleanupChild = () => {
@@ -2006,6 +2002,7 @@ var build = async (options) => {
2006
2002
  mode: "production",
2007
2003
  swcPlugins: options.swcPlugins,
2008
2004
  define: options.define,
2005
+ moduleRules: options.moduleRules,
2009
2006
  optimization: options.optimization,
2010
2007
  htmlTemplate: resolvedHtmlTemplate
2011
2008
  }),
@@ -2021,7 +2018,8 @@ var build = async (options) => {
2021
2018
  target: "node",
2022
2019
  mode: "production",
2023
2020
  swcPlugins: options.swcPlugins,
2024
- define: options.define
2021
+ define: options.define,
2022
+ moduleRules: options.moduleRules
2025
2023
  })
2026
2024
  ]);
2027
2025
  await fs.rm(tmpFilePath);
@@ -2212,17 +2210,185 @@ dist/
2212
2210
  "src/App.tsx": () => `import React from 'react';
2213
2211
  import { HadarsContext, HadarsHead, type HadarsApp } from 'hadars';
2214
2212
 
2215
- const App: HadarsApp<{}> = ({ context }) => (
2216
- <HadarsContext context={context}>
2217
- <HadarsHead status={200}>
2218
- <title>My App</title>
2219
- </HadarsHead>
2220
- <main>
2221
- <h1>Hello from hadars!</h1>
2222
- <p>Edit <code>src/App.tsx</code> to get started.</p>
2223
- </main>
2224
- </HadarsContext>
2225
- );
2213
+ const css = \`
2214
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
2215
+
2216
+ body {
2217
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
2218
+ background: #0f0f13;
2219
+ color: #e2e8f0;
2220
+ min-height: 100vh;
2221
+ }
2222
+
2223
+ .nav {
2224
+ display: flex;
2225
+ align-items: center;
2226
+ justify-content: space-between;
2227
+ padding: 1rem 2rem;
2228
+ border-bottom: 1px solid #1e1e2e;
2229
+ }
2230
+ .nav-brand { font-weight: 700; font-size: 1.1rem; color: #a78bfa; letter-spacing: -0.02em; }
2231
+ .nav-links { display: flex; gap: 1.5rem; }
2232
+ .nav-links a { color: #94a3b8; text-decoration: none; font-size: 0.9rem; }
2233
+ .nav-links a:hover { color: #e2e8f0; }
2234
+
2235
+ .hero {
2236
+ text-align: center;
2237
+ padding: 5rem 1rem 4rem;
2238
+ max-width: 680px;
2239
+ margin: 0 auto;
2240
+ }
2241
+ .hero-badge {
2242
+ display: inline-block;
2243
+ background: #1e1a2e;
2244
+ border: 1px solid #4c1d95;
2245
+ color: #a78bfa;
2246
+ font-size: 0.75rem;
2247
+ font-weight: 600;
2248
+ letter-spacing: 0.05em;
2249
+ text-transform: uppercase;
2250
+ padding: 0.3rem 0.8rem;
2251
+ border-radius: 999px;
2252
+ margin-bottom: 1.5rem;
2253
+ }
2254
+ .hero h1 {
2255
+ font-size: clamp(2rem, 5vw, 3.25rem);
2256
+ font-weight: 800;
2257
+ letter-spacing: -0.03em;
2258
+ line-height: 1.15;
2259
+ margin-bottom: 1rem;
2260
+ }
2261
+ .hero h1 span { color: #a78bfa; }
2262
+ .hero p {
2263
+ font-size: 1.1rem;
2264
+ color: #94a3b8;
2265
+ line-height: 1.7;
2266
+ margin-bottom: 2.5rem;
2267
+ }
2268
+ .hero-actions { display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap; }
2269
+ .btn {
2270
+ display: inline-flex;
2271
+ align-items: center;
2272
+ gap: 0.4rem;
2273
+ padding: 0.65rem 1.4rem;
2274
+ border-radius: 8px;
2275
+ font-size: 0.9rem;
2276
+ font-weight: 600;
2277
+ cursor: pointer;
2278
+ border: none;
2279
+ transition: opacity 0.15s, transform 0.1s;
2280
+ text-decoration: none;
2281
+ }
2282
+ .btn:hover { opacity: 0.85; transform: translateY(-1px); }
2283
+ .btn:active { transform: translateY(0); }
2284
+ .btn-primary { background: #7c3aed; color: #fff; }
2285
+ .btn-ghost { background: #1e1e2e; color: #e2e8f0; border: 1px solid #2d2d3e; }
2286
+
2287
+ .features {
2288
+ display: grid;
2289
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
2290
+ gap: 1rem;
2291
+ max-width: 900px;
2292
+ margin: 0 auto 4rem;
2293
+ padding: 0 1.5rem;
2294
+ }
2295
+ .card {
2296
+ background: #16161f;
2297
+ border: 1px solid #1e1e2e;
2298
+ border-radius: 12px;
2299
+ padding: 1.5rem;
2300
+ }
2301
+ .card-icon { font-size: 1.5rem; margin-bottom: 0.75rem; }
2302
+ .card h3 { font-size: 0.95rem; font-weight: 700; margin-bottom: 0.4rem; }
2303
+ .card p { font-size: 0.85rem; color: #64748b; line-height: 1.6; }
2304
+
2305
+ .demo {
2306
+ max-width: 480px;
2307
+ margin: 0 auto 4rem;
2308
+ padding: 0 1.5rem;
2309
+ text-align: center;
2310
+ }
2311
+ .demo-box {
2312
+ background: #16161f;
2313
+ border: 1px solid #1e1e2e;
2314
+ border-radius: 12px;
2315
+ padding: 2rem;
2316
+ }
2317
+ .demo-box h2 { font-size: 0.8rem; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 1.25rem; }
2318
+ .counter { font-size: 3.5rem; font-weight: 800; color: #a78bfa; letter-spacing: -0.04em; margin-bottom: 1.25rem; }
2319
+ .demo-actions { display: flex; gap: 0.75rem; justify-content: center; }
2320
+
2321
+ \`;
2322
+
2323
+ const App: HadarsApp<{}> = ({ context }) => {
2324
+ const [count, setCount] = React.useState(0);
2325
+
2326
+ return (
2327
+ <HadarsContext context={context}>
2328
+ <HadarsHead status={200}>
2329
+ <title>My App</title>
2330
+ <meta id="viewport" name="viewport" content="width=device-width, initial-scale=1" />
2331
+ <style id="app-styles" dangerouslySetInnerHTML={{ __html: css }} />
2332
+ </HadarsHead>
2333
+
2334
+ <nav className="nav">
2335
+ <span className="nav-brand">my-app</span>
2336
+ <div className="nav-links">
2337
+ <a href="https://github.com/dpostolachi/hadar" target="_blank" rel="noopener">github</a>
2338
+ </div>
2339
+ </nav>
2340
+
2341
+ <section className="hero">
2342
+ <div className="hero-badge">built with hadars</div>
2343
+ <h1>Ship <span>React apps</span><br />at full speed</h1>
2344
+ <p>
2345
+ SSR out of the box, zero config, instant hot-reload.
2346
+ Edit <code>src/App.tsx</code> to get started.
2347
+ </p>
2348
+ <div className="hero-actions">
2349
+ <button className="btn btn-primary" onClick={() => setCount(c => c + 1)}>
2350
+ Try the counter \u2193
2351
+ </button>
2352
+ </div>
2353
+ </section>
2354
+
2355
+ <div className="features">
2356
+ <div className="card">
2357
+ <div className="card-icon">\u26A1</div>
2358
+ <h3>Server-side rendering</h3>
2359
+ <p>Pages render on the server and hydrate on the client \u2014 great for SEO and first paint.</p>
2360
+ </div>
2361
+ <div className="card">
2362
+ <div className="card-icon">\u{1F525}</div>
2363
+ <h3>Hot module reload</h3>
2364
+ <p>Changes in <code>src/App.tsx</code> reflect instantly in the browser during development.</p>
2365
+ </div>
2366
+ <div className="card">
2367
+ <div className="card-icon">\u{1F4E6}</div>
2368
+ <h3>Zero config</h3>
2369
+ <p>One config file. Export a React component, run <code>hadars dev</code>, done.</p>
2370
+ </div>
2371
+ <div className="card">
2372
+ <div className="card-icon">\u{1F5C4}\uFE0F</div>
2373
+ <h3>Server data hooks</h3>
2374
+ <p>Use <code>useServerData</code> to fetch data on the server without extra round-trips.</p>
2375
+ </div>
2376
+ </div>
2377
+
2378
+ <div className="demo">
2379
+ <div className="demo-box">
2380
+ <h2>Client interactivity works</h2>
2381
+ <div className="counter">{count}</div>
2382
+ <div className="demo-actions">
2383
+ <button className="btn btn-ghost" onClick={() => setCount(c => c - 1)}>\u2212 dec</button>
2384
+ <button className="btn btn-primary" onClick={() => setCount(c => c + 1)}>+ inc</button>
2385
+ </div>
2386
+ </div>
2387
+ </div>
2388
+
2389
+ </HadarsContext>
2390
+ );
2391
+ };
2226
2392
 
2227
2393
  export default App;
2228
2394
  `
package/dist/index.d.ts CHANGED
@@ -111,6 +111,22 @@ interface HadarsOptions {
111
111
  * Note: inline styles are processed once at startup and are not live-reloaded.
112
112
  */
113
113
  htmlTemplate?: string;
114
+ /**
115
+ * Additional rspack module rules appended to the built-in rule set.
116
+ * Applied to both the client and the SSR bundle.
117
+ *
118
+ * Useful for loaders not included by default, such as `@mdx-js/loader`,
119
+ * `less-loader`, `yaml-loader`, etc.
120
+ *
121
+ * @example
122
+ * moduleRules: [
123
+ * {
124
+ * test: /\.mdx?$/,
125
+ * use: [{ loader: '@mdx-js/loader' }],
126
+ * },
127
+ * ]
128
+ */
129
+ moduleRules?: Record<string, any>[];
114
130
  /**
115
131
  * SSR response cache for `run()` mode. Has no effect in `dev()` mode.
116
132
  *
package/dist/ssr-watch.js CHANGED
@@ -32,15 +32,6 @@ var getConfigBase = (mode) => {
32
32
  },
33
33
  module: {
34
34
  rules: [
35
- {
36
- test: /\.mdx?$/,
37
- use: [
38
- {
39
- loader: "@mdx-js/loader",
40
- options: {}
41
- }
42
- ]
43
- },
44
35
  {
45
36
  test: /\.css$/,
46
37
  use: ["postcss-loader"],
@@ -75,7 +66,7 @@ var getConfigBase = (mode) => {
75
66
  react: {
76
67
  runtime: "automatic",
77
68
  development: isDev,
78
- refresh: isDev
69
+ refresh: isDev && !isServerBuild
79
70
  }
80
71
  }
81
72
  }
@@ -106,7 +97,7 @@ var getConfigBase = (mode) => {
106
97
  react: {
107
98
  runtime: "automatic",
108
99
  development: isDev,
109
- refresh: isDev
100
+ refresh: isDev && !isServerBuild
110
101
  }
111
102
  }
112
103
  }
@@ -168,12 +159,15 @@ var buildCompilerConfig = (entry2, opts, includeHotPlugin) => {
168
159
  }
169
160
  }
170
161
  }
171
- const isServerBuild = Boolean(
162
+ if (opts.moduleRules && opts.moduleRules.length > 0) {
163
+ localConfig.module.rules.push(...opts.moduleRules);
164
+ }
165
+ const isServerBuild2 = Boolean(
172
166
  opts.output && typeof opts.output === "object" && (opts.output.library || String(opts.output.filename || "").includes("ssr"))
173
167
  );
174
168
  const slimReactIndex = pathMod.resolve(packageDir, "slim-react", "index.js");
175
169
  const slimReactJsx = pathMod.resolve(packageDir, "slim-react", "jsx-runtime.js");
176
- const resolveAliases = isServerBuild ? {
170
+ const resolveAliases = isServerBuild2 ? {
177
171
  // Route all React imports to slim-react for SSR.
178
172
  react: slimReactIndex,
179
173
  "react/jsx-runtime": slimReactJsx,
@@ -184,7 +178,7 @@ var buildCompilerConfig = (entry2, opts, includeHotPlugin) => {
184
178
  "@emotion/cache": path.resolve(process.cwd(), "node_modules", "@emotion", "cache"),
185
179
  "@emotion/styled": path.resolve(process.cwd(), "node_modules", "@emotion", "styled")
186
180
  } : void 0;
187
- const externals = isServerBuild ? [
181
+ const externals = isServerBuild2 ? [
188
182
  // react / react-dom are replaced by slim-react via alias above — not external.
189
183
  // emotion should be external on server builds to avoid client/browser code
190
184
  "@emotion/react",
@@ -205,9 +199,9 @@ var buildCompilerConfig = (entry2, opts, includeHotPlugin) => {
205
199
  extensions: [".tsx", ".ts", ".js", ".jsx"],
206
200
  alias: resolveAliases,
207
201
  // for server builds prefer the package "main"/"module" fields and avoid "browser" so we don't pick browser-specific entrypoints
208
- mainFields: isServerBuild ? ["main", "module"] : ["browser", "module", "main"]
202
+ mainFields: isServerBuild2 ? ["main", "module"] : ["browser", "module", "main"]
209
203
  };
210
- const optimization = !isServerBuild && !isDev ? {
204
+ const optimization = !isServerBuild2 && !isDev ? {
211
205
  moduleIds: "deterministic",
212
206
  splitChunks: {
213
207
  chunks: "all",
@@ -267,7 +261,7 @@ var buildCompilerConfig = (entry2, opts, includeHotPlugin) => {
267
261
  });
268
262
  }
269
263
  },
270
- isDev && new ReactRefreshPlugin(),
264
+ isDev && !isServerBuild2 && new ReactRefreshPlugin(),
271
265
  includeHotPlugin && isDev && new rspack.HotModuleReplacementPlugin(),
272
266
  ...extraPlugins
273
267
  ],
@@ -283,7 +277,7 @@ var buildCompilerConfig = (entry2, opts, includeHotPlugin) => {
283
277
  // for client builds. SSR builds still need it for dynamic import() of exports.
284
278
  experiments: {
285
279
  ...localConfig.experiments || {},
286
- outputModule: isServerBuild
280
+ outputModule: isServerBuild2
287
281
  },
288
282
  // Prevent rspack from watching its own build output — without this the
289
283
  // SSR watcher writing .hadars/index.ssr.js triggers the client compiler
@@ -360,6 +354,7 @@ var outFile = argv["outFile"] || "index.ssr.js";
360
354
  var base = argv["base"] || "";
361
355
  var swcPlugins = argv["swcPlugins"] ? JSON.parse(argv["swcPlugins"]) : void 0;
362
356
  var define = argv["define"] ? JSON.parse(argv["define"]) : void 0;
357
+ var moduleRules = argv["moduleRules"] ? JSON.parse(argv["moduleRules"]) : void 0;
363
358
  if (!entry) {
364
359
  console.error("ssr-watch: missing --entry argument");
365
360
  process.exit(1);
@@ -381,6 +376,7 @@ if (!entry) {
381
376
  watch: true,
382
377
  swcPlugins,
383
378
  define,
379
+ moduleRules,
384
380
  onChange: () => {
385
381
  console.log("ssr-watch: SSR rebuilt");
386
382
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hadars",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "Minimal SSR framework for React — rspack, HMR, TypeScript, Bun/Node/Deno",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",
@@ -77,7 +77,6 @@
77
77
  "typescript": "^5.9.3"
78
78
  },
79
79
  "dependencies": {
80
- "@mdx-js/loader": "^3.1.1",
81
80
  "@svgr/webpack": "^8.1.0",
82
81
  "postcss": "^8.5.8",
83
82
  "postcss-load-config": "^6.0.1",
package/src/build.ts CHANGED
@@ -507,6 +507,7 @@ export const dev = async (options: HadarsRuntimeOptions) => {
507
507
  mode: 'development',
508
508
  swcPlugins: options.swcPlugins,
509
509
  define: options.define,
510
+ moduleRules: options.moduleRules,
510
511
  htmlTemplate: resolvedHtmlTemplate,
511
512
  });
512
513
 
@@ -554,6 +555,7 @@ export const dev = async (options: HadarsRuntimeOptions) => {
554
555
  `--base=${baseURL}`,
555
556
  ...(options.swcPlugins ? [`--swcPlugins=${JSON.stringify(options.swcPlugins)}`] : []),
556
557
  ...(options.define ? [`--define=${JSON.stringify(options.define)}`] : []),
558
+ ...(options.moduleRules ? [`--moduleRules=${JSON.stringify(options.moduleRules)}`] : []),
557
559
  ], { stdio: 'pipe' });
558
560
  child.stdin?.end();
559
561
 
@@ -747,6 +749,7 @@ export const build = async (options: HadarsRuntimeOptions) => {
747
749
  mode: 'production',
748
750
  swcPlugins: options.swcPlugins,
749
751
  define: options.define,
752
+ moduleRules: options.moduleRules,
750
753
  optimization: options.optimization,
751
754
  htmlTemplate: resolvedHtmlTemplate,
752
755
  }),
@@ -763,6 +766,7 @@ export const build = async (options: HadarsRuntimeOptions) => {
763
766
  mode: 'production',
764
767
  swcPlugins: options.swcPlugins,
765
768
  define: options.define,
769
+ moduleRules: options.moduleRules,
766
770
  }),
767
771
  ]);
768
772
  await fs.rm(tmpFilePath);
package/src/ssr-watch.ts CHANGED
@@ -21,6 +21,7 @@ const outFile = argv['outFile'] || 'index.ssr.js';
21
21
  const base = argv['base'] || '';
22
22
  const swcPlugins = argv['swcPlugins'] ? JSON.parse(argv['swcPlugins']) : undefined;
23
23
  const define = argv['define'] ? JSON.parse(argv['define']) : undefined;
24
+ const moduleRules = argv['moduleRules'] ? JSON.parse(argv['moduleRules']) : undefined;
24
25
 
25
26
  if (!entry) {
26
27
  console.error('ssr-watch: missing --entry argument');
@@ -44,6 +45,7 @@ if (!entry) {
44
45
  watch: true,
45
46
  swcPlugins,
46
47
  define,
48
+ moduleRules,
47
49
  onChange: () => {
48
50
  console.log('ssr-watch: SSR rebuilt');
49
51
  }
@@ -111,6 +111,22 @@ export interface HadarsOptions {
111
111
  * Note: inline styles are processed once at startup and are not live-reloaded.
112
112
  */
113
113
  htmlTemplate?: string;
114
+ /**
115
+ * Additional rspack module rules appended to the built-in rule set.
116
+ * Applied to both the client and the SSR bundle.
117
+ *
118
+ * Useful for loaders not included by default, such as `@mdx-js/loader`,
119
+ * `less-loader`, `yaml-loader`, etc.
120
+ *
121
+ * @example
122
+ * moduleRules: [
123
+ * {
124
+ * test: /\.mdx?$/,
125
+ * use: [{ loader: '@mdx-js/loader' }],
126
+ * },
127
+ * ]
128
+ */
129
+ moduleRules?: Record<string, any>[];
114
130
  /**
115
131
  * SSR response cache for `run()` mode. Has no effect in `dev()` mode.
116
132
  *
@@ -38,16 +38,6 @@ const getConfigBase = (mode: "development" | "production"): Omit<Configuration,
38
38
  },
39
39
  module: {
40
40
  rules: [
41
- {
42
- test: /\.mdx?$/,
43
- use: [
44
- {
45
- loader: '@mdx-js/loader',
46
- options: {
47
- },
48
- },
49
- ],
50
- },
51
41
  {
52
42
  test: /\.css$/,
53
43
  use: ["postcss-loader"],
@@ -82,7 +72,7 @@ const getConfigBase = (mode: "development" | "production"): Omit<Configuration,
82
72
  react: {
83
73
  runtime: "automatic",
84
74
  development: isDev,
85
- refresh: isDev,
75
+ refresh: isDev && !isServerBuild,
86
76
  },
87
77
  },
88
78
  },
@@ -113,7 +103,7 @@ const getConfigBase = (mode: "development" | "production"): Omit<Configuration,
113
103
  react: {
114
104
  runtime: "automatic",
115
105
  development: isDev,
116
- refresh: isDev,
106
+ refresh: isDev && !isServerBuild,
117
107
  },
118
108
  },
119
109
  },
@@ -142,6 +132,8 @@ interface EntryOptions {
142
132
  base?: string;
143
133
  // optional rspack optimization overrides (production client builds only)
144
134
  optimization?: Record<string, unknown>;
135
+ // additional module rules appended after the built-in rules
136
+ moduleRules?: Record<string, any>[];
145
137
  }
146
138
 
147
139
  const buildCompilerConfig = (
@@ -207,6 +199,10 @@ const buildCompilerConfig = (
207
199
  }
208
200
  }
209
201
 
202
+ if (opts.moduleRules && opts.moduleRules.length > 0) {
203
+ localConfig.module.rules.push(...opts.moduleRules);
204
+ }
205
+
210
206
  // For server (SSR) builds we should avoid bundling react/react-dom so
211
207
  // the runtime uses the same React instance as the host. If the output
212
208
  // is a library/module (i.e. `opts.output.library` present or filename
@@ -330,7 +326,7 @@ const buildCompilerConfig = (
330
326
  });
331
327
  },
332
328
  },
333
- isDev && new ReactRefreshPlugin(),
329
+ isDev && !isServerBuild && new ReactRefreshPlugin(),
334
330
  includeHotPlugin && isDev && new rspack.HotModuleReplacementPlugin(),
335
331
  ...extraPlugins,
336
332
  ],