@vinext/cloudflare 1.0.1 → 1.1.1
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/cache/cdn-adapter.runtime.d.ts +6 -2
- package/dist/cache/cdn-adapter.runtime.js +14 -4
- package/dist/cache/cdn-adapter.worker.d.ts +1 -0
- package/dist/cache/cdn-adapter.worker.js +112 -14
- package/dist/cache/config.d.ts +3 -3
- package/dist/cache/config.js +1 -1
- package/dist/cache/kv-data-adapter.d.ts +5 -1
- package/dist/cache/kv-data-adapter.runtime.js +1 -1
- package/dist/cache/kv-key.js +21 -2
- package/dist/cache/response-store-adapter.worker.js +91 -20
- package/dist/cache/response-store-cdn.runtime.js +4 -1
- package/dist/cache/response-store-data.runtime.d.ts +5 -0
- package/dist/cache/response-store-data.runtime.js +37 -10
- package/dist/cache/static-assets-adapter.build.js +8 -6
- package/dist/cache/static-assets-adapter.runtime.d.ts +3 -1
- package/dist/cache/static-assets-adapter.runtime.js +25 -4
- package/dist/cache/workers-cache-invalidation.d.ts +30 -0
- package/dist/cache/workers-cache-invalidation.js +27 -0
- package/dist/cdn-warm.d.ts +0 -3
- package/dist/cdn-warm.js +4 -17
- package/dist/cli.js +0 -1
- package/dist/deploy-help.js +0 -2
- package/dist/deploy.d.ts +0 -3
- package/dist/deploy.js +1 -3
- package/dist/worker-deployment-url.d.ts +3 -1
- package/dist/worker-deployment-url.js +4 -2
- package/package.json +7 -7
|
@@ -32,8 +32,12 @@ export declare class CloudflareCdnCacheAdapter implements CdnCacheAdapter {
|
|
|
32
32
|
set(_key: string, _data: IncrementalCacheValue | null, _ctx?: Record<string, unknown>): Promise<void>;
|
|
33
33
|
buildResponseIdentityHeaders(): CdnResponseHeaders;
|
|
34
34
|
buildResponseHeaders(input: CdnCacheableHeaderInput): CdnResponseHeaders;
|
|
35
|
-
/**
|
|
36
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Invalidate edge-cached responses by tag via the request context's cache.
|
|
37
|
+
* Stale tags map to `cache.invalidate`, so the edge serves the stale response
|
|
38
|
+
* while it refetches; expired tags map to `cache.purge`.
|
|
39
|
+
*/
|
|
40
|
+
revalidateTag(tags: string | string[], durations?: {
|
|
37
41
|
expire?: number;
|
|
38
42
|
}): Promise<void>;
|
|
39
43
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { VINEXT_CDN_BUILD_ID_HEADER, getVinextCdnBuildIdentity } from "./cdn-build-id.js";
|
|
2
2
|
import { VINEXT_EXPECTED_WORKER_VERSION_HEADER } from "../version-headers.js";
|
|
3
|
+
import { invalidateOrPurge, isStaleTagInvalidation } from "./workers-cache-invalidation.js";
|
|
3
4
|
import { isNonCacheableCacheControl, splitCacheControlDirectives } from "vinext/shims/cdn-cache";
|
|
4
5
|
import { getRequestExecutionContext } from "vinext/shims/request-context";
|
|
5
6
|
import { fnv1a64 } from "vinext/internal/utils/hash";
|
|
@@ -28,6 +29,9 @@ import { fnv1a64 } from "vinext/internal/utils/hash";
|
|
|
28
29
|
* `Cloudflare-CDN-Cache-Control` is already CDN-scoped so `max-age` is the correct knob
|
|
29
30
|
* for the edge to honor max-age + stale-while-revalidate.
|
|
30
31
|
* - `revalidateTag` purges the edge via the request context's `cache.purge({ tags })`.
|
|
32
|
+
* A stale-while-revalidate invalidation (`revalidateTag` with durations
|
|
33
|
+
* other than `expire: 0`) uses `cache.invalidate({ tags })` instead, so the
|
|
34
|
+
* edge keeps serving the stale response while it refetches in the background.
|
|
31
35
|
*
|
|
32
36
|
* Tags use fixed-size lowercase digests before emission and purge because
|
|
33
37
|
* Workers Cache tags are case-insensitive printable ASCII, while Next.js tags
|
|
@@ -208,16 +212,22 @@ var CloudflareCdnCacheAdapter = class {
|
|
|
208
212
|
"Cache-Tag": cacheTag
|
|
209
213
|
};
|
|
210
214
|
}
|
|
211
|
-
/**
|
|
212
|
-
|
|
215
|
+
/**
|
|
216
|
+
* Invalidate edge-cached responses by tag via the request context's cache.
|
|
217
|
+
* Stale tags map to `cache.invalidate`, so the edge serves the stale response
|
|
218
|
+
* while it refetches; expired tags map to `cache.purge`.
|
|
219
|
+
*/
|
|
220
|
+
async revalidateTag(tags, durations) {
|
|
213
221
|
const cache = getWorkersCache();
|
|
214
222
|
if (!cache) return;
|
|
215
223
|
const tagList = (Array.isArray(tags) ? tags : [tags]).filter((t) => typeof t === "string");
|
|
216
224
|
if (tagList.length === 0) return;
|
|
217
|
-
const
|
|
225
|
+
const options = { tags: tagList.map(encodeCloudflareCacheTag) };
|
|
226
|
+
const operation = isStaleTagInvalidation(durations) ? "invalidate" : "purge";
|
|
227
|
+
const result = await (operation === "invalidate" ? invalidateOrPurge(cache, options) : cache.purge(options));
|
|
218
228
|
if (result?.success === false) {
|
|
219
229
|
const errors = result.errors.map(({ code, message }) => `${code}: ${message}`).join(", ");
|
|
220
|
-
throw new Error(`[vinext] Workers Cache
|
|
230
|
+
throw new Error(`[vinext] Workers Cache ${operation} failed${errors ? `: ${errors}` : ""}`);
|
|
221
231
|
}
|
|
222
232
|
}
|
|
223
233
|
};
|
|
@@ -18,6 +18,7 @@ type CachePurgeOptions = {
|
|
|
18
18
|
export declare class VinextCachedResponse extends WorkerEntrypoint<unknown, unknown> {
|
|
19
19
|
fetch(request: Request): Promise<Response>;
|
|
20
20
|
purge(options: CachePurgeOptions): Promise<unknown>;
|
|
21
|
+
invalidate(options: CachePurgeOptions): Promise<unknown>;
|
|
21
22
|
}
|
|
22
23
|
/** Uncached response entrypoint retained for deployment readiness probes. */
|
|
23
24
|
export declare class VinextUncachedResponse extends WorkerEntrypoint<unknown, unknown> {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { VINEXT_CDN_BUILD_ID_HEADER, getVinextCdnBuildIdentity } from "./cdn-build-id.js";
|
|
2
2
|
import { SHARED_RESPONSE_STAGE_HEADER, finalizeGatewayResponse } from "./browser-cache-policy.js";
|
|
3
|
+
import { hasCacheMethod, invalidateOrPurge } from "./workers-cache-invalidation.js";
|
|
3
4
|
import { VINEXT_PRERENDER_READINESS_PATH, VINEXT_RSC_VARY_HEADER } from "vinext/internal/server/headers";
|
|
5
|
+
import { isNonCacheableCacheControl, splitCacheControlDirectives } from "vinext/shims/cdn-cache";
|
|
4
6
|
import { WorkerEntrypoint } from "cloudflare:workers";
|
|
5
7
|
import "vinext/internal/server/cloudflare-workers-tracing";
|
|
6
8
|
import { loadVinextRequestStage } from "vinext/server/request-stage";
|
|
@@ -13,6 +15,7 @@ const AUTHORIZATION_TRANSPORT_HEADER = "x-vinext-internal-authorization";
|
|
|
13
15
|
const REQUEST_CACHE_CONTROL_TRANSPORT_HEADER = "x-vinext-internal-request-cache-control";
|
|
14
16
|
const REQUEST_CF_TRANSPORT_HEADER = "x-vinext-internal-request-cf";
|
|
15
17
|
const REQUEST_PRAGMA_TRANSPORT_HEADER = "x-vinext-internal-request-pragma";
|
|
18
|
+
const WARMUP_USER_AGENT = "vinext-cloudflare-cdn-warm";
|
|
16
19
|
const RESPONSE_STAGE_WIRE_CACHE = {
|
|
17
20
|
bypass: "vinext-cloudflare-v1:bypass",
|
|
18
21
|
shared: "vinext-cloudflare-v1:shared"
|
|
@@ -91,9 +94,6 @@ function withWorkerHostRuntime(context, env) {
|
|
|
91
94
|
function hasFetch(value) {
|
|
92
95
|
return value !== null && (typeof value === "object" || typeof value === "function") && "fetch" in value && typeof value.fetch === "function";
|
|
93
96
|
}
|
|
94
|
-
function hasPurge(value) {
|
|
95
|
-
return value !== null && (typeof value === "object" || typeof value === "function") && "purge" in value && typeof value.purge === "function";
|
|
96
|
-
}
|
|
97
97
|
function getResponseStageBinding(context, exportName, serializedInvocation) {
|
|
98
98
|
const binding = context.exports?.[exportName];
|
|
99
99
|
if (typeof binding !== "function") return null;
|
|
@@ -179,6 +179,76 @@ function restoreResponseStageRequest(request, requestUrl, requestMethod) {
|
|
|
179
179
|
request: restored
|
|
180
180
|
};
|
|
181
181
|
}
|
|
182
|
+
function isAppPageDocumentInvocation(invocation) {
|
|
183
|
+
const props = invocation.props;
|
|
184
|
+
return props !== null && typeof props === "object" && Reflect.get(props, "kind") === "app-page" && Reflect.get(props, "isRscRequest") === false;
|
|
185
|
+
}
|
|
186
|
+
/** A Pages page render, as an HTML document or a `_next/data` payload. */
|
|
187
|
+
function isPagesPageInvocation(invocation) {
|
|
188
|
+
const props = invocation.props;
|
|
189
|
+
if (props === null || typeof props !== "object") return false;
|
|
190
|
+
const kind = Reflect.get(props, "kind");
|
|
191
|
+
return kind === "pages-page" || kind === "hybrid-pages" && Reflect.get(props, "resourceKind") === "page";
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Workers Cache revalidates an entry that has a validator with a conditional
|
|
195
|
+
* request, and serves the stale entry meanwhile or fills an expired one.
|
|
196
|
+
*/
|
|
197
|
+
function revalidatesStoredEntry(request) {
|
|
198
|
+
return request.headers.has("If-None-Match") || request.headers.has("If-Modified-Since");
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* No client waits on a deployment warm-up or on a revalidation, as with the
|
|
202
|
+
* Response Store's.
|
|
203
|
+
*/
|
|
204
|
+
function rendersWholeDocument(request) {
|
|
205
|
+
return request.headers.get("user-agent") === WARMUP_USER_AGENT || revalidatesStoredEntry(request);
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* A conditional revalidation of a Pages page skips getStaticPaths(), so only a
|
|
209
|
+
* successful render may be stored with a validator. Bot renders carry an ETag
|
|
210
|
+
* whatever their status. A 304 answers a revalidation of a stored 200, and a
|
|
211
|
+
* response a shared cache may not store keeps its validators.
|
|
212
|
+
*/
|
|
213
|
+
function withPagesRevalidationValidator(response) {
|
|
214
|
+
if (response.status === 200) return withRevalidationValidator(response);
|
|
215
|
+
const policy = selectedCachePolicy(response);
|
|
216
|
+
if (response.status === 304 || policy !== null && forbidsSharedStorage(policy) || !response.headers.has("ETag") && !response.headers.has("Last-Modified")) return response;
|
|
217
|
+
const headers = new Headers(response.headers);
|
|
218
|
+
headers.delete("ETag");
|
|
219
|
+
headers.delete("Last-Modified");
|
|
220
|
+
return new Response(response.body, {
|
|
221
|
+
headers,
|
|
222
|
+
status: response.status,
|
|
223
|
+
statusText: response.statusText
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* `no-cache` and a field-qualified `private` still let a shared cache store the
|
|
228
|
+
* response (RFC 9111 sections 5.2.2.4 and 5.2.2.7).
|
|
229
|
+
*/
|
|
230
|
+
function forbidsSharedStorage(policy) {
|
|
231
|
+
return splitCacheControlDirectives(policy).some((directive) => {
|
|
232
|
+
const normalized = directive.toLowerCase();
|
|
233
|
+
return normalized === "private" || normalized.split("=", 1)[0].trim() === "no-store";
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
function selectedCachePolicy(response) {
|
|
237
|
+
return response.headers.get("Cloudflare-CDN-Cache-Control") ?? response.headers.get("CDN-Cache-Control") ?? response.headers.get("Cache-Control");
|
|
238
|
+
}
|
|
239
|
+
/** Give a cacheable document a validator, so Workers Cache revalidates it conditionally. */
|
|
240
|
+
function withRevalidationValidator(response) {
|
|
241
|
+
if (response.headers.has("ETag") || response.headers.has("Last-Modified")) return response;
|
|
242
|
+
const policy = selectedCachePolicy(response);
|
|
243
|
+
if (policy === null || isNonCacheableCacheControl(policy)) return response;
|
|
244
|
+
const headers = new Headers(response.headers);
|
|
245
|
+
headers.set("Last-Modified", (/* @__PURE__ */ new Date()).toUTCString());
|
|
246
|
+
return new Response(response.body, {
|
|
247
|
+
headers,
|
|
248
|
+
status: response.status,
|
|
249
|
+
statusText: response.statusText
|
|
250
|
+
});
|
|
251
|
+
}
|
|
182
252
|
function preventResponseCaching(response) {
|
|
183
253
|
const headers = new Headers(response.headers);
|
|
184
254
|
headers.set("Cache-Control", "no-store");
|
|
@@ -211,17 +281,28 @@ function hasTaggedCustomVary(response) {
|
|
|
211
281
|
if (!response.headers.get("Cache-Tag")) return false;
|
|
212
282
|
return (response.headers.get("Vary") ?? "").split(",").map((name) => name.trim().toLowerCase()).filter(Boolean).some((name) => !FRAMEWORK_RESPONSE_VARY_FIELDS.has(name));
|
|
213
283
|
}
|
|
214
|
-
|
|
284
|
+
/**
|
|
285
|
+
* Workers Cache purges and invalidations only reach the calling entrypoint's
|
|
286
|
+
* cache, so route both through the cache-bearing response entrypoint.
|
|
287
|
+
*/
|
|
288
|
+
function withResponseStageCache(context) {
|
|
215
289
|
const factory = context.exports?.[CACHED_RESPONSE_STAGE_EXPORT];
|
|
216
290
|
if (typeof factory !== "function") return context;
|
|
217
291
|
const fallback = context.cache;
|
|
218
292
|
return {
|
|
219
293
|
...context,
|
|
220
|
-
cache: {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
294
|
+
cache: {
|
|
295
|
+
purge(options) {
|
|
296
|
+
const target = factory({ props: {} });
|
|
297
|
+
if (hasCacheMethod(target, "purge")) return target.purge(options);
|
|
298
|
+
return hasCacheMethod(fallback, "purge") ? fallback.purge(options) : void 0;
|
|
299
|
+
},
|
|
300
|
+
invalidate(options) {
|
|
301
|
+
const target = factory({ props: {} });
|
|
302
|
+
const targetCanUpdate = hasCacheMethod(target, "invalidate") || hasCacheMethod(target, "purge");
|
|
303
|
+
return invalidateOrPurge(targetCanUpdate ? target : fallback, options);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
225
306
|
};
|
|
226
307
|
}
|
|
227
308
|
function getResponseStageInvocation(value, expectedCache) {
|
|
@@ -282,19 +363,36 @@ var VinextCachedResponse = class extends WorkerEntrypoint {
|
|
|
282
363
|
}));
|
|
283
364
|
if (invocation.expectedResponseStageBuildIdentity !== void 0 && invocation.expectedResponseStageBuildIdentity !== getVinextCdnBuildIdentity()) return stampResponseStageBuildIdentity(responseStageUnavailable());
|
|
284
365
|
const restored = restoreResponseStageRequest(request, invocation.requestUrl, invocation.requestMethod);
|
|
285
|
-
const
|
|
286
|
-
|
|
366
|
+
const isAppPageDocument = isAppPageDocumentInvocation(invocation);
|
|
367
|
+
const isPagesPage = isPagesPageInvocation(invocation);
|
|
368
|
+
const response = await invokeResponseStage(restored.request, this.env, context, isAppPageDocument && rendersWholeDocument(request) ? {
|
|
369
|
+
...invocation,
|
|
370
|
+
options: {
|
|
371
|
+
...invocation.options,
|
|
372
|
+
renderWholeDocument: true
|
|
373
|
+
}
|
|
374
|
+
} : isPagesPage && revalidatesStoredEntry(request) ? {
|
|
375
|
+
...invocation,
|
|
376
|
+
options: {
|
|
377
|
+
...invocation.options,
|
|
378
|
+
regeneratesStoredEntry: true
|
|
379
|
+
}
|
|
380
|
+
} : invocation);
|
|
381
|
+
return stampResponseStageBuildIdentity(restored.didAccessRequestCf() || hasTaggedCustomVary(response) ? preventResponseCaching(response) : isAppPageDocument ? withRevalidationValidator(response) : isPagesPage ? withPagesRevalidationValidator(response) : response);
|
|
287
382
|
}
|
|
288
383
|
async purge(options) {
|
|
289
384
|
const cache = Reflect.get(this.ctx, "cache");
|
|
290
|
-
if (!
|
|
385
|
+
if (!hasCacheMethod(cache, "purge")) return void 0;
|
|
291
386
|
return cache.purge(options);
|
|
292
387
|
}
|
|
388
|
+
async invalidate(options) {
|
|
389
|
+
return invalidateOrPurge(Reflect.get(this.ctx, "cache"), options);
|
|
390
|
+
}
|
|
293
391
|
};
|
|
294
392
|
/** Uncached response entrypoint retained for deployment readiness probes. */
|
|
295
393
|
var VinextUncachedResponse = class extends WorkerEntrypoint {
|
|
296
394
|
async fetch(request) {
|
|
297
|
-
const context =
|
|
395
|
+
const context = withResponseStageCache(withWorkerHostRuntime(this.ctx, this.env));
|
|
298
396
|
const invocation = getResponseStageInvocation(context.props, "bypass");
|
|
299
397
|
if (!invocation) return stampResponseStageBuildIdentity(new Response("Invalid vinext response-stage invocation", {
|
|
300
398
|
status: 400,
|
|
@@ -308,7 +406,7 @@ var VinextUncachedResponse = class extends WorkerEntrypoint {
|
|
|
308
406
|
var cdn_adapter_worker_default = { async fetch(request, env, context) {
|
|
309
407
|
request = stripUntrustedTransportHeaders(request);
|
|
310
408
|
const sharedResponses = /* @__PURE__ */ new Map();
|
|
311
|
-
const stageContext =
|
|
409
|
+
const stageContext = withResponseStageCache(withWorkerHostRuntime(context, env));
|
|
312
410
|
const dispatchResponseStage = async (stageRequest, props, options) => {
|
|
313
411
|
const expectedResponseStageBuildIdentity = getVinextCdnBuildIdentity();
|
|
314
412
|
const invocation = {
|
package/dist/cache/config.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ type ResponseStoreStorageOptions = {
|
|
|
6
6
|
};
|
|
7
7
|
export declare function createWorkersResponseStoreSelfContainedConfig(options: ResponseStoreStorageOptions): Promise<{
|
|
8
8
|
cache: {
|
|
9
|
-
enabled:
|
|
9
|
+
enabled: false;
|
|
10
10
|
};
|
|
11
11
|
exports: {
|
|
12
12
|
CacheMetadata: {
|
|
@@ -45,7 +45,7 @@ export declare function createWorkersResponseStoreServiceBindingConfig(options:
|
|
|
45
45
|
accountId: string | undefined;
|
|
46
46
|
serviceBindingWorker: {
|
|
47
47
|
cache: {
|
|
48
|
-
enabled:
|
|
48
|
+
enabled: false;
|
|
49
49
|
};
|
|
50
50
|
exports: {
|
|
51
51
|
CacheMetadata: {
|
|
@@ -84,7 +84,7 @@ export declare function createWorkersResponseStoreServiceBindingConfig(options:
|
|
|
84
84
|
env: {
|
|
85
85
|
RESPONSE_STORE: import("cf/config").WorkerBinding<{
|
|
86
86
|
cache: {
|
|
87
|
-
enabled:
|
|
87
|
+
enabled: false;
|
|
88
88
|
};
|
|
89
89
|
exports: {
|
|
90
90
|
CacheMetadata: {
|
package/dist/cache/config.js
CHANGED
|
@@ -10,7 +10,7 @@ const RESPONSE_STORE_SERVICE_ENTRYPOINT = "@cloudflare/workers-response-store/se
|
|
|
10
10
|
async function createResponseStoreWorkerConfig(options) {
|
|
11
11
|
const { bindings, exports } = await import("cf/config");
|
|
12
12
|
return {
|
|
13
|
-
cache: { enabled:
|
|
13
|
+
cache: { enabled: false },
|
|
14
14
|
exports: {
|
|
15
15
|
[CACHE_METADATA_CLASS]: {
|
|
16
16
|
...exports.durableObject({ storage: "sqlite" }),
|
|
@@ -6,7 +6,11 @@ export type KvDataAdapterOptions = {
|
|
|
6
6
|
binding?: string;
|
|
7
7
|
/** Namespace prefix for cache keys (isolates multiple apps in one namespace). */
|
|
8
8
|
appPrefix?: string;
|
|
9
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* Default KV `expirationTtl` in seconds. An entry whose `expire` is longer,
|
|
11
|
+
* such as one cached with `cacheLife("max")`, keeps it for its `expire`.
|
|
12
|
+
* @default 2592000 (30 days)
|
|
13
|
+
*/
|
|
10
14
|
ttlSeconds?: number;
|
|
11
15
|
/** TTL in milliseconds for the in-memory tag-invalidation cache. @default 5000 */
|
|
12
16
|
tagCacheTtlMs?: number;
|
|
@@ -293,7 +293,7 @@ var KVCacheHandler = class {
|
|
|
293
293
|
expireAt,
|
|
294
294
|
cacheControl
|
|
295
295
|
};
|
|
296
|
-
const expirationTtl = this.ttlSeconds;
|
|
296
|
+
const expirationTtl = typeof effectiveExpire === "number" && Number.isFinite(effectiveExpire) && effectiveExpire > this.ttlSeconds ? resolveKvExpirationTtlSeconds(Math.ceil(effectiveExpire)) : this.ttlSeconds;
|
|
297
297
|
const metadata = JSON.stringify({ tags }).length <= 1024 ? { tags } : void 0;
|
|
298
298
|
return this._put(this._entryKey(key), JSON.stringify(entry), {
|
|
299
299
|
expirationTtl,
|
package/dist/cache/kv-key.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { fnv1a64 } from "vinext/internal/utils/hash";
|
|
2
3
|
//#region src/cache/kv-key.ts
|
|
3
4
|
/** Key prefix for tag invalidation timestamps. */
|
|
@@ -27,19 +28,37 @@ function kvKeyByteLength(key) {
|
|
|
27
28
|
return KV_KEY_ENCODER.encode(key).length;
|
|
28
29
|
}
|
|
29
30
|
/**
|
|
31
|
+
* Digest an entry key that is too long to store verbatim. Logical keys can
|
|
32
|
+
* carry request input, so the digest must be collision resistant: two keys
|
|
33
|
+
* sharing a digest would share an entry.
|
|
34
|
+
*/
|
|
35
|
+
function digestEntryKey(key) {
|
|
36
|
+
return createHash("sha256").update(key).digest("hex");
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Digest a tag that is too long or unsafe to store verbatim. Tags sharing a
|
|
40
|
+
* digest share one invalidation marker, which can only invalidate extra
|
|
41
|
+
* entries. Markers are permanent, so a new digest would drop the
|
|
42
|
+
* invalidations already recorded for entries still in KV.
|
|
43
|
+
*/
|
|
44
|
+
function digestTag(tag) {
|
|
45
|
+
return fnv1a64(tag);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
30
48
|
* Keep short app prefixes readable, but bound the namespace portion so a
|
|
31
49
|
* hashed entry or tag key is always able to fit within Cloudflare KV's limit.
|
|
32
50
|
*/
|
|
33
51
|
function normalizeAppPrefix(appPrefix) {
|
|
34
52
|
if (!appPrefix) return "";
|
|
35
53
|
const prefix = `${appPrefix}:`;
|
|
36
|
-
if (kvKeyByteLength(`${prefix}cache:${HASHED_KEY_PREFIX}${
|
|
54
|
+
if (kvKeyByteLength(`${prefix}cache:${HASHED_KEY_PREFIX}${digestEntryKey("")}`) <= KV_KEY_MAX_BYTES) return prefix;
|
|
37
55
|
return `__app:${fnv1a64(appPrefix)}:`;
|
|
38
56
|
}
|
|
39
57
|
function buildStorageKey(prefix, categoryPrefix, logicalKey) {
|
|
40
58
|
const key = `${prefix}${categoryPrefix}${logicalKey}`;
|
|
41
59
|
if (!(categoryPrefix === TAG_PREFIX && logicalKey.includes(":")) && kvKeyByteLength(key) <= KV_KEY_MAX_BYTES) return key;
|
|
42
|
-
|
|
60
|
+
const digest = categoryPrefix === TAG_PREFIX ? digestTag(logicalKey) : digestEntryKey(logicalKey);
|
|
61
|
+
return `${prefix}${categoryPrefix}${HASHED_KEY_PREFIX}${digest}`;
|
|
43
62
|
}
|
|
44
63
|
/** Create the deterministic key namespace for runtime cache operations. */
|
|
45
64
|
function createKvKeySpace(appPrefix) {
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { SHARED_RESPONSE_STAGE_HEADER, finalizeGatewayResponse } from "./browser-cache-policy.js";
|
|
2
2
|
import { captureResponseStoreDataRegeneration, runWithResponseStoreInvocation, setResponseStore } from "./response-store-data.runtime.js";
|
|
3
|
-
import { VINEXT_PARAMS_HEADER, VINEXT_RENDERED_PATH_AND_SEARCH_HEADER } from "vinext/internal/server/headers";
|
|
3
|
+
import { VINEXT_PARAMS_HEADER, VINEXT_RENDERED_PATH_AND_SEARCH_HEADER, VINEXT_SPECIAL_ERROR_STATUS_HEADER } from "vinext/internal/server/headers";
|
|
4
4
|
import { VINEXT_RSC_CONTENT_TYPE, VINEXT_RSC_VARY_HEADER as VINEXT_RSC_VARY_HEADER$1, applyRscCompatibilityIdHeader, applyRscDeploymentIdHeader, createCanonicalRscRequestHeaders, createCanonicalRscRequestUrl } from "vinext/internal/server/app-rsc-cache-busting";
|
|
5
|
-
import { isNonCacheableCacheControl } from "vinext/shims/cdn-cache";
|
|
5
|
+
import { hasSharedCacheCredentials, isNonCacheableCacheControl } from "vinext/shims/cdn-cache";
|
|
6
6
|
import { loadVinextRequestStage } from "vinext/server/request-stage";
|
|
7
7
|
import { loadVinextResponseStage } from "vinext/server/response-stage";
|
|
8
8
|
import { traceCachedResponseStart } from "vinext/internal/server/response-start-tracing";
|
|
9
|
+
import { isPageNotModified } from "vinext/internal/server/http-conditional";
|
|
9
10
|
//#region src/cache/response-store-adapter.worker.ts
|
|
10
11
|
const ROUTE_REVALIDATOR_ID = "vinext:response";
|
|
11
12
|
const RESPONSE_STORE_KEY_PARAM = "__workers_response_store";
|
|
@@ -41,7 +42,7 @@ function replayHeaders(request) {
|
|
|
41
42
|
function isReplayableInvocation(request, props) {
|
|
42
43
|
const method = request.method.toUpperCase();
|
|
43
44
|
if (method !== "GET" && method !== "HEAD") return false;
|
|
44
|
-
if (
|
|
45
|
+
if (hasSharedCacheCredentials(request.headers)) return false;
|
|
45
46
|
return !(props && typeof props === "object" && ["draftModeCookie", "middlewareCookieOverlay"].some((name) => name in props && Reflect.get(props, name) !== null));
|
|
46
47
|
}
|
|
47
48
|
function prepareInvocation(request, props) {
|
|
@@ -71,9 +72,9 @@ function restoreRequest(invocation) {
|
|
|
71
72
|
}
|
|
72
73
|
async function invokeRequestStage(request, env, ctx) {
|
|
73
74
|
const { handleRequestStage } = await loadVinextRequestStage();
|
|
74
|
-
return handleRequestStage(request, env, stageContext(ctx, env), (request, props, options) => invokeResponseStage(request, props, env, ctx, options.cache));
|
|
75
|
+
return handleRequestStage(request, env, stageContext(ctx, env), (request, props, options) => invokeResponseStage(request, props, env, ctx, { cache: options.cache }));
|
|
75
76
|
}
|
|
76
|
-
async function invokeResponseStage(request, props, env, ctx,
|
|
77
|
+
async function invokeResponseStage(request, props, env, ctx, stageOptions, capture, invocation) {
|
|
77
78
|
const context = stageContext(ctx, env);
|
|
78
79
|
const dispatchRequestStage = (request) => invokeRequestStage(request, env, ctx);
|
|
79
80
|
const { handleResponseStage } = await loadVinextResponseStage();
|
|
@@ -81,7 +82,7 @@ async function invokeResponseStage(request, props, env, ctx, cache, capture, inv
|
|
|
81
82
|
replayable: isReplayableInvocation(request, props),
|
|
82
83
|
serialized: serializeInvocation(request, props)
|
|
83
84
|
};
|
|
84
|
-
return runWithResponseStoreInvocation(storedInvocation.serialized, storedInvocation.replayable, () => handleResponseStage(request, env, context, props, dispatchRequestStage,
|
|
85
|
+
return runWithResponseStoreInvocation(storedInvocation.serialized, storedInvocation.replayable, () => handleResponseStage(request, env, context, props, dispatchRequestStage, stageOptions), capture);
|
|
85
86
|
}
|
|
86
87
|
function createVinextResponseStoreOptions(configuration) {
|
|
87
88
|
const locationHint = configuration?.locationHint;
|
|
@@ -94,7 +95,11 @@ function createVinextResponseStoreOptions(configuration) {
|
|
|
94
95
|
async regenerate(input, { env, ctx }) {
|
|
95
96
|
if (input.id === ROUTE_REVALIDATOR_ID) {
|
|
96
97
|
const invocation = parseInvocation(input.args.at(-1));
|
|
97
|
-
const response = await invokeResponseStage(restoreRequest(invocation), invocation.props, env, ctx,
|
|
98
|
+
const response = await invokeResponseStage(restoreRequest(invocation), invocation.props, env, ctx, {
|
|
99
|
+
cache: "shared",
|
|
100
|
+
regeneratesStoredEntry: true,
|
|
101
|
+
renderWholeDocument: true
|
|
102
|
+
});
|
|
98
103
|
if (!isCacheable(response)) {
|
|
99
104
|
await response.body?.cancel().catch(() => {});
|
|
100
105
|
throw new Error("Vinext response-stage regeneration was not cacheable");
|
|
@@ -113,7 +118,7 @@ function createVinextResponseStoreOptions(configuration) {
|
|
|
113
118
|
if (input.id === "vinext:data" && typeof input.args[0] === "string") {
|
|
114
119
|
const invocation = parseInvocation(input.args.at(-1));
|
|
115
120
|
return captureResponseStoreDataRegeneration(input.args[0], async () => {
|
|
116
|
-
await (await invokeResponseStage(restoreRequest(invocation), invocation.props, env, ctx, "bypass")).body?.pipeTo(new WritableStream());
|
|
121
|
+
await (await invokeResponseStage(restoreRequest(invocation), invocation.props, env, ctx, { cache: "bypass" })).body?.pipeTo(new WritableStream());
|
|
117
122
|
});
|
|
118
123
|
}
|
|
119
124
|
throw new Error(`Unknown vinext response-store revalidator ${input.id}`);
|
|
@@ -152,15 +157,70 @@ function withoutRequestScopedHeaders(response, responseStageProps) {
|
|
|
152
157
|
statusText: response.statusText
|
|
153
158
|
});
|
|
154
159
|
}
|
|
160
|
+
function isStoredErrorStatus(status) {
|
|
161
|
+
return status === 401 || status === 403 || status === 404;
|
|
162
|
+
}
|
|
155
163
|
function isCacheable(response) {
|
|
156
164
|
const policy = response.headers.get("Cloudflare-CDN-Cache-Control") ?? response.headers.get("CDN-Cache-Control") ?? response.headers.get("Cache-Control");
|
|
157
|
-
return response.status >= 200 && (response.status < 400 || response.status
|
|
165
|
+
return response.status >= 200 && response.status !== 304 && (response.status < 400 || isStoredErrorStatus(response.status)) && policy !== null && !isNonCacheableCacheControl(policy);
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* A page's 304 headers. Next.js sends the 304 before it sets the payload's
|
|
169
|
+
* headers, so it carries no representation or body framing headers.
|
|
170
|
+
*/
|
|
171
|
+
function notModifiedHeaders(source) {
|
|
172
|
+
const headers = new Headers(source);
|
|
173
|
+
for (const name of [
|
|
174
|
+
"Content-Encoding",
|
|
175
|
+
"Content-Length",
|
|
176
|
+
"Content-Type",
|
|
177
|
+
"Transfer-Encoding"
|
|
178
|
+
]) headers.delete(name);
|
|
179
|
+
return headers;
|
|
180
|
+
}
|
|
181
|
+
/** Send the 304 for a page whose ETag matched the request's If-None-Match. */
|
|
182
|
+
function notModifiedResponse(response) {
|
|
183
|
+
response.body?.cancel().catch(() => {});
|
|
184
|
+
return new Response(null, {
|
|
185
|
+
headers: notModifiedHeaders(response.headers),
|
|
186
|
+
status: 304
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Whether Next.js sends this stored response through `sendRenderResult`, which
|
|
191
|
+
* answers a conditional request by the page's ETag. It sends every App page
|
|
192
|
+
* that way. A Pages redirect, and a Pages data request's notFound, are sent
|
|
193
|
+
* without an ETag, as is a route handler's response. A Pages API response is
|
|
194
|
+
* never stored.
|
|
195
|
+
*/
|
|
196
|
+
function answersConditionalRequest(responseStageProps, status) {
|
|
197
|
+
if (responseStageProps === null || typeof responseStageProps !== "object") return false;
|
|
198
|
+
const kind = Reflect.get(responseStageProps, "kind");
|
|
199
|
+
if (kind === "app-page" || kind === "app-not-found") return true;
|
|
200
|
+
let isDataRequest;
|
|
201
|
+
if (kind === "hybrid-pages") {
|
|
202
|
+
if (Reflect.get(responseStageProps, "resourceKind") !== "page") return false;
|
|
203
|
+
isDataRequest = Reflect.get(responseStageProps, "isDataRequest") === true;
|
|
204
|
+
} else if (kind === "pages-page") {
|
|
205
|
+
const renderOptions = Reflect.get(responseStageProps, "renderOptions");
|
|
206
|
+
isDataRequest = renderOptions !== null && typeof renderOptions === "object" && Reflect.get(renderOptions, "isDataReq") === true;
|
|
207
|
+
} else return false;
|
|
208
|
+
return status >= 200 && status < 300 || status === 404 && !isDataRequest;
|
|
209
|
+
}
|
|
210
|
+
/** Answer a stored page's conditional request as Next.js does for a cached page. */
|
|
211
|
+
function storedForRequest(stored, request, responseStageProps) {
|
|
212
|
+
const etag = stored.headers.get("ETag");
|
|
213
|
+
return etag && answersConditionalRequest(responseStageProps, stored.status) && isPageNotModified({
|
|
214
|
+
cacheControl: request.headers.get("Cache-Control") ?? void 0,
|
|
215
|
+
ifModifiedSince: request.headers.get("If-Modified-Since") ?? void 0,
|
|
216
|
+
ifNoneMatch: request.headers.get("If-None-Match") ?? void 0
|
|
217
|
+
}, etag) ? notModifiedResponse(stored) : stored;
|
|
158
218
|
}
|
|
159
219
|
async function readStoredResponse(key) {
|
|
160
220
|
try {
|
|
161
221
|
const response = await responseStore.fetch(key);
|
|
162
222
|
const storeStatus = response.headers.get("X-Workers-Response-Store");
|
|
163
|
-
if (response.status >= 200 && response.status < 400 || response.status
|
|
223
|
+
if (response.status >= 200 && response.status < 400 || isStoredErrorStatus(response.status) && (storeStatus === "BLOB-FRESH" || storeStatus === "BLOB-STALE")) return response;
|
|
164
224
|
response.body?.cancel().catch(() => {});
|
|
165
225
|
if (response.status === 404 && storeStatus === "MISS") return null;
|
|
166
226
|
throw new Error(`Workers Response Store returned ${response.status}`);
|
|
@@ -195,7 +255,7 @@ function publicResponse(response, cacheStatus, responseStageProps, pendingAdmiss
|
|
|
195
255
|
headers.set("X-Vinext-Cache", publicCacheStatus);
|
|
196
256
|
}
|
|
197
257
|
const cacheControl = headers.get("Cache-Control");
|
|
198
|
-
if (pendingAdmission && (!cacheControl || !isNonCacheableCacheControl(cacheControl, "browser"))) headers.set("Cache-Control", "
|
|
258
|
+
if (pendingAdmission && (!cacheControl || !isNonCacheableCacheControl(cacheControl, "browser"))) headers.set("Cache-Control", "no-store, must-revalidate");
|
|
199
259
|
return traceCachedResponseStart(new Response(response.body, {
|
|
200
260
|
headers,
|
|
201
261
|
status: response.status,
|
|
@@ -211,7 +271,7 @@ function createVinextResponseStoreHandler(store) {
|
|
|
211
271
|
const handler = { async fetch(request, env, ctx) {
|
|
212
272
|
const context = stageContext(ctx, env);
|
|
213
273
|
const dispatchResponseStage = async (stageRequest, props, options) => {
|
|
214
|
-
if (options.cache === "bypass" || stageRequest.method !== "GET" && stageRequest.method !== "HEAD") return publicResponse(await invokeResponseStage(stageRequest, props, env, ctx, "bypass"), "BYPASS", props);
|
|
274
|
+
if (options.cache === "bypass" || stageRequest.method !== "GET" && stageRequest.method !== "HEAD") return publicResponse(await invokeResponseStage(stageRequest, props, env, ctx, { cache: "bypass" }), "BYPASS", props);
|
|
215
275
|
const identityRequest = options.cacheIdentity?.request ?? stageRequest;
|
|
216
276
|
const identityProps = options.cacheIdentity?.props ?? props;
|
|
217
277
|
const isWarmup = request.headers.get("user-agent") === WARMUP_USER_AGENT;
|
|
@@ -229,20 +289,24 @@ const handler = { async fetch(request, env, ctx) {
|
|
|
229
289
|
const key = await cacheRequest(invocation);
|
|
230
290
|
const stored = await readStoredResponse(key);
|
|
231
291
|
if (stored) {
|
|
232
|
-
if (!rscKey) return publicResponse(stored, "HIT", props);
|
|
292
|
+
if (!rscKey) return publicResponse(storedForRequest(stored, stageRequest, props), "HIT", props);
|
|
233
293
|
const storedRsc = await readStoredResponse(rscKey);
|
|
234
294
|
if (storedRsc) {
|
|
235
295
|
storedRsc.body?.cancel().catch(() => {});
|
|
236
|
-
return publicResponse(stored, "HIT", props);
|
|
296
|
+
return publicResponse(storedForRequest(stored, stageRequest, props), "HIT", props);
|
|
237
297
|
}
|
|
238
298
|
stored.body?.cancel().catch(() => {});
|
|
239
299
|
}
|
|
240
300
|
const capture = rscSeed ? { captureRscData: true } : isWarmup ? {} : { streamResponse: true };
|
|
241
301
|
const serializedInvocation = JSON.stringify(invocation);
|
|
242
|
-
const rendered = await invokeResponseStage(stageRequest, props, env, ctx,
|
|
302
|
+
const rendered = await invokeResponseStage(stageRequest, props, env, ctx, isWarmup ? {
|
|
303
|
+
cache: "shared",
|
|
304
|
+
renderWholeDocument: true
|
|
305
|
+
} : { cache: "shared" }, capture, {
|
|
243
306
|
replayable: isReplayableInvocation(stageRequest, props),
|
|
244
307
|
serialized: options.cacheIdentity ? serializeInvocation(stageRequest, props) : serializedInvocation
|
|
245
308
|
});
|
|
309
|
+
const foreground = (response) => capture.notModified ? notModifiedResponse(response) : response;
|
|
246
310
|
if (capture.admittedResponse) {
|
|
247
311
|
ctx.waitUntil(capture.admittedResponse.then(async (admitted) => {
|
|
248
312
|
if (!isCacheable(admitted)) {
|
|
@@ -262,17 +326,23 @@ const handler = { async fetch(request, env, ctx) {
|
|
|
262
326
|
error: error instanceof Error ? error.message : String(error)
|
|
263
327
|
}));
|
|
264
328
|
}));
|
|
265
|
-
return publicResponse(rendered, "MISS", props, true);
|
|
329
|
+
if (!capture.notModified) return publicResponse(rendered, "MISS", props, true);
|
|
330
|
+
const notModified = notModifiedResponse(rendered);
|
|
331
|
+
const admitted = await capture.admittedResponse.catch(() => null);
|
|
332
|
+
return admitted ? publicResponse(new Response(null, {
|
|
333
|
+
headers: notModifiedHeaders(admitted.headers),
|
|
334
|
+
status: 304
|
|
335
|
+
}), "MISS", props) : publicResponse(notModified, "MISS", props, true);
|
|
266
336
|
}
|
|
267
337
|
if (!isCacheable(rendered)) {
|
|
268
338
|
capture?.rscData?.catch(() => {});
|
|
269
|
-
return publicResponse(rendered, "BYPASS", props);
|
|
339
|
+
return publicResponse(foreground(rendered), "BYPASS", props);
|
|
270
340
|
}
|
|
271
341
|
if (rscSeed && !capture?.rscData) {
|
|
272
342
|
await rendered.body?.cancel();
|
|
273
343
|
throw new Error("Vinext response-store warmup did not capture the App page RSC payload");
|
|
274
344
|
}
|
|
275
|
-
const [
|
|
345
|
+
const [foregroundBody, cacheBody] = rendered.body ? rendered.body.tee() : [null, null];
|
|
276
346
|
const cacheResponse = withoutRequestScopedHeaders(new Response(cacheBody, rendered), props);
|
|
277
347
|
try {
|
|
278
348
|
await responseStore.put(key, cacheResponse, {
|
|
@@ -302,10 +372,11 @@ const handler = { async fetch(request, env, ctx) {
|
|
|
302
372
|
rscHeaders.set("Vary", VINEXT_RSC_VARY_HEADER$1);
|
|
303
373
|
applyRscCompatibilityIdHeader(rscHeaders);
|
|
304
374
|
applyRscDeploymentIdHeader(rscHeaders);
|
|
375
|
+
if (isStoredErrorStatus(rendered.status)) rscHeaders.set(VINEXT_SPECIAL_ERROR_STATUS_HEADER, String(rendered.status));
|
|
305
376
|
const serializedRscInvocation = JSON.stringify(rscInvocation);
|
|
306
377
|
await responseStore.put(rscKey, new Response(rscData, {
|
|
307
378
|
headers: rscHeaders,
|
|
308
|
-
status: 200
|
|
379
|
+
status: rendered.status >= 300 && rendered.status < 400 ? 200 : rendered.status
|
|
309
380
|
}), {
|
|
310
381
|
coalesce: true,
|
|
311
382
|
revalidator: {
|
|
@@ -314,7 +385,7 @@ const handler = { async fetch(request, env, ctx) {
|
|
|
314
385
|
}
|
|
315
386
|
});
|
|
316
387
|
}
|
|
317
|
-
return publicResponse(new Response(
|
|
388
|
+
return publicResponse(foreground(new Response(foregroundBody, rendered)), "MISS", props);
|
|
318
389
|
};
|
|
319
390
|
const { handleRequestStage } = await loadVinextRequestStage();
|
|
320
391
|
const sharedResponses = /* @__PURE__ */ new Map();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import createCloudflareCdnCacheAdapter from "./cdn-adapter.runtime.js";
|
|
2
|
-
import { captureResponseStoreRscData, deferResponseStoreAdmission } from "./response-store-data.runtime.js";
|
|
2
|
+
import { captureResponseStoreRscData, deferResponseStoreAdmission, deferResponseStoreNotModified } from "./response-store-data.runtime.js";
|
|
3
3
|
//#region src/cache/response-store-cdn.runtime.ts
|
|
4
4
|
/** Response Store owns page serving and SWR; vinext only emits admitted response policy. */
|
|
5
5
|
var ResponseStoreCdnCacheAdapter = class {
|
|
@@ -33,6 +33,9 @@ var ResponseStoreCdnCacheAdapter = class {
|
|
|
33
33
|
captureAppPageRscData(rscData) {
|
|
34
34
|
captureResponseStoreRscData(rscData);
|
|
35
35
|
}
|
|
36
|
+
deferNotModifiedResponse() {
|
|
37
|
+
return deferResponseStoreNotModified();
|
|
38
|
+
}
|
|
36
39
|
async revalidateTag() {}
|
|
37
40
|
};
|
|
38
41
|
function createResponseStoreCdnCacheAdapter(context) {
|
|
@@ -4,6 +4,8 @@ import { CacheHandler, CacheHandlerValue, IncrementalCacheValue } from "vinext/s
|
|
|
4
4
|
export type ResponseStoreInvocationCapture = {
|
|
5
5
|
admittedResponse?: Promise<Response>;
|
|
6
6
|
captureRscData?: boolean;
|
|
7
|
+
/** The render matched the request's If-None-Match; the adapter sends the 304. */
|
|
8
|
+
notModified?: boolean;
|
|
7
9
|
rscData?: Promise<ArrayBuffer>;
|
|
8
10
|
streamResponse?: boolean;
|
|
9
11
|
};
|
|
@@ -15,6 +17,8 @@ export declare function setResponseStore(store: WorkersResponseStore): void;
|
|
|
15
17
|
export declare function runWithResponseStoreInvocation<T>(serialized: string, replayable: boolean, callback: () => T, capture?: ResponseStoreInvocationCapture): T;
|
|
16
18
|
/** Retain the RSC side stream only when the outer response-store invocation requested it. */
|
|
17
19
|
export declare function captureResponseStoreRscData(rscData: Promise<ArrayBuffer>): void;
|
|
20
|
+
/** A shared invocation stores the page, so its adapter sends the page's 304. */
|
|
21
|
+
export declare function deferResponseStoreNotModified(): boolean;
|
|
18
22
|
/** Stream the foreground body while retaining an independent admission branch. */
|
|
19
23
|
export declare function deferResponseStoreAdmission(response: Response, complete: (response: Response) => Promise<Response>): Response | null;
|
|
20
24
|
/** Re-render an invocation and return the exact rewritten data-cache entry. */
|
|
@@ -25,6 +29,7 @@ export declare class WorkersResponseStoreCacheHandler implements CacheHandler {
|
|
|
25
29
|
constructor(store?: WorkersResponseStore);
|
|
26
30
|
get(key: string, context?: Record<string, unknown>): Promise<CacheHandlerValue | null>;
|
|
27
31
|
set(key: string, value: IncrementalCacheValue | null, context?: Record<string, unknown>): Promise<void>;
|
|
32
|
+
private write;
|
|
28
33
|
revalidateTag(tags: string | string[], durations?: {
|
|
29
34
|
expire?: number;
|
|
30
35
|
}): Promise<void>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isStaleTagInvalidation } from "./workers-cache-invalidation.js";
|
|
1
2
|
import { encodeCloudflareCacheTag } from "./cdn-adapter.runtime.js";
|
|
2
3
|
import { Buffer } from "node:buffer";
|
|
3
4
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
@@ -32,6 +33,13 @@ function captureResponseStoreRscData(rscData) {
|
|
|
32
33
|
if (capture?.captureRscData) capture.rscData = rscData;
|
|
33
34
|
else rscData.catch(() => {});
|
|
34
35
|
}
|
|
36
|
+
/** A shared invocation stores the page, so its adapter sends the page's 304. */
|
|
37
|
+
function deferResponseStoreNotModified() {
|
|
38
|
+
const capture = invocationStorage.getStore()?.capture;
|
|
39
|
+
if (!capture) return false;
|
|
40
|
+
capture.notModified = true;
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
35
43
|
/** Stream the foreground body while retaining an independent admission branch. */
|
|
36
44
|
function deferResponseStoreAdmission(response, complete) {
|
|
37
45
|
const capture = invocationStorage.getStore()?.capture;
|
|
@@ -136,8 +144,15 @@ function deferResponseStoreAdmission(response, complete) {
|
|
|
136
144
|
}
|
|
137
145
|
/** Re-render an invocation and return the exact rewritten data-cache entry. */
|
|
138
146
|
async function captureResponseStoreDataRegeneration(key, callback) {
|
|
139
|
-
const scope = {
|
|
140
|
-
|
|
147
|
+
const scope = {
|
|
148
|
+
sideWrites: [],
|
|
149
|
+
targetKey: key
|
|
150
|
+
};
|
|
151
|
+
try {
|
|
152
|
+
await regenerationStorage.run(scope, callback);
|
|
153
|
+
} finally {
|
|
154
|
+
while (scope.sideWrites.length) await Promise.allSettled(scope.sideWrites.splice(0));
|
|
155
|
+
}
|
|
141
156
|
if (!scope.captured) throw new Error(`vinext response-store regeneration did not rewrite data key ${key}`);
|
|
142
157
|
return scope.captured;
|
|
143
158
|
}
|
|
@@ -180,7 +195,8 @@ function deserialize(value) {
|
|
|
180
195
|
}
|
|
181
196
|
function cachePolicy(revalidate, expire) {
|
|
182
197
|
if (revalidate === false || revalidate === void 0) return `public, max-age=${CACHE_MAX_AGE_SECONDS}`;
|
|
183
|
-
|
|
198
|
+
const staleSeconds = expire === void 0 ? CACHE_MAX_AGE_SECONDS : Math.max(0, expire - revalidate);
|
|
199
|
+
return `public, max-age=${Math.max(0, revalidate)}, stale-while-revalidate=${staleSeconds}`;
|
|
184
200
|
}
|
|
185
201
|
var WorkersResponseStoreCacheHandler = class {
|
|
186
202
|
store;
|
|
@@ -231,9 +247,9 @@ var WorkersResponseStoreCacheHandler = class {
|
|
|
231
247
|
const requestedStale = typeof requestedRevalidate === "number" && requestedRevalidate > 0 && age > requestedRevalidate * 1e3;
|
|
232
248
|
let cacheState;
|
|
233
249
|
if (response.headers.get(REPLAYABLE_HEADER) === "1") {
|
|
234
|
-
if (requestedStale) cacheState = "stale";
|
|
250
|
+
if (requestedStale && storeStatus !== "BLOB-STALE") cacheState = "stale";
|
|
235
251
|
} else if (typeof entry.cacheControl?.expire === "number" && age > entry.cacheControl.expire * 1e3) cacheState = "expired";
|
|
236
|
-
else if (requestedStale || typeof entry.cacheControl?.revalidate === "number" && entry.cacheControl.revalidate > 0 && age > entry.cacheControl.revalidate * 1e3) cacheState = "stale";
|
|
252
|
+
else if (requestedStale || storeStatus === "BLOB-STALE" || typeof entry.cacheControl?.revalidate === "number" && entry.cacheControl.revalidate > 0 && age > entry.cacheControl.revalidate * 1e3) cacheState = "stale";
|
|
237
253
|
return {
|
|
238
254
|
lastModified: entry.lastModified,
|
|
239
255
|
value: entry.value,
|
|
@@ -248,7 +264,13 @@ var WorkersResponseStoreCacheHandler = class {
|
|
|
248
264
|
return null;
|
|
249
265
|
}
|
|
250
266
|
}
|
|
251
|
-
|
|
267
|
+
set(key, value, context) {
|
|
268
|
+
const write = this.write(key, value, context);
|
|
269
|
+
const regeneration = regenerationStorage.getStore();
|
|
270
|
+
if (regeneration && regeneration.targetKey !== key) regeneration.sideWrites.push(write);
|
|
271
|
+
return write;
|
|
272
|
+
}
|
|
273
|
+
async write(key, value, context) {
|
|
252
274
|
let revalidate = readCacheControlField(context, "revalidate");
|
|
253
275
|
if (value && "revalidate" in value) revalidate = value.revalidate;
|
|
254
276
|
if (revalidate === 0) return;
|
|
@@ -287,12 +309,14 @@ var WorkersResponseStoreCacheHandler = class {
|
|
|
287
309
|
...revalidator ? { [REPLAYABLE_HEADER]: "1" } : {}
|
|
288
310
|
} });
|
|
289
311
|
const regeneration = regenerationStorage.getStore();
|
|
290
|
-
if (regeneration) {
|
|
291
|
-
|
|
312
|
+
if (regeneration?.targetKey === key) {
|
|
313
|
+
regeneration.captured = response;
|
|
292
314
|
return;
|
|
293
315
|
}
|
|
316
|
+
if (regeneration && !revalidator) return;
|
|
294
317
|
await this.store.put(await cacheRequest(key), response, {
|
|
295
318
|
...revalidator ? { revalidator } : {},
|
|
319
|
+
...revalidator?.id !== "vinext:cache-function" ? { expiryBehavior: "miss" } : {},
|
|
296
320
|
coalesce: true,
|
|
297
321
|
purgeExisting: true
|
|
298
322
|
});
|
|
@@ -300,7 +324,10 @@ var WorkersResponseStoreCacheHandler = class {
|
|
|
300
324
|
async revalidateTag(tags, durations) {
|
|
301
325
|
const encodedTags = (Array.isArray(tags) ? tags : [tags]).map(encodeCloudflareCacheTag);
|
|
302
326
|
if (!encodedTags.length) return;
|
|
303
|
-
if (durations
|
|
327
|
+
if (isStaleTagInvalidation(durations)) await this.store.invalidate({
|
|
328
|
+
tags: encodedTags,
|
|
329
|
+
...durations?.expire === void 0 ? {} : { expire: durations.expire }
|
|
330
|
+
});
|
|
304
331
|
else await this.store.purge({ tags: encodedTags });
|
|
305
332
|
}
|
|
306
333
|
};
|
|
@@ -308,4 +335,4 @@ function createResponseStoreDataCacheAdapter() {
|
|
|
308
335
|
return new WorkersResponseStoreCacheHandler();
|
|
309
336
|
}
|
|
310
337
|
//#endregion
|
|
311
|
-
export { CACHE_FUNCTION_REVALIDATOR_ID, DATA_REVALIDATOR_ID, WorkersResponseStoreCacheHandler, captureResponseStoreDataRegeneration, captureResponseStoreRscData, createResponseStoreDataCacheAdapter as default, deferResponseStoreAdmission, runWithResponseStoreInvocation, setResponseStore };
|
|
338
|
+
export { CACHE_FUNCTION_REVALIDATOR_ID, DATA_REVALIDATOR_ID, WorkersResponseStoreCacheHandler, captureResponseStoreDataRegeneration, captureResponseStoreRscData, createResponseStoreDataCacheAdapter as default, deferResponseStoreAdmission, deferResponseStoreNotModified, runWithResponseStoreInvocation, setResponseStore };
|
|
@@ -2,8 +2,8 @@ import { STATIC_ASSET_CACHE_PATH } from "./static-assets-adapter.shared.js";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
|
-
import { readPrerenderManifest } from "vinext/internal/server/prerender-manifest";
|
|
6
|
-
import { appIsrCacheKey, pagesIsrCacheKey } from "vinext/internal/server/isr-cache";
|
|
5
|
+
import { isFallbackShellArtifactPath, readPrerenderManifest } from "vinext/internal/server/prerender-manifest";
|
|
6
|
+
import { appIsrCacheKey, appIsrFallbackShellKey, pagesIsrCacheKey } from "vinext/internal/server/isr-cache";
|
|
7
7
|
import { extractVinextNextDataJson } from "vinext/internal/client/vinext-next-data";
|
|
8
8
|
import { normalizePregeneratedPathname } from "vinext/internal/server/pregenerated-concrete-paths";
|
|
9
9
|
import { getAppRouteOutputPath, getOutputPath, getRscOutputPath } from "vinext/internal/utils/prerender-output-paths";
|
|
@@ -33,12 +33,13 @@ function writeCacheAsset(outputDir, index, key, kind, sourcePath, route, content
|
|
|
33
33
|
const id = cacheAssetId(key);
|
|
34
34
|
const extension = kind;
|
|
35
35
|
const policy = cacheControl(route);
|
|
36
|
+
const headers = kind !== "rsc" ? route.headers : route.responseStatus !== void 0 && route.headers?.location !== void 0 ? { location: route.headers.location } : void 0;
|
|
36
37
|
const metadata = {
|
|
37
38
|
kind,
|
|
38
39
|
lastModified: fs.statSync(sourcePath).mtimeMs,
|
|
39
40
|
...policy ? { cacheControl: policy } : {},
|
|
40
|
-
...
|
|
41
|
-
...
|
|
41
|
+
...headers ? { headers } : {},
|
|
42
|
+
...route.responseStatus !== void 0 ? { status: route.responseStatus } : {}
|
|
42
43
|
};
|
|
43
44
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
44
45
|
const outputPath = path.join(outputDir, `${id}.${extension}`);
|
|
@@ -66,8 +67,9 @@ function finalizeStaticAssetsPrerenderOutput(root, options = {}) {
|
|
|
66
67
|
const pathname = route.path ?? route.route;
|
|
67
68
|
const cachePathname = normalizePregeneratedPathname(pathname);
|
|
68
69
|
if (route.router === "app") {
|
|
69
|
-
|
|
70
|
-
count += Number(writeCacheAsset(outputDir, index,
|
|
70
|
+
const appKey = isFallbackShellArtifactPath(pathname, route) ? appIsrFallbackShellKey : appIsrCacheKey;
|
|
71
|
+
count += Number(writeCacheAsset(outputDir, index, appKey(cachePathname, "html", manifest.buildId), "html", path.join(prerenderDir, getOutputPath(pathname, manifest.trailingSlash ?? false)), route));
|
|
72
|
+
count += Number(writeCacheAsset(outputDir, index, appKey(cachePathname, "rsc", manifest.buildId), "rsc", path.join(prerenderDir, getRscOutputPath(pathname)), route));
|
|
71
73
|
} else if (route.router === "pages") {
|
|
72
74
|
const sourcePath = path.join(prerenderDir, getOutputPath(pathname, manifest.trailingSlash ?? false));
|
|
73
75
|
if (!fs.existsSync(sourcePath)) continue;
|
|
@@ -9,9 +9,11 @@ export declare class StaticAssetsCacheAdapter implements CdnCacheAdapter {
|
|
|
9
9
|
readonly assets: AssetFetcher;
|
|
10
10
|
readonly hasPrerenderedPages = true;
|
|
11
11
|
readonly ownsBackgroundRevalidation = false;
|
|
12
|
-
private
|
|
12
|
+
private index;
|
|
13
|
+
private pendingIndex;
|
|
13
14
|
constructor(assets: AssetFetcher);
|
|
14
15
|
private loadIndex;
|
|
16
|
+
private fetchIndex;
|
|
15
17
|
get(key: string): Promise<CacheHandlerValue | null>;
|
|
16
18
|
set(_key: string, _data: IncrementalCacheValue | null, _ctx?: Record<string, unknown>): Promise<void>;
|
|
17
19
|
buildResponseHeaders(input: CdnCacheableHeaderInput): CdnResponseHeaders;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { STATIC_ASSET_CACHE_PATH } from "./static-assets-adapter.shared.js";
|
|
2
2
|
import { DefaultCdnCacheAdapter } from "vinext/shims/cdn-cache";
|
|
3
|
+
import { getRequestExecutionContext } from "vinext/shims/request-context";
|
|
3
4
|
//#region src/cache/static-assets-adapter.runtime.ts
|
|
4
5
|
async function cacheAssetId(key) {
|
|
5
6
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(key));
|
|
@@ -12,12 +13,32 @@ var StaticAssetsCacheAdapter = class {
|
|
|
12
13
|
assets;
|
|
13
14
|
hasPrerenderedPages = true;
|
|
14
15
|
ownsBackgroundRevalidation = false;
|
|
15
|
-
|
|
16
|
+
index;
|
|
17
|
+
pendingIndex;
|
|
16
18
|
constructor(assets) {
|
|
17
19
|
this.assets = assets;
|
|
18
20
|
}
|
|
19
21
|
loadIndex() {
|
|
20
|
-
|
|
22
|
+
if (this.index !== void 0) return Promise.resolve(this.index);
|
|
23
|
+
if (this.pendingIndex) return this.pendingIndex;
|
|
24
|
+
const ctx = getRequestExecutionContext();
|
|
25
|
+
if (!ctx) return this.fetchIndex();
|
|
26
|
+
const pending = this.fetchIndex().finally(() => {
|
|
27
|
+
if (this.pendingIndex === pending) this.pendingIndex = void 0;
|
|
28
|
+
});
|
|
29
|
+
this.pendingIndex = pending;
|
|
30
|
+
ctx.waitUntil(pending.catch(() => {}));
|
|
31
|
+
return pending;
|
|
32
|
+
}
|
|
33
|
+
async fetchIndex() {
|
|
34
|
+
const response = await this.assets.fetch(`https://vinext.invalid${STATIC_ASSET_CACHE_PATH}/index.json`);
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
response.body?.cancel().catch(() => {});
|
|
37
|
+
if (response.status === 404) this.index = null;
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
const value = await response.json();
|
|
41
|
+
return this.index = value !== null && typeof value === "object" ? value : null;
|
|
21
42
|
}
|
|
22
43
|
async get(key) {
|
|
23
44
|
const id = await cacheAssetId(key);
|
|
@@ -56,10 +77,10 @@ var StaticAssetsCacheAdapter = class {
|
|
|
56
77
|
kind: "APP_PAGE",
|
|
57
78
|
html: "",
|
|
58
79
|
rscData: await bodyResponse.arrayBuffer(),
|
|
59
|
-
headers:
|
|
80
|
+
headers: metadata.headers,
|
|
60
81
|
postponed: void 0,
|
|
61
82
|
prerendered: true,
|
|
62
|
-
status:
|
|
83
|
+
status: metadata.status
|
|
63
84
|
};
|
|
64
85
|
else value = {
|
|
65
86
|
kind: "APP_ROUTE",
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
//#region src/cache/workers-cache-invalidation.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Workers Cache tag invalidation shared by the CDN adapter, its worker
|
|
4
|
+
* entrypoints, and the Response Store data adapter.
|
|
5
|
+
*/
|
|
6
|
+
type CacheInvalidationOptions = {
|
|
7
|
+
tags: string[];
|
|
8
|
+
};
|
|
9
|
+
type CacheMethodName = "invalidate" | "purge";
|
|
10
|
+
type CacheMethods<Name extends CacheMethodName> = Record<Name, (options: CacheInvalidationOptions) => unknown>;
|
|
11
|
+
/**
|
|
12
|
+
* Whether `revalidateTag` marks its tags stale rather than expiring them.
|
|
13
|
+
*
|
|
14
|
+
* Next.js marks a tag stale whenever durations are passed, and expires it
|
|
15
|
+
* immediately when they are absent (`updateTag`, `revalidatePath`, the legacy
|
|
16
|
+
* single-argument `revalidateTag`) or `expire` is 0.
|
|
17
|
+
* https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/incremental-cache/file-system-cache.ts
|
|
18
|
+
*/
|
|
19
|
+
export declare function isStaleTagInvalidation(durations?: {
|
|
20
|
+
expire?: number;
|
|
21
|
+
}): boolean;
|
|
22
|
+
export declare function hasCacheMethod<Name extends CacheMethodName>(value: unknown, name: Name): value is CacheMethods<Name>;
|
|
23
|
+
/**
|
|
24
|
+
* Mark matching Workers Cache responses stale so the edge keeps serving them
|
|
25
|
+
* while it refetches in the background. Local Miniflare does not implement
|
|
26
|
+
* `invalidate()` yet, so it falls back to a hard purge there. Returns
|
|
27
|
+
* `undefined` when the cache supports neither.
|
|
28
|
+
*/
|
|
29
|
+
export declare function invalidateOrPurge(cache: unknown, options: CacheInvalidationOptions): unknown;
|
|
30
|
+
//#endregion
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
//#region src/cache/workers-cache-invalidation.ts
|
|
2
|
+
/**
|
|
3
|
+
* Whether `revalidateTag` marks its tags stale rather than expiring them.
|
|
4
|
+
*
|
|
5
|
+
* Next.js marks a tag stale whenever durations are passed, and expires it
|
|
6
|
+
* immediately when they are absent (`updateTag`, `revalidatePath`, the legacy
|
|
7
|
+
* single-argument `revalidateTag`) or `expire` is 0.
|
|
8
|
+
* https://github.com/vercel/next.js/blob/canary/packages/next/src/server/lib/incremental-cache/file-system-cache.ts
|
|
9
|
+
*/
|
|
10
|
+
function isStaleTagInvalidation(durations) {
|
|
11
|
+
return durations !== void 0 && !(typeof durations.expire === "number" && durations.expire <= 0);
|
|
12
|
+
}
|
|
13
|
+
function hasCacheMethod(value, name) {
|
|
14
|
+
return value !== null && (typeof value === "object" || typeof value === "function") && name in value && typeof Reflect.get(value, name) === "function";
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Mark matching Workers Cache responses stale so the edge keeps serving them
|
|
18
|
+
* while it refetches in the background. Local Miniflare does not implement
|
|
19
|
+
* `invalidate()` yet, so it falls back to a hard purge there. Returns
|
|
20
|
+
* `undefined` when the cache supports neither.
|
|
21
|
+
*/
|
|
22
|
+
function invalidateOrPurge(cache, options) {
|
|
23
|
+
if (hasCacheMethod(cache, "invalidate")) return cache.invalidate(options);
|
|
24
|
+
return hasCacheMethod(cache, "purge") ? cache.purge(options) : void 0;
|
|
25
|
+
}
|
|
26
|
+
//#endregion
|
|
27
|
+
export { hasCacheMethod, invalidateOrPurge, isStaleTagInvalidation };
|
package/dist/cdn-warm.d.ts
CHANGED
|
@@ -43,7 +43,6 @@ export declare const DEFAULT_STAGED_READINESS_INTERVAL_MS = 1000;
|
|
|
43
43
|
export declare const DEFAULT_STAGED_READINESS_PHASE_TIMEOUT_MS = 120000;
|
|
44
44
|
export type PrerenderCdnWarmOptions = Omit<CdnWarmOptions, "paths"> & {
|
|
45
45
|
root: string;
|
|
46
|
-
includeFallbackShells?: boolean;
|
|
47
46
|
/** Use the manifest build identity unless the configured adapter cannot expose it. */
|
|
48
47
|
validateBuildIdentity?: boolean;
|
|
49
48
|
};
|
|
@@ -92,13 +91,11 @@ export type PrerenderWarmPlan = {
|
|
|
92
91
|
};
|
|
93
92
|
type PrerenderWarmPlanOptions = {
|
|
94
93
|
includeCanonicalRsc?: boolean;
|
|
95
|
-
includeFallbackShells?: boolean;
|
|
96
94
|
strict?: boolean;
|
|
97
95
|
};
|
|
98
96
|
export declare function createPrerenderWarmPlan(root: string, manifest: PrerenderPathManifest, options?: PrerenderWarmPlanOptions): PrerenderWarmPlan;
|
|
99
97
|
export declare function readPrerenderWarmPlan(root: string, options?: PrerenderWarmPlanOptions): PrerenderWarmPlan;
|
|
100
98
|
export declare function readPrerenderWarmPaths(root: string, options?: {
|
|
101
|
-
includeFallbackShells?: boolean;
|
|
102
99
|
strict?: boolean;
|
|
103
100
|
}): string[];
|
|
104
101
|
export declare function getWarmPathsFromPrerenderManifest(manifest: PrerenderManifest, options?: PrerenderedPathSelectionOptions): string[];
|
package/dist/cdn-warm.js
CHANGED
|
@@ -6,7 +6,7 @@ import { randomUUID } from "node:crypto";
|
|
|
6
6
|
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
7
7
|
import { VINEXT_CACHE_HEADER, VINEXT_PRERENDER_READINESS_HEADER, VINEXT_PRERENDER_READINESS_PATH, VINEXT_PRERENDER_SECRET_HEADER } from "vinext/internal/server/headers";
|
|
8
8
|
import { PRERENDER_PATHS_MANIFEST } from "vinext/internal/build/prerender-paths";
|
|
9
|
-
import { getPrerenderedConcretePaths
|
|
9
|
+
import { getPrerenderedConcretePaths } from "vinext/internal/server/prerender-manifest";
|
|
10
10
|
import { VINEXT_RSC_BUILD_ID_HEADER, VINEXT_RSC_CONTENT_TYPE, VINEXT_RSC_VARY_HEADER as VINEXT_RSC_VARY_HEADER$1, createCanonicalLoadingShellRscRequestHeaders, createCanonicalRscRequestHeaders, createCanonicalRscRequestUrl, createRscRequestUrl } from "vinext/internal/server/app-rsc-cache-busting";
|
|
11
11
|
import { isNonCacheableCacheControl } from "vinext/shims/cdn-cache";
|
|
12
12
|
import { normalizePathTrailingSlash } from "vinext/shims/url-utils";
|
|
@@ -73,17 +73,7 @@ function createPrerenderWarmPlan(root, manifest, options) {
|
|
|
73
73
|
const supportsCanonicalRsc = (manifest.responseVary === "verbatim" || options?.includeCanonicalRsc === true) && manifest.rscPaths !== void 0 && manifest.rscBuildId !== void 0;
|
|
74
74
|
const applyConfig = (pathname) => applyWarmPathConfig(pathname, manifest);
|
|
75
75
|
const routePatterns = manifest.routePatterns ? Object.fromEntries(Object.entries(manifest.routePatterns).map(([pathname, route]) => [pathname.includes("/_next/data/") ? pathname : applyConfig(pathname), route])) : void 0;
|
|
76
|
-
|
|
77
|
-
if (options?.includeFallbackShells === true) {
|
|
78
|
-
const prerenderManifest = readPrerenderManifest(path.join(root, "dist", "server", "vinext-prerender.json"));
|
|
79
|
-
if (!prerenderManifest) console.warn("[vinext] CDN warmup fallback shells requested, but prerender manifest not found; warming build-discovered paths only.");
|
|
80
|
-
else if (!prerenderManifest.buildId || prerenderManifest.buildId !== manifest.buildId) {
|
|
81
|
-
const message = "[vinext] CDN warmup skipped: prerender manifest buildId does not match dist/server/BUILD_ID.";
|
|
82
|
-
if (options.strict) throw new Error(message);
|
|
83
|
-
console.warn(message);
|
|
84
|
-
htmlPaths = [];
|
|
85
|
-
} else htmlPaths = getPrerenderedConcretePaths(prerenderManifest, { includeFallbackShells: true }).filter((pathname) => pathname.startsWith("/")).filter((pathname) => !manifest.excludedWarmPaths?.includes(pathname)).map(applyConfig);
|
|
86
|
-
}
|
|
76
|
+
const htmlPaths = pathPlan.paths;
|
|
87
77
|
return {
|
|
88
78
|
...manifest.appPaths ? { appPaths: manifest.appPaths.map(applyConfig) } : {},
|
|
89
79
|
buildId: manifest.buildId,
|
|
@@ -482,7 +472,7 @@ function validateBuildIdentity(response, expectedBuildId) {
|
|
|
482
472
|
return null;
|
|
483
473
|
}
|
|
484
474
|
function isExpectedTerminalStatus(status) {
|
|
485
|
-
return status >= 300 && status < 400 || status === 404;
|
|
475
|
+
return status >= 300 && status < 400 || status === 401 || status === 403 || status === 404;
|
|
486
476
|
}
|
|
487
477
|
function validateRscWarmResponse(response, expectedBuildId, expectedRscBuildId, requireCacheHit = false, statusSource = "cloudflare") {
|
|
488
478
|
const buildIdentityValidation = validateBuildIdentity(response, expectedBuildId);
|
|
@@ -959,10 +949,7 @@ async function warmCdnCache(options) {
|
|
|
959
949
|
return result;
|
|
960
950
|
}
|
|
961
951
|
async function warmCdnCacheFromPrerender(options) {
|
|
962
|
-
const plan = readPrerenderWarmPlan(options.root, {
|
|
963
|
-
includeFallbackShells: options.includeFallbackShells,
|
|
964
|
-
strict: options.strict
|
|
965
|
-
});
|
|
952
|
+
const plan = readPrerenderWarmPlan(options.root, { strict: options.strict });
|
|
966
953
|
const warmPlan = {
|
|
967
954
|
deploymentId: plan.deploymentId,
|
|
968
955
|
loadingShellPaths: plan.loadingShellPaths,
|
package/dist/cli.js
CHANGED
|
@@ -62,7 +62,6 @@ async function deployCommand() {
|
|
|
62
62
|
dangerouslyPromoteOnCdnWarmError: parsed.dangerouslyPromoteOnCdnWarmError,
|
|
63
63
|
warmCdnPromote: parsed.warmCdnPromote,
|
|
64
64
|
warmCdnPromotionDelay: parsed.warmCdnPromotionDelay,
|
|
65
|
-
warmCdnIncludeFallbacks: parsed.warmCdnIncludeFallbacks,
|
|
66
65
|
experimentalTPR: parsed.experimentalTPR,
|
|
67
66
|
tprCoverage: parsed.tprCoverage,
|
|
68
67
|
tprLimit: parsed.tprLimit,
|
package/dist/deploy-help.js
CHANGED
|
@@ -67,8 +67,6 @@ function formatDeployHelp() {
|
|
|
67
67
|
verified (never bypasses --warm-cache-certify)
|
|
68
68
|
--warm-cache-promotion-delay <ms>
|
|
69
69
|
Delay before promotion after warmup (default: 15000)
|
|
70
|
-
--warm-cache-include-fallbacks
|
|
71
|
-
Also warm PPR fallback-shell placeholder paths
|
|
72
70
|
-h, --help Show this help
|
|
73
71
|
|
|
74
72
|
Traffic-aware warming:
|
package/dist/deploy.d.ts
CHANGED
|
@@ -62,8 +62,6 @@ export type DeployOptions = {
|
|
|
62
62
|
warmCdnPromote?: boolean;
|
|
63
63
|
/** Delay between successful warmup and promotion in milliseconds */
|
|
64
64
|
warmCdnPromotionDelay?: number;
|
|
65
|
-
/** Include PPR fallback-shell placeholder paths during CDN warmup */
|
|
66
|
-
warmCdnIncludeFallbacks?: boolean;
|
|
67
65
|
/** Select CDN pre-warm routes using traffic analytics */
|
|
68
66
|
experimentalTPR?: boolean;
|
|
69
67
|
/** TPR: traffic coverage percentage target (0–100, default: 90) */
|
|
@@ -104,7 +102,6 @@ export declare function parseDeployArgs(args: string[]): {
|
|
|
104
102
|
dangerouslyPromoteOnCdnWarmError: boolean;
|
|
105
103
|
warmCdnPromote: boolean;
|
|
106
104
|
warmCdnPromotionDelay: number | undefined;
|
|
107
|
-
warmCdnIncludeFallbacks: boolean;
|
|
108
105
|
experimentalTPR: boolean;
|
|
109
106
|
tprCoverage: number | undefined;
|
|
110
107
|
tprLimit: number | undefined;
|
package/dist/deploy.js
CHANGED
|
@@ -228,7 +228,7 @@ function parseDeployArgs(args) {
|
|
|
228
228
|
values["warm-cache-readiness-probe-delay"] ??= values["warm-cdn-readiness-probe-delay"];
|
|
229
229
|
values["warm-cache-no-promote"] ||= values["warm-cdn-no-promote"];
|
|
230
230
|
values["warm-cache-promotion-delay"] ??= values["warm-cdn-promotion-delay"];
|
|
231
|
-
values["warm-cache-include-fallbacks"]
|
|
231
|
+
if (values["warm-cache-include-fallbacks"] || values["warm-cdn-include-fallbacks"]) console.warn("[vinext] --warm-cache-include-fallbacks is deprecated and has no effect.");
|
|
232
232
|
values["traffic-aware-warm-cache"] ||= values["experimental-traffic-aware-warm-cache"];
|
|
233
233
|
const trafficAwareWarmCache = values["traffic-aware-warm-cache"] || values["experimental-tpr"];
|
|
234
234
|
const warming = values["warm-cache"] || trafficAwareWarmCache;
|
|
@@ -271,7 +271,6 @@ function parseDeployArgs(args) {
|
|
|
271
271
|
dangerouslyPromoteOnCdnWarmError: values["dangerously-promote-on-warm-cache-error"],
|
|
272
272
|
warmCdnPromote: !values["no-promote"] && !values["warm-cache-no-promote"],
|
|
273
273
|
warmCdnPromotionDelay: values["warm-cache-promotion-delay"] === void 0 ? void 0 : validatePromotionDelay(parseNonNegativeIntegerArg(values["warm-cache-promotion-delay"], "--warm-cache-promotion-delay"), values["warm-cache-promotion-delay"]),
|
|
274
|
-
warmCdnIncludeFallbacks: values["warm-cache-include-fallbacks"],
|
|
275
274
|
experimentalTPR: trafficAwareWarmCache,
|
|
276
275
|
tprCoverage: parseIntArg("traffic-aware-coverage", values["traffic-aware-coverage"] ?? values["tpr-coverage"]),
|
|
277
276
|
tprLimit: parseIntArg("traffic-aware-limit", values["traffic-aware-limit"] ?? values["tpr-limit"]),
|
|
@@ -1328,7 +1327,6 @@ async function deploy(options) {
|
|
|
1328
1327
|
};
|
|
1329
1328
|
return createPrerenderWarmPlan(root, discovery, {
|
|
1330
1329
|
includeCanonicalRsc: hasCanonicalRscWarmup,
|
|
1331
|
-
includeFallbackShells: options.warmCdnIncludeFallbacks,
|
|
1332
1330
|
strict: options.warmCdnCertify === true || !options.dangerouslyPromoteOnCdnWarmError
|
|
1333
1331
|
});
|
|
1334
1332
|
},
|
|
@@ -3,7 +3,9 @@ export declare function parseWorkerDeploymentUrl(output: string): string | null;
|
|
|
3
3
|
/**
|
|
4
4
|
* Parse the concrete hostname that Wrangler reports after applying Worker
|
|
5
5
|
* triggers. Catch-all routes are valid warmup targets even though they are not
|
|
6
|
-
* canonical deployment URLs for general CLI reporting.
|
|
6
|
+
* canonical deployment URLs for general CLI reporting. Warmup requests carry the
|
|
7
|
+
* prerender secret, so an `http://`-only route (which Cloudflare does not match
|
|
8
|
+
* for HTTPS requests) is never used as a target.
|
|
7
9
|
*/
|
|
8
10
|
export declare function parseCdnWarmupDeploymentUrl(output: string): string | null;
|
|
9
11
|
//#endregion
|
|
@@ -6,7 +6,9 @@ function parseWorkerDeploymentUrl(output) {
|
|
|
6
6
|
/**
|
|
7
7
|
* Parse the concrete hostname that Wrangler reports after applying Worker
|
|
8
8
|
* triggers. Catch-all routes are valid warmup targets even though they are not
|
|
9
|
-
* canonical deployment URLs for general CLI reporting.
|
|
9
|
+
* canonical deployment URLs for general CLI reporting. Warmup requests carry the
|
|
10
|
+
* prerender secret, so an `http://`-only route (which Cloudflare does not match
|
|
11
|
+
* for HTTPS requests) is never used as a target.
|
|
10
12
|
*/
|
|
11
13
|
function parseCdnWarmupDeploymentUrl(output) {
|
|
12
14
|
return parseCustomDomainUrl(output) ?? parseCatchAllWorkerRouteOrigin(output) ?? parseWorkersDevUrl(output);
|
|
@@ -17,7 +19,7 @@ function parseCatchAllWorkerRouteOrigin(output) {
|
|
|
17
19
|
if (!route || !route.endsWith("/*")) continue;
|
|
18
20
|
try {
|
|
19
21
|
const url = new URL(route.includes("://") ? route : `https://${route}`);
|
|
20
|
-
if (url.protocol !== "https:"
|
|
22
|
+
if (url.protocol !== "https:" || url.hostname.includes("*") || url.username !== "" || url.password !== "" || url.port !== "" || url.pathname !== "/*" || url.search !== "" || url.hash !== "") continue;
|
|
21
23
|
return url.origin;
|
|
22
24
|
} catch {
|
|
23
25
|
continue;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vinext/cloudflare",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "Cloudflare cache and image adapters for vinext.",
|
|
5
5
|
"homepage": "https://vinext.dev/",
|
|
6
6
|
"license": "MIT",
|
|
@@ -48,21 +48,21 @@
|
|
|
48
48
|
}
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@cloudflare/workers-response-store": "1.
|
|
51
|
+
"@cloudflare/workers-response-store": "1.1.1"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@types/node": "^25.9.2",
|
|
55
55
|
"cf": "1.0.0-beta.1",
|
|
56
56
|
"miniflare": "5.20260730.0-alpha",
|
|
57
|
-
"vite": "npm:@voidzero-dev/vite-plus-core@
|
|
58
|
-
"vite-plus": "
|
|
59
|
-
"vitest": "
|
|
60
|
-
"vinext": "1.0
|
|
57
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@1.1.0",
|
|
58
|
+
"vite-plus": "1.1.0",
|
|
59
|
+
"vitest": "5.0.3",
|
|
60
|
+
"vinext": "1.2.0"
|
|
61
61
|
},
|
|
62
62
|
"peerDependencies": {
|
|
63
63
|
"@cloudflare/vite-plugin": "*",
|
|
64
64
|
"cf": "*",
|
|
65
|
-
"vinext": "^1.0
|
|
65
|
+
"vinext": "^1.2.0"
|
|
66
66
|
},
|
|
67
67
|
"peerDependenciesMeta": {
|
|
68
68
|
"@cloudflare/vite-plugin": {
|