@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 +211 -57
- package/lib/_chunks/app-BbPT0Y5M.js +36 -0
- package/lib/{fs-router-Bacdhsq-.js → _chunks/fs-router-DvBlRzmP.js} +21 -5
- package/lib/_chunks/use-intersection-observer-C6opeplh.js +29 -0
- package/lib/actions.js +24 -3
- package/lib/ai.js +1 -102
- package/lib/client.js +3 -33
- package/lib/csp.js +12 -9
- package/lib/favicon.js +1 -1
- package/lib/font.js +1 -1
- package/lib/image-plugin.js +1 -1
- package/lib/image.js +3 -27
- package/lib/index.js +8 -1085
- package/lib/link.js +3 -27
- package/lib/meta.js +1 -25
- package/lib/script.js +2 -26
- package/lib/seo.js +4 -4
- package/lib/server.js +275 -2129
- package/lib/testing.js +1 -69
- package/lib/theme.js +52 -22
- package/lib/types/config.d.ts +115 -0
- package/lib/types/csp.d.ts +9 -1
- package/lib/types/index.d.ts +120 -1
- package/lib/types/server.d.ts +192 -17
- package/lib/types/theme.d.ts +11 -2
- package/package.json +10 -10
- package/src/actions.ts +43 -5
- package/src/adapters/bun.ts +35 -7
- package/src/adapters/cloudflare.ts +17 -12
- package/src/adapters/netlify.ts +7 -1
- package/src/adapters/node.ts +33 -6
- package/src/adapters/vercel.ts +25 -4
- package/src/csp.ts +10 -7
- package/src/fs-router.ts +2 -1
- package/src/isr.ts +256 -51
- package/src/manifest.ts +23 -10
- package/src/server.ts +2 -1
- package/src/ssg-plugin.ts +27 -7
- package/src/theme.tsx +94 -38
- package/src/types.ts +76 -0
- package/lib/api-routes-CMsLztoj.js +0 -148
- package/lib/fs-router-3xzp-4Wj.js +0 -32
- package/lib/rolldown-runtime-CjeV3_4I.js +0 -18
package/lib/types/server.d.ts
CHANGED
|
@@ -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
|
package/lib/types/theme.d.ts
CHANGED
|
@@ -21,7 +21,16 @@ declare function toggleTheme(): void;
|
|
|
21
21
|
/** Set theme explicitly. */
|
|
22
22
|
declare function setTheme(t: Theme): void;
|
|
23
23
|
/**
|
|
24
|
-
*
|
|
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.
|
|
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.
|
|
177
|
-
"@pyreon/head": "^0.
|
|
178
|
-
"@pyreon/meta": "^0.
|
|
179
|
-
"@pyreon/reactivity": "^0.
|
|
180
|
-
"@pyreon/router": "^0.
|
|
181
|
-
"@pyreon/runtime-dom": "^0.
|
|
182
|
-
"@pyreon/runtime-server": "^0.
|
|
183
|
-
"@pyreon/server": "^0.
|
|
184
|
-
"@pyreon/vite-plugin": "^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
|
}
|
package/src/adapters/bun.ts
CHANGED
|
@@ -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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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(
|
|
81
|
+
const file = Bun.file(candidate)
|
|
54
82
|
if (await file.exists()) {
|
|
55
83
|
return new Response(file, {
|
|
56
84
|
headers: {
|
|
57
|
-
"cache-control":
|
|
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
|
},
|
package/src/adapters/netlify.ts
CHANGED
|
@@ -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
|
}
|
package/src/adapters/node.ts
CHANGED
|
@@ -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
|
-
|
|
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}, () => {
|
package/src/adapters/vercel.ts
CHANGED
|
@@ -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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
}
|