@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 +29 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +107 -11
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +132 -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;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
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
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
|
-
|
|
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`.
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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:
|
|
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":"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.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.
|
|
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
|
-
//
|
|
110
|
-
//
|
|
111
|
-
|
|
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
|
-
|
|
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`.
|
|
131
|
-
*
|
|
132
|
-
*
|
|
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:
|
|
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
|
{
|