@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
package/src/client.ts CHANGED
@@ -5,6 +5,13 @@ import { hydrateLoaderData } from '@pyreon/router'
5
5
  import { hydrateRoot, mount } from '@pyreon/runtime-dom'
6
6
  import { createApp } from './app'
7
7
 
8
+ // Vite-injected build-time constant. Defined in `vite-plugin.ts`'s
9
+ // `config()` hook from `zero({ base })`. Falls back to `'/'` for
10
+ // non-Vite builds (test environments, etc.) so the read is always
11
+ // safe. The fallback is documented intent — there's no Pyreon
12
+ // deployment outside Vite that consumes this.
13
+ declare const __ZERO_BASE__: string
14
+
8
15
  // ─── Client entry factory ───────────────────────────────────────────────────
9
16
 
10
17
  export interface StartClientOptions {
@@ -59,10 +66,21 @@ export function startClient(options: StartClientOptions) {
59
66
  const container = document.getElementById('app')
60
67
  if (!container) throw new Error('[Pyreon] Missing #app container element')
61
68
 
69
+ // Read the Vite-injected base so `createRouter({ base })` matches the
70
+ // value Vite used to rewrite asset URLs. `typeof` guard covers the
71
+ // edge case where the constant isn't defined (non-Vite test contexts);
72
+ // missing the constant in a real Vite build is impossible because the
73
+ // plugin's `config()` hook always declares it via `define`.
74
+ const base =
75
+ typeof __ZERO_BASE__ !== 'undefined' && __ZERO_BASE__ !== '/'
76
+ ? __ZERO_BASE__
77
+ : undefined
78
+
62
79
  const { App, router } = createApp({
63
80
  routes: options.routes,
64
81
  routerMode: 'history',
65
82
  ...(options.layout ? { layout: options.layout } : {}),
83
+ ...(base ? { base } : {}),
66
84
  })
67
85
 
68
86
  // ── Loader data hydration (SSR path) ───────────────────────────────────────
package/src/csp.ts CHANGED
@@ -140,21 +140,37 @@ export function buildCspHeader(directives: CspDirectives, nonce?: string): strin
140
140
  }
141
141
 
142
142
  /**
143
- * Generate a random nonce string (base64, 16 bytes).
143
+ * Generate a cryptographically-random nonce string (base64, 16 bytes).
144
+ *
145
+ * Throws when `crypto.getRandomValues` is unavailable. CSP nonces protect
146
+ * against XSS by gating inline script execution; a predictable nonce
147
+ * (`Math.random` ~31 bits of entropy) bypasses CSP entirely. Silent
148
+ * degradation here was a security anti-pattern — we surface the
149
+ * misconfiguration loudly instead.
150
+ *
151
+ * Realistic deployments always have `crypto.getRandomValues`: Node 18+,
152
+ * Bun, Deno, browsers, edge workers (Cloudflare/Vercel/Netlify), and
153
+ * vitest/happy-dom all expose it via `globalThis.crypto`. If you hit
154
+ * this throw, your environment is unusual — fix the env, don't downgrade
155
+ * the security primitive.
144
156
  */
145
157
  function generateNonce(): string {
146
- if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
147
- const bytes = new Uint8Array(16)
148
- crypto.getRandomValues(bytes)
149
- // Convert to base64 using btoa
150
- let binary = ''
151
- for (const byte of bytes) binary += String.fromCharCode(byte)
152
- return typeof btoa === 'function'
153
- ? btoa(binary)
154
- : Buffer.from(bytes).toString('base64')
158
+ if (typeof crypto === 'undefined' || !crypto.getRandomValues) {
159
+ throw new Error(
160
+ '[Pyreon] CSP nonce generation requires `crypto.getRandomValues` (Web Crypto API). ' +
161
+ 'No secure RNG is available in this environment. CSP nonces must be cryptographically ' +
162
+ 'random falling back to `Math.random` would silently weaken XSS protection. ' +
163
+ 'Ensure Node 18+, Bun, Deno, an edge runtime, or a browser environment.',
164
+ )
155
165
  }
156
- // Fallback for environments without crypto
157
- return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2)
166
+ const bytes = new Uint8Array(16)
167
+ crypto.getRandomValues(bytes)
168
+ // Convert to base64 using btoa
169
+ let binary = ''
170
+ for (const byte of bytes) binary += String.fromCharCode(byte)
171
+ return typeof btoa === 'function'
172
+ ? btoa(binary)
173
+ : Buffer.from(bytes).toString('base64')
158
174
  }
