@pyreon/zero 0.14.0 → 0.16.0

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.
Files changed (114) hide show
  1. package/lib/api-routes-Ci0kVmM4.js +146 -0
  2. package/lib/client.js +7 -2
  3. package/lib/csp.js +19 -9
  4. package/lib/env.js +6 -6
  5. package/lib/font.js +3 -3
  6. package/lib/{fs-router-CQ7Zxeca.js → fs-router-MewHc5SB.js} +56 -24
  7. package/lib/i18n-routing.js +112 -1
  8. package/lib/image-plugin.js +4 -0
  9. package/lib/image.js +141 -108
  10. package/lib/index.js +253 -132
  11. package/lib/link.js +1 -49
  12. package/lib/og-image.js +5 -5
  13. package/lib/rolldown-runtime-CjeV3_4I.js +18 -0
  14. package/lib/script.js +115 -74
  15. package/lib/seo.js +186 -15
  16. package/lib/server.js +275 -1247
  17. package/lib/theme.js +1 -50
  18. package/lib/types/config.d.ts +275 -3
  19. package/lib/types/env.d.ts +2 -2
  20. package/lib/types/i18n-routing.d.ts +197 -6
  21. package/lib/types/image.d.ts +105 -5
  22. package/lib/types/index.d.ts +640 -178
  23. package/lib/types/link.d.ts +3 -3
  24. package/lib/types/script.d.ts +78 -6
  25. package/lib/types/seo.d.ts +128 -4
  26. package/lib/types/server.d.ts +603 -77
  27. package/lib/types/theme.d.ts +2 -2
  28. package/lib/vite-plugin-xjWZwudX.js +2454 -0
  29. package/package.json +16 -13
  30. package/src/adapters/bun.ts +20 -1
  31. package/src/adapters/cloudflare.ts +78 -1
  32. package/src/adapters/index.ts +25 -3
  33. package/src/adapters/netlify.ts +63 -1
  34. package/src/adapters/node.ts +25 -1
  35. package/src/adapters/static.ts +26 -1
  36. package/src/adapters/validate.ts +8 -1
  37. package/src/adapters/vercel.ts +76 -1
  38. package/src/adapters/warn-missing-env.ts +49 -0
  39. package/src/app.ts +35 -1
  40. package/src/client.ts +18 -0
  41. package/src/csp.ts +28 -12
  42. package/src/entry-server.ts +55 -5
  43. package/src/env.ts +7 -7
  44. package/src/font.ts +3 -3
  45. package/src/fs-router.ts +123 -4
  46. package/src/i18n-routing.ts +246 -12
  47. package/src/image.tsx +242 -91
  48. package/src/index.ts +4 -4
  49. package/src/isr.ts +24 -6
  50. package/src/manifest.ts +675 -0
  51. package/src/og-image.ts +5 -5
  52. package/src/script.tsx +159 -36
  53. package/src/seo.ts +346 -15
  54. package/src/server.ts +10 -2
  55. package/src/ssg-plugin.ts +1523 -0
  56. package/src/types.ts +329 -19
  57. package/src/vercel-revalidate-handler.ts +204 -0
  58. package/src/vite-plugin.ts +326 -68
  59. package/lib/actions.js.map +0 -1
  60. package/lib/ai.js.map +0 -1
  61. package/lib/api-routes.js.map +0 -1
  62. package/lib/cache.js.map +0 -1
  63. package/lib/client.js.map +0 -1
  64. package/lib/compression.js.map +0 -1
  65. package/lib/config.js.map +0 -1
  66. package/lib/cors.js.map +0 -1
  67. package/lib/csp.js.map +0 -1
  68. package/lib/env.js.map +0 -1
  69. package/lib/favicon.js.map +0 -1
  70. package/lib/font.js.map +0 -1
  71. package/lib/fs-router-3xzp-4Wj.js.map +0 -1
  72. package/lib/fs-router-CQ7Zxeca.js.map +0 -1
  73. package/lib/i18n-routing.js.map +0 -1
  74. package/lib/image-plugin.js.map +0 -1
  75. package/lib/image.js.map +0 -1
  76. package/lib/index.js.map +0 -1
  77. package/lib/link.js.map +0 -1
  78. package/lib/logger.js.map +0 -1
  79. package/lib/meta.js.map +0 -1
  80. package/lib/middleware.js.map +0 -1
  81. package/lib/og-image.js.map +0 -1
  82. package/lib/rate-limit.js.map +0 -1
  83. package/lib/script.js.map +0 -1
  84. package/lib/seo.js.map +0 -1
  85. package/lib/server.js.map +0 -1
  86. package/lib/testing.js.map +0 -1
  87. package/lib/theme.js.map +0 -1
  88. package/lib/types/actions.d.ts.map +0 -1
  89. package/lib/types/ai.d.ts.map +0 -1
  90. package/lib/types/api-routes.d.ts.map +0 -1
  91. package/lib/types/cache.d.ts.map +0 -1
  92. package/lib/types/client.d.ts.map +0 -1
  93. package/lib/types/compression.d.ts.map +0 -1
  94. package/lib/types/config.d.ts.map +0 -1
  95. package/lib/types/cors.d.ts.map +0 -1
  96. package/lib/types/csp.d.ts.map +0 -1
  97. package/lib/types/env.d.ts.map +0 -1
  98. package/lib/types/favicon.d.ts.map +0 -1
  99. package/lib/types/font.d.ts.map +0 -1
  100. package/lib/types/i18n-routing.d.ts.map +0 -1
  101. package/lib/types/image-plugin.d.ts.map +0 -1
  102. package/lib/types/image.d.ts.map +0 -1
  103. package/lib/types/index.d.ts.map +0 -1
  104. package/lib/types/link.d.ts.map +0 -1
  105. package/lib/types/logger.d.ts.map +0 -1
  106. package/lib/types/meta.d.ts.map +0 -1
  107. package/lib/types/middleware.d.ts.map +0 -1
  108. package/lib/types/og-image.d.ts.map +0 -1
  109. package/lib/types/rate-limit.d.ts.map +0 -1
  110. package/lib/types/script.d.ts.map +0 -1
  111. package/lib/types/seo.d.ts.map +0 -1
  112. package/lib/types/server.d.ts.map +0 -1
  113. package/lib/types/testing.d.ts.map +0 -1
  114. package/lib/types/theme.d.ts.map +0 -1
