@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 +29 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +106 -11
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +131 -11
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`.
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
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
|
-
|
|
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`.
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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:
|
|
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":"AACA,OAAO,EACL,iBAAiB,EACjB,mBAAmB,EACnB,qBAAqB,EACrB,kBAAkB,EAClB,oBAAoB,GACrB,MAAM,cAAc,CAAA;
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
110
|
-
//
|
|
111
|
-
|
|
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
|
-
|
|
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`.
|
|
131
|
-
*
|
|
132
|
-
*
|
|
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:
|
|
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
|
{
|