@pyreon/zero 0.22.0 → 0.24.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/lib/testing.js CHANGED
@@ -1,73 +1,5 @@
1
- //#region src/api-routes.ts
2
- /**
3
- * Match a URL path against an API route pattern.
4
- * Returns extracted params or null if no match.
5
- */
6
- function matchApiRoute(pattern, path) {
7
- const patternParts = pattern.split("/").filter(Boolean);
8
- const pathParts = path.split("/").filter(Boolean);
9
- const params = {};
10
- const isUnsafeParam = (name) => name === "__proto__" || name === "constructor" || name === "prototype";
11
- for (let i = 0; i < patternParts.length; i++) {
12
- const pp = patternParts[i];
13
- if (!pp) continue;
14
- if (pp.endsWith("*")) {
15
- const paramName = pp.slice(1, -1);
16
- if (!isUnsafeParam(paramName)) params[paramName] = pathParts.slice(i).join("/");
17
- return params;
18
- }
19
- if (i >= pathParts.length) return null;
20
- if (pp.startsWith(":")) {
21
- const paramName = pp.slice(1);
22
- if (!isUnsafeParam(paramName)) params[paramName] = pathParts[i];
23
- continue;
24
- }
25
- if (pp !== pathParts[i]) return null;
26
- }
27
- return patternParts.length === pathParts.length ? params : null;
28
- }
29
- const HTTP_METHODS = [
30
- "GET",
31
- "POST",
32
- "PUT",
33
- "PATCH",
34
- "DELETE",
35
- "HEAD",
36
- "OPTIONS"
37
- ];
38
- /**
39
- * Create a middleware that dispatches API route requests.
40
- * API routes are matched by URL pattern and HTTP method.
41
- */
42
- function createApiMiddleware(routes) {
43
- return async (ctx) => {
44
- for (const route of routes) {
45
- const params = matchApiRoute(route.pattern, ctx.path);
46
- if (!params) continue;
47
- const method = ctx.req.method.toUpperCase();
48
- const handler = route.module[method];
49
- if (!handler) {
50
- const allowed = HTTP_METHODS.filter((m) => route.module[m]).join(", ");
51
- return new Response(null, {
52
- status: 405,
53
- headers: {
54
- Allow: allowed,
55
- "Content-Type": "application/json"
56
- }
57
- });
58
- }
59
- return handler({
60
- request: ctx.req,
61
- url: ctx.url,
62
- path: ctx.path,
63
- params,
64
- headers: ctx.req.headers
65
- });
66
- }
67
- };
68
- }
1
+ import { createApiMiddleware } from "./api-routes.js";
69
2
 
70
- //#endregion
71
3
  //#region src/testing.ts
72
4
  /**
73
5
  * Create a mock MiddlewareContext for testing middleware.
package/lib/theme.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { onMount } from "@pyreon/core";
2
- import { effect, signal } from "@pyreon/reactivity";
3
2
  import { jsx, jsxs } from "@pyreon/core/jsx-runtime";
3
+ import { effect, signal } from "@pyreon/reactivity";
4
4
 
5
5
  //#region src/theme.tsx
6
+ const __DEV__ = typeof process !== "undefined" && process.env.NODE_ENV !== "production";
7
+ const _countSink = globalThis;
6
8
  const STORAGE_KEY = "zero-theme";
7
9
  /** Reactive theme signal. */
8
10
  const theme = signal("system");
@@ -55,32 +57,60 @@ function setTheme(t) {
55
57
  } catch {}
56
58
  }
57
59
  }
