@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.
@@ -72,6 +72,126 @@ interface ApiRouteEntry {
72
72
  module: ApiRouteModule;
73
73
  }
74
74
  //#endregion
75
+ //#region src/isr.d.ts
76
+ /** Serialized SSR response cached by the ISR layer (one per cache key). */
77
+ interface ISRCacheEntry {
78
+ html: string;
79
+ headers: Record<string, string>;
80
+ timestamp: number;
81
+ }
82
+ /**
83
+ * Pluggable backing store for the ISR cache. The default in-memory
84
+ * implementation (`createMemoryStore`) is per-process — fine for
85
+ * single-instance deploys, but multi-instance / horizontally-scaled
86
+ * apps need a SHARED store (Redis, Vercel KV, Cloudflare KV, Upstash,
87
+ * etc.) so revalidation in one instance is visible to all instances.
88
+ *
89
+ * The interface accepts BOTH sync and async returns: the in-memory
90
+ * default stays cheap (sync `Map` ops, no `Promise` allocation per
91
+ * request), while external stores return promises naturally. The
92
+ * handler awaits the result either way (`await` on a non-promise just
93
+ * returns the value).
94
+ *
95
+ * `get` is responsible for any LRU bookkeeping — external stores
96
+ * typically rely on their own TTL/eviction policy and can `return
97
+ * this.map.get(key)` directly; the in-memory store does a
98
+ * delete + re-insert to keep the Map's insertion-order LRU correct.
99
+ *
100
+ * `delete` is optional. It's only used when a future on-demand
101
+ * revalidation pathway needs to evict a specific key (the current
102
+ * stale-while-revalidate flow does not call it). External stores that
103
+ * don't support per-key invalidation can omit it.
104
+ */
105
+ interface ISRStore<E = ISRCacheEntry> {
106
+ get(key: string): Promise<E | undefined> | E | undefined;
107
+ set(key: string, entry: E): Promise<void> | void;
108
+ delete?(key: string): Promise<void> | void;
109
+ /**
110
+ * Drop EVERY entry. Optional — external stores (Redis with TTL-only,
111
+ * Vercel KV with per-key invalidation, etc.) may not support a
112
+ * blanket purge. When omitted, `ISRHandler.revalidateAll()` throws a
113
+ * clear error pointing at the missing method. The default
114
+ * `createMemoryStore` implements it.
115
+ */
116
+ clear?(): Promise<void> | void;
117
+ }
118
+ /**
119
+ * The default in-memory ISR store: a `Map` with insertion-order LRU
120
+ * eviction, capped at `maxEntries` (default `1000`). Drop in as
121
+ * `config.store` if you want to tweak the cap or wrap the store with
122
+ * instrumentation; pass a different `ISRStore` impl for Redis / KV /
123
+ * etc. backings.
124
+ *
125
+ * `get` does the LRU bump (re-inserts the touched entry at the
126
+ * newest position) so hot paths survive eviction even when the cap is
127
+ * small. Without that, `Map.get(...)` wouldn't update ordering and
128
+ * frequently-read entries could be evicted by occasional writes.
129
+ */
130
+ declare function createMemoryStore<E = ISRCacheEntry>(opts?: {
131
+ maxEntries?: number;
132
+ }): ISRStore<E>;
133
+ /**
134
+ * ISR handler with stale-while-revalidate semantics.
135
+ *
136
+ * Wraps an SSR handler and caches responses per URL path. Serves stale
137
+ * content immediately while revalidating in the background. The cache
138
+ * backing is **pluggable** via `config.store` (default:
139
+ * `createMemoryStore({ maxEntries: 1000 })`) — pass a custom `ISRStore`
140
+ * implementation backed by Redis / Vercel KV / Cloudflare KV / Upstash
141
+ * etc. to share state across horizontally-scaled instances. Without an
142
+ * external store, each instance has its own per-process cache (which
143
+ * is fine for single-instance deploys; behaviour-identical to the
144
+ * pre-pluggable-store implementation).
145
+ *
146
+ * Default in-memory store: bounded by `config.maxEntries` (default
147
+ * `1000`) with insertion-order LRU eviction. Without the cap, unbounded
148
+ * URL spaces like `/user/:id` would grow cache memory without limit
149
+ * over the server's lifetime — a real leak in long-running deployments.
150
+ * `config.maxEntries` is ignored when a custom `config.store` is supplied
151
+ * (the custom store owns its own eviction policy).
152
+ */
153
+ /**
154
+ * The fetch handler `createISRHandler` returns is also a callable
155
+ * carrying imperative invalidation methods. Webhooks, CMS notifications,
156
+ * admin endpoints etc. call these methods to drop one or all cached
157
+ * entries on demand — strictly more responsive than waiting for the
158
+ * TTL-based stale-while-revalidate cycle.
159
+ *
160
+ * The shape is `(req) => Promise<Response>` PLUS the methods, so
161
+ * existing consumers (`Bun.serve({ fetch: handler })`) keep working
162
+ * byte-identically.
163
+ */
164
+ interface ISRHandler {
165
+ (req: Request): Promise<Response>;
166
+ /**
167
+ * Drop the cache entry for a single path (or `cacheKey`-derived key).
168
+ * The next request for that key will MISS and re-render fresh. Returns
169
+ * `{ dropped: true }` if an entry was found and deleted, `{ dropped: false }`
170
+ * if no entry existed.
171
+ *
172
+ * Useful for webhook-driven invalidation: a CMS notifies that a post
173
+ * was updated, the webhook handler calls `isrHandler.revalidateNow('/posts/123')`,
174
+ * and the very next visitor gets the fresh content — no stale window.
175
+ *
176
+ * Idempotent. Safe to call against keys that don't exist (returns
177
+ * `dropped: false` cleanly).
178
+ */
179
+ revalidateNow(key: string): Promise<{
180
+ dropped: boolean;
181
+ }>;
182
+ /**
183
+ * Drop ALL cached entries. Useful for "purge cache" admin actions or
184
+ * deploy-completion hooks that want a clean slate.
185
+ *
186
+ * The default in-memory store supports this via repeated `delete`
187
+ * calls under the hood; custom stores that omit `delete` (Redis with
188
+ * TTL-only, etc.) throw a clear error pointing at the missing method
189
+ * so the caller knows their store implementation can't honor the call.
190
+ */
191
+ revalidateAll(): Promise<void>;
192
+ }
193
+ declare function createISRHandler(handler: (req: Request) => Promise<Response>, config: ISRConfig): ISRHandler;
194
+ //#endregion
75
195
  //#region src/i18n-routing.d.ts
