@ruvyxa/adapter-cloudflare 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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;AAwKD;;;;;;;;;;;;;;;;;;;;;;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 });
@@ -66,16 +117,43 @@ const handler = createHandler({
66
117
  importAction: loadActionModule,
67
118
  pluginHttp: applyPluginHttp,
68
119
  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;
120
+ readPrerendered: async (pathname, revalidate = 60) => {
121
+ // A Worker has no filesystem, so the store is KV and the read is async —
122
+ // which is the whole reason this returned \`null\` before \`readPrerendered\`
123
+ // was allowed to be asynchronous.
124
+ if (!isrStore) return null;
125
+ const entry = await isrStore.getWithMetadata(isrKey(pathname), { type: 'text' });
126
+ if (entry == null || entry.value == null) return null;
127
+ const storedAt = Number(entry.metadata && entry.metadata.storedAt);
128
+ // An entry with no usable stamp is stale rather than fresh: serving it is
129
+ // still correct, and it schedules the refresh that replaces it.
130
+ const fresh = Number.isFinite(storedAt) && Date.now() - storedAt < revalidate * 1000;
131
+ return { html: entry.value, stale: !fresh };
132
+ },
133
+ writePrerendered: async (pathname, html, revalidate = 60) => {
134
+ if (!isrStore) return;
135
+ await isrStore.put(isrKey(pathname), html, {
136
+ metadata: { storedAt: Date.now() },
137
+ // Kept well past the revalidation window on purpose. Serving a stale
138
+ // document while refreshing behind it is what ISR *is*, so expiring the
139
+ // entry the moment it goes stale would turn every refresh into a blocking
140
+ // render — the opposite of the strategy. KV's own floor is 60 seconds.
141
+ expirationTtl: Math.max(60, Math.round(revalidate * STALE_RETENTION_FACTOR)),
142
+ });
73
143
  },
74
- supportedStrategies: ['ssr', 'ssg', 'csr', 'api'],
144
+ // The project's own not-found page, pre-rendered by the build and carried
145
+ // inline in the manifest: an unmatched URL is answered with the page the
146
+ // application actually wrote, on every host.
147
+ notFoundDocument: manifest.notFoundDocument,
148
+ supportedStrategies: ${JSON.stringify(workerStrategies(kvBinding))},
75
149
  });
76
150
 