60
+ let _initRefCount = 0;
61
+ let _disposeShared = null;
62
+ function _setupShared() {
63
+ try {
64
+ const stored = localStorage.getItem(STORAGE_KEY);
65
+ if (stored === "light" || stored === "dark" || stored === "system") theme.set(stored);
66
+ } catch {}
67
+ document.documentElement.dataset.theme = resolvedTheme();
68
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
69
+ _osPrefersDark.set(mq.matches);
70
+ function onChange(e) {
71
+ _osPrefersDark.set(e.matches);
72
+ }
73
+ mq.addEventListener("change", onChange);
74
+ const dispose = effect(() => {
75
+ const mode = resolvedTheme();
76
+ document.documentElement.dataset.theme = mode;
77
+ const faviconLinks = document.querySelectorAll("[data-favicon-theme]");
78
+ for (const link of faviconLinks) link.media = link.dataset.faviconTheme === mode ? "" : "not all";
79
+ });
80
+ return () => {
81
+ mq.removeEventListener("change", onChange);
82
+ dispose?.dispose();
83
+ };
84
+ }
85
+ /**
86
+ * Reset the refcount + shared teardown. Useful for tests.
87
+ * @internal
88
+ */
89
+ function _resetInitThemeForTests() {
90
+ if (_disposeShared) _disposeShared();
91
+ _initRefCount = 0;
92
+ _disposeShared = null;
93
+ }
58
94
  /**
59
- * Initialize the theme system. Call once in your app entry or layout.
95
+ * Initialize the theme system. Safe to call multiple times uses a
96
+ * mount-based refcount so multiple `<ThemeToggle>` instances (or
97
+ * `<ThemeToggle>` plus an explicit `initTheme()` in your layout) share
98
+ * a SINGLE matchMedia listener + effect.
99
+ *
60
100
  * Reads from localStorage, listens for system preference changes.
61
101
  */
62
102
  function initTheme() {
63
103
  onMount(() => {
64
- try {
65
- const stored = localStorage.getItem(STORAGE_KEY);
66
- if (stored === "light" || stored === "dark" || stored === "system") theme.set(stored);
67
- } catch {}
68
- document.documentElement.dataset.theme = resolvedTheme();
69
- const mq = window.matchMedia("(prefers-color-scheme: dark)");
70
- _osPrefersDark.set(mq.matches);
71
- function onChange(e) {
72
- _osPrefersDark.set(e.matches);
73
- }
74
- mq.addEventListener("change", onChange);
75
- const dispose = effect(() => {
76
- const mode = resolvedTheme();
77
- document.documentElement.dataset.theme = mode;
78
- const faviconLinks = document.querySelectorAll("[data-favicon-theme]");
79
- for (const link of faviconLinks) link.media = link.dataset.faviconTheme === mode ? "" : "not all";
80
- });
104
+ if (_initRefCount === 0) _disposeShared = _setupShared();
105
+ _initRefCount++;
106
+ if (__DEV__) _countSink.__pyreon_count__?.("theme.initRefAcquire");
81
107
  return () => {
82
- mq.removeEventListener("change", onChange);
83
- dispose?.dispose();
108
+ _initRefCount--;
109
+ if (__DEV__) _countSink.__pyreon_count__?.("theme.initRefRelease");
110
+ if (_initRefCount === 0 && _disposeShared) {
111
+ _disposeShared();
112
+ _disposeShared = null;
113
+ }
84
114
  };
85
115
  });
86
116
  }