@@ -1,9 +1,20 @@
1
1
  import { readFile } from 'node:fs/promises'
2
2
  import { existsSync, readdirSync } from 'node:fs'
3
3
  import { join } from 'node:path'
4
+ import { Readable } from 'node:stream'
4
5
  import type { Plugin, ViteDevServer } from 'vite'
5
- import { generateApiRouteModule } from './api-routes'
6
+ import type { IncomingMessage, ServerResponse } from 'node:http'
7
+ import type { ApiRouteEntry } from './api-routes'
8
+ import {
9
+ createApiMiddleware,
10
+ generateApiRouteModule,
11
+ matchApiRoute,
12
+ } from './api-routes'
6
13
  import { resolveConfig } from './config'
14
+ // Used in the dev-mode SSR catch handler to convert loader-thrown
15
+ // `redirect()` errors into real HTTP redirects (302/307/308).
16
+ import { getRedirectInfo } from '@pyreon/router'
17
+ import { matchPattern } from './entry-server'
7
18
 
8
19
  /**
9
20
  * Scan node_modules/@pyreon/ to discover all installed Pyreon packages.
@@ -36,7 +47,6 @@ function resolveNestedPackage(root: string, name: string): string | undefined {
36
47
  if (existsSync(nested)) return nested
37
48
  return undefined
38
49
  }
39
- import { matchPattern } from "./entry-server";
40
50
  import { renderErrorOverlay } from "./error-overlay";
41
51
  import {
42
52
  generateMiddlewareModule,
@@ -44,7 +54,9 @@ import {
44
54
  scanRouteFiles,
45
55
  scanRouteFilesWithExports,
46
56
  } from "./fs-router";
57
+ import { expandRoutesForLocales } from "./i18n-routing";
47
58
  import { render404Page } from "./not-found";
59
+ import { ssgPlugin } from "./ssg-plugin";
48
60
  import type { ZeroConfig } from "./types";
49
61
 
50
62
  const VIRTUAL_ROUTES_ID = "virtual:zero/routes";
@@ -56,6 +68,28 @@ const RESOLVED_VIRTUAL_MIDDLEWARE_ID = `\0${VIRTUAL_MIDDLEWARE_ID}`;
56
68
  const VIRTUAL_API_ROUTES_ID = "virtual:zero/api-routes";
57
69
  const RESOLVED_VIRTUAL_API_ROUTES_ID = `\0${VIRTUAL_API_ROUTES_ID}`;
58
70
 
71
+ /**
72
+ * Per-plugin-instance storage for the user-supplied ZeroConfig. Lets
73
+ * downstream consumers (e.g. `@pyreon/zero-cli`'s `build` command, which
74
+ * loads the user's `vite.config.ts` and inspects its plugin list)
75
+ * recover the original config without us attaching internal state to
76
+ * the public Plugin object via an underscore-prefixed property.
77
+ *
78
+ * Exported via `getZeroPluginConfig(plugin)` so the WeakMap itself
79
+ * stays an implementation detail — callers can't enumerate or mutate
80
+ * the table, only read by Plugin identity.
81
+ */
82
+ const zeroPluginConfigMap = new WeakMap<Plugin, ZeroConfig>();
83
+
84
+ /**
85
+ * Retrieve the `ZeroConfig` that was passed to `zeroPlugin(userConfig)`
86
+ * when the plugin was created. Returns `undefined` if the argument
87
+ * isn't a recognized pyreon-zero main plugin instance.
88
+ */
89
+ export function getZeroPluginConfig(plugin: Plugin): ZeroConfig | undefined {
90
+ return zeroPluginConfigMap.get(plugin);
91
+ }
92
+
59
93
  /**
60
94
  * Zero Vite plugin — adds file-based routing and zero-config conventions
61
95
  * on top of @pyreon/vite-plugin.
@@ -69,15 +103,14 @@ const RESOLVED_VIRTUAL_API_ROUTES_ID = `\0${VIRTUAL_API_ROUTES_ID}`;
69
103
  * plugins: [pyreon(), zero()],
70
104
  * }
71
105
  */
