@pyreon/zero 0.13.1 → 0.15.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 (80) hide show
  1. package/lib/api-routes-DANluJic.js +146 -0
  2. package/lib/client.js +3 -1
  3. package/lib/csp.js +19 -9
  4. package/lib/{fs-router-CQ7Zxeca.js → fs-router-ZebyutPa.js} +43 -6
  5. package/lib/image-plugin.js +4 -0
  6. package/lib/image.js +1 -50
  7. package/lib/index.js +1 -50
  8. package/lib/link.js +1 -49
  9. package/lib/script.js +1 -49
  10. package/lib/server.js +6 -688
  11. package/lib/theme.js +1 -50
  12. package/lib/types/i18n-routing.d.ts +4 -4
  13. package/lib/types/index.d.ts +23 -13
  14. package/lib/types/link.d.ts +3 -3
  15. package/lib/types/server.d.ts +28 -5
  16. package/lib/types/theme.d.ts +2 -2
  17. package/lib/vite-plugin-E4BHYvYW.js +855 -0
  18. package/package.json +15 -13
  19. package/src/app.ts +21 -1
  20. package/src/csp.ts +28 -12
  21. package/src/fs-router.ts +53 -3
  22. package/src/ssg-plugin.ts +366 -0
  23. package/src/types.ts +28 -9
  24. package/src/vite-plugin.ts +220 -40
  25. package/lib/actions.js.map +0 -1
  26. package/lib/ai.js.map +0 -1
  27. package/lib/api-routes.js.map +0 -1
  28. package/lib/cache.js.map +0 -1
  29. package/lib/client.js.map +0 -1
  30. package/lib/compression.js.map +0 -1
  31. package/lib/config.js.map +0 -1
  32. package/lib/cors.js.map +0 -1
  33. package/lib/csp.js.map +0 -1
  34. package/lib/env.js.map +0 -1
  35. package/lib/favicon.js.map +0 -1
  36. package/lib/font.js.map +0 -1
  37. package/lib/fs-router-3xzp-4Wj.js.map +0 -1
  38. package/lib/fs-router-CQ7Zxeca.js.map +0 -1
  39. package/lib/i18n-routing.js.map +0 -1
  40. package/lib/image-plugin.js.map +0 -1
  41. package/lib/image.js.map +0 -1
  42. package/lib/index.js.map +0 -1
  43. package/lib/link.js.map +0 -1
  44. package/lib/logger.js.map +0 -1
  45. package/lib/meta.js.map +0 -1
  46. package/lib/middleware.js.map +0 -1
  47. package/lib/og-image.js.map +0 -1
  48. package/lib/rate-limit.js.map +0 -1
  49. package/lib/script.js.map +0 -1
  50. package/lib/seo.js.map +0 -1
  51. package/lib/server.js.map +0 -1
  52. package/lib/testing.js.map +0 -1
  53. package/lib/theme.js.map +0 -1
  54. package/lib/types/actions.d.ts.map +0 -1
  55. package/lib/types/ai.d.ts.map +0 -1
  56. package/lib/types/api-routes.d.ts.map +0 -1
  57. package/lib/types/cache.d.ts.map +0 -1
  58. package/lib/types/client.d.ts.map +0 -1
  59. package/lib/types/compression.d.ts.map +0 -1
  60. package/lib/types/config.d.ts.map +0 -1
  61. package/lib/types/cors.d.ts.map +0 -1
  62. package/lib/types/csp.d.ts.map +0 -1
  63. package/lib/types/env.d.ts.map +0 -1
  64. package/lib/types/favicon.d.ts.map +0 -1
  65. package/lib/types/font.d.ts.map +0 -1
  66. package/lib/types/i18n-routing.d.ts.map +0 -1
  67. package/lib/types/image-plugin.d.ts.map +0 -1
  68. package/lib/types/image.d.ts.map +0 -1
  69. package/lib/types/index.d.ts.map +0 -1
  70. package/lib/types/link.d.ts.map +0 -1
  71. package/lib/types/logger.d.ts.map +0 -1
  72. package/lib/types/meta.d.ts.map +0 -1
  73. package/lib/types/middleware.d.ts.map +0 -1
  74. package/lib/types/og-image.d.ts.map +0 -1
  75. package/lib/types/rate-limit.d.ts.map +0 -1
  76. package/lib/types/script.d.ts.map +0 -1
  77. package/lib/types/seo.d.ts.map +0 -1
  78. package/lib/types/server.d.ts.map +0 -1
  79. package/lib/types/testing.d.ts.map +0 -1
  80. package/lib/types/theme.d.ts.map +0 -1
