diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 838aed119..92672f1b0 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,7 @@ jobs: working-directory: ${{ runner.temp }}/cna-cloudflare run: >- vp exec vinext init --skip-check --platform=cloudflare - --data-cache=kv + --cdn-cache=response-store --image-optimization=cloudflare-images - name: Configure generated Cloudflare deployment @@ -349,11 +347,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 +356,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 +396,7 @@ jobs: --skip-install --disable-git --platform=cloudflare - --cdn-cache=data-cache - --data-cache=kv + --cdn-cache=response-store --image-optimization=cloudflare-images - name: Pin pnpm in scaffolded project to vinext's version @@ -445,11 +437,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 +446,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 e6fa00041..0b49076d4 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,7 @@ jobs: --skip-install --disable-git --platform=cloudflare - --cdn-cache=data-cache - --data-cache=kv + --cdn-cache=response-store --image-optimization=cloudflare-images - name: Pin pnpm in scaffolded project to vinext's version @@ -98,11 +95,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 +104,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 13c2e102e..fc0c9f122 100644 --- a/.github/workflows/deploy-examples.yml +++ b/.github/workflows/deploy-examples.yml @@ -37,7 +37,7 @@ 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" @@ -203,30 +203,19 @@ 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' && 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 @@ -347,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 diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index a28e1538c..d27623518 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 ed72d2a14..d360e2180 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/examples/response-store-demo/vite.config.ts b/examples/response-store-demo/vite.config.ts index 7d7286385..114d45af8 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 484d6aed3..f8e591d62 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 bc4c6144b..c70f79e64 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 `shouldDeployService: false`: + +```ts +vinext({ + cache: responseStoreAdapter({ + serviceName: "shared-response-store", + shouldDeployService: false, + }), +}); +``` + +When omitted, names are derived from the application Worker. Setting +`shouldDeployService` 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"; @@ -81,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/package.json b/packages/cloudflare/package.json index 4eeac7275..3e0614de2 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 000000000..8c2d0119e --- /dev/null +++ b/packages/cloudflare/src/cache/response-store-adapter-config.ts @@ -0,0 +1,312 @@ +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 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 & { + 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"], + }; +} + +async function readAppConfig(outDir: string): Promise<{ + appConfig: WranglerOutputConfig; + appConfigPath: string; +}> { + const appConfigPath = path.resolve(outDir, "wrangler.json"); + try { + const parsed: unknown = JSON.parse(await fs.readFile(appConfigPath, "utf8")); + if (!isRecord(parsed)) throw new TypeError("the root value must be an object"); + 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.", + ); + } + 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)}.`, + ); + } +} + +/** Emit the service Worker and connect the application Worker to it. */ +export async function finalizeResponseStoreBuildOutput({ + outDir, + isPrimaryServerOutput, + serviceName: configuredServiceName, + r2BucketName, + shouldDeployService = true, +}: { + outDir: string; + isPrimaryServerOutput: boolean; + serviceName?: string; + r2BucketName?: string; + shouldDeployService?: 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."); + } + + 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 (shouldDeployService) { + 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_BINDING, ...(r2BucketName ? { bucket_name: r2BucketName } : {}) }, + ], + durable_objects: { + bindings: [{ name: CACHE_METADATA_BINDING, class_name: CACHE_METADATA_CLASS }], + }, + migrations: [{ tag: "v1", new_sqlite_classes: [CACHE_METADATA_CLASS] }], + ...(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`); +} + +/** 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 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, + ); + 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) => + 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.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 } : {}) }, + ], + durable_objects: { + ...durableObjects, + bindings: existingCacheMetadataBinding + ? existingDurableBindings + : [ + ...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: { + ...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, + 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 6d7229c5a..4856f06e5 100644 --- a/packages/cloudflare/src/cache/response-store-adapter.ts +++ b/packages/cloudflare/src/cache/response-store-adapter.ts @@ -1,9 +1,19 @@ import { fileURLToPath } from "node:url"; +import { + finalizeResponseStoreBuildOutput, + finalizeSelfContainedResponseStoreBuildOutput, +} 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. */ + shouldDeployService?: boolean; }; /** @@ -16,6 +26,38 @@ 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.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.shouldDeployService !== undefined) + ) { + throw new TypeError("Response Store service options cannot be used in self-contained mode."); + } + if (options.shouldDeployService === false && !options.serviceName) { + throw new TypeError( + "responseStoreAdapter({ shouldDeployService: false }) requires an existing serviceName.", + ); + } + if (options.shouldDeployService === false && options.r2BucketName) { + throw new TypeError( + "r2BucketName configures a deployed service and cannot be used when shouldDeployService is false.", + ); + } const workerEntry = fileURLToPath( import.meta.resolve( mode === "self-contained" @@ -43,6 +85,16 @@ export function responseStoreAdapter(options: ResponseStoreAdapterOptions = {}) if (cleanId !== CLOUDFLARE_WORKER_ENTRY_ID) return null; return `${code}\nexport { ${entrypoints} } from ${JSON.stringify(workerEntry)};\n`; }, + finalizeBuildOutput(output: { outDir: string; isPrimaryServerOutput: boolean }) { + return mode === "self-contained" + ? finalizeSelfContainedResponseStoreBuildOutput(output) + : finalizeResponseStoreBuildOutput({ + ...output, + serviceName: options.serviceName, + r2BucketName: options.r2BucketName, + shouldDeployService: options.shouldDeployService, + }); + }, type: "multi-stage" as const, }, capabilities: { diff --git a/packages/cloudflare/src/deploy.ts b/packages/cloudflare/src/deploy.ts index 1b7e870f9..9a9a29a11 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, @@ -710,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 = { @@ -720,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); @@ -759,6 +763,48 @@ 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)] : []; + 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") { + const redirectedConfig = path.resolve(path.dirname(redirectPath), redirect.configPath); + if (fs.statSync(redirectedConfig).isFile()) { + appConfigs.push(redirectedConfig); + hasRedirect = true; + } + } + } catch { + // The conventional output remains valid without a redirect. + } + 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)) + .find((config) => fs.existsSync(config)); + if (!serviceConfig) return false; + + await runWranglerDeploy( + root, + { + config: path.relative(root, serviceConfig), + displayName: "Workers Response Store service", + }, + execute, + ); + return true; +} + export function hasCdnWarmRequests( plan: Omit & { pagesDataPaths?: readonly string[] }, ): boolean { @@ -2063,6 +2109,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/src/version-deploy.ts b/packages/cloudflare/src/version-deploy.ts index 65d0919e1..8362151c0 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/packages/cloudflare/tests/response-store-adapter.e2e.test.ts b/packages/cloudflare/tests/response-store-adapter.e2e.test.ts index 02128322e..8ce3c5237 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 43a2ac7f8..8754f504d 100644 --- a/packages/create-vinext-app/src/index.ts +++ b/packages/create-vinext-app/src/index.ts @@ -10,13 +10,15 @@ 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" | "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; }; @@ -66,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 @@ -148,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 (
@@ -274,7 +279,8 @@ 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: 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 @@ -334,6 +340,7 @@ function parseArgs(args: string[]): ParsedArgs { arg === "--platform" || arg === "--data-cache" || arg === "--cdn-cache" || + arg === "--response-store-mode" || arg === "--image-optimization" ) { index++; @@ -430,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/cli.ts b/packages/vinext/src/cli.ts index baf979458..3c3c6d625 100644 --- a/packages/vinext/src/cli.ts +++ b/packages/vinext/src/cli.ts @@ -1020,10 +1020,12 @@ 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: 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 -h, --help Show this help diff --git a/packages/vinext/src/init-cloudflare.ts b/packages/vinext/src/init-cloudflare.ts index c997d2555..b341715b3 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: "none", 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,11 @@ function vinextExpression( cacheEntries.push(`cdn: cdnAdapter(${adapterOptions})`); } const optionEntries: string[] = []; - if (cacheEntries.length > 0) { + if (responseStore) { + optionEntries.push( + `cache: responseStoreAdapter(${options.responseStoreMode === "self-contained" ? '{ mode: "self-contained" }' : ""})`, + ); + } else if (cacheEntries.length > 0) { optionEntries.push(`cache: { ${cacheEntries.join(", ")} }`); } if (options.imageOptimization === "cloudflare-images") { @@ -1203,6 +1213,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 +1335,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 +1583,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( @@ -1569,7 +1614,105 @@ 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")) || + (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.", + ); + } + } 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) + ) { + throw new Error( + "The vinext() cache option is already configured. Remove it before configuring Workers Response Store.", + ); + } + 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}(${cacheOptions.responseStoreMode === "self-contained" ? '{ mode: "self-contained" }' : ""})`; + if (alreadyConfigured && cache) { + 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) { + 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); + } + } else { + throw new Error( + "responseStoreAdapter() options must be a static object for vinext init to update its mode.", + ); + } + responseStoreExpression = undefined; + } + } + } if (cacheOptions.dataCache === "kv" && !hasVinextCacheSlot(existingVinextCall, "data")) { const existing = commonJs ? findRequiredBinding(program, "@vinext/cloudflare/cache/kv-data-adapter", "kvDataAdapter") @@ -1688,10 +1831,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 +1865,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 8b87b7472..b6ac53537 100644 --- a/packages/vinext/src/init-platform.ts +++ b/packages/vinext/src/init-platform.ts @@ -4,13 +4,15 @@ 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" | "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; }; @@ -111,13 +113,22 @@ 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", [ + "none", + "response-store", + "workers-cache", + "data-cache", + ]); } export function parseImageOptimizationArg(args: string[]): InitImageOptimization | undefined { 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, @@ -214,15 +225,19 @@ 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") { + 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=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" && supportsWarmCdnCache ? await resolveInitWarmCdnCache(args, options) : false; @@ -296,9 +311,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 === "") { @@ -325,20 +338,42 @@ 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 && explicitDataCache && explicitImageOptimization) { + 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" || explicitCdnCache === "none" || explicitDataCache) && + explicitImageOptimization + ) { return { - dataCache: explicitDataCache, + dataCache: + explicitCdnCache === "response-store" || explicitCdnCache === "none" + ? "none" + : (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 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 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=....", ); } @@ -347,10 +382,15 @@ export async function resolveCloudflareInitOptions( const isInteractive = options.isInteractive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY); if (!isInteractive) { + const cdnCache = explicitCdnCache ?? (explicitDataCache === "kv" ? "data-cache" : "none"); return { - dataCache: explicitDataCache ?? "kv", - cdnCache: explicitCdnCache ?? "workers-cache", + dataCache: + cdnCache === "response-store" || cdnCache === "none" ? "none" : (explicitDataCache ?? "kv"), + cdnCache, imageOptimization: explicitImageOptimization ?? "cloudflare-images", + ...(cdnCache === "response-store" + ? { responseStoreMode: explicitResponseStoreMode ?? "service-binding" } + : {}), }; } @@ -380,27 +420,69 @@ export async function resolveCloudflareInitOptions( } }; + 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") { + 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, - " Choose a CDN cache:\n 1. Workers Cache (default)\n 2. Data cache\n CDN cache [1]: ", + selectedCdnCache, + " 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" || 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" + : 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]: ", @@ -414,7 +496,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/packages/workers-response-store/tests/live-e2e.test.ts b/packages/workers-response-store/tests/live-e2e.test.ts index 72b388e50..156acc3eb 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"); }); diff --git a/tests/cdn-adapter-config.test.ts b/tests/cdn-adapter-config.test.ts index c69ccb80b..e7265499d 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,262 @@ 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("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("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("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("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", + r2_buckets: [{ binding: "CACHE_BODIES" }], + }); + + 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", + 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", + shouldDeployService: 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({ 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({ shouldDeployService: false })).toThrow( + "requires an existing serviceName", + ); + expect(() => + responseStoreAdapter({ + serviceName: "response-store", + r2BucketName: "response-store-bodies", + shouldDeployService: false, + }), + ).toThrow("cannot be used when shouldDeployService 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/cloudflare-version-deploy.test.ts b/tests/cloudflare-version-deploy.test.ts index 223156f15..954705f66 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/create-vinext-app.test.ts b/tests/create-vinext-app.test.ts index 4723218b8..382a5b217 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", }, }; @@ -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"); @@ -133,6 +159,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"); @@ -374,4 +401,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" })', + ); + }); }); diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index f8f21360b..81c7a1278 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,80 @@ 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", "{}"); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + 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"), + ]); + expect(log).toHaveBeenCalledWith( + "\n Deploying Workers Response Store service to production...", + ); + }); + + 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(); + }); + + 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(); + }); + + 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(); + }); + + 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", () => { it("uploads a bulk JSON file to the configured KV binding", () => { expect( @@ -1198,7 +1273,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 +1284,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 921b574a0..92008fd10 100644 --- a/tests/init-cloudflare.test.ts +++ b/tests/init-cloudflare.test.ts @@ -20,6 +20,171 @@ function expectValidConfig(output: string): void { } describe("updateViteConfigForCloudflare", () => { + it("does not configure caching by default", () => { + const output = generateAppRouterViteConfig(); + expectValidConfig(output); + 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", () => { + 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("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 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({ 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("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 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"; +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"; @@ -389,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, }, ); diff --git a/tests/init-platform.test.ts b/tests/init-platform.test.ts index 05cf88799..abf796be1 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, @@ -30,17 +31,22 @@ 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"); + expect(parseResponseStoreModeArg(["--response-store-mode=self-contained"])).toBe( + "self-contained", + ); }); - it("defaults to KV data, Workers Cache CDN, and Cloudflare Images", async () => { + it("defaults to no cache and Cloudflare Images", async () => { await expect( resolveCloudflareInitOptions([], { env: {}, isInteractive: false }), ).resolves.toEqual({ - dataCache: "kv", - cdnCache: "workers-cache", + dataCache: "none", + cdnCache: "none", imageOptimization: "cloudflare-images", }); }); @@ -48,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 () => { @@ -71,21 +79,20 @@ 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", () => { expect(() => parseCdnCacheArg(["--cdn-cache=kv"])).toThrow( - "Expected workers-cache or data-cache", - ); - expect(() => parseCdnCacheArg(["--cdn-cache=none"])).toThrow( - "Expected 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 = ["2", "2", "2"]; + const answers = ["yes", "3", "2", "2"]; const output = new PassThrough(); await expect( resolveCloudflareInitOptions([], { @@ -103,11 +110,92 @@ 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]: ", + " 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", + 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 () => { @@ -125,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( @@ -135,7 +223,7 @@ describe("Cloudflare init choices", () => { output, question: async (prompt) => { prompts.push(prompt); - return "2"; + return ""; }, }), ).resolves.toEqual({ @@ -143,15 +231,13 @@ describe("Cloudflare init choices", () => { cdnCache: "data-cache", imageOptimization: "none", }); - expect(prompts).toEqual([ - " Choose a CDN cache:\n 1. Workers Cache (default)\n 2. 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 () => { const prompts: string[] = []; - const answers = ["invalid", "2", "2", "2"]; + const answers = ["yes", "invalid", "3", "2", "2"]; const output = new PassThrough(); await resolveCloudflareInitOptions([], { env: {}, @@ -163,12 +249,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 Cache (1) or Data cache (2).\n\n\n\n", + "\n Please choose Workers Response Store (1), Workers Cache (2), or Data cache (3).\n\n\n\n", ); }); }); @@ -260,9 +347,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 +411,13 @@ describe("resolveInitPlatform", () => { }); describe("resolveInitOptions", () => { - it("defaults Cloudflare Workers Cache init away from CDN 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: "kv", - cdnCache: "workers-cache", + dataCache: "none", + cdnCache: "none", imageOptimization: "cloudflare-images", warmCdnCache: false, }, @@ -363,9 +448,9 @@ describe("resolveInitOptions", () => { }); }); - it("asks whether to pre-warm Workers Cache 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"], { @@ -380,25 +465,27 @@ describe("resolveInitOptions", () => { platform: "cloudflare", prerender: false, cloudflare: { - dataCache: "kv", - cdnCache: "workers-cache", + dataCache: "none", + cdnCache: "response-store", imageOptimization: "cloudflare-images", + responseStoreMode: "service-binding", 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]: ", + " 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 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 = ["yes", "3", "", "", ""]; await expect( resolveInitOptions(["--platform=cloudflare"], { @@ -421,7 +508,8 @@ describe("resolveInitOptions", () => { }); expect(prompts).toEqual([ - " Choose a CDN cache:\n 1. Workers Cache (default)\n 2. Data cache\n CDN cache [1]: ", + " 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]: ", " Pre-render all static routes after build? [y/N]: ", @@ -440,6 +528,24 @@ 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", + ); + }); + + 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", + ); }); }); diff --git a/tests/init.test.ts b/tests/init.test.ts index 280af250a..433645ea9 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", },