hadars 0.2.1 → 0.2.2-rc.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/README.md CHANGED
@@ -19,30 +19,30 @@ Bring your own router (or none), keep your components as plain React, and get SS
19
19
  ## Benchmarks
20
20
 
21
21
  <!-- BENCHMARK_START -->
22
- > Last run: 2026-03-20 · 60s · 100 connections · Bun runtime
23
- > hadars is **8.8x faster** in requests/sec
22
+ > Last run: 2026-03-21 · 60s · 100 connections · Bun runtime
23
+ > hadars is **8.7x faster** in requests/sec
24
24
 
25
25
  **Throughput** (autocannon, 60s)
26
26
 
27
27
  | Metric | hadars | Next.js |
28
28
  |---|---:|---:|
29
- | Requests/sec | **159** | 18 |
30
- | Latency median | **598 ms** | 2644 ms |
31
- | Latency p99 | **953 ms** | 6436 ms |
32
- | Throughput | **45.24** MB/s | 10.28 MB/s |
33
- | Peak RSS | 1027.8 MB | **469.8 MB** |
34
- | Avg RSS | 794.1 MB | **417.9 MB** |
35
- | Build time | 0.8 s | 5.8 s |
29
+ | Requests/sec | **148** | 17 |
30
+ | Latency median | **647 ms** | 2712 ms |
31
+ | Latency p99 | **969 ms** | 6544 ms |
32
+ | Throughput | **42.23** MB/s | 9.29 MB/s |
33
+ | Peak RSS | 1062.8 MB | **472.3 MB** |
34
+ | Avg RSS | 799.7 MB | **404.7 MB** |
35
+ | Build time | 0.7 s | 6.0 s |
36
36
 
37
37
  **Page load** (Playwright · Chromium headless · median)
38
38
 
39
39
  | Metric | hadars | Next.js |
40
40
  |---|---:|---:|
41
- | TTFB | **17 ms** | 45 ms |
42
- | FCP | **88 ms** | 128 ms |
43
- | DOMContentLoaded | **36 ms** | 114 ms |
44
- | Load | **115 ms** | 157 ms |
45
- | Peak RSS | 502.8 MB | **282.8 MB** |
41
+ | TTFB | **20 ms** | 43 ms |
42
+ | FCP | **100 ms** | 136 ms |
43
+ | DOMContentLoaded | **41 ms** | 127 ms |
44
+ | Load | **127 ms** | 174 ms |
45
+ | Peak RSS | 444.7 MB | **284.9 MB** |
46
46
  <!-- BENCHMARK_END -->
47
47
 
48
48
  ## Quick start
@@ -114,6 +114,9 @@ hadars build
114
114
  # Serve the production build
115
115
  hadars run
116
116
 
117
+ # Pre-render every page to static HTML files (output goes to out/ by default)
118
+ hadars export static [outDir]
119
+
117
120
  # Bundle the app into a single self-contained Lambda .mjs file
118
121
  hadars export lambda [output.mjs]
119
122
 
