@ruvyxa/adapter-cloudflare 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -26,14 +26,40 @@ export interface CloudflareAdapterOptions {
26
26
  * `wrangler`. Raise it deliberately after checking Cloudflare's changelog.
27
27
  */
28
28
  compatibilityDate?: string;
29
+ /**
30
+ * Workers KV namespace binding that stores revalidated documents, which is
31
+ * what lets this adapter serve ISR and PPR.
32
+ *
33
+ * Off by default, and the capability follows it: with no binding the adapter
34
+ * declares it does not support `isr` or `ppr`, so a project using either is
35
+ * refused at build time with `RUV2202` rather than deployed to a Worker that
36
+ * would re-render every request and call it a cache.
37
+ *
38
+ * Create the namespace once (`wrangler kv namespace create RUVYXA_ISR`), then
39
+ * name the binding here. The generated `wrangler.jsonc` declares it, but the
40
+ * namespace id is the project's to fill in — it is account-specific and must
41
+ * not be baked into a generated file.
42
+ *
43
+ * ```ts
44
+ * cloudflareAdapter({ isr: { kvBinding: 'RUVYXA_ISR' } })
45
+ * ```
46
+ */
47
+ isr?: {
48
+ kvBinding: string;
49
+ };
29
50
  }
30
51
  /**
31
52
  * Create a Cloudflare Workers deployment adapter for Ruvyxa.
32
53
  *
33
54
  * Produces a Worker fetch handler and static assets for deployment via
34
- * `wrangler`. Supports SSR, API routes, SSG, and CSR. ISR and PPR are
35
- * rejected with RUV2210 because they require persistent storage (KV/DO)
36
- * which is not yet integrated.
55
+ * `wrangler`. SSR, API routes, SSG, and CSR always work.
56
+ *
57
+ * ISR and PPR need somewhere to keep a revalidated document, and a Worker has
58
+ * no filesystem — so they are available exactly when the project names a
59
+ * Workers KV binding through `isr.kvBinding`, and refused with `RUV2202` when
60
+ * it does not. The capability is declared from the option rather than assumed,
61
+ * because a Worker that re-renders every request while reporting a cache hit is
62
+ * worse than a build that stops.
37
63
  *
38
64
  * @example
39
65
  * ```ts
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAgD,MAAM,cAAc,CAAA;AASzF;;GAEG;AACH,MAAM,WAAW,wBAAwB;IACvC,0EAA0E;IAC1E,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;;;;;;OASG;IACH,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB;;;;;;;;OAQG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAA;CAC3B;AAyFD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,UAAU,CAAC,OAAO,GAAE,wBAA6B,GAAG,OAAO,CAkG1E;eAEc,UAAU"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAgD,MAAM,cAAc,CAAA;AASzF;;GAEG;AACH,MAAM,WAAW,wBAAwB;IACvC,0EAA0E;IAC1E,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;;;;;;OASG;IACH,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB;;;;;;;;OAQG;IACH,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B;;;;;;;;;;;;;;;;;OAiBG;IACH,GAAG,CAAC,EAAE;QAAE,SAAS,EAAE,MAAM,CAAA;KAAE,CAAA;CAC5B;AAyKD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,UAAU,CAAC,OAAO,GAAE,wBAA6B,GAAG,OAAO,CAmH1E;eAEc,UAAU"}
package/dist/index.js CHANGED
@@ -20,6 +20,35 @@ const DEFAULT_COMPATIBILITY_DATE = '2025-09-01';
20
20
  * The flag stays harmless once the date is raised past the cutoff.
21
21
  */
22
22
  const COMPATIBILITY_FLAGS = ['nodejs_compat'];