159
175
 
160
176
  /**
@@ -123,6 +123,11 @@ export function createServer(options: CreateServerOptions) {
123
123
  const { App } = createApp({
124
124
  routes: options.routes,
125
125
  routerMode: "history",
126
+ // Forward zero's `base` to createRouter so RouterLinks render
127
+ // correctly prefixed hrefs during SSR — must match the value
128
+ // the client-side `startClient` reads from `__ZERO_BASE__` so
129
+ // hydration doesn't mismatch.
130
+ ...(config.base && config.base !== "/" ? { base: config.base } : {}),
126
131
  });
127
132
 
128
133
  const handler = createHandler({
@@ -134,18 +139,40 @@ export function createServer(options: CreateServerOptions) {
134
139
  ...(options.clientEntry ? { clientEntry: options.clientEntry } : {}),
135
140
  });
136
141
 
137
- // Wrap handler with 404 detection when a notFoundComponent is provided
142
+ // M1.2 Runtime SSR 404 routes through the router (PR L5).
143
+ // When a URL doesn't match any leaf, @pyreon/router's resolveRoute
144
+ // walks up to the closest parent `notFoundComponent` and builds a
145
+ // synthetic chain `[...ancestorLayouts, syntheticLeaf]`. The handler
146
+ // renders that chain, producing 404 HTML INSIDE the layout's chrome,
147
+ // and reads `resolved.isNotFound` to set HTTP status 404. This
148
+ // replaces the pre-M1 URL-pattern wrapper that bypassed the router
149
+ // for unmatched URLs and rendered the not-found component standalone
150
+ // (no layout wrapping).
151
+ //
152
+ // `options.notFoundComponent` is a legacy fallback for apps that
153
+ // don't carry `_404.tsx` in their routes tree. When set AND the
154
+ // routes tree has no reachable `notFoundComponent`, we render the
155
+ // standalone shape as a final fallback. The canonical pattern is
156
+ // `_404.tsx` inside a `_layout.tsx` directory — that goes through
157
+ // PR L5's router-driven path and gets layout chrome for free.
138
158
  if (!options.notFoundComponent) return handler;
139
159
 
140
160
  const NotFound = options.notFoundComponent;
141
- const routePatterns = flattenRoutePatterns(options.routes);
161
+ const hasRouteTreeNotFound = routeTreeHasNotFound(options.routes);
142
162
 
143
163
  return async (req: Request) => {
164
+ // Route-tree notFoundComponent present → handler handles 404 via
165
+ // resolveRoute's `isNotFound` fallback (PR L5). Skip the legacy
166
+ // wrapper entirely — handler.ts sets status 404 + renders layout
167
+ // chrome correctly.
168
+ if (hasRouteTreeNotFound) return handler(req);
169
+
170
+ // Legacy fallback: routes tree has no notFoundComponent but the
171
+ // caller passed `options.notFoundComponent`. Run the URL-pattern
172
+ // check + standalone render for backward compat.
144
173
  const url = new URL(req.url);
145
174
  const pathname = url.pathname;
146
-
147
- // Check if any defined route matches this path
148
- if (!routePatterns.some((pattern) => matchPattern(pattern, pathname))) {
175
+ if (!routePatternsCache(options.routes).some((p) => matchPattern(p, pathname))) {
149
176
  const fullHtml = await render404Page(NotFound, options.template);
150
177
  return new Response(fullHtml, {
151
178
  status: 404,
@@ -157,6 +184,29 @@ export function createServer(options: CreateServerOptions) {
157
184
  };
158
185
  }
159
186
 
187
+ /** Walk the route tree looking for any record with a `notFoundComponent`. */
188
+ function routeTreeHasNotFound(routes: RouteRecord[]): boolean {
189
+ for (const r of routes) {
190
+ if (typeof (r as { notFoundComponent?: unknown }).notFoundComponent === "function") {
191
+ return true;
192
+ }
193
+ if (r.children && routeTreeHasNotFound(r.children as RouteRecord[])) {
194
+ return true;
195
+ }
196
+ }
197
+ return false;
198
+ }
199
+
200
+ /** Lazy cache of flattened patterns — only computed if legacy fallback fires. */
201
+ const _routePatternsCache = new WeakMap<RouteRecord[], string[]>();
202
+ function routePatternsCache(routes: RouteRecord[]): string[] {
203
+ const cached = _routePatternsCache.get(routes);
204
+ if (cached) return cached;
205
+ const out = flattenRoutePatterns(routes);
206
+ _routePatternsCache.set(routes, out);
207
+ return out;
208
+ }
209
+
160
210
  /** Extract all URL patterns from a nested route tree. */
