@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/src/theme.tsx CHANGED
@@ -2,6 +2,10 @@ import type { VNodeChild } from '@pyreon/core'
2
2
  import { onMount } from '@pyreon/core'
3
3
  import { effect, signal } from '@pyreon/reactivity'
4
4
 
5
+ // Dev-mode counter sink — see packages/internals/perf-harness for contract.
6
+ const __DEV__ = typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'
7
+ const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
8
+
5
9
  // ─── Theme system ───────────────────────────────────────────────────────────
6
10
  //
7
11
  // Provides dark/light/system theme support with:
@@ -75,52 +79,104 @@ export function setTheme(t: Theme) {
75
79
  }
76
80
  }
77
81
 
78
- /**
79
- * Initialize the theme system. Call once in your app entry or layout.
80
- * Reads from localStorage, listens for system preference changes.
81
- */
82
- export function initTheme() {
83
- onMount(() => {
84
- // Read persisted preference
85
- try {
86
- const stored = localStorage.getItem(STORAGE_KEY) as Theme | null
87
- if (stored === 'light' || stored === 'dark' || stored === 'system') {
88
- theme.set(stored)
89
- }
90
- } catch {
91
- // localStorage may not be available
82
+ // Refcount + shared-teardown for `initTheme()`. The first mount runs the
83
+ // real setup (localStorage read + matchMedia listener + effect); subsequent
84
+ // mounts (other ThemeToggles, or an explicit `initTheme()` call in a
85
+ // layout alongside `<ThemeToggle>`) only bump the refcount. Each unmount
86
+ // decrements; when the count returns to 0 the shared teardown runs.
87
+ //
88
+ // Pre-fix every `initTheme()` call ran its own `onMount(setup)` — N
89
+ // mounted ThemeToggles → N matchMedia listeners + N effects, all writing
90
+ // the SAME values to the SAME `document.documentElement.dataset.theme`.
91
+ // Class D event-listener pile-up, real production case for any app with
92
+ // header + footer ThemeToggle widgets.
93
+ let _initRefCount = 0
94
+ let _disposeShared: (() => void) | null = null
95
+
96
+ function _setupShared(): () => void {
97
+ // Read persisted preference
98
+ try {
99
+ const stored = localStorage.getItem(STORAGE_KEY) as Theme | null
100
+ if (stored === 'light' || stored === 'dark' || stored === 'system') {
101
+ theme.set(stored)
92
102
  }
103
+ } catch {
104
+ // localStorage may not be available
105
+ }
93
106
 
94
- // Apply to document
95
- document.documentElement.dataset.theme = resolvedTheme()
107
+ // Apply to document
108
+ document.documentElement.dataset.theme = resolvedTheme()
109
+
110
+ // Watch for system preference changes. Seed the signal from the
111
+ // current media-query state, then update reactively on each OS
112
+ // preference flip. Components reading `resolvedTheme()` pick up the
113
+ // change automatically (they subscribe to `_osPrefersDark` when
114
+ // `theme === 'system'`).
115
+ const mq = window.matchMedia('(prefers-color-scheme: dark)')
116
+ _osPrefersDark.set(mq.matches)
117
+ function onChange(e: MediaQueryListEvent) {
118
+ _osPrefersDark.set(e.matches)
119
+ }
120
+ mq.addEventListener('change', onChange)
121
+
122
+ // Re-apply when theme signal changes — updates data-theme + favicons
123
+ const dispose = effect(() => {
124
+ const mode = resolvedTheme()
125
+ document.documentElement.dataset.theme = mode
96
126
 
97
- // Watch for system preference changes. Seed the signal from the
98
- // current media-query state, then update reactively on each OS
99
- // preference flip. Components reading `resolvedTheme()` pick up the
100
- // change automatically (they subscribe to `_osPrefersDark` when
101
- // `theme === 'system'`).
102
- const mq = window.matchMedia('(prefers-color-scheme: dark)')
103
- _osPrefersDark.set(mq.matches)
104
- function onChange(e: MediaQueryListEvent) {
105
- _osPrefersDark.set(e.matches)
127
+ // Swap favicon variants (if dual-variant favicons are present)
128
+ const faviconLinks = document.querySelectorAll<HTMLLinkElement>('[data-favicon-theme]')
129
+ for (const link of faviconLinks) {
130
+ link.media = link.dataset.faviconTheme === mode ? '' : 'not all'
106
131
  }
107
- mq.addEventListener('change', onChange)
132
+ })
108
133
 
109
- // Re-apply when theme signal changes — updates data-theme + favicons
110
- const dispose = effect(() => {
111
- const mode = resolvedTheme()
112
- document.documentElement.dataset.theme = mode
134
+ return () => {
135
+ mq.removeEventListener('change', onChange)
136
+ dispose?.dispose()
137
+ }
138
+ }
113
139
 
114
- // Swap favicon variants (if dual-variant favicons are present)
115
- const faviconLinks = document.querySelectorAll<HTMLLinkElement>('[data-favicon-theme]')
116
- for (const link of faviconLinks) {
117
- link.media = link.dataset.faviconTheme === mode ? '' : 'not all'
118
- }
119
- })
140
+ /**
141
+ * Reset the refcount + shared teardown. Useful for tests.
142
+ * @internal
143
+ */
144
+ export function _resetInitThemeForTests(): void {
145
+ if (_disposeShared) _disposeShared()
146
+ _initRefCount = 0
147
+ _disposeShared = null
148
+ }
149
+
150
+ /**
151
+ * Initialize the theme system. Safe to call multiple times — uses a
152
+ * mount-based refcount so multiple `<ThemeToggle>` instances (or
153
+ * `<ThemeToggle>` plus an explicit `initTheme()` in your layout) share
154
+ * a SINGLE matchMedia listener + effect.
155
+ *
156
+ * Reads from localStorage, listens for system preference changes.
157
+ */
158
+ export function initTheme() {
159
+ onMount(() => {
160
+ if (_initRefCount === 0) {
161
+ _disposeShared = _setupShared()
162
+ }
163
+ _initRefCount++
164
+ // Leak-class D diagnostic — emit per refcount++. Net (acquire -
165
+ // release) = currently-mounted ThemeToggle count; should be
166
+ // bounded by the user's UI shape. Steady-state monotonic growth
167
+ // signals the refcount guard regressed (every mount registers a
168
+ // fresh listener again).
169
+ if (__DEV__) _countSink.__pyreon_count__?.('theme.initRefAcquire')
120
170
 
121
171
  return () => {
122
- mq.removeEventListener('change', onChange)
123
- dispose?.dispose()
172
+ _initRefCount--
173
+ // Pair with `theme.initRefAcquire`. Equal counts at process exit
174
+ // = healthy lifecycle. Diff = active subscribers / orphan inits.
175
+ if (__DEV__) _countSink.__pyreon_count__?.('theme.initRefRelease')
176
+ if (_initRefCount === 0 && _disposeShared) {
177
+ _disposeShared()
178
+ _disposeShared = null
179
+ }
124
180
  }
125
181
  })
126
182
  }
package/src/types.ts CHANGED
@@ -103,6 +103,82 @@ export interface ISRConfig {
103
103
  * }
104
104
  */
105
105
  cacheKey?: (req: Request) => string
106
+ /**
107
+ * Pluggable cache backing for multi-instance / horizontally-scaled
108
+ * production. Default: in-memory `Map` per-process (capped by
109
+ * `maxEntries`). Pass a Redis / Vercel KV / Cloudflare KV / Upstash
110
+ * adapter (anything matching the `ISRStore` interface from
111
+ * `@pyreon/zero/server`) for state shared across instances — a
112
+ * revalidation in one pod is visible to all pods.
113
+ *
114
+ * The store interface accepts BOTH sync and async returns; the
115
+ * handler `await`s the result either way, so an in-memory store
116
+ * stays cheap (no Promise allocation per request) while a Redis
117
+ * store can return its native promises directly.
118
+ *
119
+ * When set, `maxEntries` is ignored — the custom store owns its own
120
+ * eviction / TTL policy.
121
+ *
122
+ * @example
123
+ * // Redis adapter (uses `ioredis` or `@upstash/redis`):
124
+ * const redis = new Redis(...)
125
+ * const store: ISRStore = {
126
+ * async get(key) {
127
+ * const v = await redis.get(`isr:${key}`)
128
+ * return v ? JSON.parse(v) : undefined
129
+ * },
130
+ * async set(key, entry) {
131
+ * await redis.set(`isr:${key}`, JSON.stringify(entry), 'EX', 86400)
132
+ * },
133
+ * async delete(key) {
134
+ * await redis.del(`isr:${key}`)
135
+ * },
136
+ * }
137
+ *
138
+ * isr: { revalidate: 60, store }
139
+ */
140
+ // The actual type lives in `./isr` to avoid `types.ts` pulling the
141
+ // implementation file; we type it as `unknown` here and let consumers
142
+ // pass an `ISRStore` directly — `createISRHandler`'s signature checks
143
+ // the shape statically.
144
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
145
+ store?: import('./isr').ISRStore<any>
146
+ /**
147
+ * Construct the `Request` used for background revalidation. Default:
148
+ * the ORIGINAL user's request (headers, method, URL) — which means a
149
+ * `cacheKey`-bearing entry triggered by user A is revalidated against
150
+ * A's cookies / auth headers. For auth-gated `cacheKey` setups this
151
+ * is risky: if A's session expires before the revalidation runs, the
152
+ * new render may misbehave (auth-gate hits redirect path, or worse,
153
+ * still emits A's personalized HTML because the server hasn't yet
154
+ * invalidated the session token).
155
+ *
156
+ * Supply `revalidateRequest` to construct a request scoped to the
157
+ * cache key — e.g. anonymous for anonymous entries, service-account
158
+ * for shared entries. Returning `null` SKIPS revalidation entirely
159
+ * for this entry (stale stays stale until the next live request).
160
+ *
161
+ * Compatible with `store`: the revalidate path still reads/writes
162
+ * the configured store; this hook only controls what request the
163
+ * re-render runs against.
164
+ *
165
+ * @example
166
+ * isr: {
167
+ * revalidate: 60,
168
+ * cacheKey: (req) => {
169
+ * const session = req.headers.get('cookie')?.match(/session=([^;]+)/)?.[1] ?? 'anon'
170
+ * return `${new URL(req.url).pathname}::${session}`
171
+ * },
172
+ * revalidateRequest: (req) => {
173
+ * // Anonymous entries re-revalidate as anonymous (safe default).
174
+ * // Authenticated entries skip revalidation — the user's next
175
+ * // hit will re-render with their current cookies on cache miss.
176
+ * const hasAuth = /session=(?!anon)/.test(req.headers.get('cookie') ?? '')
177
+ * return hasAuth ? null : new Request(req.url, { method: 'GET' })
178
+ * },
179
+ * }
180
+ */
181
+ revalidateRequest?: (req: Request) => Request | null
106
182
  }
107
183
 
108
184
  // ─── Zero config ─────────────────────────────────────────────────────────────
@@ -1,148 +0,0 @@
1
- import { t as __exportAll } from "./rolldown-runtime-CjeV3_4I.js";
2
-
3
- //#region src/api-routes.ts
4
- var api_routes_exports = /* @__PURE__ */ __exportAll({
5
- apiFilePathToPattern: () => apiFilePathToPattern,
6
- createApiMiddleware: () => createApiMiddleware,
7
- generateApiRouteModule: () => generateApiRouteModule,
8
- isApiRoute: () => isApiRoute,
9
- matchApiRoute: () => matchApiRoute
10
- });
11
- /**
12
- * Match a URL path against an API route pattern.
13
- * Returns extracted params or null if no match.
14
- */
15
- function matchApiRoute(pattern, path) {
16
- const patternParts = pattern.split("/").filter(Boolean);
17
- const pathParts = path.split("/").filter(Boolean);
18
- const params = {};
19
- const isUnsafeParam = (name) => name === "__proto__" || name === "constructor" || name === "prototype";
20
- for (let i = 0; i < patternParts.length; i++) {
21
- const pp = patternParts[i];
22
- if (!pp) continue;
23
- if (pp.endsWith("*")) {
24
- const paramName = pp.slice(1, -1);
25
- if (!isUnsafeParam(paramName)) params[paramName] = pathParts.slice(i).join("/");
26
- return params;
27
- }
28
- if (i >= pathParts.length) return null;
29
- if (pp.startsWith(":")) {
30
- const paramName = pp.slice(1);
31
- if (!isUnsafeParam(paramName)) params[paramName] = pathParts[i];
32
- continue;
33
- }
34
- if (pp !== pathParts[i]) return null;
35
- }
36
- return patternParts.length === pathParts.length ? params : null;
37
- }
38
- const HTTP_METHODS = [
39
- "GET",
40
- "POST",
41
- "PUT",
42
- "PATCH",
43
- "DELETE",
44
- "HEAD",
45
- "OPTIONS"
46
- ];
47
- /**
48
- * Create a middleware that dispatches API route requests.
49
- * API routes are matched by URL pattern and HTTP method.
50
- */
51
- function createApiMiddleware(routes) {
52
- return async (ctx) => {
53
- for (const route of routes) {
54
- const params = matchApiRoute(route.pattern, ctx.path);
55
- if (!params) continue;
56
- const method = ctx.req.method.toUpperCase();
57
- const handler = route.module[method];
58
- if (!handler) {
59
- const allowed = HTTP_METHODS.filter((m) => route.module[m]).join(", ");
60
- return new Response(null, {
61
- status: 405,
62
- headers: {
63
- Allow: allowed,
64
- "Content-Type": "application/json"
65
- }
66
- });
67
- }
68
- return handler({
69
- request: ctx.req,
70
- url: ctx.url,
71
- path: ctx.path,
72
- params,
73
- headers: ctx.req.headers
74
- });
75
- }
76
- };
77
- }
78
- /**
79
- * Detect whether a route file is an API route.
80
- * API routes are `.ts` or `.js` files inside an `api/` directory.
81
- */
82
- function isApiRoute(filePath) {
83
- const normalized = filePath.replace(/\\/g, "/");
84
- return normalized.startsWith("api/") && (normalized.endsWith(".ts") || normalized.endsWith(".js")) && !normalized.endsWith(".tsx") && !normalized.endsWith(".jsx");
85
- }
86
- /**
87
- * Convert an API route file path to a URL pattern.
88
- *
89
- * Examples:
90
- * "api/posts.ts" → "/api/posts"
91
- * "api/posts/index.ts" → "/api/posts"
92
- * "api/posts/[id].ts" → "/api/posts/:id"
93
- * "api/[...path].ts" → "/api/:path*"
94
- */
95
- function apiFilePathToPattern(filePath) {
96
- let route = filePath;
97
- for (const ext of [".ts", ".js"]) if (route.endsWith(ext)) {
98
- route = route.slice(0, -ext.length);
99
- break;
100
- }
101
- const segments = route.split("/");
102
- const urlSegments = [];
103
- for (const seg of segments) {
104
- if (seg === "index") continue;
105
- const catchAll = seg.match(/^\[\.\.\.(\w+)\]$/);
106
- if (catchAll) {
107
- urlSegments.push(`:${catchAll[1]}*`);
108
- continue;
109
- }
110
- const dynamic = seg.match(/^\[(\w+)\]$/);
111
- if (dynamic) {
112
- urlSegments.push(`:${dynamic[1]}`);
113
- continue;
114
- }
115
- urlSegments.push(seg);
116
- }
117
- return `/${urlSegments.join("/")}`;
118
- }
119
- /**
120
- * Generate a virtual module that exports API route entries.
121
- * Each entry maps a URL pattern to a module with HTTP method handlers.
122
- */
123
- function generateApiRouteModule(files, routesDir) {
124
- const apiFiles = files.filter(isApiRoute);
125
- if (apiFiles.length === 0) return "export const apiRoutes = []\n";
126
- const imports = [];
127
- const entries = [];
128
- for (let i = 0; i < apiFiles.length; i++) {
129
- const name = `_api${i}`;
130
- const file = apiFiles[i];
131
- if (!file) continue;
132
- const fullPath = `${routesDir}/${file}`;
133
- const pattern = apiFilePathToPattern(file);
134
- imports.push(`import * as ${name} from "${fullPath}"`);
135
- entries.push(` { pattern: ${JSON.stringify(pattern)}, module: ${name} }`);
136
- }
137
- return [
138
- ...imports,
139
- "",
140
- "export const apiRoutes = [",
141
- entries.join(",\n"),
142
- "]"
143
- ].join("\n");
144
- }
145
-
146
- //#endregion
147
- export { matchApiRoute as i, createApiMiddleware as n, generateApiRouteModule as r, api_routes_exports as t };
148
- //# sourceMappingURL=api-routes-CMsLztoj.js.map
@@ -1,32 +0,0 @@
1
- import { join } from "node:path";
2
-
3
- //#region src/fs-router.ts
4
- const ROUTE_EXTENSIONS = [
5
- ".tsx",
6
- ".jsx",
7
- ".ts",
8
- ".js"
9
- ];
10
- /**
11
- * Scan a directory for route files.
12
- * Returns paths relative to the routes directory.
13
- */
14
- async function scanRouteFiles(routesDir) {
15
- const { readdir } = await import("node:fs/promises");
16
- const { relative } = await import("node:path");
17
- const files = [];
18
- async function walk(dir) {
19
- const entries = await readdir(dir, { withFileTypes: true });
20
- for (const entry of entries) {
21
- const fullPath = join(dir, entry.name);
22
- if (entry.isDirectory()) await walk(fullPath);
23
- else if (ROUTE_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) files.push(relative(routesDir, fullPath));
24
- }
25
- }
26
- await walk(routesDir);
27
- return files;
28
- }
29
-
30
- //#endregion
31
- export { scanRouteFiles };
32
- //# sourceMappingURL=fs-router-3xzp-4Wj.js.map
@@ -1,18 +0,0 @@
1
- //#region \0rolldown/runtime.js
2
- var __defProp = Object.defineProperty;
3
- var __exportAll = (all, no_symbols) => {
4
- let target = {};
5
- for (var name in all) {
6
- __defProp(target, name, {
7
- get: all[name],
8
- enumerable: true
9
- });
10
- }
11
- if (!no_symbols) {
12
- __defProp(target, Symbol.toStringTag, { value: "Module" });
13
- }
14
- return target;
15
- };
16
-
17
- //#endregion
18
- export { __exportAll as t };