@pyreon/zero 0.24.4 → 0.24.6
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/package.json +10 -39
- package/src/actions.ts +0 -196
- package/src/adapters/bun.ts +0 -114
- package/src/adapters/cloudflare.ts +0 -166
- package/src/adapters/index.ts +0 -61
- package/src/adapters/netlify.ts +0 -154
- package/src/adapters/node.ts +0 -163
- package/src/adapters/static.ts +0 -42
- package/src/adapters/validate.ts +0 -23
- package/src/adapters/vercel.ts +0 -182
- package/src/adapters/warn-missing-env.ts +0 -49
- package/src/ai.ts +0 -623
- package/src/api-routes.ts +0 -219
- package/src/app.ts +0 -92
- package/src/cache.ts +0 -136
- package/src/client.ts +0 -143
- package/src/compression.ts +0 -116
- package/src/config.ts +0 -35
- package/src/cors.ts +0 -94
- package/src/csp.ts +0 -226
- package/src/entry-server.ts +0 -224
- package/src/env.ts +0 -344
- package/src/error-overlay.ts +0 -118
- package/src/favicon.ts +0 -841
- package/src/font.ts +0 -511
- package/src/fs-router.ts +0 -1519
- package/src/i18n-routing.ts +0 -533
- package/src/icon.tsx +0 -182
- package/src/icons-plugin.ts +0 -296
- package/src/image-plugin.ts +0 -751
- package/src/image-types.ts +0 -60
- package/src/image.tsx +0 -340
- package/src/index.ts +0 -92
- package/src/isr.ts +0 -394
- package/src/link.tsx +0 -304
- package/src/logger.ts +0 -144
- package/src/manifest.ts +0 -787
- package/src/meta.tsx +0 -354
- package/src/middleware.ts +0 -65
- package/src/not-found.ts +0 -44
- package/src/og-image.ts +0 -378
- package/src/rate-limit.ts +0 -140
- package/src/script.tsx +0 -260
- package/src/seo.ts +0 -617
- package/src/server.ts +0 -89
- package/src/sharp.d.ts +0 -22
- package/src/ssg-plugin.ts +0 -1582
- package/src/testing.ts +0 -146
- package/src/theme.tsx +0 -257
- package/src/types.ts +0 -624
- package/src/utils/use-intersection-observer.ts +0 -36
- package/src/utils/with-headers.ts +0 -13
- package/src/vercel-revalidate-handler.ts +0 -204
- package/src/vite-plugin.ts +0 -848
package/src/isr.ts
DELETED
|
@@ -1,394 +0,0 @@
|
|
|
1
|
-
import type { ISRConfig } from './types'
|
|
2
|
-
|
|
3
|
-
// Dev-mode counter sink — see packages/internals/perf-harness for contract.
|
|
4
|
-
// Zero cost in prod (gate folds to `false`, optional-chain short-circuits).
|
|
5
|
-
const __DEV__ = typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'
|
|
6
|
-
const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
|
|
7
|
-
|
|
8
|
-
// ─── ISR Cache ───────────────────────────────────────────────────────────────
|
|
9
|
-
|
|
10
|
-
/** Serialized SSR response cached by the ISR layer (one per cache key). */
|
|
11
|
-
export interface ISRCacheEntry {
|
|
12
|
-
html: string
|
|
13
|
-
headers: Record<string, string>
|
|
14
|
-
timestamp: number
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Pluggable backing store for the ISR cache. The default in-memory
|
|
19
|
-
* implementation (`createMemoryStore`) is per-process — fine for
|
|
20
|
-
* single-instance deploys, but multi-instance / horizontally-scaled
|
|
21
|
-
* apps need a SHARED store (Redis, Vercel KV, Cloudflare KV, Upstash,
|
|
22
|
-
* etc.) so revalidation in one instance is visible to all instances.
|
|
23
|
-
*
|
|
24
|
-
* The interface accepts BOTH sync and async returns: the in-memory
|
|
25
|
-
* default stays cheap (sync `Map` ops, no `Promise` allocation per
|
|
26
|
-
* request), while external stores return promises naturally. The
|
|
27
|
-
* handler awaits the result either way (`await` on a non-promise just
|
|
28
|
-
* returns the value).
|
|
29
|
-
*
|
|
30
|
-
* `get` is responsible for any LRU bookkeeping — external stores
|
|
31
|
-
* typically rely on their own TTL/eviction policy and can `return
|
|
32
|
-
* this.map.get(key)` directly; the in-memory store does a
|
|
33
|
-
* delete + re-insert to keep the Map's insertion-order LRU correct.
|
|
34
|
-
*
|
|
35
|
-
* `delete` is optional. It's only used when a future on-demand
|
|
36
|
-
* revalidation pathway needs to evict a specific key (the current
|
|
37
|
-
* stale-while-revalidate flow does not call it). External stores that
|
|
38
|
-
* don't support per-key invalidation can omit it.
|
|
39
|
-
*/
|
|
40
|
-
export interface ISRStore<E = ISRCacheEntry> {
|
|
41
|
-
get(key: string): Promise<E | undefined> | E | undefined
|
|
42
|
-
set(key: string, entry: E): Promise<void> | void
|
|
43
|
-
delete?(key: string): Promise<void> | void
|
|
44
|
-
/**
|
|
45
|
-
* Drop EVERY entry. Optional — external stores (Redis with TTL-only,
|
|
46
|
-
* Vercel KV with per-key invalidation, etc.) may not support a
|
|
47
|
-
* blanket purge. When omitted, `ISRHandler.revalidateAll()` throws a
|
|
48
|
-
* clear error pointing at the missing method. The default
|
|
49
|
-
* `createMemoryStore` implements it.
|
|
50
|
-
*/
|
|
51
|
-
clear?(): Promise<void> | void
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* The default in-memory ISR store: a `Map` with insertion-order LRU
|
|
56
|
-
* eviction, capped at `maxEntries` (default `1000`). Drop in as
|
|
57
|
-
* `config.store` if you want to tweak the cap or wrap the store with
|
|
58
|
-
* instrumentation; pass a different `ISRStore` impl for Redis / KV /
|
|
59
|
-
* etc. backings.
|
|
60
|
-
*
|
|
61
|
-
* `get` does the LRU bump (re-inserts the touched entry at the
|
|
62
|
-
* newest position) so hot paths survive eviction even when the cap is
|
|
63
|
-
* small. Without that, `Map.get(...)` wouldn't update ordering and
|
|
64
|
-
* frequently-read entries could be evicted by occasional writes.
|
|
65
|
-
*/
|
|
66
|
-
export function createMemoryStore<E = ISRCacheEntry>(opts: {
|
|
67
|
-
maxEntries?: number
|
|
68
|
-
} = {}): ISRStore<E> {
|
|
69
|
-
const cache = new Map<string, E>()
|
|
70
|
-
const maxEntries = Math.max(1, opts.maxEntries ?? 1000)
|
|
71
|
-
return {
|
|
72
|
-
get(key) {
|
|
73
|
-
const entry = cache.get(key)
|
|
74
|
-
if (entry !== undefined) {
|
|
75
|
-
cache.delete(key)
|
|
76
|
-
cache.set(key, entry)
|
|
77
|
-
}
|
|
78
|
-
return entry
|
|
79
|
-
},
|
|
80
|
-
set(key, entry) {
|
|
81
|
-
if (cache.has(key)) cache.delete(key)
|
|
82
|
-
cache.set(key, entry)
|
|
83
|
-
while (cache.size > maxEntries) {
|
|
84
|
-
const oldest = cache.keys().next().value
|
|
85
|
-
if (oldest === undefined) break
|
|
86
|
-
cache.delete(oldest)
|
|
87
|
-
}
|
|
88
|
-
},
|
|
89
|
-
delete(key) {
|
|
90
|
-
cache.delete(key)
|
|
91
|
-
},
|
|
92
|
-
clear() {
|
|
93
|
-
cache.clear()
|
|
94
|
-
},
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Internal alias for the default-shape entry — keeps function bodies tidy
|
|
99
|
-
// while staying source-compatible with the public `ISRCacheEntry` export.
|
|
100
|
-
type CacheEntry = ISRCacheEntry
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* ISR handler with stale-while-revalidate semantics.
|
|
104
|
-
*
|
|
105
|
-
* Wraps an SSR handler and caches responses per URL path. Serves stale
|
|
106
|
-
* content immediately while revalidating in the background. The cache
|
|
107
|
-
* backing is **pluggable** via `config.store` (default:
|
|
108
|
-
* `createMemoryStore({ maxEntries: 1000 })`) — pass a custom `ISRStore`
|
|
109
|
-
* implementation backed by Redis / Vercel KV / Cloudflare KV / Upstash
|
|
110
|
-
* etc. to share state across horizontally-scaled instances. Without an
|
|
111
|
-
* external store, each instance has its own per-process cache (which
|
|
112
|
-
* is fine for single-instance deploys; behaviour-identical to the
|
|
113
|
-
* pre-pluggable-store implementation).
|
|
114
|
-
*
|
|
115
|
-
* Default in-memory store: bounded by `config.maxEntries` (default
|
|
116
|
-
* `1000`) with insertion-order LRU eviction. Without the cap, unbounded
|
|
117
|
-
* URL spaces like `/user/:id` would grow cache memory without limit
|
|
118
|
-
* over the server's lifetime — a real leak in long-running deployments.
|
|
119
|
-
* `config.maxEntries` is ignored when a custom `config.store` is supplied
|
|
120
|
-
* (the custom store owns its own eviction policy).
|
|
121
|
-
*/
|
|
122
|
-
/**
|
|
123
|
-
* The fetch handler `createISRHandler` returns is also a callable
|
|
124
|
-
* carrying imperative invalidation methods. Webhooks, CMS notifications,
|
|
125
|
-
* admin endpoints etc. call these methods to drop one or all cached
|
|
126
|
-
* entries on demand — strictly more responsive than waiting for the
|
|
127
|
-
* TTL-based stale-while-revalidate cycle.
|
|
128
|
-
*
|
|
129
|
-
* The shape is `(req) => Promise<Response>` PLUS the methods, so
|
|
130
|
-
* existing consumers (`Bun.serve({ fetch: handler })`) keep working
|
|
131
|
-
* byte-identically.
|
|
132
|
-
*/
|
|
133
|
-
export interface ISRHandler {
|
|
134
|
-
(req: Request): Promise<Response>
|
|
135
|
-
/**
|
|
136
|
-
* Drop the cache entry for a single path (or `cacheKey`-derived key).
|
|
137
|
-
* The next request for that key will MISS and re-render fresh. Returns
|
|
138
|
-
* `{ dropped: true }` if an entry was found and deleted, `{ dropped: false }`
|
|
139
|
-
* if no entry existed.
|
|
140
|
-
*
|
|
141
|
-
* Useful for webhook-driven invalidation: a CMS notifies that a post
|
|
142
|
-
* was updated, the webhook handler calls `isrHandler.revalidateNow('/posts/123')`,
|
|
143
|
-
* and the very next visitor gets the fresh content — no stale window.
|
|
144
|
-
*
|
|
145
|
-
* Idempotent. Safe to call against keys that don't exist (returns
|
|
146
|
-
* `dropped: false` cleanly).
|
|
147
|
-
*/
|
|
148
|
-
revalidateNow(key: string): Promise<{ dropped: boolean }>
|
|
149
|
-
/**
|
|
150
|
-
* Drop ALL cached entries. Useful for "purge cache" admin actions or
|
|
151
|
-
* deploy-completion hooks that want a clean slate.
|
|
152
|
-
*
|
|
153
|
-
* The default in-memory store supports this via repeated `delete`
|
|
154
|
-
* calls under the hood; custom stores that omit `delete` (Redis with
|
|
155
|
-
* TTL-only, etc.) throw a clear error pointing at the missing method
|
|
156
|
-
* so the caller knows their store implementation can't honor the call.
|
|
157
|
-
*/
|
|
158
|
-
revalidateAll(): Promise<void>
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
export function createISRHandler(
|
|
162
|
-
handler: (req: Request) => Promise<Response>,
|
|
163
|
-
config: ISRConfig,
|
|
164
|
-
): ISRHandler {
|
|
165
|
-
// Pluggable backing store. Default keeps the prior in-memory LRU
|
|
166
|
-
// behaviour byte-identical (same `maxEntries` default, same Map +
|
|
167
|
-
// delete+re-insert LRU bump) so existing callers see no behavioural
|
|
168
|
-
// change.
|
|
169
|
-
const store: ISRStore<CacheEntry>
|
|
170
|
-
= config.store
|
|
171
|
-
?? createMemoryStore<CacheEntry>(
|
|
172
|
-
config.maxEntries !== undefined ? { maxEntries: config.maxEntries } : {},
|
|
173
|
-
)
|
|
174
|
-
const revalidating = new Set<string>()
|
|
175
|
-
const revalidateMs = config.revalidate * 1000
|
|
176
|
-
// Bounded background-revalidation timeout. Without it, a handler that
|
|
177
|
-
// hangs forever leaves its key permanently in `revalidating` (the
|
|
178
|
-
// `finally` that clears it never runs), so EVERY later request for
|
|
179
|
-
// that key short-circuits the `revalidating.has(key)` guard and the
|
|
180
|
-
// entry stays stale for the rest of the process lifetime — it can
|
|
181
|
-
// never recover. 30s default matches the Suspense streaming timeout;
|
|
182
|
-
// overridable via ISRConfig.revalidateTimeoutMs.
|
|
183
|
-
const REVALIDATE_TIMEOUT_MS = Math.max(1, config.revalidateTimeoutMs ?? 30_000)
|
|
184
|
-
|
|
185
|
-
// Only 2xx, cookie-free responses may be cached. Caching a transient
|
|
186
|
-
// 5xx/3xx/404 and replaying it as a 200 for the whole revalidate
|
|
187
|
-
// window is a self-inflicted outage / cache-poisoning bug. Caching a
|
|
188
|
-
// `Set-Cookie` response and replaying it to every visitor leaks one
|
|
189
|
-
// user's session/CSRF cookie cross-user — not covered by the
|
|
190
|
-
// documented "ISR-without-cacheKey is for non-personalized pages"
|
|
191
|
-
// caveat (that caveat is about key variance, not header stripping).
|
|
192
|
-
function isCacheable(res: Response): boolean {
|
|
193
|
-
return res.status >= 200 && res.status < 300 && !res.headers.has('set-cookie')
|
|
194
|
-
}
|
|
195
|
-
// M1.1 — cache-key derivation. Default keys by pathname only (the
|
|
196
|
-
// pre-M1 behaviour). User-supplied `cacheKey` opts in to varying
|
|
197
|
-
// by cookies / query / headers — required for auth-gated pages.
|
|
198
|
-
// See `ISRConfig.cacheKey` JSDoc for the auth-incompatibility caveat.
|
|
199
|
-
const deriveKey: (req: Request, url: URL) => string
|
|
200
|
-
= typeof config.cacheKey === 'function'
|
|
201
|
-
? (req, _url) => (config.cacheKey as (r: Request) => string)(req)
|
|
202
|
-
: (_req, url) => url.pathname
|
|
203
|
-
|
|
204
|
-
async function revalidate(url: URL, originalReq: Request) {
|
|
205
|
-
// Re-derive key from the ORIGINAL request so cookies / headers /
|
|
206
|
-
// query that varied the cache entry are preserved across revalidation.
|
|
207
|
-
// Without this, a user-supplied `cacheKey` that reads cookies would
|
|
208
|
-
// re-render against a no-cookie request and stomp the cached entry
|
|
209
|
-
// with the wrong-user content.
|
|
210
|
-
const key = deriveKey(originalReq, url)
|
|
211
|
-
if (revalidating.has(key)) return
|
|
212
|
-
revalidating.add(key)
|
|
213
|
-
|
|
214
|
-
// Hold the timer id outside try/Promise.race so the finally block
|
|
215
|
-
// can `clearTimeout` it on the SUCCESS path. Pre-fix the rejection
|
|
216
|
-
// setTimeout was left pending until REVALIDATE_TIMEOUT_MS (default
|
|
217
|
-
// 30s) every time `handler(req)` won the race — i.e. every
|
|
218
|
-
// successful revalidation. Under sustained traffic this accumulates
|
|
219
|
-
// hundreds of pending timer closures + rejection callbacks before
|
|
220
|
-
// they self-clear.
|
|
221
|
-
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
|
222
|
-
try {
|
|
223
|
-
// Default: forward the original request shape (headers + method)
|
|
224
|
-
// so the re-render sees the same auth context as the user's read.
|
|
225
|
-
// Opt-in `revalidateRequest` hook lets auth-gated callers scope
|
|
226
|
-
// the revalidation explicitly — e.g. return `null` to skip
|
|
227
|
-
// revalidation for authenticated entries (stale stays stale until
|
|
228
|
-
// the next live request), or return an anonymous Request for
|
|
229
|
-
// non-personalized entries.
|
|
230
|
-
let req: Request
|
|
231
|
-
if (typeof config.revalidateRequest === 'function') {
|
|
232
|
-
const custom = config.revalidateRequest(originalReq)
|
|
233
|
-
if (custom === null) {
|
|
234
|
-
revalidating.delete(key)
|
|
235
|
-
return
|
|
236
|
-
}
|
|
237
|
-
req = custom
|
|
238
|
-
} else {
|
|
239
|
-
req = new Request(url.href, {
|
|
240
|
-
method: 'GET',
|
|
241
|
-
headers: originalReq.headers,
|
|
242
|
-
})
|
|
243
|
-
}
|
|
244
|
-
// Bound the revalidation so a hung handler can't pin `key` in
|
|
245
|
-
// `revalidating` forever (which would freeze the entry stale).
|
|
246
|
-
const res = await Promise.race([
|
|
247
|
-
handler(req),
|
|
248
|
-
new Promise<never>((_, reject) => {
|
|
249
|
-
timeoutId = setTimeout(
|
|
250
|
-
() => reject(new Error('[Pyreon ISR] revalidation timeout')),
|
|
251
|
-
REVALIDATE_TIMEOUT_MS,
|
|
252
|
-
)
|
|
253
|
-
}),
|
|
254
|
-
])
|
|
255
|
-
// Never overwrite a good stale entry with a bad re-render
|
|
256
|
-
// (5xx/3xx) or poison it with a Set-Cookie response.
|
|
257
|
-
if (isCacheable(res)) {
|
|
258
|
-
const html = await res.text()
|
|
259
|
-
const headers: Record<string, string> = {}
|
|
260
|
-
res.headers.forEach((v, k) => {
|
|
261
|
-
headers[k] = v
|
|
262
|
-
})
|
|
263
|
-
await store.set(key, { html, headers, timestamp: Date.now() })
|
|
264
|
-
}
|
|
265
|
-
} catch {
|
|
266
|
-
// Revalidation failed / timed out — stale cache entry remains valid
|
|
267
|
-
} finally {
|
|
268
|
-
if (timeoutId !== undefined) {
|
|
269
|
-
clearTimeout(timeoutId)
|
|
270
|
-
// Leak-class I diagnostic — emit one per cleared timer. Healthy
|
|
271
|
-
// count = revalidation-attempts (every attempt should clear its
|
|
272
|
-
// timer in finally). A LOWER count than revalidations means
|
|
273
|
-
// clearTimeout failed to fire — the orphan-timer leak is back.
|
|
274
|
-
if (__DEV__) _countSink.__pyreon_count__?.('isr.revalidate.timerClear')
|
|
275
|
-
}
|
|
276
|
-
revalidating.delete(key)
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
const fetch = async (req: Request): Promise<Response> => {
|
|
281
|
-
// Only cache GET requests
|
|
282
|
-
if (req.method !== 'GET') {
|
|
283
|
-
return handler(req)
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
const url = new URL(req.url)
|
|
287
|
-
const key = deriveKey(req, url)
|
|
288
|
-
// The store's `get` is responsible for any LRU bookkeeping — the
|
|
289
|
-
// default in-memory store does delete + re-insert so hot paths
|
|
290
|
-
// survive eviction. External stores (Redis/KV) rely on their own
|
|
291
|
-
// TTL/eviction policy and typically just read directly.
|
|
292
|
-
const entry = await store.get(key)
|
|
293
|
-
|
|
294
|
-
if (entry) {
|
|
295
|
-
const age = Date.now() - entry.timestamp
|
|
296
|
-
|
|
297
|
-
if (age > revalidateMs) {
|
|
298
|
-
// Stale — serve cached but revalidate in background
|
|
299
|
-
revalidate(url, req)
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
return new Response(entry.html, {
|
|
303
|
-
status: 200,
|
|
304
|
-
headers: {
|
|
305
|
-
...entry.headers,
|
|
306
|
-
'content-type': 'text/html; charset=utf-8',
|
|
307
|
-
'x-isr-cache': age > revalidateMs ? 'STALE' : 'HIT',
|
|
308
|
-
'x-isr-age': String(Math.round(age / 1000)),
|
|
309
|
-
},
|
|
310
|
-
})
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// Cache miss — render. Only cache (and only normalize to a 200
|
|
314
|
-
// text/html response) when the render is actually cacheable; a
|
|
315
|
-
// transient error / redirect / Set-Cookie response is passed
|
|
316
|
-
// through verbatim with its ORIGINAL status + headers and is NOT
|
|
317
|
-
// stored, so it can't be replayed as a 200 to later visitors.
|
|
318
|
-
const res = await handler(req)
|
|
319
|
-
const html = await res.text()
|
|
320
|
-
const headers: Record<string, string> = {}
|
|
321
|
-
res.headers.forEach((v, k) => {
|
|
322
|
-
headers[k] = v
|
|
323
|
-
})
|
|
324
|
-
|
|
325
|
-
if (!isCacheable(res)) {
|
|
326
|
-
return new Response(html, {
|
|
327
|
-
status: res.status,
|
|
328
|
-
statusText: res.statusText,
|
|
329
|
-
headers: { ...headers, 'x-isr-cache': 'BYPASS' },
|
|
330
|
-
})
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
await store.set(key, { html, headers, timestamp: Date.now() })
|
|
334
|
-
|
|
335
|
-
return new Response(html, {
|
|
336
|
-
status: 200,
|
|
337
|
-
headers: {
|
|
338
|
-
...headers,
|
|
339
|
-
'content-type': 'text/html; charset=utf-8',
|
|
340
|
-
'x-isr-cache': 'MISS',
|
|
341
|
-
},
|
|
342
|
-
})
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
// Attach the imperative invalidation methods. The result is a
|
|
346
|
-
// callable (still works with `Bun.serve({ fetch: handler })`) plus
|
|
347
|
-
// `.revalidateNow(key)` and `.revalidateAll()` for webhook-driven
|
|
348
|
-
// cache busting.
|
|
349
|
-
const isrHandler = fetch as ISRHandler
|
|
350
|
-
|
|
351
|
-
isrHandler.revalidateNow = async (key: string) => {
|
|
352
|
-
// Get-then-delete so we can return an accurate `dropped` flag. The
|
|
353
|
-
// store's `delete` doesn't return whether anything existed, so the
|
|
354
|
-
// precheck is the only way to distinguish "actually dropped" from
|
|
355
|
-
// "no-op against missing key". For the default in-memory store the
|
|
356
|
-
// get is O(1); external stores pay one extra round-trip (acceptable
|
|
357
|
-
// for an invalidation API that fires on CMS webhooks, not on the
|
|
358
|
-
// hot path).
|
|
359
|
-
//
|
|
360
|
-
// `dropped: true` is only returned when (a) the entry existed AND
|
|
361
|
-
// (b) the store actually supported delete. A store without
|
|
362
|
-
// `delete?` returns `dropped: false` even if the entry existed —
|
|
363
|
-
// the caller's intent (make this key MISS next time) wasn't
|
|
364
|
-
// honored, so the honest answer is "no". Such stores rely on TTL
|
|
365
|
-
// for eviction.
|
|
366
|
-
const existed = (await store.get(key)) !== undefined
|
|
367
|
-
let dropped = false
|
|
368
|
-
if (existed && store.delete) {
|
|
369
|
-
await store.delete(key)
|
|
370
|
-
dropped = true
|
|
371
|
-
}
|
|
372
|
-
// Always clear the in-flight revalidation flag so the next request
|
|
373
|
-
// re-renders fresh rather than short-circuiting on the
|
|
374
|
-
// `revalidating.has(key)` guard — regardless of whether we
|
|
375
|
-
// physically dropped the entry.
|
|
376
|
-
revalidating.delete(key)
|
|
377
|
-
return { dropped }
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
isrHandler.revalidateAll = async () => {
|
|
381
|
-
if (!store.clear) {
|
|
382
|
-
throw new Error(
|
|
383
|
-
'[Pyreon ISR] revalidateAll() called against a store that does not implement `clear()`. The default in-memory store supports this; external stores (Redis/KV/etc.) must opt in by implementing `ISRStore.clear()`.',
|
|
384
|
-
)
|
|
385
|
-
}
|
|
386
|
-
await store.clear()
|
|
387
|
-
// Drop every in-flight revalidation flag — fresh requests should
|
|
388
|
-
// re-render rather than waiting on stale resolve callbacks pointing
|
|
389
|
-
// at entries we just purged.
|
|
390
|
-
revalidating.clear()
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
return isrHandler
|
|
394
|
-
}
|
package/src/link.tsx
DELETED
|
@@ -1,304 +0,0 @@
|
|
|
1
|
-
import { createRef } from '@pyreon/core'
|
|
2
|
-
import { useRouter } from '@pyreon/router'
|
|
3
|
-
import { useIntersectionObserver } from './utils/use-intersection-observer'
|
|
4
|
-
|
|
5
|
-
// ─── Link component with prefetching ────────────────────────────────────────
|
|
6
|
-
//
|
|
7
|
-
// Provides client-side navigation, prefetching, and active state tracking.
|
|
8
|
-
// Three levels of API:
|
|
9
|
-
//
|
|
10
|
-
// 1. useLink(props) — composable returning handlers, state, and ref callback
|
|
11
|
-
// 2. createLink(Comp) — HOC wrapping any component with link behavior
|
|
12
|
-
// 3. Link — default <a>-based link (built on createLink)
|
|
13
|
-
|
|
14
|
-
export interface LinkProps {
|
|
15
|
-
/** Target URL path. */
|
|
16
|
-
href: string
|
|
17
|
-
/** Link content. */
|
|
18
|
-
children?: any
|
|
19
|
-
/** CSS class name. */
|
|
20
|
-
class?: string
|
|
21
|
-
/** Class applied when this link matches the current route. */
|
|
22
|
-
activeClass?: string
|
|
23
|
-
/** Class applied when this link exactly matches the current route. */
|
|
24
|
-
exactActiveClass?: string
|
|
25
|
-
/** Prefetch strategy. Default: "hover" */
|
|
26
|
-
prefetch?: 'hover' | 'viewport' | 'none'
|
|
27
|
-
/** Open in new tab. */
|
|
28
|
-
external?: boolean
|
|
29
|
-
/** Inline styles. */
|
|
30
|
-
style?: string
|
|
31
|
-
/** ARIA label. */
|
|
32
|
-
'aria-label'?: string
|
|
33
|
-
/** Additional click handler — called before navigation. Call e.preventDefault() to cancel. */
|
|
34
|
-
onClick?: ((e: MouseEvent) => void) | undefined
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Props passed to a custom component via createLink. */
|
|
38
|
-
export interface LinkRenderProps {
|
|
39
|
-
href: string
|
|
40
|
-
ref: import('@pyreon/core').Ref<HTMLAnchorElement>
|
|
41
|
-
onClick: (e: MouseEvent) => void
|
|
42
|
-
onMouseEnter: () => void
|
|
43
|
-
onTouchStart: () => void
|
|
44
|
-
isActive: () => boolean
|
|
45
|
-
isExactActive: () => boolean
|
|
46
|
-
/** Reactive class string — pass directly to element for auto-updates on route change. */
|
|
47
|
-
class: (() => string) | string | undefined
|
|
48
|
-
style?: string
|
|
49
|
-
target?: string
|
|
50
|
-
rel?: string
|
|
51
|
-
'aria-label'?: string
|
|
52
|
-
children?: any
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** Return type of useLink. */
|
|
56
|
-
export interface UseLinkReturn {
|
|
57
|
-
/** Ref object — attach to the root element for viewport-based prefetch. */
|
|
58
|
-
ref: import('@pyreon/core').Ref<HTMLAnchorElement>
|
|
59
|
-
/** Click handler — performs client-side navigation. */
|
|
60
|
-
handleClick: (e: MouseEvent) => void
|
|
61
|
-
/** Mouse enter handler — triggers hover prefetch. */
|
|
62
|
-
handleMouseEnter: () => void
|
|
63
|
-
/** Touch start handler — triggers prefetch on mobile. */
|
|
64
|
-
handleTouchStart: () => void
|
|
65
|
-
/** Whether the link partially matches the current route. */
|
|
66
|
-
isActive: () => boolean
|
|
67
|
-
/** Whether the link exactly matches the current route. */
|
|
68
|
-
isExactActive: () => boolean
|
|
69
|
-
/** Resolved class string including active classes. */
|
|
70
|
-
classes: () => string
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const MAX_PREFETCH_CACHE = 200
|
|
74
|
-
// Maps href → list of <link> elements injected into <head>. When the
|
|
75
|
-
// cache evicts an href (FIFO at MAX_PREFETCH_CACHE), the matching <link>
|
|
76
|
-
// elements must be removed too — otherwise head bloats unboundedly
|
|
77
|
-
// across long SPA sessions (every Link interaction added 2 <link> nodes
|
|
78
|
-
// with no cleanup).
|
|
79
|
-
const prefetched = new Map<string, Element[]>()
|
|
80
|
-
|
|
81
|
-
function doPrefetch(href: string) {
|
|
82
|
-
// Prefetch only fires from browser-mounted Link interactions (hover /
|
|
83
|
-
// click intent). Explicit guard documents the SSR-safety contract.
|
|
84
|
-
if (typeof document === 'undefined') return
|
|
85
|
-
if (prefetched.has(href)) return
|
|
86
|
-
// Evict oldest entries when cache is full — AND remove their DOM nodes.
|
|
87
|
-
if (prefetched.size >= MAX_PREFETCH_CACHE) {
|
|
88
|
-
const firstEntry = prefetched.entries().next().value
|
|
89
|
-
if (firstEntry) {
|
|
90
|
-
const [oldestHref, oldestLinks] = firstEntry
|
|
91
|
-
for (const link of oldestLinks) link.remove()
|
|
92
|
-
prefetched.delete(oldestHref)
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const injected: Element[] = []
|
|
97
|
-
const docLink = document.createElement('link')
|
|
98
|
-
docLink.rel = 'prefetch'
|
|
99
|
-
docLink.href = href
|
|
100
|
-
docLink.as = 'document'
|
|
101
|
-
document.head.appendChild(docLink)
|
|
102
|
-
injected.push(docLink)
|
|
103
|
-
|
|
104
|
-
try {
|
|
105
|
-
const chunkHint = document.createElement('link')
|
|
106
|
-
chunkHint.rel = 'modulepreload'
|
|
107
|
-
chunkHint.href = href
|
|
108
|
-
document.head.appendChild(chunkHint)
|
|
109
|
-
injected.push(chunkHint)
|
|
110
|
-
} catch {
|
|
111
|
-
// modulepreload is a hint, not critical
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
prefetched.set(href, injected)
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Prefetch a route's JS chunk by injecting `<link rel="prefetch">` into the
|
|
119
|
-
* document head. Deduplicates — calling with the same href twice is a no-op.
|
|
120
|
-
*
|
|
121
|
-
* @example
|
|
122
|
-
* prefetchRoute('/about')
|
|
123
|
-
* prefetchRoute('/dashboard')
|
|
124
|
-
*/
|
|
125
|
-
export function prefetchRoute(href: string): void {
|
|
126
|
-
doPrefetch(href)
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Composable that provides all link behavior — navigation, prefetching,
|
|
131
|
-
* active state, and viewport observation.
|
|
132
|
-
*
|
|
133
|
-
* Use this for full control when `createLink` is too opinionated.
|
|
134
|
-
*
|
|
135
|
-
* @example
|
|
136
|
-
* function MyLink(props: LinkProps) {
|
|
137
|
-
* const link = useLink(props)
|
|
138
|
-
* return (
|
|
139
|
-
* <button ref={link.ref} class={link.classes()} onClick={link.handleClick}>
|
|
140
|
-
* {props.children}
|
|
141
|
-
* </button>
|
|
142
|
-
* )
|
|
143
|
-
* }
|
|
144
|
-
*/
|
|
145
|
-
export function useLink(props: LinkProps): UseLinkReturn {
|
|
146
|
-
const router = useRouter()
|
|
147
|
-
const elementRef = createRef<HTMLAnchorElement>()
|
|
148
|
-
const strategy = props.prefetch ?? 'hover'
|
|
149
|
-
|
|
150
|
-
function handleClick(e: MouseEvent) {
|
|
151
|
-
// Call user's onClick first — they may call e.preventDefault()
|
|
152
|
-
if (props.onClick) {
|
|
153
|
-
;(props.onClick as (e: MouseEvent) => void)(e)
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
if (
|
|
157
|
-
e.defaultPrevented ||
|
|
158
|
-
e.button !== 0 ||
|
|
159
|
-
e.metaKey ||
|
|
160
|
-
e.ctrlKey ||
|
|
161
|
-
e.shiftKey ||
|
|
162
|
-
e.altKey ||
|
|
163
|
-
props.external ||
|
|
164
|
-
!props.href
|
|
165
|
-
) {
|
|
166
|
-
return
|
|
167
|
-
}
|
|
168
|
-
e.preventDefault()
|
|
169
|
-
router.push(props.href)
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function handleMouseEnter() {
|
|
173
|
-
if (strategy === 'hover') {
|
|
174
|
-
doPrefetch(props.href)
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function handleTouchStart() {
|
|
179
|
-
if (strategy === 'hover' || strategy === 'viewport') {
|
|
180
|
-
doPrefetch(props.href)
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
if (strategy === 'viewport') {
|
|
185
|
-
useIntersectionObserver(
|
|
186
|
-
() => elementRef.current ?? undefined,
|
|
187
|
-
() => doPrefetch(props.href),
|
|
188
|
-
)
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
const isActive = () => {
|
|
192
|
-
const currentPath = router.currentRoute()?.path
|
|
193
|
-
if (!currentPath || !props.href) return false
|
|
194
|
-
if (props.href === '/') return currentPath === '/'
|
|
195
|
-
return currentPath.startsWith(props.href)
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
const isExactActive = () => {
|
|
199
|
-
const currentPath = router.currentRoute()?.path
|
|
200
|
-
if (!currentPath) return false
|
|
201
|
-
return currentPath === props.href
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
const classes = () => {
|
|
205
|
-
const cls: string[] = []
|
|
206
|
-
if (props.class) cls.push(props.class)
|
|
207
|
-
if (props.activeClass && isActive()) cls.push(props.activeClass)
|
|
208
|
-
if (props.exactActiveClass && isExactActive()) cls.push(props.exactActiveClass)
|
|
209
|
-
return cls.join(' ')
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
return {
|
|
213
|
-
ref: elementRef,
|
|
214
|
-
handleClick,
|
|
215
|
-
handleMouseEnter,
|
|
216
|
-
handleTouchStart,
|
|
217
|
-
isActive,
|
|
218
|
-
isExactActive,
|
|
219
|
-
classes,
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* Higher-order component that wraps any component with link behavior.
|
|
225
|
-
*
|
|
226
|
-
* The wrapped component receives {@link LinkRenderProps} with all handlers,
|
|
227
|
-
* active state, and accessibility attributes pre-wired.
|
|
228
|
-
*
|
|
229
|
-
* @example
|
|
230
|
-
* // Custom button link
|
|
231
|
-
* const ButtonLink = createLink((props) => (
|
|
232
|
-
* <button
|
|
233
|
-
* ref={props.ref}
|
|
234
|
-
* class={props.class}
|
|
235
|
-
* onClick={props.onClick}
|
|
236
|
-
* onMouseEnter={props.onMouseEnter}
|
|
237
|
-
* >
|
|
238
|
-
* {props.children}
|
|
239
|
-
* </button>
|
|
240
|
-
* ))
|
|
241
|
-
*
|
|
242
|
-
* // Custom styled component
|
|
243
|
-
* const CardLink = createLink((props) => (
|
|
244
|
-
* <div
|
|
245
|
-
* ref={props.ref}
|
|
246
|
-
* class={`card ${props.isActive() ? "card--active" : ""}`}
|
|
247
|
-
* onClick={props.onClick}
|
|
248
|
-
* onMouseEnter={props.onMouseEnter}
|
|
249
|
-
* >
|
|
250
|
-
* {props.children}
|
|
251
|
-
* </div>
|
|
252
|
-
* ))
|
|
253
|
-
*
|
|
254
|
-
* // Usage
|
|
255
|
-
* <ButtonLink href="/about">About</ButtonLink>
|
|
256
|
-
* <CardLink href="/posts" prefetch="viewport">Posts</CardLink>
|
|
257
|
-
*/
|
|
258
|
-
export function createLink(Component: (props: LinkRenderProps) => any): (props: LinkProps) => any {
|
|
259
|
-
return function WrappedLink(props: LinkProps) {
|
|
260
|
-
const link = useLink(props)
|
|
261
|
-
|
|
262
|
-
return (
|
|
263
|
-
<Component
|
|
264
|
-
href={props.href}
|
|
265
|
-
ref={link.ref}
|
|
266
|
-
onClick={link.handleClick}
|
|
267
|
-
onMouseEnter={link.handleMouseEnter}
|
|
268
|
-
onTouchStart={link.handleTouchStart}
|
|
269
|
-
isActive={link.isActive}
|
|
270
|
-
isExactActive={link.isExactActive}
|
|
271
|
-
class={link.classes}
|
|
272
|
-
{...(props.style ? { style: props.style } : {})}
|
|
273
|
-
{...(props.external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
|
274
|
-
{...(props['aria-label'] ? { 'aria-label': props['aria-label'] } : {})}
|
|
275
|
-
children={props.children}
|
|
276
|
-
/>
|
|
277
|
-
)
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* Default navigation link built on an `<a>` tag.
|
|
283
|
-
*
|
|
284
|
-
* @example
|
|
285
|
-
* <Link href="/about" prefetch="viewport">About</Link>
|
|
286
|
-
* <Link href="/posts" activeClass="nav-active">Posts</Link>
|
|
287
|
-
*/
|
|
288
|
-
export const Link = createLink((props: LinkRenderProps) => (
|
|
289
|
-
<a
|
|
290
|
-
ref={props.ref}
|
|
291
|
-
href={props.href}
|
|
292
|
-
{...(props.class ? { class: props.class } : {})}
|
|
293
|
-
{...(props.style ? { style: props.style } : {})}
|
|
294
|
-
{...(props.target ? { target: props.target } : {})}
|
|
295
|
-
{...(props.rel ? { rel: props.rel } : {})}
|
|
296
|
-
{...(props['aria-label'] ? { 'aria-label': props['aria-label'] } : {})}
|
|
297
|
-
{...(props.isExactActive() ? { 'aria-current': 'page' as const } : {})}
|
|
298
|
-
onClick={props.onClick}
|
|
299
|
-
onMouseEnter={props.onMouseEnter}
|
|
300
|
-
onTouchStart={props.onTouchStart}
|
|
301
|
-
>
|
|
302
|
-
{props.children}
|
|
303
|
-
</a>
|
|
304
|
-
))
|