@void/isr 0.26.1 → 0.28.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/dist/core.d.mts CHANGED
@@ -10,6 +10,10 @@ export declare const SHARED_CACHE_POLICY_HEADER = "x-void-shared-cache-policy";
10
10
  export declare function markSharedCachePolicy(headers: Headers): void;
11
11
  /** Reject opaque edge entries admitted before the current privacy policy. */
12
12
  export declare function hasCurrentSharedCachePolicy(response: Response): boolean;
13
+ /**
14
+ * KV metadata stored with every ISR cache entry. Writers should annotate it with
15
+ * `satisfies IsrCacheMetadata` so a renamed field fails typecheck.
16
+ */
13
17
  export type IsrCacheMetadata = Readonly<{
14
18
  /** HTTP status code. */
15
19
  s: number;
package/dist/index.d.mts CHANGED
@@ -17,113 +17,55 @@ export declare function denormalizeIsrPath(normalized: string): string;
17
17
  */
18
18
  export declare function canonicalIsrPathname(pathname: string): string;
19
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/`).
20
+ * Prefix for listing/purging ISR keys. An empty `deploymentId` still yields the
21
+ * versioned prefix (`<projectId>/isr/v3//`), because writers may use an empty
22
+ * id. Only `undefined` selects every key (`<projectId>/isr/`).
27
23
  */
28
24
  export declare function isrCachePrefix(projectId: string, deploymentId?: string): string;
29
25
  /**
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.
26
+ * Like `isrCachePrefix`, for hostless `v2` keys. The `isrLegacy*` helpers exist
27
+ * only to purge `v2` entries; remove them once those entries have aged out.
40
28
  */
41
29
  export declare function isrLegacyCachePrefix(projectId: string, deploymentId?: string): string;
42
30
  /**
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`).
31
+ * KV key for an ISR-cached page: `<proj>/isr/v3/<D>/<host>/<path>`. Deployment
32
+ * scoping keeps cached HTML from outliving its hashed assets; host scoping keeps
33
+ * different hostnames apart. `host` is the request hostname.
50
34
  *
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
- *
54
- * When `originalUrl` is provided, the request was served via a dispatch-side
55
- * rewrite (`redirectRules`/`fallbackRules` with status 200). The original
56
- * request URL is mixed into the key so a direct hit to `/en/docs` and a
57
- * rewrite from `/docs` to `/en/docs` do not collide — user code can branch
58
- * on `c.isRewritten()` / `c.originalUrl()`, and the cache must honor that.
59
- * A missing `originalUrl` means "direct hit" and produces the unsuffixed key.
35
+ * Pass `originalUrl` for a rewritten request (or a query variant). It adds a
36
+ * `#rw=<token>` suffix, so a rewrite from `/docs` to `/en/docs` does not share
37
+ * an entry with a direct hit on `/en/docs`.
60
38
  */
61
39
  export declare function isrCacheKey(projectId: string, deploymentId: string, host: string, pathname: string, originalUrl?: string | null): string;
62
- /**
63
- * Prefix used to list/purge every variant of a given pathname under a
64
- * deployment — both the direct-hit key and any rewrite-variant keys.
65
- */
40
+ /** Prefix for listing/purging the `#rw=` variant keys of a pathname (not its direct-hit key). */
66
41
  export declare function isrCacheVariantPrefix(projectId: string, deploymentId: string, host: string, pathname: string): string;
67
- /**
68
- * Build the exact LEGACY (pre-host-scoping) direct-hit key for a pathname under
69
- * a deployment: `<projectId>/isr/v2/<deploymentId>/<normalizedPath>` — the
70
- * hostless counterpart of `isrCacheKey` with no `<host>/` segment and no
71
- * rewrite-variant suffix. Used by the purger to reap the orphaned `v2`
72
- * direct-hit KV key for a requested path.
73
- *
74
- * Exists SOLELY for the host-scoping rollout — current dispatch neither writes
75
- * nor reads hostless keys. Transition-only. Do not use it on the hot path.
76
- */
42
+ /** Hostless `v2` direct-hit key for a pathname (purge only; see `isrLegacyCachePrefix`). */
77
43
  export declare function isrLegacyCacheKey(projectId: string, deploymentId: string, pathname: string): string;
44
+ /** Hostless `v2` rewrite-variant prefix for a pathname (purge only; see `isrLegacyCachePrefix`). */
45
+ export declare function isrLegacyCacheVariantPrefix(projectId: string, deploymentId: string, pathname: string): string;
78
46
  /**
79
- * Prefix used to list/purge the pre-host-scoped ("legacy") rewrite variants of
80
- * a given pathname under a deployment. Before ISR keys grew a `<host>` segment,
81
- * variant keys were written as
82
- * `<projectId>/isr/v2/<deploymentId>/<normalizedPath>#rw=<token>` — identical to
83
- * `isrCacheVariantPrefix` but with NO `<host>/` segment. Those entries still
84
- * persist during the host-scoping rollout (ISR puts set no `expirationTtl`),
85
- * so the purger must scan this hostless `v2` prefix in addition to the
86
- * host-scoped `v3` one to recover their variant tokens and purge the matching
87
- * edge URLs.
88
- *
89
- * Built off `isrLegacyCachePrefix` (the `v2` namespace), NOT `isrCachePrefix`
90
- * (now `v3`), so it targets the legacy hostless keys it is meant to sweep.
91
- *
92
- * Exists SOLELY for that rollout cleanup — current dispatch neither writes nor
93
- * reads hostless keys. Transition-only. Do not use it on the hot path.
47
+ * Hash an original URL into a 16-hex-char variant token (FNV-1a 64-bit), used
48
+ * as both the KV-key suffix and the `__void_orig` edge-cache parameter. It is
49
+ * sync so it fits every read/write path, and opaque so KV never exposes
50
+ * original URLs or their query strings. Not cryptographic.
94
51
  */
95
- export declare function isrLegacyCacheVariantPrefix(projectId: string, deploymentId: string, pathname: string): string;
96
52
  export declare function hashOriginalUrl(originalUrl: string): string;
97
53
  /**
98
- * Split a fully-qualified ISR KV key into the normalized pathname segment
99
- * and the optional rewrite-variant token. Returns `null` token when the
100
- * key has no `#rw=` suffix (direct hit).
101
- *
102
- * The token is opaque (a hash); the original URL it was derived from is not
103
- * recoverable. Callers that need to reproduce the matching edge-cache URL
104
- * pass the token through `isrEdgeCacheUrlForToken`.
54
+ * Split an ISR KV key into its normalized path and its `#rw=` variant token
55
+ * (`null` for a direct hit). Pass the token to `isrEdgeCacheUrlForToken` to
56
+ * rebuild the matching edge-cache URL.
105
57
  */
106
58
  export declare function splitIsrCacheKey(fullKey: string): {
107
59
  normalizedPath: string;
108
60
  variantToken: string | null;
109
61
  };
110
62
  /**
111
- * Build the edge cache URL that dispatch uses as its `caches.default` key
112
- * (wrapped in a GET `Request`). The public URL stays clean; the internal
113
- * cache key carries the deployment id so a new deploy never reads HTML
114
- * produced by an older deploy. The `json` variant separates pages-protocol
115
- * JSON responses from HTML for the same path+deployment.
116
- *
117
- * This MUST match the URL dispatch writes. Dispatch builds the URL from
118
- * `new URL(request.url).hostname`; for standard-port HTTPS traffic (all CF
119
- * edge traffic) that yields the same origin as the one produced here.
63
+ * Edge-cache (`caches.default`) URL for an ISR page. It carries the deployment
64
+ * id so a new deploy never reads older HTML, and the `json` variant keeps
65
+ * Pages-protocol JSON apart from HTML for the same path.
120
66
  */
121
67
  export declare function isrEdgeCacheUrl(hostname: string, pathname: string, deploymentId: string, variant: "html" | "json", originalUrl?: string | null): string;
122
- /**
123
- * Like `isrEdgeCacheUrl`, but takes a pre-hashed variant token (from
124
- * `splitIsrCacheKey`). Used by the purger, which only knows the hashed
125
- * token: the original URL is not recoverable from a KV key.
126
- */
68
+ /** Like `isrEdgeCacheUrl`, but takes a variant token from `splitIsrCacheKey`. */
127
69
  export declare function isrEdgeCacheUrlForToken(hostname: string, pathname: string, deploymentId: string, variant: "html" | "json", variantToken?: string | null): string;
128
70
  //#endregion
129
71
  export type { IsrCacheMetadata };
package/dist/index.mjs CHANGED
@@ -1,32 +1,16 @@
1
1
  //#region src/index.ts
2
2
  /**
3
- * Shared ISR key/URL helpers used by both the API (purge) and the dispatch
4
- * worker (read/write). These functions MUST produce byte-identical output:
5
- * the API purges KV keys and edge URLs that dispatch writes and reads.
3
+ * ISR cache keys and edge-cache URLs. Writers, readers, and purgers must build
4
+ * byte-identical keys, so all of them use these helpers.
6
5
  *
7
- * Dispatch derives the deployment id for its keys via
8
- * `routing.version ?? 'legacy'`, which allows any non-nullish value —
9
- * including the empty string — to pass through unchanged. The helpers below
10
- * preserve that shape so the API can compute the exact same key for purge.
6
+ * Key shapes: `v3` = `<proj>/isr/v3/<D>/<host>/<path>[#rw=<token>]`;
7
+ * `v2` = hostless `<proj>/isr/v2/<D>/<path>`, kept only to purge old entries.
11
8
  */
12
9
  const ISR_CACHE_KEY_VERSION = "v3";
13
10
  /**
14
- * Previous key version. `v3` keys are ALWAYS host-scoped
15
- * (`<proj>/isr/v3/<D>/<host>/<path>`); `v2` keys are ALWAYS hostless
16
- * (`<proj>/isr/v2/<D>/<path>`) — the `<host>` segment did not exist yet.
17
- *
18
- * The bump to `v3` was forced by a real aliasing hazard: under a single
19
- * version, a host-scoped key `<D>/<host>/<path>` and a legacy hostless key
20
- * `<D>/<host>/<path>` are byte-identical whenever the legacy path's leading
21
- * segment happens to look like a hostname. Dotted leading path segments are
22
- * legal (`[domain]` → `:domain`, `[...slug]` → `(.+)`), so a hostless key for
23
- * the page `/example.com/about` collides exactly with the host-scoped key for
24
- * `(host=example.com, /about)`. The stored metadata is only `{s,c,t}` (no
25
- * path/host), so a reader cannot tell them apart and could serve one page's
26
- * body for the other. Encoding the shape in the version removes the ambiguity:
27
- * the version alone determines whether a `<host>` segment is present, so no
28
- * runtime heuristic (e.g. "is the first segment an attached hostname?") is
29
- * needed to classify a key. Package-internal, like `ISR_CACHE_KEY_VERSION`.
11
+ * Hostless key version. Host scoping needed a new version: otherwise a hostless
12
+ * key for `/example.com/about` would equal the host-scoped key for
13
+ * (`example.com`, `/about`).
30
14
  */
31
15
  const ISR_LEGACY_CACHE_KEY_VERSION = "v2";
32
16
  /**
@@ -62,125 +46,57 @@ function versionedIsrCachePrefix(projectId, version, deploymentId) {
62
46
  return `${projectId}/isr/`;
63
47
  }
64
48
  /**
65
- * Prefix for listing/purging ISR keys.
66
- *
67
- * `deploymentId === ''` must still produce the versioned prefix with an
68
- * empty segment (`<projectId>/isr/v3//`) because dispatch uses
69
- * `routing.version ?? 'legacy'`, which allows empty strings and writes
70
- * keys at `<projectId>/isr/v3//<path>`. Only `undefined` selects the
71
- * legacy scan-all prefix (`<projectId>/isr/`).
49
+ * Prefix for listing/purging ISR keys. An empty `deploymentId` still yields the
50
+ * versioned prefix (`<projectId>/isr/v3//`), because writers may use an empty
51
+ * id. Only `undefined` selects every key (`<projectId>/isr/`).
72
52
  */
73
53
  function isrCachePrefix(projectId, deploymentId) {
74
54
  return versionedIsrCachePrefix(projectId, ISR_CACHE_KEY_VERSION, deploymentId);
75
55
  }
76
56
  /**
77
- * Prefix for listing/purging LEGACY (pre-host-scoping) ISR keys — the `v2`
78
- * namespace `<projectId>/isr/v2/<deploymentId>/`. Mirrors `isrCachePrefix`'s
79
- * optional-`deploymentId` behavior exactly (empty string yields the versioned
80
- * prefix with an empty segment; only `undefined` selects the scan-all
81
- * `<projectId>/isr/` prefix).
82
- *
83
- * Exists SOLELY for the host-scoping rollout: the purger sweeps this namespace
84
- * to reap orphaned `v2` KV keys and purge their edge URLs. Current dispatch
85
- * neither reads nor writes `v2` keys. Transition-only — remove once `v2`
86
- * entries have aged out.
57
+ * Like `isrCachePrefix`, for hostless `v2` keys. The `isrLegacy*` helpers exist
58
+ * only to purge `v2` entries; remove them once those entries have aged out.
87
59
  */
88
60
  function isrLegacyCachePrefix(projectId, deploymentId) {
89
61
  return versionedIsrCachePrefix(projectId, ISR_LEGACY_CACHE_KEY_VERSION, deploymentId);
90
62
  }
91
- /**
92
- * Separator that marks the rewrite-variant suffix on an ISR cache key or
93
- * the corresponding edge-cache URL parameter. `#` cannot appear in
94
- * `url.pathname` (fragments are stripped before the request reaches the
95
- * server) and does not survive `normalizeIsrPath`, so it unambiguously
96
- * identifies the variant suffix.
97
- */
63
+ /** `#` never appears in a request pathname, so it unambiguously marks the variant suffix. */
98
64
  const ISR_VARIANT_SEPARATOR = "#rw=";
99
65
  /**
100
- * Build the cache key for an ISR-cached page.
101
- * ISR is deployment-scoped so cached HTML cannot outlive the hashed assets
102
- * referenced by the deployment that produced it, and host-scoped (`<host>`
103
- * segment after the deployment id) so the same path served under different
104
- * hostnames — e.g. a project slug and a custom domain, or two custom
105
- * domains — never share a cache slot. `host` is the request hostname the
106
- * dispatch worker saw (`new URL(request.url).hostname`).
107
- *
108
- * Host-scoping is a `v3` key format: `<proj>/isr/v3/<D>/<host>/<path>`. The
109
- * version bump is load-bearing, not cosmetic — see `ISR_LEGACY_CACHE_KEY_VERSION`.
66
+ * KV key for an ISR-cached page: `<proj>/isr/v3/<D>/<host>/<path>`. Deployment
67
+ * scoping keeps cached HTML from outliving its hashed assets; host scoping keeps
68
+ * different hostnames apart. `host` is the request hostname.
110
69
  *
111
- * When `originalUrl` is provided, the request was served via a dispatch-side
112
- * rewrite (`redirectRules`/`fallbackRules` with status 200). The original
113
- * request URL is mixed into the key so a direct hit to `/en/docs` and a
114
- * rewrite from `/docs` to `/en/docs` do not collide — user code can branch
115
- * on `c.isRewritten()` / `c.originalUrl()`, and the cache must honor that.
116
- * A missing `originalUrl` means "direct hit" and produces the unsuffixed key.
70
+ * Pass `originalUrl` for a rewritten request (or a query variant). It adds a
71
+ * `#rw=<token>` suffix, so a rewrite from `/docs` to `/en/docs` does not share
72
+ * an entry with a direct hit on `/en/docs`.
117
73
  */
118
74
  function isrCacheKey(projectId, deploymentId, host, pathname, originalUrl) {
119
75
  const base = `${isrCachePrefix(projectId, deploymentId)}${host}/${normalizeIsrPath(pathname)}`;
120
76
  if (!originalUrl) return base;
121
77
  return `${base}${ISR_VARIANT_SEPARATOR}${hashOriginalUrl(originalUrl)}`;
122
78
  }
123
- /**
124
- * Prefix used to list/purge every variant of a given pathname under a
125
- * deployment — both the direct-hit key and any rewrite-variant keys.
126
- */
79
+ /** Prefix for listing/purging the `#rw=` variant keys of a pathname (not its direct-hit key). */
127
80
  function isrCacheVariantPrefix(projectId, deploymentId, host, pathname) {
128
81
  return `${isrCachePrefix(projectId, deploymentId)}${host}/${normalizeIsrPath(pathname)}${ISR_VARIANT_SEPARATOR}`;
129
82
  }
130
- /**
131
- * Build the exact LEGACY (pre-host-scoping) direct-hit key for a pathname under
132
- * a deployment: `<projectId>/isr/v2/<deploymentId>/<normalizedPath>` — the
133
- * hostless counterpart of `isrCacheKey` with no `<host>/` segment and no
134
- * rewrite-variant suffix. Used by the purger to reap the orphaned `v2`
135
- * direct-hit KV key for a requested path.
136
- *
137
- * Exists SOLELY for the host-scoping rollout — current dispatch neither writes
138
- * nor reads hostless keys. Transition-only. Do not use it on the hot path.
139
- */
83
+ /** Hostless `v2` direct-hit key for a pathname (purge only; see `isrLegacyCachePrefix`). */
140
84
  function isrLegacyCacheKey(projectId, deploymentId, pathname) {
141
85
  return `${isrLegacyCachePrefix(projectId, deploymentId)}${normalizeIsrPath(pathname)}`;
142
86
  }
143
- /**
144
- * Prefix used to list/purge the pre-host-scoped ("legacy") rewrite variants of
145
- * a given pathname under a deployment. Before ISR keys grew a `<host>` segment,
146
- * variant keys were written as
147
- * `<projectId>/isr/v2/<deploymentId>/<normalizedPath>#rw=<token>` — identical to
148
- * `isrCacheVariantPrefix` but with NO `<host>/` segment. Those entries still
149
- * persist during the host-scoping rollout (ISR puts set no `expirationTtl`),
150
- * so the purger must scan this hostless `v2` prefix in addition to the
151
- * host-scoped `v3` one to recover their variant tokens and purge the matching
152
- * edge URLs.
153
- *
154
- * Built off `isrLegacyCachePrefix` (the `v2` namespace), NOT `isrCachePrefix`
155
- * (now `v3`), so it targets the legacy hostless keys it is meant to sweep.
156
- *
157
- * Exists SOLELY for that rollout cleanup — current dispatch neither writes nor
158
- * reads hostless keys. Transition-only. Do not use it on the hot path.
159
- */
87
+ /** Hostless `v2` rewrite-variant prefix for a pathname (purge only; see `isrLegacyCachePrefix`). */
160
88
  function isrLegacyCacheVariantPrefix(projectId, deploymentId, pathname) {
161
89
  return `${isrLegacyCachePrefix(projectId, deploymentId)}${normalizeIsrPath(pathname)}${ISR_VARIANT_SEPARATOR}`;
162
90
  }
163
- /**
164
- * Hash an original-URL value into a fixed-length, opaque variant token.
165
- * Used as both the KV-key suffix and the `__void_orig` query value, so the
166
- * writer and purger paths see byte-identical tokens for the same URL.
167
- *
168
- * Properties:
169
- * - Sync (Workers `crypto.subtle.digest` is async; we don't want to push
170
- * async through every read/write path for this).
171
- * - 16 hex chars (64 bits) — bounds the KV key length regardless of input.
172
- * - Non-recoverable: KV is operator-readable, so the token must not reveal
173
- * original URLs (including sensitive query strings like `?token=…`).
174
- * - Not cryptographic: collision resistance is ~2^32 (birthday). Fine for
175
- * the per-deployment, per-pathname variant set we ever store in one slot.
176
- *
177
- * Implementation: FNV-1a 64-bit over UTF-8 bytes. BigInt arithmetic for the
178
- * multiply (no native u64 in JS), but inputs are short (URLs, sub-1KB) and
179
- * this only runs once per ISR-touched request.
180
- */
181
91
  const FNV_OFFSET_BASIS = 14695981039346656037n;
182
92
  const FNV_PRIME = 1099511628211n;
183
93
  const FNV_MASK = 18446744073709551615n;
94
+ /**
95
+ * Hash an original URL into a 16-hex-char variant token (FNV-1a 64-bit), used
96
+ * as both the KV-key suffix and the `__void_orig` edge-cache parameter. It is
97
+ * sync so it fits every read/write path, and opaque so KV never exposes
98
+ * original URLs or their query strings. Not cryptographic.
99
+ */
184
100
  function hashOriginalUrl(originalUrl) {
185
101
  const bytes = new TextEncoder().encode(originalUrl);
186
102
  let h = FNV_OFFSET_BASIS;
@@ -191,13 +107,9 @@ function hashOriginalUrl(originalUrl) {
191
107
  return h.toString(16).padStart(16, "0");
192
108
  }
193
109
  /**
194
- * Split a fully-qualified ISR KV key into the normalized pathname segment
195
- * and the optional rewrite-variant token. Returns `null` token when the
196
- * key has no `#rw=` suffix (direct hit).
197
- *
198
- * The token is opaque (a hash); the original URL it was derived from is not
199
- * recoverable. Callers that need to reproduce the matching edge-cache URL
200
- * pass the token through `isrEdgeCacheUrlForToken`.
110
+ * Split an ISR KV key into its normalized path and its `#rw=` variant token
111
+ * (`null` for a direct hit). Pass the token to `isrEdgeCacheUrlForToken` to
112
+ * rebuild the matching edge-cache URL.
201
113
  */
202
114
  function splitIsrCacheKey(fullKey) {
203
115
  const sepIdx = fullKey.indexOf(ISR_VARIANT_SEPARATOR);
@@ -211,24 +123,14 @@ function splitIsrCacheKey(fullKey) {
211
123
  };
212
124
  }
213
125
  /**
214
- * Build the edge cache URL that dispatch uses as its `caches.default` key
215
- * (wrapped in a GET `Request`). The public URL stays clean; the internal
216
- * cache key carries the deployment id so a new deploy never reads HTML
217
- * produced by an older deploy. The `json` variant separates pages-protocol
218
- * JSON responses from HTML for the same path+deployment.
219
- *
220
- * This MUST match the URL dispatch writes. Dispatch builds the URL from
221
- * `new URL(request.url).hostname`; for standard-port HTTPS traffic (all CF
222
- * edge traffic) that yields the same origin as the one produced here.
126
+ * Edge-cache (`caches.default`) URL for an ISR page. It carries the deployment
127
+ * id so a new deploy never reads older HTML, and the `json` variant keeps
128
+ * Pages-protocol JSON apart from HTML for the same path.
223
129
  */
224
130
  function isrEdgeCacheUrl(hostname, pathname, deploymentId, variant, originalUrl) {
225
131
  return buildIsrEdgeCacheUrl(hostname, pathname, deploymentId, variant, originalUrl ? hashOriginalUrl(originalUrl) : null);
226
132
  }
227
- /**
228
- * Like `isrEdgeCacheUrl`, but takes a pre-hashed variant token (from
229
- * `splitIsrCacheKey`). Used by the purger, which only knows the hashed
230
- * token: the original URL is not recoverable from a KV key.
231
- */
133
+ /** Like `isrEdgeCacheUrl`, but takes a variant token from `splitIsrCacheKey`. */
232
134
  function isrEdgeCacheUrlForToken(hostname, pathname, deploymentId, variant, variantToken) {
233
135
  return buildIsrEdgeCacheUrl(hostname, pathname, deploymentId, variant, variantToken ?? null);
234
136
  }
@@ -4,36 +4,13 @@ import { canonicalIsrPathname, denormalizeIsrPath, normalizeIsrPath } from "./in
4
4
  import { matchSegmentPattern } from "@void/edge";
5
5
  //#region src/runtime.d.ts
6
6
  /**
7
- * Single-tenant ISR (incremental static regeneration) cache ladder for the
8
- * direct Cloudflare Worker (Void apps and framework deploy wrappers).
9
- *
10
- * On managed Void, ISR lives in the dispatch worker that sits IN FRONT of the
11
- * user worker. Self-host has no dispatch worker, so this module ports that
12
- * caching ladder INTO the user worker, collapsed from multi-tenant to a single
13
- * deployment:
14
- *
15
- * - No `projectId`, no cross-tenant metering.
16
- * - No `#rw=` rewrite variant, no query-string variant fanout, no variant cap.
17
- * - The deployment id is baked at build time as a compile-time literal and
18
- * surfaced on `env.__VOID_ISR_DEPLOYMENT_ID`. The KV helpers read
19
- * it from `env` (matching the `revalidate()` purge path in `runtime/isr.ts`),
20
- * while `handleIsr` uses the `deploymentId` option for the edge-cache keys —
21
- * the two are the same value.
22
- *
23
- * Fail-open contract: every `env.ISR_CACHE` (KV) and `caches.default` operation
24
- * is wrapped so a misconfigured or erroring cache degrades to a live render,
25
- * never a 500. Read/hit-path failures bail (caller renders live); write-path
26
- * failures are swallowed.
27
- *
28
- * Worker-only: no `lib.dom` types — globals come from `@cloudflare/workers-types`.
29
- *
30
- * Ported from the hosted dispatch runtime and the shared `@void/isr` contract.
31
- */
32
- /**
33
- * Minimal env surface this module needs. Both fields are optional so a worker
34
- * with no ISR binding (or a caller that only knows the deployment id) still
35
- * typechecks and fails open at runtime.
7
+ * Single-tenant ISR cache for a direct Cloudflare Worker (Void apps and
8
+ * framework deploy wrappers). Every `ISR_CACHE` (KV) and `caches.default`
9
+ * operation fails open: read errors render live, write errors are ignored.
10
+ * KV helpers read the deployment id from `env.__VOID_ISR_DEPLOYMENT_ID`;
11
+ * edge-cache keys use the `deploymentId` option. Both hold the same value.
36
12
  */
13
+ /** Env fields ISR reads. Both are optional: without them ISR fails open. */
37
14
  export interface IsrCacheEnv {
38
15
  ISR_CACHE?: KVNamespace;
39
16
  __VOID_ISR_DEPLOYMENT_ID?: string;
@@ -54,19 +31,15 @@ export declare const MAX_ISR_VARIANTS_PER_PATH = 200;
54
31
  /** Prefix for the current direct deployment's host-scoped cache entries. */
55
32
  export declare function isrPrefix(deploymentId: string): string;
56
33
  /**
57
- * KV cache key for an ISR-cached page. Deployment- AND host-scoped: cached HTML
58
- * cannot outlive the hashed assets of the deployment that produced it (a fresh
59
- * `wrangler deploy` mints a new deployment id → cold cache), and one host's
60
- * cached content never bleeds into another host served by the same worker
61
- * (custom domain + workers.dev, apex + www, etc.).
34
+ * KV key for an ISR-cached page, scoped by deployment (a new deploy starts with
35
+ * a cold cache) and by host (custom domain, workers.dev, apex, and www stay apart).
62
36
  */
63
37
  export declare function isrKey(deploymentId: string, host: string, pathname: string, variantUrl?: string | null): string;
64
38
  export declare function isrVariantPrefix(deploymentId: string, host: string, pathname: string): string;
65
39
  /**
66
- * Host-registry key. Every host that has written cached content registers itself
67
- * under `void-isr-hosts/<id>/<host>` (value `''`) so a purge with no request host
68
- * (cron / queue `revalidate({ paths })`) can enumerate the hosts to purge.
69
- * Scoped by deployment id so registrations from an old deploy do not leak.
40
+ * Host-registry key. Every host that writes cached content registers here, so a
41
+ * purge without a request host (cron or queue `revalidate({ paths })`) can find
42
+ * the hosts to purge.
70
43
  */
71
44
  export declare function hostRegistryKey(deploymentId: string, host: string): string;
72
45
  /**
@@ -80,68 +53,43 @@ export declare function htmlEdgeKey(host: string, pathname: string, id: string,
80
53
  * the `&__void=json` marker.
81
54
  */
82
55
  export declare function jsonEdgeKey(host: string, pathname: string, id: string, variantUrl?: string | null): string;
83
- /**
84
- * Read an ISR cache entry from KV. Returns null on cache miss, malformed
85
- * metadata, or ANY KV error (fail-open → caller renders live). The KV key is
86
- * deployment-scoped via `env.__VOID_ISR_DEPLOYMENT_ID` (see module header) and
87
- * host-scoped via `host` so one host never reads another host's cached bytes.
88
- */
56
+ /** Read an ISR entry from KV. Returns null on a miss, a malformed entry, or any KV error. */
89
57
  export declare function readIsrCache(env: IsrCacheEnv, host: string, pathname: string, variantUrl?: string | null): Promise<IsrCacheEntry | null>;
90
58
  /**
91
- * Write an ISR cache entry to KV. The `response.clone()` is taken SYNCHRONOUSLY
92
- * before any `await` so a caller that consumes `response.body` for the client
93
- * response (after kicking us off via `ctx.waitUntil`) cannot race the body
94
- * away. Any KV error is swallowed (fail-open on the write path).
59
+ * Write an ISR entry to KV. The response is cloned before any `await`, so the
60
+ * caller can stream the original body to the client. KV errors are ignored.
95
61
  */
96
62
  export declare function writeIsrCache(env: IsrCacheEnv, host: string, pathname: string, response: Response, ttl: number, variantUrl?: string | null): Promise<void>;
97
63
  /**
98
- * List every host that has registered cached content for a deployment. Used by
99
- * the on-demand `revalidate({ paths })` purge when there is no request host
100
- * (cron / queue). Fail-open: a registry read error yields the hosts collected so
101
- * far (or none) rather than throwing — degraded purge coverage, never a 500.
64
+ * List every host registered for a deployment. A registry read error returns
65
+ * the hosts collected so far instead of throwing.
102
66
  */
103
67
  export declare function listIsrHosts(kv: KVNamespace, deploymentId: string): Promise<Array<string>>;
104
- /**
105
- * Delete an ISR cache entry from KV. Used when a worker permanently opts out
106
- * via `x-revalidate: 0`. Any KV error is swallowed (fail-open).
107
- */
68
+ /** Delete an ISR entry from KV (for example after `x-revalidate: 0`). KV errors are ignored. */
108
69
  export declare function deleteIsrCache(env: IsrCacheEnv, host: string, pathname: string, variantUrl?: string | null): Promise<void>;
109
70
  export declare function clientRevalidateResponse(response: Response): Response;
110
71
  export declare function isEventStreamResponse(response: Response): boolean;
111
72
  /**
112
- * Populate the JSON edge cache from a response's embedded page data (the
113
- * `x-void-page-data` header or the `__VOID_PAGE_DATA__` inline script). No-ops
114
- * when there is no extractable page data. Any cache error is swallowed
115
- * (fail-open) so this is always safe to fire inside `ctx.waitUntil`.
73
+ * Fill the JSON edge cache from a response's page data (the `x-void-page-data`
74
+ * header or the `__VOID_PAGE_DATA__` script). Does nothing without page data;
75
+ * cache errors are ignored.
116
76
  */
117
77
  export declare function populateJsonEdgeCache(cache: Cache | undefined, cacheKey: string, response: Response, ttl: number, state: "HIT" | "MISS"): Promise<void>;
118
- /**
119
- * Build a new Request with the `X-VoidPages` header removed. Headers on an
120
- * existing Request are immutable, so we must clone into a fresh Request. ISR
121
- * only runs for GET, so there is no body to preserve.
122
- */
78
+ /** Copy a request without its `X-VoidPages` header. */
123
79
  export declare function cloneWithoutVoidPages(request: Request): Request;
124
80
  /** Cache only framework document HTML; leave client data requests and other response types live. */
125
81
  export declare function handleFrameworkIsr(request: Request, env: IsrCacheEnv, ctx: ExecutionContext, options: HandleIsrOptions, render: RenderFn): Promise<Response | null>;
126
82
  /**
127
- * Run the ISR caching ladder for a request.
128
- *
129
- * Returns a cached/rendered `Response` when ISR applies, or `null` when the
130
- * caller should render live itself (request not ISR-eligible, a read/hit
131
- * cache op failed open, or a cache miss for a URL carrying query params
132
- * outside the allowlist).
83
+ * Run the ISR cache ladder for a request. Returns the response to send, or
84
+ * `null` when the caller should render live: the request is not eligible
85
+ * (not GET, `/api`, Range, `shouldBypassIsr`, or no TTL), a cache read failed,
86
+ * or a miss carries query params outside the allowlist.
133
87
  *
134
- * Ladder:
135
- * - Bail (`null`) if not GET, an API path, a Range request, `shouldBypassIsr`,
136
- * or the path has no ISR TTL. Range requests bail BEFORE any cache read/write so they
137
- * render live (correct 206) and never touch the shared full-response entry.
138
- * - JSON (X-VoidPages) path: edge match → hit serves `clientRevalidateResponse`;
139
- * miss renders live JSON and returns it — the JSON path never writes the edge
140
- * cache. The JSON edge variant is warmed only as a side-effect of the HTML
141
- * render path (cold colo re-renders JSON).
142
- * - HTML path: L1 edge match → L2 KV. Hit serves `isrCacheResponse`
143
- * (stale hits also queue a background revalidation). Miss renders live and,
144
- * when cacheable, writes KV + JSON edge + HTML edge in the background.
88
+ * Pages-protocol (`X-VoidPages`) requests read only the JSON edge cache, and a
89
+ * miss renders live without caching; HTML renders fill the JSON variant. HTML
90
+ * requests read the edge cache, then KV. A stale hit is served while it
91
+ * revalidates in the background, and a cacheable miss fills KV and the edge
92
+ * cache in the background.
145
93
  */
146
94
  export declare function handleIsr(request: Request, env: IsrCacheEnv, ctx: ExecutionContext, options: HandleIsrOptions, render: RenderFn): Promise<Response | null>;
147
95
  //#endregion
package/dist/runtime.mjs CHANGED
@@ -10,11 +10,8 @@ function isrPrefix(deploymentId) {
10
10
  return isrCachePrefix(DIRECT_ISR_PROJECT_ID, deploymentId);
11
11
  }
12
12
  /**
13
- * KV cache key for an ISR-cached page. Deployment- AND host-scoped: cached HTML
14
- * cannot outlive the hashed assets of the deployment that produced it (a fresh
15
- * `wrangler deploy` mints a new deployment id → cold cache), and one host's
16
- * cached content never bleeds into another host served by the same worker
17
- * (custom domain + workers.dev, apex + www, etc.).
13
+ * KV key for an ISR-cached page, scoped by deployment (a new deploy starts with
14
+ * a cold cache) and by host (custom domain, workers.dev, apex, and www stay apart).
18
15
  */
19
16
  function isrKey(deploymentId, host, pathname, variantUrl) {
20
17
  return isrCacheKey(DIRECT_ISR_PROJECT_ID, deploymentId, host, pathname, variantUrl);
@@ -23,10 +20,9 @@ function isrVariantPrefix(deploymentId, host, pathname) {
23
20
  return isrCacheVariantPrefix(DIRECT_ISR_PROJECT_ID, deploymentId, host, pathname);
24
21
  }
25
22
  /**
26
- * Host-registry key. Every host that has written cached content registers itself
27
- * under `void-isr-hosts/<id>/<host>` (value `''`) so a purge with no request host
28
- * (cron / queue `revalidate({ paths })`) can enumerate the hosts to purge.
29
- * Scoped by deployment id so registrations from an old deploy do not leak.
23
+ * Host-registry key. Every host that writes cached content registers here, so a
24
+ * purge without a request host (cron or queue `revalidate({ paths })`) can find
25
+ * the hosts to purge.
30
26
  */
31
27
  function hostRegistryKey(deploymentId, host) {
32
28
  return `void-isr-hosts/${deploymentId}/${host}`;
@@ -46,12 +42,7 @@ function htmlEdgeKey(host, pathname, id, variantUrl) {
46
42
  function jsonEdgeKey(host, pathname, id, variantUrl) {
47
43
  return isrEdgeCacheUrl(host, pathname, id, "json", variantUrl);
48
44
  }
49
- /**
50
- * Read an ISR cache entry from KV. Returns null on cache miss, malformed
51
- * metadata, or ANY KV error (fail-open → caller renders live). The KV key is
52
- * deployment-scoped via `env.__VOID_ISR_DEPLOYMENT_ID` (see module header) and
53
- * host-scoped via `host` so one host never reads another host's cached bytes.
54
- */
45
+ /** Read an ISR entry from KV. Returns null on a miss, a malformed entry, or any KV error. */
55
46
  async function readIsrCache(env, host, pathname, variantUrl) {
56
47
  const kv = env.ISR_CACHE;
57
48
  if (!kv) return null;
@@ -68,10 +59,8 @@ async function readIsrCache(env, host, pathname, variantUrl) {
68
59
  return decodeIsrCacheEntry(value, metadata);
69
60
  }
70
61
  /**
71
- * Write an ISR cache entry to KV. The `response.clone()` is taken SYNCHRONOUSLY
72
- * before any `await` so a caller that consumes `response.body` for the client
73
- * response (after kicking us off via `ctx.waitUntil`) cannot race the body
74
- * away. Any KV error is swallowed (fail-open on the write path).
62
+ * Write an ISR entry to KV. The response is cloned before any `await`, so the
63
+ * caller can stream the original body to the client. KV errors are ignored.
75
64
  */
76
65
  async function writeIsrCache(env, host, pathname, response, ttl, variantUrl) {
77
66
  const kv = env.ISR_CACHE;
@@ -104,11 +93,7 @@ async function isVariantWriteAllowed(kv, deploymentId, host, pathname, key) {
104
93
  });
105
94
  return listed.keys.length < 200 || listed.keys.some((entry) => entry.name === key);
106
95
  }
107
- /**
108
- * In-isolate guard: `${id}/${host}` values whose host-registry key this isolate
109
- * has already written (or attempted). Bounds host registration to ~one KV write
110
- * per (isolate, deployment, host) instead of one per page write.
111
- */
96
+ /** `${id}/${host}` pairs this isolate has registered: one registry write per pair, not per page. */
112
97
  const registeredHosts = /* @__PURE__ */ new Set();
113
98
  async function registerIsrHost(kv, deploymentId, host) {
114
99
  const guardKey = `${deploymentId}/${host}`;
@@ -121,10 +106,8 @@ async function registerIsrHost(kv, deploymentId, host) {
121
106
  }
122
107
  }
123
108
  /**
124
- * List every host that has registered cached content for a deployment. Used by
125
- * the on-demand `revalidate({ paths })` purge when there is no request host
126
- * (cron / queue). Fail-open: a registry read error yields the hosts collected so
127
- * far (or none) rather than throwing — degraded purge coverage, never a 500.
109
+ * List every host registered for a deployment. A registry read error returns
110
+ * the hosts collected so far instead of throwing.
128
111
  */
129
112
  async function listIsrHosts(kv, deploymentId) {
130
113
  const prefix = `void-isr-hosts/${deploymentId}/`;
@@ -142,10 +125,7 @@ async function listIsrHosts(kv, deploymentId) {
142
125
  } catch {}
143
126
  return hosts;
144
127
  }
145
- /**
146
- * Delete an ISR cache entry from KV. Used when a worker permanently opts out
147
- * via `x-revalidate: 0`. Any KV error is swallowed (fail-open).
148
- */
128
+ /** Delete an ISR entry from KV (for example after `x-revalidate: 0`). KV errors are ignored. */
149
129
  async function deleteIsrCache(env, host, pathname, variantUrl) {
150
130
  const kv = env.ISR_CACHE;
151
131
  if (!kv) return;
@@ -199,10 +179,9 @@ function jsonCacheResponse(pageData, ttl, state) {
199
179
  return response;
200
180
  }
201
181
  /**
202
- * Populate the JSON edge cache from a response's embedded page data (the
203
- * `x-void-page-data` header or the `__VOID_PAGE_DATA__` inline script). No-ops
204
- * when there is no extractable page data. Any cache error is swallowed
205
- * (fail-open) so this is always safe to fire inside `ctx.waitUntil`.
182
+ * Fill the JSON edge cache from a response's page data (the `x-void-page-data`
183
+ * header or the `__VOID_PAGE_DATA__` script). Does nothing without page data;
184
+ * cache errors are ignored.
206
185
  */
207
186
  async function populateJsonEdgeCache(cache, cacheKey, response, ttl, state) {
208
187
  if (!cache) return;
@@ -213,19 +192,7 @@ async function populateJsonEdgeCache(cache, cacheKey, response, ttl, state) {
213
192
  await cache.put(cacheKey, jsonCacheResponse(pageData, ttl, state));
214
193
  } catch {}
215
194
  }
216
- /**
217
- * The per-colo edge cache (`caches.default`), or `undefined` when the Cache API
218
- * is absent/degraded. Accessed through a cast because this package has both
219
- * `@types/node` and `@cloudflare/workers-types` in scope, and Node's ambient
220
- * `CacheStorage` (which lacks `.default`) otherwise shadows the Workers one on
221
- * the `caches` global.
222
- *
223
- * Non-throwing by contract: dereferencing `caches.default` can throw if the
224
- * Cache API is missing (e.g. a non-worker runtime) or degraded. We swallow that
225
- * here and return `undefined` so `handleIsr` never rejects before its fail-open
226
- * guards run — the serving invariant is a degraded cache must render live, never
227
- * 500. Every call site guards `undefined` (via `if (!cache)` or `cache?.`).
228
- */
195
+ /** `caches.default`, or `undefined` when reading it throws (for example, with no Cache API). */
229
196
  function edgeCache() {
230
197
  try {
231
198
  return caches.default;
@@ -250,26 +217,20 @@ async function deleteSharedIsrVariants(env, cache, host, pathname, keys, variant
250
217
  await safeCacheDelete(cache, keys.htmlKey);
251
218
  await safeCacheDelete(cache, keys.jsonKey);
252
219
  }
253
- /**
254
- * Build a new Request with the `X-VoidPages` header removed. Headers on an
255
- * existing Request are immutable, so we must clone into a fresh Request. ISR
256
- * only runs for GET, so there is no body to preserve.
257
- */
220
+ /** Copy a request without its `X-VoidPages` header. */
258
221
  function cloneWithoutVoidPages(request) {
259
222
  const headers = new Headers(request.headers);
260
223
  headers.delete("X-VoidPages");
261
224
  return new Request(request, { headers });
262
225
  }
263
226
  /**
264
- * Revalidate an ISR page in the background. Re-renders as HTML (X-VoidPages
265
- * stripped), writes fresh HTML to KV + HTML edge cache, and populates the JSON
266
- * edge cache from embedded page data. Errors are caught and logged — serve
267
- * stale, don't retry. An explicit cache opt-out (including `ttl === 0`)
268
- * deletes the KV entry and both edge variants.
227
+ * Re-render a stale page in the background and refresh KV and the edge cache.
228
+ * Errors are logged and the stale entry keeps serving. An explicit cache
229
+ * opt-out (including `ttl === 0`) deletes the KV entry and both edge variants.
269
230
  */
270
231
  async function revalidateInBackground(env, request, host, pathname, defaultTtl, keys, render, variantUrl, htmlOnly, staleSinceMs) {
271
232
  try {
272
- const response = await render(cloneWithoutVoidPages(request));
233
+ const response = await render(request);
273
234
  const ttl = resolveRevalidateTtl(response, defaultTtl);
274
235
  const cache = edgeCache();
275
236
  if (response.status === 200 && isEventStreamResponse(response)) {
@@ -282,7 +243,7 @@ async function revalidateInBackground(env, request, host, pathname, defaultTtl,
282
243
  status: response.status,
283
244
  headers: sharedHtmlCacheHeaders(response, ttl, "HIT")
284
245
  }));
285
- await populateJsonEdgeCache(cache, keys.jsonKey, response, ttl, "HIT");
246
+ if (!htmlOnly) await populateJsonEdgeCache(cache, keys.jsonKey, response, ttl, "HIT");
286
247
  } else if (isUncacheableRender(response, ttl, htmlOnly) || (response.status === 404 || response.status === 410) && Date.now() - staleSinceMs > ISR_STALE_EVICT_GRACE_MS) await deleteSharedIsrVariants(env, cache, host, pathname, keys, variantUrl);
287
248
  } catch (err) {
288
249
  console.error("ISR revalidation failed:", err);
@@ -294,24 +255,16 @@ async function handleFrameworkIsr(request, env, ctx, options, render) {
294
255
  return handleIsrInternal(request, env, ctx, options, render, true);
295
256
  }
296
257
  /**
297
- * Run the ISR caching ladder for a request.
298
- *
299
- * Returns a cached/rendered `Response` when ISR applies, or `null` when the
300
- * caller should render live itself (request not ISR-eligible, a read/hit
301
- * cache op failed open, or a cache miss for a URL carrying query params
302
- * outside the allowlist).
258
+ * Run the ISR cache ladder for a request. Returns the response to send, or
259
+ * `null` when the caller should render live: the request is not eligible
260
+ * (not GET, `/api`, Range, `shouldBypassIsr`, or no TTL), a cache read failed,
261
+ * or a miss carries query params outside the allowlist.
303
262
  *
304
- * Ladder:
305
- * - Bail (`null`) if not GET, an API path, a Range request, `shouldBypassIsr`,
306
- * or the path has no ISR TTL. Range requests bail BEFORE any cache read/write so they
307
- * render live (correct 206) and never touch the shared full-response entry.
308
- * - JSON (X-VoidPages) path: edge match → hit serves `clientRevalidateResponse`;
309
- * miss renders live JSON and returns it — the JSON path never writes the edge
310
- * cache. The JSON edge variant is warmed only as a side-effect of the HTML
311
- * render path (cold colo re-renders JSON).
312
- * - HTML path: L1 edge match → L2 KV. Hit serves `isrCacheResponse`
313
- * (stale hits also queue a background revalidation). Miss renders live and,
314
- * when cacheable, writes KV + JSON edge + HTML edge in the background.
263
+ * Pages-protocol (`X-VoidPages`) requests read only the JSON edge cache, and a
264
+ * miss renders live without caching; HTML renders fill the JSON variant. HTML
265
+ * requests read the edge cache, then KV. A stale hit is served while it
266
+ * revalidates in the background, and a cacheable miss fills KV and the edge
267
+ * cache in the background.
315
268
  */
316
269
  async function handleIsr(request, env, ctx, options, render) {
317
270
  return handleIsrInternal(request, env, ctx, options, render, false);
@@ -381,7 +334,7 @@ async function handleIsrInternal(request, env, ctx, options, render, htmlOnly) {
381
334
  const ttl = resolveRevalidateTtl(response, isrTtl);
382
335
  if (isStorableRender(response, ttl, htmlOnly)) {
383
336
  ctx.waitUntil(writeIsrCache(env, host, pathname, response, ttl, variantUrl));
384
- ctx.waitUntil(populateJsonEdgeCache(cache, jsonKey, response, ttl, "HIT"));
337
+ if (!htmlOnly) ctx.waitUntil(populateJsonEdgeCache(cache, jsonKey, response, ttl, "HIT"));
385
338
  const cacheResponse = new Response(response.clone().body, {
386
339
  status: response.status,
387
340
  headers: sharedHtmlCacheHeaders(response, ttl, "HIT")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@void/isr",
3
- "version": "0.26.1",
3
+ "version": "0.28.0",
4
4
  "description": "Incremental revalidation and cache helpers for Void runtimes.",
5
5
  "homepage": "https://void.cloud",
6
6
  "bugs": {
@@ -35,7 +35,7 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@cloudflare/workers-types": "^5.20261009.1",
38
- "@void/edge": "0.26.1"
38
+ "@void/edge": "0.28.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "7.0.2",