@void/isr 0.20.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/LICENSE +21 -0
- package/README.md +89 -0
- package/dist/core.d.mts +81 -0
- package/dist/core.mjs +200 -0
- package/dist/index.d.mts +137 -0
- package/dist/index.mjs +265 -0
- package/dist/runtime.d.mts +145 -0
- package/dist/runtime.mjs +378 -0
- package/package.json +43 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026-present, Cloudflare, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# @void/isr
|
|
2
|
+
|
|
3
|
+
Shared Incremental Static Regeneration contracts for Void runtimes and deployment targets.
|
|
4
|
+
|
|
5
|
+
The package is public so the Void SDK, the hosted platform, and deployment adapters can consume the same cache-key and edge-cache URL protocol directly. Applications normally use `void/isr`; this lower-level package is intended for runtime and adapter authors.
|
|
6
|
+
|
|
7
|
+
## Purpose
|
|
8
|
+
|
|
9
|
+
When a request is served via a dispatch-side rewrite (`routing.rewrites` or `routing.fallbacks`), the ISR slot splits per original-request URL via the `#rw=` variant suffix — so user code that branches on `c.isRewritten()` / `c.originalUrl()` doesn't share a cache entry with the direct-hit path to the same destination.
|
|
10
|
+
|
|
11
|
+
ISR cache entries are written by `platform/packages/dispatch` and purged by `platform/packages/api`. The two workers must produce byte-identical keys and URLs — if the purger and the writer disagree on a single character, purges silently miss and stale HTML survives revalidation. This package centralizes every helper that constructs an ISR KV key or an edge-cache URL so the two sides cannot drift.
|
|
12
|
+
|
|
13
|
+
Any new helper that touches the key or URL format belongs here, not in `api/` or `dispatch/`. Duplication is the failure mode this package exists to prevent.
|
|
14
|
+
|
|
15
|
+
## Public API
|
|
16
|
+
|
|
17
|
+
The package has three entry points:
|
|
18
|
+
|
|
19
|
+
- `@void/isr` owns the immutable, versioned KV-key and edge-cache URL protocol.
|
|
20
|
+
- `@void/isr/core` owns target-neutral codecs, validation, TTL selection, request bypass rules, response construction, header sanitization, Void Pages data extraction, and query-variant normalization.
|
|
21
|
+
- `@void/isr/runtime` implements the fail-open, single-tenant Cloudflare KV and Cache API ladder used by direct Void deployments, including bounded variants from `routing.revalidateQueryAllowlist`.
|
|
22
|
+
|
|
23
|
+
The direct runtime applies ISR only to public page requests. API paths, credentialed requests, Range requests, and EventSource requests render live. A rendered response is shared only when it is a complete `200` response without `Cache-Control: private`, `no-store`, or `no-cache`, and without `Set-Cookie`. When a background revalidation returns one of those opt-outs, the runtime removes the old HTML, Pages JSON, and KV variants so previously public content cannot remain reachable.
|
|
24
|
+
|
|
25
|
+
| Symbol | Description |
|
|
26
|
+
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
|
27
|
+
| `isrCacheKey` | Build a full ISR KV key, optionally including a rewrite-variant suffix derived from the original request URL. |
|
|
28
|
+
| `isrCachePrefix` | Prefix for listing/purging ISR keys by deployment (or all deployments when `deploymentId` is `undefined`). |
|
|
29
|
+
| `isrCacheVariantPrefix` | Prefix that matches every rewrite variant of a given pathname under a deployment (for listing/purging). |
|
|
30
|
+
| `isrLegacyCachePrefix` | `v2` (hostless) counterpart of `isrCachePrefix`. Transition-only — purge path sweeps the pre-host-scoping namespace. |
|
|
31
|
+
| `isrLegacyCacheKey` | `v2` (hostless) direct-hit key for a pathname. Transition-only — purge path reaps the orphaned pre-host-scoping key. |
|
|
32
|
+
| `isrLegacyCacheVariantPrefix` | `v2` (hostless) counterpart of `isrCacheVariantPrefix`. Transition-only — purge path recovers pre-host-scoping variants. |
|
|
33
|
+
| `splitIsrCacheKey` | Split a full key into `{ normalizedPath, variantToken }` — `variantToken` is `null` for direct-hit (non-rewrite) keys. |
|
|
34
|
+
| `denormalizeIsrPath` | Turn a normalized path segment back into a pathname starting with `/`. Paired with `splitIsrCacheKey` on purge. |
|
|
35
|
+
| `isrEdgeCacheUrl` | Build the internal edge-cache URL used as the `caches.default` key. Takes the original URL; hashes it internally. |
|
|
36
|
+
| `isrEdgeCacheUrlForToken` | Same shape, but takes a pre-hashed variant token (the form returned by `splitIsrCacheKey`). Used by the purge path. |
|
|
37
|
+
| `hashOriginalUrl` | Sync FNV-1a 64-bit hex hash of the original URL. Exposed for callers that need to compute the token outside the helpers. |
|
|
38
|
+
|
|
39
|
+
The legacy (`v2`) sweep helpers `isrLegacyCachePrefix`, `isrLegacyCacheKey`, and `isrLegacyCacheVariantPrefix` are exported for the API purge path only — they target the pre-host-scoping `v2` namespace during the host-scoping rollout. Dispatch never uses them.
|
|
40
|
+
|
|
41
|
+
Path normalization (`normalizeIsrPath` / `canonicalIsrPathname`), the version prefixes (`isrCacheVersionPrefix` / `isrLegacyCacheVersionPrefix`), and the version constants (`ISR_CACHE_KEY_VERSION` / `ISR_LEGACY_CACHE_KEY_VERSION`) are package-internal. Consumers work through the key-building helpers above so the format contract lives in one place; add a new exported helper rather than reaching for an internal if a call site needs one.
|
|
42
|
+
|
|
43
|
+
## Key Format
|
|
44
|
+
|
|
45
|
+
The current version is `v3` (host-scoped):
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
<projectId>/isr/v3/<deploymentId>/<host>/<normalizedPath>[#rw=<variantTokenHex>]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The legacy `v2` namespace is hostless (still swept by the purger during the rollout, never written or read by dispatch):
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
<projectId>/isr/v2/<deploymentId>/<normalizedPath>[#rw=<variantTokenHex>]
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
- `<projectId>/isr/v3/` — namespacing plus the current format version. The version prefix exists so a format change can coexist with stale entries from the previous version; bump `ISR_CACHE_KEY_VERSION` rather than reusing the current prefix.
|
|
58
|
+
- `v1`→`v2` added the rewrite-variant (`#rw=`) suffix.
|
|
59
|
+
- `v2`→`v3` added the `<host>` segment (host-scoping). This bump was **mandatory**, not cosmetic: under a single version a host-scoped key `<D>/<host>/<path>` and a legacy hostless key `<D>/<path>` are byte-identical whenever the hostless path's leading segment looks like a hostname. Dotted leading path segments are legal (a route param `[domain]` compiles to `:domain` = `[^/]+`, and `[...slug]` to `(.+)`), so the hostless page `/example.com/about` produces the key `<D>/example.com/about` — exactly the host-scoped key for `(host=example.com, /about)`. Encoding the shape in the version removes the ambiguity: `v3` keys are always host-scoped, `v2` keys always hostless, so the version segment alone classifies the key — no runtime heuristic needed. Do not remove the version prefix as "dead weight" — that silently breaks cache compatibility the next time the format evolves.
|
|
60
|
+
- Every KV entry carries `{s,c,t,p}` metadata. `p` is the shared-response privacy-policy version. Readers reject entries without the current value because older writers stripped private headers before storage, leaving no reliable way to prove their bodies safe to share.
|
|
61
|
+
- `<deploymentId>` — scopes each entry to the deployment that produced it. Cached HTML embeds hashed asset URLs from a specific deployment; reading it after a new deploy ships would serve stale HTML pointing at assets that no longer exist. Scoping by deployment makes that structurally impossible.
|
|
62
|
+
- `<host>` — the request hostname dispatch saw (`new URL(request.url).hostname`). The same path served under a project slug and a custom domain (or two custom domains) never shares a cache slot. `v3` only; absent in `v2`.
|
|
63
|
+
- `<normalizedPath>` — the pathname passed through `normalizeIsrPath`. Root `/` becomes `__index__`; other paths have leading and trailing slashes stripped.
|
|
64
|
+
- `#rw=<token>` — optional rewrite-variant suffix. When a request was served via a dispatch-phase rewrite (`routing.rewrites`, `routing.fallbacks`, or a `_redirects` `200`/`200!` entry), the ISR slot splits per original request URL so direct hits and rewrite-served hits do not share cache entries (user code can branch on `c.isRewritten()` / `c.originalUrl()`). `#` is used as the separator because it cannot appear inside `url.pathname` — the browser strips fragments before the request reaches the edge — and it never survives `normalizeIsrPath`, so the suffix is unambiguous.
|
|
65
|
+
|
|
66
|
+
## Variant Tokens
|
|
67
|
+
|
|
68
|
+
Variant tokens are a fixed-length hex hash (FNV-1a 64-bit, 16 chars) of the UTF-8 bytes of the original request URL. The same hash is used as the KV-key suffix and as the `__void_orig=<token>` query value on the internal edge-cache URL, so the writer (dispatch) and the purger (api) compute byte-identical tokens for the same URL.
|
|
69
|
+
|
|
70
|
+
Hashing — rather than reversibly encoding the URL — is deliberate:
|
|
71
|
+
|
|
72
|
+
- **KV is operator-readable.** A reversible token (the prior `btoa` encoding) let any operator with KV list access read the original URLs, including sensitive query strings (`?token=…`, `?email=…`).
|
|
73
|
+
- **Variant tokens are KV-key suffixes.** A fixed-length hash bounds the total key size regardless of how long the original URL was, so KV's name-length limit can't be approached by any single request.
|
|
74
|
+
|
|
75
|
+
The hash is sync (BigInt FNV-1a; Workers' `crypto.subtle.digest` is async and we don't want to push async through every read/write). Collision resistance is ~2^32 (birthday) — fine for the per-deployment, per-pathname variant set we ever store; the per-pathname variant cap (see `isr` issue #6) keeps that population well under the threshold.
|
|
76
|
+
|
|
77
|
+
Because the original URL is not recoverable from a variant token, `splitIsrCacheKey` returns the token directly (as `variantToken`), and the purger calls `isrEdgeCacheUrlForToken` rather than re-hashing.
|
|
78
|
+
|
|
79
|
+
## Monorepo Consumers
|
|
80
|
+
|
|
81
|
+
- `platform/packages/api/src/isr-cache.ts` — hosted purge path. Uses `splitIsrCacheKey` to decode variant KV entries and `isrEdgeCacheUrl` to reconstruct the matching edge-cache URL.
|
|
82
|
+
- `platform/packages/dispatch/src/isr.ts` — hosted read/write path. Uses `isrCacheKey` and `isrEdgeCacheUrl` to resolve the slot for every ISR-backed page request.
|
|
83
|
+
- `packages/void` — direct Cloudflare runtime and CLI integration.
|
|
84
|
+
|
|
85
|
+
## Rules for Adding Helpers
|
|
86
|
+
|
|
87
|
+
1. Any new helper that touches ISR key or URL format MUST live in this package. Do not inline a new format in `api/` or `dispatch/`.
|
|
88
|
+
2. Any incompatible change to the format (different prefix, different separators, different encoding, or a new key segment like `<host>`) requires bumping `ISR_CACHE_KEY_VERSION`. Writers after the bump must produce keys under the new version; the old keys expire naturally as their deployments age out. Do not reuse a version number. The `v2`→`v3` (host-scoping) bump is the cautionary example: shipping host-scoping WITHOUT the bump let a `<host>/<path>` key alias a legacy hostless `<path>` key (see Key Format), so the purger's legacy sweep is written against the `v2` namespace explicitly rather than trusting a runtime heuristic to tell the shapes apart.
|
|
89
|
+
3. Additive changes that extend an existing key suffix (new query parameters on the edge URL, new optional suffix segments on the KV key) are safe without a version bump as long as readers at both versions treat the absence of the new component as the previous default.
|
package/dist/core.d.mts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
//#region src/core.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Bump when a cached response accepted by an older writer can no longer be
|
|
4
|
+
* proven safe to share. Readers reject entries from earlier policies instead
|
|
5
|
+
* of replaying opaque bodies whose private headers were stripped at write time.
|
|
6
|
+
*/
|
|
7
|
+
export declare const SHARED_CACHE_POLICY_VERSION: 1;
|
|
8
|
+
export declare const SHARED_CACHE_POLICY_HEADER = "x-void-shared-cache-policy";
|
|
9
|
+
/** Stamp an internal edge-cache representation with the policy that admitted it. */
|
|
10
|
+
export declare function markSharedCachePolicy(headers: Headers): void;
|
|
11
|
+
/** Reject opaque edge entries admitted before the current privacy policy. */
|
|
12
|
+
export declare function hasCurrentSharedCachePolicy(response: Response): boolean;
|
|
13
|
+
export type IsrCacheMetadata = Readonly<{
|
|
14
|
+
/** HTTP status code. */
|
|
15
|
+
s: number;
|
|
16
|
+
/** Creation time in milliseconds since the Unix epoch. */
|
|
17
|
+
c: number;
|
|
18
|
+
/** Freshness lifetime in seconds. */
|
|
19
|
+
t: number;
|
|
20
|
+
/** Shared-response privacy policy version. */
|
|
21
|
+
p: typeof SHARED_CACHE_POLICY_VERSION;
|
|
22
|
+
}>;
|
|
23
|
+
export type IsrCacheEntry = Readonly<{
|
|
24
|
+
body: ReadableStream;
|
|
25
|
+
status: number;
|
|
26
|
+
headers: Record<string, string>;
|
|
27
|
+
createdAt: number;
|
|
28
|
+
ttl: number;
|
|
29
|
+
}>;
|
|
30
|
+
/** Map of literal path pattern to the query names retained in an ISR cache variant. */
|
|
31
|
+
export type QueryAllowlist = Record<string, ReadonlyArray<string>>;
|
|
32
|
+
/**
|
|
33
|
+
* Normalize a request URL for cache-key use by retaining only query parameters
|
|
34
|
+
* explicitly allowlisted for its user-facing pathname.
|
|
35
|
+
*/
|
|
36
|
+
export declare function buildVariantOriginalUrl(requestUrl: string, pathname: string, queryAllowlist: QueryAllowlist | undefined): string;
|
|
37
|
+
/** Keep only exact allowlist patterns for uncapped static-cache consumers. */
|
|
38
|
+
export declare function exactQueryAllowlist(allowlist: QueryAllowlist | undefined): QueryAllowlist | undefined;
|
|
39
|
+
/** Pack response headers and body into the canonical ISR binary value. */
|
|
40
|
+
export declare function packIsrValue(headers: Record<string, string>, body: ArrayBuffer): ArrayBuffer;
|
|
41
|
+
/** Unpack a canonical ISR binary value, returning null for malformed bytes. */
|
|
42
|
+
export declare function unpackIsrValue(buffer: ArrayBuffer): {
|
|
43
|
+
headers: Record<string, string>;
|
|
44
|
+
body: ArrayBuffer;
|
|
45
|
+
} | null;
|
|
46
|
+
/** Decode and validate a KV value plus metadata into a safe cache entry. */
|
|
47
|
+
export declare function decodeIsrCacheEntry(value: ArrayBuffer | null, metadata: IsrCacheMetadata | null): IsrCacheEntry | null;
|
|
48
|
+
/** Check whether an ISR cache entry has passed its freshness lifetime. */
|
|
49
|
+
export declare function isStale(entry: IsrCacheEntry, now?: number): boolean;
|
|
50
|
+
/** Build an edge-cacheable response from a validated ISR cache entry. */
|
|
51
|
+
export declare function isrCacheResponse(entry: IsrCacheEntry, stale: boolean, now?: number): Response;
|
|
52
|
+
/** Resolve an x-revalidate override, falling back for absent or invalid values. */
|
|
53
|
+
export declare function resolveRevalidateTtl(response: Response, defaultTtl: number): number;
|
|
54
|
+
/** Return whether credentials or EventSource semantics require an ISR bypass. */
|
|
55
|
+
export declare function shouldBypassIsr(request: Request): boolean;
|
|
56
|
+
/** Return whether a response explicitly forbids storage in a shared cache. */
|
|
57
|
+
export declare function hasSharedCacheOptOut(response: Response): boolean;
|
|
58
|
+
/** Return whether a complete render may be persisted in the shared ISR cache. */
|
|
59
|
+
export declare function isSharedCacheableResponse(response: Response): boolean;
|
|
60
|
+
/** Resolve the first matching path TTL using Void route-pattern semantics. */
|
|
61
|
+
export declare function resolvePathTtl(revalidate: number | Record<string, number> | undefined, pathname: string): number;
|
|
62
|
+
/** One ordered page-route entry in the shared ISR TTL policy. */
|
|
63
|
+
export type IsrPageTtl = Readonly<{
|
|
64
|
+
pattern: string;
|
|
65
|
+
revalidate?: number;
|
|
66
|
+
}>;
|
|
67
|
+
/**
|
|
68
|
+
* Resolve ISR TTL from the framework's ordered page table.
|
|
69
|
+
*
|
|
70
|
+
* The first matching page is authoritative. A baked positive TTL enables ISR,
|
|
71
|
+
* a baked `0` opts out, and an absent TTL inherits from the raw per-path config.
|
|
72
|
+
* Only requests that match no page fall back to the flattened revalidate config.
|
|
73
|
+
*/
|
|
74
|
+
export declare function resolvePageTtl(pages: ReadonlyArray<IsrPageTtl> | undefined, revalidate: number | Record<string, number> | undefined, pathRevalidate: Record<string, number> | undefined, pathname: string): number;
|
|
75
|
+
/** Extract response headers while removing ISR-managed and private headers. */
|
|
76
|
+
export declare function serializeHeaders(response: Response): Record<string, string>;
|
|
77
|
+
/** Validate page data before storing it as the JSON ISR variant. */
|
|
78
|
+
export declare function normalizePageDataForJsonCache(raw: string): string | null;
|
|
79
|
+
/** Extract validated Void Pages data embedded in an HTML response. */
|
|
80
|
+
export declare function extractPageDataFromHtml(html: string): string | null;
|
|
81
|
+
//#endregion
|
package/dist/core.mjs
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { matchLiteralPattern, matchSegmentPattern } from "@void/edge";
|
|
2
|
+
//#region src/core.ts
|
|
3
|
+
/**
|
|
4
|
+
* Bump when a cached response accepted by an older writer can no longer be
|
|
5
|
+
* proven safe to share. Readers reject entries from earlier policies instead
|
|
6
|
+
* of replaying opaque bodies whose private headers were stripped at write time.
|
|
7
|
+
*/
|
|
8
|
+
const SHARED_CACHE_POLICY_VERSION = 1;
|
|
9
|
+
const SHARED_CACHE_POLICY_HEADER = "x-void-shared-cache-policy";
|
|
10
|
+
/** Stamp an internal edge-cache representation with the policy that admitted it. */
|
|
11
|
+
function markSharedCachePolicy(headers) {
|
|
12
|
+
headers.set(SHARED_CACHE_POLICY_HEADER, String(1));
|
|
13
|
+
}
|
|
14
|
+
/** Reject opaque edge entries admitted before the current privacy policy. */
|
|
15
|
+
function hasCurrentSharedCachePolicy(response) {
|
|
16
|
+
return response.headers.get(SHARED_CACHE_POLICY_HEADER) === String(1);
|
|
17
|
+
}
|
|
18
|
+
const PAGE_DATA_SCRIPT_RE = /<script\b[^>]*\bid=(["'])__VOID_PAGE_DATA__\1[^>]*>([\s\S]*?)<\/script>/i;
|
|
19
|
+
const SKIP_HEADERS = /* @__PURE__ */ new Set([
|
|
20
|
+
"cache-control",
|
|
21
|
+
"x-isr-cache",
|
|
22
|
+
"x-void-page-data",
|
|
23
|
+
"set-cookie"
|
|
24
|
+
]);
|
|
25
|
+
/**
|
|
26
|
+
* Normalize a request URL for cache-key use by retaining only query parameters
|
|
27
|
+
* explicitly allowlisted for its user-facing pathname.
|
|
28
|
+
*/
|
|
29
|
+
function buildVariantOriginalUrl(requestUrl, pathname, queryAllowlist) {
|
|
30
|
+
const url = new URL(requestUrl);
|
|
31
|
+
if (!url.search) return url.toString();
|
|
32
|
+
const allowed = resolveQueryAllowlist(queryAllowlist, pathname);
|
|
33
|
+
if (allowed.size === 0) {
|
|
34
|
+
url.search = "";
|
|
35
|
+
return url.toString();
|
|
36
|
+
}
|
|
37
|
+
const keys = [...new Set(url.searchParams.keys())].filter((key) => allowed.has(key)).sort();
|
|
38
|
+
const filtered = new URLSearchParams();
|
|
39
|
+
for (const key of keys) for (const value of url.searchParams.getAll(key)) filtered.append(key, value);
|
|
40
|
+
const filteredString = filtered.toString();
|
|
41
|
+
url.search = filteredString ? `?${filteredString}` : "";
|
|
42
|
+
return url.toString();
|
|
43
|
+
}
|
|
44
|
+
/** Keep only exact allowlist patterns for uncapped static-cache consumers. */
|
|
45
|
+
function exactQueryAllowlist(allowlist) {
|
|
46
|
+
if (!allowlist) return;
|
|
47
|
+
const exact = Object.fromEntries(Object.entries(allowlist).filter(([pattern]) => !pattern.includes("*")));
|
|
48
|
+
return Object.keys(exact).length > 0 ? exact : void 0;
|
|
49
|
+
}
|
|
50
|
+
function resolveQueryAllowlist(allowlist, pathname) {
|
|
51
|
+
if (!allowlist) return /* @__PURE__ */ new Set();
|
|
52
|
+
for (const [pattern, params] of Object.entries(allowlist)) if (matchLiteralPattern(pattern, pathname)) return new Set(params);
|
|
53
|
+
return /* @__PURE__ */ new Set();
|
|
54
|
+
}
|
|
55
|
+
/** Pack response headers and body into the canonical ISR binary value. */
|
|
56
|
+
function packIsrValue(headers, body) {
|
|
57
|
+
const headerJson = new TextEncoder().encode(JSON.stringify(headers));
|
|
58
|
+
const buffer = new ArrayBuffer(4 + headerJson.byteLength + body.byteLength);
|
|
59
|
+
new DataView(buffer).setUint32(0, headerJson.byteLength, true);
|
|
60
|
+
new Uint8Array(buffer, 4, headerJson.byteLength).set(headerJson);
|
|
61
|
+
new Uint8Array(buffer, 4 + headerJson.byteLength).set(new Uint8Array(body));
|
|
62
|
+
return buffer;
|
|
63
|
+
}
|
|
64
|
+
/** Unpack a canonical ISR binary value, returning null for malformed bytes. */
|
|
65
|
+
function unpackIsrValue(buffer) {
|
|
66
|
+
if (buffer.byteLength < 4) return null;
|
|
67
|
+
const headerLength = new DataView(buffer).getUint32(0, true);
|
|
68
|
+
if (buffer.byteLength < 4 + headerLength) return null;
|
|
69
|
+
try {
|
|
70
|
+
const headerBytes = new Uint8Array(buffer, 4, headerLength);
|
|
71
|
+
const parsed = JSON.parse(new TextDecoder().decode(headerBytes));
|
|
72
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
73
|
+
const headers = {};
|
|
74
|
+
for (const [name, value] of Object.entries(parsed)) {
|
|
75
|
+
if (typeof value !== "string") return null;
|
|
76
|
+
headers[name] = value;
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
headers,
|
|
80
|
+
body: buffer.slice(4 + headerLength)
|
|
81
|
+
};
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Decode and validate a KV value plus metadata into a safe cache entry. */
|
|
87
|
+
function decodeIsrCacheEntry(value, metadata) {
|
|
88
|
+
if (!value || !metadata) return null;
|
|
89
|
+
const unpacked = unpackIsrValue(value);
|
|
90
|
+
if (!unpacked) return null;
|
|
91
|
+
if (!Number.isInteger(metadata.s) || metadata.s < 200 || metadata.s > 599) return null;
|
|
92
|
+
if (!Number.isFinite(metadata.c) || metadata.c <= 0 || !Number.isFinite(metadata.t) || metadata.t <= 0 || metadata.p !== 1) return null;
|
|
93
|
+
try {
|
|
94
|
+
new Headers(unpacked.headers);
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
body: new ReadableStream({ start(controller) {
|
|
100
|
+
controller.enqueue(new Uint8Array(unpacked.body));
|
|
101
|
+
controller.close();
|
|
102
|
+
} }),
|
|
103
|
+
status: metadata.s,
|
|
104
|
+
headers: unpacked.headers,
|
|
105
|
+
createdAt: metadata.c,
|
|
106
|
+
ttl: metadata.t
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/** Check whether an ISR cache entry has passed its freshness lifetime. */
|
|
110
|
+
function isStale(entry, now = Date.now()) {
|
|
111
|
+
return now - entry.createdAt > entry.ttl * 1e3;
|
|
112
|
+
}
|
|
113
|
+
/** Build an edge-cacheable response from a validated ISR cache entry. */
|
|
114
|
+
function isrCacheResponse(entry, stale, now = Date.now()) {
|
|
115
|
+
const headers = new Headers(entry.headers);
|
|
116
|
+
headers.set("x-isr-cache", stale ? "STALE" : "HIT");
|
|
117
|
+
if (stale) headers.set("Cache-Control", `public, s-maxage=${Math.min(10, entry.ttl)}, max-age=0, must-revalidate`);
|
|
118
|
+
else {
|
|
119
|
+
const remainingSeconds = Math.max(0, Math.floor((entry.createdAt + entry.ttl * 1e3 - now) / 1e3));
|
|
120
|
+
headers.set("Cache-Control", `public, s-maxage=${remainingSeconds}, max-age=0, must-revalidate`);
|
|
121
|
+
}
|
|
122
|
+
markSharedCachePolicy(headers);
|
|
123
|
+
return new Response(entry.body, {
|
|
124
|
+
status: entry.status,
|
|
125
|
+
headers
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
/** Resolve an x-revalidate override, falling back for absent or invalid values. */
|
|
129
|
+
function resolveRevalidateTtl(response, defaultTtl) {
|
|
130
|
+
const header = response.headers.get("x-revalidate");
|
|
131
|
+
if (header === null) return defaultTtl;
|
|
132
|
+
const value = Number.parseInt(header, 10);
|
|
133
|
+
return Number.isNaN(value) || value < 0 ? defaultTtl : value;
|
|
134
|
+
}
|
|
135
|
+
function acceptsEventStream(request) {
|
|
136
|
+
return request.headers.get("Accept")?.toLowerCase().split(",").some((part) => part.split(";", 1)[0]?.trim() === "text/event-stream") ?? false;
|
|
137
|
+
}
|
|
138
|
+
/** Return whether credentials or EventSource semantics require an ISR bypass. */
|
|
139
|
+
function shouldBypassIsr(request) {
|
|
140
|
+
return request.headers.has("Cookie") || request.headers.has("Authorization") || acceptsEventStream(request);
|
|
141
|
+
}
|
|
142
|
+
/** Return whether a response explicitly forbids storage in a shared cache. */
|
|
143
|
+
function hasSharedCacheOptOut(response) {
|
|
144
|
+
if (response.headers.has("Set-Cookie")) return true;
|
|
145
|
+
const cacheControl = response.headers.get("Cache-Control");
|
|
146
|
+
if (!cacheControl) return false;
|
|
147
|
+
return cacheControl.split(",").some((part) => {
|
|
148
|
+
const directive = part.split("=", 1)[0]?.trim().toLowerCase();
|
|
149
|
+
return directive === "private" || directive === "no-store" || directive === "no-cache";
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
/** Return whether a complete render may be persisted in the shared ISR cache. */
|
|
153
|
+
function isSharedCacheableResponse(response) {
|
|
154
|
+
return response.status === 200 && !hasSharedCacheOptOut(response);
|
|
155
|
+
}
|
|
156
|
+
/** Resolve the first matching path TTL using Void route-pattern semantics. */
|
|
157
|
+
function resolvePathTtl(revalidate, pathname) {
|
|
158
|
+
if (revalidate == null) return 0;
|
|
159
|
+
if (typeof revalidate === "number") return revalidate;
|
|
160
|
+
for (const [pattern, ttl] of Object.entries(revalidate)) if (matchSegmentPattern(pattern, pathname)) return ttl;
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Resolve ISR TTL from the framework's ordered page table.
|
|
165
|
+
*
|
|
166
|
+
* The first matching page is authoritative. A baked positive TTL enables ISR,
|
|
167
|
+
* a baked `0` opts out, and an absent TTL inherits from the raw per-path config.
|
|
168
|
+
* Only requests that match no page fall back to the flattened revalidate config.
|
|
169
|
+
*/
|
|
170
|
+
function resolvePageTtl(pages, revalidate, pathRevalidate, pathname) {
|
|
171
|
+
if (pages) for (const page of pages) {
|
|
172
|
+
if (!matchSegmentPattern(page.pattern, pathname)) continue;
|
|
173
|
+
return page.revalidate === void 0 ? resolvePathTtl(pathRevalidate, pathname) : page.revalidate > 0 ? page.revalidate : 0;
|
|
174
|
+
}
|
|
175
|
+
return resolvePathTtl(revalidate, pathname);
|
|
176
|
+
}
|
|
177
|
+
/** Extract response headers while removing ISR-managed and private headers. */
|
|
178
|
+
function serializeHeaders(response) {
|
|
179
|
+
const result = {};
|
|
180
|
+
response.headers.forEach((value, key) => {
|
|
181
|
+
if (!SKIP_HEADERS.has(key.toLowerCase())) result[key] = value;
|
|
182
|
+
});
|
|
183
|
+
return result;
|
|
184
|
+
}
|
|
185
|
+
/** Validate page data before storing it as the JSON ISR variant. */
|
|
186
|
+
function normalizePageDataForJsonCache(raw) {
|
|
187
|
+
try {
|
|
188
|
+
const parsed = JSON.parse(raw);
|
|
189
|
+
return parsed.deferred === void 0 && parsed.deferredKeys === void 0 ? raw : null;
|
|
190
|
+
} catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/** Extract validated Void Pages data embedded in an HTML response. */
|
|
195
|
+
function extractPageDataFromHtml(html) {
|
|
196
|
+
const match = html.match(PAGE_DATA_SCRIPT_RE);
|
|
197
|
+
return match ? normalizePageDataForJsonCache(match[2]) : null;
|
|
198
|
+
}
|
|
199
|
+
//#endregion
|
|
200
|
+
export { SHARED_CACHE_POLICY_HEADER, SHARED_CACHE_POLICY_VERSION, buildVariantOriginalUrl, decodeIsrCacheEntry, exactQueryAllowlist, extractPageDataFromHtml, hasCurrentSharedCachePolicy, hasSharedCacheOptOut, isSharedCacheableResponse, isStale, isrCacheResponse, markSharedCachePolicy, normalizePageDataForJsonCache, packIsrValue, resolvePageTtl, resolvePathTtl, resolveRevalidateTtl, serializeHeaders, shouldBypassIsr, unpackIsrValue };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { IsrCacheMetadata } from "./core.mjs";
|
|
2
|
+
//#region src/index.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Build the normalized path segment for an ISR-cached page.
|
|
5
|
+
* Root path `/` maps to `__index__` to avoid trailing-slash ambiguity.
|
|
6
|
+
*/
|
|
7
|
+
export declare function normalizeIsrPath(pathname: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Inverse of `normalizeIsrPath`: turn a normalized segment back into a
|
|
10
|
+
* pathname starting with `/`.
|
|
11
|
+
*/
|
|
12
|
+
export declare function denormalizeIsrPath(normalized: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* Canonicalize an inbound pathname to the form used inside edge cache URLs.
|
|
15
|
+
* Equivalent to `denormalizeIsrPath(normalizeIsrPath(pathname))` — strips any
|
|
16
|
+
* trailing slash except for root.
|
|
17
|
+
*/
|
|
18
|
+
export declare function canonicalIsrPathname(pathname: string): string;
|
|
19
|
+
/**
|
|
20
|
+
* Prefix for listing/purging ISR keys.
|
|
21
|
+
*
|
|
22
|
+
* `deploymentId === ''` must still produce the versioned prefix with an
|
|
23
|
+
* empty segment (`<projectId>/isr/v3//`) because dispatch uses
|
|
24
|
+
* `routing.version ?? 'legacy'`, which allows empty strings and writes
|
|
25
|
+
* keys at `<projectId>/isr/v3//<path>`. Only `undefined` selects the
|
|
26
|
+
* legacy scan-all prefix (`<projectId>/isr/`).
|
|
27
|
+
*/
|
|
28
|
+
export declare function isrCachePrefix(projectId: string, deploymentId?: string): string;
|
|
29
|
+
/**
|
|
30
|
+
* Prefix for listing/purging LEGACY (pre-host-scoping) ISR keys — the `v2`
|
|
31
|
+
* namespace `<projectId>/isr/v2/<deploymentId>/`. Mirrors `isrCachePrefix`'s
|
|
32
|
+
* optional-`deploymentId` behavior exactly (empty string yields the versioned
|
|
33
|
+
* prefix with an empty segment; only `undefined` selects the scan-all
|
|
34
|
+
* `<projectId>/isr/` prefix).
|
|
35
|
+
*
|
|
36
|
+
* Exists SOLELY for the host-scoping rollout: the purger sweeps this namespace
|
|
37
|
+
* to reap orphaned `v2` KV keys and purge their edge URLs. Current dispatch
|
|
38
|
+
* neither reads nor writes `v2` keys. Transition-only — remove once `v2`
|
|
39
|
+
* entries have aged out.
|
|
40
|
+
*/
|
|
41
|
+
export declare function isrLegacyCachePrefix(projectId: string, deploymentId?: string): string;
|
|
42
|
+
/**
|
|
43
|
+
* Build the cache key for an ISR-cached page.
|
|
44
|
+
* ISR is deployment-scoped so cached HTML cannot outlive the hashed assets
|
|
45
|
+
* referenced by the deployment that produced it, and host-scoped (`<host>`
|
|
46
|
+
* segment after the deployment id) so the same path served under different
|
|
47
|
+
* hostnames — e.g. a project slug and a custom domain, or two custom
|
|
48
|
+
* domains — never share a cache slot. `host` is the request hostname the
|
|
49
|
+
* dispatch worker saw (`new URL(request.url).hostname`).
|
|
50
|
+
*
|
|
51
|
+
* Host-scoping is a `v3` key format: `<proj>/isr/v3/<D>/<host>/<path>`. The
|
|
52
|
+
* version bump is load-bearing, not cosmetic — see `ISR_LEGACY_CACHE_KEY_VERSION`.
|
|
53
|
+
* Under a single version, this key would be byte-identical to a legacy hostless
|
|
54
|
+
* key for a page whose leading path segment looks like a hostname (e.g.
|
|
55
|
+
* `(host=example.com, /about)` vs the hostless page `/example.com/about`, both
|
|
56
|
+
* `<D>/example.com/about`). Dotted leading segments are legal, so the collision
|
|
57
|
+
* is reachable. `v3` keys are ALWAYS host-scoped and `v2` keys ALWAYS hostless,
|
|
58
|
+
* so the version segment alone disambiguates the two shapes.
|
|
59
|
+
*
|
|
60
|
+
* When `originalUrl` is provided, the request was served via a dispatch-side
|
|
61
|
+
* rewrite (`redirectRules`/`fallbackRules` with status 200). The original
|
|
62
|
+
* request URL is mixed into the key so a direct hit to `/en/docs` and a
|
|
63
|
+
* rewrite from `/docs` to `/en/docs` do not collide — user code can branch
|
|
64
|
+
* on `c.isRewritten()` / `c.originalUrl()`, and the cache must honor that.
|
|
65
|
+
* A missing `originalUrl` means "direct hit" and produces the legacy
|
|
66
|
+
* rewriteless key (backward-compatible with entries written before this
|
|
67
|
+
* feature landed).
|
|
68
|
+
*/
|
|
69
|
+
export declare function isrCacheKey(projectId: string, deploymentId: string, host: string, pathname: string, originalUrl?: string | null): string;
|
|
70
|
+
/**
|
|
71
|
+
* Prefix used to list/purge every variant of a given pathname under a
|
|
72
|
+
* deployment — both the direct-hit key and any rewrite-variant keys.
|
|
73
|
+
*/
|
|
74
|
+
export declare function isrCacheVariantPrefix(projectId: string, deploymentId: string, host: string, pathname: string): string;
|
|
75
|
+
/**
|
|
76
|
+
* Build the exact LEGACY (pre-host-scoping) direct-hit key for a pathname under
|
|
77
|
+
* a deployment: `<projectId>/isr/v2/<deploymentId>/<normalizedPath>` — the
|
|
78
|
+
* hostless counterpart of `isrCacheKey` with no `<host>/` segment and no
|
|
79
|
+
* rewrite-variant suffix. Used by the purger to reap the orphaned `v2`
|
|
80
|
+
* direct-hit KV key for a requested path.
|
|
81
|
+
*
|
|
82
|
+
* Exists SOLELY for the host-scoping rollout — current dispatch neither writes
|
|
83
|
+
* nor reads hostless keys. Transition-only. Do not use it on the hot path.
|
|
84
|
+
*/
|
|
85
|
+
export declare function isrLegacyCacheKey(projectId: string, deploymentId: string, pathname: string): string;
|
|
86
|
+
/**
|
|
87
|
+
* Prefix used to list/purge the pre-host-scoped ("legacy") rewrite variants of
|
|
88
|
+
* a given pathname under a deployment. Before ISR keys grew a `<host>` segment,
|
|
89
|
+
* variant keys were written as
|
|
90
|
+
* `<projectId>/isr/v2/<deploymentId>/<normalizedPath>#rw=<token>` — identical to
|
|
91
|
+
* `isrCacheVariantPrefix` but with NO `<host>/` segment. Those entries still
|
|
92
|
+
* persist during the host-scoping rollout (ISR puts set no `expirationTtl`),
|
|
93
|
+
* so the purger must scan this hostless `v2` prefix in addition to the
|
|
94
|
+
* host-scoped `v3` one to recover their variant tokens and purge the matching
|
|
95
|
+
* edge URLs.
|
|
96
|
+
*
|
|
97
|
+
* Built off `isrLegacyCachePrefix` (the `v2` namespace), NOT `isrCachePrefix`
|
|
98
|
+
* (now `v3`), so it targets the legacy hostless keys it is meant to sweep.
|
|
99
|
+
*
|
|
100
|
+
* Exists SOLELY for that rollout cleanup — current dispatch neither writes nor
|
|
101
|
+
* reads hostless keys. Transition-only. Do not use it on the hot path.
|
|
102
|
+
*/
|
|
103
|
+
export declare function isrLegacyCacheVariantPrefix(projectId: string, deploymentId: string, pathname: string): string;
|
|
104
|
+
export declare function hashOriginalUrl(originalUrl: string): string;
|
|
105
|
+
/**
|
|
106
|
+
* Split a fully-qualified ISR KV key into the normalized pathname segment
|
|
107
|
+
* and the optional rewrite-variant token. Returns `null` token when the
|
|
108
|
+
* key has no `#rw=` suffix (direct hit).
|
|
109
|
+
*
|
|
110
|
+
* The token is opaque (a hash); the original URL it was derived from is not
|
|
111
|
+
* recoverable. Callers that need to reproduce the matching edge-cache URL
|
|
112
|
+
* pass the token through `isrEdgeCacheUrlForToken`.
|
|
113
|
+
*/
|
|
114
|
+
export declare function splitIsrCacheKey(fullKey: string): {
|
|
115
|
+
normalizedPath: string;
|
|
116
|
+
variantToken: string | null;
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* Build the edge cache URL that dispatch uses as its `caches.default` key
|
|
120
|
+
* (wrapped in a GET `Request`). The public URL stays clean; the internal
|
|
121
|
+
* cache key carries the deployment id so a new deploy never reads HTML
|
|
122
|
+
* produced by an older deploy. The `json` variant separates pages-protocol
|
|
123
|
+
* JSON responses from HTML for the same path+deployment.
|
|
124
|
+
*
|
|
125
|
+
* This MUST match the URL dispatch writes. Dispatch builds the URL from
|
|
126
|
+
* `new URL(request.url).hostname`; for standard-port HTTPS traffic (all CF
|
|
127
|
+
* edge traffic) that yields the same origin as the one produced here.
|
|
128
|
+
*/
|
|
129
|
+
export declare function isrEdgeCacheUrl(hostname: string, pathname: string, deploymentId: string, variant: "html" | "json", originalUrl?: string | null): string;
|
|
130
|
+
/**
|
|
131
|
+
* Like `isrEdgeCacheUrl`, but takes a pre-hashed variant token (from
|
|
132
|
+
* `splitIsrCacheKey`). Used by the purger, which only knows the hashed
|
|
133
|
+
* token: the original URL is not recoverable from a KV key.
|
|
134
|
+
*/
|
|
135
|
+
export declare function isrEdgeCacheUrlForToken(hostname: string, pathname: string, deploymentId: string, variant: "html" | "json", variantToken?: string | null): string;
|
|
136
|
+
//#endregion
|
|
137
|
+
export type { IsrCacheMetadata };
|