From 87defa65ddf72ec13a71d315ebfba26ae08bd712 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 14 Sep 2026 11:06:53 +0100 Subject: [PATCH 01/20] feat(cloudflare): automate response store deployment --- .github/workflows/ci.yml | 25 +-- .../create-vinext-app-cloudflare-deploy.yml | 13 +- .github/workflows/deploy-examples.yml | 18 +- examples/response-store-demo/vite.config.ts | 6 +- examples/response-store-demo/wrangler.jsonc | 8 - packages/cloudflare/README.md | 39 +++- packages/cloudflare/package.json | 2 +- .../cache/response-store-adapter-config.ts | 170 ++++++++++++++++++ .../src/cache/response-store-adapter.ts | 48 +++++ packages/cloudflare/src/deploy.ts | 32 ++++ .../tests/response-store-adapter.e2e.test.ts | 31 +++- packages/create-vinext-app/src/index.ts | 4 +- packages/vinext/src/cli.ts | 8 +- packages/vinext/src/init-cloudflare.ts | 95 ++++++++-- packages/vinext/src/init-platform.ts | 70 +++++--- tests/cdn-adapter-config.test.ts | 165 +++++++++++++++++ tests/create-vinext-app.test.ts | 5 +- tests/deploy.test.ts | 40 ++++- tests/init-cloudflare.test.ts | 30 ++++ tests/init-platform.test.ts | 54 +++--- 20 files changed, 727 insertions(+), 136 deletions(-) create mode 100644 packages/cloudflare/src/cache/response-store-adapter-config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 838aed119c..67eb83919d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -280,8 +280,6 @@ jobs: runs-on: ubuntu-latest env: CNA_CLOUDFLARE_WORKER: create-next-app-cloudflare - # Public test namespace already used by examples/response-store-demo. - CNA_CLOUDFLARE_KV_NAMESPACE_ID: b38f84f642aa4bd4ba17a37bb516f0b5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -337,7 +335,6 @@ jobs: working-directory: ${{ runner.temp }}/cna-cloudflare run: >- vp exec vinext init --skip-check --platform=cloudflare - --data-cache=kv --image-optimization=cloudflare-images - name: Configure generated Cloudflare deployment @@ -349,11 +346,6 @@ jobs: const config = JSON.parse(fs.readFileSync(configPath, "utf8")); config.name = process.env.CNA_CLOUDFLARE_WORKER; config.preview_urls = true; - for (const namespace of config.kv_namespaces ?? []) { - if (namespace.binding === "VINEXT_KV_CACHE") { - namespace.id = process.env.CNA_CLOUDFLARE_KV_NAMESPACE_ID; - } - } fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n"); ' @@ -363,15 +355,15 @@ jobs: - name: Verify generated Cloudflare build output working-directory: ${{ runner.temp }}/cna-cloudflare - run: test -f dist/server/wrangler.json + run: | + test -f dist/server/wrangler.json + test -f dist/server/vinext-response-store/wrangler.json create-vinext-app-cloudflare: name: create-vinext-app (Cloudflare build) runs-on: ubuntu-latest env: CVA_CLOUDFLARE_WORKER: create-vinext-app-cloudflare - # Public test namespace already used by examples/response-store-demo. - CVA_CLOUDFLARE_KV_NAMESPACE_ID: b38f84f642aa4bd4ba17a37bb516f0b5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -403,8 +395,6 @@ jobs: --skip-install --disable-git --platform=cloudflare - --cdn-cache=data-cache - --data-cache=kv --image-optimization=cloudflare-images - name: Pin pnpm in scaffolded project to vinext's version @@ -445,11 +435,6 @@ jobs: const config = JSON.parse(fs.readFileSync(configPath, "utf8")); config.name = process.env.CVA_CLOUDFLARE_WORKER; config.preview_urls = true; - for (const namespace of config.kv_namespaces ?? []) { - if (namespace.binding === "VINEXT_KV_CACHE") { - namespace.id = process.env.CVA_CLOUDFLARE_KV_NAMESPACE_ID; - } - } fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n"); ' @@ -459,7 +444,9 @@ jobs: - name: Verify generated Cloudflare build output working-directory: ${{ runner.temp }}/cva-cloudflare - run: test -f dist/server/wrangler.json + run: | + test -f dist/server/wrangler.json + test -f dist/server/vinext-response-store/wrangler.json e2e: name: E2E (${{ matrix.label }}) diff --git a/.github/workflows/create-vinext-app-cloudflare-deploy.yml b/.github/workflows/create-vinext-app-cloudflare-deploy.yml index e6fa00041c..49e906b1c4 100644 --- a/.github/workflows/create-vinext-app-cloudflare-deploy.yml +++ b/.github/workflows/create-vinext-app-cloudflare-deploy.yml @@ -21,8 +21,6 @@ jobs: runs-on: ubuntu-latest env: CVA_CLOUDFLARE_WORKER: create-vinext-app-cloudflare - # Public test namespace already used by examples/response-store-demo. - CVA_CLOUDFLARE_KV_NAMESPACE_ID: b38f84f642aa4bd4ba17a37bb516f0b5 CVA_CLOUDFLARE_EXPECTED_TEXT: vinext + Cloudflare Workers steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -56,8 +54,6 @@ jobs: --skip-install --disable-git --platform=cloudflare - --cdn-cache=data-cache - --data-cache=kv --image-optimization=cloudflare-images - name: Pin pnpm in scaffolded project to vinext's version @@ -98,11 +94,6 @@ jobs: const config = JSON.parse(fs.readFileSync(configPath, "utf8")); config.name = process.env.CVA_CLOUDFLARE_WORKER; config.preview_urls = true; - for (const namespace of config.kv_namespaces ?? []) { - if (namespace.binding === "VINEXT_KV_CACHE") { - namespace.id = process.env.CVA_CLOUDFLARE_KV_NAMESPACE_ID; - } - } fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n"); ' @@ -112,7 +103,9 @@ jobs: - name: Verify generated Cloudflare build output working-directory: ${{ runner.temp }}/cva-cloudflare - run: test -f dist/server/wrangler.json + run: | + test -f dist/server/wrangler.json + test -f dist/server/vinext-response-store/wrangler.json - name: Deploy generated Cloudflare project working-directory: ${{ runner.temp }}/cva-cloudflare diff --git a/.github/workflows/deploy-examples.yml b/.github/workflows/deploy-examples.yml index 13c2e102e2..e1050aa541 100644 --- a/.github/workflows/deploy-examples.yml +++ b/.github/workflows/deploy-examples.yml @@ -203,24 +203,8 @@ jobs: if: matrix.example.static_export != true run: vp build working-directory: ${{ matrix.example.working_directory }} - - - name: Bind Response Store demo to this run's cache Worker - if: matrix.example.name == 'response-store-demo' - working-directory: ${{ matrix.example.working_directory }} env: - RESPONSE_STORE_SERVICE: ${{ needs.response_store.outputs.service_name }} - run: | - # Node reads the value from process.env. - # shellcheck disable=SC2016 - node -e ' - const fs = require("node:fs"); - const path = "dist/server/wrangler.json"; - const config = JSON.parse(fs.readFileSync(path, "utf8")); - const binding = config.services?.find(({ binding }) => binding === "RESPONSE_STORE"); - if (!binding) throw new Error("Missing RESPONSE_STORE service binding"); - binding.service = process.env.RESPONSE_STORE_SERVICE; - fs.writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`); - ' + VINEXT_RESPONSE_STORE_SERVICE_NAME: ${{ matrix.example.name == 'response-store-demo' && needs.response_store.outputs.service_name || '' }} - name: Build static export if: matrix.example.static_export == true diff --git a/examples/response-store-demo/vite.config.ts b/examples/response-store-demo/vite.config.ts index 7d72863857..114d45af8f 100644 --- a/examples/response-store-demo/vite.config.ts +++ b/examples/response-store-demo/vite.config.ts @@ -4,12 +4,16 @@ import { responseStoreAdapter } from "@vinext/cloudflare/cache/response-store-ad import { cloudflare } from "@cloudflare/vite-plugin"; const selfContained = process.env.VINEXT_RESPONSE_STORE_MODE === "self-contained"; +const serviceName = process.env.VINEXT_RESPONSE_STORE_SERVICE_NAME; const outputRoot = selfContained ? ".vinext/response-store-self-contained" : "dist"; export default defineConfig({ plugins: [ vinext({ - cache: responseStoreAdapter({ mode: selfContained ? "self-contained" : "service-binding" }), + cache: responseStoreAdapter({ + mode: selfContained ? "self-contained" : "service-binding", + ...(serviceName ? { serviceName } : {}), + }), clientOutDir: `${outputRoot}/client`, rscOutDir: `${outputRoot}/server`, ssrOutDir: `${outputRoot}/server/ssr`, diff --git a/examples/response-store-demo/wrangler.jsonc b/examples/response-store-demo/wrangler.jsonc index 484d6aed3b..f8e591d62a 100644 --- a/examples/response-store-demo/wrangler.jsonc +++ b/examples/response-store-demo/wrangler.jsonc @@ -22,12 +22,4 @@ "enabled": true, }, }, - "services": [ - { - "binding": "RESPONSE_STORE", - "service": "vinext-workers-response-store-service-poc", - "entrypoint": "ResponseStoreService", - }, - ], - "version_metadata": { "binding": "CF_VERSION_METADATA" }, } diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md index bc4c6144b8..91a79d1963 100644 --- a/packages/cloudflare/README.md +++ b/packages/cloudflare/README.md @@ -72,8 +72,43 @@ representation variants cannot collide. `responseStoreAdapter()` replaces both `cdnAdapter()` and `kvDataAdapter()`. It defaults to a separate cache Worker reached through the `RESPONSE_STORE` -service binding. To deploy storage and cache entrypoints with the application -instead, select self-contained mode: +service binding. The Cloudflare build emits that Worker and its binding, and +`vinext-cloudflare deploy` deploys it before the application Worker. Wrangler +provisions the R2 bucket and SQLite Durable Object automatically, so the app +does not need storage IDs or a separate deployment command. + +Resource names can be fixed explicitly when they need to match existing +infrastructure: + +```ts +vinext({ + cache: responseStoreAdapter({ + serviceName: "my-response-store", + r2BucketName: "my-response-store-bodies", + }), +}); +``` + +The service name also identifies the service-owned Durable Object namespace. +Keeping it stable reuses its metadata. To bind a compatible Response Store +Worker managed outside the vinext deployment, set `deployService: false`: + +```ts +vinext({ + cache: responseStoreAdapter({ + serviceName: "shared-response-store", + deployService: false, + }), +}); +``` + +When omitted, names are derived from the application Worker. Setting +`deployService` to `false` emits only the application service binding; the +external Worker and its R2 and Durable Object resources remain independently +managed. + +To deploy storage and cache entrypoints with the application instead, select +self-contained mode: ```ts import { responseStoreAdapter } from "@vinext/cloudflare/cache/response-store-adapter"; diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 4eeac7275b..3e0614de26 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -55,7 +55,7 @@ "build": "vp pack", "prepack": "vp run build", "dev": "vp pack --watch", - "test": "vp run vinext#build && vp run @cloudflare/workers-response-store#build && vp run @vinext/cloudflare#build && VINEXT_RESPONSE_STORE_MODE=self-contained pnpm --filter response-store-demo run build && pnpm --filter response-store-demo run build && pnpm --filter @cloudflare/workers-response-store run build:service-workers && vitest run" + "test": "vp run vinext#build && vp run @cloudflare/workers-response-store#build && vp run @vinext/cloudflare#build && VINEXT_RESPONSE_STORE_MODE=self-contained pnpm --filter response-store-demo run build && pnpm --filter response-store-demo run build && vitest run" }, "dependencies": { "@cloudflare/workers-response-store": "workspace:*" diff --git a/packages/cloudflare/src/cache/response-store-adapter-config.ts b/packages/cloudflare/src/cache/response-store-adapter-config.ts new file mode 100644 index 0000000000..d1957f5eff --- /dev/null +++ b/packages/cloudflare/src/cache/response-store-adapter-config.ts @@ -0,0 +1,170 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { createHash } from "node:crypto"; +import { fileURLToPath } from "node:url"; + +export const RESPONSE_STORE_SERVICE_CONFIG = "vinext-response-store/wrangler.json"; + +const RESPONSE_STORE_BINDING = "RESPONSE_STORE"; +const VERSION_METADATA_BINDING = "CF_VERSION_METADATA"; +const CTX_EXPORTS_DEFAULT_DATE = "2025-11-17"; + +type WranglerOutputConfig = Record & { + compatibility_date?: string; + compatibility_flags?: string[]; + exports?: Record>; + name?: string; + services?: Array>; + version_metadata?: unknown; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function withCtxExports(config: WranglerOutputConfig): WranglerOutputConfig { + const flags = config.compatibility_flags ?? []; + if (flags.includes("disable_ctx_exports")) { + throw new Error( + "[vinext] responseStoreAdapter() requires ctx.exports, but the generated Wrangler config explicitly disables it.", + ); + } + if ( + config.compatibility_date !== undefined && + config.compatibility_date >= CTX_EXPORTS_DEFAULT_DATE + ) { + return config; + } + return { + ...config, + compatibility_flags: flags.includes("enable_ctx_exports") + ? flags + : [...flags, "enable_ctx_exports"], + }; +} + +/** Emit the service Worker and connect the application Worker to it. */ +export async function finalizeResponseStoreBuildOutput({ + outDir, + isPrimaryServerOutput, + serviceName: configuredServiceName, + r2BucketName, + deployService = true, +}: { + outDir: string; + isPrimaryServerOutput: boolean; + serviceName?: string; + r2BucketName?: string; + deployService?: boolean; +}): Promise { + if (!isPrimaryServerOutput) return; + + const appConfigPath = path.resolve(outDir, "wrangler.json"); + let appConfig: WranglerOutputConfig; + try { + const parsed: unknown = JSON.parse(await fs.readFile(appConfigPath, "utf8")); + if (!isRecord(parsed)) throw new TypeError("the root value must be an object"); + appConfig = parsed; + } catch (cause) { + throw new Error(`[vinext] Could not read the generated Wrangler config at ${appConfigPath}.`, { + cause, + }); + } + + if (!appConfig.name || !appConfig.compatibility_date) { + throw new Error( + "[vinext] responseStoreAdapter() requires the generated Wrangler config to contain a Worker name and compatibility date.", + ); + } + if ( + appConfig.version_metadata !== undefined && + (!isRecord(appConfig.version_metadata) || + appConfig.version_metadata.binding !== VERSION_METADATA_BINDING) + ) { + throw new Error( + `[vinext] responseStoreAdapter() requires version_metadata.binding to be ${JSON.stringify(VERSION_METADATA_BINDING)}.`, + ); + } + if (appConfig.services !== undefined && !Array.isArray(appConfig.services)) { + throw new Error("[vinext] The generated Wrangler config has an invalid services value."); + } + + const serviceSuffix = "-response-store"; + const fullServiceName = `${appConfig.name}${serviceSuffix}`; + const serviceName = + configuredServiceName ?? + (fullServiceName.length <= 49 + ? fullServiceName + : `${appConfig.name.slice(0, 25)}-${createHash("sha256").update(appConfig.name).digest("hex").slice(0, 8)}${serviceSuffix}`); + const serviceDir = path.resolve(outDir, path.dirname(RESPONSE_STORE_SERVICE_CONFIG)); + if (deployService) { + if (!r2BucketName && serviceName.length > 49) { + throw new Error( + "[vinext] A Response Store serviceName longer than 49 characters requires an explicit r2BucketName.", + ); + } + const packageDir = path.dirname( + fileURLToPath(import.meta.resolve("@cloudflare/workers-response-store/service")), + ); + await fs.rm(serviceDir, { recursive: true, force: true }); + await fs.mkdir(serviceDir, { recursive: true }); + for (const file of await fs.readdir(packageDir)) { + if (file.endsWith(".js")) { + await fs.copyFile(path.join(packageDir, file), path.join(serviceDir, file)); + } + } + + const serviceConfig = withCtxExports({ + $schema: + "https://raw.githubusercontent.com/cloudflare/workers-sdk/main/packages/wrangler/config-schema.json", + name: serviceName, + main: "service.js", + compatibility_date: appConfig.compatibility_date, + compatibility_flags: ["nodejs_compat"], + workers_dev: false, + preview_urls: false, + cache: { enabled: true }, + exports: { + default: { type: "worker", cache: { enabled: false } }, + ResponseStoreBinding: { type: "worker", cache: { enabled: true } }, + }, + r2_buckets: [ + { binding: "CACHE_BODIES", ...(r2BucketName ? { bucket_name: r2BucketName } : {}) }, + ], + durable_objects: { + bindings: [{ name: "CACHE_METADATA", class_name: "CacheMetadata" }], + }, + migrations: [{ tag: "v1", new_sqlite_classes: ["CacheMetadata"] }], + ...(typeof appConfig.account_id === "string" ? { account_id: appConfig.account_id } : {}), + }); + await fs.writeFile( + path.resolve(outDir, RESPONSE_STORE_SERVICE_CONFIG), + `${JSON.stringify(serviceConfig, null, 2)}\n`, + ); + } else { + await fs.rm(serviceDir, { recursive: true, force: true }); + } + + const existingCache = appConfig.cache; + if (existingCache !== undefined && !isRecord(existingCache)) { + throw new Error("[vinext] The generated Wrangler config has an invalid cache value."); + } + const configuredApp = withCtxExports({ + ...appConfig, + cache: { ...existingCache, enabled: false }, + version_metadata: { binding: VERSION_METADATA_BINDING }, + services: [ + ...(appConfig.services ?? []).filter((service) => service.binding !== RESPONSE_STORE_BINDING), + { + binding: RESPONSE_STORE_BINDING, + service: serviceName, + entrypoint: "ResponseStoreService", + }, + ], + exports: { + ...appConfig.exports, + default: { ...appConfig.exports?.default, type: "worker", cache: { enabled: false } }, + }, + }); + await fs.writeFile(appConfigPath, `${JSON.stringify(configuredApp, null, 2)}\n`); +} diff --git a/packages/cloudflare/src/cache/response-store-adapter.ts b/packages/cloudflare/src/cache/response-store-adapter.ts index 6d7229c5aa..f42718442a 100644 --- a/packages/cloudflare/src/cache/response-store-adapter.ts +++ b/packages/cloudflare/src/cache/response-store-adapter.ts @@ -1,9 +1,16 @@ import { fileURLToPath } from "node:url"; +import { finalizeResponseStoreBuildOutput } from "./response-store-adapter-config.js"; const CLOUDFLARE_WORKER_ENTRY_ID = "virtual:cloudflare/worker-entry"; export type ResponseStoreAdapterOptions = { mode?: "self-contained" | "service-binding"; + /** Existing or desired Response Store service Worker name. */ + serviceName?: string; + /** Existing or desired R2 bucket name for response bodies. */ + r2BucketName?: string; + /** Set false to bind an existing compatible service without deploying it. */ + deployService?: boolean; }; /** @@ -16,6 +23,35 @@ export function responseStoreAdapter(options: ResponseStoreAdapterOptions = {}) if (mode !== "service-binding" && mode !== "self-contained") { throw new Error(`Unknown Workers Response Store mode: ${String(mode)}`); } + for (const [name, value] of [ + ["serviceName", options.serviceName], + ["r2BucketName", options.r2BucketName], + ] as const) { + if (value !== undefined && (typeof value !== "string" || value.length === 0)) { + throw new TypeError(`responseStoreAdapter({ ${name} }) must be a non-empty string.`); + } + } + if (options.deployService !== undefined && typeof options.deployService !== "boolean") { + throw new TypeError("responseStoreAdapter({ deployService }) must be a boolean."); + } + if ( + mode === "self-contained" && + (options.serviceName !== undefined || + options.r2BucketName !== undefined || + options.deployService !== undefined) + ) { + throw new TypeError("Response Store service options cannot be used in self-contained mode."); + } + if (options.deployService === false && !options.serviceName) { + throw new TypeError( + "responseStoreAdapter({ deployService: false }) requires an existing serviceName.", + ); + } + if (options.deployService === false && options.r2BucketName) { + throw new TypeError( + "r2BucketName configures a deployed service and cannot be used when deployService is false.", + ); + } const workerEntry = fileURLToPath( import.meta.resolve( mode === "self-contained" @@ -43,6 +79,18 @@ export function responseStoreAdapter(options: ResponseStoreAdapterOptions = {}) if (cleanId !== CLOUDFLARE_WORKER_ENTRY_ID) return null; return `${code}\nexport { ${entrypoints} } from ${JSON.stringify(workerEntry)};\n`; }, + ...(mode === "service-binding" + ? { + finalizeBuildOutput(output: { outDir: string; isPrimaryServerOutput: boolean }) { + return finalizeResponseStoreBuildOutput({ + ...output, + serviceName: options.serviceName, + r2BucketName: options.r2BucketName, + deployService: options.deployService, + }); + }, + } + : {}), type: "multi-stage" as const, }, capabilities: { diff --git a/packages/cloudflare/src/deploy.ts b/packages/cloudflare/src/deploy.ts index 1b7e870f99..42387c99af 100644 --- a/packages/cloudflare/src/deploy.ts +++ b/packages/cloudflare/src/deploy.ts @@ -93,6 +93,7 @@ import { PHASE_PRODUCTION_BUILD } from "vinext/shims/constants"; import { cacheabilityRoutePathname } from "vinext/internal/server/cacheability-manifest"; import { buildPrerenderKVPairs, type KVBulkPair } from "./prerender-kv-populate.js"; import { writeCacheabilityManifestArtifact } from "./cacheability-artifact.js"; +import { RESPONSE_STORE_SERVICE_CONFIG } from "./cache/response-store-adapter-config.js"; import { DEFAULT_CACHEABILITY_PROBE_PHASE_TIMEOUT_MS, DEFAULT_CACHEABILITY_PROBE_RETRIES, @@ -759,6 +760,35 @@ export async function runWranglerDeploy( return deployedUrl ?? "(URL not detected in wrangler output)"; } +/** Deploy a generated Response Store service before its application Worker. */ +export async function deployResponseStoreService( + root: string, + appConfig: string | undefined, + execute: typeof spawn = spawn, +): Promise { + const appConfigs = appConfig ? [path.resolve(root, appConfig)] : []; + try { + const redirectPath = path.resolve(root, ".wrangler/deploy/config.json"); + const redirect = JSON.parse(fs.readFileSync(redirectPath, "utf8")) as { + configPath?: unknown; + }; + if (typeof redirect.configPath === "string") { + appConfigs.push(path.resolve(path.dirname(redirectPath), redirect.configPath)); + } + } catch { + // The explicit config and conventional output remain valid without a redirect. + } + appConfigs.push(path.resolve(root, "dist/server/wrangler.json")); + const serviceConfig = appConfigs + .map((config) => path.resolve(path.dirname(config), RESPONSE_STORE_SERVICE_CONFIG)) + .find((config) => fs.existsSync(config)); + if (!serviceConfig) return false; + + console.log("\n Deploying Workers Response Store..."); + await runWranglerDeploy(root, { config: path.relative(root, serviceConfig) }, execute); + return true; +} + export function hasCdnWarmRequests( plan: Omit & { pagesDataPaths?: readonly string[] }, ): boolean { @@ -2063,6 +2093,8 @@ export async function deploy(options: DeployOptions): Promise { } // Step 7: Deploy via wrangler + await deployResponseStoreService(root, options.config); + const wranglerOptions = { env: deployEnv === "production" && !options.env ? undefined : deployEnv, name: options.name, diff --git a/packages/cloudflare/tests/response-store-adapter.e2e.test.ts b/packages/cloudflare/tests/response-store-adapter.e2e.test.ts index 02128322ef..8ce3c5237d 100644 --- a/packages/cloudflare/tests/response-store-adapter.e2e.test.ts +++ b/packages/cloudflare/tests/response-store-adapter.e2e.test.ts @@ -11,7 +11,7 @@ const selfContainedAppOutput = path.join( root, "examples/response-store-demo/.vinext/response-store-self-contained/server", ); -const cacheOutput = path.join(root, "packages/workers-response-store/dist/service-cache"); +const cacheOutput = path.join(appOutput, "vinext-response-store"); let miniflare: Miniflare; let workerVersionId: string; @@ -75,7 +75,7 @@ beforeEach(async () => { durableObjects: { CACHE_METADATA: { className: "CacheMetadata", useSQLite: true }, }, - modules: await modules(cacheOutput, "cache-worker.js"), + modules: await modules(cacheOutput, "service.js"), name: "cache", r2Buckets: { CACHE_BODIES: crypto.randomUUID() }, }, @@ -132,16 +132,37 @@ describe("Cloudflare Workers Response Store adapter", () => { } }); - test("the application build owns no cache storage", async () => { + test("the generated application and cache Workers own only their required bindings", async () => { const config = JSON.parse( await readFile(path.join(appOutput, "wrangler.json"), "utf8"), ) as Record; - assert.deepEqual(config.exports, {}); + assert.deepEqual(config.exports, { + default: { type: "worker", cache: { enabled: false } }, + }); assert.deepEqual(config.kv_namespaces, []); assert.deepEqual(config.r2_buckets, []); assert.deepEqual(config.durable_objects, { bindings: [] }); - assert.equal(config.cache, undefined); + assert.deepEqual(config.cache, { enabled: false }); assert.deepEqual(config.version_metadata, { binding: "CF_VERSION_METADATA" }); + assert.deepEqual(config.services, [ + { + binding: "RESPONSE_STORE", + service: "response-store-demo-response-store", + entrypoint: "ResponseStoreService", + }, + ]); + + const cacheConfig = JSON.parse( + await readFile(path.join(cacheOutput, "wrangler.json"), "utf8"), + ) as Record; + assert.deepEqual(cacheConfig.r2_buckets, [{ binding: "CACHE_BODIES" }]); + assert.deepEqual(cacheConfig.durable_objects, { + bindings: [{ name: "CACHE_METADATA", class_name: "CacheMetadata" }], + }); + assert.deepEqual(cacheConfig.exports, { + default: { type: "worker", cache: { enabled: false } }, + ResponseStoreBinding: { type: "worker", cache: { enabled: true } }, + }); }); test("validates staged-version warmup requests and exposes build identity", async () => { diff --git a/packages/create-vinext-app/src/index.ts b/packages/create-vinext-app/src/index.ts index 43a2ac7f8d..22992e11c6 100644 --- a/packages/create-vinext-app/src/index.ts +++ b/packages/create-vinext-app/src/index.ts @@ -10,7 +10,7 @@ import { resolveInitOptions } from "../../vinext/src/init-platform"; type PackageManagerName = "npm" | "pnpm" | "yarn" | "bun"; type InitPlatform = "cloudflare" | "node"; type InitDataCache = "kv" | "none"; -type InitCdnCache = "data-cache" | "workers-cache"; +type InitCdnCache = "data-cache" | "response-store" | "workers-cache"; type InitImageOptimization = "cloudflare-images" | "none"; type CloudflareInitOptions = { @@ -274,7 +274,7 @@ function printHelp(): void { Options: --platform Deployment target: cloudflare or node --data-cache Cloudflare data cache: kv or none - --cdn-cache Cloudflare CDN cache: data-cache or workers-cache + --cdn-cache Cloudflare CDN cache: response-store, workers-cache, or data-cache --image-optimization Cloudflare image optimization: cloudflare-images or none --prerender Configure vinext to pre-render static routes --no-prerender Do not configure pre-rendering diff --git a/packages/vinext/src/cli.ts b/packages/vinext/src/cli.ts index baf979458a..186a2da7cc 100644 --- a/packages/vinext/src/cli.ts +++ b/packages/vinext/src/cli.ts @@ -1020,10 +1020,10 @@ function printHelp(cmd?: string) { (default: prompt, with No selected by default) --experimental-warm-cdn-cache Add experimental CDN pre-warming to the Cloudflare deploy script - (Workers Cache CDN only, default: prompt with No) - --cdn-cache Cloudflare CDN cache: workers-cache or data-cache - (default: workers-cache) - --data-cache Cloudflare data cache: kv or none (default: kv) + (Response Store or Workers Cache, default: prompt with No) + --cdn-cache Cloudflare CDN cache: response-store, workers-cache, or data-cache + (default: response-store) + --data-cache Cloudflare data cache: kv or none --image-optimization Cloudflare image optimization: cloudflare-images or none -h, --help Show this help diff --git a/packages/vinext/src/init-cloudflare.ts b/packages/vinext/src/init-cloudflare.ts index c997d25553..f327c56401 100644 --- a/packages/vinext/src/init-cloudflare.ts +++ b/packages/vinext/src/init-cloudflare.ts @@ -18,8 +18,8 @@ export type CloudflareProjectInfo = { }; const DEFAULT_CLOUDFLARE_INIT_OPTIONS: CloudflareInitOptions = { - dataCache: "kv", - cdnCache: "workers-cache", + dataCache: "none", + cdnCache: "response-store", imageOptimization: "cloudflare-images", }; const DEFAULT_VERSION_METADATA_BINDING = "CF_VERSION_METADATA"; @@ -528,6 +528,11 @@ function cacheImports(options: CloudflareInitOptions): string[] { if (options.cdnCache === "workers-cache") { imports.push('import { cdnAdapter } from "@vinext/cloudflare/cache/cdn-adapter";'); } + if (options.cdnCache === "response-store") { + imports.push( + 'import { responseStoreAdapter } from "@vinext/cloudflare/cache/response-store-adapter";', + ); + } if (options.imageOptimization === "cloudflare-images") { imports.push('import { imagesOptimizer } from "@vinext/cloudflare/images/images-optimizer";'); } @@ -542,6 +547,7 @@ function vinextExpression( prerender = false, versionMetadataBinding = DEFAULT_VERSION_METADATA_BINDING, ): string { + const responseStore = options.cdnCache === "response-store"; const cacheEntries: string[] = []; if (options.dataCache === "kv") { cacheEntries.push("data: kvDataAdapter()"); @@ -554,7 +560,9 @@ function vinextExpression( cacheEntries.push(`cdn: cdnAdapter(${adapterOptions})`); } const optionEntries: string[] = []; - if (cacheEntries.length > 0) { + if (responseStore) { + optionEntries.push("cache: responseStoreAdapter()"); + } else if (cacheEntries.length > 0) { optionEntries.push(`cache: { ${cacheEntries.join(", ")} }`); } if (options.imageOptimization === "cloudflare-images") { @@ -1203,6 +1211,20 @@ function getVinextCacheSlot( return findProperty(cache.value as AstObject, name); } +function getVinextCacheOption( + call: (ESTree.CallExpression & AstNode) | undefined, +): AstProperty | undefined { + const firstArgument = call?.arguments[0]; + if ( + !firstArgument || + firstArgument.type === "SpreadElement" || + firstArgument.type !== "ObjectExpression" + ) { + return undefined; + } + return findProperty(firstArgument as AstObject, "cache"); +} + function hasVinextCacheSlot( call: (ESTree.CallExpression & AstNode) | undefined, name: "data" | "cdn", @@ -1311,6 +1333,31 @@ function ensureVinextCache( } } +function ensureVinextResponseStore( + output: MagicString, + config: AstObject, + vinextBinding: string, + expression: string | undefined, + code: string, +): void { + if (!expression) return; + const call = findPluginCall(config, vinextBinding); + const firstArgument = call?.arguments[0]; + if (!call || !firstArgument || firstArgument.type === "SpreadElement") return; + if (firstArgument.type !== "ObjectExpression") { + throw new Error( + "The vinext() plugin options must be a static object for vinext init to configure Workers Response Store.", + ); + } + const optionsObject = firstArgument as AstObject; + const cache = findProperty(optionsObject, "cache"); + if (!cache) { + insertObjectProperty(output, optionsObject, ` cache: ${expression},`, code); + } else if (cache.value.type === "ObjectExpression" && cache.value.properties.length === 0) { + output.overwrite((cache.value as AstNode).start, (cache.value as AstNode).end, expression); + } +} + function ensureVinextImageOptimizer( output: MagicString, config: AstObject, @@ -1534,11 +1581,7 @@ export function updateViteConfigForCloudflare( }, ): string { const program = parseViteConfig(filePath, code); - const cacheOptions = options.cache ?? { - dataCache: "none", - cdnCache: "workers-cache", - imageOptimization: "cloudflare-images", - }; + const cacheOptions = options.cache ?? DEFAULT_CLOUDFLARE_INIT_OPTIONS; const config = findConfigObject(program); if (!config) { throw new Error( @@ -1570,6 +1613,32 @@ export function updateViteConfigForCloudflare( const needsPrerender = Boolean(options.prerender && !hasVinextPrerender(existingVinextCall)); const configureCaches = options.cache !== undefined; const cacheAdditions: Array<{ name: "data" | "cdn"; expression: string }> = []; + let responseStoreExpression: string | undefined; + if (configureCaches && cacheOptions.cdnCache === "response-store") { + const source = "@vinext/cloudflare/cache/response-store-adapter"; + const imported = "responseStoreAdapter"; + const existing = commonJs + ? findRequiredBinding(program, source, imported) + : findImportedBinding(program, source, imported); + const cache = getVinextCacheOption(existingVinextCall); + const alreadyConfigured = Boolean( + existing && + cache?.value.type === "CallExpression" && + cache.value.callee.type === "Identifier" && + cache.value.callee.name === existing, + ); + if ( + !cache || + alreadyConfigured || + (cache.value.type === "ObjectExpression" && cache.value.properties.length === 0) + ) { + const local = existing ?? allocateBinding(bindings, imported); + const binding = commonJs + ? ensureNamedRequire(program, output, source, imported, local) + : ensureNamedImport(program, output, source, imported, local); + responseStoreExpression = `${binding}()`; + } + } if (cacheOptions.dataCache === "kv" && !hasVinextCacheSlot(existingVinextCall, "data")) { const existing = commonJs ? findRequiredBinding(program, "@vinext/cloudflare/cache/kv-data-adapter", "kvDataAdapter") @@ -1688,10 +1757,15 @@ export function updateViteConfigForCloudflare( if (existingVinextCall) { if ( existingVinextCall.arguments.length === 0 && - (cacheAdditions.length > 0 || imageOptimizerExpression || needsPrerender) + (responseStoreExpression || + cacheAdditions.length > 0 || + imageOptimizerExpression || + needsPrerender) ) { const properties: string[] = []; - if (cacheAdditions.length > 0) { + if (responseStoreExpression) { + properties.push(`cache: ${responseStoreExpression}`); + } else if (cacheAdditions.length > 0) { properties.push( `cache: { ${cacheAdditions.map(({ name, expression }) => `${name}: ${expression}`).join(", ")} }`, ); @@ -1717,6 +1791,7 @@ export function updateViteConfigForCloudflare( `{\n${propertyEntryIndent}${properties.join(`,\n${propertyEntryIndent}`)},\n${closingIndent}}`, ); } else { + ensureVinextResponseStore(output, config, vinextBinding, responseStoreExpression, code); ensureVinextCache(output, config, vinextBinding, cacheAdditions, code); ensureVinextImageOptimizer(output, config, vinextBinding, imageOptimizerExpression, code); ensureVinextPrerender(output, config, vinextBinding, options.prerender, code); diff --git a/packages/vinext/src/init-platform.ts b/packages/vinext/src/init-platform.ts index 8b87b74728..1a0217b632 100644 --- a/packages/vinext/src/init-platform.ts +++ b/packages/vinext/src/init-platform.ts @@ -4,7 +4,7 @@ import { isAgent } from "am-i-vibing"; export type InitPlatform = "cloudflare" | "node"; export type InitDataCache = "kv" | "none"; -export type InitCdnCache = "data-cache" | "workers-cache"; +export type InitCdnCache = "data-cache" | "response-store" | "workers-cache"; export type InitImageOptimization = "cloudflare-images" | "none"; export type CloudflareInitOptions = { @@ -111,7 +111,7 @@ export function parseDataCacheArg(args: string[]): InitDataCache | undefined { } export function parseCdnCacheArg(args: string[]): InitCdnCache | undefined { - return parseChoiceArg(args, "--cdn-cache", ["workers-cache", "data-cache"]); + return parseChoiceArg(args, "--cdn-cache", ["response-store", "workers-cache", "data-cache"]); } export function parseImageOptimizationArg(args: string[]): InitImageOptimization | undefined { @@ -214,15 +214,17 @@ export async function resolveInitOptions( const platform = await resolveInitPlatform(args, options); const platformOptions = await INIT_PLATFORMS[platform].options(args, options); const explicitWarmCdnCache = parseWarmCdnCacheArg(args); - if (platform === "cloudflare" && platformOptions?.cdnCache !== "workers-cache") { + if (platform === "cloudflare" && platformOptions?.cdnCache === "data-cache") { if (explicitWarmCdnCache === true) { - throw new Error("--experimental-warm-cdn-cache requires --cdn-cache=workers-cache."); + throw new Error( + "--experimental-warm-cdn-cache requires --cdn-cache=response-store or workers-cache.", + ); } } const prerender = await resolveInitPrerender(args, options); const warmCdnCache = - platform === "cloudflare" && platformOptions?.cdnCache === "workers-cache" + platform === "cloudflare" && platformOptions?.cdnCache !== "data-cache" ? await resolveInitWarmCdnCache(args, options) : false; @@ -296,9 +298,7 @@ export async function resolveInitWarmCdnCache( try { while (true) { - const answer = ( - await question(" Enable Workers Cache experimental pre-warm during deploy? [y/N]: ") - ) + const answer = (await question(" Enable experimental cache pre-warm during deploy? [y/N]: ")) .trim() .toLowerCase(); if (answer === "") { @@ -327,9 +327,18 @@ export async function resolveCloudflareInitOptions( const explicitDataCache = parseDataCacheArg(args); const explicitCdnCache = parseCdnCacheArg(args); const explicitImageOptimization = parseImageOptimizationArg(args); - if (explicitCdnCache && explicitDataCache && explicitImageOptimization) { + if (explicitCdnCache === "response-store" && explicitDataCache === "kv") { + throw new Error( + "--cdn-cache=response-store provides the data cache and cannot be combined with --data-cache=kv.", + ); + } + if ( + explicitCdnCache && + (explicitCdnCache === "response-store" || explicitDataCache) && + explicitImageOptimization + ) { return { - dataCache: explicitDataCache, + dataCache: explicitCdnCache === "response-store" ? "none" : (explicitDataCache ?? "kv"), cdnCache: explicitCdnCache, imageOptimization: explicitImageOptimization, }; @@ -338,7 +347,7 @@ export async function resolveCloudflareInitOptions( const env = options.env ?? process.env; if (isAgentEnvironment(env)) { throw new Error( - "vinext init needs Cloudflare cache and image choices. Ask the user which CDN cache (workers-cache or data-cache), data cache (kv or none), and image optimization (cloudflare-images or none) they want, then re-run with --cdn-cache=..., --data-cache=..., and --image-optimization=....", + "vinext init needs Cloudflare cache and image choices. Ask the user which CDN cache (response-store, workers-cache, or data-cache), data cache (kv or none), and image optimization (cloudflare-images or none) they want, then re-run with --cdn-cache=..., --data-cache=..., and --image-optimization=....", ); } @@ -347,9 +356,10 @@ export async function resolveCloudflareInitOptions( const isInteractive = options.isInteractive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY); if (!isInteractive) { + const cdnCache = explicitCdnCache ?? (explicitDataCache ? "data-cache" : "response-store"); return { - dataCache: explicitDataCache ?? "kv", - cdnCache: explicitCdnCache ?? "workers-cache", + dataCache: cdnCache === "response-store" ? "none" : (explicitDataCache ?? "kv"), + cdnCache, imageOptimization: explicitImageOptimization ?? "cloudflare-images", }; } @@ -382,25 +392,35 @@ export async function resolveCloudflareInitOptions( const cdnCache = await promptChoice( explicitCdnCache, - " Choose a CDN cache:\n 1. Workers Cache (default)\n 2. Data cache\n CDN cache [1]: ", + " Choose a CDN cache:\n 1. Workers Response Store (default)\n 2. Workers Cache\n 3. Data cache\n CDN cache [1]: ", { - "1": "workers-cache", + "1": "response-store", + "response-store": "response-store", + "2": "workers-cache", "workers-cache": "workers-cache", workers: "workers-cache", - "2": "data-cache", + "3": "data-cache", "data-cache": "data-cache", data: "data-cache", }, - "workers-cache", - "Please choose Workers Cache (1) or Data cache (2).", - ); - const dataCache = await promptChoice( - explicitDataCache, - " Choose a data cache:\n 1. Cloudflare KV (default)\n 2. None\n Data cache [1]: ", - { "1": "kv", kv: "kv", "2": "none", none: "none" }, - "kv", - "Please choose Cloudflare KV (1) or None (2).", + "response-store", + "Please choose Workers Response Store (1), Workers Cache (2), or Data cache (3).", ); + if (cdnCache === "response-store" && explicitDataCache === "kv") { + throw new Error( + "--cdn-cache=response-store provides the data cache and cannot be combined with --data-cache=kv.", + ); + } + const dataCache = + cdnCache === "response-store" + ? "none" + : await promptChoice( + explicitDataCache, + " Choose a data cache:\n 1. Cloudflare KV (default)\n 2. None\n Data cache [1]: ", + { "1": "kv", kv: "kv", "2": "none", none: "none" }, + "kv", + "Please choose Cloudflare KV (1) or None (2).", + ); const imageOptimization = await promptChoice( explicitImageOptimization, " Choose image optimization:\n 1. Cloudflare Images (default)\n 2. None\n Image optimization [1]: ", diff --git a/tests/cdn-adapter-config.test.ts b/tests/cdn-adapter-config.test.ts index c69ccb80b0..24d3ee8d47 100644 --- a/tests/cdn-adapter-config.test.ts +++ b/tests/cdn-adapter-config.test.ts @@ -12,6 +12,10 @@ import { DEFAULT_CDN_VERSION_METADATA_BINDING, } from "../packages/cloudflare/src/cache/cdn-adapter.js"; import { responseStoreAdapter } from "../packages/cloudflare/src/cache/response-store-adapter.js"; +import { + finalizeResponseStoreBuildOutput, + RESPONSE_STORE_SERVICE_CONFIG, +} from "../packages/cloudflare/src/cache/response-store-adapter-config.js"; import { resolveCdnAdapterConfig } from "../packages/cloudflare/src/deploy-config.js"; import { assertCdnVersionMetadataConfig } from "../packages/cloudflare/src/wrangler-version-metadata.js"; @@ -79,6 +83,167 @@ describe("Cloudflare CDN adapter generated config", () => { expect(fs.readFileSync(sourcePath, "utf8")).toBe('{"name":"source-worker"}'); }); + it("emits and connects the Response Store service Worker", async () => { + const generatedPath = writeGeneratedConfig("dist/server/wrangler.json", { + name: "test-worker", + main: "index.js", + compatibility_date: "2026-09-14", + compatibility_flags: ["nodejs_compat"], + services: [{ binding: "OTHER", service: "other-worker" }], + }); + + await finalizeResponseStoreBuildOutput({ + outDir: path.dirname(generatedPath), + isPrimaryServerOutput: true, + }); + + const appConfig = JSON.parse(fs.readFileSync(generatedPath, "utf8")); + expect(appConfig.cache).toEqual({ enabled: false }); + expect(appConfig.version_metadata).toEqual({ binding: "CF_VERSION_METADATA" }); + expect(appConfig.services).toEqual([ + { binding: "OTHER", service: "other-worker" }, + { + binding: "RESPONSE_STORE", + service: "test-worker-response-store", + entrypoint: "ResponseStoreService", + }, + ]); + expect(appConfig.exports).toMatchObject({ + default: { type: "worker", cache: { enabled: false } }, + }); + + const serviceConfigPath = path.join(path.dirname(generatedPath), RESPONSE_STORE_SERVICE_CONFIG); + const serviceConfig = JSON.parse(fs.readFileSync(serviceConfigPath, "utf8")); + expect(serviceConfig).toMatchObject({ + name: "test-worker-response-store", + main: "service.js", + compatibility_date: "2026-09-14", + workers_dev: false, + preview_urls: false, + cache: { enabled: true }, + r2_buckets: [{ binding: "CACHE_BODIES" }], + durable_objects: { + bindings: [{ name: "CACHE_METADATA", class_name: "CacheMetadata" }], + }, + migrations: [{ tag: "v1", new_sqlite_classes: ["CacheMetadata"] }], + exports: { + default: { type: "worker", cache: { enabled: false } }, + ResponseStoreBinding: { type: "worker", cache: { enabled: true } }, + }, + }); + expect(fs.existsSync(path.join(path.dirname(serviceConfigPath), "service.js"))).toBe(true); + }); + + it("does not emit a Response Store service for self-contained mode", () => { + expect(responseStoreAdapter({ mode: "self-contained" }).cdn.output.finalizeBuildOutput).toBe( + undefined, + ); + expect(responseStoreAdapter().cdn.output.finalizeBuildOutput).toEqual(expect.any(Function)); + }); + + it("uses custom Response Store service and R2 bucket names", async () => { + const generatedPath = writeGeneratedConfig("dist/server/wrangler.json", { + name: "test-worker", + main: "index.js", + compatibility_date: "2026-09-14", + }); + + await responseStoreAdapter({ + serviceName: "shared-response-store", + r2BucketName: "shared-response-store-bodies", + }).cdn.output.finalizeBuildOutput?.({ + outDir: path.dirname(generatedPath), + isPrimaryServerOutput: true, + }); + + const appConfig = JSON.parse(fs.readFileSync(generatedPath, "utf8")); + expect(appConfig.services).toContainEqual({ + binding: "RESPONSE_STORE", + service: "shared-response-store", + entrypoint: "ResponseStoreService", + }); + const serviceConfig = JSON.parse( + fs.readFileSync( + path.join(path.dirname(generatedPath), RESPONSE_STORE_SERVICE_CONFIG), + "utf8", + ), + ); + expect(serviceConfig.name).toBe("shared-response-store"); + expect(serviceConfig.r2_buckets).toEqual([ + { binding: "CACHE_BODIES", bucket_name: "shared-response-store-bodies" }, + ]); + }); + + it("can bind an existing Response Store service without emitting it", async () => { + const generatedPath = writeGeneratedConfig("dist/server/wrangler.json", { + name: "test-worker", + main: "index.js", + compatibility_date: "2026-09-14", + }); + const serviceConfigPath = path.join(path.dirname(generatedPath), RESPONSE_STORE_SERVICE_CONFIG); + writeJson(path.relative(root, serviceConfigPath), { name: "stale-generated-response-store" }); + + await responseStoreAdapter({ + serviceName: "managed-response-store", + deployService: false, + }).cdn.output.finalizeBuildOutput?.({ + outDir: path.dirname(generatedPath), + isPrimaryServerOutput: true, + }); + + expect(fs.existsSync(serviceConfigPath)).toBe(false); + expect(JSON.parse(fs.readFileSync(generatedPath, "utf8")).services).toEqual([ + { + binding: "RESPONSE_STORE", + service: "managed-response-store", + entrypoint: "ResponseStoreService", + }, + ]); + }); + + it("rejects invalid Response Store resource options", () => { + expect(() => responseStoreAdapter({ serviceName: "" })).toThrow("non-empty string"); + expect(() => responseStoreAdapter({ r2BucketName: "" })).toThrow("non-empty string"); + expect(() => responseStoreAdapter({ deployService: "false" as never })).toThrow( + "must be a boolean", + ); + expect(() => + responseStoreAdapter({ mode: "self-contained", serviceName: "response-store" }), + ).toThrow("cannot be used in self-contained mode"); + expect(() => responseStoreAdapter({ deployService: false })).toThrow( + "requires an existing serviceName", + ); + expect(() => + responseStoreAdapter({ + serviceName: "response-store", + r2BucketName: "response-store-bodies", + deployService: false, + }), + ).toThrow("cannot be used when deployService is false"); + }); + + it("keeps the provisioned R2 bucket name within Cloudflare's limit", async () => { + const generatedPath = writeGeneratedConfig("dist/server/wrangler.json", { + name: "a-very-long-worker-name-that-nearly-reaches-the-worker-name-limit", + main: "index.js", + compatibility_date: "2026-09-14", + }); + await finalizeResponseStoreBuildOutput({ + outDir: path.dirname(generatedPath), + isPrimaryServerOutput: true, + }); + const serviceConfig = JSON.parse( + fs.readFileSync( + path.join(path.dirname(generatedPath), RESPONSE_STORE_SERVICE_CONFIG), + "utf8", + ), + ); + expect(`${serviceConfig.name}-cache-bodies`.length).toBeLessThanOrEqual(63); + expect(JSON.parse(fs.readFileSync(generatedPath, "utf8")).services[0].service).toBe( + serviceConfig.name, + ); + }); + it("uses the primary vinext server output when the deploy redirect is absent", async () => { const generatedPath = writeJson("dist/server/wrangler.json", { name: "test-worker", diff --git a/tests/create-vinext-app.test.ts b/tests/create-vinext-app.test.ts index 4723218b8c..9cc26e05f6 100644 --- a/tests/create-vinext-app.test.ts +++ b/tests/create-vinext-app.test.ts @@ -71,8 +71,8 @@ const cloudflareInitOptions: ResolvedInitOptions = { platform: "cloudflare", prerender: false, cloudflare: { - dataCache: "kv", - cdnCache: "data-cache", + dataCache: "none", + cdnCache: "response-store", imageOptimization: "cloudflare-images", }, }; @@ -133,6 +133,7 @@ describe("createVinextApp", () => { expect(readFile(appPath, "README.md")).not.toMatch(/\bnpm\b|\bnpx\b/); expect(readFile(appPath, "app/globals.css")).toContain('@import "tailwindcss"'); expect(readFile(appPath, "vite.config.ts")).toContain("@cloudflare/vite-plugin"); + expect(readFile(appPath, "vite.config.ts")).toContain("cache: responseStoreAdapter()"); expect(readFile(appPath, "wrangler.jsonc")).toContain('"main": "vinext/server/fetch-handler"'); expect(readFile(appPath, ".gitignore")).toContain(".wrangler/"); expect(readFile(appPath, ".gitignore")).toContain("next-env.d.ts"); diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index f8f21360b5..26423ba80e 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -7,6 +7,7 @@ import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; import { deploy, + deployResponseStoreService, buildNodeCliInvocation, buildWranglerKVBulkPutArgs, buildWranglerInvocation, @@ -225,6 +226,38 @@ describe("buildWranglerDeployArgs", () => { }); }); +describe("deployResponseStoreService", () => { + it("deploys a generated service config before the application deploy", async () => { + writeWranglerPackageForTest(tmpDir); + writeFile(tmpDir, "dist/server/wrangler.json", "{}"); + writeFile(tmpDir, "dist/server/vinext-response-store/wrangler.json", "{}"); + let observed: Parameters | undefined; + const execute = ((...args: Parameters) => { + observed = args; + return createMockChildProcess("Deployed\n"); + }) as typeof spawn; + + await expect( + deployResponseStoreService(tmpDir, "dist/server/wrangler.json", execute), + ).resolves.toBe(true); + + expect(observed?.[1]).toEqual([ + expectedWranglerBinForTest(tmpDir), + "deploy", + "--config", + path.join("dist", "server", "vinext-response-store", "wrangler.json"), + ]); + }); + + it("is a no-op when the build has no generated service", async () => { + const execute = vi.fn() as unknown as typeof spawn; + await expect( + deployResponseStoreService(tmpDir, "dist/server/wrangler.json", execute), + ).resolves.toBe(false); + expect(execute).not.toHaveBeenCalled(); + }); +}); + describe("buildWranglerKVBulkPutArgs", () => { it("uploads a bulk JSON file to the configured KV binding", () => { expect( @@ -1198,7 +1231,7 @@ describe("generateWranglerConfig", () => { expect(parsed.compatibility_date).toBe(today); }); - it("includes the default KV namespace", () => { + it("does not require a KV namespace for the default Response Store", () => { mkdir(tmpDir, "app"); writeFile( tmpDir, @@ -1209,8 +1242,9 @@ describe("generateWranglerConfig", () => { const config = generateWranglerConfig(info); const parsed = JSON.parse(config); - expect(parsed.kv_namespaces).toBeDefined(); - expect(parsed.kv_namespaces[0].binding).toBe("VINEXT_KV_CACHE"); + expect(parsed.kv_namespaces).toBeUndefined(); + expect(parsed.cache).toBeUndefined(); + expect(parsed.version_metadata).toBeUndefined(); }); it("omits KV namespace when KV caches are disabled", () => { diff --git a/tests/init-cloudflare.test.ts b/tests/init-cloudflare.test.ts index 921b574a0f..5aa4675d49 100644 --- a/tests/init-cloudflare.test.ts +++ b/tests/init-cloudflare.test.ts @@ -20,6 +20,36 @@ function expectValidConfig(output: string): void { } describe("updateViteConfigForCloudflare", () => { + it("configures Workers Response Store as the generated default", () => { + const output = generateAppRouterViteConfig(); + expectValidConfig(output); + expect(output).toContain( + 'import { responseStoreAdapter } from "@vinext/cloudflare/cache/response-store-adapter";', + ); + expect(output).toContain("cache: responseStoreAdapter()"); + expect(output).not.toContain("kvDataAdapter"); + expect(output).not.toContain("cdnAdapter"); + }); + + it("adds Workers Response Store to an existing bare vinext config", () => { + const input = `import vinext from "vinext"; +export default { plugins: [vinext()] }; +`; + const options = { + isAppRouter: false, + nativeModulesToStub: [], + cache: { + dataCache: "none" as const, + cdnCache: "response-store" as const, + imageOptimization: "none" as const, + }, + }; + const output = updateViteConfigForCloudflare("vite.config.ts", input, options); + expectValidConfig(output); + expect(output).toContain("vinext({\n cache: responseStoreAdapter(),\n })"); + expect(updateViteConfigForCloudflare("vite.config.ts", output, options)).toBe(output); + }); + it("updates an existing ESM App Router config without replacing user code", () => { const input = `import { defineConfig } from "vite"; import vinext from "vinext"; diff --git a/tests/init-platform.test.ts b/tests/init-platform.test.ts index 05cf887995..e0504fb7ad 100644 --- a/tests/init-platform.test.ts +++ b/tests/init-platform.test.ts @@ -31,16 +31,17 @@ describe("Cloudflare init choices", () => { it("parses cache and image flags", () => { expect(parseDataCacheArg(["--data-cache=none"])).toBe("none"); expect(parseCdnCacheArg(["--cdn-cache", "data-cache"])).toBe("data-cache"); + expect(parseCdnCacheArg(["--cdn-cache=response-store"])).toBe("response-store"); expect(parseCdnCacheArg(["--cdn-cache=workers-cache"])).toBe("workers-cache"); expect(parseImageOptimizationArg(["--image-optimization=none"])).toBe("none"); }); - it("defaults to KV data, Workers Cache CDN, and Cloudflare Images", async () => { + it("defaults to Workers Response Store and Cloudflare Images", async () => { await expect( resolveCloudflareInitOptions([], { env: {}, isInteractive: false }), ).resolves.toEqual({ - dataCache: "kv", - cdnCache: "workers-cache", + dataCache: "none", + cdnCache: "response-store", imageOptimization: "cloudflare-images", }); }); @@ -76,16 +77,16 @@ describe("Cloudflare init choices", () => { it("rejects legacy CDN cache choices", () => { expect(() => parseCdnCacheArg(["--cdn-cache=kv"])).toThrow( - "Expected workers-cache or data-cache", + "Expected response-store or workers-cache or data-cache", ); expect(() => parseCdnCacheArg(["--cdn-cache=none"])).toThrow( - "Expected workers-cache or data-cache", + "Expected response-store or workers-cache or data-cache", ); }); it("prompts for CDN cache before the other Cloudflare choices", async () => { const prompts: string[] = []; - const answers = ["2", "2", "2"]; + const answers = ["3", "2", "2"]; const output = new PassThrough(); await expect( resolveCloudflareInitOptions([], { @@ -103,7 +104,7 @@ describe("Cloudflare init choices", () => { imageOptimization: "none", }); expect(prompts).toEqual([ - " Choose a CDN cache:\n 1. Workers Cache (default)\n 2. Data cache\n CDN cache [1]: ", + " Choose a CDN cache:\n 1. Workers Response Store (default)\n 2. Workers Cache\n 3. Data cache\n CDN cache [1]: ", " Choose a data cache:\n 1. Cloudflare KV (default)\n 2. None\n Data cache [1]: ", " Choose image optimization:\n 1. Cloudflare Images (default)\n 2. None\n Image optimization [1]: ", ]); @@ -135,7 +136,7 @@ describe("Cloudflare init choices", () => { output, question: async (prompt) => { prompts.push(prompt); - return "2"; + return "3"; }, }), ).resolves.toEqual({ @@ -144,14 +145,14 @@ describe("Cloudflare init choices", () => { imageOptimization: "none", }); expect(prompts).toEqual([ - " Choose a CDN cache:\n 1. Workers Cache (default)\n 2. Data cache\n CDN cache [1]: ", + " Choose a CDN cache:\n 1. Workers Response Store (default)\n 2. Workers Cache\n 3. Data cache\n CDN cache [1]: ", ]); expect(output.read()?.toString()).toBe("\n"); }); it("does not add a section break when repeating an invalid choice", async () => { const prompts: string[] = []; - const answers = ["invalid", "2", "2", "2"]; + const answers = ["invalid", "3", "2", "2"]; const output = new PassThrough(); await resolveCloudflareInitOptions([], { env: {}, @@ -168,7 +169,7 @@ describe("Cloudflare init choices", () => { expect(prompts[2]).toMatch(/^ Choose a data cache:/); expect(prompts[3]).toMatch(/^ Choose image optimization:/); expect(output.read()?.toString()).toBe( - " Please choose Workers Cache (1) or Data cache (2).\n\n\n\n", + " Please choose Workers Response Store (1), Workers Cache (2), or Data cache (3).\n\n\n\n", ); }); }); @@ -260,9 +261,7 @@ describe("warm CDN cache init choice", () => { }, }), ).resolves.toBe(false); - expect(prompts).toEqual([ - " Enable Workers Cache experimental pre-warm during deploy? [y/N]: ", - ]); + expect(prompts).toEqual([" Enable experimental cache pre-warm during deploy? [y/N]: "]); expect(output.read()?.toString()).toBe("\n"); }); }); @@ -326,13 +325,13 @@ describe("resolveInitPlatform", () => { }); describe("resolveInitOptions", () => { - it("defaults Cloudflare Workers Cache init away from CDN pre-warming", async () => { + it("defaults Cloudflare Workers Response Store init away from pre-warming", async () => { await expect(resolveInitOptions([], { env: {}, isInteractive: false })).resolves.toEqual({ platform: "cloudflare", prerender: false, cloudflare: { - dataCache: "kv", - cdnCache: "workers-cache", + dataCache: "none", + cdnCache: "response-store", imageOptimization: "cloudflare-images", warmCdnCache: false, }, @@ -363,9 +362,9 @@ describe("resolveInitOptions", () => { }); }); - it("asks whether to pre-warm Workers Cache after the prerender prompt", async () => { + it("asks whether to pre-warm Workers Response Store after the prerender prompt", async () => { const prompts: string[] = []; - const answers = ["", "", "", "n", ""]; + const answers = ["", "", "n", ""]; await expect( resolveInitOptions(["--platform=cloudflare"], { @@ -380,25 +379,24 @@ describe("resolveInitOptions", () => { platform: "cloudflare", prerender: false, cloudflare: { - dataCache: "kv", - cdnCache: "workers-cache", + dataCache: "none", + cdnCache: "response-store", imageOptimization: "cloudflare-images", warmCdnCache: false, }, }); expect(prompts).toEqual([ - " Choose a CDN cache:\n 1. Workers Cache (default)\n 2. Data cache\n CDN cache [1]: ", - " Choose a data cache:\n 1. Cloudflare KV (default)\n 2. None\n Data cache [1]: ", + " Choose a CDN cache:\n 1. Workers Response Store (default)\n 2. Workers Cache\n 3. Data cache\n CDN cache [1]: ", " Choose image optimization:\n 1. Cloudflare Images (default)\n 2. None\n Image optimization [1]: ", " Pre-render all static routes after build? [y/N]: ", - " Enable Workers Cache experimental pre-warm during deploy? [y/N]: ", + " Enable experimental cache pre-warm during deploy? [y/N]: ", ]); }); it("does not ask about pre-warming when Data cache is selected for CDN cache", async () => { const prompts: string[] = []; - const answers = ["2", "", "", ""]; + const answers = ["3", "", "", ""]; await expect( resolveInitOptions(["--platform=cloudflare"], { @@ -421,7 +419,7 @@ describe("resolveInitOptions", () => { }); expect(prompts).toEqual([ - " Choose a CDN cache:\n 1. Workers Cache (default)\n 2. Data cache\n CDN cache [1]: ", + " Choose a CDN cache:\n 1. Workers Response Store (default)\n 2. Workers Cache\n 3. Data cache\n CDN cache [1]: ", " Choose a data cache:\n 1. Cloudflare KV (default)\n 2. None\n Data cache [1]: ", " Choose image optimization:\n 1. Cloudflare Images (default)\n 2. None\n Image optimization [1]: ", " Pre-render all static routes after build? [y/N]: ", @@ -440,6 +438,8 @@ describe("resolveInitOptions", () => { ], { env: {}, isInteractive: false }, ), - ).rejects.toThrow("--experimental-warm-cdn-cache requires --cdn-cache=workers-cache"); + ).rejects.toThrow( + "--experimental-warm-cdn-cache requires --cdn-cache=response-store or workers-cache", + ); }); }); From d6dd4ec70218f670554d6aedbd483c12b167db77 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 14 Sep 2026 11:29:43 +0100 Subject: [PATCH 02/20] fix(cloudflare): keep generated caching opt-in --- .github/workflows/ci.yml | 2 + .../create-vinext-app-cloudflare-deploy.yml | 1 + .github/workflows/deploy-examples.yml | 7 +- apps/web/vite.config.ts | 8 +- apps/web/wrangler.jsonc | 16 ---- packages/create-vinext-app/src/index.ts | 4 +- packages/vinext/src/cli.ts | 4 +- packages/vinext/src/init-cloudflare.ts | 2 +- packages/vinext/src/init-platform.ts | 64 +++++++++---- tests/init-cloudflare.test.ts | 8 +- tests/init-platform.test.ts | 94 +++++++++++++++---- 11 files changed, 140 insertions(+), 70 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67eb83919d..92672f1b0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -335,6 +335,7 @@ jobs: working-directory: ${{ runner.temp }}/cna-cloudflare run: >- vp exec vinext init --skip-check --platform=cloudflare + --cdn-cache=response-store --image-optimization=cloudflare-images - name: Configure generated Cloudflare deployment @@ -395,6 +396,7 @@ jobs: --skip-install --disable-git --platform=cloudflare + --cdn-cache=response-store --image-optimization=cloudflare-images - name: Pin pnpm in scaffolded project to vinext's version diff --git a/.github/workflows/create-vinext-app-cloudflare-deploy.yml b/.github/workflows/create-vinext-app-cloudflare-deploy.yml index 49e906b1c4..0b49076d42 100644 --- a/.github/workflows/create-vinext-app-cloudflare-deploy.yml +++ b/.github/workflows/create-vinext-app-cloudflare-deploy.yml @@ -54,6 +54,7 @@ jobs: --skip-install --disable-git --platform=cloudflare + --cdn-cache=response-store --image-optimization=cloudflare-images - name: Pin pnpm in scaffolded project to vinext's version diff --git a/.github/workflows/deploy-examples.yml b/.github/workflows/deploy-examples.yml index e1050aa541..f15603f933 100644 --- a/.github/workflows/deploy-examples.yml +++ b/.github/workflows/deploy-examples.yml @@ -204,13 +204,18 @@ jobs: run: vp build working-directory: ${{ matrix.example.working_directory }} env: - VINEXT_RESPONSE_STORE_SERVICE_NAME: ${{ matrix.example.name == 'response-store-demo' && needs.response_store.outputs.service_name || '' }} + VINEXT_RESPONSE_STORE_SERVICE_NAME: ${{ (matrix.example.name == 'response-store-demo' || (matrix.example.name == 'web' && github.event_name == 'pull_request')) && needs.response_store.outputs.service_name || '' }} - name: Build static export if: matrix.example.static_export == true run: vp exec vinext build working-directory: ${{ matrix.example.working_directory }} + - name: Verify generated Response Store Worker + if: matrix.example.name == 'response-store-demo' || matrix.example.name == 'web' + working-directory: ${{ matrix.example.working_directory }} + run: test -f dist/server/vinext-response-store/wrangler.json + - name: Verify static export artifact if: matrix.example.static_export == true run: node scripts/verify-output.mjs diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index a28e1538c9..d27623518b 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,17 +1,13 @@ import vinext from "vinext"; import { defineConfig } from "vite-plus"; -import { cdnAdapter } from "@vinext/cloudflare/cache/cdn-adapter"; -import { kvDataAdapter } from "@vinext/cloudflare/cache/kv-data-adapter"; +import { responseStoreAdapter } from "@vinext/cloudflare/cache/response-store-adapter"; import { imagesOptimizer } from "@vinext/cloudflare/images/images-optimizer"; import { cloudflare } from "@cloudflare/vite-plugin"; export default defineConfig({ plugins: [ vinext({ - cache: { - cdn: cdnAdapter(), - data: kvDataAdapter(), - }, + cache: responseStoreAdapter(), images: { optimizer: imagesOptimizer(), }, diff --git a/apps/web/wrangler.jsonc b/apps/web/wrangler.jsonc index ed72d2a147..d360e2180c 100644 --- a/apps/web/wrangler.jsonc +++ b/apps/web/wrangler.jsonc @@ -22,16 +22,6 @@ "migrations_dir": "./migrations", }, ], - // KV namespace used as the backing store for vinext's ISR CacheHandler. - // Pages opt into ISR with `export const revalidate = `, and the - // rendered output (RSC + HTML) is cached here keyed by route + searchParams. - // Created via: wrangler kv namespace create vinext-web-cache - "kv_namespaces": [ - { - "binding": "VINEXT_KV_CACHE", - "id": "08075d24ec854a19a52c13f031723def", - }, - ], "r2_buckets": [ { "binding": "PERFORMANCE_PROFILES", @@ -55,10 +45,4 @@ "secrets": { "required": ["COMPAT_INGEST_SECRET"], }, - "cache": { - "enabled": true, - }, - "version_metadata": { - "binding": "CF_VERSION_METADATA", - }, } diff --git a/packages/create-vinext-app/src/index.ts b/packages/create-vinext-app/src/index.ts index 22992e11c6..8c564c374f 100644 --- a/packages/create-vinext-app/src/index.ts +++ b/packages/create-vinext-app/src/index.ts @@ -10,7 +10,7 @@ import { resolveInitOptions } from "../../vinext/src/init-platform"; type PackageManagerName = "npm" | "pnpm" | "yarn" | "bun"; type InitPlatform = "cloudflare" | "node"; type InitDataCache = "kv" | "none"; -type InitCdnCache = "data-cache" | "response-store" | "workers-cache"; +type InitCdnCache = "data-cache" | "none" | "response-store" | "workers-cache"; type InitImageOptimization = "cloudflare-images" | "none"; type CloudflareInitOptions = { @@ -274,7 +274,7 @@ function printHelp(): void { Options: --platform Deployment target: cloudflare or node --data-cache Cloudflare data cache: kv or none - --cdn-cache Cloudflare CDN cache: response-store, workers-cache, or data-cache + --cdn-cache Cloudflare CDN cache: none, response-store, workers-cache, or data-cache --image-optimization Cloudflare image optimization: cloudflare-images or none --prerender Configure vinext to pre-render static routes --no-prerender Do not configure pre-rendering diff --git a/packages/vinext/src/cli.ts b/packages/vinext/src/cli.ts index 186a2da7cc..012c336f75 100644 --- a/packages/vinext/src/cli.ts +++ b/packages/vinext/src/cli.ts @@ -1021,8 +1021,8 @@ function printHelp(cmd?: string) { --experimental-warm-cdn-cache Add experimental CDN pre-warming to the Cloudflare deploy script (Response Store or Workers Cache, default: prompt with No) - --cdn-cache Cloudflare CDN cache: response-store, workers-cache, or data-cache - (default: response-store) + --cdn-cache Cloudflare CDN cache: none, response-store, workers-cache, or data-cache + (default: none; response-store is the default cache choice) --data-cache Cloudflare data cache: kv or none --image-optimization Cloudflare image optimization: cloudflare-images or none diff --git a/packages/vinext/src/init-cloudflare.ts b/packages/vinext/src/init-cloudflare.ts index f327c56401..85fc51b508 100644 --- a/packages/vinext/src/init-cloudflare.ts +++ b/packages/vinext/src/init-cloudflare.ts @@ -19,7 +19,7 @@ export type CloudflareProjectInfo = { const DEFAULT_CLOUDFLARE_INIT_OPTIONS: CloudflareInitOptions = { dataCache: "none", - cdnCache: "response-store", + cdnCache: "none", imageOptimization: "cloudflare-images", }; const DEFAULT_VERSION_METADATA_BINDING = "CF_VERSION_METADATA"; diff --git a/packages/vinext/src/init-platform.ts b/packages/vinext/src/init-platform.ts index 1a0217b632..624afa7fea 100644 --- a/packages/vinext/src/init-platform.ts +++ b/packages/vinext/src/init-platform.ts @@ -4,7 +4,7 @@ import { isAgent } from "am-i-vibing"; export type InitPlatform = "cloudflare" | "node"; export type InitDataCache = "kv" | "none"; -export type InitCdnCache = "data-cache" | "response-store" | "workers-cache"; +export type InitCdnCache = "data-cache" | "none" | "response-store" | "workers-cache"; export type InitImageOptimization = "cloudflare-images" | "none"; export type CloudflareInitOptions = { @@ -111,7 +111,12 @@ export function parseDataCacheArg(args: string[]): InitDataCache | undefined { } export function parseCdnCacheArg(args: string[]): InitCdnCache | undefined { - return parseChoiceArg(args, "--cdn-cache", ["response-store", "workers-cache", "data-cache"]); + return parseChoiceArg(args, "--cdn-cache", [ + "none", + "response-store", + "workers-cache", + "data-cache", + ]); } export function parseImageOptimizationArg(args: string[]): InitImageOptimization | undefined { @@ -214,7 +219,9 @@ export async function resolveInitOptions( const platform = await resolveInitPlatform(args, options); const platformOptions = await INIT_PLATFORMS[platform].options(args, options); const explicitWarmCdnCache = parseWarmCdnCacheArg(args); - if (platform === "cloudflare" && platformOptions?.cdnCache === "data-cache") { + const supportsWarmCdnCache = + platformOptions?.cdnCache === "response-store" || platformOptions?.cdnCache === "workers-cache"; + if (platform === "cloudflare" && !supportsWarmCdnCache) { if (explicitWarmCdnCache === true) { throw new Error( "--experimental-warm-cdn-cache requires --cdn-cache=response-store or workers-cache.", @@ -224,7 +231,7 @@ export async function resolveInitOptions( const prerender = await resolveInitPrerender(args, options); const warmCdnCache = - platform === "cloudflare" && platformOptions?.cdnCache !== "data-cache" + platform === "cloudflare" && supportsWarmCdnCache ? await resolveInitWarmCdnCache(args, options) : false; @@ -327,18 +334,22 @@ export async function resolveCloudflareInitOptions( const explicitDataCache = parseDataCacheArg(args); const explicitCdnCache = parseCdnCacheArg(args); const explicitImageOptimization = parseImageOptimizationArg(args); - if (explicitCdnCache === "response-store" && explicitDataCache === "kv") { - throw new Error( - "--cdn-cache=response-store provides the data cache and cannot be combined with --data-cache=kv.", - ); + if ( + (explicitCdnCache === "response-store" || explicitCdnCache === "none") && + explicitDataCache === "kv" + ) { + throw new Error(`--cdn-cache=${explicitCdnCache} cannot be combined with --data-cache=kv.`); } if ( explicitCdnCache && - (explicitCdnCache === "response-store" || explicitDataCache) && + (explicitCdnCache === "response-store" || explicitCdnCache === "none" || explicitDataCache) && explicitImageOptimization ) { return { - dataCache: explicitCdnCache === "response-store" ? "none" : (explicitDataCache ?? "kv"), + dataCache: + explicitCdnCache === "response-store" || explicitCdnCache === "none" + ? "none" + : (explicitDataCache ?? "kv"), cdnCache: explicitCdnCache, imageOptimization: explicitImageOptimization, }; @@ -347,7 +358,7 @@ export async function resolveCloudflareInitOptions( const env = options.env ?? process.env; if (isAgentEnvironment(env)) { throw new Error( - "vinext init needs Cloudflare cache and image choices. Ask the user which CDN cache (response-store, workers-cache, or data-cache), data cache (kv or none), and image optimization (cloudflare-images or none) they want, then re-run with --cdn-cache=..., --data-cache=..., and --image-optimization=....", + "vinext init needs Cloudflare cache and image choices. Ask the user whether they want no cache or which CDN cache (response-store, workers-cache, or data-cache), data cache (kv or none), and image optimization (cloudflare-images or none) they want, then re-run with --cdn-cache=..., --data-cache=..., and --image-optimization=....", ); } @@ -356,9 +367,10 @@ export async function resolveCloudflareInitOptions( const isInteractive = options.isInteractive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY); if (!isInteractive) { - const cdnCache = explicitCdnCache ?? (explicitDataCache ? "data-cache" : "response-store"); + const cdnCache = explicitCdnCache ?? (explicitDataCache === "kv" ? "data-cache" : "none"); return { - dataCache: cdnCache === "response-store" ? "none" : (explicitDataCache ?? "kv"), + dataCache: + cdnCache === "response-store" || cdnCache === "none" ? "none" : (explicitDataCache ?? "kv"), cdnCache, imageOptimization: explicitImageOptimization ?? "cloudflare-images", }; @@ -390,8 +402,24 @@ export async function resolveCloudflareInitOptions( } }; + let selectedCdnCache = explicitCdnCache; + if (!selectedCdnCache && explicitDataCache !== "kv") { + while (true) { + const answer = (await question(" Enable caching? [y/N]: ")).trim().toLowerCase(); + if (answer === "" || answer === "n" || answer === "no") { + output.write("\n"); + selectedCdnCache = "none"; + break; + } + if (answer === "y" || answer === "yes") { + output.write("\n"); + break; + } + output.write(" Please answer yes or no.\n"); + } + } const cdnCache = await promptChoice( - explicitCdnCache, + selectedCdnCache, " Choose a CDN cache:\n 1. Workers Response Store (default)\n 2. Workers Cache\n 3. Data cache\n CDN cache [1]: ", { "1": "response-store", @@ -406,13 +434,11 @@ export async function resolveCloudflareInitOptions( "response-store", "Please choose Workers Response Store (1), Workers Cache (2), or Data cache (3).", ); - if (cdnCache === "response-store" && explicitDataCache === "kv") { - throw new Error( - "--cdn-cache=response-store provides the data cache and cannot be combined with --data-cache=kv.", - ); + if ((cdnCache === "response-store" || cdnCache === "none") && explicitDataCache === "kv") { + throw new Error(`--cdn-cache=${cdnCache} cannot be combined with --data-cache=kv.`); } const dataCache = - cdnCache === "response-store" + cdnCache === "response-store" || cdnCache === "none" ? "none" : await promptChoice( explicitDataCache, diff --git a/tests/init-cloudflare.test.ts b/tests/init-cloudflare.test.ts index 5aa4675d49..2a4fa27865 100644 --- a/tests/init-cloudflare.test.ts +++ b/tests/init-cloudflare.test.ts @@ -20,15 +20,13 @@ function expectValidConfig(output: string): void { } describe("updateViteConfigForCloudflare", () => { - it("configures Workers Response Store as the generated default", () => { + it("does not configure caching by default", () => { const output = generateAppRouterViteConfig(); expectValidConfig(output); - expect(output).toContain( - 'import { responseStoreAdapter } from "@vinext/cloudflare/cache/response-store-adapter";', - ); - expect(output).toContain("cache: responseStoreAdapter()"); + expect(output).not.toContain("responseStoreAdapter"); expect(output).not.toContain("kvDataAdapter"); expect(output).not.toContain("cdnAdapter"); + expect(output).not.toContain("cache:"); }); it("adds Workers Response Store to an existing bare vinext config", () => { diff --git a/tests/init-platform.test.ts b/tests/init-platform.test.ts index e0504fb7ad..d9280c030c 100644 --- a/tests/init-platform.test.ts +++ b/tests/init-platform.test.ts @@ -30,18 +30,19 @@ describe("parsePlatformArg", () => { describe("Cloudflare init choices", () => { it("parses cache and image flags", () => { expect(parseDataCacheArg(["--data-cache=none"])).toBe("none"); + expect(parseCdnCacheArg(["--cdn-cache=none"])).toBe("none"); expect(parseCdnCacheArg(["--cdn-cache", "data-cache"])).toBe("data-cache"); expect(parseCdnCacheArg(["--cdn-cache=response-store"])).toBe("response-store"); expect(parseCdnCacheArg(["--cdn-cache=workers-cache"])).toBe("workers-cache"); expect(parseImageOptimizationArg(["--image-optimization=none"])).toBe("none"); }); - it("defaults to Workers Response Store and Cloudflare Images", async () => { + it("defaults to no cache and Cloudflare Images", async () => { await expect( resolveCloudflareInitOptions([], { env: {}, isInteractive: false }), ).resolves.toEqual({ dataCache: "none", - cdnCache: "response-store", + cdnCache: "none", imageOptimization: "cloudflare-images", }); }); @@ -77,16 +78,13 @@ describe("Cloudflare init choices", () => { it("rejects legacy CDN cache choices", () => { expect(() => parseCdnCacheArg(["--cdn-cache=kv"])).toThrow( - "Expected response-store or workers-cache or data-cache", - ); - expect(() => parseCdnCacheArg(["--cdn-cache=none"])).toThrow( - "Expected response-store or workers-cache or data-cache", + "Expected none or response-store or workers-cache or data-cache", ); }); it("prompts for CDN cache before the other Cloudflare choices", async () => { const prompts: string[] = []; - const answers = ["3", "2", "2"]; + const answers = ["yes", "3", "2", "2"]; const output = new PassThrough(); await expect( resolveCloudflareInitOptions([], { @@ -104,11 +102,52 @@ describe("Cloudflare init choices", () => { imageOptimization: "none", }); expect(prompts).toEqual([ + " Enable caching? [y/N]: ", " Choose a CDN cache:\n 1. Workers Response Store (default)\n 2. Workers Cache\n 3. Data cache\n CDN cache [1]: ", " Choose a data cache:\n 1. Cloudflare KV (default)\n 2. None\n Data cache [1]: ", " Choose image optimization:\n 1. Cloudflare Images (default)\n 2. None\n Image optimization [1]: ", ]); - expect(output.read()?.toString()).toBe("\n\n\n"); + expect(output.read()?.toString()).toBe("\n\n\n\n"); + }); + + it("keeps caching disabled when the opt-in prompt is declined", async () => { + const prompts: string[] = []; + const output = new PassThrough(); + await expect( + resolveCloudflareInitOptions([], { + env: {}, + isInteractive: true, + output, + question: async (prompt) => { + prompts.push(prompt); + return ""; + }, + }), + ).resolves.toEqual({ + dataCache: "none", + cdnCache: "none", + imageOptimization: "cloudflare-images", + }); + expect(prompts).toEqual([ + " Enable caching? [y/N]: ", + " Choose image optimization:\n 1. Cloudflare Images (default)\n 2. None\n Image optimization [1]: ", + ]); + expect(output.read()?.toString()).toBe("\n\n"); + }); + + it("defaults to Workers Response Store after caching is enabled", async () => { + const answers = ["yes", "", ""]; + await expect( + resolveCloudflareInitOptions([], { + env: {}, + isInteractive: true, + question: async () => answers.shift() ?? "", + }), + ).resolves.toEqual({ + dataCache: "none", + cdnCache: "response-store", + imageOptimization: "cloudflare-images", + }); }); it("preserves an explicit CDN cache flag during interactive setup", async () => { @@ -152,7 +191,7 @@ describe("Cloudflare init choices", () => { it("does not add a section break when repeating an invalid choice", async () => { const prompts: string[] = []; - const answers = ["invalid", "3", "2", "2"]; + const answers = ["yes", "invalid", "3", "2", "2"]; const output = new PassThrough(); await resolveCloudflareInitOptions([], { env: {}, @@ -164,12 +203,13 @@ describe("Cloudflare init choices", () => { }, }); - expect(prompts[0]).toMatch(/^ Choose a CDN cache:/); + expect(prompts[0]).toMatch(/^ Enable caching/); expect(prompts[1]).toMatch(/^ Choose a CDN cache:/); - expect(prompts[2]).toMatch(/^ Choose a data cache:/); - expect(prompts[3]).toMatch(/^ Choose image optimization:/); + expect(prompts[2]).toMatch(/^ Choose a CDN cache:/); + expect(prompts[3]).toMatch(/^ Choose a data cache:/); + expect(prompts[4]).toMatch(/^ Choose image optimization:/); expect(output.read()?.toString()).toBe( - " Please choose Workers Response Store (1), Workers Cache (2), or Data cache (3).\n\n\n\n", + "\n Please choose Workers Response Store (1), Workers Cache (2), or Data cache (3).\n\n\n\n", ); }); }); @@ -325,13 +365,13 @@ describe("resolveInitPlatform", () => { }); describe("resolveInitOptions", () => { - it("defaults Cloudflare Workers Response Store init away from pre-warming", async () => { + it("defaults Cloudflare init to no cache", async () => { await expect(resolveInitOptions([], { env: {}, isInteractive: false })).resolves.toEqual({ platform: "cloudflare", prerender: false, cloudflare: { dataCache: "none", - cdnCache: "response-store", + cdnCache: "none", imageOptimization: "cloudflare-images", warmCdnCache: false, }, @@ -362,9 +402,9 @@ describe("resolveInitOptions", () => { }); }); - it("asks whether to pre-warm Workers Response Store after the prerender prompt", async () => { + it("uses Workers Response Store as the default cache choice", async () => { const prompts: string[] = []; - const answers = ["", "", "n", ""]; + const answers = ["yes", "", "", "n", ""]; await expect( resolveInitOptions(["--platform=cloudflare"], { @@ -387,6 +427,7 @@ describe("resolveInitOptions", () => { }); expect(prompts).toEqual([ + " Enable caching? [y/N]: ", " Choose a CDN cache:\n 1. Workers Response Store (default)\n 2. Workers Cache\n 3. Data cache\n CDN cache [1]: ", " Choose image optimization:\n 1. Cloudflare Images (default)\n 2. None\n Image optimization [1]: ", " Pre-render all static routes after build? [y/N]: ", @@ -396,7 +437,7 @@ describe("resolveInitOptions", () => { it("does not ask about pre-warming when Data cache is selected for CDN cache", async () => { const prompts: string[] = []; - const answers = ["3", "", "", ""]; + const answers = ["yes", "3", "", "", ""]; await expect( resolveInitOptions(["--platform=cloudflare"], { @@ -419,6 +460,7 @@ describe("resolveInitOptions", () => { }); expect(prompts).toEqual([ + " Enable caching? [y/N]: ", " Choose a CDN cache:\n 1. Workers Response Store (default)\n 2. Workers Cache\n 3. Data cache\n CDN cache [1]: ", " Choose a data cache:\n 1. Cloudflare KV (default)\n 2. None\n Data cache [1]: ", " Choose image optimization:\n 1. Cloudflare Images (default)\n 2. None\n Image optimization [1]: ", @@ -442,4 +484,20 @@ describe("resolveInitOptions", () => { "--experimental-warm-cdn-cache requires --cdn-cache=response-store or workers-cache", ); }); + + it("rejects cache warming when caching is disabled", async () => { + await expect( + resolveInitOptions( + [ + "--platform=cloudflare", + "--cdn-cache=none", + "--image-optimization=none", + "--experimental-warm-cdn-cache", + ], + { env: { CODEX_THREAD_ID: "test" } }, + ), + ).rejects.toThrow( + "--experimental-warm-cdn-cache requires --cdn-cache=response-store or workers-cache", + ); + }); }); From 6ec47b06757642fcd83b5bb2ddf67d491353d7e3 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 14 Sep 2026 11:34:41 +0100 Subject: [PATCH 03/20] fix(cloudflare): bind web previews to response store --- apps/web/vite.config.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index d27623518b..ec3c98ebd9 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -4,10 +4,14 @@ import { responseStoreAdapter } from "@vinext/cloudflare/cache/response-store-ad import { imagesOptimizer } from "@vinext/cloudflare/images/images-optimizer"; import { cloudflare } from "@cloudflare/vite-plugin"; +const responseStoreServiceName = process.env.VINEXT_RESPONSE_STORE_SERVICE_NAME; + export default defineConfig({ plugins: [ vinext({ - cache: responseStoreAdapter(), + cache: responseStoreAdapter( + responseStoreServiceName ? { serviceName: responseStoreServiceName } : undefined, + ), images: { optimizer: imagesOptimizer(), }, From 987698abc250c981751e965a3f4c70648ffc076a Mon Sep 17 00:00:00 2001 From: James Date: Mon, 14 Sep 2026 11:41:16 +0100 Subject: [PATCH 04/20] fix(cloudflare): deploy web response store through CLI --- .github/workflows/deploy-examples.yml | 20 ++++++++++++++++++-- apps/web/vite.config.ts | 6 +----- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/.github/workflows/deploy-examples.yml b/.github/workflows/deploy-examples.yml index f15603f933..49514668c0 100644 --- a/.github/workflows/deploy-examples.yml +++ b/.github/workflows/deploy-examples.yml @@ -199,12 +199,20 @@ jobs: - name: Build vinext plugin run: vp run build + - name: Use a dedicated web preview Worker + if: matrix.example.name == 'web' && github.event_name == 'pull_request' + working-directory: apps/web + env: + WORKER_NAME: pr-${{ github.event.pull_request.number }}-vinext-web + run: | + node -e 'const fs = require("node:fs"); const file = "wrangler.jsonc"; fs.writeFileSync(file, fs.readFileSync(file, "utf8").replace(/"name": "vinext-web"/, "\"name\": " + JSON.stringify(process.env.WORKER_NAME)))' + - name: Build example if: matrix.example.static_export != true run: vp build working-directory: ${{ matrix.example.working_directory }} env: - VINEXT_RESPONSE_STORE_SERVICE_NAME: ${{ (matrix.example.name == 'response-store-demo' || (matrix.example.name == 'web' && github.event_name == 'pull_request')) && needs.response_store.outputs.service_name || '' }} + VINEXT_RESPONSE_STORE_SERVICE_NAME: ${{ matrix.example.name == 'response-store-demo' && needs.response_store.outputs.service_name || '' }} - name: Build static export if: matrix.example.static_export == true @@ -263,7 +271,7 @@ jobs: vp exec vinext-cloudflare deploy "${args[@]}" - name: Deploy Preview Version - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && matrix.example.name != 'web' uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} @@ -271,6 +279,14 @@ jobs: workingDirectory: ${{ matrix.example.working_directory }} command: versions upload --config ${{ matrix.example.wrangler_config }} --preview-alias pr-${{ github.event.pull_request.number }} + - name: Deploy Web Preview + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && matrix.example.name == 'web' + working-directory: apps/web + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: vp exec vinext-cloudflare deploy --skip-build --config dist/server/wrangler.json + # `versions upload` does not apply non-versioned workers.dev/preview URL # settings. A newly added assets-only Worker has no routes until this runs. - name: Apply Static Export Preview Routes diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index ec3c98ebd9..d27623518b 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -4,14 +4,10 @@ import { responseStoreAdapter } from "@vinext/cloudflare/cache/response-store-ad import { imagesOptimizer } from "@vinext/cloudflare/images/images-optimizer"; import { cloudflare } from "@cloudflare/vite-plugin"; -const responseStoreServiceName = process.env.VINEXT_RESPONSE_STORE_SERVICE_NAME; - export default defineConfig({ plugins: [ vinext({ - cache: responseStoreAdapter( - responseStoreServiceName ? { serviceName: responseStoreServiceName } : undefined, - ), + cache: responseStoreAdapter(), images: { optimizer: imagesOptimizer(), }, From 3416466af1af8b9eba11ef3de71ebb30c1790f7b Mon Sep 17 00:00:00 2001 From: James Date: Mon, 14 Sep 2026 11:54:35 +0100 Subject: [PATCH 05/20] feat(cloudflare): configure response store deployment mode --- .github/workflows/deploy-examples.yml | 18 +- packages/cloudflare/README.md | 10 +- .../cache/response-store-adapter-config.ts | 157 +++++++++++++++--- .../src/cache/response-store-adapter.ts | 27 +-- packages/create-vinext-app/src/index.ts | 2 + packages/vinext/src/cli.ts | 2 + packages/vinext/src/init-cloudflare.ts | 6 +- packages/vinext/src/init-platform.ts | 46 ++++- tests/cdn-adapter-config.test.ts | 44 ++++- tests/init-cloudflare.test.ts | 12 ++ tests/init-platform.test.ts | 58 ++++++- 11 files changed, 311 insertions(+), 71 deletions(-) diff --git a/.github/workflows/deploy-examples.yml b/.github/workflows/deploy-examples.yml index 49514668c0..eda41165c6 100644 --- a/.github/workflows/deploy-examples.yml +++ b/.github/workflows/deploy-examples.yml @@ -199,14 +199,6 @@ jobs: - name: Build vinext plugin run: vp run build - - name: Use a dedicated web preview Worker - if: matrix.example.name == 'web' && github.event_name == 'pull_request' - working-directory: apps/web - env: - WORKER_NAME: pr-${{ github.event.pull_request.number }}-vinext-web - run: | - node -e 'const fs = require("node:fs"); const file = "wrangler.jsonc"; fs.writeFileSync(file, fs.readFileSync(file, "utf8").replace(/"name": "vinext-web"/, "\"name\": " + JSON.stringify(process.env.WORKER_NAME)))' - - name: Build example if: matrix.example.static_export != true run: vp build @@ -271,7 +263,7 @@ jobs: vp exec vinext-cloudflare deploy "${args[@]}" - name: Deploy Preview Version - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && matrix.example.name != 'web' + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} @@ -279,14 +271,6 @@ jobs: workingDirectory: ${{ matrix.example.working_directory }} command: versions upload --config ${{ matrix.example.wrangler_config }} --preview-alias pr-${{ github.event.pull_request.number }} - - name: Deploy Web Preview - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && matrix.example.name == 'web' - working-directory: apps/web - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - run: vp exec vinext-cloudflare deploy --skip-build --config dist/server/wrangler.json - # `versions upload` does not apply non-versioned workers.dev/preview URL # settings. A newly added assets-only Worker has no routes until this runs. - name: Apply Static Export Preview Routes diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md index 91a79d1963..30fe665a7e 100644 --- a/packages/cloudflare/README.md +++ b/packages/cloudflare/README.md @@ -116,12 +116,10 @@ import { responseStoreAdapter } from "@vinext/cloudflare/cache/response-store-ad vinext({ cache: responseStoreAdapter({ mode: "self-contained" }) }); ``` -Self-contained Workers must bind `CACHE_BODIES` to R2, bind the SQLite -`CacheMetadata` Durable Object as `CACHE_METADATA`, export -`ResponseStoreBinding` with Workers Cache enabled, and include the -`CF_VERSION_METADATA` version-metadata binding. The default Worker entrypoint -must keep Workers Cache disabled. This removes the cache Worker and service -binding without changing cache behavior or the application API. +The build adds the required R2, SQLite Durable Object, Workers Cache entrypoint, +and version-metadata bindings to the application Worker. This removes the cache +Worker and service binding without changing cache behavior or the application +API. ## Deploy diff --git a/packages/cloudflare/src/cache/response-store-adapter-config.ts b/packages/cloudflare/src/cache/response-store-adapter-config.ts index d1957f5eff..2cd9982018 100644 --- a/packages/cloudflare/src/cache/response-store-adapter-config.ts +++ b/packages/cloudflare/src/cache/response-store-adapter-config.ts @@ -7,6 +7,9 @@ export const RESPONSE_STORE_SERVICE_CONFIG = "vinext-response-store/wrangler.jso const RESPONSE_STORE_BINDING = "RESPONSE_STORE"; const VERSION_METADATA_BINDING = "CF_VERSION_METADATA"; +const CACHE_BODIES_BINDING = "CACHE_BODIES"; +const CACHE_METADATA_BINDING = "CACHE_METADATA"; +const CACHE_METADATA_CLASS = "CacheMetadata"; const CTX_EXPORTS_DEFAULT_DATE = "2025-11-17"; type WranglerOutputConfig = Record & { @@ -43,34 +46,25 @@ function withCtxExports(config: WranglerOutputConfig): WranglerOutputConfig { }; } -/** Emit the service Worker and connect the application Worker to it. */ -export async function finalizeResponseStoreBuildOutput({ - outDir, - isPrimaryServerOutput, - serviceName: configuredServiceName, - r2BucketName, - deployService = true, -}: { - outDir: string; - isPrimaryServerOutput: boolean; - serviceName?: string; - r2BucketName?: string; - deployService?: boolean; -}): Promise { - if (!isPrimaryServerOutput) return; - +async function readAppConfig(outDir: string): Promise<{ + appConfig: WranglerOutputConfig; + appConfigPath: string; +}> { const appConfigPath = path.resolve(outDir, "wrangler.json"); - let appConfig: WranglerOutputConfig; try { const parsed: unknown = JSON.parse(await fs.readFile(appConfigPath, "utf8")); if (!isRecord(parsed)) throw new TypeError("the root value must be an object"); - appConfig = parsed; + return { appConfig: parsed, appConfigPath }; } catch (cause) { throw new Error(`[vinext] Could not read the generated Wrangler config at ${appConfigPath}.`, { cause, }); } +} +function assertResponseStoreAppConfig( + appConfig: WranglerOutputConfig, +): asserts appConfig is WranglerOutputConfig & { name: string; compatibility_date: string } { if (!appConfig.name || !appConfig.compatibility_date) { throw new Error( "[vinext] responseStoreAdapter() requires the generated Wrangler config to contain a Worker name and compatibility date.", @@ -85,6 +79,26 @@ export async function finalizeResponseStoreBuildOutput({ `[vinext] responseStoreAdapter() requires version_metadata.binding to be ${JSON.stringify(VERSION_METADATA_BINDING)}.`, ); } +} + +/** Emit the service Worker and connect the application Worker to it. */ +export async function finalizeResponseStoreBuildOutput({ + outDir, + isPrimaryServerOutput, + serviceName: configuredServiceName, + r2BucketName, + deployService = true, +}: { + outDir: string; + isPrimaryServerOutput: boolean; + serviceName?: string; + r2BucketName?: string; + deployService?: boolean; +}): Promise { + if (!isPrimaryServerOutput) return; + + const { appConfig, appConfigPath } = await readAppConfig(outDir); + assertResponseStoreAppConfig(appConfig); if (appConfig.services !== undefined && !Array.isArray(appConfig.services)) { throw new Error("[vinext] The generated Wrangler config has an invalid services value."); } @@ -129,12 +143,12 @@ export async function finalizeResponseStoreBuildOutput({ ResponseStoreBinding: { type: "worker", cache: { enabled: true } }, }, r2_buckets: [ - { binding: "CACHE_BODIES", ...(r2BucketName ? { bucket_name: r2BucketName } : {}) }, + { binding: CACHE_BODIES_BINDING, ...(r2BucketName ? { bucket_name: r2BucketName } : {}) }, ], durable_objects: { - bindings: [{ name: "CACHE_METADATA", class_name: "CacheMetadata" }], + bindings: [{ name: CACHE_METADATA_BINDING, class_name: CACHE_METADATA_CLASS }], }, - migrations: [{ tag: "v1", new_sqlite_classes: ["CacheMetadata"] }], + migrations: [{ tag: "v1", new_sqlite_classes: [CACHE_METADATA_CLASS] }], ...(typeof appConfig.account_id === "string" ? { account_id: appConfig.account_id } : {}), }); await fs.writeFile( @@ -168,3 +182,104 @@ export async function finalizeResponseStoreBuildOutput({ }); await fs.writeFile(appConfigPath, `${JSON.stringify(configuredApp, null, 2)}\n`); } + +/** Add Response Store resources to a self-contained application Worker. */ +export async function finalizeSelfContainedResponseStoreBuildOutput({ + outDir, + isPrimaryServerOutput, +}: { + outDir: string; + isPrimaryServerOutput: boolean; +}): Promise { + if (!isPrimaryServerOutput) return; + + const { appConfig, appConfigPath } = await readAppConfig(outDir); + assertResponseStoreAppConfig(appConfig); + + const cache = appConfig.cache; + const r2Buckets = appConfig.r2_buckets; + const durableObjects = appConfig.durable_objects; + const migrations = appConfig.migrations; + if (cache !== undefined && !isRecord(cache)) { + throw new Error("[vinext] The generated Wrangler config has an invalid cache value."); + } + if (r2Buckets !== undefined && !Array.isArray(r2Buckets)) { + throw new Error("[vinext] The generated Wrangler config has an invalid r2_buckets value."); + } + if (durableObjects !== undefined && !isRecord(durableObjects)) { + throw new Error("[vinext] The generated Wrangler config has an invalid durable_objects value."); + } + const durableBindings = durableObjects?.bindings; + if (durableBindings !== undefined && !Array.isArray(durableBindings)) { + throw new Error( + "[vinext] The generated Wrangler config has an invalid durable_objects.bindings value.", + ); + } + if (migrations !== undefined && !Array.isArray(migrations)) { + throw new Error("[vinext] The generated Wrangler config has an invalid migrations value."); + } + + if ( + (r2Buckets ?? []).some((value) => !isRecord(value)) || + (durableBindings ?? []).some((value) => !isRecord(value)) || + (migrations ?? []).some((value) => !isRecord(value)) + ) { + throw new Error("[vinext] The generated Wrangler config contains an invalid binding."); + } + const existingR2Buckets = (r2Buckets ?? []) as Record[]; + const existingDurableBindings = (durableBindings ?? []) as Record[]; + const existingMigrations = (migrations ?? []) as Record[]; + const hasCacheMetadataMigration = existingMigrations.some( + (migration) => + Array.isArray(migration.new_sqlite_classes) && + migration.new_sqlite_classes.includes(CACHE_METADATA_CLASS), + ); + let migrationNumber = existingMigrations.length + 1; + while ( + existingMigrations.some( + (migration) => migration.tag === `vinext-response-store-v${migrationNumber}`, + ) + ) { + migrationNumber++; + } + const configuredApp = withCtxExports({ + ...appConfig, + cache: { ...cache, enabled: true }, + version_metadata: { binding: VERSION_METADATA_BINDING }, + r2_buckets: existingR2Buckets.some((binding) => binding.binding === CACHE_BODIES_BINDING) + ? existingR2Buckets + : [...existingR2Buckets, { binding: CACHE_BODIES_BINDING }], + durable_objects: { + ...durableObjects, + bindings: existingDurableBindings.some((binding) => binding.name === CACHE_METADATA_BINDING) + ? existingDurableBindings.map((binding) => + binding.name === CACHE_METADATA_BINDING + ? { ...binding, class_name: CACHE_METADATA_CLASS } + : binding, + ) + : [ + ...existingDurableBindings, + { name: CACHE_METADATA_BINDING, class_name: CACHE_METADATA_CLASS }, + ], + }, + migrations: hasCacheMetadataMigration + ? existingMigrations + : [ + ...existingMigrations, + { + tag: `vinext-response-store-v${migrationNumber}`, + new_sqlite_classes: [CACHE_METADATA_CLASS], + }, + ], + exports: { + ...appConfig.exports, + default: { ...appConfig.exports?.default, type: "worker", cache: { enabled: false } }, + ResponseStoreBinding: { + ...appConfig.exports?.ResponseStoreBinding, + type: "worker", + cache: { enabled: true }, + }, + }, + }); + await fs.writeFile(appConfigPath, `${JSON.stringify(configuredApp, null, 2)}\n`); +} diff --git a/packages/cloudflare/src/cache/response-store-adapter.ts b/packages/cloudflare/src/cache/response-store-adapter.ts index f42718442a..930372cf46 100644 --- a/packages/cloudflare/src/cache/response-store-adapter.ts +++ b/packages/cloudflare/src/cache/response-store-adapter.ts @@ -1,5 +1,8 @@ import { fileURLToPath } from "node:url"; -import { finalizeResponseStoreBuildOutput } from "./response-store-adapter-config.js"; +import { + finalizeResponseStoreBuildOutput, + finalizeSelfContainedResponseStoreBuildOutput, +} from "./response-store-adapter-config.js"; const CLOUDFLARE_WORKER_ENTRY_ID = "virtual:cloudflare/worker-entry"; @@ -79,18 +82,16 @@ export function responseStoreAdapter(options: ResponseStoreAdapterOptions = {}) if (cleanId !== CLOUDFLARE_WORKER_ENTRY_ID) return null; return `${code}\nexport { ${entrypoints} } from ${JSON.stringify(workerEntry)};\n`; }, - ...(mode === "service-binding" - ? { - finalizeBuildOutput(output: { outDir: string; isPrimaryServerOutput: boolean }) { - return finalizeResponseStoreBuildOutput({ - ...output, - serviceName: options.serviceName, - r2BucketName: options.r2BucketName, - deployService: options.deployService, - }); - }, - } - : {}), + finalizeBuildOutput(output: { outDir: string; isPrimaryServerOutput: boolean }) { + return mode === "self-contained" + ? finalizeSelfContainedResponseStoreBuildOutput(output) + : finalizeResponseStoreBuildOutput({ + ...output, + serviceName: options.serviceName, + r2BucketName: options.r2BucketName, + deployService: options.deployService, + }); + }, type: "multi-stage" as const, }, capabilities: { diff --git a/packages/create-vinext-app/src/index.ts b/packages/create-vinext-app/src/index.ts index 8c564c374f..974d42df2b 100644 --- a/packages/create-vinext-app/src/index.ts +++ b/packages/create-vinext-app/src/index.ts @@ -12,11 +12,13 @@ type InitPlatform = "cloudflare" | "node"; type InitDataCache = "kv" | "none"; type InitCdnCache = "data-cache" | "none" | "response-store" | "workers-cache"; type InitImageOptimization = "cloudflare-images" | "none"; +type InitResponseStoreMode = "self-contained" | "service-binding"; type CloudflareInitOptions = { dataCache: InitDataCache; cdnCache: InitCdnCache; imageOptimization: InitImageOptimization; + responseStoreMode?: InitResponseStoreMode; warmCdnCache?: boolean; }; diff --git a/packages/vinext/src/cli.ts b/packages/vinext/src/cli.ts index 012c336f75..3c3c6d6259 100644 --- a/packages/vinext/src/cli.ts +++ b/packages/vinext/src/cli.ts @@ -1023,6 +1023,8 @@ function printHelp(cmd?: string) { (Response Store or Workers Cache, default: prompt with No) --cdn-cache Cloudflare CDN cache: none, response-store, workers-cache, or data-cache (default: none; response-store is the default cache choice) + --response-store-mode + Workers Response Store mode: service-binding or self-contained --data-cache Cloudflare data cache: kv or none --image-optimization Cloudflare image optimization: cloudflare-images or none diff --git a/packages/vinext/src/init-cloudflare.ts b/packages/vinext/src/init-cloudflare.ts index 85fc51b508..211124d496 100644 --- a/packages/vinext/src/init-cloudflare.ts +++ b/packages/vinext/src/init-cloudflare.ts @@ -561,7 +561,9 @@ function vinextExpression( } const optionEntries: string[] = []; if (responseStore) { - optionEntries.push("cache: responseStoreAdapter()"); + optionEntries.push( + `cache: responseStoreAdapter(${options.responseStoreMode === "self-contained" ? '{ mode: "self-contained" }' : ""})`, + ); } else if (cacheEntries.length > 0) { optionEntries.push(`cache: { ${cacheEntries.join(", ")} }`); } @@ -1636,7 +1638,7 @@ export function updateViteConfigForCloudflare( const binding = commonJs ? ensureNamedRequire(program, output, source, imported, local) : ensureNamedImport(program, output, source, imported, local); - responseStoreExpression = `${binding}()`; + responseStoreExpression = `${binding}(${cacheOptions.responseStoreMode === "self-contained" ? '{ mode: "self-contained" }' : ""})`; } } if (cacheOptions.dataCache === "kv" && !hasVinextCacheSlot(existingVinextCall, "data")) { diff --git a/packages/vinext/src/init-platform.ts b/packages/vinext/src/init-platform.ts index 624afa7fea..e107a69ae9 100644 --- a/packages/vinext/src/init-platform.ts +++ b/packages/vinext/src/init-platform.ts @@ -6,11 +6,13 @@ export type InitPlatform = "cloudflare" | "node"; export type InitDataCache = "kv" | "none"; export type InitCdnCache = "data-cache" | "none" | "response-store" | "workers-cache"; export type InitImageOptimization = "cloudflare-images" | "none"; +export type InitResponseStoreMode = "self-contained" | "service-binding"; export type CloudflareInitOptions = { dataCache: InitDataCache; cdnCache: InitCdnCache; imageOptimization: InitImageOptimization; + responseStoreMode?: InitResponseStoreMode; warmCdnCache?: boolean; }; @@ -123,6 +125,10 @@ export function parseImageOptimizationArg(args: string[]): InitImageOptimization return parseChoiceArg(args, "--image-optimization", ["cloudflare-images", "none"]); } +export function parseResponseStoreModeArg(args: string[]): InitResponseStoreMode | undefined { + return parseChoiceArg(args, "--response-store-mode", ["service-binding", "self-contained"]); +} + export function parsePrerenderArg(args: string[]): boolean | undefined { return parseBooleanArg( args, @@ -332,7 +338,13 @@ export async function resolveCloudflareInitOptions( options: PlatformPromptOptions = {}, ): Promise { const explicitDataCache = parseDataCacheArg(args); - const explicitCdnCache = parseCdnCacheArg(args); + const requestedCdnCache = parseCdnCacheArg(args); + const explicitResponseStoreMode = parseResponseStoreModeArg(args); + if (explicitResponseStoreMode && requestedCdnCache && requestedCdnCache !== "response-store") { + throw new Error("--response-store-mode can only be used with --cdn-cache=response-store."); + } + const explicitCdnCache = + requestedCdnCache ?? (explicitResponseStoreMode ? "response-store" : undefined); const explicitImageOptimization = parseImageOptimizationArg(args); if ( (explicitCdnCache === "response-store" || explicitCdnCache === "none") && @@ -352,13 +364,16 @@ export async function resolveCloudflareInitOptions( : (explicitDataCache ?? "kv"), cdnCache: explicitCdnCache, imageOptimization: explicitImageOptimization, + ...(explicitCdnCache === "response-store" + ? { responseStoreMode: explicitResponseStoreMode ?? "service-binding" } + : {}), }; } const env = options.env ?? process.env; if (isAgentEnvironment(env)) { throw new Error( - "vinext init needs Cloudflare cache and image choices. Ask the user whether they want no cache or which CDN cache (response-store, workers-cache, or data-cache), data cache (kv or none), and image optimization (cloudflare-images or none) they want, then re-run with --cdn-cache=..., --data-cache=..., and --image-optimization=....", + "vinext init needs Cloudflare cache and image choices. Ask the user whether they want no cache or which CDN cache (response-store, workers-cache, or data-cache), the Response Store mode when selected (service-binding or self-contained), data cache (kv or none), and image optimization (cloudflare-images or none) they want, then re-run with --cdn-cache=..., --response-store-mode=..., --data-cache=..., and --image-optimization=....", ); } @@ -373,6 +388,9 @@ export async function resolveCloudflareInitOptions( cdnCache === "response-store" || cdnCache === "none" ? "none" : (explicitDataCache ?? "kv"), cdnCache, imageOptimization: explicitImageOptimization ?? "cloudflare-images", + ...(cdnCache === "response-store" + ? { responseStoreMode: explicitResponseStoreMode ?? "service-binding" } + : {}), }; } @@ -437,6 +455,23 @@ export async function resolveCloudflareInitOptions( if ((cdnCache === "response-store" || cdnCache === "none") && explicitDataCache === "kv") { throw new Error(`--cdn-cache=${cdnCache} cannot be combined with --data-cache=kv.`); } + const responseStoreMode = + cdnCache === "response-store" + ? await promptChoice( + explicitResponseStoreMode, + " Choose a Workers Response Store mode:\n 1. Service binding (default)\n 2. Self-contained\n Response Store mode [1]: ", + { + "1": "service-binding", + "service-binding": "service-binding", + service: "service-binding", + "2": "self-contained", + "self-contained": "self-contained", + self: "self-contained", + }, + "service-binding", + "Please choose Service binding (1) or Self-contained (2).", + ) + : undefined; const dataCache = cdnCache === "response-store" || cdnCache === "none" ? "none" @@ -460,7 +495,12 @@ export async function resolveCloudflareInitOptions( "cloudflare-images", "Please choose Cloudflare Images (1) or None (2).", ); - return { dataCache, cdnCache, imageOptimization }; + return { + dataCache, + cdnCache, + imageOptimization, + ...(responseStoreMode ? { responseStoreMode } : {}), + }; } finally { readline?.close(); } diff --git a/tests/cdn-adapter-config.test.ts b/tests/cdn-adapter-config.test.ts index 24d3ee8d47..b74f61588d 100644 --- a/tests/cdn-adapter-config.test.ts +++ b/tests/cdn-adapter-config.test.ts @@ -134,11 +134,45 @@ describe("Cloudflare CDN adapter generated config", () => { expect(fs.existsSync(path.join(path.dirname(serviceConfigPath), "service.js"))).toBe(true); }); - it("does not emit a Response Store service for self-contained mode", () => { - expect(responseStoreAdapter({ mode: "self-contained" }).cdn.output.finalizeBuildOutput).toBe( - undefined, - ); - expect(responseStoreAdapter().cdn.output.finalizeBuildOutput).toEqual(expect.any(Function)); + it("configures self-contained Response Store resources on the application Worker", async () => { + const generatedPath = writeGeneratedConfig("dist/server/wrangler.json", { + name: "test-worker", + main: "index.js", + compatibility_date: "2026-09-14", + }); + const adapter = responseStoreAdapter({ mode: "self-contained" }); + + await adapter.cdn.output.finalizeBuildOutput({ + outDir: path.dirname(generatedPath), + isPrimaryServerOutput: true, + }); + await adapter.cdn.output.finalizeBuildOutput({ + outDir: path.dirname(generatedPath), + isPrimaryServerOutput: true, + }); + + const appConfig = JSON.parse(fs.readFileSync(generatedPath, "utf8")); + expect(appConfig).toMatchObject({ + cache: { enabled: true }, + version_metadata: { binding: "CF_VERSION_METADATA" }, + r2_buckets: [{ binding: "CACHE_BODIES" }], + durable_objects: { + bindings: [{ name: "CACHE_METADATA", class_name: "CacheMetadata" }], + }, + migrations: [ + { + tag: "vinext-response-store-v1", + new_sqlite_classes: ["CacheMetadata"], + }, + ], + exports: { + default: { type: "worker", cache: { enabled: false } }, + ResponseStoreBinding: { type: "worker", cache: { enabled: true } }, + }, + }); + expect( + fs.existsSync(path.join(path.dirname(generatedPath), RESPONSE_STORE_SERVICE_CONFIG)), + ).toBe(false); }); it("uses custom Response Store service and R2 bucket names", async () => { diff --git a/tests/init-cloudflare.test.ts b/tests/init-cloudflare.test.ts index 2a4fa27865..26180ebcc9 100644 --- a/tests/init-cloudflare.test.ts +++ b/tests/init-cloudflare.test.ts @@ -48,6 +48,18 @@ export default { plugins: [vinext()] }; expect(updateViteConfigForCloudflare("vite.config.ts", output, options)).toBe(output); }); + it("configures a self-contained Workers Response Store", () => { + const output = generateAppRouterViteConfig(undefined, { + dataCache: "none", + cdnCache: "response-store", + imageOptimization: "none", + responseStoreMode: "self-contained", + }); + + expectValidConfig(output); + expect(output).toContain('cache: responseStoreAdapter({ mode: "self-contained" })'); + }); + it("updates an existing ESM App Router config without replacing user code", () => { const input = `import { defineConfig } from "vite"; import vinext from "vinext"; diff --git a/tests/init-platform.test.ts b/tests/init-platform.test.ts index d9280c030c..0570f54c34 100644 --- a/tests/init-platform.test.ts +++ b/tests/init-platform.test.ts @@ -6,6 +6,7 @@ import { parseDataCacheArg, parseCdnCacheArg, parseImageOptimizationArg, + parseResponseStoreModeArg, parsePrerenderArg, parseWarmCdnCacheArg, resolveCloudflareInitOptions, @@ -35,6 +36,9 @@ describe("Cloudflare init choices", () => { expect(parseCdnCacheArg(["--cdn-cache=response-store"])).toBe("response-store"); expect(parseCdnCacheArg(["--cdn-cache=workers-cache"])).toBe("workers-cache"); expect(parseImageOptimizationArg(["--image-optimization=none"])).toBe("none"); + expect(parseResponseStoreModeArg(["--response-store-mode=self-contained"])).toBe( + "self-contained", + ); }); it("defaults to no cache and Cloudflare Images", async () => { @@ -50,7 +54,9 @@ describe("Cloudflare init choices", () => { it("tells agents to ask and rerun with public Cloudflare flags", async () => { await expect( resolveCloudflareInitOptions([], { env: { CODEX_THREAD_ID: "test" } }), - ).rejects.toThrow("--cdn-cache=..., --data-cache=..., and --image-optimization=..."); + ).rejects.toThrow( + "--cdn-cache=..., --response-store-mode=..., --data-cache=..., and --image-optimization=...", + ); }); it("uses explicit Cloudflare choices in agent environments", async () => { @@ -73,7 +79,9 @@ describe("Cloudflare init choices", () => { resolveCloudflareInitOptions(["--data-cache=kv", "--image-optimization=none"], { env: { CODEX_THREAD_ID: "test" }, }), - ).rejects.toThrow("--cdn-cache=..., --data-cache=..., and --image-optimization=..."); + ).rejects.toThrow( + "--cdn-cache=..., --response-store-mode=..., --data-cache=..., and --image-optimization=...", + ); }); it("rejects legacy CDN cache choices", () => { @@ -136,7 +144,7 @@ describe("Cloudflare init choices", () => { }); it("defaults to Workers Response Store after caching is enabled", async () => { - const answers = ["yes", "", ""]; + const answers = ["yes", "", "", ""]; await expect( resolveCloudflareInitOptions([], { env: {}, @@ -147,9 +155,49 @@ describe("Cloudflare init choices", () => { dataCache: "none", cdnCache: "response-store", imageOptimization: "cloudflare-images", + responseStoreMode: "service-binding", + }); + }); + + it("lets interactive setup choose a self-contained Response Store", async () => { + const answers = ["yes", "1", "2", "2"]; + await expect( + resolveCloudflareInitOptions([], { + env: {}, + isInteractive: true, + question: async () => answers.shift() ?? "", + }), + ).resolves.toEqual({ + dataCache: "none", + cdnCache: "response-store", + imageOptimization: "none", + responseStoreMode: "self-contained", }); }); + it("accepts a Response Store mode non-interactively", async () => { + await expect( + resolveCloudflareInitOptions( + ["--response-store-mode=self-contained", "--image-optimization=none"], + { env: {}, isInteractive: false }, + ), + ).resolves.toEqual({ + dataCache: "none", + cdnCache: "response-store", + imageOptimization: "none", + responseStoreMode: "self-contained", + }); + }); + + it("rejects a Response Store mode with another cache", async () => { + await expect( + resolveCloudflareInitOptions( + ["--cdn-cache=workers-cache", "--response-store-mode=self-contained"], + { env: {}, isInteractive: false }, + ), + ).rejects.toThrow("can only be used with --cdn-cache=response-store"); + }); + it("preserves an explicit CDN cache flag during interactive setup", async () => { const answers = ["2", "2"]; await expect( @@ -404,7 +452,7 @@ describe("resolveInitOptions", () => { it("uses Workers Response Store as the default cache choice", async () => { const prompts: string[] = []; - const answers = ["yes", "", "", "n", ""]; + const answers = ["yes", "", "", "", "n", ""]; await expect( resolveInitOptions(["--platform=cloudflare"], { @@ -422,6 +470,7 @@ describe("resolveInitOptions", () => { dataCache: "none", cdnCache: "response-store", imageOptimization: "cloudflare-images", + responseStoreMode: "service-binding", warmCdnCache: false, }, }); @@ -429,6 +478,7 @@ describe("resolveInitOptions", () => { expect(prompts).toEqual([ " Enable caching? [y/N]: ", " Choose a CDN cache:\n 1. Workers Response Store (default)\n 2. Workers Cache\n 3. Data cache\n CDN cache [1]: ", + " Choose a Workers Response Store mode:\n 1. Service binding (default)\n 2. Self-contained\n Response Store mode [1]: ", " Choose image optimization:\n 1. Cloudflare Images (default)\n 2. None\n Image optimization [1]: ", " Pre-render all static routes after build? [y/N]: ", " Enable experimental cache pre-warm during deploy? [y/N]: ", From 5da53215089718a88e5a0e13a12ed74f5e569fe9 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 14 Sep 2026 11:55:44 +0100 Subject: [PATCH 06/20] fix(create): parse response store mode option --- packages/create-vinext-app/src/index.ts | 2 ++ tests/create-vinext-app.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/packages/create-vinext-app/src/index.ts b/packages/create-vinext-app/src/index.ts index 974d42df2b..63d3b291cd 100644 --- a/packages/create-vinext-app/src/index.ts +++ b/packages/create-vinext-app/src/index.ts @@ -277,6 +277,7 @@ function printHelp(): void { --platform Deployment target: cloudflare or node --data-cache Cloudflare data cache: kv or none --cdn-cache Cloudflare CDN cache: none, response-store, workers-cache, or data-cache + --response-store-mode Workers Response Store mode: service-binding or self-contained --image-optimization Cloudflare image optimization: cloudflare-images or none --prerender Configure vinext to pre-render static routes --no-prerender Do not configure pre-rendering @@ -336,6 +337,7 @@ function parseArgs(args: string[]): ParsedArgs { arg === "--platform" || arg === "--data-cache" || arg === "--cdn-cache" || + arg === "--response-store-mode" || arg === "--image-optimization" ) { index++; diff --git a/tests/create-vinext-app.test.ts b/tests/create-vinext-app.test.ts index 9cc26e05f6..168de8ee6e 100644 --- a/tests/create-vinext-app.test.ts +++ b/tests/create-vinext-app.test.ts @@ -375,4 +375,26 @@ describe("create-vinext-app CLI", () => { logSpy.mockRestore(); } }); + + it("accepts a space-separated Response Store mode before the app directory", async () => { + const appPath = path.join(tmpDir, "self-contained-app"); + + await withQuietConsole(() => + runCreateVinextAppCli([ + "--response-store-mode", + "self-contained", + appPath, + "--platform=cloudflare", + "--image-optimization=none", + "--skip-install", + "--disable-git", + "--use-pnpm", + "--yes", + ]), + ); + + expect(readFile(appPath, "vite.config.ts")).toContain( + 'cache: responseStoreAdapter({ mode: "self-contained" })', + ); + }); }); From 94b46837a63b3704d284b2f7b8371a4fdf3b7bb2 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 14 Sep 2026 11:59:53 +0100 Subject: [PATCH 07/20] fix(init): update existing response store mode --- packages/vinext/src/init-cloudflare.ts | 8 ++++++++ tests/init-cloudflare.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/packages/vinext/src/init-cloudflare.ts b/packages/vinext/src/init-cloudflare.ts index 211124d496..c80efee882 100644 --- a/packages/vinext/src/init-cloudflare.ts +++ b/packages/vinext/src/init-cloudflare.ts @@ -1639,6 +1639,14 @@ export function updateViteConfigForCloudflare( ? ensureNamedRequire(program, output, source, imported, local) : ensureNamedImport(program, output, source, imported, local); responseStoreExpression = `${binding}(${cacheOptions.responseStoreMode === "self-contained" ? '{ mode: "self-contained" }' : ""})`; + if (alreadyConfigured && cache) { + output.overwrite( + (cache.value as AstNode).start, + (cache.value as AstNode).end, + responseStoreExpression, + ); + responseStoreExpression = undefined; + } } } if (cacheOptions.dataCache === "kv" && !hasVinextCacheSlot(existingVinextCall, "data")) { diff --git a/tests/init-cloudflare.test.ts b/tests/init-cloudflare.test.ts index 26180ebcc9..91943f62af 100644 --- a/tests/init-cloudflare.test.ts +++ b/tests/init-cloudflare.test.ts @@ -60,6 +60,31 @@ export default { plugins: [vinext()] }; expect(output).toContain('cache: responseStoreAdapter({ mode: "self-contained" })'); }); + it("updates the mode of an existing Workers Response Store", () => { + const input = `import vinext from "vinext"; +import { responseStoreAdapter } from "@vinext/cloudflare/cache/response-store-adapter"; +export default { plugins: [vinext({ cache: responseStoreAdapter() })] }; +`; + const options = { + isAppRouter: false, + nativeModulesToStub: [], + cache: { + dataCache: "none" as const, + cdnCache: "response-store" as const, + imageOptimization: "none" as const, + responseStoreMode: "self-contained" as const, + }, + }; + + const selfContained = updateViteConfigForCloudflare("vite.config.ts", input, options); + expect(selfContained).toContain('cache: responseStoreAdapter({ mode: "self-contained" })'); + const serviceBinding = updateViteConfigForCloudflare("vite.config.ts", selfContained, { + ...options, + cache: { ...options.cache, responseStoreMode: "service-binding" }, + }); + expect(serviceBinding).toContain("cache: responseStoreAdapter()"); + }); + it("updates an existing ESM App Router config without replacing user code", () => { const input = `import { defineConfig } from "vite"; import vinext from "vinext"; From 02ca5c57c6ba7ef74eefa9fa39ebd9ddc9251ff0 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 14 Sep 2026 12:01:41 +0100 Subject: [PATCH 08/20] fix(ci): keep preview response store available --- .github/workflows/deploy-examples.yml | 28 ++------------------------- 1 file changed, 2 insertions(+), 26 deletions(-) diff --git a/.github/workflows/deploy-examples.yml b/.github/workflows/deploy-examples.yml index eda41165c6..8b847631cf 100644 --- a/.github/workflows/deploy-examples.yml +++ b/.github/workflows/deploy-examples.yml @@ -37,10 +37,10 @@ jobs: run: | if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then prefix="pr-${{ github.event.pull_request.number }}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" - service_name="${prefix}-rs-service" + service_name="pr-${{ github.event.pull_request.number }}-response-store-demo-response-store" else prefix="main" - service_name="vinext-workers-response-store-service-poc" + service_name="response-store-demo-response-store" fi { echo "service_name=$service_name" @@ -336,30 +336,6 @@ jobs: tests/e2e/cloudflare-workers/web-worker.spec.ts tests/e2e/cloudflare-workers/optimistic-search-navigation.spec.ts - cleanup-response-store: - name: Clean up Workers Response Store service - if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository - needs: [response_store, deploy, smoke-test, deployed-e2e] - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - uses: ./.github/actions/setup - - - name: Delete cache Worker - working-directory: packages/workers-response-store - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - SERVICE_NAME: ${{ needs.response_store.outputs.service_name }} - run: | - if [[ -n "$SERVICE_NAME" ]]; then - vp exec wrangler delete "$SERVICE_NAME" --force - fi - comment: name: Comment Preview URLs runs-on: ubuntu-latest From 129479c3fa0aa0d016eb704d27c9bf770d753586 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 14 Sep 2026 12:02:06 +0100 Subject: [PATCH 09/20] fix(ci): let main deploy the demo response store --- .github/workflows/deploy-examples.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-examples.yml b/.github/workflows/deploy-examples.yml index 8b847631cf..fc0c9f122e 100644 --- a/.github/workflows/deploy-examples.yml +++ b/.github/workflows/deploy-examples.yml @@ -40,7 +40,7 @@ jobs: service_name="pr-${{ github.event.pull_request.number }}-response-store-demo-response-store" else prefix="main" - service_name="response-store-demo-response-store" + service_name="vinext-workers-response-store-service-poc" fi { echo "service_name=$service_name" @@ -204,7 +204,7 @@ jobs: run: vp build working-directory: ${{ matrix.example.working_directory }} env: - VINEXT_RESPONSE_STORE_SERVICE_NAME: ${{ matrix.example.name == 'response-store-demo' && needs.response_store.outputs.service_name || '' }} + VINEXT_RESPONSE_STORE_SERVICE_NAME: ${{ matrix.example.name == 'response-store-demo' && github.event_name == 'pull_request' && needs.response_store.outputs.service_name || '' }} - name: Build static export if: matrix.example.static_export == true From b1f33a82fc25b90917858a73205d827064bad764 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 14 Sep 2026 12:31:41 +0100 Subject: [PATCH 10/20] refactor(cloudflare): rename response store deploy option --- packages/cloudflare/README.md | 6 +++--- .../cache/response-store-adapter-config.ts | 6 +++--- .../src/cache/response-store-adapter.ts | 21 +++++++++++-------- tests/cdn-adapter-config.test.ts | 10 ++++----- 4 files changed, 23 insertions(+), 20 deletions(-) diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md index 30fe665a7e..c70f79e641 100644 --- a/packages/cloudflare/README.md +++ b/packages/cloudflare/README.md @@ -91,19 +91,19 @@ vinext({ The service name also identifies the service-owned Durable Object namespace. Keeping it stable reuses its metadata. To bind a compatible Response Store -Worker managed outside the vinext deployment, set `deployService: false`: +Worker managed outside the vinext deployment, set `shouldDeployService: false`: ```ts vinext({ cache: responseStoreAdapter({ serviceName: "shared-response-store", - deployService: false, + shouldDeployService: false, }), }); ``` When omitted, names are derived from the application Worker. Setting -`deployService` to `false` emits only the application service binding; the +`shouldDeployService` to `false` emits only the application service binding; the external Worker and its R2 and Durable Object resources remain independently managed. diff --git a/packages/cloudflare/src/cache/response-store-adapter-config.ts b/packages/cloudflare/src/cache/response-store-adapter-config.ts index 2cd9982018..120969a921 100644 --- a/packages/cloudflare/src/cache/response-store-adapter-config.ts +++ b/packages/cloudflare/src/cache/response-store-adapter-config.ts @@ -87,13 +87,13 @@ export async function finalizeResponseStoreBuildOutput({ isPrimaryServerOutput, serviceName: configuredServiceName, r2BucketName, - deployService = true, + shouldDeployService = true, }: { outDir: string; isPrimaryServerOutput: boolean; serviceName?: string; r2BucketName?: string; - deployService?: boolean; + shouldDeployService?: boolean; }): Promise { if (!isPrimaryServerOutput) return; @@ -111,7 +111,7 @@ export async function finalizeResponseStoreBuildOutput({ ? fullServiceName : `${appConfig.name.slice(0, 25)}-${createHash("sha256").update(appConfig.name).digest("hex").slice(0, 8)}${serviceSuffix}`); const serviceDir = path.resolve(outDir, path.dirname(RESPONSE_STORE_SERVICE_CONFIG)); - if (deployService) { + if (shouldDeployService) { if (!r2BucketName && serviceName.length > 49) { throw new Error( "[vinext] A Response Store serviceName longer than 49 characters requires an explicit r2BucketName.", diff --git a/packages/cloudflare/src/cache/response-store-adapter.ts b/packages/cloudflare/src/cache/response-store-adapter.ts index 930372cf46..4856f06e50 100644 --- a/packages/cloudflare/src/cache/response-store-adapter.ts +++ b/packages/cloudflare/src/cache/response-store-adapter.ts @@ -13,7 +13,7 @@ export type ResponseStoreAdapterOptions = { /** Existing or desired R2 bucket name for response bodies. */ r2BucketName?: string; /** Set false to bind an existing compatible service without deploying it. */ - deployService?: boolean; + shouldDeployService?: boolean; }; /** @@ -34,25 +34,28 @@ export function responseStoreAdapter(options: ResponseStoreAdapterOptions = {}) throw new TypeError(`responseStoreAdapter({ ${name} }) must be a non-empty string.`); } } - if (options.deployService !== undefined && typeof options.deployService !== "boolean") { - throw new TypeError("responseStoreAdapter({ deployService }) must be a boolean."); + if ( + options.shouldDeployService !== undefined && + typeof options.shouldDeployService !== "boolean" + ) { + throw new TypeError("responseStoreAdapter({ shouldDeployService }) must be a boolean."); } if ( mode === "self-contained" && (options.serviceName !== undefined || options.r2BucketName !== undefined || - options.deployService !== undefined) + options.shouldDeployService !== undefined) ) { throw new TypeError("Response Store service options cannot be used in self-contained mode."); } - if (options.deployService === false && !options.serviceName) { + if (options.shouldDeployService === false && !options.serviceName) { throw new TypeError( - "responseStoreAdapter({ deployService: false }) requires an existing serviceName.", + "responseStoreAdapter({ shouldDeployService: false }) requires an existing serviceName.", ); } - if (options.deployService === false && options.r2BucketName) { + if (options.shouldDeployService === false && options.r2BucketName) { throw new TypeError( - "r2BucketName configures a deployed service and cannot be used when deployService is false.", + "r2BucketName configures a deployed service and cannot be used when shouldDeployService is false.", ); } const workerEntry = fileURLToPath( @@ -89,7 +92,7 @@ export function responseStoreAdapter(options: ResponseStoreAdapterOptions = {}) ...output, serviceName: options.serviceName, r2BucketName: options.r2BucketName, - deployService: options.deployService, + shouldDeployService: options.shouldDeployService, }); }, type: "multi-stage" as const, diff --git a/tests/cdn-adapter-config.test.ts b/tests/cdn-adapter-config.test.ts index b74f61588d..52b8506fa7 100644 --- a/tests/cdn-adapter-config.test.ts +++ b/tests/cdn-adapter-config.test.ts @@ -219,7 +219,7 @@ describe("Cloudflare CDN adapter generated config", () => { await responseStoreAdapter({ serviceName: "managed-response-store", - deployService: false, + shouldDeployService: false, }).cdn.output.finalizeBuildOutput?.({ outDir: path.dirname(generatedPath), isPrimaryServerOutput: true, @@ -238,22 +238,22 @@ describe("Cloudflare CDN adapter generated config", () => { it("rejects invalid Response Store resource options", () => { expect(() => responseStoreAdapter({ serviceName: "" })).toThrow("non-empty string"); expect(() => responseStoreAdapter({ r2BucketName: "" })).toThrow("non-empty string"); - expect(() => responseStoreAdapter({ deployService: "false" as never })).toThrow( + expect(() => responseStoreAdapter({ shouldDeployService: "false" as never })).toThrow( "must be a boolean", ); expect(() => responseStoreAdapter({ mode: "self-contained", serviceName: "response-store" }), ).toThrow("cannot be used in self-contained mode"); - expect(() => responseStoreAdapter({ deployService: false })).toThrow( + expect(() => responseStoreAdapter({ shouldDeployService: false })).toThrow( "requires an existing serviceName", ); expect(() => responseStoreAdapter({ serviceName: "response-store", r2BucketName: "response-store-bodies", - deployService: false, + shouldDeployService: false, }), - ).toThrow("cannot be used when deployService is false"); + ).toThrow("cannot be used when shouldDeployService is false"); }); it("keeps the provisioned R2 bucket name within Cloudflare's limit", async () => { From d409d2401b4d7a822f5691bd3f9149ac650f5c7d Mon Sep 17 00:00:00 2001 From: James Date: Mon, 14 Sep 2026 12:41:05 +0100 Subject: [PATCH 11/20] fix(init): derive data cache CDN selection --- packages/vinext/src/init-platform.ts | 5 +++-- tests/init-platform.test.ts | 10 ++++------ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/vinext/src/init-platform.ts b/packages/vinext/src/init-platform.ts index e107a69ae9..b6ac53537c 100644 --- a/packages/vinext/src/init-platform.ts +++ b/packages/vinext/src/init-platform.ts @@ -420,8 +420,9 @@ export async function resolveCloudflareInitOptions( } }; - let selectedCdnCache = explicitCdnCache; - if (!selectedCdnCache && explicitDataCache !== "kv") { + let selectedCdnCache = + explicitCdnCache ?? (explicitDataCache === "kv" ? "data-cache" : undefined); + if (!selectedCdnCache) { while (true) { const answer = (await question(" Enable caching? [y/N]: ")).trim().toLowerCase(); if (answer === "" || answer === "n" || answer === "no") { diff --git a/tests/init-platform.test.ts b/tests/init-platform.test.ts index 0570f54c34..abf796be1f 100644 --- a/tests/init-platform.test.ts +++ b/tests/init-platform.test.ts @@ -213,7 +213,7 @@ describe("Cloudflare init choices", () => { }); }); - it("prompts interactively for a missing CDN cache choice before honoring other flags", async () => { + it("derives the Data cache CDN choice from an explicit KV data cache", async () => { const prompts: string[] = []; const output = new PassThrough(); await expect( @@ -223,7 +223,7 @@ describe("Cloudflare init choices", () => { output, question: async (prompt) => { prompts.push(prompt); - return "3"; + return ""; }, }), ).resolves.toEqual({ @@ -231,10 +231,8 @@ describe("Cloudflare init choices", () => { cdnCache: "data-cache", imageOptimization: "none", }); - expect(prompts).toEqual([ - " Choose a CDN cache:\n 1. Workers Response Store (default)\n 2. Workers Cache\n 3. Data cache\n CDN cache [1]: ", - ]); - expect(output.read()?.toString()).toBe("\n"); + expect(prompts).toEqual([]); + expect(output.read()).toBeNull(); }); it("does not add a section break when repeating an invalid choice", async () => { From 7e547a5abb4ac51a4d6efd1769534a993d6e7c0a Mon Sep 17 00:00:00 2001 From: James Date: Mon, 14 Sep 2026 12:59:06 +0100 Subject: [PATCH 12/20] fix(cloudflare): reject response store config collisions --- .../cache/response-store-adapter-config.ts | 20 ++++++++++++------ packages/vinext/src/init-cloudflare.ts | 9 ++++++++ tests/cdn-adapter-config.test.ts | 20 ++++++++++++++++++ tests/init-cloudflare.test.ts | 21 +++++++++++++++++++ 4 files changed, 64 insertions(+), 6 deletions(-) diff --git a/packages/cloudflare/src/cache/response-store-adapter-config.ts b/packages/cloudflare/src/cache/response-store-adapter-config.ts index 120969a921..af73e65460 100644 --- a/packages/cloudflare/src/cache/response-store-adapter-config.ts +++ b/packages/cloudflare/src/cache/response-store-adapter-config.ts @@ -228,6 +228,18 @@ export async function finalizeSelfContainedResponseStoreBuildOutput({ } const existingR2Buckets = (r2Buckets ?? []) as Record[]; const existingDurableBindings = (durableBindings ?? []) as Record[]; + const existingCacheMetadataBinding = existingDurableBindings.find( + (binding) => binding.name === CACHE_METADATA_BINDING, + ); + if ( + existingCacheMetadataBinding && + (existingCacheMetadataBinding.class_name !== CACHE_METADATA_CLASS || + existingCacheMetadataBinding.script_name !== undefined) + ) { + throw new Error( + `[vinext] responseStoreAdapter() cannot use the existing ${CACHE_METADATA_BINDING} Durable Object binding.`, + ); + } const existingMigrations = (migrations ?? []) as Record[]; const hasCacheMetadataMigration = existingMigrations.some( (migration) => @@ -251,12 +263,8 @@ export async function finalizeSelfContainedResponseStoreBuildOutput({ : [...existingR2Buckets, { binding: CACHE_BODIES_BINDING }], durable_objects: { ...durableObjects, - bindings: existingDurableBindings.some((binding) => binding.name === CACHE_METADATA_BINDING) - ? existingDurableBindings.map((binding) => - binding.name === CACHE_METADATA_BINDING - ? { ...binding, class_name: CACHE_METADATA_CLASS } - : binding, - ) + bindings: existingCacheMetadataBinding + ? existingDurableBindings : [ ...existingDurableBindings, { name: CACHE_METADATA_BINDING, class_name: CACHE_METADATA_CLASS }, diff --git a/packages/vinext/src/init-cloudflare.ts b/packages/vinext/src/init-cloudflare.ts index c80efee882..3739e328e0 100644 --- a/packages/vinext/src/init-cloudflare.ts +++ b/packages/vinext/src/init-cloudflare.ts @@ -1629,6 +1629,15 @@ export function updateViteConfigForCloudflare( cache.value.callee.type === "Identifier" && cache.value.callee.name === existing, ); + if ( + cache && + !alreadyConfigured && + !(cache.value.type === "ObjectExpression" && cache.value.properties.length === 0) + ) { + throw new Error( + "The vinext() cache option is already configured. Remove it before configuring Workers Response Store.", + ); + } if ( !cache || alreadyConfigured || diff --git a/tests/cdn-adapter-config.test.ts b/tests/cdn-adapter-config.test.ts index 52b8506fa7..f8faca5045 100644 --- a/tests/cdn-adapter-config.test.ts +++ b/tests/cdn-adapter-config.test.ts @@ -175,6 +175,26 @@ describe("Cloudflare CDN adapter generated config", () => { ).toBe(false); }); + it("rejects a conflicting self-contained Response Store Durable Object binding", async () => { + const generatedPath = writeGeneratedConfig("dist/server/wrangler.json", { + name: "test-worker", + main: "index.js", + compatibility_date: "2026-09-14", + durable_objects: { + bindings: [{ name: "CACHE_METADATA", class_name: "UserCacheMetadata" }], + }, + }); + + await expect( + responseStoreAdapter({ mode: "self-contained" }).cdn.output.finalizeBuildOutput({ + outDir: path.dirname(generatedPath), + isPrimaryServerOutput: true, + }), + ).rejects.toThrow( + "responseStoreAdapter() cannot use the existing CACHE_METADATA Durable Object binding", + ); + }); + it("uses custom Response Store service and R2 bucket names", async () => { const generatedPath = writeGeneratedConfig("dist/server/wrangler.json", { name: "test-worker", diff --git a/tests/init-cloudflare.test.ts b/tests/init-cloudflare.test.ts index 91943f62af..03b19634b6 100644 --- a/tests/init-cloudflare.test.ts +++ b/tests/init-cloudflare.test.ts @@ -85,6 +85,27 @@ export default { plugins: [vinext({ cache: responseStoreAdapter() })] }; expect(serviceBinding).toContain("cache: responseStoreAdapter()"); }); + it("rejects replacing an existing cache configuration with Workers Response Store", () => { + const input = `import vinext from "vinext"; +import { cdnAdapter } from "@vinext/cloudflare/cache/cdn-adapter"; +export default { plugins: [vinext({ cache: { cdn: cdnAdapter() } })] }; +`; + + expect(() => + updateViteConfigForCloudflare("vite.config.ts", input, { + isAppRouter: false, + nativeModulesToStub: [], + cache: { + dataCache: "none", + cdnCache: "response-store", + imageOptimization: "none", + }, + }), + ).toThrow( + "The vinext() cache option is already configured. Remove it before configuring Workers Response Store.", + ); + }); + it("updates an existing ESM App Router config without replacing user code", () => { const input = `import { defineConfig } from "vite"; import vinext from "vinext"; From 0394a5f40864e8824bbf3c835c244ca451feee55 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 14 Sep 2026 13:04:45 +0100 Subject: [PATCH 13/20] fix(init): preserve response store service options --- packages/vinext/src/init-cloudflare.ts | 39 ++++++++++++++++++++++---- tests/init-cloudflare.test.ts | 22 ++++++++++++++- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/packages/vinext/src/init-cloudflare.ts b/packages/vinext/src/init-cloudflare.ts index 3739e328e0..8d286d01d6 100644 --- a/packages/vinext/src/init-cloudflare.ts +++ b/packages/vinext/src/init-cloudflare.ts @@ -1649,11 +1649,40 @@ export function updateViteConfigForCloudflare( : ensureNamedImport(program, output, source, imported, local); responseStoreExpression = `${binding}(${cacheOptions.responseStoreMode === "self-contained" ? '{ mode: "self-contained" }' : ""})`; if (alreadyConfigured && cache) { - output.overwrite( - (cache.value as AstNode).start, - (cache.value as AstNode).end, - responseStoreExpression, - ); + const call = cache.value as ESTree.CallExpression & AstNode; + const argument = call.arguments[0]; + const mode = cacheOptions.responseStoreMode ?? "service-binding"; + if (!argument) { + if (mode === "self-contained") { + output.appendLeft(call.end - 1, '{ mode: "self-contained" }'); + } + } else if (argument.type === "ObjectExpression") { + const optionsObject = argument as AstObject; + const existingMode = findProperty(optionsObject, "mode"); + if ( + mode === "self-contained" && + ["serviceName", "r2BucketName", "shouldDeployService"].some((name) => + findProperty(optionsObject, name), + ) + ) { + throw new Error( + "Remove Workers Response Store service options before switching to self-contained mode.", + ); + } + if (existingMode) { + output.overwrite( + (existingMode.value as AstNode).start, + (existingMode.value as AstNode).end, + JSON.stringify(mode), + ); + } else if (mode === "self-contained") { + insertObjectProperty(output, optionsObject, ' mode: "self-contained",', code); + } + } else { + throw new Error( + "responseStoreAdapter() options must be a static object for vinext init to update its mode.", + ); + } responseStoreExpression = undefined; } } diff --git a/tests/init-cloudflare.test.ts b/tests/init-cloudflare.test.ts index 03b19634b6..b83022e911 100644 --- a/tests/init-cloudflare.test.ts +++ b/tests/init-cloudflare.test.ts @@ -82,7 +82,27 @@ export default { plugins: [vinext({ cache: responseStoreAdapter() })] }; ...options, cache: { ...options.cache, responseStoreMode: "service-binding" }, }); - expect(serviceBinding).toContain("cache: responseStoreAdapter()"); + expect(serviceBinding).toContain('cache: responseStoreAdapter({ mode: "service-binding" })'); + }); + + it("preserves existing Workers Response Store service options", () => { + const input = `import vinext from "vinext"; +import { responseStoreAdapter } from "@vinext/cloudflare/cache/response-store-adapter"; +import { cloudflare } from "@cloudflare/vite-plugin"; +export default { plugins: [vinext({ cache: responseStoreAdapter({ serviceName: "shared", shouldDeployService: false }) }), cloudflare()] }; +`; + const output = updateViteConfigForCloudflare("vite.config.ts", input, { + isAppRouter: false, + nativeModulesToStub: [], + cache: { + dataCache: "none", + cdnCache: "response-store", + imageOptimization: "none", + responseStoreMode: "service-binding", + }, + }); + + expect(output).toBe(input); }); it("rejects replacing an existing cache configuration with Workers Response Store", () => { From 9183dd755822fecf0ff360523576b461214d7b9b Mon Sep 17 00:00:00 2001 From: James Date: Mon, 14 Sep 2026 13:15:51 +0100 Subject: [PATCH 14/20] fix(cloudflare): harden response store configuration --- .../cache/response-store-adapter-config.ts | 7 +++- packages/cloudflare/src/deploy.ts | 22 +++++----- packages/vinext/src/init-cloudflare.ts | 35 +++++++++++++--- tests/cdn-adapter-config.test.ts | 23 +++++++++++ tests/deploy.test.ts | 11 +++++ tests/init-cloudflare.test.ts | 41 +++++++++++++++++++ 6 files changed, 123 insertions(+), 16 deletions(-) diff --git a/packages/cloudflare/src/cache/response-store-adapter-config.ts b/packages/cloudflare/src/cache/response-store-adapter-config.ts index af73e65460..3a05c88eb0 100644 --- a/packages/cloudflare/src/cache/response-store-adapter-config.ts +++ b/packages/cloudflare/src/cache/response-store-adapter-config.ts @@ -280,7 +280,12 @@ export async function finalizeSelfContainedResponseStoreBuildOutput({ }, ], exports: { - ...appConfig.exports, + ...Object.fromEntries( + Object.entries(appConfig.exports ?? {}).map(([name, value]) => [ + name, + value.cache === undefined ? { ...value, cache: { enabled: false } } : value, + ]), + ), default: { ...appConfig.exports?.default, type: "worker", cache: { enabled: false } }, ResponseStoreBinding: { ...appConfig.exports?.ResponseStoreBinding, diff --git a/packages/cloudflare/src/deploy.ts b/packages/cloudflare/src/deploy.ts index 42387c99af..eb4b39197a 100644 --- a/packages/cloudflare/src/deploy.ts +++ b/packages/cloudflare/src/deploy.ts @@ -767,18 +767,20 @@ export async function deployResponseStoreService( execute: typeof spawn = spawn, ): Promise { const appConfigs = appConfig ? [path.resolve(root, appConfig)] : []; - try { - const redirectPath = path.resolve(root, ".wrangler/deploy/config.json"); - const redirect = JSON.parse(fs.readFileSync(redirectPath, "utf8")) as { - configPath?: unknown; - }; - if (typeof redirect.configPath === "string") { - appConfigs.push(path.resolve(path.dirname(redirectPath), redirect.configPath)); + if (!appConfig) { + try { + const redirectPath = path.resolve(root, ".wrangler/deploy/config.json"); + const redirect = JSON.parse(fs.readFileSync(redirectPath, "utf8")) as { + configPath?: unknown; + }; + if (typeof redirect.configPath === "string") { + appConfigs.push(path.resolve(path.dirname(redirectPath), redirect.configPath)); + } + } catch { + // The conventional output remains valid without a redirect. } - } catch { - // The explicit config and conventional output remain valid without a redirect. + appConfigs.push(path.resolve(root, "dist/server/wrangler.json")); } - appConfigs.push(path.resolve(root, "dist/server/wrangler.json")); const serviceConfig = appConfigs .map((config) => path.resolve(path.dirname(config), RESPONSE_STORE_SERVICE_CONFIG)) .find((config) => fs.existsSync(config)); diff --git a/packages/vinext/src/init-cloudflare.ts b/packages/vinext/src/init-cloudflare.ts index 8d286d01d6..cc04abde34 100644 --- a/packages/vinext/src/init-cloudflare.ts +++ b/packages/vinext/src/init-cloudflare.ts @@ -1614,6 +1614,23 @@ export function updateViteConfigForCloudflare( const existingImageOptimizer = getVinextImageOptimizer(existingVinextCall); const needsPrerender = Boolean(options.prerender && !hasVinextPrerender(existingVinextCall)); const configureCaches = options.cache !== undefined; + const existingCache = getVinextCacheOption(existingVinextCall); + if (configureCaches && existingCache) { + const cacheObject = + existingCache.value.type === "ObjectExpression" + ? (existingCache.value as AstObject) + : undefined; + if ( + (!cacheObject && cacheOptions.cdnCache !== "response-store") || + (cacheObject && + (cacheOptions.cdnCache === "none" || cacheOptions.cdnCache === "data-cache") && + findProperty(cacheObject, "cdn")) + ) { + throw new Error( + "The existing vinext() cache configuration does not match the selected cache options. Remove it before rerunning vinext init.", + ); + } + } const cacheAdditions: Array<{ name: "data" | "cdn"; expression: string }> = []; let responseStoreExpression: string | undefined; if (configureCaches && cacheOptions.cdnCache === "response-store") { @@ -1670,11 +1687,19 @@ export function updateViteConfigForCloudflare( ); } if (existingMode) { - output.overwrite( - (existingMode.value as AstNode).start, - (existingMode.value as AstNode).end, - JSON.stringify(mode), - ); + if (existingMode.shorthand) { + output.overwrite( + (existingMode as AstNode).start, + (existingMode as AstNode).end, + `mode: ${JSON.stringify(mode)}`, + ); + } else { + output.overwrite( + (existingMode.value as AstNode).start, + (existingMode.value as AstNode).end, + JSON.stringify(mode), + ); + } } else if (mode === "self-contained") { insertObjectProperty(output, optionsObject, ' mode: "self-contained",', code); } diff --git a/tests/cdn-adapter-config.test.ts b/tests/cdn-adapter-config.test.ts index f8faca5045..620a8bce38 100644 --- a/tests/cdn-adapter-config.test.ts +++ b/tests/cdn-adapter-config.test.ts @@ -195,6 +195,29 @@ describe("Cloudflare CDN adapter generated config", () => { ); }); + it("keeps existing entrypoints uncached in self-contained mode", async () => { + const generatedPath = writeGeneratedConfig("dist/server/wrangler.json", { + name: "test-worker", + main: "index.js", + compatibility_date: "2026-09-14", + exports: { + UncachedEntrypoint: { type: "worker" }, + CachedEntrypoint: { type: "worker", cache: { enabled: true } }, + }, + }); + + await responseStoreAdapter({ mode: "self-contained" }).cdn.output.finalizeBuildOutput({ + outDir: path.dirname(generatedPath), + isPrimaryServerOutput: true, + }); + + expect(JSON.parse(fs.readFileSync(generatedPath, "utf8")).exports).toMatchObject({ + UncachedEntrypoint: { cache: { enabled: false } }, + CachedEntrypoint: { cache: { enabled: true } }, + ResponseStoreBinding: { cache: { enabled: true } }, + }); + }); + it("uses custom Response Store service and R2 bucket names", async () => { const generatedPath = writeGeneratedConfig("dist/server/wrangler.json", { name: "test-worker", diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index 26423ba80e..425a7bef8f 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -256,6 +256,17 @@ describe("deployResponseStoreService", () => { ).resolves.toBe(false); expect(execute).not.toHaveBeenCalled(); }); + + it("does not discover a stale service beside conventional output for an explicit config", async () => { + writeFile(tmpDir, "custom/wrangler.json", "{}"); + writeFile(tmpDir, "dist/server/vinext-response-store/wrangler.json", "{}"); + const execute = vi.fn() as unknown as typeof spawn; + + await expect(deployResponseStoreService(tmpDir, "custom/wrangler.json", execute)).resolves.toBe( + false, + ); + expect(execute).not.toHaveBeenCalled(); + }); }); describe("buildWranglerKVBulkPutArgs", () => { diff --git a/tests/init-cloudflare.test.ts b/tests/init-cloudflare.test.ts index b83022e911..ad200b86b1 100644 --- a/tests/init-cloudflare.test.ts +++ b/tests/init-cloudflare.test.ts @@ -105,6 +105,47 @@ export default { plugins: [vinext({ cache: responseStoreAdapter({ serviceName: " expect(output).toBe(input); }); + it("expands a shorthand Response Store mode when updating it", () => { + const input = `import vinext from "vinext"; +import { cloudflare } from "@cloudflare/vite-plugin"; +import { responseStoreAdapter } from "@vinext/cloudflare/cache/response-store-adapter"; +const mode = "service-binding"; +export default { plugins: [vinext({ cache: responseStoreAdapter({ mode }) }), cloudflare()] }; +`; + const output = updateViteConfigForCloudflare("vite.config.ts", input, { + isAppRouter: false, + nativeModulesToStub: [], + cache: { + dataCache: "none", + cdnCache: "response-store", + imageOptimization: "none", + responseStoreMode: "self-contained", + }, + }); + + expectValidConfig(output); + expect(output).toContain('responseStoreAdapter({ mode: "self-contained" })'); + }); + + it("rejects disabling an existing cache configuration without removing it", () => { + const input = `import vinext from "vinext"; +import { responseStoreAdapter } from "@vinext/cloudflare/cache/response-store-adapter"; +export default { plugins: [vinext({ cache: responseStoreAdapter() })] }; +`; + + expect(() => + updateViteConfigForCloudflare("vite.config.ts", input, { + isAppRouter: false, + nativeModulesToStub: [], + cache: { + dataCache: "none", + cdnCache: "none", + imageOptimization: "none", + }, + }), + ).toThrow("does not match the selected cache options"); + }); + it("rejects replacing an existing cache configuration with Workers Response Store", () => { const input = `import vinext from "vinext"; import { cdnAdapter } from "@vinext/cloudflare/cache/cdn-adapter"; From 2ab45b64226bc63535f0c581b79bbe308d5cc0ce Mon Sep 17 00:00:00 2001 From: James Date: Mon, 14 Sep 2026 13:21:58 +0100 Subject: [PATCH 15/20] fix(init): keep no-cache scaffolds deployable --- packages/create-vinext-app/src/index.ts | 13 ++++++++----- packages/vinext/src/init-cloudflare.ts | 3 ++- tests/create-vinext-app.test.ts | 26 +++++++++++++++++++++++++ tests/init-cloudflare.test.ts | 20 ++++++++++++++++++- 4 files changed, 55 insertions(+), 7 deletions(-) diff --git a/packages/create-vinext-app/src/index.ts b/packages/create-vinext-app/src/index.ts index 63d3b291cd..8754f504d8 100644 --- a/packages/create-vinext-app/src/index.ts +++ b/packages/create-vinext-app/src/index.ts @@ -68,8 +68,13 @@ const packageManagerFlags: Record = { "--use-bun": "bun", }; -function getTemplateFiles(platform: InitPlatform): Record { +function getTemplateFiles(initOptions: ResolvedInitOptions): Record { + const { platform } = initOptions; const isCloudflare = platform === "cloudflare"; + const revalidate = + !isCloudflare || initOptions.cloudflare?.cdnCache !== "none" + ? "export const revalidate = 300;\n\n" + : ""; const apiMessage = isCloudflare ? "Hello from vinext on Cloudflare Workers" : "Hello from vinext"; const title = isCloudflare ? "vinext on Cloudflare Workers" : "vinext app"; const secondaryLink = isCloudflare @@ -150,9 +155,7 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac ${secondaryLink} ]; -export const revalidate = 300; - -export default function Home() { +${revalidate}export default function Home() { return (
@@ -434,7 +437,7 @@ function writeTemplate( ): void { fs.mkdirSync(root, { recursive: true }); writePackageJson(root, appName, packageManager); - for (const [relativePath, content] of Object.entries(getTemplateFiles(initOptions.platform))) { + for (const [relativePath, content] of Object.entries(getTemplateFiles(initOptions))) { writeFile(root, relativePath, content); } } diff --git a/packages/vinext/src/init-cloudflare.ts b/packages/vinext/src/init-cloudflare.ts index cc04abde34..b341715b3c 100644 --- a/packages/vinext/src/init-cloudflare.ts +++ b/packages/vinext/src/init-cloudflare.ts @@ -1624,7 +1624,8 @@ export function updateViteConfigForCloudflare( (!cacheObject && cacheOptions.cdnCache !== "response-store") || (cacheObject && (cacheOptions.cdnCache === "none" || cacheOptions.cdnCache === "data-cache") && - findProperty(cacheObject, "cdn")) + findProperty(cacheObject, "cdn")) || + (cacheObject && cacheOptions.dataCache === "none" && findProperty(cacheObject, "data")) ) { throw new Error( "The existing vinext() cache configuration does not match the selected cache options. Remove it before rerunning vinext init.", diff --git a/tests/create-vinext-app.test.ts b/tests/create-vinext-app.test.ts index 168de8ee6e..382a5b2171 100644 --- a/tests/create-vinext-app.test.ts +++ b/tests/create-vinext-app.test.ts @@ -92,6 +92,16 @@ const nodeInitOptions: ResolvedInitOptions = { prerender: false, }; +const noCacheCloudflareInitOptions: ResolvedInitOptions = { + platform: "cloudflare", + prerender: false, + cloudflare: { + dataCache: "none", + cdnCache: "none", + imageOptimization: "cloudflare-images", + }, +}; + async function withQuietConsole(task: () => Promise): Promise { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); try { @@ -110,6 +120,22 @@ afterEach(() => { }); describe("createVinextApp", () => { + it("does not enable ISR when Cloudflare caching is declined", async () => { + const appPath = path.join(tmpDir, "no-cache-app"); + + await withQuietConsole(() => + createVinextApp({ + appPath, + packageManager: "npm", + install: false, + git: false, + initOptions: noCacheCloudflareInitOptions, + }), + ); + + expect(readFile(appPath, "app/page.tsx")).not.toContain("export const revalidate"); + }); + it("creates a fixed App Router TypeScript Tailwind template and applies Cloudflare init", async () => { const appPath = path.join(tmpDir, "fresh-app"); diff --git a/tests/init-cloudflare.test.ts b/tests/init-cloudflare.test.ts index ad200b86b1..92008fd107 100644 --- a/tests/init-cloudflare.test.ts +++ b/tests/init-cloudflare.test.ts @@ -146,6 +146,24 @@ export default { plugins: [vinext({ cache: responseStoreAdapter() })] }; ).toThrow("does not match the selected cache options"); }); + it("rejects disabling an existing data cache without removing it", () => { + const input = `import vinext from "vinext"; +export default { plugins: [vinext({ cache: { data: customData() } })] }; +`; + + expect(() => + updateViteConfigForCloudflare("vite.config.ts", input, { + isAppRouter: false, + nativeModulesToStub: [], + cache: { + dataCache: "none", + cdnCache: "none", + imageOptimization: "none", + }, + }), + ).toThrow("does not match the selected cache options"); + }); + it("rejects replacing an existing cache configuration with Workers Response Store", () => { const input = `import vinext from "vinext"; import { cdnAdapter } from "@vinext/cloudflare/cache/cdn-adapter"; @@ -536,7 +554,7 @@ export default { plugins: [vinext({ cache: { data: customData() } })] }; { isAppRouter: false, nativeModulesToStub: [], - cache: { dataCache: "none", cdnCache: "data-cache", imageOptimization: "none" }, + cache: { dataCache: "kv", cdnCache: "data-cache", imageOptimization: "none" }, prerender: true, }, ); From 28619f5e3ada89c247930b11b25ad62c86393872 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 14 Sep 2026 13:30:06 +0100 Subject: [PATCH 16/20] fix(cloudflare): scope response store provisioning --- .../src/cache/response-store-adapter-config.ts | 10 +++++++++- packages/cloudflare/src/deploy.ts | 9 +++++++-- tests/cdn-adapter-config.test.ts | 17 +++++++++++++++++ tests/deploy.test.ts | 17 +++++++++++++++++ 4 files changed, 50 insertions(+), 3 deletions(-) diff --git a/packages/cloudflare/src/cache/response-store-adapter-config.ts b/packages/cloudflare/src/cache/response-store-adapter-config.ts index 3a05c88eb0..1d700965b5 100644 --- a/packages/cloudflare/src/cache/response-store-adapter-config.ts +++ b/packages/cloudflare/src/cache/response-store-adapter-config.ts @@ -227,6 +227,11 @@ export async function finalizeSelfContainedResponseStoreBuildOutput({ throw new Error("[vinext] The generated Wrangler config contains an invalid binding."); } const existingR2Buckets = (r2Buckets ?? []) as Record[]; + const automaticBucketName = `${appConfig.name}-cache-bodies`; + const bucketName = + automaticBucketName.length <= 63 + ? undefined + : `${appConfig.name.slice(0, 41)}-${createHash("sha256").update(appConfig.name).digest("hex").slice(0, 8)}-cache-bodies`; const existingDurableBindings = (durableBindings ?? []) as Record[]; const existingCacheMetadataBinding = existingDurableBindings.find( (binding) => binding.name === CACHE_METADATA_BINDING, @@ -260,7 +265,10 @@ export async function finalizeSelfContainedResponseStoreBuildOutput({ version_metadata: { binding: VERSION_METADATA_BINDING }, r2_buckets: existingR2Buckets.some((binding) => binding.binding === CACHE_BODIES_BINDING) ? existingR2Buckets - : [...existingR2Buckets, { binding: CACHE_BODIES_BINDING }], + : [ + ...existingR2Buckets, + { binding: CACHE_BODIES_BINDING, ...(bucketName ? { bucket_name: bucketName } : {}) }, + ], durable_objects: { ...durableObjects, bindings: existingCacheMetadataBinding diff --git a/packages/cloudflare/src/deploy.ts b/packages/cloudflare/src/deploy.ts index eb4b39197a..fc5bec40c1 100644 --- a/packages/cloudflare/src/deploy.ts +++ b/packages/cloudflare/src/deploy.ts @@ -768,18 +768,23 @@ export async function deployResponseStoreService( ): Promise { const appConfigs = appConfig ? [path.resolve(root, appConfig)] : []; if (!appConfig) { + let hasRedirect = false; try { const redirectPath = path.resolve(root, ".wrangler/deploy/config.json"); const redirect = JSON.parse(fs.readFileSync(redirectPath, "utf8")) as { configPath?: unknown; }; if (typeof redirect.configPath === "string") { - appConfigs.push(path.resolve(path.dirname(redirectPath), redirect.configPath)); + const redirectedConfig = path.resolve(path.dirname(redirectPath), redirect.configPath); + if (fs.existsSync(redirectedConfig)) { + appConfigs.push(redirectedConfig); + hasRedirect = true; + } } } catch { // The conventional output remains valid without a redirect. } - appConfigs.push(path.resolve(root, "dist/server/wrangler.json")); + if (!hasRedirect) appConfigs.push(path.resolve(root, "dist/server/wrangler.json")); } const serviceConfig = appConfigs .map((config) => path.resolve(path.dirname(config), RESPONSE_STORE_SERVICE_CONFIG)) diff --git a/tests/cdn-adapter-config.test.ts b/tests/cdn-adapter-config.test.ts index 620a8bce38..d721a7ed20 100644 --- a/tests/cdn-adapter-config.test.ts +++ b/tests/cdn-adapter-config.test.ts @@ -218,6 +218,23 @@ describe("Cloudflare CDN adapter generated config", () => { }); }); + it("bounds the self-contained Response Store bucket name", async () => { + const generatedPath = writeGeneratedConfig("dist/server/wrangler.json", { + name: "a".repeat(60), + main: "index.js", + compatibility_date: "2026-09-14", + }); + + await responseStoreAdapter({ mode: "self-contained" }).cdn.output.finalizeBuildOutput({ + outDir: path.dirname(generatedPath), + isPrimaryServerOutput: true, + }); + + const [bucket] = JSON.parse(fs.readFileSync(generatedPath, "utf8")).r2_buckets; + expect(bucket.bucket_name).toHaveLength(63); + expect(bucket.bucket_name).toMatch(/-[a-f0-9]{8}-cache-bodies$/); + }); + it("uses custom Response Store service and R2 bucket names", async () => { const generatedPath = writeGeneratedConfig("dist/server/wrangler.json", { name: "test-worker", diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index 425a7bef8f..6ccef623df 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -267,6 +267,23 @@ describe("deployResponseStoreService", () => { ); expect(execute).not.toHaveBeenCalled(); }); + + it("does not discover conventional output when a valid deploy redirect exists", async () => { + const redirectedConfig = path.join(tmpDir, "custom/wrangler.json"); + writeFile(tmpDir, "custom/wrangler.json", "{}"); + writeFile( + tmpDir, + ".wrangler/deploy/config.json", + JSON.stringify({ + configPath: path.relative(path.join(tmpDir, ".wrangler/deploy"), redirectedConfig), + }), + ); + writeFile(tmpDir, "dist/server/vinext-response-store/wrangler.json", "{}"); + const execute = vi.fn() as unknown as typeof spawn; + + await expect(deployResponseStoreService(tmpDir, undefined, execute)).resolves.toBe(false); + expect(execute).not.toHaveBeenCalled(); + }); }); describe("buildWranglerKVBulkPutArgs", () => { From a3aef5d86ffea0b8948017b8d025f3f76d1d1d7d Mon Sep 17 00:00:00 2001 From: James Date: Mon, 14 Sep 2026 13:30:06 +0100 Subject: [PATCH 17/20] test(response-store): allow skipped revision ids --- packages/workers-response-store/tests/live-e2e.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/workers-response-store/tests/live-e2e.test.ts b/packages/workers-response-store/tests/live-e2e.test.ts index 72b388e503..156acc3eb8 100644 --- a/packages/workers-response-store/tests/live-e2e.test.ts +++ b/packages/workers-response-store/tests/live-e2e.test.ts @@ -285,7 +285,7 @@ test("live R2 path stores and refills a 10 MiB body", async () => { assert.equal(returned.at(-1), 97); }); -test("live SWR serves stale immediately, regenerates once, and promotes fresh R2 on a later callback", async () => { +test("live SWR serves stale immediately and promotes fresh R2 on a later callback", async () => { const id = key("swr"); await put(`/${id}`, "swr-seed", { cacheControl: "public, max-age=1, stale-while-revalidate=20", @@ -312,6 +312,8 @@ test("live SWR serves stale immediately, regenerates once, and promotes fresh R2 ? { ok: true, value: response } : { ok: false, message: `SWR still returned ${JSON.stringify(body)}` }; }); - assert.equal(fresh.headers.get("X-Workers-Response-Store-Revision"), "2"); + const revision = fresh.headers.get("X-Workers-Response-Store-Revision"); + assert.match(revision ?? "", /^\d+$/); + assert.ok(Number(revision) >= 2); assert.equal(fresh.headers.get("X-Revalidation-Reason"), "swr"); }); From 6624eb39650f6c509202035df9a8358c777a6859 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 14 Sep 2026 13:34:36 +0100 Subject: [PATCH 18/20] test(init): align prerender rerun cache choice --- tests/init.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/init.test.ts b/tests/init.test.ts index 280af250a3..433645ea90 100644 --- a/tests/init.test.ts +++ b/tests/init.test.ts @@ -767,7 +767,7 @@ export default { plugins: [vinext({ cache: { data: customData() } })] }; platform: "cloudflare", prerender: true, cloudflare: { - dataCache: "none", + dataCache: "kv", cdnCache: "data-cache", imageOptimization: "none", }, From 76663763c3129de912bac638b26fb2f297bbb81d Mon Sep 17 00:00:00 2001 From: James Date: Mon, 14 Sep 2026 13:35:09 +0100 Subject: [PATCH 19/20] fix(cloudflare): validate generated resource discovery --- .../src/cache/response-store-adapter-config.ts | 8 +++++++- packages/cloudflare/src/deploy.ts | 2 +- tests/cdn-adapter-config.test.ts | 1 + tests/deploy.test.ts | 10 ++++++++++ 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/cloudflare/src/cache/response-store-adapter-config.ts b/packages/cloudflare/src/cache/response-store-adapter-config.ts index 1d700965b5..8c2d0119e7 100644 --- a/packages/cloudflare/src/cache/response-store-adapter-config.ts +++ b/packages/cloudflare/src/cache/response-store-adapter-config.ts @@ -264,7 +264,13 @@ export async function finalizeSelfContainedResponseStoreBuildOutput({ cache: { ...cache, enabled: true }, version_metadata: { binding: VERSION_METADATA_BINDING }, r2_buckets: existingR2Buckets.some((binding) => binding.binding === CACHE_BODIES_BINDING) - ? existingR2Buckets + ? existingR2Buckets.map((binding) => + binding.binding === CACHE_BODIES_BINDING && + binding.bucket_name === undefined && + bucketName + ? { ...binding, bucket_name: bucketName } + : binding, + ) : [ ...existingR2Buckets, { binding: CACHE_BODIES_BINDING, ...(bucketName ? { bucket_name: bucketName } : {}) }, diff --git a/packages/cloudflare/src/deploy.ts b/packages/cloudflare/src/deploy.ts index fc5bec40c1..aa5cb97b69 100644 --- a/packages/cloudflare/src/deploy.ts +++ b/packages/cloudflare/src/deploy.ts @@ -776,7 +776,7 @@ export async function deployResponseStoreService( }; if (typeof redirect.configPath === "string") { const redirectedConfig = path.resolve(path.dirname(redirectPath), redirect.configPath); - if (fs.existsSync(redirectedConfig)) { + if (fs.statSync(redirectedConfig).isFile()) { appConfigs.push(redirectedConfig); hasRedirect = true; } diff --git a/tests/cdn-adapter-config.test.ts b/tests/cdn-adapter-config.test.ts index d721a7ed20..e7265499d5 100644 --- a/tests/cdn-adapter-config.test.ts +++ b/tests/cdn-adapter-config.test.ts @@ -223,6 +223,7 @@ describe("Cloudflare CDN adapter generated config", () => { name: "a".repeat(60), main: "index.js", compatibility_date: "2026-09-14", + r2_buckets: [{ binding: "CACHE_BODIES" }], }); await responseStoreAdapter({ mode: "self-contained" }).cdn.output.finalizeBuildOutput({ diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index 6ccef623df..19aa71c0e2 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -284,6 +284,16 @@ describe("deployResponseStoreService", () => { await expect(deployResponseStoreService(tmpDir, undefined, execute)).resolves.toBe(false); expect(execute).not.toHaveBeenCalled(); }); + + it("falls back to conventional output when a deploy redirect targets a directory", async () => { + writeWranglerPackageForTest(tmpDir); + writeFile(tmpDir, ".wrangler/deploy/config.json", JSON.stringify({ configPath: "." })); + writeFile(tmpDir, "dist/server/wrangler.json", "{}"); + writeFile(tmpDir, "dist/server/vinext-response-store/wrangler.json", "{}"); + const execute = (() => createMockChildProcess("Deployed\n")) as typeof spawn; + + await expect(deployResponseStoreService(tmpDir, undefined, execute)).resolves.toBe(true); + }); }); describe("buildWranglerKVBulkPutArgs", () => { From 9007bce5531a1c0b655e79a9c05591cfe3ffce9b Mon Sep 17 00:00:00 2001 From: James Date: Mon, 14 Sep 2026 13:50:28 +0100 Subject: [PATCH 20/20] chore(cloudflare): clarify deployment phase output --- packages/cloudflare/src/deploy.ts | 19 ++++++++++++++----- packages/cloudflare/src/version-deploy.ts | 12 +++++++----- tests/cloudflare-version-deploy.test.ts | 10 +++++++--- tests/deploy.test.ts | 4 ++++ 4 files changed, 32 insertions(+), 13 deletions(-) diff --git a/packages/cloudflare/src/deploy.ts b/packages/cloudflare/src/deploy.ts index aa5cb97b69..9a9a29a11a 100644 --- a/packages/cloudflare/src/deploy.ts +++ b/packages/cloudflare/src/deploy.ts @@ -711,7 +711,9 @@ export async function runWranglerKVBulkPut( export async function runWranglerDeploy( root: string, - options: Pick, + options: Pick & { + displayName?: string; + }, execute: typeof spawn = spawn, ): Promise { const spawnOptions: SpawnOptions = { @@ -721,11 +723,12 @@ export async function runWranglerDeploy( }; const { file, args, env } = buildWranglerInvocation(root, options); + const displayName = options.displayName ?? "application Worker"; if (env) { - console.log(`\n Deploying to env: ${env}...`); + console.log(`\n Deploying ${displayName} to env: ${env}...`); } else { - console.log("\n Deploying to production..."); + console.log(`\n Deploying ${displayName} to production...`); } const child = execute(file, args, spawnOptions); @@ -791,8 +794,14 @@ export async function deployResponseStoreService( .find((config) => fs.existsSync(config)); if (!serviceConfig) return false; - console.log("\n Deploying Workers Response Store..."); - await runWranglerDeploy(root, { config: path.relative(root, serviceConfig) }, execute); + await runWranglerDeploy( + root, + { + config: path.relative(root, serviceConfig), + displayName: "Workers Response Store service", + }, + execute, + ); return true; } diff --git a/packages/cloudflare/src/version-deploy.ts b/packages/cloudflare/src/version-deploy.ts index 65d0919e1f..8362151c07 100644 --- a/packages/cloudflare/src/version-deploy.ts +++ b/packages/cloudflare/src/version-deploy.ts @@ -279,9 +279,9 @@ export function runWranglerVersionUpload( ): WranglerVersionUploadResult { const { args, env } = buildWranglerVersionUploadArgs(options); if (env) { - console.log(`\n Uploading Worker version for env: ${env}...`); + console.log(`\n Uploading application Worker version for env: ${env}...`); } else { - console.log("\n Uploading Worker version for production..."); + console.log("\n Uploading application Worker version for production..."); } try { return parseWranglerVersionUploadOutput( @@ -305,11 +305,13 @@ export function runWranglerVersionDeploy( const { args, env } = buildWranglerVersionDeployArgs(versionTraffic, options); const target = env ? `env: ${env}` : "production"; if (phase === "stage") { - console.log(`\n Staging uploaded Worker version at 0% for CDN warmup in ${target}...`); + console.log( + `\n Staging uploaded application Worker version at 0% for CDN warmup in ${target}...`, + ); } else if (phase === "promote-warmed") { - console.log(`\n Promoting warmed Worker version to ${target}...`); + console.log(`\n Promoting warmed application Worker version to ${target}...`); } else { - console.log(`\n Promoting uploaded Worker version to ${target}...`); + console.log(`\n Promoting uploaded application Worker version to ${target}...`); } const output = runWranglerCommand(root, args, execute, options.verbose === true); return { deployedUrl: parseWorkersDevUrl(output), output }; diff --git a/tests/cloudflare-version-deploy.test.ts b/tests/cloudflare-version-deploy.test.ts index 223156f153..954705f66a 100644 --- a/tests/cloudflare-version-deploy.test.ts +++ b/tests/cloudflare-version-deploy.test.ts @@ -126,10 +126,14 @@ describe("Cloudflare Wrangler version deployment helpers", () => { ); expect(log).toHaveBeenCalledWith( - "\n Staging uploaded Worker version at 0% for CDN warmup in production...", + "\n Staging uploaded application Worker version at 0% for CDN warmup in production...", + ); + expect(log).toHaveBeenCalledWith( + "\n Promoting warmed application Worker version to production...", + ); + expect(log).toHaveBeenCalledWith( + "\n Promoting uploaded application Worker version to env: staging...", ); - expect(log).toHaveBeenCalledWith("\n Promoting warmed Worker version to production..."); - expect(log).toHaveBeenCalledWith("\n Promoting uploaded Worker version to env: staging..."); }); it("hides raw Wrangler upload output by default and shows it in verbose mode", () => { diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index 19aa71c0e2..81c7a12784 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -231,6 +231,7 @@ describe("deployResponseStoreService", () => { writeWranglerPackageForTest(tmpDir); writeFile(tmpDir, "dist/server/wrangler.json", "{}"); writeFile(tmpDir, "dist/server/vinext-response-store/wrangler.json", "{}"); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); let observed: Parameters | undefined; const execute = ((...args: Parameters) => { observed = args; @@ -247,6 +248,9 @@ describe("deployResponseStoreService", () => { "--config", path.join("dist", "server", "vinext-response-store", "wrangler.json"), ]); + expect(log).toHaveBeenCalledWith( + "\n Deploying Workers Response Store service to production...", + ); }); it("is a no-op when the build has no generated service", async () => {