161
211
  function flattenRoutePatterns(routes: RouteRecord[], prefix = ""): string[] {
162
212
  const patterns: string[] = [];
package/src/env.ts CHANGED
@@ -244,14 +244,14 @@ type InferEnvSchema<T> = {
244
244
  * ```
245
245
  */
246
246
  export function validateEnv<T extends Record<string, SchemaEntry>>(
247
- schema: T,
247
+ envSchema: T,
248
248
  source?: Record<string, string | undefined>,
249
249
  ): InferEnvSchema<T> {
250
250
  const env = source ?? (typeof process !== 'undefined' ? process.env : {})
251
251
  const result: Record<string, unknown> = {}
252
252
  const errors: string[] = []
253
253
 
254
- for (const [key, entry] of Object.entries(schema)) {
254
+ for (const [key, entry] of Object.entries(envSchema)) {
255
255
  const validator = toValidator(entry)
256
256
  try {
257
257
  result[key] = validator.parse(env[key], key)
@@ -284,12 +284,12 @@ export function validateEnv<T extends Record<string, SchemaEntry>>(
284
284
  * ```
285
285
  */
286
286
  export function publicEnv(): Record<string, string>
287
- export function publicEnv<T extends Record<string, SchemaEntry>>(schema: T): InferEnvSchema<T>
288
- export function publicEnv(schema?: Record<string, SchemaEntry>): Record<string, unknown> {
287
+ export function publicEnv<T extends Record<string, SchemaEntry>>(envSchema: T): InferEnvSchema<T>
288
+ export function publicEnv(envSchema?: Record<string, SchemaEntry>): Record<string, unknown> {
289
289
  const prefix = 'ZERO_PUBLIC_'
290
290
  const env = typeof process !== 'undefined' ? process.env : {}
291
291
 
292
- if (!schema) {
292
+ if (!envSchema) {
293
293
  const result: Record<string, string> = {}
294
294
  for (const [key, value] of Object.entries(env)) {
295
295
  if (key.startsWith(prefix) && value !== undefined) {
@@ -300,10 +300,10 @@ export function publicEnv(schema?: Record<string, SchemaEntry>): Record<string,
300
300
  }
301
301
 
302
302
  const prefixedSource: Record<string, string | undefined> = {}
303
- for (const key of Object.keys(schema)) {
303
+ for (const key of Object.keys(envSchema)) {
304
304
  prefixedSource[key] = env[`${prefix}${key}`]
305
305
  }
306
- return validateEnv(schema, prefixedSource)
306
+ return validateEnv(envSchema, prefixedSource)
307
307
  }
308
308
 
309
309
  // ─── Custom validator escape hatch ──────────────────────────────────────────
package/src/font.ts CHANGED
@@ -159,11 +159,11 @@ export function parseGoogleFamily(input: string): ResolvedFont {
159
159
  for (const entry of entries) {
160
160
  if (entry.includes(',')) {
161
161
  // Tuple format: "0,300" or "1,500" — last value is the weight
162
- const parts = entry.split(',')
163
- const weight = Number(parts[parts.length - 1])
162
+ const tuple = entry.split(',')
163
+ const weight = Number(tuple[tuple.length - 1])
164
164
  if (weight > 0) weights.add(weight)
165
165
  // Detect italic from tuple: "1,xxx" means italic
166
- if (parts[0] === '1') italic = true
166
+ if (tuple[0] === '1') italic = true
167
167
  } else if (entry.includes('..')) {
168
168
  // Variable range already handled above — skip
169
169
  } else {
package/src/fs-router.ts CHANGED
@@ -2,6 +2,34 @@ import { readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
  import type { FileRoute, RenderMode, RouteFileExports } from './types'
4
4
 
5
+ /**
6
+ * Return type of a route file's `getStaticPaths()` export. Each entry
7
+ * supplies one set of concrete values for the route's dynamic segments;
8
+ * the SSG plugin expands the route's URL pattern with these params and
9
+ * renders one HTML file per entry.
10
+ *
11
+ * @example
12
+ * ```tsx
13
+ * // src/routes/posts/[id].tsx
14
+ * import type { GetStaticPaths } from '@pyreon/zero/server'
15
+ *
16
+ * export const getStaticPaths: GetStaticPaths<{ id: string }> = async () => {
17
+ * const posts = await fetch('https://api.example.com/posts').then(r => r.json())
18
+ * return posts.map((p) => ({ params: { id: p.slug } }))
19
+ * }
20
+ *
21
+ * export default function Post() { ... }
22
+ * ```
23
+ *
24
+ * For catch-all routes (`/blog/[...slug].tsx`), pass the full path through
25
+ * the catch-all param: `{ params: { slug: 'a/b' } }` → `/blog/a/b`.
26
+ */
27
+ export type GetStaticPaths<
28
+ TParams extends Record<string, string> = Record<string, string>,
29
+ > = () =>
30
+ | Array<{ params: TParams }>
31
+ | Promise<Array<{ params: TParams }>>
32
+
5
33
  // ─── File-system route conventions ──────────────────────────────────────────
6
34
  //
7
35
  // src/routes/
@@ -40,6 +68,10 @@ const ROUTE_EXPORT_NAMES = [
40
68
  'renderMode',
41
69
  'error',
42
70
  'middleware',
71
+ 'loaderKey',
72
+ 'gcTime',
73
+ 'getStaticPaths',
74
+ 'revalidate',
43
75
  ] as const
44
76
 
45
77
  type RouteExportName = (typeof ROUTE_EXPORT_NAMES)[number]
@@ -100,12 +132,27 @@ export function detectRouteExports(source: string): RouteFileExports {
100
132
  const rawRenderMode = found.has('renderMode')
101
133
  ? extractLiteralExport(source, 'renderMode')
102
134
  : undefined
135
+ // PR I — capture `revalidate` as a literal so the build-time ISR
136
+ // manifest (`dist/_pyreon-revalidate.json`) can be emitted from the
137
+ // SSG plugin without loading the route module. The route generator
138
+ // does NOT inline `revalidate` into the route record — it's a build-
139
+ // time-only concern that adapters consume via the manifest.
140
+ const rawRevalidate = found.has('revalidate')
141
+ ? extractLiteralExport(source, 'revalidate')
142
+ : undefined
103
143
  const cleanMeta = rawMeta !== undefined ? stripTypeAssertions(rawMeta) : undefined
104
144
  const cleanRenderMode =
105
145
  rawRenderMode !== undefined ? stripTypeAssertions(rawRenderMode) : undefined
146
+ const cleanRevalidate =
147
+ rawRevalidate !== undefined ? stripTypeAssertions(rawRevalidate) : undefined
106
148
  const metaLiteral = cleanMeta !== undefined && isPureLiteral(cleanMeta) ? cleanMeta : undefined
107
149
  const renderModeLiteral =
108
150
  cleanRenderMode !== undefined && isPureLiteral(cleanRenderMode) ? cleanRenderMode : undefined
151
+ // `revalidate` literals are number (`60`) or boolean (`false`) — never
152
+ // an object/array — so `isPureLiteral` is overkill. Keep the same
153
+ // safety check for defense-in-depth.
154
+ const revalidateLiteral =
155
+ cleanRevalidate !== undefined && isPureLiteral(cleanRevalidate) ? cleanRevalidate : undefined
109
156
 
110
157
  return {
111
158
  hasLoader: found.has('loader'),
@@ -114,8 +161,13 @@ export function detectRouteExports(source: string): RouteFileExports {
114
161
  hasRenderMode: found.has('renderMode'),
115
162
  hasError: found.has('error'),
116
163
  hasMiddleware: found.has('middleware'),
164
+ hasLoaderKey: found.has('loaderKey'),
165
+ hasGcTime: found.has('gcTime'),
166
+ hasGetStaticPaths: found.has('getStaticPaths'),
167
+ hasRevalidate: found.has('revalidate'),
117
168
  ...(metaLiteral !== undefined ? { metaLiteral } : {}),
118
169
  ...(renderModeLiteral !== undefined ? { renderModeLiteral } : {}),
170
+ ...(revalidateLiteral !== undefined ? { revalidateLiteral } : {}),
119
171
  }
120
172
  }
121
173
 
@@ -745,6 +797,10 @@ const EMPTY_EXPORTS: RouteFileExports = {
745
797
  hasRenderMode: false,
746
798
  hasError: false,
747
799
  hasMiddleware: false,
800
+ hasLoaderKey: false,
801
+ hasGcTime: false,
802
+ hasGetStaticPaths: false,
803
+ hasRevalidate: false,
748
804
  }
749
805
 
750
806
  /**
@@ -759,7 +815,10 @@ export function hasAnyMetaExport(exports: RouteFileExports): boolean {
759
815
  exports.hasMeta ||
760
816
  exports.hasRenderMode ||
761
817
  exports.hasError ||
762
- exports.hasMiddleware
818
+ exports.hasMiddleware ||
819
+ exports.hasLoaderKey ||
820
+ exports.hasGcTime ||
821
+ exports.hasGetStaticPaths
763
822
  )
764
823
  }
765
824
 
@@ -1085,6 +1144,10 @@ export function generateRouteModuleFromRoutes(
1085
1144
  props.push(`${indent} component: ${mod}.default`)
1086
1145
  if (exp.hasLoader) props.push(`${indent} loader: ${mod}.loader`)
1087
1146
  if (exp.hasGuard) props.push(`${indent} beforeEnter: ${mod}.guard`)
1147
+ if (exp.hasLoaderKey) props.push(`${indent} loaderKey: ${mod}.loaderKey`)
1148
+ if (exp.hasGcTime) props.push(`${indent} gcTime: ${mod}.gcTime`)
1149
+ if (exp.hasGetStaticPaths)
1150
+ props.push(`${indent} getStaticPaths: ${mod}.getStaticPaths`)
1088
1151
  if (exp.hasMeta || exp.hasRenderMode) {
1089
1152
  const metaParts: string[] = []
1090
1153
  if (exp.hasMeta) metaParts.push(`...${mod}.meta`)
@@ -1122,7 +1185,13 @@ export function generateRouteModuleFromRoutes(
1122
1185
  const inlineableMeta =
1123
1186
  (!exp.hasMeta || exp.metaLiteral !== undefined) &&
1124
1187
  (!exp.hasRenderMode || exp.renderModeLiteral !== undefined)
1125
- const needsFunctionExports = exp.hasLoader || exp.hasGuard || exp.hasError
1188
+ // getStaticPaths is a build-time export consumed by the SSG plugin's
1189
+ // path-resolution phase. Like loader/guard/error, it can't be inlined
1190
+ // as a literal — we need the actual function reference. Force the
1191
+ // generator into the mixed branch (case 2) when present so a namespace
1192
+ // import is emitted and `mod.getStaticPaths` lands on the route record.
1193
+ const needsFunctionExports =
1194
+ exp.hasLoader || exp.hasGuard || exp.hasError || exp.hasGetStaticPaths
1126
1195
 
1127
1196
  if (hasMeta && inlineableMeta && !needsFunctionExports) {
1128
1197
  // Optimal path — component lazy, metadata inlined.
@@ -1152,6 +1221,27 @@ export function generateRouteModuleFromRoutes(
1152
1221
  `${indent} beforeEnter: (to, from) => import("${fullPath}").then((m) => m.guard(to, from))`,
1153
1222
  )
1154
1223
  }
1224
+ if (exp.hasLoaderKey) {
1225
+ // loaderKey runs SYNCHRONOUSLY during the cache-key check; can't be
1226
+ // routed through a dynamic import. Inline a `mod.loaderKey` lookup
1227
+ // via the same namespace-import pattern as the metadata path. Rolldown
1228
+ // will share the chunk with the lazy() component thunk.
1229
+ const mod = nextModuleImport(page.filePath)
1230
+ props.push(`${indent} loaderKey: ${mod}.loaderKey`)
1231
+ }
1232
+ if (exp.hasGcTime) {
1233
+ const mod = nextModuleImport(page.filePath)
1234
+ props.push(`${indent} gcTime: ${mod}.gcTime`)
1235
+ }
1236
+ if (exp.hasGetStaticPaths) {
1237
+ // getStaticPaths runs at SSG build time (not request time), so
1238
+ // routing it through a dynamic import is fine — but going through
1239
+ // a namespace import keeps it consistent with loaderKey/gcTime
1240
+ // and avoids per-call import overhead during the SSG enumeration
1241
+ // phase.
1242
+ const mod = nextModuleImport(page.filePath)
1243
+ props.push(`${indent} getStaticPaths: ${mod}.getStaticPaths`)
1244
+ }
1155
1245
  emitInlineMeta(exp, props, indent)
1156
1246
  if (errorName) {
1157
1247
  // For error components we can't easily await — pass the lazy
@@ -1171,6 +1261,10 @@ export function generateRouteModuleFromRoutes(
1171
1261
  props.push(`${indent} component: ${mod}.default`)
1172
1262
  if (exp.hasLoader) props.push(`${indent} loader: ${mod}.loader`)
1173
1263
  if (exp.hasGuard) props.push(`${indent} beforeEnter: ${mod}.guard`)
1264
+ if (exp.hasLoaderKey) props.push(`${indent} loaderKey: ${mod}.loaderKey`)
1265
+ if (exp.hasGcTime) props.push(`${indent} gcTime: ${mod}.gcTime`)
1266
+ if (exp.hasGetStaticPaths)
1267
+ props.push(`${indent} getStaticPaths: ${mod}.getStaticPaths`)
1174
1268
  if (exp.hasMeta || exp.hasRenderMode) {
1175
1269
  const metaParts: string[] = []
1176
1270
  if (exp.hasMeta) metaParts.push(`...${mod}.meta`)
@@ -1231,6 +1325,8 @@ export function generateRouteModuleFromRoutes(
1231
1325
  if (layoutMod !== undefined) {
1232
1326
  if (exp.hasLoader) props.push(`${indent}loader: ${layoutMod}.loader`)
1233
1327
  if (exp.hasGuard) props.push(`${indent}beforeEnter: ${layoutMod}.guard`)
1328
+ if (exp.hasLoaderKey) props.push(`${indent}loaderKey: ${layoutMod}.loaderKey`)
1329
+ if (exp.hasGcTime) props.push(`${indent}gcTime: ${layoutMod}.gcTime`)
1234
1330
  if (exp.hasMeta || exp.hasRenderMode) {
1235
1331
  const metaParts: string[] = []
1236
1332
  if (exp.hasMeta) metaParts.push(`...${layoutMod}.meta`)
@@ -1307,6 +1403,12 @@ export function generateRouteModuleFromRoutes(
1307
1403
  /**
1308
1404
  * Generate a virtual module that maps URL patterns to their middleware exports.
1309
1405
  * Used by the server entry to dispatch per-route middleware.
1406
+ *
1407
+ * Detects whether each route file actually exports `middleware` (via
1408
+ * `detectRouteExports` source scanning) and only emits an import for files
1409
+ * that do. The `lazy()` import path tolerates missing exports, but the SSG
1410
+ * static-import path fails Rolldown's missing-export check at build time —
1411
+ * skipping no-middleware files keeps both paths working.
1310
1412
  */
1311
1413
  export function generateMiddlewareModule(files: string[], routesDir: string): string {
1312
1414
  const routes = parseFileRoutes(files)
@@ -1316,6 +1418,14 @@ export function generateMiddlewareModule(files: string[], routesDir: string): st
1316
1418
 
1317
1419
  for (const route of routes) {
1318
1420
  if (route.isLayout || route.isError || route.isLoading || route.isNotFound) continue
1421
+ let hasMw = false
1422
+ try {
1423
+ const source = readFileSync(`${routesDir}/${route.filePath}`, 'utf-8')
1424
+ hasMw = detectRouteExports(source).hasMiddleware
1425
+ } catch {
1426
+ // File can't be read — skip; the SSR runtime falls back gracefully.
1427
+ }
1428
+ if (!hasMw) continue
1319
1429
  const name = `_mw${counter++}`
1320
1430
  const fullPath = `${routesDir}/${route.filePath}`
1321
1431
  imports.push(`import { middleware as ${name} } from "${fullPath}"`)
@@ -1372,8 +1482,17 @@ export async function scanRouteFilesWithExports(
1372
1482
  defaultMode: RenderMode = 'ssr',
1373
1483
  ): Promise<FileRoute[]> {
1374
1484
  const { readFile } = await import('node:fs/promises')
1375
-
1376
- const files = await scanRouteFiles(routesDir)
1485
+ const { isApiRoute } = await import('./api-routes')
1486
+
1487
+ // Api routes (`api/**/*.ts`) live in the same routes tree but are served by
1488
+ // a separate virtual module (`virtual:zero/api-routes`). Page-route
1489
+ // generation MUST skip them — they export named HTTP method handlers
1490
+ // (`GET`/`POST`/...), not a default page component, so the SSG `staticImports`
1491
+ // mode would emit `import _N from "api/posts.ts"` and fail Rolldown's
1492
+ // missing-export check at build time. The bug only surfaced under SSG
1493
+ // because the regular lazy()-mode `import()` doesn't fail on missing
1494
+ // default exports.
1495
+ const files = (await scanRouteFiles(routesDir)).filter((f) => !isApiRoute(f))
1377
1496
  const exportsMap = new Map<string, RouteFileExports>()
1378
1497
 
1379
1498
  await Promise.all(