package/src/types.ts CHANGED
@@ -1,7 +1,17 @@
1
1
  import type { ComponentFn } from '@pyreon/core'
2
- import type { NavigationGuard } from '@pyreon/router'
2
+ import type { LoaderContext, NavigationGuard } from '@pyreon/router'
3
3
  import type { Middleware } from '@pyreon/server'
4
4
 
5
+ // Re-export router's `LoaderContext` so consumers importing it from
6
+ // `@pyreon/zero` keep working. The previous duplicate `interface
7
+ // LoaderContext` (with a `request: Request` field that was never
8
+ // populated by the actually-constructed runtime ctx) was a
9
+ // typed-but-unimplemented bug class — caught by `audit-types`. If
10
+ // SSR loaders need access to the request, plumb it through the
11
+ // router-level `LoaderContext` in a follow-up PR; do NOT add fields
12
+ // here that the runtime doesn't populate.
13
+ export type { LoaderContext }
14
+
5
15
  // ─── Route module conventions ────────────────────────────────────────────────
6
16
 
7
17
  /** What a route file (e.g. `src/routes/index.tsx`) can export. */
@@ -26,14 +36,6 @@ export interface RouteModule {
26
36
  renderMode?: RenderMode
27
37
  }
28
38
 
29
- /** Context passed to route loaders. */
30
- export interface LoaderContext {
31
- params: Record<string, string>
32
- query: Record<string, string>
33
- signal: AbortSignal
34
- request: Request
35
- }
36
-
37
39
  /** Per-route metadata. */