77
151
  export default {
78
152
  async fetch(request, env, ctx) {
153
+ // Bindings arrive with the request while the handler is built once at
154
+ // module scope, so the store is captured here and read by the closures
155
+ // above. An isolate serves many requests; this assignment is idempotent.
156
+ isrStore = ${kvBinding ? `env.${kvBinding} ?? null` : 'null'};
79
157
  // The runtime context carries waitUntil, which the shared handler uses to
80
158
  // finish background work after the response is returned.
81
159
  return handler(request, ctx);
@@ -87,9 +165,14 @@ export default {
87
165
  * Create a Cloudflare Workers deployment adapter for Ruvyxa.
88
166
  *
89
167
  * 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.
168
+ * `wrangler`. SSR, API routes, SSG, and CSR always work.
169
+ *
170
+ * ISR and PPR need somewhere to keep a revalidated document, and a Worker has
171
+ * no filesystem — so they are available exactly when the project names a
172
+ * Workers KV binding through `isr.kvBinding`, and refused with `RUV2202` when
173
+ * it does not. The capability is declared from the option rather than assumed,
174
+ * because a Worker that re-renders every request while reporting a cache hit is
175
+ * worse than a build that stops.
93
176
  *
94
177
  * @example
95
178
  * ```ts
@@ -108,10 +191,20 @@ export function cloudflare(options = {}) {
108
191
  if (options.workerEntry !== undefined && options.workerEntry.trim() === '') {
109
192
  throw new Error(`[RUV2001] cloudflareAdapter: "workerEntry" must not be an empty string`);
110
193
  }
194
+ const kvBinding = options.isr?.kvBinding ?? null;
195
+ if (options.isr !== undefined && (typeof kvBinding !== 'string' || kvBinding.trim() === '')) {
196
+ throw new Error(`[RUV2001] cloudflareAdapter: "isr.kvBinding" must be a non-empty string naming a Workers KV binding`);
197
+ }
198
+ // A binding is a JavaScript identifier on `env`, so a name that is not one
199
+ // would emit a Worker that does not parse — caught here rather than by
200
+ // `wrangler` after the build has already claimed success.
201
+ if (kvBinding !== null && !/^[A-Za-z_$][\w$]*$/.test(kvBinding)) {
202
+ throw new Error(`[RUV2001] cloudflareAdapter: "isr.kvBinding" must be a valid identifier, got ${JSON.stringify(kvBinding)}`);
203
+ }
111
204
  return {
112
205
  name: 'cloudflare',
113
206
  target: 'edge',
114
- supports: ['ssr', 'ssg', 'csr', 'api'],
207
+ supports: workerStrategies(kvBinding),
115
208
  build(ctx) {
116
209
  validateBuildContext(ctx, 'cloudflareAdapter');
117
210
  const compatDate = options.compatibilityDate ?? DEFAULT_COMPATIBILITY_DATE;
@@ -125,6 +218,7 @@ export function cloudflare(options = {}) {
125
218
  compatibility_date: compatDate,
126
219
  compatibility_flags: COMPATIBILITY_FLAGS,
127
220
  assets: { directory: './assets' },
221
+ ...kvNamespaces(kvBinding),
128
222
  }, null, 2);
129
223
  const projectWranglerConfig = JSON.stringify({
130
224
  name: 'ruvyxa-app',
@@ -132,6 +226,7 @@ export function cloudflare(options = {}) {
132
226
  compatibility_date: compatDate,
133
227
  compatibility_flags: COMPATIBILITY_FLAGS,
134
228
  assets: { directory: `${relativeOutDir}/deploy/cloudflare/assets` },
229
+ ...kvNamespaces(kvBinding),
135
230
  }, null, 2);
136
231
  return {
137
232
  name: 'cloudflare',
@@ -151,7 +246,7 @@ export function cloudflare(options = {}) {
151
246
  {
152
247
  kind: 'function',
153
248
  path: 'deploy/cloudflare/worker',
154
- handlerSource: workerHandlerSource(runtimePolicy),
249
+ handlerSource: workerHandlerSource(runtimePolicy, kvBinding),
155
250
  },
156
251
  // Wrangler config pointing at the Worker + assets
157
252
  {
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.1",
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.1"
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 });
@@ -105,16 +176,43 @@ const handler = createHandler({
105
176
  importAction: loadActionModule,
106
177
  pluginHttp: applyPluginHttp,
107
178
  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;
179
+ readPrerendered: async (pathname, revalidate = 60) => {
180
+ // A Worker has no filesystem, so the store is KV and the read is async —
181
+ // which is the whole reason this returned \`null\` before \`readPrerendered\`
182
+ // was allowed to be asynchronous.
183
+ if (!isrStore) return null;
184
+ const entry = await isrStore.getWithMetadata(isrKey(pathname), { type: 'text' });
185
+ if (entry == null || entry.value == null) return null;
186
+ const storedAt = Number(entry.metadata && entry.metadata.storedAt);
187
+ // An entry with no usable stamp is stale rather than fresh: serving it is
188
+ // still correct, and it schedules the refresh that replaces it.
189
+ const fresh = Number.isFinite(storedAt) && Date.now() - storedAt < revalidate * 1000;
190
+ return { html: entry.value, stale: !fresh };
191
+ },
192
+ writePrerendered: async (pathname, html, revalidate = 60) => {
193
+ if (!isrStore) return;
194
+ await isrStore.put(isrKey(pathname), html, {
195
+ metadata: { storedAt: Date.now() },
196
+ // Kept well past the revalidation window on purpose. Serving a stale
197
+ // document while refreshing behind it is what ISR *is*, so expiring the
198
+ // entry the moment it goes stale would turn every refresh into a blocking
199
+ // render — the opposite of the strategy. KV's own floor is 60 seconds.
200
+ expirationTtl: Math.max(60, Math.round(revalidate * STALE_RETENTION_FACTOR)),
201
+ });
112
202
  },
113
- supportedStrategies: ['ssr', 'ssg', 'csr', 'api'],
203
+ // The project's own not-found page, pre-rendered by the build and carried
204
+ // inline in the manifest: an unmatched URL is answered with the page the
205
+ // application actually wrote, on every host.
206
+ notFoundDocument: manifest.notFoundDocument,
207
+ supportedStrategies: ${JSON.stringify(workerStrategies(kvBinding))},
114
208
  });
115
209
 
116
210
  export default {
117
211
  async fetch(request, env, ctx) {
212
+ // Bindings arrive with the request while the handler is built once at
213
+ // module scope, so the store is captured here and read by the closures
214
+ // above. An isolate serves many requests; this assignment is idempotent.
215
+ isrStore = ${kvBinding ? `env.${kvBinding} ?? null` : 'null'};
118
216
  // The runtime context carries waitUntil, which the shared handler uses to
119
217
  // finish background work after the response is returned.
120
218
  return handler(request, ctx);
@@ -127,9 +225,14 @@ export default {
127
225
  * Create a Cloudflare Workers deployment adapter for Ruvyxa.
128
226
  *
129
227
  * 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.
228
+ * `wrangler`. SSR, API routes, SSG, and CSR always work.
229
+ *
230
+ * ISR and PPR need somewhere to keep a revalidated document, and a Worker has
231
+ * no filesystem — so they are available exactly when the project names a
232
+ * Workers KV binding through `isr.kvBinding`, and refused with `RUV2202` when
233
+ * it does not. The capability is declared from the option rather than assumed,
234
+ * because a Worker that re-renders every request while reporting a cache hit is
235
+ * worse than a build that stops.
133
236
  *
134
237
  * @example
135
238
  * ```ts
@@ -152,10 +255,25 @@ export function cloudflare(options: CloudflareAdapterOptions = {}): Adapter {
152
255
  throw new Error(`[RUV2001] cloudflareAdapter: "workerEntry" must not be an empty string`)
153
256
  }
154
257
 
258
+ const kvBinding = options.isr?.kvBinding ?? null
259
+ if (options.isr !== undefined && (typeof kvBinding !== 'string' || kvBinding.trim() === '')) {
260
+ throw new Error(
261
+ `[RUV2001] cloudflareAdapter: "isr.kvBinding" must be a non-empty string naming a Workers KV binding`,
262
+ )
263
+ }
264
+ // A binding is a JavaScript identifier on `env`, so a name that is not one
265
+ // would emit a Worker that does not parse — caught here rather than by
266
+ // `wrangler` after the build has already claimed success.
267
+ if (kvBinding !== null && !/^[A-Za-z_$][\w$]*$/.test(kvBinding)) {
268
+ throw new Error(
269
+ `[RUV2001] cloudflareAdapter: "isr.kvBinding" must be a valid identifier, got ${JSON.stringify(kvBinding)}`,
270
+ )
271
+ }
272
+
155
273
  return {
156
274
  name: 'cloudflare',
157
275
  target: 'edge',
158
- supports: ['ssr', 'ssg', 'csr', 'api'],
276
+ supports: workerStrategies(kvBinding),
159
277
  build(ctx: BuildContext): AdapterOutput {
160
278
  validateBuildContext(ctx, 'cloudflareAdapter')
161
279
 
@@ -172,6 +290,7 @@ export function cloudflare(options: CloudflareAdapterOptions = {}): Adapter {
172
290
  compatibility_date: compatDate,
173
291
  compatibility_flags: COMPATIBILITY_FLAGS,
174
292
  assets: { directory: './assets' },
293
+ ...kvNamespaces(kvBinding),
175
294
  },
176
295
  null,
177
296
  2,
@@ -184,6 +303,7 @@ export function cloudflare(options: CloudflareAdapterOptions = {}): Adapter {
184
303
  compatibility_date: compatDate,
185
304
  compatibility_flags: COMPATIBILITY_FLAGS,
186
305
  assets: { directory: `${relativeOutDir}/deploy/cloudflare/assets` },
306
+ ...kvNamespaces(kvBinding),
187
307
  },
188
308
  null,
189
309
  2,
@@ -207,7 +327,7 @@ export function cloudflare(options: CloudflareAdapterOptions = {}): Adapter {
207
327
  {
208
328
  kind: 'function',
209
329
  path: 'deploy/cloudflare/worker',
210
- handlerSource: workerHandlerSource(runtimePolicy),
330
+ handlerSource: workerHandlerSource(runtimePolicy, kvBinding),
211
331
  },
212
332
  // Wrangler config pointing at the Worker + assets
213
333
  {