@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/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/testing.js
CHANGED
|
@@ -1,73 +1,5 @@
|
|
|
1
|
-
|
|
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.
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
package/lib/types/config.d.ts
CHANGED
|
@@ -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" */
|
package/lib/types/csp.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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.
|
|
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;
|