@@ -181,6 +184,9 @@ const UserCard = ({ userId }: { userId: string }) => {
181
184
  | `htmlTemplate` | `string` | - | Path to a custom HTML template with `HADARS_HEAD` / `HADARS_BODY` markers |
182
185
  | `optimization` | `object` | - | Override rspack `optimization` for production client builds |
183
186
  | `cache` | `function` | - | SSR response cache for `run()` mode; return `{ key, ttl? }` to cache a request |
187
+ | `paths` | `function` | - | Returns URL list to pre-render with `hadars export static`; receives `HadarsStaticContext` |
188
+ | `sources` | `array` | - | Gatsby-compatible source plugins; hadars infers a GraphQL schema from their nodes |
189
+ | `graphql` | `function` | - | Custom GraphQL executor passed to `paths()` and `getInitProps()` as `ctx.graphql` |
184
190
 
185
191
  ### moduleRules example
186
192
 
@@ -216,6 +222,163 @@ const config: HadarsOptions = {
216
222
  export default config;
217
223
  ```
218
224
 
225
+ ## Static Export
226
+
227
+ > **Experimental.** Static export and Gatsby-compatible source plugins are new features. The API — including config shape, context object, and schema inference behaviour — may change in future releases without a major version bump.
228
+
229
+ Pre-render every page to a plain HTML file and deploy to any static host — no server required.
230
+
231
+ ```bash
232
+ # Output goes to out/ by default
233
+ hadars export static
234
+
235
+ # Custom output directory
236
+ hadars export static dist
237
+ ```
238
+
239
+ Add a `paths` function to `hadars.config.ts` that returns the list of URLs to pre-render:
240
+
241
+ ```ts
242
+ // hadars.config.ts
243
+ import type { HadarsOptions } from 'hadars';
244
+
245
+ export default {
246
+ entry: './src/App.tsx',
247
+ paths: () => ['/', '/about', '/contact'],
248
+ } satisfies HadarsOptions;
249
+ ```
250
+
251
+ Each URL is written as `<outDir>/<path>/index.html` plus an `index.json` sidecar so `useServerData` keeps working on client-side navigation without a live server. Static assets are copied from `.hadars/static/`.
252
+
253
+ ### Data in static pages
254
+
255
+ `getInitProps` receives a `HadarsStaticContext` as its second argument during static export. Use it to fetch data from a database, API, or GraphQL layer:
256
+
257
+ ```ts
258
+ import type { HadarsApp, HadarsRequest, HadarsStaticContext } from 'hadars';
259
+
260
+ export const getInitProps = async (
261
+ req: HadarsRequest,
262
+ ctx?: HadarsStaticContext,
263
+ ): Promise<Props> => {
264
+ if (!ctx) return { posts: [] };
265
+ const { data } = await ctx.graphql('{ allPost { id title } }');
266
+ return { posts: data?.allPost ?? [] };
267
+ };
268
+ ```
269
+
270
+ ## Source Plugins
271
+
272
+ hadars source plugins follow the same API as Gatsby's `sourceNodes` — so most existing Gatsby CMS source plugins work out of the box. Each plugin creates typed nodes in an in-memory store; hadars infers a GraphQL schema automatically and exposes it to `paths()` and `getInitProps()`.
273
+
274
+ During `hadars dev`, a GraphiQL IDE is available at `/__hadars/graphql` so you can explore the inferred schema while you build.
275
+
276
+ ### Install graphql
277
+
278
+ Schema inference requires `graphql` to be installed in your project:
279
+
280
+ ```bash
281
+ npm install graphql
282
+ ```
283
+
284
+ ### Config
285
+
286
+ ```ts
287
+ // hadars.config.ts
288
+ import type { HadarsOptions, HadarsStaticContext } from 'hadars';
289
+
290
+ export default {
291
+ entry: './src/App.tsx',
292
+
293
+ sources: [
294
+ {
295
+ resolve: 'gatsby-source-contentful',
296
+ options: {
297
+ spaceId: process.env.CONTENTFUL_SPACE_ID,
298
+ accessToken: process.env.CONTENTFUL_ACCESS_TOKEN,
299
+ },
300
+ },
301
+ ],
302
+
303
+ paths: async ({ graphql }: HadarsStaticContext) => {
304
+ const { data } = await graphql(`{ allContentfulBlogPost { slug } }`);
305
+ const slugs = data?.allContentfulBlogPost?.map((p: any) => p.slug) ?? [];
306
+ return ['/', ...slugs.map((s: string) => `/post/${s}`)];
307
+ },
308
+ } satisfies HadarsOptions;
309
+ ```
310
+
311
+ ### Local source plugin
312
+
313
+ Pass a pre-imported module instead of a package name to use a local plugin without publishing it to npm:
314
+
315
+ ```ts
316
+ // src/posts-source.ts
317
+ export async function sourceNodes(
318
+ { actions, createNodeId, createContentDigest }: any,
319
+ options: { dataDir: string } = {},
320
+ ) {
321
+ const { createNode } = actions;
322
+ const posts = await fetchPostsFromMyApi();
323
+ for (const post of posts) {
324
+ createNode({
325
+ ...post,
326
+ id: createNodeId(post.slug),
327
+ internal: { type: 'BlogPost', contentDigest: createContentDigest(post) },
328
+ });
329
+ }
330
+ }
331
+ ```
332
+
333
+ ```ts
334
+ // hadars.config.ts
335
+ import * as postsSource from './src/posts-source';
336
+
337
+ export default {
338
+ entry: './src/App.tsx',
339
+ sources: [{ resolve: postsSource }],
340
+ paths: async ({ graphql }) => {
341
+ const { data } = await graphql('{ allBlogPost { slug } }');
342
+ return ['/', ...(data?.allBlogPost ?? []).map((p: any) => `/post/${p.slug}`)];
343
+ },
344
+ } satisfies HadarsOptions;
345
+ ```
346
+
347
+ ### Inferred GraphQL schema
348
+
349
+ For each node type (e.g. `BlogPost`) you get two root queries:
350
+
351
+ | Query | Returns |
352
+ |---|---|
353
+ | `allBlogPost` | Every BlogPost node |
354
+ | `blogPost(id, slug, title, …)` | First node matching all supplied args |
355
+
356
+ All scalar fields are automatically added as optional lookup arguments, so you can do `blogPost(slug: "hello")` without knowing the hashed node id.
357
+
358
+ ### Custom GraphQL executor
359
+
360
+ Skip `sources` and provide a `graphql` executor directly for full control over resolvers:
361
+
362
+ ```ts
363
+ import { graphql, buildSchema } from 'graphql';
364
+ import type { HadarsOptions } from 'hadars';
365
+
366
+ const schema = buildSchema(`
367
+ type Post { id: ID! title: String slug: String }
368
+ type Query { allPost: [Post!]! }
369
+ `);
370
+
371
+ export default {
372
+ entry: './src/App.tsx',
373
+ graphql: (query, variables) =>
374
+ graphql({ schema, rootValue: { allPost: fetchPostsFromDb }, source: query, variableValues: variables }),
375
+ paths: async ({ graphql }) => {
376
+ const { data } = await graphql('{ allPost { slug } }');
377
+ return ['/', ...(data?.allPost ?? []).map((p: any) => `/post/${p.slug}`)];
378
+ },
379
+ } satisfies HadarsOptions;
380
+ ```
381
+
219
382
  ## AWS Lambda
220
383
 
221
384
  hadars apps can be deployed to AWS Lambda backed by API Gateway (HTTP API v2 or REST API v1).
package/cli-lib.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  import { existsSync } from 'node:fs'
2
- import { mkdir, writeFile, unlink } from 'node:fs/promises'
2
+ import { mkdir, writeFile, unlink, readFile } from 'node:fs/promises'
3
3
  import { resolve, join, dirname } from 'node:path'
4
- import { fileURLToPath } from 'node:url'
4
+ import { fileURLToPath, pathToFileURL } from 'node:url'
5
5
  import * as Hadars from './src/build'
6
- import type { HadarsOptions } from './src/types/hadars'
6
+ import type { HadarsOptions, HadarsEntryModule } from './src/types/hadars'
7
+ import { renderStaticSite } from './src/static'
8
+ import { runSources } from './src/source/runner'
9
+ import { buildSchemaExecutor } from './src/source/inference'
7
10
 
8
11
  const SUPPORTED = ['hadars.config.js', 'hadars.config.mjs', 'hadars.config.cjs', 'hadars.config.ts']
9
12
 
@@ -43,6 +46,91 @@ async function loadConfig(configPath: string): Promise<HadarsOptions> {
43
46
  return (mod && (mod.default ?? mod)) as HadarsOptions
44
47
  }
45
48
 
49
+ // ── hadars export static ─────────────────────────────────────────────────────
50
+
51
+ async function exportStatic(
52
+ config: HadarsOptions,
53
+ outputDir: string,
54
+ cwd: string,
55
+ ): Promise<void> {
56
+ if (!config.paths) {
57
+ console.error(
58
+ 'Error: `paths` is not defined in your hadars config.\n' +
59
+ 'Add a `paths` function that returns the list of URLs to pre-render:\n\n' +
60
+ ' paths: () => [\'/\', \'/about\', \'/contact\']\n',
61
+ )
62
+ process.exit(1)
63
+ }
64
+
65
+ console.log('Building hadars project...')
66
+ await Hadars.build({ ...config, mode: 'production' })
67
+
68
+ const ssrBundle = resolve(cwd, '.hadars', 'index.ssr.js')
69
+ const outHtml = resolve(cwd, '.hadars', 'static', 'out.html')
70
+ const staticSrc = resolve(cwd, '.hadars', 'static')
71
+
72
+ if (!existsSync(ssrBundle)) {
73
+ console.error(`SSR bundle not found: ${ssrBundle}`)
74
+ process.exit(1)
75
+ }
76
+ if (!existsSync(outHtml)) {
77
+ console.error(`HTML template not found: ${outHtml}`)
78
+ process.exit(1)
79
+ }
80
+
81
+ const ssrModule = await import(pathToFileURL(ssrBundle).href) as HadarsEntryModule<any>
82
+ const htmlSource = await readFile(outHtml, 'utf-8')
83
+ const outDir = resolve(cwd, outputDir)
84
+
85
+ // Run source plugins (if any) and build an auto-generated GraphQL executor.
86
+ // An explicit config.graphql always takes precedence over the inferred one.
87
+ let graphql = config.graphql
88
+ if (config.sources && config.sources.length > 0) {
89
+ console.log(`Running ${config.sources.length} source plugin(s)...`)
90
+ const store = await runSources(config.sources)
91
+ if (!graphql) {
92
+ const inferred = await buildSchemaExecutor(store)
93
+ if (!inferred) {
94
+ console.error(
95
+ 'Error: `graphql` package not found.\n' +
96
+ 'Source plugins require graphql-js to be installed:\n\n' +
97
+ ' npm install graphql\n',
98
+ )
99
+ process.exit(1)
100
+ }
101
+ graphql = inferred
102
+ console.log(`Schema inferred for types: ${store.getTypes().join(', ') || '(none)'}`)
103
+ }
104
+ }
105
+
106
+ const staticCtx = {
107
+ graphql: graphql ?? (() => Promise.reject(
108
+ new Error('[hadars] No graphql executor configured. Add a `graphql` function to your hadars.config.'),
109
+ )),
110
+ }
111
+
112
+ const paths = await config.paths(staticCtx)
113
+
114
+ console.log(`Pre-rendering ${paths.length} page(s)...`)
115
+
116
+ const { rendered, errors } = await renderStaticSite({
117
+ ssrModule,
118
+ htmlSource,
119
+ staticSrc,
120
+ paths,
121
+ outputDir: outDir,
122
+ graphql,
123
+ })
124
+
125
+ for (const p of rendered) console.log(` [200] ${p}`)
126
+ for (const { path, error } of errors) console.error(` [ERR] ${path}: ${error.message}`)
127
+
128
+ console.log(`\nExported to ${outputDir}/`)
129
+ if (errors.length > 0) console.log(` ${errors.length} page(s) failed`)
130
+ console.log(`\nServe locally:`)
131
+ console.log(` npx serve ${outputDir}`)
132
+ }
133
+
46
134
  // ── hadars export cloudflare ─────────────────────────────────────────────────
47
135
 
48
136
  async function bundleCloudflare(
@@ -460,7 +548,7 @@ Done! Next steps:
460
548
  // ── CLI entry ─────────────────────────────────────────────────────────────────
461
549
 
462
550
  function usage(): void {
463
- console.log('Usage: hadars <new <name> | dev | build | run | export lambda [output.mjs] | export cloudflare [output.mjs]>')
551
+ console.log('Usage: hadars <new <name> | dev | build | run | export lambda [output.mjs] | export cloudflare [output.mjs] | export static [outDir]>')
464
552
  }
465
553
 
466
554
  export async function runCli(argv: string[], cwd = process.cwd()): Promise<void> {
@@ -516,8 +604,12 @@ export async function runCli(argv: string[], cwd = process.cwd()): Promise<void>
516
604
  const outputFile = resolve(cwd, argv[4] ?? 'cloudflare.mjs')
517
605
  await bundleCloudflare(cfg, configPath, outputFile, cwd)
518
606
  process.exit(0)
607
+ } else if (subCmd === 'static') {
608
+ const outDirArg = argv[4] ?? 'out'
609
+ await exportStatic(cfg, outDirArg, cwd)
610
+ process.exit(0)
519
611
  } else {
520
- console.error(`Unknown export target: ${subCmd ?? '(none)'}. Supported: lambda, cloudflare`)
612
+ console.error(`Unknown export target: ${subCmd ?? '(none)'}. Supported: lambda, cloudflare, static`)
521
613
  process.exit(1)
522
614
  }
523
615
  }
@@ -171,7 +171,7 @@ var getReactResponse = async (req, opts) => {
171
171
  head: { title: "Hadars App", meta: {}, link: {}, style: {}, script: {}, status: 200 }
172
172
  };
173
173
  let props = {
174
- ...getInitProps ? await getInitProps(req) : {},
174
+ ...getInitProps ? await getInitProps(req, opts.staticCtx) : {},
175
175
  location: req.location,
176
176
  context
177
177
  };
@@ -197,7 +197,7 @@ var getReactResponse = async (req, opts) => {
197
197
  }
198
198
  };
199
199
  const finalize = async () => {
200
- const { context: _, ...restProps } = getFinalProps ? await getFinalProps(props) : props;
200
+ const restProps = getFinalProps ? await getFinalProps(props) : props;
201
201
  const serverData = {};
202
202
  let hasServerData = false;
203
203
  for (const [key, entry] of unsuspend.cache) {