Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions packages/cloudflare/src/cache/cdn-adapter-config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import fs from "node:fs/promises";
import path from "node:path";

const RESPONSE_STAGE_EXPORT = "VinextCachedResponse";
const CACHED_RESPONSE_STAGE_EXPORT = "VinextCachedResponse";
const UNCACHED_RESPONSE_STAGE_EXPORT = "VinextUncachedResponse";
const CTX_EXPORTS_DEFAULT_DATE = "2025-11-17";

type WranglerWorkerExport = {
Expand Down Expand Up @@ -78,7 +79,7 @@ export function configureWorkersCacheEntrypoints(
: [...compatibilityFlags, "enable_ctx_exports"]
: compatibilityFlags.filter((flag) => flag !== "enable_ctx_exports");

for (const name of ["default", RESPONSE_STAGE_EXPORT]) {
for (const name of ["default", CACHED_RESPONSE_STAGE_EXPORT, UNCACHED_RESPONSE_STAGE_EXPORT]) {
const existing = configuredExports[name];
if (existing?.type !== undefined && existing.type !== "worker") {
throw new Error(
Expand All @@ -97,11 +98,16 @@ export function configureWorkersCacheEntrypoints(
type: "worker",
cache: { enabled: false },
},
[RESPONSE_STAGE_EXPORT]: {
...configuredExports[RESPONSE_STAGE_EXPORT],
[CACHED_RESPONSE_STAGE_EXPORT]: {
...configuredExports[CACHED_RESPONSE_STAGE_EXPORT],
type: "worker",
cache: { enabled: true },
},
[UNCACHED_RESPONSE_STAGE_EXPORT]: {
...configuredExports[UNCACHED_RESPONSE_STAGE_EXPORT],
type: "worker",
cache: { enabled: false },
},
},
};
}
Expand Down
9 changes: 6 additions & 3 deletions packages/cloudflare/src/cache/cdn-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,15 @@ export type CdnAdapterOptions = {
* Unlike the data adapter (which stores cache entries in a durable store and
* serves HIT/STALE itself), this adapter delegates serving to Workers Cache on
* a named Worker entrypoint. The default entrypoint always runs middleware and
* request-time routing before dispatching the cacheable response stage.
* request-time routing before dispatching to cached or uncached response-stage
* entrypoints.
*
* The emitted Wrangler configuration enables Workers Cache only for that
* response-stage export, so cache hits do not start the application stage.
* The generated deployment config automatically enables Workers Cache for the
* response entrypoint and adds the version metadata binding used by warmup.
* cached response entrypoint and adds the version metadata binding used by
* warmup. The uncached response entrypoint keeps bypass and probe renders out
* of the gateway without enabling Workers Cache for them.
*
* The adapter adds a transport-only URL digest so distinct response-stage
* identities cannot collide. Workers Cache owns this key independently of
Expand Down Expand Up @@ -57,7 +60,7 @@ export function cdnAdapter(options?: CdnAdapterOptions) {
transformHostEntry({ code, id }: { code: string; id: string }) {
const cleanId = id.charCodeAt(0) === 0 ? id.slice(1) : id;
if (cleanId !== CLOUDFLARE_WORKER_ENTRY_ID) return null;
return `${code}\nexport { VinextCachedResponse } from ${JSON.stringify(workerEntry)};\n`;
return `${code}\nexport { VinextCachedResponse, VinextUncachedResponse } from ${JSON.stringify(workerEntry)};\n`;
},
finalizeBuildOutput({
outDir,
Expand Down
145 changes: 118 additions & 27 deletions packages/cloudflare/src/cache/cdn-adapter.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,20 @@ type RestoredResponseStageRequest = {
request: Request;
};

const RESPONSE_STAGE_EXPORT = "VinextCachedResponse";
type CloudflareResponse = Response & {
readonly webSocket?: WebSocket | null;
};

type CloudflareResponseInit = ResponseInit & {
webSocket?: WebSocket | null;
};

const CACHED_RESPONSE_STAGE_EXPORT = "VinextCachedResponse";
const UNCACHED_RESPONSE_STAGE_EXPORT = "VinextUncachedResponse";
const AUTHORIZATION_TRANSPORT_HEADER = "x-vinext-internal-authorization";
const REQUEST_CACHE_CONTROL_TRANSPORT_HEADER = "x-vinext-internal-request-cache-control";
const REQUEST_CF_TRANSPORT_HEADER = "x-vinext-internal-request-cf";
const REQUEST_PRAGMA_TRANSPORT_HEADER = "x-vinext-internal-request-pragma";
const CLOUDFLARE_EDGE_POLICY_HEADER = "Cloudflare-CDN-Cache-Control";
const SHARED_RESPONSE_STAGE_HEADER = "x-vinext-cloudflare-shared-response-stage";
const RESPONSE_STAGE_WIRE_CACHE = {
Expand Down Expand Up @@ -90,11 +101,22 @@ function stampResponseStageBuildIdentity(response: Response): Response {
} catch {
const headers = new Headers(response.headers);
headers.set(VINEXT_CDN_BUILD_ID_HEADER, buildIdentity);
return new Response(response.body, {
const webSocket = (response as CloudflareResponse).webSocket;
// A Workers WebSocket upgrade is the one non-standard status that can be
// reconstructed. Convert other non-HTTP responses before they cross the
// entrypoint boundary, where a network-error response would reject fetch.
if (!webSocket && (response.status < 200 || response.status > 599)) {
const unavailable = responseStageUnavailable();
unavailable.headers.set(VINEXT_CDN_BUILD_ID_HEADER, buildIdentity);
return unavailable;
}
const init: CloudflareResponseInit = {
headers,
status: response.status,
statusText: response.statusText,
});
};
if (webSocket) init.webSocket = webSocket;
return new Response(response.body, init);
}
}

Expand All @@ -116,13 +138,17 @@ function validateResponseStageBuildIdentity(response: Response): Response {
function stripUntrustedTransportHeaders(request: Request): Request {
if (
!request.headers.has(AUTHORIZATION_TRANSPORT_HEADER) &&
!request.headers.has(REQUEST_CF_TRANSPORT_HEADER)
!request.headers.has(REQUEST_CACHE_CONTROL_TRANSPORT_HEADER) &&
!request.headers.has(REQUEST_CF_TRANSPORT_HEADER) &&
!request.headers.has(REQUEST_PRAGMA_TRANSPORT_HEADER)
) {
return request;
}
const headers = new Headers(request.headers);
headers.delete(AUTHORIZATION_TRANSPORT_HEADER);
headers.delete(REQUEST_CACHE_CONTROL_TRANSPORT_HEADER);
headers.delete(REQUEST_CF_TRANSPORT_HEADER);
headers.delete(REQUEST_PRAGMA_TRANSPORT_HEADER);
const sanitized = new Request(request, { headers });
const requestCf = Reflect.get(request, "cf");
if (requestCf !== undefined) {
Expand Down Expand Up @@ -186,9 +212,10 @@ function hasPurge(value: unknown): value is Required<Pick<StageBinding, "purge">

function getResponseStageBinding(
context: CloudflareStageContext,
exportName: typeof CACHED_RESPONSE_STAGE_EXPORT | typeof UNCACHED_RESPONSE_STAGE_EXPORT,
serializedInvocation: string,
): StageBinding | null {
const binding = context.exports?.[RESPONSE_STAGE_EXPORT];
const binding = context.exports?.[exportName];
if (typeof binding !== "function") return null;

// Configurable-entrypoint props cross a Workers RPC boundary. Some vinext
Expand Down Expand Up @@ -240,15 +267,27 @@ async function createCacheFacingRequest(
const url = new URL(request.url);
url.searchParams.set("__vinext_cache_key", key);
const headers = new Headers(request.headers);
const requestCacheControl = headers.get("Cache-Control");
const requestPragma = headers.get("Pragma");
headers.delete("Cache-Control");
headers.delete("Pragma");
headers.delete("Authorization");
headers.delete(AUTHORIZATION_TRANSPORT_HEADER);
headers.delete(REQUEST_CACHE_CONTROL_TRANSPORT_HEADER);
headers.delete(REQUEST_CF_TRANSPORT_HEADER);
headers.delete(REQUEST_PRAGMA_TRANSPORT_HEADER);
if (authorization !== null) {
headers.set(AUTHORIZATION_TRANSPORT_HEADER, encodeURIComponent(authorization));
}
if (serializedRequestCf !== null) {
headers.set(REQUEST_CF_TRANSPORT_HEADER, serializedRequestCf);
}
if (requestCacheControl !== null) {
headers.set(REQUEST_CACHE_CONTROL_TRANSPORT_HEADER, encodeURIComponent(requestCacheControl));
}
if (requestPragma !== null) {
headers.set(REQUEST_PRAGMA_TRANSPORT_HEADER, encodeURIComponent(requestPragma));
}
const init = {
// Explicitly replace inherited inbound `cf` metadata. In workerd,
// Request-from-Request construction otherwise preserves values such as a
Expand All @@ -259,7 +298,14 @@ async function createCacheFacingRequest(
} satisfies RequestInit & {
cf: { vary: { default: { action: "passthrough" } } };
};
return new Request(new Request(url, request), init);
// Construct from the URL rather than cloning the inbound request. Workers
// carries cache-bypass state from browser reloads outside the visible header
// map, and cloning would leak that state into the cache-enabled entrypoint
// even after the directives above were transported privately.
return new Request(url, {
...init,
method: request.method,
});
}

function restoreResponseStageRequest(
Expand All @@ -269,10 +315,16 @@ function restoreResponseStageRequest(
): RestoredResponseStageRequest {
const headers = new Headers(request.headers);
const serializedAuthorization = headers.get(AUTHORIZATION_TRANSPORT_HEADER);
const serializedRequestCacheControl = headers.get(REQUEST_CACHE_CONTROL_TRANSPORT_HEADER);
const serializedRequestCf = headers.get(REQUEST_CF_TRANSPORT_HEADER);
const serializedRequestPragma = headers.get(REQUEST_PRAGMA_TRANSPORT_HEADER);
headers.delete("Cache-Control");
headers.delete("Pragma");
headers.delete("Authorization");
headers.delete(AUTHORIZATION_TRANSPORT_HEADER);
headers.delete(REQUEST_CACHE_CONTROL_TRANSPORT_HEADER);
headers.delete(REQUEST_CF_TRANSPORT_HEADER);
headers.delete(REQUEST_PRAGMA_TRANSPORT_HEADER);
if (serializedAuthorization !== null) {
try {
headers.set("Authorization", decodeURIComponent(serializedAuthorization));
Expand All @@ -288,6 +340,20 @@ function restoreResponseStageRequest(
// Malformed internal metadata is stripped rather than exposed to userland.
}
}
if (serializedRequestCacheControl !== null) {
try {
headers.set("Cache-Control", decodeURIComponent(serializedRequestCacheControl));
} catch {
// Malformed internal metadata is stripped rather than exposed to userland.
}
}
if (serializedRequestPragma !== null) {
try {
headers.set("Pragma", decodeURIComponent(serializedRequestPragma));
} catch {
// Malformed internal metadata is stripped rather than exposed to userland.
}
}
const restored = new Request(new Request(requestUrl, request), {
headers,
method: requestMethod,
Expand Down Expand Up @@ -399,7 +465,7 @@ function hasTaggedCustomVary(response: Response): boolean {
}

function withResponseStagePurge(context: CloudflareStageContext): CloudflareStageContext {
const factory = context.exports?.[RESPONSE_STAGE_EXPORT];
const factory = context.exports?.[CACHED_RESPONSE_STAGE_EXPORT];
if (typeof factory !== "function") return context;
const fallback = context.cache;
return {
Expand All @@ -414,7 +480,10 @@ function withResponseStagePurge(context: CloudflareStageContext): CloudflareStag
};
}

function getResponseStageInvocation(value: unknown): CloudflareResponseStageInvocation | null {
function getResponseStageInvocation(
value: unknown,
expectedCache?: VinextResponseStageDispatchOptions["cache"],
): CloudflareResponseStageInvocation | null {
if (!value || typeof value !== "object") return null;
const expectedResponseStageBuildIdentity = Reflect.get(
value,
Expand Down Expand Up @@ -452,6 +521,7 @@ function getResponseStageInvocation(value: unknown): CloudflareResponseStageInvo
} else {
return null;
}
if (expectedCache !== undefined && cache !== expectedCache) return null;
const requestUrl = Reflect.get(value, "requestUrl");
if (typeof requestUrl !== "string") return null;
const requestMethod = Reflect.get(value, "requestMethod");
Expand Down Expand Up @@ -504,7 +574,7 @@ async function invokeResponseStage(
export class VinextCachedResponse extends WorkerEntrypoint<unknown, unknown> {
async fetch(request: Request): Promise<Response> {
const context = withWorkerHostRuntime(this.ctx, this.env);
const invocation = getResponseStageInvocation(context.props);
const invocation = getResponseStageInvocation(context.props, "shared");
if (!invocation) {
return stampResponseStageBuildIdentity(
new Response("Invalid vinext response-stage invocation", {
Expand Down Expand Up @@ -545,6 +615,31 @@ export class VinextCachedResponse extends WorkerEntrypoint<unknown, unknown> {
}
}

/** Uncached response entrypoint. Bypass and probe renders execute only here. */
export class VinextUncachedResponse extends WorkerEntrypoint<unknown, unknown> {
async fetch(request: Request): Promise<Response> {
const context = withResponseStagePurge(withWorkerHostRuntime(this.ctx, this.env));
const invocation = getResponseStageInvocation(context.props, "bypass");
if (!invocation) {
return stampResponseStageBuildIdentity(
new Response("Invalid vinext response-stage invocation", {
status: 400,
headers: { "Cache-Control": "no-store" },
}),
);
}
if (
invocation.expectedResponseStageBuildIdentity !== undefined &&
invocation.expectedResponseStageBuildIdentity !== getVinextCdnBuildIdentity()
) {
return stampResponseStageBuildIdentity(responseStageUnavailable());
}
return stampResponseStageBuildIdentity(
Comment thread
james-elicx marked this conversation as resolved.
await invokeResponseStage(request, this.env, context, invocation),
);
}
}

/** Uncached gateway: request routing and middleware always execute here. */
export default {
async fetch(
Expand All @@ -570,10 +665,7 @@ export default {
requestMethod: stageRequest.method,
requestUrl: stageRequest.url,
};
const requiresEntrypoint = isResponseStageReadinessRequest(stageRequest);
if (options.cache === "bypass" && !requiresEntrypoint) {
return invokeResponseStage(stageRequest, env, stageContext, invocation);
}
const usesSharedCache = options.cache === "shared";
try {
const serializedInvocation = JSON.stringify({
...invocation,
Expand All @@ -585,24 +677,23 @@ export default {
cache: RESPONSE_STAGE_WIRE_CACHE[options.cache] satisfies ResponseStageWireCache,
},
});
const binding = getResponseStageBinding(stageContext, serializedInvocation);
const binding = getResponseStageBinding(
stageContext,
usesSharedCache ? CACHED_RESPONSE_STAGE_EXPORT : UNCACHED_RESPONSE_STAGE_EXPORT,
serializedInvocation,
);
if (!binding) {
return requiresEntrypoint
? responseStageUnavailable()
: markSharedResponseStage(
await invokeResponseStage(stageRequest, env, stageContext, invocation),
sharedResponseStageProvenance,
);
return responseStageUnavailable();
}
const entrypointRequest = requiresEntrypoint
? stageRequest
: await createCacheFacingRequest(stageRequest, serializedInvocation);
const entrypointRequest = usesSharedCache
? await createCacheFacingRequest(stageRequest, serializedInvocation)
: stageRequest;
const response = validateResponseStageBuildIdentity(await binding.fetch(entrypointRequest));
return requiresEntrypoint
? response
: markSharedResponseStage(response, sharedResponseStageProvenance, true);
return usesSharedCache
? markSharedResponseStage(response, sharedResponseStageProvenance, true)
: response;
} catch (error) {
if (requiresEntrypoint) return responseStageUnavailable();
if (isResponseStageReadinessRequest(stageRequest)) return responseStageUnavailable();
throw error;
}
};
Expand Down
Loading