@pyreon/zero 0.21.0 → 0.23.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.
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # @pyreon/zero
2
2
 
3
- Core meta-framework for building full-stack apps with [Pyreon](https://github.com/pyreon/pyreon) and [Vite](https://vite.dev).
3
+ Zero-config full-stack meta-framework file-system routing, SSR/SSG/ISR/SPA, deploy adapters.
4
+
5
+ `@pyreon/zero` wraps `@pyreon/server` + `@pyreon/router` + `@pyreon/head` + `@pyreon/vite-plugin` into a single Vite plugin and a conventions-based project layout: `src/routes/` is the route tree (`[param]`, `[...catchAll]`, `_layout`, `_404`, `_loading`, `_error`, `(groups)`), per-file `export const { loader, meta, middleware, getStaticPaths, revalidate, renderMode }` opts into capabilities, and `mode: 'ssr' | 'ssg' | 'isr' | 'spa'` picks the rendering strategy. Production builds run through one of six deploy adapters (Vercel / Cloudflare Pages / Netlify / Node / Bun / static). The main entry is **client-safe**; server-only APIs live at `@pyreon/zero/server`.
4
6
 
5
7
  ## Install
6
8
 
@@ -8,70 +10,222 @@ Core meta-framework for building full-stack apps with [Pyreon](https://github.co
8
10
  bun add @pyreon/zero
9
11
  ```
10
12
 
11
- ## Features
12
-
13
- - **File-based routing** — `[param]`, `[...catchAll]`, `_layout`, `_error`, `_loading`, `(groups)`
14
- - **Rendering modes** — SSR (streaming + string), SSG, ISR, SPA (per-route configurable via `renderMode` export)
15
- - **API routes** — `.ts` files in `src/routes/api/` export HTTP method handlers (`GET`, `POST`, `PUT`, `DELETE`)
16
- - **Server actions** — `defineAction()` for mutations with automatic client/server boundary detection
17
- - **Per-route middleware** — route files export `middleware` using `@pyreon/server`'s signature
18
- - **Components** — `<Image>` (lazy load, srcset, blur-up), `<Link>` (prefetch, active state), `<Script>` (loading strategies)
19
- - **Theme** — Dark/light/system with `theme` signal, `<ThemeToggle>`, and anti-flash inline script
20
- - **Fonts** — Google Fonts self-hosting at build time, local fonts, size-adjusted fallbacks
21
- - **Image optimization** — Build-time processing via `?optimize` imports (WebP/AVIF, blur placeholders). Type the custom queries with one line — `/// <reference types="@pyreon/zero/image-types" />` — which ships ambient `declare module "*?optimize"` / `"*?component"` / `"*?raw"` reusing the plugin's own `ProcessedImage`.
22
- - **SEO** — Sitemap, robots.txt, JSON-LD helpers (Vite plugin + dev middleware)
23
- - **Middleware** — `cacheMiddleware()`, `securityHeaders()`, `corsMiddleware()`, `rateLimitMiddleware()`, `compressionMiddleware()`
24
- - **Adapters** — Node.js, Bun, static, Vercel, Cloudflare Pages, Netlify Functions
25
- - **Testing** — `createTestContext()`, `testMiddleware()`, `createTestApiServer()`, `createMockHandler()`
26
- - **Dev overlay** — Styled error overlay with source-mapped stack traces for SSR errors
27
- - **CSP middleware** — `cspMiddleware({ directives })` with `useNonce()` for inline scripts
28
- - **Env validation** — `validateEnv()` with type coercion, `schema()` for custom parsers, `publicEnv()`
29
- - **Request logging** — `loggerMiddleware()` with structured output
30
- - **AI integration** — `aiPlugin()` for llms.txt, JSON-LD inference, AI plugin manifest
31
- - **useRequestLocals()** — Bridge middleware locals into components
32
- - **Locale-aware favicons** — Per-locale favicon generation from source SVG/PNG
33
- - **OG image generation** — Build-time Open Graph image rendering
34
- - **Reactive favicon** — Theme-synced light/dark favicon switching
35
- - **Client-safe entry** — `@pyreon/zero` = client-safe, `@pyreon/zero/server` = server-only
36
-
37
- ## Usage
13
+ `sharp` is an optional peer dep — install it (`bun add -D sharp`) only if you use `imagePlugin` / `faviconPlugin` / `ogImagePlugin`.
14
+
15
+ ## Quick start
38
16
 
39
17
  ```ts
40
18
  // vite.config.ts
19
+ import { defineConfig } from 'vite'
41
20
  import pyreon from '@pyreon/vite-plugin'
42
- import zero from '@pyreon/zero'
21
+ import zero from '@pyreon/zero/server'
22
+
23
+ export default defineConfig({
24
+ plugins: [
25
+ pyreon({ islands: true }),
26
+ zero({
27
+ mode: 'ssr', // 'ssr' | 'ssg' | 'isr' | 'spa'
28
+ ssr: { mode: 'stream' }, // 'string' | 'stream'
29
+ adapter: 'node', // 'vercel' | 'cloudflare' | 'netlify' | 'node' | 'bun' | 'static'
30
+ }),
31
+ ],
32
+ })
33
+ ```
34
+
35
+ ```tsx
36
+ // src/routes/index.tsx — the homepage
37
+ import { useLoaderData } from '@pyreon/router'
43
38
 
44
- export default {
45
- plugins: [pyreon(), zero({ mode: 'ssr', ssr: { mode: 'stream' } })],
39
+ export const loader = async () => fetch('/api/hello').then((r) => r.json())
40
+
41
+ export default function Home() {
42
+ const data = useLoaderData<{ message: string }>()
43
+ return <h1>{data.message}</h1>
46
44
  }
47
45
  ```
48
46
 
49
- ## Subpath Exports
50
-
51
- | Export | Description |
52
- | --------------------------- | ----------------------------------------------------- |
53
- | `@pyreon/zero` | Client-safe: components, middleware, adapters, theme, SEO, fonts |
54
- | `@pyreon/zero/client` | Client-side entry (`startClient`) |
55
- | `@pyreon/zero/config` | `defineConfig`, `resolveConfig` |
56
- | `@pyreon/zero/image` | `<Image>` component |
57
- | `@pyreon/zero/link` | `<Link>`, `useLink`, `createLink` |
58
- | `@pyreon/zero/script` | `<Script>` component |
59
- | `@pyreon/zero/theme` | Theme system and `<ThemeToggle>` |
60
- | `@pyreon/zero/font` | Font optimization plugin |
61
- | `@pyreon/zero/cache` | Cache and security middleware |
62
- | `@pyreon/zero/seo` | SEO plugin, sitemap, robots.txt |
63
- | `@pyreon/zero/image-plugin` | Image optimization Vite plugin |
64
- | `@pyreon/zero/actions` | `defineAction`, `createActionMiddleware` |
65
- | `@pyreon/zero/api-routes` | API route utilities and middleware |
66
- | `@pyreon/zero/cors` | CORS middleware |
67
- | `@pyreon/zero/rate-limit` | Rate limiting middleware |
68
- | `@pyreon/zero/compression` | Compression middleware |
69
- | `@pyreon/zero/testing` | Test utilities for middleware and API routes |
70
- | `@pyreon/zero/server` | Server-only: `createServer`, `validateEnv`, `useNonce`, `useRequestLocals` |
71
- | `@pyreon/zero/adapter-vercel` | Vercel serverless deployment adapter |
72
- | `@pyreon/zero/adapter-cloudflare` | Cloudflare Pages deployment adapter |
73
- | `@pyreon/zero/adapter-netlify` | Netlify Functions deployment adapter |
47
+ ```tsx
48
+ // src/routes/_layout.tsx — wraps every route in this subtree
49
+ import { Link, Meta } from '@pyreon/zero'
50
+
51
+ export default function Layout({ children }) {
52
+ return (
53
+ <>
54
+ <Meta title="My App" description="..." />
55
+ <nav><Link to="/">Home</Link> <Link to="/posts">Posts</Link></nav>
56
+ <main>{children}</main>
57
+ </>
58
+ )
59
+ }
60
+ ```
61
+
62
+ ## File-system routing
63
+
64
+ | File | Role |
65
+ |----------------------------|-------------------------------------------------------------------|
66
+ | `src/routes/index.tsx` | `/` — homepage |
67
+ | `src/routes/about.tsx` | `/about` |
68
+ | `src/routes/[id].tsx` | `/:id` dynamic param |
69
+ | `src/routes/[...slug].tsx` | `/*` catch-all |
70
+ | `src/routes/_layout.tsx` | Wraps the whole subtree |
71
+ | `src/routes/_404.tsx` | Not-found page (auto-emitted as `dist/404.html` in SSG) |
72
+ | `src/routes/_error.tsx` | Route-level error boundary |
73
+ | `src/routes/_loading.tsx` | Loader-in-flight component |
74
+ | `src/routes/(group)/x.tsx` | `/x` — group prefix is stripped from the URL |
75
+ | `src/routes/api/*.ts` | API routes — `export function GET / POST / PUT / DELETE / …` |
76
+
77
+ Each route file may also export `loader`, `meta`, `middleware`, `guard`, `getStaticPaths`, `revalidate`, and `renderMode`.
78
+
79
+ ## Rendering modes
80
+
81
+ ```ts
82
+ zero({ mode: 'ssr' }) // server-rendered per request (default)
83
+ zero({ mode: 'ssg' }) // prerender every static path at build time → dist/<path>/index.html
84
+ zero({ mode: 'isr' }) // SSR + in-memory LRU cache, on-demand revalidation
85
+ zero({ mode: 'spa' }) // client-only — single dist/index.html shell
86
+ ```
87
+
88
+ Per-route override: `export const renderMode = 'ssg'`.
89
+
90
+ ## SSG
91
+
92
+ ```tsx
93
+ // src/routes/posts/[slug].tsx — enumerate static paths at build time
94
+ import type { GetStaticPaths } from '@pyreon/zero/server'
95
+
96
+ export const getStaticPaths: GetStaticPaths<{ slug: string }> = async () => {
97
+ const posts = await loadAllPosts()
98
+ return posts.map((p) => ({ params: { slug: p.slug } }))
99
+ }
100
+
101
+ export const revalidate = 3600 // optional — build-time ISR (per platform adapter)
102
+
103
+ export const loader = ({ params }) => fetchPost(params.slug)
104
+ export default function Post() { /* ... */ }
105
+ ```
106
+
107
+ SSG features (all on by default; opt out via `ssg: { ... }`):
108
+
109
+ - `_404.tsx` → `dist/404.html` (per-locale variants if i18n configured)
110
+ - Loader-thrown `redirect()` → `dist/_redirects` (Netlify/Cloudflare) + `_redirects.json` (Vercel)
111
+ - Sitemap auto-emit (via `seoPlugin({ sitemap: { useSsgPaths: true } })`)
112
+ - Concurrent rendering (`ssg.concurrency`, default 4) + per-path `onProgress` callbacks
113
+ - Render-error fallback via `ssg.onPathError`; structured `_pyreon-ssg-errors.json` artifact
114
+ - Path-collision detection (loud build failure on duplicate URLs)
115
+
116
+ ## ISR
117
+
118
+ ```ts
119
+ zero({ mode: 'isr', isr: { revalidate: 60, maxEntries: 1000 } })
120
+ ```
121
+
122
+ In-memory LRU SSR cache with TTL revalidation. **Default keys cache by `url.pathname` only** — for auth-gated pages, supply `cacheKey: (req) => …` that varies on session cookie / user-id header to avoid serving one user's HTML to another.
123
+
124
+ ## Built-in components
125
+
126
+ ```tsx
127
+ import { Image, Link, Script, Meta, Icon, createIcon, createNamedIcon, ThemeToggle } from '@pyreon/zero'
128
+
129
+ <Image src="/hero.jpg" width={1200} height={600} placeholder="blur" />
130
+ <Link to="/about" prefetch="intent">About</Link>
131
+ <Script strategy="afterInteractive" src="https://analytics.example.com/script.js" />
132
+ <Meta title="..." description="..." />
133
+ <Icon as={MyIconSvgComponent} /> {/* loaded via `?component` */}
134
+ <ThemeToggle /> {/* light/dark/system mode */}
135
+ ```
136
+
137
+ `<Image>` ships with `imagePlugin` (build-time WebP/AVIF + blur/color placeholders). `<Link>` is `@pyreon/router`'s `RouterLink` re-exported. `<Meta>` writes via `@pyreon/head`.
138
+
139
+ ## Vite plugins (server-only)
140
+
141
+ ```ts
142
+ import { faviconPlugin, iconsPlugin, ogImagePlugin, seoPlugin, aiPlugin } from '@pyreon/zero/server'
143
+
144
+ // vite.config.ts
145
+ plugins: [
146
+ zero({ /* ... */ }),
147
+ iconsPlugin({
148
+ sets: {
149
+ ui: { dir: './src/icons/ui' },
150
+ brand: { dir: './src/icons/brand', mode: 'image' },
151
+ },
152
+ }),
153
+ faviconPlugin({ source: './src/favicon.svg' }),
154
+ ogImagePlugin({ templates: { default: { /* ... */ } } }),
155
+ seoPlugin({ sitemap: { useSsgPaths: true }, robots: true }),
156
+ aiPlugin(), // generates llms.txt + JSON-LD inference + AI plugin manifest
157
+ ]
158
+ ```
159
+
160
+ ## Deploy adapters
161
+
162
+ ```ts
163
+ import { vercelAdapter, cloudflareAdapter, netlifyAdapter, nodeAdapter, bunAdapter, staticAdapter } from '@pyreon/zero/server'
164
+
165
+ zero({ adapter: vercelAdapter() })
166
+ // or by string id:
167
+ zero({ adapter: 'cloudflare' })
168
+ ```
169
+
170
+ Each adapter writes its own platform config (`.vercel/output/config.json`, `_routes.json`, `netlify.toml`, etc.) during `closeBundle`. Adapters with revalidation support (`vercel` / `cloudflare` / `netlify`) implement `Adapter.revalidate(path)` — pair with `vercelRevalidateHandler` for the canonical webhook scaffold.
171
+
172
+ ## Server middleware
173
+
174
+ ```ts
175
+ import { compose } from '@pyreon/zero/server'
176
+ import { cspMiddleware, useNonce } from '@pyreon/zero/csp'
177
+ import { loggerMiddleware } from '@pyreon/zero/logger'
178
+ import { corsMiddleware } from '@pyreon/zero/cors'
179
+ import { rateLimitMiddleware } from '@pyreon/zero/rate-limit'
180
+ import { compressionMiddleware } from '@pyreon/zero/compression'
181
+
182
+ const handler = compose([
183
+ loggerMiddleware(),
184
+ corsMiddleware({ origin: 'https://app.example.com' }),
185
+ rateLimitMiddleware({ windowMs: 60_000, max: 100 }),
186
+ cspMiddleware({ directives: { 'script-src': ["'self'", "'nonce-{nonce}'"] } }),
187
+ compressionMiddleware(),
188
+ ])
189
+ ```
190
+
191
+ ## i18n routing
192
+
193
+ ```ts
194
+ zero({
195
+ i18n: { locales: ['en', 'de', 'cs'], defaultLocale: 'en', strategy: 'prefix-except-default' },
196
+ })
197
+ ```
198
+
199
+ Routes are duplicated per locale at build time. `prefix-except-default` keeps the default locale unprefixed (`/about`) and prefixes others (`/de/about`); `prefix` prefixes every locale including the default. Loader context + sitemap hreflang siblings + per-locale `_404.tsx` all compose automatically.
200
+
201
+ ## Subpath exports (server-only)
202
+
203
+ | Subpath | Notes |
204
+ |-------------------------------|--------------------------------------------------------------------------------------|
205
+ | `@pyreon/zero/server` | `createServer`, `createApp`, `createISRHandler`, adapters, plugins, `vercelRevalidateHandler` |
206
+ | `@pyreon/zero/client` | `startClient`, `hydrateIslands*` re-exports |
207
+ | `@pyreon/zero/config` | `defineConfig`, `resolveConfig` |
208
+ | `@pyreon/zero/env` | `validateEnv`, `publicEnv`, `schema` |
209
+ | `@pyreon/zero/middleware` | Generic `Middleware` helpers |
210
+ | `@pyreon/zero/testing` | `createTestContext`, `testMiddleware`, `createTestApiServer` |
211
+
212
+ The main entry (`@pyreon/zero`) re-exports browser-safe pieces only — components, theme, i18n helpers. Server APIs imported from the main entry throw a clear error pointing at the right subpath.
213
+
214
+ ## Gotchas
215
+
216
+ - `@pyreon/zero` ≠ `@pyreon/zero/server` — the main entry is client-safe. Server plugins (`faviconPlugin`, `seoPlugin`, `createServer`) MUST be imported from `/server`. Importing them from the main entry throws at module-load with a pointer to the right path.
217
+ - ISR with auth-gated pages needs `cacheKey: (req) => …` that varies on session — the default keys by `url.pathname` only and will serve one user's HTML to another.
218
+ - `_404.tsx` rendered HTML is emitted by SSG, but **static hosts must be configured to serve it** for unmatched URLs (most managed hosts do this by convention; bare S3 / nginx / Caddy need explicit per-locale `try_files` / `[[redirects]]`).
219
+ - `getStaticPaths` / `revalidate` literal-extraction skips re-exports + non-literal expressions. Inline the value (`export const revalidate = 60`), don't reference a const.
220
+ - `sharp` is optional. Without it, `imagePlugin` falls back to a soft warning in dev and a HARD `vite build` error in prod (never silently ships an image-broken site).
221
+ - Never pass `layout` to `startClient` when using fs-router's `_layout.tsx` convention — the route tree already wraps every page in the layout, and the explicit option double-mounts.
222
+
223
+ ## Documentation
224
+
225
+ Full docs: [docs.pyreon.dev/docs/zero](https://docs.pyreon.dev/docs/zero) (or `docs/docs/zero.md` in this repo).
226
+
227
+ SSG-specific guide: [docs.pyreon.dev/docs/ssg](https://docs.pyreon.dev/docs/ssg).
74
228
 
75
229
  ## License
76
230
 
77
- [MIT](LICENSE)
231
+ MIT
@@ -0,0 +1,36 @@
1
+ import { Fragment, h } from "@pyreon/core";
2
+ import { RouterProvider, RouterView, createRouter } from "@pyreon/router";
3
+ import { HeadProvider } from "@pyreon/head";
4
+
5
+ //#region src/app.ts
6
+ /**
7
+ * Create a full Zero app — assembles router, head provider, and root layout.
8
+ *
9
+ * Used internally by entry-server and entry-client.
10
+ */
11
+ function createApp(options) {
12
+ const router = createRouter({
13
+ routes: options.routes,
14
+ mode: options.routerMode ?? "history",
15
+ ...options.url ? { url: options.url } : {},
16
+ ...options.base && options.base !== "/" ? { base: options.base } : {},
17
+ scrollBehavior: "top"
18
+ });
19
+ const hasLayoutInRoutes = options.layout !== void 0 && options.routes.some((r) => r.component === options.layout);
20
+ if (hasLayoutInRoutes && process.env.NODE_ENV !== "production") console.warn("[Pyreon] `createApp({ layout })` was passed a component that is ALSO a parent route in the matched chain (likely an fs-router `_layout.tsx`). The explicit `layout` option is being ignored to prevent double-mount. Remove the `layout` argument from `createApp`/`startClient` — the fs-router-emitted route handles it.");
21
+ const Layout = hasLayoutInRoutes ? DefaultLayout : options.layout ?? DefaultLayout;
22
+ function App() {
23
+ return h(HeadProvider, null, h(RouterProvider, { router }, h(Layout, null, h(RouterView, null))));
24
+ }
25
+ return {
26
+ App,
27
+ router
28
+ };
29
+ }
30
+ function DefaultLayout(props) {
31
+ return h(Fragment, null, ...Array.isArray(props.children) ? props.children : [props.children]);
32
+ }
33
+
34
+ //#endregion
35
+ export { createApp as t };
36
+ //# sourceMappingURL=app-BbPT0Y5M.js.map
@@ -1,7 +1,23 @@
1
- import { t as __exportAll } from "./rolldown-runtime-CjeV3_4I.js";
2
1
  import { readFileSync } from "node:fs";
3
2
  import { join } from "node:path";
4
3
 
4
+ //#region \0rolldown/runtime.js
5
+ var __defProp = Object.defineProperty;
6
+ var __exportAll = (all, no_symbols) => {
7
+ let target = {};
8
+ for (var name in all) {
9
+ __defProp(target, name, {
10
+ get: all[name],
11
+ enumerable: true
12
+ });
13
+ }
14
+ if (!no_symbols) {
15
+ __defProp(target, Symbol.toStringTag, { value: "Module" });
16
+ }
17
+ return target;
18
+ };
19
+
20
+ //#endregion
5
21
  //#region src/fs-router.ts
6
22
  var fs_router_exports = /* @__PURE__ */ __exportAll({
7
23
  detectRouteExports: () => detectRouteExports,
@@ -752,7 +768,7 @@ function generateRouteModuleFromRoutes(routes, routesDir, options) {
752
768
  if (errorName) opts.push(`error: ${errorName}`);
753
769
  opts.push(`hmrId: ${JSON.stringify(fullPath)}`);
754
770
  const optsStr = `, { ${opts.join(", ")} }`;
755
- imports.push(`const ${name} = lazy(() => import("${fullPath}")${optsStr})`);
771
+ imports.push(`const ${name} = lazy(() => import(${JSON.stringify(fullPath)})${optsStr})`);
756
772
  return name;
757
773
  }
758
774
  /**
@@ -969,7 +985,7 @@ async function scanRouteFiles(routesDir) {
969
985
  */
970
986
  async function scanRouteFilesWithExports(routesDir, defaultMode = "ssr") {
971
987
  const { readFile } = await import("node:fs/promises");
972
- const { isApiRoute } = await import("./api-routes-CMsLztoj.js").then((n) => n.t);
988
+ const { isApiRoute } = await import("../api-routes.js");
973
989
  const files = (await scanRouteFiles(routesDir)).filter((f) => !isApiRoute(f));
974
990
  const exportsMap = /* @__PURE__ */ new Map();
975
991
  await Promise.all(files.map(async (filePath) => {
@@ -984,5 +1000,5 @@ async function scanRouteFilesWithExports(routesDir, defaultMode = "ssr") {
984
1000
  }
985
1001
 
986
1002
  //#endregion
987
- export { generateRouteModuleFromRoutes as a, scanRouteFilesWithExports as c, generateRouteModule as i, fs_router_exports as n, parseFileRoutes as o, generateMiddlewareModule as r, scanRouteFiles as s, filePathToUrlPath as t };
988
- //# sourceMappingURL=fs-router-Bacdhsq-.js.map
1003
+ export { generateRouteModuleFromRoutes as a, scanRouteFilesWithExports as c, generateRouteModule as i, __exportAll as l, fs_router_exports as n, parseFileRoutes as o, generateMiddlewareModule as r, scanRouteFiles as s, filePathToUrlPath as t };
1004
+ //# sourceMappingURL=fs-router-DvBlRzmP.js.map
@@ -0,0 +1,29 @@
1
+ import { onMount, onUnmount } from "@pyreon/core";
2
+
3
+ //#region src/utils/use-intersection-observer.ts
4
+ /**
5
+ * Observes an element and calls `onIntersect` once it enters the viewport.
6
+ * Automatically disconnects after the first intersection.
7
+ *
8
+ * @param getElement - Getter for the target element (may be undefined before mount).
9
+ * @param onIntersect - Callback fired when the element becomes visible.
10
+ * @param rootMargin - IntersectionObserver rootMargin. Default: "200px".
11
+ */
12
+ function useIntersectionObserver(getElement, onIntersect, rootMargin = "200px") {
13
+ onMount(() => {
14
+ const el = getElement();
15
+ if (!el) return void 0;
16
+ const observer = new IntersectionObserver((entries) => {
17
+ for (const entry of entries) if (entry.isIntersecting) {
18
+ onIntersect();
19
+ observer.disconnect();
20
+ }
21
+ }, { rootMargin });
22
+ observer.observe(el);
23
+ onUnmount(() => observer.disconnect());
24
+ });
25
+ }
26
+
27
+ //#endregion
28
+ export { useIntersectionObserver as t };
29
+ //# sourceMappingURL=use-intersection-observer-C6opeplh.js.map
package/lib/actions.js CHANGED
@@ -1,4 +1,19 @@
1
1
  //#region src/actions.ts
2
+ /**
3
+ * Module-level registry of every `defineAction()` call. Lookup is by the
4
+ * `action_<uuid>` string the client sends in `POST /_zero/actions/<id>`.
5
+ *
6
+ * **HMR caveat (dev-only):** the registry uses fresh `crypto.randomUUID()`
7
+ * per `defineAction()` invocation. When Vite hot-replaces a module that
8
+ * calls `defineAction()`, the module re-runs and a NEW entry is inserted
9
+ * — the OLD entry stays in the Map until the dev process exits. Each
10
+ * entry holds `{ id, handler }` (~80 bytes). Bounded by the count of
11
+ * distinct UUIDs minted in the session; a realistic dev session sees
12
+ * <50 entries, so total dev-memory cost stays under ~5KB. Production
13
+ * registers each module exactly once at startup — no leak. A
14
+ * FinalizationRegistry-based purge is tracked as a follow-up; the
15
+ * current cost is too small to justify the WeakRef/finalizer complexity.
16
+ */
2
17
  const actionRegistry = /* @__PURE__ */ new Map();
3
18
  /**
4
19
  * Define a server action. Returns a callable function that:
@@ -73,12 +88,17 @@ function createActionMiddleware() {
73
88
  };
74
89
  }
75
90
  async function executeAction(action, req) {
91
+ const contentType = req.headers.get("content-type") ?? "";
92
+ let formData = null;
93
+ let json = null;
76
94
  try {
77
- const contentType = req.headers.get("content-type") ?? "";
78
- let formData = null;
79
- let json = null;
80
95
  if (contentType.includes("application/json")) json = await req.json();
81
96
  else if (contentType.includes("multipart/form-data") || contentType.includes("application/x-www-form-urlencoded")) formData = await req.formData();
97
+ } catch (err) {
98
+ console.error("[Pyreon Action] failed to parse request body:", err);
99
+ return Response.json({ error: "Invalid request body" }, { status: 400 });
100
+ }
101
+ try {
82
102
  const result = await action.handler({
83
103
  request: req,
84
104
  formData,
@@ -87,6 +107,7 @@ async function executeAction(action, req) {
87
107
  });
88
108
  return Response.json(result ?? null);
89
109
  } catch (err) {
110
+ console.error("[Pyreon Action] handler failed:", err);
90
111
  const message = err instanceof Error ? err.message : "Internal server error";
91
112
  return Response.json({ error: message }, { status: 500 });
92
113
  }
package/lib/ai.js CHANGED
@@ -1,106 +1,5 @@
1
- //#region src/fs-router.ts
2
- const ROUTE_EXTENSIONS = [
3
- ".tsx",
4
- ".jsx",
5
- ".ts",
6
- ".js"
7
- ];
8
- /**
9
- * Parse a set of file paths (relative to routes dir) into FileRoute objects.
10
- *
11
- * @param files Array of file paths like ["index.tsx", "users/[id].tsx"]
12
- * @param defaultMode Default rendering mode from config
13
- * @param exportsMap Optional map of filePath → detected exports. When
14
- * provided, the resulting FileRoute objects carry export info that the
15
- * code generator uses to optimize imports (skip metadata namespace
16
- * imports for routes that only export `default`).
17
- */
18
- function parseFileRoutes(files, defaultMode = "ssr", exportsMap) {
19
- return files.filter((f) => ROUTE_EXTENSIONS.some((ext) => f.endsWith(ext))).map((filePath) => {
20
- const route = parseFilePath(filePath, defaultMode);
21
- const exp = exportsMap?.get(filePath);
22
- return exp ? {
23
- ...route,
24
- exports: exp
25
- } : route;
26
- }).sort(sortRoutes);
27
- }
28
- function parseFilePath(filePath, defaultMode) {
29
- let route = filePath;
30
- for (const ext of ROUTE_EXTENSIONS) if (route.endsWith(ext)) {
31
- route = route.slice(0, -ext.length);
32
- break;
33
- }
34
- const fileName = getFileName(route);
35
- const isLayout = fileName === "_layout";
36
- const isError = fileName === "_error";
37
- const isLoading = fileName === "_loading";
38
- const isNotFound = fileName === "_404" || fileName === "_not-found";
39
- const isCatchAll = route.includes("[...");
40
- const parts = route.split("/");
41
- parts.pop();
42
- const dirPath = parts.filter((s) => !(s.startsWith("(") && s.endsWith(")"))).join("/");
43
- const urlPath = filePathToUrlPath(route);
44
- return {
45
- filePath,
46
- urlPath,
47
- dirPath,
48
- depth: urlPath === "/" ? 0 : urlPath.split("/").filter(Boolean).length,
49
- isLayout,
50
- isError,
51
- isLoading,
52
- isNotFound,
53
- isCatchAll,
54
- renderMode: defaultMode
55
- };
56
- }
57
- /**
58
- * Convert a file path (without extension) to a URL path pattern.
59
- *
60
- * Examples:
61
- * "index" → "/"
62
- * "about" → "/about"
63
- * "users/index" → "/users"
64
- * "users/[id]" → "/users/:id"
65
- * "blog/[...slug]" → "/blog/:slug*"
66
- * "(auth)/login" → "/login" (group stripped)
67
- * "_layout" → "/" (layout marker)
68
- */
69
- function filePathToUrlPath(filePath) {
70
- const segments = filePath.split("/");
71
- const urlSegments = [];
72
- for (const seg of segments) {
73
- if (seg.startsWith("(") && seg.endsWith(")")) continue;
74
- if (seg === "_layout" || seg === "_error" || seg === "_loading" || seg === "_404" || seg === "_not-found") continue;
75
- if (seg === "index") continue;
76
- const catchAll = seg.match(/^\[\.\.\.(\w+)\]$/);
77
- if (catchAll) {
78
- urlSegments.push(`:${catchAll[1]}*`);
79
- continue;
80
- }
81
- const dynamic = seg.match(/^\[(\w+)\]$/);
82
- if (dynamic) {
83
- urlSegments.push(`:${dynamic[1]}`);
84
- continue;
85
- }
86
- urlSegments.push(seg);
87
- }
88
- return `/${urlSegments.join("/")}` || "/";
89
- }
90
- /** Sort routes: static before dynamic, catch-all last. */
91
- function sortRoutes(a, b) {
92
- if (a.isCatchAll !== b.isCatchAll) return a.isCatchAll ? 1 : -1;
93
- if (a.isLayout !== b.isLayout) return a.isLayout ? -1 : 1;
94
- const aDynamic = a.urlPath.includes(":");
95
- if (aDynamic !== b.urlPath.includes(":")) return aDynamic ? 1 : -1;
96
- return a.urlPath.localeCompare(b.urlPath);
97
- }
98
- function getFileName(filePath) {
99
- const parts = filePath.split("/");
100
- return parts[parts.length - 1] ?? "";
101
- }
1
+ import { o as parseFileRoutes } from "./_chunks/fs-router-DvBlRzmP.js";
102
2
 
103
- //#endregion
104
3
  //#region src/ai.ts
105
4
  /**
106
5
  * Generate llms.txt content from route files and config.
package/lib/client.js CHANGED
@@ -1,38 +1,8 @@
1
- import { Fragment, h } from "@pyreon/core";
2
- import { RouterProvider, RouterView, createRouter, hydrateLoaderData } from "@pyreon/router";
1
+ import { t as createApp } from "./_chunks/app-BbPT0Y5M.js";
2
+ import { h } from "@pyreon/core";
3
+ import { hydrateLoaderData } from "@pyreon/router";
3
4
  import { hydrateRoot, mount } from "@pyreon/runtime-dom";
4
- import { HeadProvider } from "@pyreon/head";
5
5
 
6
- //#region src/app.ts
7
- /**
8
- * Create a full Zero app — assembles router, head provider, and root layout.
9
- *
10
- * Used internally by entry-server and entry-client.
11
- */
12
- function createApp(options) {
13
- const router = createRouter({
14
- routes: options.routes,
15
- mode: options.routerMode ?? "history",
16
- ...options.url ? { url: options.url } : {},
17
- ...options.base && options.base !== "/" ? { base: options.base } : {},
18
- scrollBehavior: "top"
19
- });
20
- const hasLayoutInRoutes = options.layout !== void 0 && options.routes.some((r) => r.component === options.layout);
21
- if (hasLayoutInRoutes && process.env.NODE_ENV !== "production") console.warn("[Pyreon] `createApp({ layout })` was passed a component that is ALSO a parent route in the matched chain (likely an fs-router `_layout.tsx`). The explicit `layout` option is being ignored to prevent double-mount. Remove the `layout` argument from `createApp`/`startClient` — the fs-router-emitted route handles it.");
22
- const Layout = hasLayoutInRoutes ? DefaultLayout : options.layout ?? DefaultLayout;
23
- function App() {
24
- return h(HeadProvider, null, h(RouterProvider, { router }, h(Layout, null, h(RouterView, null))));
25
- }
26
- return {
27
- App,
28
- router
29
- };
30
- }
31
- function DefaultLayout(props) {
32
- return h(Fragment, null, ...Array.isArray(props.children) ? props.children : [props.children]);
33
- }
34
-
35
- //#endregion
36
6
  //#region src/client.ts
37
7
  /**
38
8
  * Start the client-side app — hydrates SSR content or mounts fresh for SPA.
package/lib/csp.js CHANGED
@@ -1,14 +1,20 @@
1
1
  import { useRequestLocals } from "@pyreon/server";
2
2
 
3
3
  //#region src/csp.ts
4
- /** Client-side fallback nonce (dev server, SPA). */
5
- let _clientNonce = "";
6
4
  /**
7
5
  * Read the current CSP nonce in a component.
8
6
  *
9
7
  * SSR: reads from per-request `ctx.locals.cspNonce` via Pyreon's context
10
8
  * system — fully isolated between concurrent requests via AsyncLocalStorage.
11
- * Client/dev: falls back to module-level variable set by middleware.
9
+ *
10
+ * Returns `''` outside an active request context (client-side after
11
+ * hydration, dev preview, or any render path that bypassed the CSP
12
+ * middleware). Nonces are SSR-only by design: a client-side nonce
13
+ * mirrored from the last SSR request is a cross-request bleed waiting
14
+ * to happen, and a build-time-baked nonce would defeat the entire CSP
15
+ * mechanism. If you need a script-tag nonce, render the script during
16
+ * SSR through `useNonce()` so the value the browser sees IS the value
17
+ * the response's `Content-Security-Policy` header authorized.
12
18
  *
13
19
  * @example
14
20
  * ```tsx
@@ -23,7 +29,7 @@ let _clientNonce = "";
23
29
  function useNonce() {
24
30
  const locals = useRequestLocals();
25
31
  if (locals.cspNonce) return locals.cspNonce;
26
- return _clientNonce;
32
+ return "";
27
33
  }
28
34
  const DIRECTIVE_MAP = {
29
35
  defaultSrc: "default-src",
@@ -112,12 +118,9 @@ function cspMiddleware(config) {
112
118
  const headerName = config.reportOnly ? "Content-Security-Policy-Report-Only" : "Content-Security-Policy";
113
119
  const staticHeader = Object.values(config.directives).some((v) => Array.isArray(v) && v.includes("'nonce'")) ? null : buildCspHeader(config.directives);
114
120
  return (ctx) => {
115
- if (staticHeader) {
116
- _clientNonce = "";
117
- ctx.headers.set(headerName, staticHeader);
118
- } else {
121
+ if (staticHeader) ctx.headers.set(headerName, staticHeader);
122
+ else {
119
123
  const nonce = generateNonce();
120
- _clientNonce = nonce;
121
124
  ctx.locals.cspNonce = nonce;
122
125
  ctx.headers.set(headerName, buildCspHeader(config.directives, nonce));
123
126
  }
package/lib/favicon.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { readFile } from "node:fs/promises";
3
2
  import { join } from "node:path";
3
+ import { readFile } from "node:fs/promises";
4
4
 
5
5
  //#region src/favicon.ts
6
6
  /**