76
196
  interface I18nRoutingConfig {
77
197
  /** Supported locales. e.g. ["en", "de", "cs"] */
@@ -189,6 +309,77 @@ interface ISRConfig {
189
309
  * }
190
310
  */
191
311
  cacheKey?: (req: Request) => string;
312
+ /**
313
+ * Pluggable cache backing for multi-instance / horizontally-scaled
314
+ * production. Default: in-memory `Map` per-process (capped by
315
+ * `maxEntries`). Pass a Redis / Vercel KV / Cloudflare KV / Upstash
316
+ * adapter (anything matching the `ISRStore` interface from
317
+ * `@pyreon/zero/server`) for state shared across instances — a
318
+ * revalidation in one pod is visible to all pods.
319
+ *
320
+ * The store interface accepts BOTH sync and async returns; the
321
+ * handler `await`s the result either way, so an in-memory store
322
+ * stays cheap (no Promise allocation per request) while a Redis
323
+ * store can return its native promises directly.
324
+ *
325
+ * When set, `maxEntries` is ignored — the custom store owns its own
326
+ * eviction / TTL policy.
327
+ *
328
+ * @example
329
+ * // Redis adapter (uses `ioredis` or `@upstash/redis`):
330
+ * const redis = new Redis(...)
331
+ * const store: ISRStore = {
332
+ * async get(key) {
333
+ * const v = await redis.get(`isr:${key}`)
334
+ * return v ? JSON.parse(v) : undefined
335
+ * },
336
+ * async set(key, entry) {
337
+ * await redis.set(`isr:${key}`, JSON.stringify(entry), 'EX', 86400)
338
+ * },
339
+ * async delete(key) {
340
+ * await redis.del(`isr:${key}`)
341
+ * },
342
+ * }
343
+ *
344
+ * isr: { revalidate: 60, store }
345
+ */
346
+ store?: ISRStore<any>;
347
+ /**
348
+ * Construct the `Request` used for background revalidation. Default:
349
+ * the ORIGINAL user's request (headers, method, URL) — which means a
350
+ * `cacheKey`-bearing entry triggered by user A is revalidated against
351
+ * A's cookies / auth headers. For auth-gated `cacheKey` setups this
352
+ * is risky: if A's session expires before the revalidation runs, the
353
+ * new render may misbehave (auth-gate hits redirect path, or worse,
354
+ * still emits A's personalized HTML because the server hasn't yet
355
+ * invalidated the session token).
356
+ *
357
+ * Supply `revalidateRequest` to construct a request scoped to the
358
+ * cache key — e.g. anonymous for anonymous entries, service-account
359
+ * for shared entries. Returning `null` SKIPS revalidation entirely
360
+ * for this entry (stale stays stale until the next live request).
361
+ *
362
+ * Compatible with `store`: the revalidate path still reads/writes
363
+ * the configured store; this hook only controls what request the
364
+ * re-render runs against.
365
+ *
366
+ * @example
367
+ * isr: {
368
+ * revalidate: 60,
369
+ * cacheKey: (req) => {
370
+ * const session = req.headers.get('cookie')?.match(/session=([^;]+)/)?.[1] ?? 'anon'
371
+ * return `${new URL(req.url).pathname}::${session}`
372
+ * },
373
+ * revalidateRequest: (req) => {
374
+ * // Anonymous entries re-revalidate as anonymous (safe default).
375
+ * // Authenticated entries skip revalidation — the user's next
376
+ * // hit will re-render with their current cookies on cache miss.
377
+ * const hasAuth = /session=(?!anon)/.test(req.headers.get('cookie') ?? '')
378
+ * return hasAuth ? null : new Request(req.url, { method: 'GET' })
379
+ * },
380
+ * }
381
+ */
382
+ revalidateRequest?: (req: Request) => Request | null;
192
383
  }