@@ -193,5 +223,5 @@ function ThemeToggle(props) {
193
223
  const themeScript = `(function(){try{var t=localStorage.getItem("${STORAGE_KEY}");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){}})()`;
194
224
 
195
225
  //#endregion
196
- export { ThemeToggle, initTheme, resolvedTheme, setSSRThemeDefault, setTheme, theme, themeScript, toggleTheme };
226
+ export { ThemeToggle, _resetInitThemeForTests, initTheme, resolvedTheme, setSSRThemeDefault, setTheme, theme, themeScript, toggleTheme };
197
227
  //# sourceMappingURL=theme.js.map
@@ -1,4 +1,48 @@
1
1
  import { Middleware } from "@pyreon/server";
2
+ //#region src/isr.d.ts
3
+ /** Serialized SSR response cached by the ISR layer (one per cache key). */
4
+ interface ISRCacheEntry {
5
+ html: string;
6
+ headers: Record<string, string>;
7
+ timestamp: number;
8
+ }
9
+ /**
10
+ * Pluggable backing store for the ISR cache. The default in-memory
11
+ * implementation (`createMemoryStore`) is per-process — fine for
12
+ * single-instance deploys, but multi-instance / horizontally-scaled
13
+ * apps need a SHARED store (Redis, Vercel KV, Cloudflare KV, Upstash,
14
+ * etc.) so revalidation in one instance is visible to all instances.
15
+ *
16
+ * The interface accepts BOTH sync and async returns: the in-memory
17
+ * default stays cheap (sync `Map` ops, no `Promise` allocation per
18
+ * request), while external stores return promises naturally. The
19
+ * handler awaits the result either way (`await` on a non-promise just
20
+ * returns the value).
21
+ *
22
+ * `get` is responsible for any LRU bookkeeping — external stores
23
+ * typically rely on their own TTL/eviction policy and can `return
24
+ * this.map.get(key)` directly; the in-memory store does a
25
+ * delete + re-insert to keep the Map's insertion-order LRU correct.
26
+ *
27
+ * `delete` is optional. It's only used when a future on-demand
28
+ * revalidation pathway needs to evict a specific key (the current
29
+ * stale-while-revalidate flow does not call it). External stores that
30
+ * don't support per-key invalidation can omit it.
31
+ */
32
+ interface ISRStore<E = ISRCacheEntry> {
33
+ get(key: string): Promise<E | undefined> | E | undefined;
34
+ set(key: string, entry: E): Promise<void> | void;
35
+ delete?(key: string): Promise<void> | void;
36
+ /**
37
+ * Drop EVERY entry. Optional — external stores (Redis with TTL-only,
38
+ * Vercel KV with per-key invalidation, etc.) may not support a
39
+ * blanket purge. When omitted, `ISRHandler.revalidateAll()` throws a
40
+ * clear error pointing at the missing method. The default
41
+ * `createMemoryStore` implements it.
42
+ */
43
+ clear?(): Promise<void> | void;
44
+ }
45
+ //#endregion
2
46
  //#region src/i18n-routing.d.ts
3
47
  interface I18nRoutingConfig {
4
48
  /** Supported locales. e.g. ["en", "de", "cs"] */
@@ -70,6 +114,77 @@ interface ISRConfig {
70
114
  * }
71
115
  */
72
116
  cacheKey?: (req: Request) => string;
117
+ /**
118
+ * Pluggable cache backing for multi-instance / horizontally-scaled
119
+ * production. Default: in-memory `Map` per-process (capped by
120
+ * `maxEntries`). Pass a Redis / Vercel KV / Cloudflare KV / Upstash
121
+ * adapter (anything matching the `ISRStore` interface from
122
+ * `@pyreon/zero/server`) for state shared across instances — a
123
+ * revalidation in one pod is visible to all pods.
124
+ *
125
+ * The store interface accepts BOTH sync and async returns; the
126
+ * handler `await`s the result either way, so an in-memory store
127
+ * stays cheap (no Promise allocation per request) while a Redis
128
+ * store can return its native promises directly.
129
+ *
130
+ * When set, `maxEntries` is ignored — the custom store owns its own
131
+ * eviction / TTL policy.
132
+ *
133
+ * @example
134
+ * // Redis adapter (uses `ioredis` or `@upstash/redis`):
135
+ * const redis = new Redis(...)
136
+ * const store: ISRStore = {
137
+ * async get(key) {
138
+ * const v = await redis.get(`isr:${key}`)
139
+ * return v ? JSON.parse(v) : undefined
140
+ * },
141
+ * async set(key, entry) {
142
+ * await redis.set(`isr:${key}`, JSON.stringify(entry), 'EX', 86400)
143
+ * },
144
+ * async delete(key) {
145
+ * await redis.del(`isr:${key}`)
146
+ * },
147
+ * }
148
+ *
149
+ * isr: { revalidate: 60, store }
150
+ */
151
+ store?: ISRStore<any>;
152
+ /**
153
+ * Construct the `Request` used for background revalidation. Default:
154
+ * the ORIGINAL user's request (headers, method, URL) — which means a
155
+ * `cacheKey`-bearing entry triggered by user A is revalidated against
156
+ * A's cookies / auth headers. For auth-gated `cacheKey` setups this
157
+ * is risky: if A's session expires before the revalidation runs, the
158
+ * new render may misbehave (auth-gate hits redirect path, or worse,
159
+ * still emits A's personalized HTML because the server hasn't yet
160
+ * invalidated the session token).
161
+ *
162
+ * Supply `revalidateRequest` to construct a request scoped to the
163
+ * cache key — e.g. anonymous for anonymous entries, service-account
164
+ * for shared entries. Returning `null` SKIPS revalidation entirely
165
+ * for this entry (stale stays stale until the next live request).
166
+ *
167
+ * Compatible with `store`: the revalidate path still reads/writes
168
+ * the configured store; this hook only controls what request the
169
+ * re-render runs against.
170
+ *
171
+ * @example
172
+ * isr: {
173
+ * revalidate: 60,
174
+ * cacheKey: (req) => {
175
+ * const session = req.headers.get('cookie')?.match(/session=([^;]+)/)?.[1] ?? 'anon'
176
+ * return `${new URL(req.url).pathname}::${session}`
177
+ * },
178
+ * revalidateRequest: (req) => {
179
+ * // Anonymous entries re-revalidate as anonymous (safe default).
180
+ * // Authenticated entries skip revalidation — the user's next
181
+ * // hit will re-render with their current cookies on cache miss.
182
+ * const hasAuth = /session=(?!anon)/.test(req.headers.get('cookie') ?? '')
183
+ * return hasAuth ? null : new Request(req.url, { method: 'GET' })
184
+ * },
185
+ * }
186
+ */
187
+ revalidateRequest?: (req: Request) => Request | null;
73
188
  }
74
189
  interface ZeroConfig {
75
190
  /** Default rendering mode. Default: "ssr" */
@@ -6,7 +6,15 @@ import { Middleware } from "@pyreon/server";
6
6
  *
7
7
  * SSR: reads from per-request `ctx.locals.cspNonce` via Pyreon's context
8
8
  * system — fully isolated between concurrent requests via AsyncLocalStorage.
9
- * Client/dev: falls back to module-level variable set by middleware.
9
+ *
10
+ * Returns `''` outside an active request context (client-side after
11
+ * hydration, dev preview, or any render path that bypassed the CSP
12
+ * middleware). Nonces are SSR-only by design: a client-side nonce
13
+ * mirrored from the last SSR request is a cross-request bleed waiting
14
+ * to happen, and a build-time-baked nonce would defeat the entire CSP
15
+ * mechanism. If you need a script-tag nonce, render the script during
16
+ * SSR through `useNonce()` so the value the browser sees IS the value
17
+ * the response's `Content-Security-Policy` header authorized.
10
18
  *
11
19
  * @example
12
20
  * ```tsx
@@ -479,6 +479,50 @@ declare function createScript(Component: (p: ScriptRenderProps) => any): (props:
479
479
  */
480
480
  declare const Script: (props: ScriptProps) => VNodeChild;
481
481
  //#endregion
482
+ //#region src/isr.d.ts
483
+ /** Serialized SSR response cached by the ISR layer (one per cache key). */
484
+ interface ISRCacheEntry {
485
+ html: string;
486
+ headers: Record<string, string>;
487
+ timestamp: number;
488
+ }
489
+ /**
490
+ * Pluggable backing store for the ISR cache. The default in-memory
491
+ * implementation (`createMemoryStore`) is per-process — fine for
492
+ * single-instance deploys, but multi-instance / horizontally-scaled
493
+ * apps need a SHARED store (Redis, Vercel KV, Cloudflare KV, Upstash,
494
+ * etc.) so revalidation in one instance is visible to all instances.
495
+ *
496
+ * The interface accepts BOTH sync and async returns: the in-memory
497
+ * default stays cheap (sync `Map` ops, no `Promise` allocation per
498
+ * request), while external stores return promises naturally. The
499
+ * handler awaits the result either way (`await` on a non-promise just
500
+ * returns the value).
501
+ *
502
+ * `get` is responsible for any LRU bookkeeping — external stores
503
+ * typically rely on their own TTL/eviction policy and can `return
504
+ * this.map.get(key)` directly; the in-memory store does a
505
+ * delete + re-insert to keep the Map's insertion-order LRU correct.
506
+ *
507
+ * `delete` is optional. It's only used when a future on-demand
508
+ * revalidation pathway needs to evict a specific key (the current
509
+ * stale-while-revalidate flow does not call it). External stores that
510
+ * don't support per-key invalidation can omit it.
511
+ */
512
+ interface ISRStore<E = ISRCacheEntry> {
513
+ get(key: string): Promise<E | undefined> | E | undefined;
514
+ set(key: string, entry: E): Promise<void> | void;
515
+ delete?(key: string): Promise<void> | void;
516
+ /**
517
+ * Drop EVERY entry. Optional — external stores (Redis with TTL-only,
518
+ * Vercel KV with per-key invalidation, etc.) may not support a
519
+ * blanket purge. When omitted, `ISRHandler.revalidateAll()` throws a
520
+ * clear error pointing at the missing method. The default
521
+ * `createMemoryStore` implements it.
522
+ */
523
+ clear?(): Promise<void> | void;
524
+ }
525
+ //#endregion
482
526
  //#region src/types.d.ts
483
527
  /** What a route file (e.g. `src/routes/index.tsx`) can export. */
484
528
  interface RouteModule {
@@ -563,6 +607,77 @@ interface ISRConfig {
563
607
  * }
564
608
  */
565
609
  cacheKey?: (req: Request) => string;
610
+ /**
611
+ * Pluggable cache backing for multi-instance / horizontally-scaled
612
+ * production. Default: in-memory `Map` per-process (capped by
613
+ * `maxEntries`). Pass a Redis / Vercel KV / Cloudflare KV / Upstash
614
+ * adapter (anything matching the `ISRStore` interface from
615
+ * `@pyreon/zero/server`) for state shared across instances — a
616
+ * revalidation in one pod is visible to all pods.
617
+ *
618
+ * The store interface accepts BOTH sync and async returns; the
619
+ * handler `await`s the result either way, so an in-memory store
620
+ * stays cheap (no Promise allocation per request) while a Redis
621
+ * store can return its native promises directly.
622
+ *
623
+ * When set, `maxEntries` is ignored — the custom store owns its own
624
+ * eviction / TTL policy.
625
+ *
626
+ * @example
627
+ * // Redis adapter (uses `ioredis` or `@upstash/redis`):
628
+ * const redis = new Redis(...)
629
+ * const store: ISRStore = {
630
+ * async get(key) {
631
+ * const v = await redis.get(`isr:${key}`)
632
+ * return v ? JSON.parse(v) : undefined
633
+ * },
634
+ * async set(key, entry) {
635
+ * await redis.set(`isr:${key}`, JSON.stringify(entry), 'EX', 86400)
636
+ * },
637
+ * async delete(key) {
638
+ * await redis.del(`isr:${key}`)
639
+ * },
640
+ * }
641
+ *
642
+ * isr: { revalidate: 60, store }
643
+ */
644
+ store?: ISRStore<any>;
645
+ /**
646
+ * Construct the `Request` used for background revalidation. Default:
647
+ * the ORIGINAL user's request (headers, method, URL) — which means a
648
+ * `cacheKey`-bearing entry triggered by user A is revalidated against
649
+ * A's cookies / auth headers. For auth-gated `cacheKey` setups this
650
+ * is risky: if A's session expires before the revalidation runs, the
651
+ * new render may misbehave (auth-gate hits redirect path, or worse,
652
+ * still emits A's personalized HTML because the server hasn't yet
653
+ * invalidated the session token).
654
+ *
655
+ * Supply `revalidateRequest` to construct a request scoped to the
656
+ * cache key — e.g. anonymous for anonymous entries, service-account
657
+ * for shared entries. Returning `null` SKIPS revalidation entirely
658
+ * for this entry (stale stays stale until the next live request).
659
+ *
660
+ * Compatible with `store`: the revalidate path still reads/writes
661
+ * the configured store; this hook only controls what request the
662
+ * re-render runs against.
663
+ *
664
+ * @example
665
+ * isr: {
666
+ * revalidate: 60,
667
+ * cacheKey: (req) => {
668
+ * const session = req.headers.get('cookie')?.match(/session=([^;]+)/)?.[1] ?? 'anon'
669
+ * return `${new URL(req.url).pathname}::${session}`
670
+ * },
671
+ * revalidateRequest: (req) => {
672
+ * // Anonymous entries re-revalidate as anonymous (safe default).
673
+ * // Authenticated entries skip revalidation — the user's next
674
+ * // hit will re-render with their current cookies on cache miss.
675
+ * const hasAuth = /session=(?!anon)/.test(req.headers.get('cookie') ?? '')
676
+ * return hasAuth ? null : new Request(req.url, { method: 'GET' })
677
+ * },
678
+ * }
679
+ */
680
+ revalidateRequest?: (req: Request) => Request | null;
566
681
  }
567
682
  interface ZeroConfig {
568
683
  /** Default rendering mode. Default: "ssr" */
@@ -1207,7 +1322,11 @@ declare function toggleTheme(): void;
1207
1322
  /** Set theme explicitly. */
1208
1323
  declare function setTheme(t: Theme): void;
1209
1324
  /**
1210
- * Initialize the theme system. Call once in your app entry or layout.
1325
+ * Initialize the theme system. Safe to call multiple times uses a
1326
+ * mount-based refcount so multiple `<ThemeToggle>` instances (or
1327
+ * `<ThemeToggle>` plus an explicit `initTheme()` in your layout) share
1328
+ * a SINGLE matchMedia listener + effect.
1329
+ *
1211
1330
  * Reads from localStorage, listens for system preference changes.
1212
1331
  */
1213
1332
  declare function initTheme(): void;