23
+ /**
24
+ * The `kv_namespaces` stanza a wrangler config carries when ISR is configured.
25
+ *
26
+ * `id` is left as a placeholder on purpose: a namespace id belongs to one
27
+ * Cloudflare account, so baking a real one into a generated file would either
28
+ * leak it or hand every reader a namespace that is not theirs. `wrangler kv
29
+ * namespace create <binding>` prints the id to paste in.
30
+ */
31
+ function kvNamespaces(kvBinding) {
32
+ if (!kvBinding)
33
+ return {};
34
+ return {
35
+ kv_namespaces: [
36
+ { binding: kvBinding, id: '<run: wrangler kv namespace create ' + kvBinding + '>' },
37
+ ],
38
+ };
39
+ }
40
+ /**
41
+ * The strategies a Worker can answer, which is decided by whether the project
42
+ * gave this adapter somewhere to store a revalidated document.
43
+ *
44
+ * Declared rather than guessed. Claiming ISR with no store would deploy a
45
+ * Worker that re-renders every request and reports `x-ruvyxa-isr: HIT`, which
46
+ * is worse than refusing the build.
47
+ */
48
+ function workerStrategies(kvBinding) {
49
+ const base = ['ssr', 'ssg', 'csr', 'api'];
50
+ return kvBinding ? [...base, 'isr', 'ppr'] : base;
51
+ }
23
52
  /**
24
53
  * Worker fetch handler source code.
25
54
  *
@@ -30,7 +59,7 @@ const COMPATIBILITY_FLAGS = ['nodejs_compat'];
30
59
  * Static assets (client bundles, pre-rendered pages for SSG/CSR) are served
31
60
  * by Cloudflare's `assets` binding; the Worker only handles dynamic routes.
32
61
  */