72
- export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
106
+ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin[] {
73
107
  const config = resolveConfig(userConfig);
74
108
  let routesDir: string;
75
109
  let root: string;
76
110
 
77
- const plugin: Plugin & { _zeroConfig: ZeroConfig } = {
111
+ const mainPlugin: Plugin = {
78
112
  name: "pyreon-zero",
79
113
  enforce: "pre",
80
- _zeroConfig: userConfig,
81
114
 
82
115
  configResolved(resolvedConfig) {
83
116
  root = resolvedConfig.root;
@@ -98,7 +131,13 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
98
131
  // • lazy() for routes that only export `default` (best code splitting)
99
132
  // • Direct mod.loader/.guard/.meta access for routes with metadata
100
133
  // • No spurious IMPORT_IS_UNDEFINED warnings from Rolldown
101
- const routes = await scanRouteFilesWithExports(routesDir, config.mode);
134
+ const baseRoutes = await scanRouteFilesWithExports(routesDir, config.mode);
135
+ // PR H — fan routes into per-locale variants when `i18n` is
136
+ // configured. No-op when unset; identity-returns the input
137
+ // otherwise so existing apps see byte-identical output.
138
+ const routes = config.i18n
139
+ ? expandRoutesForLocales(baseRoutes, config.i18n)
140
+ : baseRoutes;
102
141
  return generateRouteModuleFromRoutes(routes, routesDir, {
103
142
  staticImports: config.mode === 'ssg',
104
143
  });
@@ -127,6 +166,35 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
127
166
  },
128
167
 
129
168
  configureServer(server) {
169
+ // Dev-mode API-route middleware — production wires `createApiMiddleware`
170
+ // via `createServer`, but dev had no equivalent. API requests fell
171
+ // through to Vite's default 404. This middleware loads the
172
+ // `virtual:zero/api-routes` module and dispatches matching requests to
173
+ // the route's `GET()` / `POST()` / etc. handler. Mirrors the production
174
+ // flow in `entry-server.ts` minus the ergonomic helpers (auth, cors,
175
+ // etc. — those plug in via user-defined Pyreon middleware which dev
176
+ // doesn't currently load; not in scope here).
177
+ //
178
+ // Registered FIRST so API requests don't get SSR'd or 404'd.
179
+ server.middlewares.use((req, res, next) => {
180
+ const pathname = req.url?.split("?")[0] ?? "/";
181
+ if (pathname.startsWith("/@") || pathname.startsWith("/__"))
182
+ return next();
183
+ // Skip files (extension-bearing) — let Vite's static pipeline serve.
184
+ if (/\.\w+$/.test(pathname)) return next();
185
+
186
+ dispatchApiRoute(server, req, res).then(
187
+ (handled) => {
188
+ if (!handled) next();
189
+ },
190
+ (err: unknown) => {
191
+ // oxlint-disable-next-line no-console
192
+ console.error("[Pyreon] Error in dev API dispatcher:", err);
193
+ next();
194
+ },
195
+ );
196
+ });
197
+
130
198
  // Dev-mode SSR middleware — for mode: "ssr", actually render each
131
199
  // matched route server-side instead of serving the SPA shell.
132
200
  // Runs BEFORE the 404 handler so matched routes are SSR'd and
@@ -141,15 +209,44 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
141
209
  return next();
142
210
  if (/\.\w+$/.test(pathname)) return next();
143
211
 
144
- renderSsr(server, root, req.originalUrl ?? pathname, pathname).then(
212
+ // Build a Web Request from the Node IncomingMessage so loaders
213
+ // can read cookies / auth headers via `ctx.request` and call
214
+ // `redirect()` from a server-side context.
215
+ const reqHost = req.headers.host ?? "localhost";
216
+ const reqUrl = new URL(req.url ?? "/", `http://${reqHost}`);
217
+ const reqHeaders = new Headers();
218
+ for (const [key, value] of Object.entries(req.headers)) {
219
+ if (value !== undefined) {
220
+ reqHeaders.set(
221
+ key,
222
+ Array.isArray(value) ? value.join(", ") : String(value),
223
+ );
224
+ }
225
+ }
226
+ const webReq = new Request(reqUrl.href, {
227
+ method: req.method ?? "GET",
228
+ headers: reqHeaders,
229
+ });
230
+
231
+ renderSsr(server, root, req.originalUrl ?? pathname, pathname, webReq).then(
145
232
  (result) => {
146
233
  if (result === null) return next();
147
- res.statusCode = 200;
234
+ res.statusCode = result.status;
148
235
  res.setHeader("Content-Type", "text/html; charset=utf-8");
149
- res.setHeader("Content-Length", Buffer.byteLength(result));
150
- res.end(result);
236
+ res.setHeader("Content-Length", Buffer.byteLength(result.html));
237
+ res.end(result.html);
151
238
  },
152
239
  (err: unknown) => {
240
+ // Loader-thrown `redirect()` — convert to a real HTTP redirect
241
+ // (302/307/308) BEFORE the layout renders. This is the dev-mode
242
+ // equivalent of the production handler's redirect catch.
243
+ const info = getRedirectInfo(err);
244
+ if (info) {
245
+ res.statusCode = info.status;
246
+ res.setHeader("Location", info.url);
247
+ res.end();
248
+ return;
249
+ }
153
250
  const error = err instanceof Error ? err : new Error(String(err));
154
251
  server.ssrFixStacktrace(error);
155
252
  const html = renderErrorOverlay(error);
@@ -254,19 +351,19 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
254
351
  });
255
352
  },
256
353
 
257
- config(userConfig) {
354
+ config(viteUserConfig) {
258
355
  // Discover all @pyreon/* packages installed in node_modules.
259
356
  // The "bun" export condition points to TS source — esbuild's
260
357
  // dep optimizer would compile them with the wrong JSX runtime.
261
- const root = userConfig.root ?? process.cwd()
262
- const pyreonExclude = scanPyreonPackages(root)
358
+ const cwd = viteUserConfig.root ?? process.cwd()
359
+ const pyreonExclude = scanPyreonPackages(cwd)
263
360
 
264
361
  // `@pyreon/runtime-server` is only imported by zero's dev SSR
265
362
  // middleware and the production server entry — apps rarely list it
266
363
  // as a direct dep. Resolve it to the copy nested under zero so
267
364
  // `ssrLoadModule("@pyreon/runtime-server")` works uniformly.
268
365
  const runtimeServerAlias = resolveNestedPackage(
269
- root,
366
+ cwd,
270
367
  "@pyreon/runtime-server",
271
368
  )
272
369
 
@@ -292,9 +389,35 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
292
389
  optimizeDeps: {
293
390
  exclude: pyreonExclude,
294
391
  },
295
- server: {
296
- port: config.port,
297
- },
392
+ // Only set the port when the user explicitly provided one in
393
+ // `zero({ port: N })`. Without this guard, the plugin always
394
+ // returned `server: { port: 3000 }` which overrode Vite's CLI
395
+ // `--port` flag and made multi-example dev impossible — every
396
+ // example tried to bind 3000 even when launched with
397
+ // `vite --port 5173`. Surfaced when wiring up the playwright
398
+ // e2e suite.
399
+ ...(userConfig.port !== undefined
400
+ ? { server: { port: config.port } }
401
+ : {}),
402
+ // Propagate `zero({ base })` to Vite's `base` config — that's
403
+ // what controls asset URL rewriting in the built HTML/JS
404
+ // (`<script src="/blog/assets/…">`). Pre-fix this was a
405
+ // typed-but-unimplemented field: `__ZERO_BASE__` was defined
406
+ // as a Vite global but no consumer existed, AND Vite's own
407
+ // `base` had to be set manually in vite.config.ts. Setting
408
+ // it here makes `zero({ base: '/blog/' })` the canonical
409
+ // single-source-of-truth surface; the value flows through
410
+ // to (a) Vite's HTML/asset URL rewriter, (b) `createRouter`
411
+ // via `__ZERO_BASE__` in `startClient` / `createApp`, (c)
412
+ // the SSG entry's `createApp({ base })` call.
413
+ //
414
+ // Vite's config-merge semantics: plugin-returned config is
415
+ // the BASE; user's `vite.config.ts` top-level overrides.
416
+ // So a user who sets BOTH `zero({ base: '/blog/' })` AND
417
+ // `vite.config.base: '/foo/'` gets the latter — the user's
418
+ // explicit override wins. The default `/` is a no-op
419
+ // (matches Vite's default), so always-setting is safe.
420
+ base: config.base,
298
421
  define: {
299
422
  __ZERO_MODE__: JSON.stringify(config.mode),
300
423
  __ZERO_BASE__: JSON.stringify(config.base),
@@ -303,17 +426,139 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
303
426
  },
304
427
  };
305
428
 
306
- return plugin;
429
+ // Stash the original user config keyed by plugin identity so the CLI
430
+ // (which loads vite.config.ts and inspects the plugin list) can
431
+ // recover it via `getZeroPluginConfig(plugin)` without us hanging a
432
+ // `_`-prefixed property off the public Plugin object.
433
+ zeroPluginConfigMap.set(mainPlugin, userConfig);
434
+
435
+ // SSG mode auto-wires the static-site generation hook. Other modes get
436
+ // just the main plugin. The SSG plugin internally no-ops when
437
+ // `mode !== 'ssg'`, but skipping it entirely keeps the plugin chain
438
+ // minimal for SSR/SPA/ISR builds (one less `closeBundle` to call).
439
+ return config.mode === "ssg" ? [mainPlugin, ssgPlugin(userConfig)] : [mainPlugin];
440
+ }
441
+
442
+ /**
443
+ * Dev-mode API-route dispatcher. Loads the `virtual:zero/api-routes` virtual
444
+ * module, builds a Web `Request` from the Node `IncomingMessage`, and invokes
445
+ * the matching route's HTTP-method handler.
446
+ *
447
+ * Returns `true` if the API middleware handled the request (response written).
448
+ * Returns `false` if no route matched (caller falls through to next middleware).
449
+ *
450
+ * Mirrors what `createServer` wires up in production via `createApiMiddleware`,
451
+ * but adapted for Vite's connect-style middleware stack — needs a Node→Web
452
+ * request adapter.
453
+ */
454
+ async function dispatchApiRoute(
455
+ server: ViteDevServer,
456
+ req: IncomingMessage,
457
+ res: ServerResponse,
458
+ ): Promise<boolean> {
459
+ let apiRoutes: ApiRouteEntry[];
460
+ try {
461
+ const mod = await server.ssrLoadModule(VIRTUAL_API_ROUTES_ID);
462
+ apiRoutes = (mod.apiRoutes ?? []) as ApiRouteEntry[];
463
+ } catch {
464
+ return false;
465
+ }
466
+ if (apiRoutes.length === 0) return false;
467
+
468
+ const host = req.headers.host ?? "localhost";
469
+ const url = new URL(req.url ?? "/", `http://${host}`);
470
+ const pathname = url.pathname;
471
+
472
+ // Quick gate: only build the Web Request when the path actually matches
473
+ // an api route. Reuses the same `matchApiRoute` that `createApiMiddleware`
474
+ // uses internally — including catch-all `:param*` patterns from
475
+ // `[...slug].ts` API routes — so the gate and the dispatcher agree on
476
+ // what counts as a match. Avoids per-request body buffering for SSR /
477
+ // static traffic that doesn't target an API route.
478
+ const anyMatch = apiRoutes.some((r) => matchApiRoute(r.pattern, pathname) !== null);
479
+ if (!anyMatch) return false;
480
+
481
+ // Convert Node IncomingMessage → Web Request. Stream the request body
482
+ // for non-GET/HEAD via `Readable.toWeb` instead of buffering — large
483
+ // uploads (multipart, file POSTs) don't have to fit in memory before
484
+ // the handler sees them. `duplex: 'half'` is required by the WHATWG
485
+ // fetch spec when `body` is a `ReadableStream`.
486
+ const method = (req.method ?? "GET").toUpperCase();
487
+ const headers = new Headers();
488
+ for (const [key, value] of Object.entries(req.headers)) {
489
+ if (value !== undefined) {
490
+ headers.set(key, Array.isArray(value) ? value.join(", ") : String(value));
491
+ }
492
+ }
493
+ const requestInit: RequestInit & { duplex?: "half" } = { method, headers };
494
+ if (method !== "GET" && method !== "HEAD") {
495
+ // `Readable.toWeb` returns Node's `node:stream/web` `ReadableStream`;
496
+ // `RequestInit.body` expects the DOM `ReadableStream`. They're
497
+ // structurally identical at runtime but TS keeps them as separate
498
+ // types — `as unknown as` is the standard bridge per TS's own
499
+ // "convert to unknown first" suggestion.
500
+ requestInit.body = Readable.toWeb(req) as unknown as ReadableStream<Uint8Array>;
501
+ requestInit.duplex = "half";
502
+ }
503
+ const webReq = new Request(url.href, requestInit);
504
+
505
+ const middleware = createApiMiddleware(apiRoutes);
506
+ const response = await middleware({
507
+ req: webReq,
508
+ url,
509
+ path: pathname + url.search,
510
+ headers: new Headers(),
511
+ locals: {},
512
+ });
513
+
514
+ if (!response) return false;
515
+
516
+ // Pipe the Web Response body directly to the Node response stream
517
+ // instead of buffering with `arrayBuffer()`. Critical for SSE, large
518
+ // downloads, and any handler that returns a `Response` constructed
519
+ // from a streaming source — buffering would defeat the streaming
520
+ // contract and OOM on large payloads.
521
+ res.statusCode = response.status;
522
+ response.headers.forEach((v, k) => {
523
+ res.setHeader(k, v);
524
+ });
525
+ if (response.body) {
526
+ // `pipe(res)` ends `res` automatically on stream completion and
527
+ // auto-cancels the upstream Web ReadableStream if the client
528
+ // disconnects (Node ≥18). We don't await — once the headers and
529
+ // pipe are wired, the function's job is done. The connect chain
530
+ // doesn't call `next()` because we resolved with `true`.
531
+ // `response.body` is a DOM `ReadableStream`; `Readable.fromWeb`
532
+ // expects `node:stream/web`'s `ReadableStream`. Cross-realm types
533
+ // don't unify in TS — bridge via `unknown` per TS's own guidance.
534
+ Readable.fromWeb(
535
+ response.body as unknown as import("node:stream/web").ReadableStream,
536
+ ).pipe(res);
537
+ } else {
538
+ res.end();
539
+ }
540
+ return true;
307
541
  }
308
542
 
309
543
  /**
310
- * Check if the requested path matches any route. If not, render a 404 page.
311
- * Returns true if the 404 was handled (response sent), false otherwise.
544
+ * Static-page 404 fallback for apps WITHOUT `_404.tsx` in the routes tree.
545
+ *
546
+ * For `mode: 'ssr'` apps with `_404.tsx`, the SSR middleware's `renderSsr`
547
+ * routes unmatched URLs through the router-driven path (PR L5 + M1.2) — that
548
+ * produces a layout-wrapped 404 with HTTP status 404, never reaching here.
549
+ * This function is the LEGACY fallback that fires only when:
550
+ * - The app is in `mode: 'spa'` / `mode: 'ssg'` (no dev SSR middleware), OR
551
+ * - The app has no reachable `notFoundComponent` in its routes tree (so the
552
+ * SSR middleware's `resolveRoute` returns matched: [] and falls through).
553
+ *
554
+ * Returns true if the 404 was handled (response sent), false if the path
555
+ * actually matches a route (caller continues to next middleware).
312
556
  *
313
- * In dev mode, the _404.tsx component cannot be SSR-rendered because
314
- * the compiler emits _tpl() calls that require `document`. Instead,
315
- * we return a static 404 page. The actual component rendering happens
316
- * on the client side when the SPA loads.
557
+ * Pre-M1.2 a stale comment claimed `_404.tsx` "cannot be SSR-rendered because
558
+ * the compiler emits _tpl() calls that require document". That was wrong — the
559
+ * SSR runtime renders compiler-emitted components fine via `renderToString`
560
+ * (no document needed). The static fallback exists for backward compat with
561
+ * apps that don't ship `_404.tsx`, not because SSR-rendering it is impossible.
317
562
  */
318
563
  async function handle404(
319
564
  server: import("vite").ViteDevServer,
@@ -329,10 +574,11 @@ async function handle404(
329
574
  return false; // Route matches — not a 404
330
575
  }
331
576
 
332
- // No route matched — return a 404.
333
- // In dev, we return a static page since the compiler emits _tpl() calls
334
- // that require document (unavailable in SSR). The _404.tsx component
335
- // renders on the client side after hydration.
577
+ // No route matched + no `_404.tsx` reachable emit a minimal static page
578
+ // so the user gets SOMETHING. Apps that want branded 404s should add
579
+ // `_404.tsx` to their routes tree (canonical pattern); the SSR middleware
580
+ // then routes through the router-driven path with layout chrome instead
581
+ // of landing here.
336
582
  const html = await render404Page(undefined);
337
583
 
338
584
  res.statusCode = 404;
@@ -359,18 +605,13 @@ async function renderSsr(
359
605
  root: string,
360
606
  originalUrl: string,
361
607
  pathname: string,
362
- ): Promise<string | null> {
363
- // Pattern check FIRST otherwise SSR would try (and likely crash) on
364
- // asset paths that happened to accept text/html (e.g. curl-style).
608
+ req?: Request,
609
+ ): Promise<{ html: string; status: number } | null> {
365
610
  const routesMod = await server.ssrLoadModule(VIRTUAL_ROUTES_ID);
366
611
  const routes = routesMod.routes as Array<{
367
612
  path?: string;
368
613
  children?: unknown[];
369
614
  }>;
370
- const patterns = flattenRoutePatterns(routes);
371
- if (!patterns.some((pattern) => matchPattern(pattern, pathname))) {
372
- return null;
373
- }
374
615
 
375
616
  // Read + transform index.html (Vite injects the HMR client / JSX prelude).
376
617
  let template = await readFile(join(root, "index.html"), "utf-8");
@@ -383,7 +624,7 @@ async function renderSsr(
383
624
  // `@pyreon/runtime-server` isn't a direct dep of most apps, so zero's
384
625
  // `config()` hook registers an alias that points it at the copy under
385
626
  // zero's own `node_modules` — same path → same Vite module → same instance.
386
- const [core, headPkg, headSsr, routerPkg, runtimeServer] = await Promise.all(
627
+ const [core, _headPkg, headSsr, routerPkg, runtimeServer] = await Promise.all(
387
628
  [
388
629
  server.ssrLoadModule("@pyreon/core") as Promise<
389
630
  typeof import("@pyreon/core")
@@ -403,25 +644,19 @@ async function renderSsr(
403
644
  ],
404
645
  );
405
646
 
406
- // Build the SAME app tree the client will hydrate against. `entry-client`
407
- // imports `layout` from `_layout.tsx` and passes it explicitly to
408
- // `startClient` `createApp`. We mirror that here: discover the user's
409
- // `_layout` (if present) via Vite's SSR module graph and pass it along.
410
- // Without this, SSR renders a different tree (no outer Layout wrapper)
411
- // and hydration mismatches at the very first nesting level — cascading
412
- // into duplicated mounts of every section below.
413
- let userLayout: unknown
414
- for (const ext of ['tsx', 'ts', 'jsx', 'js']) {
415
- try {
416
- const layoutMod = (await server.ssrLoadModule(
417
- `/src/routes/_layout.${ext}`,
418
- )) as { layout?: unknown; default?: unknown }
419
- userLayout = layoutMod.layout ?? layoutMod.default
420
- if (userLayout) break
421
- } catch {
422
- // Try the next extension. If none exist, createApp uses DefaultLayout.
423
- }
424
- }
647
+ // Don't auto-load `_layout.tsx` as the outer Layout. fs-router already
648
+ // emits it as a parent route in the matched chain — wrapping createApp
649
+ // with the same layout AGAIN produced a double mount: the App tree was
650
+ // `<Layout><RouterView/></Layout>` AND the router's first matched
651
+ // record was the layout, rendering it a second time inside RouterView.
652
+ // Symptom: duplicate `<nav>`, duplicate `<div id="layout">`,
653
+ // hydration mismatches, strict-mode locator violations in playwright.
654
+ //
655
+ // If a user genuinely needs an outer wrapper that sits ABOVE the
656
+ // router (e.g. a global provider not tied to any route), they can
657
+ // still pass it via `startClient({ layout })` — but the file should
658
+ // NOT live under `src/routes/_layout.{ts,tsx,…}` (which fs-router
659
+ // always treats as a parent route).
425
660
 
426
661
  // Use zero's own `createApp` rather than reassembling the tree by hand —
427
662
  // guarantees server and client agree on every wrapper component (any
@@ -435,20 +670,33 @@ async function renderSsr(
435
670
  const appMod = (await server.ssrLoadModule(
436
671
  "@pyreon/zero/server",
437
672
  )) as typeof import("./server")
438
- type CreateAppLayout = NonNullable<
439
- Parameters<typeof appMod.createApp>[0]["layout"]
440
- >
441
673
  const { App, router: routerInst } = appMod.createApp({
442
674
  routes: routes as import("@pyreon/router").RouteRecord[],
443
675
  routerMode: "history",
444
676
  url: pathname,
445
- ...(userLayout ? { layout: userLayout as CreateAppLayout } : {}),
446
677
  })
447
678
 
448
679
  // `preload` loads lazy route components AND runs loaders for `pathname` so
449
680
  // the synchronous render pass produces final HTML — no loading fallbacks,
450
681
  // no `useLoaderData() === undefined`.
451
- await routerInst.preload(pathname);
682
+ //
683
+ // M1.2 — Unmatched URLs no longer bail to a static 404 page. The router's
684
+ // `resolveRoute` (PR L5) walks the route tree and, if a parent layout has
685
+ // `notFoundComponent` AND the URL is under that layout's prefix, builds a
686
+ // synthetic chain `[...ancestorLayouts, syntheticLeaf]` with
687
+ // `isNotFound: true`. The render then produces 404 HTML INSIDE the
688
+ // layout's chrome. If the routes tree has no reachable `notFoundComponent`,
689
+ // `matched` stays empty — fall through to `handle404` for the static
690
+ // fallback (preserves backward compat for apps without `_404.tsx`).
691
+ await routerInst.preload(pathname, req);
692
+
693
+ const resolved = routerInst.currentRoute() as
694
+ | { matched?: unknown[]; isNotFound?: boolean }
695
+ | undefined;
696
+ if (!resolved?.matched || resolved.matched.length === 0) {
697
+ return null;
698
+ }
699
+ const status = resolved.isNotFound === true ? 404 : 200;
452
700
 
453
701
  return runtimeServer.runWithRequestContext(async () => {
454
702
  const app = core.h(App as Parameters<typeof core.h>[0], null);
@@ -458,33 +706,43 @@ async function renderSsr(
458
706
  routerInst as Parameters<typeof routerPkg.serializeLoaderData>[0],
459
707
  );
460
708
  const hasData = loaderData && Object.keys(loaderData).length > 0;
709
+ // M2.2 — safe serializer (parity with production handler / SSG entry).
461
710
  const loaderScript = hasData
462
- ? `<script>window.__PYREON_LOADER_DATA__=${JSON.stringify(loaderData).replace(/<\//g, "<\\/")}</script>`
711
+ ? `<script>window.__PYREON_LOADER_DATA__=${routerPkg.stringifyLoaderData(loaderData)}</script>`
463
712
  : "";
464
713
 
465
- return template
714
+ const html = template
466
715
  .replace("<!--pyreon-head-->", head)
467
716
  .replace("<!--pyreon-app-->", appHtml)
468
717
  .replace("<!--pyreon-scripts-->", loaderScript);
718
+ return { html, status };
469
719
  });
470
720
  }
471
721
 
472
- /** Extract all URL patterns from a nested route tree. */
722
+ /**
723
+ * Extract all URL patterns from a nested route tree.
724
+ *
725
+ * The fs-router emits ABSOLUTE paths for every route, including grandchildren —
726
+ * `{ path: "/app/dashboard" }` not `{ path: "dashboard" }`. The matcher reads
727
+ * each route's `path` as-is; no prefix accumulation. Pre-fix, this function
728
+ * concatenated `${prefix}${route.path}` which produced patterns like
729
+ * `///app/app/dashboard` (prefix `'/app'` + path `'/app/dashboard'`). After
730
+ * `path.split('/').filter(Boolean)` those became `['app', 'app', 'dashboard']`
731
+ * — which can't match a real `/app/dashboard` request — so dev-server returned
732
+ * 404 for every nested-layout route. Re-enables PR #411 specs that rely on
733
+ * `/app/*` routing.
734
+ */
473
735
  function flattenRoutePatterns(
474
736
  routes: Array<{ path?: string; children?: unknown[] }>,
475
- prefix = "",
476
737
  ): string[] {
477
738
  const patterns: string[] = [];
478
739
  for (const route of routes) {
479
740
  if (!route.path) continue;
480
- const fullPath =
481
- route.path === "/" && prefix ? prefix : `${prefix}${route.path}`;
482
- patterns.push(fullPath);
741
+ patterns.push(route.path);
483
742
  if (route.children) {
484
743
  patterns.push(
485
744
  ...flattenRoutePatterns(
486
745
  route.children as Array<{ path?: string; children?: unknown[] }>,
487
- fullPath,
488
746
  ),
489
747
  );
490
748
  }
@@ -1 +0,0 @@
1
- {"version":3,"file":"actions.js","names":[],"sources":["../src/actions.ts"],"sourcesContent":["import type { MiddlewareContext } from '@pyreon/server'\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\n/** Context passed to server action handlers. */\nexport interface ActionContext {\n /** The original request. */\n request: Request\n /** Parsed form data (for form submissions). */\n formData: FormData | null\n /** Parsed JSON body (for JSON submissions). */\n json: unknown\n /** Request headers. */\n headers: Headers\n}\n\n/** A server action handler function. */\nexport type ActionHandler<T = unknown> = (ctx: ActionContext) => T | Promise<T>\n\n/** A registered action with its ID and handler. */\ninterface RegisteredAction {\n id: string\n handler: ActionHandler\n}\n\n/** Client-side callable action returned by defineAction. */\nexport interface Action<T = unknown> {\n /** Call the action with JSON data. */\n (data?: unknown): Promise<T>\n /** The action's unique ID. */\n actionId: string\n}\n\n// ─── Registry ────────────────────────────────────────────────────────────────\n\nconst actionRegistry = new Map<string, RegisteredAction>()\n\n/**\n * Define a server action. Returns a callable function that:\n * - On the **client**: sends a POST request to `/_zero/actions/<id>`\n * - On the **server** (SSR): executes the handler directly (no fetch)\n *\n * @example\n * // In a route file or module:\n * export const createPost = defineAction(async (ctx) => {\n * const data = ctx.json as { title: string; body: string }\n * // ... save to database\n * return { success: true, id: 123 }\n * })\n *\n * // In a component:\n * const result = await createPost({ title: 'Hello', body: '...' })\n */\nexport function defineAction<T = unknown>(handler: ActionHandler<T>): Action<T> {\n const id = `action_${crypto.randomUUID().slice(0, 8)}`\n\n actionRegistry.set(id, { id, handler: handler as ActionHandler })\n\n const callable = async (data?: unknown): Promise<T> => {\n // Server-side: execute handler directly (no network round-trip)\n if (typeof globalThis.window === 'undefined') {\n return handler({\n request: new Request(`http://localhost/_zero/actions/${id}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(data ?? null),\n }),\n formData: null,\n json: data ?? null,\n headers: new Headers({ 'Content-Type': 'application/json' }),\n })\n }\n\n // Client-side: POST to the action endpoint\n const response = await fetch(`/_zero/actions/${id}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(data ?? null),\n })\n if (!response.ok) {\n const body = await response.json().catch(() => ({}))\n throw new Error((body as { error?: string }).error ?? `Action failed: ${response.statusText}`)\n }\n return response.json()\n }\n\n callable.actionId = id\n return callable as Action<T>\n}\n\n/** Get all registered actions. Useful for testing. */\nexport function getRegisteredActions(): Map<string, RegisteredAction> {\n return actionRegistry\n}\n\n/**\n * Reset the action registry. Useful for testing.\n * @internal\n */\nexport function _resetActions(): void {\n actionRegistry.clear()\n}\n\n// ─── Server handler ──────────────────────────────────────────────────────────\n\n/**\n * Create a middleware that handles action requests at `/_zero/actions/*`.\n * Mount this before the SSR handler in the server entry.\n */\nexport function createActionMiddleware(): (\n ctx: MiddlewareContext,\n) => Response | undefined | Promise<Response | undefined> {\n return async (ctx: MiddlewareContext) => {\n if (!ctx.path.startsWith('/_zero/actions/')) return\n\n const actionId = ctx.path.slice('/_zero/actions/'.length)\n const action = actionRegistry.get(actionId)\n\n if (!action) {\n return Response.json({ error: 'Action not found' }, { status: 404 })\n }\n\n if (ctx.req.method !== 'POST') {\n return Response.json({ error: 'Method not allowed' }, { status: 405 })\n }\n\n return executeAction(action, ctx.req)\n }\n}\n\nasync function executeAction(action: RegisteredAction, req: Request): Promise<Response> {\n try {\n const contentType = req.headers.get('content-type') ?? ''\n let formData: FormData | null = null\n let json: unknown = null\n\n if (contentType.includes('application/json')) {\n json = await req.json()\n } else if (\n contentType.includes('multipart/form-data') ||\n contentType.includes('application/x-www-form-urlencoded')\n ) {\n formData = await req.formData()\n }\n\n const result = await action.handler({\n request: req,\n formData,\n json,\n headers: req.headers,\n })\n\n return Response.json(result ?? null)\n } catch (err) {\n const message = err instanceof Error ? err.message : 'Internal server error'\n return Response.json({ error: message }, { status: 500 })\n }\n}\n"],"mappings":";AAmCA,MAAM,iCAAiB,IAAI,KAA+B;;;;;;;;;;;;;;;;;AAkB1D,SAAgB,aAA0B,SAAsC;CAC9E,MAAM,KAAK,UAAU,OAAO,YAAY,CAAC,MAAM,GAAG,EAAE;AAEpD,gBAAe,IAAI,IAAI;EAAE;EAAa;EAA0B,CAAC;CAEjE,MAAM,WAAW,OAAO,SAA+B;AAErD,MAAI,OAAO,WAAW,WAAW,YAC/B,QAAO,QAAQ;GACb,SAAS,IAAI,QAAQ,kCAAkC,MAAM;IAC3D,QAAQ;IACR,SAAS,EAAE,gBAAgB,oBAAoB;IAC/C,MAAM,KAAK,UAAU,QAAQ,KAAK;IACnC,CAAC;GACF,UAAU;GACV,MAAM,QAAQ;GACd,SAAS,IAAI,QAAQ,EAAE,gBAAgB,oBAAoB,CAAC;GAC7D,CAAC;EAIJ,MAAM,WAAW,MAAM,MAAM,kBAAkB,MAAM;GACnD,QAAQ;GACR,SAAS,EAAE,gBAAgB,oBAAoB;GAC/C,MAAM,KAAK,UAAU,QAAQ,KAAK;GACnC,CAAC;AACF,MAAI,CAAC,SAAS,IAAI;GAChB,MAAM,OAAO,MAAM,SAAS,MAAM,CAAC,aAAa,EAAE,EAAE;AACpD,SAAM,IAAI,MAAO,KAA4B,SAAS,kBAAkB,SAAS,aAAa;;AAEhG,SAAO,SAAS,MAAM;;AAGxB,UAAS,WAAW;AACpB,QAAO;;;AAIT,SAAgB,uBAAsD;AACpE,QAAO;;;;;;AAOT,SAAgB,gBAAsB;AACpC,gBAAe,OAAO;;;;;;AASxB,SAAgB,yBAE0C;AACxD,QAAO,OAAO,QAA2B;AACvC,MAAI,CAAC,IAAI,KAAK,WAAW,kBAAkB,CAAE;EAE7C,MAAM,WAAW,IAAI,KAAK,MAAM,GAAyB;EACzD,MAAM,SAAS,eAAe,IAAI,SAAS;AAE3C,MAAI,CAAC,OACH,QAAO,SAAS,KAAK,EAAE,OAAO,oBAAoB,EAAE,EAAE,QAAQ,KAAK,CAAC;AAGtE,MAAI,IAAI,IAAI,WAAW,OACrB,QAAO,SAAS,KAAK,EAAE,OAAO,sBAAsB,EAAE,EAAE,QAAQ,KAAK,CAAC;AAGxE,SAAO,cAAc,QAAQ,IAAI,IAAI;;;AAIzC,eAAe,cAAc,QAA0B,KAAiC;AACtF,KAAI;EACF,MAAM,cAAc,IAAI,QAAQ,IAAI,eAAe,IAAI;EACvD,IAAI,WAA4B;EAChC,IAAI,OAAgB;AAEpB,MAAI,YAAY,SAAS,mBAAmB,CAC1C,QAAO,MAAM,IAAI,MAAM;WAEvB,YAAY,SAAS,sBAAsB,IAC3C,YAAY,SAAS,oCAAoC,CAEzD,YAAW,MAAM,IAAI,UAAU;EAGjC,MAAM,SAAS,MAAM,OAAO,QAAQ;GAClC,SAAS;GACT;GACA;GACA,SAAS,IAAI;GACd,CAAC;AAEF,SAAO,SAAS,KAAK,UAAU,KAAK;UAC7B,KAAK;EACZ,MAAM,UAAU,eAAe,QAAQ,IAAI,UAAU;AACrD,SAAO,SAAS,KAAK,EAAE,OAAO,SAAS,EAAE,EAAE,QAAQ,KAAK,CAAC"}