193
384
  interface ZeroConfig {
194
385
  /** Default rendering mode. Default: "ssr" */
@@ -731,22 +922,6 @@ declare function generateMiddlewareModule(files: string[], routesDir: string): s
731
922
  */
732
923
  declare function scanRouteFiles(routesDir: string): Promise<string[]>;
733
924
  //#endregion
734
- //#region src/isr.d.ts
735
- /**
736
- * In-memory ISR cache with stale-while-revalidate semantics.
737
- *
738
- * Wraps an SSR handler and caches responses per URL path.
739
- * Serves stale content immediately while revalidating in the background.
740
- *
741
- * Bounded by `config.maxEntries` (default: 1000) with LRU eviction. The
742
- * `Map` preserves insertion order, so re-inserting an entry on every
743
- * serve (touching it) keeps the LRU order correct. Without the cap,
744
- * unbounded URL spaces like `/user/:id` would grow cache memory without
745
- * limit over the server's lifetime — a real leak in long-running
746
- * deployments.
747
- */
748
- declare function createISRHandler(handler: (req: Request) => Promise<Response>, config: ISRConfig): (req: Request) => Promise<Response>;
749
- //#endregion
750
925
  //#region src/vercel-revalidate-handler.d.ts
751
926
  /**
752
927
  * M3.1 — Drop-in Vercel revalidate webhook handler.
@@ -1597,5 +1772,5 @@ declare function inferJsonLd(options: InferJsonLdOptions): Record<string, unknow
1597
1772
  */
1598
1773
  declare function aiPlugin(config: AiPluginConfig): Plugin;
1599
1774
  //#endregion
1600
- export { type AiPluginConfig, type CreateAppOptions, type CreateServerOptions, type FaviconLocaleConfig, type FaviconPluginConfig, type GenerateRouteModuleOptions, type GetStaticPaths, type IconSetConfig, type IconsPluginConfig, type InferJsonLdOptions, type NamedSetInput, type OgImageLayer, type OgImagePluginConfig, type OgImageTemplate, type RobotsConfig, type SeoPluginConfig, type SitemapConfig, type VercelRevalidateHandlerOptions, _resetVercelRevalidateHandlerCache, aiPlugin, bunAdapter, cloudflareAdapter, componentNameFromSetKey, compose, createApp, createISRHandler, createLocaleContext, createServer, zeroPlugin as default, defineConfig, detectLocaleFromHeader, faviconLinks, faviconPlugin, filePathToUrlPath, generateIconSetSource, generateLlmsFullTxt, generateLlmsTxt, generateMiddlewareModule, generateNamedIconSetsSource, generateRobots, generateRouteModule, generateSitemap, getContext, getZeroPluginConfig, i18nRouting, iconNameFromFile, iconsPlugin, inferJsonLd, jsonLd, netlifyAdapter, nodeAdapter, ogImagePath, ogImagePlugin, parseFileRoutes, render404Page, resolveAdapter, resolveConfig, scanIconDir, scanRouteFiles, seoMiddleware, seoPlugin, staticAdapter, vercelAdapter, vercelRevalidateHandler };
1775
+ export { type AiPluginConfig, type CreateAppOptions, type CreateServerOptions, type FaviconLocaleConfig, type FaviconPluginConfig, type GenerateRouteModuleOptions, type GetStaticPaths, type ISRCacheEntry, type ISRStore, type IconSetConfig, type IconsPluginConfig, type InferJsonLdOptions, type NamedSetInput, type OgImageLayer, type OgImagePluginConfig, type OgImageTemplate, type RobotsConfig, type SeoPluginConfig, type SitemapConfig, type VercelRevalidateHandlerOptions, _resetVercelRevalidateHandlerCache, aiPlugin, bunAdapter, cloudflareAdapter, componentNameFromSetKey, compose, createApp, createISRHandler, createLocaleContext, createMemoryStore, createServer, zeroPlugin as default, defineConfig, detectLocaleFromHeader, faviconLinks, faviconPlugin, filePathToUrlPath, generateIconSetSource, generateLlmsFullTxt, generateLlmsTxt, generateMiddlewareModule, generateNamedIconSetsSource, generateRobots, generateRouteModule, generateSitemap, getContext, getZeroPluginConfig, i18nRouting, iconNameFromFile, iconsPlugin, inferJsonLd, jsonLd, netlifyAdapter, nodeAdapter, ogImagePath, ogImagePlugin, parseFileRoutes, render404Page, resolveAdapter, resolveConfig, scanIconDir, scanRouteFiles, seoMiddleware, seoPlugin, staticAdapter, vercelAdapter, vercelRevalidateHandler };
1601
1776
  //# sourceMappingURL=server2.d.ts.map
@@ -21,7 +21,16 @@ declare function toggleTheme(): void;
21
21
  /** Set theme explicitly. */
22
22
  declare function setTheme(t: Theme): void;
23
23
  /**
24
- * Initialize the theme system. Call once in your app entry or layout.
24
+ * Reset the refcount + shared teardown. Useful for tests.
25
+ * @internal
26
+ */
27
+ declare function _resetInitThemeForTests(): void;
28
+ /**
29
+ * Initialize the theme system. Safe to call multiple times — uses a
30
+ * mount-based refcount so multiple `<ThemeToggle>` instances (or
31
+ * `<ThemeToggle>` plus an explicit `initTheme()` in your layout) share
32
+ * a SINGLE matchMedia listener + effect.
33
+ *
25
34
  * Reads from localStorage, listens for system preference changes.
26
35
  */
27
36
  declare function initTheme(): void;
@@ -49,5 +58,5 @@ declare function ThemeToggle(props: {
49
58
  */
50
59
  declare const themeScript = "(function(){try{var t=localStorage.getItem(\"zero-theme\");var r=t===\"light\"?\"light\":t===\"dark\"?\"dark\":window.matchMedia(\"(prefers-color-scheme:dark)\").matches?\"dark\":\"light\";document.documentElement.dataset.theme=r;document.querySelectorAll(\"[data-favicon-theme]\").forEach(function(l){l.media=l.dataset.faviconTheme===r?\"\":\"not all\"})}catch(e){}})()";
51
60
  //#endregion
52
- export { Theme, ThemeToggle, initTheme, resolvedTheme, setSSRThemeDefault, setTheme, theme, themeScript, toggleTheme };
61
+ export { Theme, ThemeToggle, _resetInitThemeForTests, initTheme, resolvedTheme, setSSRThemeDefault, setTheme, theme, themeScript, toggleTheme };
53
62
  //# sourceMappingURL=theme2.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/zero",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "description": "Pyreon Zero — zero-config full-stack framework powered by Pyreon and Vite",
5
5
  "license": "MIT",
6
6
  "author": "Vit Bokisch",
@@ -173,15 +173,15 @@
173
173
  "lint": "oxlint ."
174
174
  },
175
175
  "dependencies": {
176
- "@pyreon/core": "^0.21.0",
177
- "@pyreon/head": "^0.21.0",
178
- "@pyreon/meta": "^0.21.0",
179
- "@pyreon/reactivity": "^0.21.0",
180
- "@pyreon/router": "^0.21.0",
181
- "@pyreon/runtime-dom": "^0.21.0",
182
- "@pyreon/runtime-server": "^0.21.0",
183
- "@pyreon/server": "^0.21.0",
184
- "@pyreon/vite-plugin": "^0.21.0",
176
+ "@pyreon/core": "^0.23.0",
177
+ "@pyreon/head": "^0.23.0",
178
+ "@pyreon/meta": "^0.23.0",
179
+ "@pyreon/reactivity": "^0.23.0",
180
+ "@pyreon/router": "^0.23.0",
181
+ "@pyreon/runtime-dom": "^0.23.0",
182
+ "@pyreon/runtime-server": "^0.23.0",
183
+ "@pyreon/server": "^0.23.0",
184
+ "@pyreon/vite-plugin": "^0.23.0",
185
185
  "vite": "^8.0.0"
186
186
  },
187
187
  "devDependencies": {
package/src/actions.ts CHANGED
@@ -33,6 +33,21 @@ export interface Action<T = unknown> {
33
33
 
34
34
  // ─── Registry ────────────────────────────────────────────────────────────────
35
35
 
36
+ /**
37
+ * Module-level registry of every `defineAction()` call. Lookup is by the
38
+ * `action_<uuid>` string the client sends in `POST /_zero/actions/<id>`.
39
+ *
40
+ * **HMR caveat (dev-only):** the registry uses fresh `crypto.randomUUID()`
41
+ * per `defineAction()` invocation. When Vite hot-replaces a module that
42
+ * calls `defineAction()`, the module re-runs and a NEW entry is inserted
43
+ * — the OLD entry stays in the Map until the dev process exits. Each
44
+ * entry holds `{ id, handler }` (~80 bytes). Bounded by the count of
45
+ * distinct UUIDs minted in the session; a realistic dev session sees
46
+ * <50 entries, so total dev-memory cost stays under ~5KB. Production
47
+ * registers each module exactly once at startup — no leak. A
48
+ * FinalizationRegistry-based purge is tracked as a follow-up; the
49
+ * current cost is too small to justify the WeakRef/finalizer complexity.
50
+ */
36
51
  const actionRegistry = new Map<string, RegisteredAction>()
37
52
 
38
53
  /**
@@ -129,11 +144,16 @@ export function createActionMiddleware(): (
129
144
  }
130
145
 
131
146
  async function executeAction(action: RegisteredAction, req: Request): Promise<Response> {
147
+ // Parse the request payload separately so a malformed body returns
148
+ // 400 (Bad Request) instead of being conflated with a runtime 500.
149
+ // `req.json()` / `req.formData()` throw on syntactically invalid
150
+ // payloads (truncated JSON, malformed multipart, invalid UTF-8, etc.)
151
+ // — that's a client problem, not a server problem, and the HTTP
152
+ // status code should reflect that.
153
+ const contentType = req.headers.get('content-type') ?? ''
154
+ let formData: FormData | null = null
155
+ let json: unknown = null
132
156
  try {
133
- const contentType = req.headers.get('content-type') ?? ''
134
- let formData: FormData | null = null
135
- let json: unknown = null
136
-
137
157
  if (contentType.includes('application/json')) {
138
158
  json = await req.json()
139
159
  } else if (
@@ -142,16 +162,34 @@ async function executeAction(action: RegisteredAction, req: Request): Promise<Re
142
162
  ) {
143
163
  formData = await req.formData()
144
164
  }
165
+ } catch (err) {
166
+ // Malformed request body — log for ops diagnostics but return 400
167
+ // (not 500) so the client sees the right status code. Don't leak
168
+ // the parser's internal error message; surface only the shape.
169
+ console.error('[Pyreon Action] failed to parse request body:', err)
170
+ return Response.json(
171
+ { error: 'Invalid request body' },
172
+ { status: 400 },
173
+ )
174
+ }
145
175
 
176
+ // Execute the user-supplied action handler. Surface errors to server
177
+ // logs via `console.error` — the cloud adapter audit (PR #755) found
178
+ // this same swallow-error pattern hiding production crashes from
179
+ // operators. Without it, a CMS-triggered action that crashed inside
180
+ // the user's handler returned a generic 500 to the client AND
181
+ // logged nothing on the server side, so the operator couldn't
182
+ // diagnose the failure.
183
+ try {
146
184
  const result = await action.handler({
147
185
  request: req,
148
186
  formData,
149
187
  json,
150
188
  headers: req.headers,
151
189
  })
152
-
153
190
  return Response.json(result ?? null)
154
191
  } catch (err) {
192
+ console.error('[Pyreon Action] handler failed:', err)
155
193
  const message = err instanceof Error ? err.message : 'Internal server error'
156
194
  return Response.json({ error: message }, { status: 500 })
157
195
  }
@@ -34,6 +34,8 @@ export function bunAdapter(): Adapter {
34
34
 
35
35
  const port = options.config.port ?? 3000
36
36
  const serverEntry = `
37
+ import { normalize } from "node:path"
38
+
37
39
  const handler = (await import("./server/entry-server.js")).default
38
40
  const clientDir = new URL("./client/", import.meta.url).pathname
39
41
 
@@ -42,19 +44,45 @@ Bun.serve({
42
44
  async fetch(req) {
43
45
  const url = new URL(req.url)
44
46
 
45
- // Try static files first
47
+ // Try static files first (GET only).
48
+ //
49
+ // Path safety: decode percent-encoding, normalize \`..\` segments,
50
+ // then assert the resulting path doesn't escape the clientDir
51
+ // prefix. The previous implementation used \`Bun.resolveSync\`,
52
+ // which is MODULE resolution — it throws on any non-existent
53
+ // path, so it crashed every SSR route (URLs without a matching
54
+ // static file) with a 500 before the SSR handler ran.
55
+ // \`node:path.normalize\` is pure-string path arithmetic and
56
+ // doesn't touch the filesystem — safe for arbitrary input.
46
57
  if (req.method === "GET") {
47
- const filePath = clientDir + (url.pathname === "/" ? "index.html" : url.pathname)
48
- // Prevent path traversal — ensure resolved path stays within clientDir
49
- const resolved = Bun.resolveSync(filePath, ".")
50
- if (!resolved.startsWith(Bun.resolveSync(clientDir, "."))) {
58
+ let decoded
59
+ try {
60
+ decoded = decodeURIComponent(url.pathname)
61
+ } catch {
62
+ // Malformed %-encoding → reject (don't fall through to SSR
63
+ // with a corrupt URL).
64
+ return new Response("Bad Request", { status: 400 })
65
+ }
66
+ // Reject null bytes outright — no legitimate use in a URL,
67
+ // and they can confuse downstream filesystem code.
68
+ if (decoded.includes("\\0")) {
69
+ return new Response("Forbidden", { status: 403 })
70
+ }
71
+ const reqPath = decoded === "/" ? "/index.html" : decoded
72
+ // Prepend clientDir then normalize. If the normalized result
73
+ // no longer starts with clientDir, a \`..\` segment escaped —
74
+ // reject. Using string-startsWith with clientDir (which ends
75
+ // in "/") prevents the "/clientdir-evil/" sibling-prefix
76
+ // bypass.
77
+ const candidate = normalize(clientDir + reqPath)
78
+ if (!candidate.startsWith(clientDir)) {
51
79
  return new Response("Forbidden", { status: 403 })
52
80
  }
53
- const file = Bun.file(filePath)
81
+ const file = Bun.file(candidate)
54
82
  if (await file.exists()) {
55
83
  return new Response(file, {
56
84
  headers: {
57
- "cache-control": filePath.endsWith(".js") || filePath.endsWith(".css")
85
+ "cache-control": candidate.endsWith(".js") || candidate.endsWith(".css")
58
86
  ? "public, max-age=31536000, immutable"
59
87
  : "public, max-age=3600",
60
88
  },
@@ -72,26 +72,31 @@ export function cloudflareAdapter(): Adapter {
72
72
  recursive: true,
73
73
  })
74
74
 
75
- // Generate Cloudflare Pages _worker.js (ES module format)
75
+ // Generate Cloudflare Pages _worker.js (ES module format).
76
+ //
77
+ // Static assets are handled by Cloudflare Pages itself via the
78
+ // asset binding (Cloudflare's CDN serves files from the dist
79
+ // root before invoking the worker). The pre-fix harness had an
80
+ // \`if (ext && ...) { /* comment */ }\` block here computing an
81
+ // \`ext\` variable and checking a condition with an EMPTY body —
82
+ // pure dead code that did nothing at runtime. Removed for
83
+ // clarity.
76
84
  const workerEntry = `
77
85
  import handler from "./_server/entry-server.js"
78
86
 
79
87
  export default {
80
88
  async fetch(request, env, ctx) {
81
- const url = new URL(request.url)
82
-
83
- // Let Cloudflare serve static assets (files with extensions)
84
- // This check is a fallback — Pages routes static files automatically
85
- const ext = url.pathname.split(".").pop()
86
- if (ext && ext !== url.pathname && !url.pathname.endsWith("/")) {
87
- // Cloudflare Pages handles static assets automatically via its asset binding
88
- // Only reach here if the file doesn't exist — fall through to SSR
89
- }
90
-
91
- // SSR handler
92
89
  try {
93
90
  return await handler(request)
94
91
  } catch (err) {
92
+ // Surface the error to Cloudflare Tail logs so production
93
+ // crashes give real diagnostic info — pre-fix the catch
94
+ // swallowed \`err\` entirely and the operator saw only a
95
+ // bare "Internal Server Error" with no stack, no message,
96
+ // no path. Logging via \`console.error\` is the standard
97
+ // Workers logging surface (lands in \`wrangler tail\` + the
98
+ // Cloudflare dashboard log stream).
99
+ console.error("[Pyreon SSR] handler failed:", err)
95
100
  return new Response("Internal Server Error", { status: 500 })
96
101
  }
97
102
  },
@@ -68,7 +68,7 @@ export function netlifyAdapter(): Adapter {
68
68
  recursive: true,
69
69
  })
70
70
 
71
- // Generate Netlify Function (v2 format — ESM, Web-standard Request/Response)
71
+ // Generate Netlify Function (v2 format — ESM, Web-standard Request/Response).
72
72
  const funcEntry = `
73
73
  import handler from "./_server/entry-server.js"
74
74
 
@@ -76,6 +76,12 @@ export default async function(req, context) {
76
76
  try {
77
77
  return await handler(req)
78
78
  } catch (err) {
79
+ // Surface the error to Netlify Function logs so production
80
+ // crashes give real diagnostic info — pre-fix the catch
81
+ // swallowed \`err\` entirely and the operator saw only a
82
+ // bare "Internal Server Error". \`console.error\` lands in
83
+ // Netlify's function runtime logs panel + \`netlify functions:log\`.
84
+ console.error("[Pyreon SSR] handler failed:", err)
79
85
  return new Response("Internal Server Error", { status: 500 })
80
86
  }
81
87
  }
@@ -60,11 +60,11 @@ const MIME_TYPES = {
60
60
  const server = createServer(async (req, res) => {
61
61
  const url = new URL(req.url ?? "/", "http://localhost")
62
62
 
63
- // Try to serve static files first
63
+ // Try to serve static files first (GET only).
64
64
  if (req.method === "GET") {
65
65
  try {
66
66
  const filePath = join(clientDir, url.pathname === "/" ? "index.html" : url.pathname)
67
- // Prevent path traversal — ensure resolved path stays within clientDir
67
+ // Prevent path traversal — ensure resolved path stays within clientDir.
68
68
  const { resolve } = await import("node:path")
69
69
  const resolved = resolve(filePath)
70
70
  if (!resolved.startsWith(resolve(clientDir))) {
@@ -73,7 +73,14 @@ const server = createServer(async (req, res) => {
73
73
  return
74
74
  }
75
75
  const ext = extname(filePath)
76
- if (ext && ext !== ".html") {
76
+ // Pre-fix shape was \`if (ext && ext !== ".html")\` which made the
77
+ // static branch silently refuse to serve .html files — INCLUDING
78
+ // the root \`/\` → \`index.html\` mapping the line above explicitly
79
+ // sets up. Result: GET / always fell through to SSR, even when an
80
+ // \`index.html\` shell existed in clientDir. Matches the bun
81
+ // adapter's behavior (which serves index.html at /) and the
82
+ // standard static + dynamic deployment pattern.
83
+ if (ext) {
77
84
  const data = await readFile(filePath)
78
85
  const mime = MIME_TYPES[ext] || "application/octet-stream"
79
86
  res.writeHead(200, {
@@ -88,7 +95,7 @@ const server = createServer(async (req, res) => {
88
95
  } catch {}
89
96
  }
90
97
 
91
- // Fall through to SSR handler
98
+ // Fall through to SSR handler.
92
99
  const headers = {}
93
100
  for (const [key, value] of Object.entries(req.headers)) {
94
101
  if (value) headers[key] = Array.isArray(value) ? value.join(", ") : value
@@ -100,13 +107,33 @@ const server = createServer(async (req, res) => {
100
107
  })
101
108
 
102
109
  const response = await handler(request)
103
- const body = await response.text()
104
110
 
105
111
  const responseHeaders = {}
106
112
  response.headers.forEach((v, k) => { responseHeaders[k] = v })
107
113
 
108
114
  res.writeHead(response.status, responseHeaders)
109
- res.end(body)
115
+
116
+ // Pipe the Response body stream directly to res instead of buffering
117
+ // the whole body via response.text(). For mode: 'stream' SSR (Suspense
118
+ // out-of-order streaming) the pre-fix \`await response.text()\` drained
119
+ // every Suspense chunk server-side and arrived at the client all at
120
+ // once at the end — silently defeating streaming. For mode: 'string'
121
+ // the body is a single chunk and this loop runs once with identical
122
+ // observable behaviour.
123
+ if (response.body) {
124
+ const reader = response.body.getReader()
125
+ try {
126
+ while (true) {
127
+ const { value, done } = await reader.read()
128
+ if (done) break
129
+ res.write(value)
130
+ }
131
+ } finally {
132
+ res.end()
133
+ }
134
+ } else {
135
+ res.end()
136
+ }
110
137
  })
111
138
 
112
139
  server.listen(${port}, () => {
@@ -71,11 +71,32 @@ export function vercelAdapter(): Adapter {
71
71
  // Copy server build to function directory
72
72
  await cp(join(options.serverEntry, '..'), funcDir, { recursive: true })
73
73
 
74
- // Generate serverless function entry
74
+ // Generate serverless function entry.
75
+ //
76
+ // Pre-fix the handler dynamically imported \`./entry-server.js\` on
77
+ // EVERY invocation. Node's module cache makes calls after the
78
+ // first one near-free, but the FIRST request on every fresh
79
+ // serverless instance (i.e. every cold start) paid the full
80
+ // module evaluation cost inside the request budget — observable
81
+ // as a TTFB spike on cold starts. Hoisting the import to module
82
+ // scope evaluates the SSR module once at function-init time,
83
+ // before the first request lands.
84
+ //
85
+ // Also surface SSR errors to Vercel function logs via
86
+ // \`console.error\` (mirrors the cloudflare + netlify fix). Pre-fix
87
+ // an unhandled SSR throw propagated to Vercel's launcher (which
88
+ // logs it generically); adding our own prefix makes the cause
89
+ // trivially greppable in the dashboard log stream.
75
90
  const funcEntry = `
76
- export default async function handler(req) {
77
- const handler = (await import("./entry-server.js")).default
78
- return handler(req)
91
+ import handler from "./entry-server.js"
92
+
93
+ export default async function vercelHandler(req) {
94
+ try {
95
+ return await handler(req)
96
+ } catch (err) {
97
+ console.error("[Pyreon SSR] handler failed:", err)
98
+ return new Response("Internal Server Error", { status: 500 })
99
+ }
79
100
  }
80
101
  `.trimStart()
81
102
 
package/src/csp.ts CHANGED
@@ -24,15 +24,20 @@
24
24
  import type { Middleware, MiddlewareContext } from '@pyreon/server'
25
25
  import { useRequestLocals } from '@pyreon/server'
26
26
 
27
- /** Client-side fallback nonce (dev server, SPA). */
28
- let _clientNonce = ''
29
-
30
27
  /**
31
28
  * Read the current CSP nonce in a component.
32
29
  *
33
30
  * SSR: reads from per-request `ctx.locals.cspNonce` via Pyreon's context
34
31
  * system — fully isolated between concurrent requests via AsyncLocalStorage.
35
- * Client/dev: falls back to module-level variable set by middleware.
32
+ *
33
+ * Returns `''` outside an active request context (client-side after
34
+ * hydration, dev preview, or any render path that bypassed the CSP
35
+ * middleware). Nonces are SSR-only by design: a client-side nonce
36
+ * mirrored from the last SSR request is a cross-request bleed waiting
37
+ * to happen, and a build-time-baked nonce would defeat the entire CSP
38
+ * mechanism. If you need a script-tag nonce, render the script during
39
+ * SSR through `useNonce()` so the value the browser sees IS the value
40
+ * the response's `Content-Security-Policy` header authorized.
36
41
  *
37
42
  * @example
38
43
  * ```tsx
@@ -47,7 +52,7 @@ let _clientNonce = ''
47
52
  export function useNonce(): string {
48
53
  const locals = useRequestLocals()
49
54
  if (locals.cspNonce) return locals.cspNonce as string
50
- return _clientNonce
55
+ return ''
51
56
  }
52
57
 
53
58
  export interface CspDirectives {
@@ -211,11 +216,9 @@ export function cspMiddleware(config: CspConfig): Middleware {
211
216
 
212
217
  return (ctx: MiddlewareContext) => {
213
218
  if (staticHeader) {
214
- _clientNonce = ''
215
219
  ctx.headers.set(headerName, staticHeader)
216
220
  } else {
217
221
  const nonce = generateNonce()
218
- _clientNonce = nonce
219
222
  ;(ctx.locals as Record<string, unknown>).cspNonce = nonce
220
223
  ctx.headers.set(headerName, buildCspHeader(config.directives, nonce))
221
224
  }