33
- function workerHandlerSource(runtimePolicy) {
62
+ function workerHandlerSource(runtimePolicy, kvBinding) {
34
63
  return `import { createHandler } from './serverless-handler.mjs';
35
64
  import { applyPluginHttp, loadActionModule, loadRouteModule } from './route-modules.mjs';
36
65
  // A JS module, not a JSON import: import attributes for JSON are not uniformly
@@ -39,6 +68,28 @@ import manifest from './manifest.mjs';
39
68
 
40
69
  const runtimePolicy = ${JSON.stringify(runtimePolicy ?? {})};
41
70
 
71
+ /**
72
+ * The KV namespace revalidated documents are stored in, once a request has
73
+ * handed the bindings over. Null when the project configured no namespace, in
74
+ * which case the adapter also declared it does not support ISR or PPR, so
75
+ * nothing reaches the reader.
76
+ */
77
+ let isrStore = null;
78
+
79
+ /**
80
+ * How much longer than its revalidation window a document is kept.
81
+ *
82
+ * ISR answers from a stale copy while it refreshes behind the response, so the
83
+ * entry has to outlive the moment it goes stale — dropping it on the TTL would
84
+ * make every refresh a blocking render.
85
+ */
86
+ const STALE_RETENTION_FACTOR = 10;
87
+
88
+ /** A document's key. Prefixed so the namespace can hold other things safely. */
89
+ function isrKey(pathname) {
90
+ return \`isr:\${pathname}\`;
91
+ }
92
+
42
93
  async function optimizeImage(request, { src, width, quality }) {
43
94
  if (width > (runtimePolicy.image?.maxWidth ?? 3840)) {
44
95
  return new Response('Image width exceeds configured maximum', { status: 400 });
@@ -61,21 +112,49 @@ const handler = createHandler({
61
112
  middleware: runtimePolicy.middleware,
62
113
  i18n: manifest.i18n,
63
114
  optimizeImage: runtimePolicy.image?.onDemand === true ? optimizeImage : undefined,
115
+ imageQuality: runtimePolicy.image?.quality,
64
116
  importPage: loadRouteModule,
65
117
  importApi: loadRouteModule,
66
118
  importAction: loadActionModule,
67
119
  pluginHttp: applyPluginHttp,
68
120
  security: runtimePolicy.security,
69
- readPrerendered: (pathname) => {
70
- // In Workers, pre-rendered pages are served as static assets.
71
- // ISR revalidation requires KV or Durable Objects (not yet supported).
72
- return null;
121
+ readPrerendered: async (pathname, revalidate = 60) => {
122
+ // A Worker has no filesystem, so the store is KV and the read is async —
123
+ // which is the whole reason this returned \`null\` before \`readPrerendered\`
124
+ // was allowed to be asynchronous.
125
+ if (!isrStore) return null;
126
+ const entry = await isrStore.getWithMetadata(isrKey(pathname), { type: 'text' });
127
+ if (entry == null || entry.value == null) return null;
128
+ const storedAt = Number(entry.metadata && entry.metadata.storedAt);
129
+ // An entry with no usable stamp is stale rather than fresh: serving it is
130
+ // still correct, and it schedules the refresh that replaces it.
131
+ const fresh = Number.isFinite(storedAt) && Date.now() - storedAt < revalidate * 1000;
132
+ return { html: entry.value, stale: !fresh };
133
+ },
134
+ writePrerendered: async (pathname, html, revalidate = 60) => {
135
+ if (!isrStore) return;
136
+ await isrStore.put(isrKey(pathname), html, {
137
+ metadata: { storedAt: Date.now() },
138
+ // Kept well past the revalidation window on purpose. Serving a stale
139
+ // document while refreshing behind it is what ISR *is*, so expiring the
140
+ // entry the moment it goes stale would turn every refresh into a blocking
141
+ // render — the opposite of the strategy. KV's own floor is 60 seconds.
142
+ expirationTtl: Math.max(60, Math.round(revalidate * STALE_RETENTION_FACTOR)),
143
+ });
73
144
  },
74
- supportedStrategies: ['ssr', 'ssg', 'csr', 'api'],
145
+ // The project's own not-found page, pre-rendered by the build and carried
146
+ // inline in the manifest: an unmatched URL is answered with the page the
147
+ // application actually wrote, on every host.
148
+ notFoundDocument: manifest.notFoundDocument,
149
+ supportedStrategies: ${JSON.stringify(workerStrategies(kvBinding))},
75
150
  });
76
151
 
77
152
  export default {
78
153
  async fetch(request, env, ctx) {
154
+ // Bindings arrive with the request while the handler is built once at
155
+ // module scope, so the store is captured here and read by the closures
156
+ // above. An isolate serves many requests; this assignment is idempotent.
157
+ isrStore = ${kvBinding ? `env.${kvBinding} ?? null` : 'null'};
79
158
  // The runtime context carries waitUntil, which the shared handler uses to
80
159
  // finish background work after the response is returned.
81
160
  return handler(request, ctx);
@@ -87,9 +166,14 @@ export default {
87
166
  * Create a Cloudflare Workers deployment adapter for Ruvyxa.
88
167
  *
89
168
  * Produces a Worker fetch handler and static assets for deployment via
90
- * `wrangler`. Supports SSR, API routes, SSG, and CSR. ISR and PPR are
91
- * rejected with RUV2210 because they require persistent storage (KV/DO)
92
- * which is not yet integrated.
169
+ * `wrangler`. SSR, API routes, SSG, and CSR always work.
170
+ *
171
+ * ISR and PPR need somewhere to keep a revalidated document, and a Worker has
172
+ * no filesystem — so they are available exactly when the project names a
173
+ * Workers KV binding through `isr.kvBinding`, and refused with `RUV2202` when
174
+ * it does not. The capability is declared from the option rather than assumed,
175
+ * because a Worker that re-renders every request while reporting a cache hit is
176
+ * worse than a build that stops.
93
177
  *
94
178
  * @example
95
179
  * ```ts
@@ -108,10 +192,20 @@ export function cloudflare(options = {}) {
108
192
  if (options.workerEntry !== undefined && options.workerEntry.trim() === '') {
109
193
  throw new Error(`[RUV2001] cloudflareAdapter: "workerEntry" must not be an empty string`);
110
194
  }
195
+ const kvBinding = options.isr?.kvBinding ?? null;
196
+ if (options.isr !== undefined && (typeof kvBinding !== 'string' || kvBinding.trim() === '')) {
197
+ throw new Error(`[RUV2001] cloudflareAdapter: "isr.kvBinding" must be a non-empty string naming a Workers KV binding`);
198
+ }
199
+ // A binding is a JavaScript identifier on `env`, so a name that is not one
200
+ // would emit a Worker that does not parse — caught here rather than by
201
+ // `wrangler` after the build has already claimed success.
202
+ if (kvBinding !== null && !/^[A-Za-z_$][\w$]*$/.test(kvBinding)) {
203
+ throw new Error(`[RUV2001] cloudflareAdapter: "isr.kvBinding" must be a valid identifier, got ${JSON.stringify(kvBinding)}`);
204
+ }
111
205
  return {
112
206
  name: 'cloudflare',
113
207
  target: 'edge',
114
- supports: ['ssr', 'ssg', 'csr', 'api'],
208
+ supports: workerStrategies(kvBinding),
115
209
  build(ctx) {
116
210
  validateBuildContext(ctx, 'cloudflareAdapter');
117
211
  const compatDate = options.compatibilityDate ?? DEFAULT_COMPATIBILITY_DATE;
@@ -125,6 +219,7 @@ export function cloudflare(options = {}) {
125
219
  compatibility_date: compatDate,
126
220
  compatibility_flags: COMPATIBILITY_FLAGS,
127
221
  assets: { directory: './assets' },
222
+ ...kvNamespaces(kvBinding),
128
223
  }, null, 2);
129
224
  const projectWranglerConfig = JSON.stringify({
130
225
  name: 'ruvyxa-app',
@@ -132,6 +227,7 @@ export function cloudflare(options = {}) {
132
227
  compatibility_date: compatDate,
133
228
  compatibility_flags: COMPATIBILITY_FLAGS,
134
229
  assets: { directory: `${relativeOutDir}/deploy/cloudflare/assets` },
230
+ ...kvNamespaces(kvBinding),
135
231
  }, null, 2);
136
232
  return {
137
233
  name: 'cloudflare',
@@ -151,7 +247,7 @@ export function cloudflare(options = {}) {
151
247
  {
152
248
  kind: 'function',
153
249
  path: 'deploy/cloudflare/worker',
154
- handlerSource: workerHandlerSource(runtimePolicy),
250
+ handlerSource: workerHandlerSource(runtimePolicy, kvBinding),
155
251
  },
156
252
  // Wrangler config pointing at the Worker + assets
157
253
  {
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ruvyxa/adapter-cloudflare",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Shape Ruvyxa builds for Cloudflare edge targets with typed worker and asset output metadata.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -59,7 +59,7 @@
59
59
  }
60
60
  },
61
61
  "dependencies": {
62
- "@ruvyxa/core": "^1.1.0"
62
+ "@ruvyxa/core": "^1.1.2"
63
63
  },
64
64
  "scripts": {
65
65
  "build": "tsc -p tsconfig.json",
package/src/index.ts CHANGED
@@ -34,6 +34,25 @@ export interface CloudflareAdapterOptions {
34
34
  * `wrangler`. Raise it deliberately after checking Cloudflare's changelog.
35
35
  */
36
36
  compatibilityDate?: string
37
+ /**
38
+ * Workers KV namespace binding that stores revalidated documents, which is
39
+ * what lets this adapter serve ISR and PPR.
40
+ *
41
+ * Off by default, and the capability follows it: with no binding the adapter
42
+ * declares it does not support `isr` or `ppr`, so a project using either is
43
+ * refused at build time with `RUV2202` rather than deployed to a Worker that
44
+ * would re-render every request and call it a cache.
45
+ *
46
+ * Create the namespace once (`wrangler kv namespace create RUVYXA_ISR`), then
47
+ * name the binding here. The generated `wrangler.jsonc` declares it, but the
48
+ * namespace id is the project's to fill in — it is account-specific and must
49
+ * not be baked into a generated file.
50
+ *
51
+ * ```ts
52
+ * cloudflareAdapter({ isr: { kvBinding: 'RUVYXA_ISR' } })
53
+ * ```
54
+ */
55
+ isr?: { kvBinding: string }
37
56
  }
38
57
 
39
58
  /**
@@ -59,6 +78,36 @@ const DEFAULT_COMPATIBILITY_DATE = '2025-09-01'
59
78
  */
60
79
  const COMPATIBILITY_FLAGS = ['nodejs_compat']
61
80
 
81
+ /**
82
+ * The `kv_namespaces` stanza a wrangler config carries when ISR is configured.
83
+ *
84
+ * `id` is left as a placeholder on purpose: a namespace id belongs to one
85
+ * Cloudflare account, so baking a real one into a generated file would either
86
+ * leak it or hand every reader a namespace that is not theirs. `wrangler kv
87
+ * namespace create <binding>` prints the id to paste in.
88
+ */
89
+ function kvNamespaces(kvBinding: string | null) {
90
+ if (!kvBinding) return {}
91
+ return {
92
+ kv_namespaces: [
93
+ { binding: kvBinding, id: '<run: wrangler kv namespace create ' + kvBinding + '>' },
94
+ ],
95
+ }
96
+ }
97
+
98
+ /**
99
+ * The strategies a Worker can answer, which is decided by whether the project
100
+ * gave this adapter somewhere to store a revalidated document.
101
+ *
102
+ * Declared rather than guessed. Claiming ISR with no store would deploy a
103
+ * Worker that re-renders every request and reports `x-ruvyxa-isr: HIT`, which
104
+ * is worse than refusing the build.
105
+ */
106
+ function workerStrategies(kvBinding: string | null): Adapter['supports'] {
107
+ const base: Adapter['supports'] = ['ssr', 'ssg', 'csr', 'api']
108
+ return kvBinding ? [...base, 'isr', 'ppr'] : base
109
+ }
110
+
62
111
  /**
63
112
  * Worker fetch handler source code.
64
113
  *
@@ -69,7 +118,7 @@ const COMPATIBILITY_FLAGS = ['nodejs_compat']
69
118
  * Static assets (client bundles, pre-rendered pages for SSG/CSR) are served
70
119
  * by Cloudflare's `assets` binding; the Worker only handles dynamic routes.
71
120
  */
72
- function workerHandlerSource(runtimePolicy: unknown): string {
121
+ function workerHandlerSource(runtimePolicy: unknown, kvBinding: string | null): string {
73
122
  return `import { createHandler } from './serverless-handler.mjs';
74
123
  import { applyPluginHttp, loadActionModule, loadRouteModule } from './route-modules.mjs';
75
124
  // A JS module, not a JSON import: import attributes for JSON are not uniformly
@@ -78,6 +127,28 @@ import manifest from './manifest.mjs';
78
127
 
79
128
  const runtimePolicy = ${JSON.stringify(runtimePolicy ?? {})};
80
129
 
130
+ /**
131
+ * The KV namespace revalidated documents are stored in, once a request has
132
+ * handed the bindings over. Null when the project configured no namespace, in
133
+ * which case the adapter also declared it does not support ISR or PPR, so
134
+ * nothing reaches the reader.
135
+ */
136
+ let isrStore = null;
137
+
138
+ /**
139
+ * How much longer than its revalidation window a document is kept.
140
+ *
141
+ * ISR answers from a stale copy while it refreshes behind the response, so the
142
+ * entry has to outlive the moment it goes stale — dropping it on the TTL would
143
+ * make every refresh a blocking render.
144
+ */
145
+ const STALE_RETENTION_FACTOR = 10;
146
+
147
+ /** A document's key. Prefixed so the namespace can hold other things safely. */
148
+ function isrKey(pathname) {
149
+ return \`isr:\${pathname}\`;
150
+ }
151
+
81
152
  async function optimizeImage(request, { src, width, quality }) {
82
153
  if (width > (runtimePolicy.image?.maxWidth ?? 3840)) {
83
154
  return new Response('Image width exceeds configured maximum', { status: 400 });
@@ -100,21 +171,49 @@ const handler = createHandler({
100
171
  middleware: runtimePolicy.middleware,
101
172
  i18n: manifest.i18n,
102
173
  optimizeImage: runtimePolicy.image?.onDemand === true ? optimizeImage : undefined,
174
+ imageQuality: runtimePolicy.image?.quality,
103
175
  importPage: loadRouteModule,
104
176
  importApi: loadRouteModule,
105
177
  importAction: loadActionModule,
106
178
  pluginHttp: applyPluginHttp,
107
179
  security: runtimePolicy.security,
108
- readPrerendered: (pathname) => {
109
- // In Workers, pre-rendered pages are served as static assets.
110
- // ISR revalidation requires KV or Durable Objects (not yet supported).
111
- return null;
180
+ readPrerendered: async (pathname, revalidate = 60) => {
181
+ // A Worker has no filesystem, so the store is KV and the read is async —
182
+ // which is the whole reason this returned \`null\` before \`readPrerendered\`
183
+ // was allowed to be asynchronous.
184
+ if (!isrStore) return null;
185
+ const entry = await isrStore.getWithMetadata(isrKey(pathname), { type: 'text' });
186
+ if (entry == null || entry.value == null) return null;
187
+ const storedAt = Number(entry.metadata && entry.metadata.storedAt);
188
+ // An entry with no usable stamp is stale rather than fresh: serving it is
189
+ // still correct, and it schedules the refresh that replaces it.
190
+ const fresh = Number.isFinite(storedAt) && Date.now() - storedAt < revalidate * 1000;
191
+ return { html: entry.value, stale: !fresh };
192
+ },
193
+ writePrerendered: async (pathname, html, revalidate = 60) => {
194
+ if (!isrStore) return;
195
+ await isrStore.put(isrKey(pathname), html, {
196
+ metadata: { storedAt: Date.now() },
197
+ // Kept well past the revalidation window on purpose. Serving a stale
198
+ // document while refreshing behind it is what ISR *is*, so expiring the
199
+ // entry the moment it goes stale would turn every refresh into a blocking
200
+ // render — the opposite of the strategy. KV's own floor is 60 seconds.
201
+ expirationTtl: Math.max(60, Math.round(revalidate * STALE_RETENTION_FACTOR)),
202
+ });
112
203
  },
113
- supportedStrategies: ['ssr', 'ssg', 'csr', 'api'],
204
+ // The project's own not-found page, pre-rendered by the build and carried
205
+ // inline in the manifest: an unmatched URL is answered with the page the
206
+ // application actually wrote, on every host.
207
+ notFoundDocument: manifest.notFoundDocument,
208
+ supportedStrategies: ${JSON.stringify(workerStrategies(kvBinding))},
114
209
  });
115
210
 
116
211
  export default {
117
212
  async fetch(request, env, ctx) {
213
+ // Bindings arrive with the request while the handler is built once at
214
+ // module scope, so the store is captured here and read by the closures
215
+ // above. An isolate serves many requests; this assignment is idempotent.
216
+ isrStore = ${kvBinding ? `env.${kvBinding} ?? null` : 'null'};
118
217
  // The runtime context carries waitUntil, which the shared handler uses to
119
218
  // finish background work after the response is returned.
120
219
  return handler(request, ctx);
@@ -127,9 +226,14 @@ export default {
127
226
  * Create a Cloudflare Workers deployment adapter for Ruvyxa.
128
227
  *
129
228
  * Produces a Worker fetch handler and static assets for deployment via
130
- * `wrangler`. Supports SSR, API routes, SSG, and CSR. ISR and PPR are
131
- * rejected with RUV2210 because they require persistent storage (KV/DO)
132
- * which is not yet integrated.
229
+ * `wrangler`. SSR, API routes, SSG, and CSR always work.
230
+ *
231
+ * ISR and PPR need somewhere to keep a revalidated document, and a Worker has
232
+ * no filesystem — so they are available exactly when the project names a
233
+ * Workers KV binding through `isr.kvBinding`, and refused with `RUV2202` when
234
+ * it does not. The capability is declared from the option rather than assumed,
235
+ * because a Worker that re-renders every request while reporting a cache hit is
236
+ * worse than a build that stops.
133
237
  *
134
238
  * @example
135
239
  * ```ts
@@ -152,10 +256,25 @@ export function cloudflare(options: CloudflareAdapterOptions = {}): Adapter {
152
256
  throw new Error(`[RUV2001] cloudflareAdapter: "workerEntry" must not be an empty string`)
153
257
  }
154
258
 
259
+ const kvBinding = options.isr?.kvBinding ?? null
260
+ if (options.isr !== undefined && (typeof kvBinding !== 'string' || kvBinding.trim() === '')) {
261
+ throw new Error(
262
+ `[RUV2001] cloudflareAdapter: "isr.kvBinding" must be a non-empty string naming a Workers KV binding`,
263
+ )
264
+ }
265
+ // A binding is a JavaScript identifier on `env`, so a name that is not one
266
+ // would emit a Worker that does not parse — caught here rather than by
267
+ // `wrangler` after the build has already claimed success.
268
+ if (kvBinding !== null && !/^[A-Za-z_$][\w$]*$/.test(kvBinding)) {
269
+ throw new Error(
270
+ `[RUV2001] cloudflareAdapter: "isr.kvBinding" must be a valid identifier, got ${JSON.stringify(kvBinding)}`,
271
+ )
272
+ }
273
+
155
274
  return {
156
275
  name: 'cloudflare',
157
276
  target: 'edge',
158
- supports: ['ssr', 'ssg', 'csr', 'api'],
277
+ supports: workerStrategies(kvBinding),
159
278
  build(ctx: BuildContext): AdapterOutput {
160
279
  validateBuildContext(ctx, 'cloudflareAdapter')
161
280
 
@@ -172,6 +291,7 @@ export function cloudflare(options: CloudflareAdapterOptions = {}): Adapter {
172
291
  compatibility_date: compatDate,
173
292
  compatibility_flags: COMPATIBILITY_FLAGS,
174
293
  assets: { directory: './assets' },
294
+ ...kvNamespaces(kvBinding),
175
295
  },
176
296
  null,
177
297
  2,
@@ -184,6 +304,7 @@ export function cloudflare(options: CloudflareAdapterOptions = {}): Adapter {
184
304
  compatibility_date: compatDate,
185
305
  compatibility_flags: COMPATIBILITY_FLAGS,
186
306
  assets: { directory: `${relativeOutDir}/deploy/cloudflare/assets` },
307
+ ...kvNamespaces(kvBinding),
187
308
  },
188
309
  null,
189
310
  2,
@@ -207,7 +328,7 @@ export function cloudflare(options: CloudflareAdapterOptions = {}): Adapter {
207
328
  {
208
329
  kind: 'function',
209
330
  path: 'deploy/cloudflare/worker',
210
- handlerSource: workerHandlerSource(runtimePolicy),
331
+ handlerSource: workerHandlerSource(runtimePolicy, kvBinding),
211
332
  },
212
333
  // Wrangler config pointing at the Worker + assets
213
334
  {