38
40
  export interface RouteMeta {
39
41
  title?: string
@@ -116,6 +118,23 @@ export interface RouteFileExports {
116
118
  hasError: boolean
117
119
  /** Has `export const middleware` */
118
120
  hasMiddleware: boolean
121
+ /**
122
+ * Has `export const loaderKey` or `export function loaderKey`. When present,
123
+ * the route generator wires it as the `loaderKey` field on the route record,
124
+ * which controls cache identity for `_loaderCache`. Useful for auth-gate
125
+ * loaders that should invalidate when the session cookie changes — read
126
+ * `document.cookie` (CSR) or `ctx.request.headers.get('cookie')` (SSR) and
127
+ * derive a key from session identity. Default cache key is `path + params`,
128
+ * which doesn't see cookie changes.
129
+ */
130
+ hasLoaderKey: boolean
131
+ /**
132
+ * Has `export const gcTime` (number, in ms). When present, the route generator
133
+ * inlines it on the route record. `gcTime: 0` disables caching entirely —
134
+ * the loader runs on every navigation. Useful for auth-gate loaders that
135
+ * must validate session on every navigation rather than serve stale data.
136
+ */
137
+ hasGcTime: boolean
119
138
  /**
120
139
  * Raw text of the `export const meta = …` initializer, captured as a
121
140
  * literal expression. When present, the route generator inlines this
@@ -1,9 +1,19 @@
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'
7
17
 
8
18
  /**
9
19
  * Scan node_modules/@pyreon/ to discover all installed Pyreon packages.
@@ -45,6 +55,7 @@ import {
45
55
  scanRouteFilesWithExports,
46
56
  } from "./fs-router";
47
57
  import { render404Page } from "./not-found";
58
+ import { ssgPlugin } from "./ssg-plugin";
48
59
  import type { ZeroConfig } from "./types";
49
60
 
50
61
  const VIRTUAL_ROUTES_ID = "virtual:zero/routes";
@@ -69,12 +80,12 @@ const RESOLVED_VIRTUAL_API_ROUTES_ID = `\0${VIRTUAL_API_ROUTES_ID}`;
69
80
  * plugins: [pyreon(), zero()],
70
81
  * }
71
82
  */
72
- export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
83
+ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin[] {
73
84
  const config = resolveConfig(userConfig);
74
85
  let routesDir: string;
75
86
  let root: string;
76
87
 
77
- const plugin: Plugin & { _zeroConfig: ZeroConfig } = {
88
+ const mainPlugin: Plugin & { _zeroConfig: ZeroConfig } = {
78
89
  name: "pyreon-zero",
79
90
  enforce: "pre",
80
91
  _zeroConfig: userConfig,
@@ -127,6 +138,35 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
127
138
  },
128
139
 
129
140
  configureServer(server) {
141
+ // Dev-mode API-route middleware — production wires `createApiMiddleware`
142
+ // via `createServer`, but dev had no equivalent. API requests fell
143
+ // through to Vite's default 404. This middleware loads the
144
+ // `virtual:zero/api-routes` module and dispatches matching requests to
145
+ // the route's `GET()` / `POST()` / etc. handler. Mirrors the production
146
+ // flow in `entry-server.ts` minus the ergonomic helpers (auth, cors,
147
+ // etc. — those plug in via user-defined Pyreon middleware which dev
148
+ // doesn't currently load; not in scope here).
149
+ //
150
+ // Registered FIRST so API requests don't get SSR'd or 404'd.
151
+ server.middlewares.use((req, res, next) => {
152
+ const pathname = req.url?.split("?")[0] ?? "/";
153
+ if (pathname.startsWith("/@") || pathname.startsWith("/__"))
154
+ return next();
155
+ // Skip files (extension-bearing) — let Vite's static pipeline serve.
156
+ if (/\.\w+$/.test(pathname)) return next();
157
+
158
+ dispatchApiRoute(server, req, res).then(
159
+ (handled) => {
160
+ if (!handled) next();
161
+ },
162
+ (err: unknown) => {
163
+ // oxlint-disable-next-line no-console
164
+ console.error("[Pyreon] Error in dev API dispatcher:", err);
165
+ next();
166
+ },
167
+ );
168
+ });
169
+
130
170
  // Dev-mode SSR middleware — for mode: "ssr", actually render each
131
171
  // matched route server-side instead of serving the SPA shell.
132
172
  // Runs BEFORE the 404 handler so matched routes are SSR'd and
@@ -141,7 +181,26 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
141
181
  return next();
142
182
  if (/\.\w+$/.test(pathname)) return next();
143
183
 
144
- renderSsr(server, root, req.originalUrl ?? pathname, pathname).then(
184
+ // Build a Web Request from the Node IncomingMessage so loaders
185
+ // can read cookies / auth headers via `ctx.request` and call
186
+ // `redirect()` from a server-side context.
187
+ const reqHost = req.headers.host ?? "localhost";
188
+ const reqUrl = new URL(req.url ?? "/", `http://${reqHost}`);
189
+ const reqHeaders = new Headers();
190
+ for (const [key, value] of Object.entries(req.headers)) {
191
+ if (value !== undefined) {
192
+ reqHeaders.set(
193
+ key,
194
+ Array.isArray(value) ? value.join(", ") : String(value),
195
+ );
196
+ }
197
+ }
198
+ const webReq = new Request(reqUrl.href, {
199
+ method: req.method ?? "GET",
200
+ headers: reqHeaders,
201
+ });
202
+
203
+ renderSsr(server, root, req.originalUrl ?? pathname, pathname, webReq).then(
145
204
  (result) => {
146
205
  if (result === null) return next();
147
206
  res.statusCode = 200;
@@ -150,6 +209,16 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
150
209
  res.end(result);
151
210
  },
152
211
  (err: unknown) => {
212
+ // Loader-thrown `redirect()` — convert to a real HTTP redirect
213
+ // (302/307/308) BEFORE the layout renders. This is the dev-mode
214
+ // equivalent of the production handler's redirect catch.
215
+ const info = getRedirectInfo(err);
216
+ if (info) {
217
+ res.statusCode = info.status;
218
+ res.setHeader("Location", info.url);
219
+ res.end();
220
+ return;
221
+ }
153
222
  const error = err instanceof Error ? err : new Error(String(err));
154
223
  server.ssrFixStacktrace(error);
155
224
  const html = renderErrorOverlay(error);
@@ -254,11 +323,11 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
254
323
  });
255
324
  },
256
325
 
257
- config(userConfig) {
326
+ config(viteUserConfig) {
258
327
  // Discover all @pyreon/* packages installed in node_modules.
259
328
  // The "bun" export condition points to TS source — esbuild's
260
329
  // dep optimizer would compile them with the wrong JSX runtime.
261
- const root = userConfig.root ?? process.cwd()
330
+ const root = viteUserConfig.root ?? process.cwd()
262
331
  const pyreonExclude = scanPyreonPackages(root)
263
332
 
264
333
  // `@pyreon/runtime-server` is only imported by zero's dev SSR
@@ -292,9 +361,16 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
292
361
  optimizeDeps: {
293
362
  exclude: pyreonExclude,
294
363
  },
295
- server: {
296
- port: config.port,
297
- },
364
+ // Only set the port when the user explicitly provided one in
365
+ // `zero({ port: N })`. Without this guard, the plugin always
366
+ // returned `server: { port: 3000 }` which overrode Vite's CLI
367
+ // `--port` flag and made multi-example dev impossible — every
368
+ // example tried to bind 3000 even when launched with
369
+ // `vite --port 5173`. Surfaced when wiring up the playwright
370
+ // e2e suite.
371
+ ...(userConfig.port !== undefined
372
+ ? { server: { port: config.port } }
373
+ : {}),
298
374
  define: {
299
375
  __ZERO_MODE__: JSON.stringify(config.mode),
300
376
  __ZERO_BASE__: JSON.stringify(config.base),
@@ -303,7 +379,112 @@ export function zeroPlugin(userConfig: ZeroConfig = {}): Plugin {
303
379
  },
304
380
  };
305
381
 
306
- return plugin;
382
+ // SSG mode auto-wires the static-site generation hook. Other modes get
383
+ // just the main plugin. The SSG plugin internally no-ops when
384
+ // `mode !== 'ssg'`, but skipping it entirely keeps the plugin chain
385
+ // minimal for SSR/SPA/ISR builds (one less `closeBundle` to call).
386
+ return config.mode === "ssg" ? [mainPlugin, ssgPlugin(userConfig)] : [mainPlugin];
387
+ }
388
+
389
+ /**
390
+ * Dev-mode API-route dispatcher. Loads the `virtual:zero/api-routes` virtual
391
+ * module, builds a Web `Request` from the Node `IncomingMessage`, and invokes
392
+ * the matching route's HTTP-method handler.
393
+ *
394
+ * Returns `true` if the API middleware handled the request (response written).
395
+ * Returns `false` if no route matched (caller falls through to next middleware).
396
+ *
397
+ * Mirrors what `createServer` wires up in production via `createApiMiddleware`,
398
+ * but adapted for Vite's connect-style middleware stack — needs a Node→Web
399
+ * request adapter.
400
+ */
401
+ async function dispatchApiRoute(
402
+ server: ViteDevServer,
403
+ req: IncomingMessage,
404
+ res: ServerResponse,
405
+ ): Promise<boolean> {
406
+ let apiRoutes: ApiRouteEntry[];
407
+ try {
408
+ const mod = await server.ssrLoadModule(VIRTUAL_API_ROUTES_ID);
409
+ apiRoutes = (mod.apiRoutes ?? []) as ApiRouteEntry[];
410
+ } catch {
411
+ return false;
412
+ }
413
+ if (apiRoutes.length === 0) return false;
414
+
415
+ const host = req.headers.host ?? "localhost";
416
+ const url = new URL(req.url ?? "/", `http://${host}`);
417
+ const pathname = url.pathname;
418
+
419
+ // Quick gate: only build the Web Request when the path actually matches
420
+ // an api route. Reuses the same `matchApiRoute` that `createApiMiddleware`
421
+ // uses internally — including catch-all `:param*` patterns from
422
+ // `[...slug].ts` API routes — so the gate and the dispatcher agree on
423
+ // what counts as a match. Avoids per-request body buffering for SSR /
424
+ // static traffic that doesn't target an API route.
425
+ const anyMatch = apiRoutes.some((r) => matchApiRoute(r.pattern, pathname) !== null);
426
+ if (!anyMatch) return false;
427
+
428
+ // Convert Node IncomingMessage → Web Request. Stream the request body
429
+ // for non-GET/HEAD via `Readable.toWeb` instead of buffering — large
430
+ // uploads (multipart, file POSTs) don't have to fit in memory before
431
+ // the handler sees them. `duplex: 'half'` is required by the WHATWG
432
+ // fetch spec when `body` is a `ReadableStream`.
433
+ const method = (req.method ?? "GET").toUpperCase();
434
+ const headers = new Headers();
435
+ for (const [key, value] of Object.entries(req.headers)) {
436
+ if (value !== undefined) {
437
+ headers.set(key, Array.isArray(value) ? value.join(", ") : String(value));
438
+ }
439
+ }
440
+ const requestInit: RequestInit & { duplex?: "half" } = { method, headers };
441
+ if (method !== "GET" && method !== "HEAD") {
442
+ // `Readable.toWeb` returns Node's `node:stream/web` `ReadableStream`;
443
+ // `RequestInit.body` expects the DOM `ReadableStream`. They're
444
+ // structurally identical at runtime but TS keeps them as separate
445
+ // types — `as unknown as` is the standard bridge per TS's own
446
+ // "convert to unknown first" suggestion.
447
+ requestInit.body = Readable.toWeb(req) as unknown as ReadableStream<Uint8Array>;
448
+ requestInit.duplex = "half";
449
+ }
450
+ const webReq = new Request(url.href, requestInit);
451
+
452
+ const middleware = createApiMiddleware(apiRoutes);
453
+ const response = await middleware({
454
+ req: webReq,
455
+ url,
456
+ path: pathname + url.search,
457
+ headers: new Headers(),
458
+ locals: {},
459
+ });
460
+
461
+ if (!response) return false;
462
+
463
+ // Pipe the Web Response body directly to the Node response stream
464
+ // instead of buffering with `arrayBuffer()`. Critical for SSE, large
465
+ // downloads, and any handler that returns a `Response` constructed
466
+ // from a streaming source — buffering would defeat the streaming
467
+ // contract and OOM on large payloads.
468
+ res.statusCode = response.status;
469
+ response.headers.forEach((v, k) => {
470
+ res.setHeader(k, v);
471
+ });
472
+ if (response.body) {
473
+ // `pipe(res)` ends `res` automatically on stream completion and
474
+ // auto-cancels the upstream Web ReadableStream if the client
475
+ // disconnects (Node ≥18). We don't await — once the headers and
476
+ // pipe are wired, the function's job is done. The connect chain
477
+ // doesn't call `next()` because we resolved with `true`.
478
+ // `response.body` is a DOM `ReadableStream`; `Readable.fromWeb`
479
+ // expects `node:stream/web`'s `ReadableStream`. Cross-realm types
480
+ // don't unify in TS — bridge via `unknown` per TS's own guidance.
481
+ Readable.fromWeb(
482
+ response.body as unknown as import("node:stream/web").ReadableStream,
483
+ ).pipe(res);
484
+ } else {
485
+ res.end();
486
+ }
487
+ return true;
307
488
  }
308
489
 
309
490
  /**
@@ -359,6 +540,7 @@ async function renderSsr(
359
540
  root: string,
360
541
  originalUrl: string,
361
542
  pathname: string,
543
+ req?: Request,
362
544
  ): Promise<string | null> {
363
545
  // Pattern check FIRST — otherwise SSR would try (and likely crash) on
364
546
  // asset paths that happened to accept text/html (e.g. curl-style).
@@ -403,25 +585,19 @@ async function renderSsr(
403
585
  ],
404
586
  );
405
587
 
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
- }
588
+ // Don't auto-load `_layout.tsx` as the outer Layout. fs-router already
589
+ // emits it as a parent route in the matched chain — wrapping createApp
590
+ // with the same layout AGAIN produced a double mount: the App tree was
591
+ // `<Layout><RouterView/></Layout>` AND the router's first matched
592
+ // record was the layout, rendering it a second time inside RouterView.
593
+ // Symptom: duplicate `<nav>`, duplicate `<div id="layout">`,
594
+ // hydration mismatches, strict-mode locator violations in playwright.
595
+ //
596
+ // If a user genuinely needs an outer wrapper that sits ABOVE the
597
+ // router (e.g. a global provider not tied to any route), they can
598
+ // still pass it via `startClient({ layout })` — but the file should
599
+ // NOT live under `src/routes/_layout.{ts,tsx,…}` (which fs-router
600
+ // always treats as a parent route).
425
601
 
426
602
  // Use zero's own `createApp` rather than reassembling the tree by hand —
427
603
  // guarantees server and client agree on every wrapper component (any
@@ -435,20 +611,16 @@ async function renderSsr(
435
611
  const appMod = (await server.ssrLoadModule(
436
612
  "@pyreon/zero/server",
437
613
  )) as typeof import("./server")
438
- type CreateAppLayout = NonNullable<
439
- Parameters<typeof appMod.createApp>[0]["layout"]
440
- >
441
614
  const { App, router: routerInst } = appMod.createApp({
442
615
  routes: routes as import("@pyreon/router").RouteRecord[],
443
616
  routerMode: "history",
444
617
  url: pathname,
445
- ...(userLayout ? { layout: userLayout as CreateAppLayout } : {}),
446
618
  })
447
619
 
448
620
  // `preload` loads lazy route components AND runs loaders for `pathname` so
449
621
  // the synchronous render pass produces final HTML — no loading fallbacks,
450
622
  // no `useLoaderData() === undefined`.
451
- await routerInst.preload(pathname);
623
+ await routerInst.preload(pathname, req);
452
624
 
453
625
  return runtimeServer.runWithRequestContext(async () => {
454
626
  const app = core.h(App as Parameters<typeof core.h>[0], null);
@@ -469,22 +641,30 @@ async function renderSsr(
469
641
  });
470
642
  }
471
643
 
472
- /** Extract all URL patterns from a nested route tree. */
644
+ /**
645
+ * Extract all URL patterns from a nested route tree.
646
+ *
647
+ * The fs-router emits ABSOLUTE paths for every route, including grandchildren —
648
+ * `{ path: "/app/dashboard" }` not `{ path: "dashboard" }`. The matcher reads
649
+ * each route's `path` as-is; no prefix accumulation. Pre-fix, this function
650
+ * concatenated `${prefix}${route.path}` which produced patterns like
651
+ * `///app/app/dashboard` (prefix `'/app'` + path `'/app/dashboard'`). After
652
+ * `path.split('/').filter(Boolean)` those became `['app', 'app', 'dashboard']`
653
+ * — which can't match a real `/app/dashboard` request — so dev-server returned
654
+ * 404 for every nested-layout route. Re-enables PR #411 specs that rely on
655
+ * `/app/*` routing.
656
+ */
473
657
  function flattenRoutePatterns(
474
658
  routes: Array<{ path?: string; children?: unknown[] }>,
475
- prefix = "",
476
659
  ): string[] {
477
660
  const patterns: string[] = [];
478
661
  for (const route of routes) {
479
662
  if (!route.path) continue;
480
- const fullPath =
481
- route.path === "/" && prefix ? prefix : `${prefix}${route.path}`;
482
- patterns.push(fullPath);
663
+ patterns.push(route.path);
483
664
  if (route.children) {
484
665
  patterns.push(
485
666
  ...flattenRoutePatterns(
486
667
  route.children as Array<{ path?: string; children?: unknown[] }>,
487
- fullPath,
488
668
  ),
489
669
  );
490
670
  }
@@ -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"}