@ruvyxa/adapter-netlify 1.1.2 → 1.1.3
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 +24 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +79 -11
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +115 -31
package/dist/index.d.ts
CHANGED
|
@@ -6,23 +6,35 @@ export interface NetlifyAdapterOptions {
|
|
|
6
6
|
/** Custom Netlify functions directory. Defaults to `${outDir}/netlify/functions`. */
|
|
7
7
|
functionsDir?: string;
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
* generated publish directory and
|
|
11
|
-
* `netlify.toml` is never overwritten.
|
|
9
|
+
* Emit a `netlify.toml` at the project root naming the build command, the
|
|
10
|
+
* generated publish directory, the functions directory, and the headers. An
|
|
11
|
+
* existing project `netlify.toml` is never overwritten.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* On by default, because it is the only mechanism that can answer the one
|
|
14
|
+
* question Netlify cannot learn from the build: **the publish directory**.
|
|
15
|
+
* The Frameworks API has no key for it, and a plugin cannot supply it without
|
|
16
|
+
* a `netlify.toml` of its own — Netlify installs a plugin from its UI only
|
|
17
|
+
* when the plugin is listed in Netlify's own directory, and a community
|
|
18
|
+
* package has to be declared in `[[plugins]]` and installed as a
|
|
19
|
+
* devDependency. So the choice is not "config file or no config file"; it is
|
|
20
|
+
* "one generated file, or the same file written by hand".
|
|
21
|
+
*
|
|
22
|
+
* Netlify reads `netlify.toml` *before* running the build command, so this
|
|
23
|
+
* file has to be committed to take effect — the build writes it once and
|
|
24
|
+
* never again.
|
|
25
|
+
* @default true
|
|
18
26
|
*/
|
|
19
27
|
projectConfig?: boolean;
|
|
20
28
|
/**
|
|
21
29
|
* Emit the Netlify Frameworks API directory (`.netlify/v1/`) at the project
|
|
22
|
-
* root during `ruvyxa build`: the SSR/API function and the
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
30
|
+
* root during `ruvyxa build`: the SSR/API function and the cache headers.
|
|
31
|
+
*
|
|
32
|
+
* Defaults to the opposite of {@link projectConfig}, because the two are
|
|
33
|
+
* alternatives rather than layers. Shipping both puts two functions on the
|
|
34
|
+
* site — the one under `.netlify/v1/functions` and the one the `netlify.toml`
|
|
35
|
+
* `functions` directory declares — and both claim `path: '/*'`, so which of
|
|
36
|
+
* them answers a request is not a question with an answer.
|
|
37
|
+
* @default `!projectConfig`
|
|
26
38
|
*/
|
|
27
39
|
frameworksApi?: boolean;
|
|
28
40
|
}
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAgD,MAAM,cAAc,CAAA;AAczF;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACpC,qFAAqF;IACrF,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAgD,MAAM,cAAc,CAAA;AAczF;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACpC,qFAAqF;IACrF,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB;;;;;;;;;;;;;;;;;;OAkBG;IACH,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB;;;;;;;;;;OAUG;IACH,aAAa,CAAC,EAAE,OAAO,CAAA;CACxB;AAiND;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,OAAO,CAAC,OAAO,GAAE,qBAA0B,GAAG,OAAO,CAsJpE;eAEc,OAAO"}
|
package/dist/index.js
CHANGED
|
@@ -14,6 +14,7 @@ import { applyPluginHttp, loadActionModule, loadRouteModule } from './route-modu
|
|
|
14
14
|
// needs must be reachable through the module graph. A sibling manifest.json
|
|
15
15
|
// read from import.meta.dirname is not, and never reaches /var/task.
|
|
16
16
|
import manifest from './manifest.mjs';
|
|
17
|
+
import { createHash } from 'node:crypto';
|
|
17
18
|
import { readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
|
|
18
19
|
import os from 'node:os';
|
|
19
20
|
import path from 'node:path';
|
|
@@ -31,6 +32,41 @@ const prerenderDir = path.join(import.meta.dirname, 'prerender');
|
|
|
31
32
|
// back before the bundled deploy-time prerender output.
|
|
32
33
|
const isrCacheDir = path.join(os.tmpdir(), 'ruvyxa-isr-cache');
|
|
33
34
|
|
|
35
|
+
/**
|
|
36
|
+
* The cache tag a document is stored under, so one path can be dropped from
|
|
37
|
+
* Netlify's cache without dropping the site's.
|
|
38
|
+
*
|
|
39
|
+
* A tag list is comma-separated, so the path has to become one token — and the
|
|
40
|
+
* mapping to that token has to be injective, which folding every run of
|
|
41
|
+
* non-alphanumerics to a single \`-\` is not: \`/a/b\`, \`/a-b\` and \`/a_b\` all
|
|
42
|
+
* produced \`ruvyxa-a-b\`, so \`revalidatePath('/a/b')\` dropped two other pages
|
|
43
|
+
* from the edge as well. A digest is one token, is the same length whatever the
|
|
44
|
+
* path, and cannot collide by construction.
|
|
45
|
+
*/
|
|
46
|
+
function cacheTag(pathname) {
|
|
47
|
+
return 'ruvyxa-' + createHash('sha256').update(pathname).digest('hex').slice(0, 32);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Drop this path from Netlify's edge, which the durable cache in front of this
|
|
52
|
+
* function would otherwise keep serving until its window expired.
|
|
53
|
+
*
|
|
54
|
+
* \`purgeCache\` infers the site and its credentials from the function runtime,
|
|
55
|
+
* so it needs no configuration — but it lives in \`@netlify/functions\`, which a
|
|
56
|
+
* project may not have installed. Imported at the moment it is needed and
|
|
57
|
+
* behind a catch, because a missing optional dependency must not take the whole
|
|
58
|
+
* function down at module load: every page would 500 to make one revalidation
|
|
59
|
+
* work.
|
|
60
|
+
*/
|
|
61
|
+
async function purgeDurableCache(pathname) {
|
|
62
|
+
try {
|
|
63
|
+
const { purgeCache } = await import('@netlify/functions');
|
|
64
|
+
await purgeCache({ tags: [cacheTag(pathname)] });
|
|
65
|
+
} catch (error) {
|
|
66
|
+
console.error('[ruvyxa] could not purge ' + pathname + ' from the Netlify cache:', error);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
34
70
|
const readEntry = (htmlPath, revalidate) => {
|
|
35
71
|
const html = readFileSync(htmlPath, 'utf8');
|
|
36
72
|
const stale = Date.now() - statSync(htmlPath).mtimeMs >= revalidate * 1000;
|
|
@@ -62,12 +98,13 @@ const handler = createHandler({
|
|
|
62
98
|
return null;
|
|
63
99
|
}
|
|
64
100
|
},
|
|
65
|
-
writePrerendered: (pathname, html, revalidate) => {
|
|
101
|
+
writePrerendered: (pathname, html, revalidate, forced) => {
|
|
66
102
|
const relative = prerenderRelativePath(pathname);
|
|
67
103
|
if (relative === null) return;
|
|
68
104
|
const htmlPath = path.join(isrCacheDir, relative);
|
|
69
105
|
mkdirSync(path.dirname(htmlPath), { recursive: true });
|
|
70
106
|
writeFileSync(htmlPath, html, 'utf8');
|
|
107
|
+
if (forced === true) return purgeDurableCache(pathname);
|
|
71
108
|
},
|
|
72
109
|
// The project's own not-found page, pre-rendered by the build and carried
|
|
73
110
|
// inline in the manifest: an unmatched URL is answered with the page the
|
|
@@ -78,7 +115,34 @@ const handler = createHandler({
|
|
|
78
115
|
|
|
79
116
|
// Netlify Functions v2 — Web-standard Request/Response
|
|
80
117
|
export default async function(request, context) {
|
|
81
|
-
|
|
118
|
+
const response = await handler(request, context);
|
|
119
|
+
return withDurableCache(request, response);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Re-state a cacheable answer for Netlify's own cache, durably.
|
|
124
|
+
*
|
|
125
|
+
* \`Cache-Control\` reaches the browser and every CDN; \`Netlify-CDN-Cache-Control\`
|
|
126
|
+
* is read by Netlify alone and is the only place the \`durable\` directive means
|
|
127
|
+
* anything. Durable storage is what makes one render serve every edge node
|
|
128
|
+
* instead of one — and it is what \`purgeCache\` above can invalidate, so the two
|
|
129
|
+
* halves have to be added together or \`revalidatePath()\` purges nothing.
|
|
130
|
+
*
|
|
131
|
+
* Only a shared-cacheable response is re-stated: \`s-maxage\` is the marker the
|
|
132
|
+
* handler already puts on an ISR document, and \`no-store\` on everything a
|
|
133
|
+
* request produced for one visitor.
|
|
134
|
+
*/
|
|
135
|
+
function withDurableCache(request, response) {
|
|
136
|
+
const cacheControl = response.headers.get('cache-control');
|
|
137
|
+
if (!cacheControl || !cacheControl.includes('s-maxage')) return response;
|
|
138
|
+
const headers = new Headers(response.headers);
|
|
139
|
+
headers.set('netlify-cdn-cache-control', 'public, durable, ' + cacheControl);
|
|
140
|
+
headers.set('cache-tag', cacheTag(new URL(request.url).pathname));
|
|
141
|
+
return new Response(response.body, {
|
|
142
|
+
status: response.status,
|
|
143
|
+
statusText: response.statusText,
|
|
144
|
+
headers,
|
|
145
|
+
});
|
|
82
146
|
}
|
|
83
147
|
|
|
84
148
|
// Netlify Functions v2 config
|
|
@@ -93,13 +157,13 @@ function packageVersion() {
|
|
|
93
157
|
}
|
|
94
158
|
return metadata.version;
|
|
95
159
|
}
|
|
96
|
-
function functionConfigFor(
|
|
160
|
+
function functionConfigFor() {
|
|
97
161
|
return {
|
|
98
162
|
path: '/*',
|
|
99
163
|
preferStatic: true,
|
|
100
164
|
name: 'Ruvyxa SSR',
|
|
101
165
|
generator: `ruvyxa/${packageVersion()}`,
|
|
102
|
-
includedFiles: [
|
|
166
|
+
includedFiles: ['prerender/**'],
|
|
103
167
|
};
|
|
104
168
|
}
|
|
105
169
|
/**
|
|
@@ -132,6 +196,10 @@ export function netlify(options = {}) {
|
|
|
132
196
|
supports: ['ssr', 'ssg', 'csr', 'isr', 'ppr', 'api'],
|
|
133
197
|
build(ctx) {
|
|
134
198
|
validateBuildContext(ctx, 'netlifyAdapter');
|
|
199
|
+
// Exactly one of the two ships: see `frameworksApi` above for why both
|
|
200
|
+
// at once is a site with two functions claiming the same path.
|
|
201
|
+
const projectConfig = options.projectConfig !== false;
|
|
202
|
+
const frameworksApi = options.frameworksApi ?? !projectConfig;
|
|
135
203
|
const functionsDir = options.functionsDir ?? `${ctx.outDir}/netlify/functions`;
|
|
136
204
|
// Config files are committed or read on other machines; never embed the
|
|
137
205
|
// absolute build-machine outDir in them.
|
|
@@ -204,7 +272,7 @@ export function netlify(options = {}) {
|
|
|
204
272
|
{
|
|
205
273
|
kind: 'function',
|
|
206
274
|
path: 'deploy/netlify/functions/ruvyxa-handler',
|
|
207
|
-
handlerSource: netlifyHandlerSource(runtimePolicy, functionConfigFor(
|
|
275
|
+
handlerSource: netlifyHandlerSource(runtimePolicy, functionConfigFor()),
|
|
208
276
|
},
|
|
209
277
|
// Netlify config for the deploy directory
|
|
210
278
|
{
|
|
@@ -215,14 +283,13 @@ export function netlify(options = {}) {
|
|
|
215
283
|
// Frameworks API artifacts: Netlify discovers .netlify/v1/ at the
|
|
216
284
|
// project root automatically, so a fresh site needs only the publish
|
|
217
285
|
// directory set once in the dashboard — no committed config file.
|
|
218
|
-
...(
|
|
219
|
-
? [
|
|
220
|
-
: [
|
|
286
|
+
...(frameworksApi
|
|
287
|
+
? [
|
|
221
288
|
{
|
|
222
289
|
kind: 'function',
|
|
223
290
|
path: '.netlify/v1/functions/ruvyxa-handler',
|
|
224
291
|
scope: 'project',
|
|
225
|
-
handlerSource: netlifyHandlerSource(runtimePolicy, functionConfigFor(
|
|
292
|
+
handlerSource: netlifyHandlerSource(runtimePolicy, functionConfigFor()),
|
|
226
293
|
},
|
|
227
294
|
{
|
|
228
295
|
kind: 'file',
|
|
@@ -230,8 +297,9 @@ export function netlify(options = {}) {
|
|
|
230
297
|
scope: 'project',
|
|
231
298
|
contents: frameworksApiConfig + '\n',
|
|
232
299
|
},
|
|
233
|
-
]
|
|
234
|
-
|
|
300
|
+
]
|
|
301
|
+
: []),
|
|
302
|
+
...(projectConfig
|
|
235
303
|
? [
|
|
236
304
|
{
|
|
237
305
|
kind: 'file',
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAA;AAGtC,OAAO,EACL,oBAAoB,EACpB,iBAAiB,EACjB,wBAAwB,EACxB,uBAAuB,EACvB,wBAAwB,EACxB,qBAAqB,EACrB,0BAA0B,EAC1B,gBAAgB,EAChB,kBAAkB,EAClB,oBAAoB,GACrB,MAAM,cAAc,CAAA;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAA;AAGtC,OAAO,EACL,oBAAoB,EACpB,iBAAiB,EACjB,wBAAwB,EACxB,uBAAuB,EACvB,wBAAwB,EACxB,qBAAqB,EACrB,0BAA0B,EAC1B,gBAAgB,EAChB,kBAAkB,EAClB,oBAAoB,GACrB,MAAM,cAAc,CAAA;AA0CrB;;;;;;GAMG;AACH,SAAS,oBAAoB,CAC3B,aAAsB,EACtB,cAAqC;IAErC,OAAO;;;;;;;;;;;wBAWe,IAAI,CAAC,SAAS,CAAC,aAAa,IAAI,EAAE,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;wBA+HnC,IAAI,CAAC,SAAS,CAAC,cAAc,EAAE,IAAI,EAAE,CAAC,CAAC;CAC9D,CAAA;AACD,CAAC;AAiCD,2EAA2E;AAC3E,SAAS,cAAc;IACrB,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CACzB,YAAY,CAAC,IAAI,GAAG,CAAC,iBAAiB,EAAE,OAAO,IAAI,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,CAGlE,CAAA;IACD,IAAI,OAAO,QAAQ,CAAC,OAAO,KAAK,QAAQ,IAAI,CAAC,gBAAgB,CAAC,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC;QACrF,MAAM,IAAI,KAAK,CAAC,yEAAyE,CAAC,CAAA;IAC5F,CAAC;IACD,OAAO,QAAQ,CAAC,OAAO,CAAA;AACzB,CAAC;AAED,SAAS,iBAAiB;IACxB,OAAO;QACL,IAAI,EAAE,IAAI;QACV,YAAY,EAAE,IAAI;QAClB,IAAI,EAAE,YAAY;QAClB,SAAS,EAAE,UAAU,cAAc,EAAE,EAAE;QACvC,aAAa,EAAE,CAAC,cAAc,CAAC;KAChC,CAAA;AACH,CAAC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,OAAO,CAAC,OAAO,GAA0B,EAAE;IACzD,IAAI,OAAO,CAAC,YAAY,KAAK,SAAS,IAAI,OAAO,OAAO,CAAC,YAAY,KAAK,QAAQ,EAAE,CAAC;QACnF,MAAM,IAAI,KAAK,CACb,kEAAkE,OAAO,OAAO,CAAC,YAAY,EAAE,CAChG,CAAA;IACH,CAAC;IAED,IAAI,OAAO,CAAC,YAAY,KAAK,SAAS,IAAI,OAAO,CAAC,YAAY,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC;QAC7E,MAAM,IAAI,KAAK,CAAC,sEAAsE,CAAC,CAAA;IACzF,CAAC;IAED,OAAO;QACL,IAAI,EAAE,SAAS;QACf,MAAM,EAAE,YAAY;QACpB,QAAQ,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,CAAC;QACpD,KAAK,CAAC,GAAiB;YACrB,oBAAoB,CAAC,GAAG,EAAE,gBAAgB,CAAC,CAAA;YAC3C,uEAAuE;YACvE,+DAA+D;YAC/D,MAAM,aAAa,GAAG,OAAO,CAAC,aAAa,KAAK,KAAK,CAAA;YACrD,MAAM,aAAa,GAAG,OAAO,CAAC,aAAa,IAAI,CAAC,aAAa,CAAA;YAC7D,MAAM,YAAY,GAAG,OAAO,CAAC,YAAY,IAAI,GAAG,GAAG,CAAC,MAAM,oBAAoB,CAAA;YAC9E,wEAAwE;YACxE,yCAAyC;YACzC,MAAM,cAAc,GAAG,qBAAqB,CAAC,GAAG,CAAC,CAAA;YACjD,MAAM,aAAa,GAAG,kBAAkB,CAAC,GAAG,CAAC,CAAA;YAE7C,oEAAoE;YACpE,wEAAwE;YACxE,kEAAkE;YAClE,sEAAsE;YACtE,wCAAwC;YACxC,EAAE;YACF,uEAAuE;YACvE,wEAAwE;YACxE,yEAAyE;YACzE,sEAAsE;YACtE,kEAAkE;YAClE,MAAM,WAAW,GAA2D;gBAC1E,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,GAAG,wBAAwB,EAAE,EAAE;gBACtD;oBACE,GAAG,EAAE,GAAG,oBAAoB,GAAG;oBAC/B,MAAM,EAAE,EAAE,eAAe,EAAE,uBAAuB,EAAE;iBACrD;gBACD,GAAG,gBAAgB,EAAE,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;oBACnC,GAAG,EAAE,IAAI;oBACT,MAAM,EAAE,EAAE,eAAe,EAAE,0BAA0B,EAAE;iBACxD,CAAC,CAAC;aACJ,CAAA;YAED,MAAM,mBAAmB,GAAG,WAAW;iBACpC,GAAG,CACF,CAAC,IAAI,EAAE,EAAE,CACP,yBAAyB,IAAI,CAAC,GAAG,yBAAyB;gBAC1D,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC;qBACxB,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,OAAO,IAAI,OAAO,KAAK,KAAK,CAAC;qBACpD,IAAI,CAAC,EAAE,CAAC,CACd;iBACA,IAAI,CAAC,EAAE,CAAC,CAAA;YAEX,wCAAwC;YACxC,MAAM,WAAW,GACf,+DAA+D,GAAG,mBAAmB,CAAA;YAEvF,kEAAkE;YAClE,MAAM,kBAAkB,GACtB,WAAW;gBACX,+CAA+C;gBAC/C,gBAAgB,cAAc,4BAA4B;gBAC1D,kBAAkB,cAAc,gCAAgC;gBAChE,mBAAmB,CAAA;YAErB,gEAAgE;YAChE,MAAM,mBAAmB,GAAG,IAAI,CAAC,SAAS,CACxC;gBACE,OAAO,EAAE,WAAW,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,EAAE,GAAG,EAAE,IAAI,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC;aAC7E,EACD,IAAI,EACJ,CAAC,CACF,CAAA;YAED,OAAO;gBACL,IAAI,EAAE,SAAS;gBACf,MAAM,EAAE,YAAY;gBACpB,QAAQ,EAAE,SAAS;gBACnB,KAAK,EAAE,GAAG,GAAG,CAAC,MAAM,aAAa;gBACjC,SAAS,EAAE,GAAG,GAAG,CAAC,MAAM,SAAS;gBACjC,GAAG,iBAAiB,CAAC,GAAG,CAAC;gBACzB,YAAY;gBACZ,WAAW,EAAE,CAAC,cAAc,CAAC;gBAC7B,SAAS,EAAE;oBACT,mEAAmE;oBACnE,oEAAoE;oBACpE,oEAAoE;oBACpE,sBAAsB;oBACtB,qEAAqE;oBACrE,oEAAoE;oBACpE,iCAAiC;oBACjC;wBACE,IAAI,EAAE,aAAa;wBACnB,IAAI,EAAE,wBAAwB;wBAC9B,QAAQ,EAAE,IAAI;wBACd,iBAAiB,EAAE,wBAAwB,EAAE;qBAC9C;oBACD,6BAA6B;oBAC7B;wBACE,IAAI,EAAE,UAAU;wBAChB,IAAI,EAAE,yCAAyC;wBAC/C,aAAa,EAAE,oBAAoB,CAAC,aAAa,EAAE,iBAAiB,EAAE,CAAC;qBACxE;oBACD,0CAA0C;oBAC1C;wBACE,IAAI,EAAE,MAAM;wBACZ,IAAI,EAAE,6BAA6B;wBACnC,QAAQ,EAAE,WAAW;qBACtB;oBACD,kEAAkE;oBAClE,qEAAqE;oBACrE,kEAAkE;oBAClE,GAAG,CAAC,aAAa;wBACf,CAAC,CAAC;4BACE;gCACE,IAAI,EAAE,UAAU;gCAChB,IAAI,EAAE,sCAAsC;gCAC5C,KAAK,EAAE,SAAS;gCAChB,aAAa,EAAE,oBAAoB,CAAC,aAAa,EAAE,iBAAiB,EAAE,CAAC;6BAC9C;4BAC3B;gCACE,IAAI,EAAE,MAAM;gCACZ,IAAI,EAAE,yBAAyB;gCAC/B,KAAK,EAAE,SAAS;gCAChB,QAAQ,EAAE,mBAAmB,GAAG,IAAI;6BACX;yBAC5B;wBACH,CAAC,CAAC,EAAE,CAAC;oBACP,GAAG,CAAC,aAAa;wBACf,CAAC,CAAC;4BACE;gCACE,IAAI,EAAE,MAAM;gCACZ,IAAI,EAAE,cAAc;gCACpB,KAAK,EAAE,SAAS;gCAChB,YAAY,EAAE,IAAI;gCAClB,QAAQ,EAAE,kBAAkB;6BACH;yBAC5B;wBACH,CAAC,CAAC,EAAE,CAAC;iBACR;aACF,CAAA;QACH,CAAC;KACF,CAAA;AACH,CAAC;AAED,eAAe,OAAO,CAAA"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ruvyxa/adapter-netlify",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.3",
|
|
4
4
|
"description": "Shape Ruvyxa builds for Netlify functions and assets with a small typed adapter contract.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
}
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
|
-
"@ruvyxa/core": "^1.1.
|
|
59
|
+
"@ruvyxa/core": "^1.1.3"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@types/node": "24.13.3"
|
package/src/index.ts
CHANGED
|
@@ -21,23 +21,35 @@ export interface NetlifyAdapterOptions {
|
|
|
21
21
|
/** Custom Netlify functions directory. Defaults to `${outDir}/netlify/functions`. */
|
|
22
22
|
functionsDir?: string
|
|
23
23
|
/**
|
|
24
|
-
*
|
|
25
|
-
* generated publish directory and
|
|
26
|
-
* `netlify.toml` is never overwritten.
|
|
24
|
+
* Emit a `netlify.toml` at the project root naming the build command, the
|
|
25
|
+
* generated publish directory, the functions directory, and the headers. An
|
|
26
|
+
* existing project `netlify.toml` is never overwritten.
|
|
27
27
|
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
28
|
+
* On by default, because it is the only mechanism that can answer the one
|
|
29
|
+
* question Netlify cannot learn from the build: **the publish directory**.
|
|
30
|
+
* The Frameworks API has no key for it, and a plugin cannot supply it without
|
|
31
|
+
* a `netlify.toml` of its own — Netlify installs a plugin from its UI only
|
|
32
|
+
* when the plugin is listed in Netlify's own directory, and a community
|
|
33
|
+
* package has to be declared in `[[plugins]]` and installed as a
|
|
34
|
+
* devDependency. So the choice is not "config file or no config file"; it is
|
|
35
|
+
* "one generated file, or the same file written by hand".
|
|
36
|
+
*
|
|
37
|
+
* Netlify reads `netlify.toml` *before* running the build command, so this
|
|
38
|
+
* file has to be committed to take effect — the build writes it once and
|
|
39
|
+
* never again.
|
|
40
|
+
* @default true
|
|
33
41
|
*/
|
|
34
42
|
projectConfig?: boolean
|
|
35
43
|
/**
|
|
36
44
|
* Emit the Netlify Frameworks API directory (`.netlify/v1/`) at the project
|
|
37
|
-
* root during `ruvyxa build`: the SSR/API function and the
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
45
|
+
* root during `ruvyxa build`: the SSR/API function and the cache headers.
|
|
46
|
+
*
|
|
47
|
+
* Defaults to the opposite of {@link projectConfig}, because the two are
|
|
48
|
+
* alternatives rather than layers. Shipping both puts two functions on the
|
|
49
|
+
* site — the one under `.netlify/v1/functions` and the one the `netlify.toml`
|
|
50
|
+
* `functions` directory declares — and both claim `path: '/*'`, so which of
|
|
51
|
+
* them answers a request is not a question with an answer.
|
|
52
|
+
* @default `!projectConfig`
|
|
41
53
|
*/
|
|
42
54
|
frameworksApi?: boolean
|
|
43
55
|
}
|
|
@@ -59,6 +71,7 @@ import { applyPluginHttp, loadActionModule, loadRouteModule } from './route-modu
|
|
|
59
71
|
// needs must be reachable through the module graph. A sibling manifest.json
|
|
60
72
|
// read from import.meta.dirname is not, and never reaches /var/task.
|
|
61
73
|
import manifest from './manifest.mjs';
|
|
74
|
+
import { createHash } from 'node:crypto';
|
|
62
75
|
import { readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
|
|
63
76
|
import os from 'node:os';
|
|
64
77
|
import path from 'node:path';
|
|
@@ -76,6 +89,41 @@ const prerenderDir = path.join(import.meta.dirname, 'prerender');
|
|
|
76
89
|
// back before the bundled deploy-time prerender output.
|
|
77
90
|
const isrCacheDir = path.join(os.tmpdir(), 'ruvyxa-isr-cache');
|
|
78
91
|
|
|
92
|
+
/**
|
|
93
|
+
* The cache tag a document is stored under, so one path can be dropped from
|
|
94
|
+
* Netlify's cache without dropping the site's.
|
|
95
|
+
*
|
|
96
|
+
* A tag list is comma-separated, so the path has to become one token — and the
|
|
97
|
+
* mapping to that token has to be injective, which folding every run of
|
|
98
|
+
* non-alphanumerics to a single \`-\` is not: \`/a/b\`, \`/a-b\` and \`/a_b\` all
|
|
99
|
+
* produced \`ruvyxa-a-b\`, so \`revalidatePath('/a/b')\` dropped two other pages
|
|
100
|
+
* from the edge as well. A digest is one token, is the same length whatever the
|
|
101
|
+
* path, and cannot collide by construction.
|
|
102
|
+
*/
|
|
103
|
+
function cacheTag(pathname) {
|
|
104
|
+
return 'ruvyxa-' + createHash('sha256').update(pathname).digest('hex').slice(0, 32);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Drop this path from Netlify's edge, which the durable cache in front of this
|
|
109
|
+
* function would otherwise keep serving until its window expired.
|
|
110
|
+
*
|
|
111
|
+
* \`purgeCache\` infers the site and its credentials from the function runtime,
|
|
112
|
+
* so it needs no configuration — but it lives in \`@netlify/functions\`, which a
|
|
113
|
+
* project may not have installed. Imported at the moment it is needed and
|
|
114
|
+
* behind a catch, because a missing optional dependency must not take the whole
|
|
115
|
+
* function down at module load: every page would 500 to make one revalidation
|
|
116
|
+
* work.
|
|
117
|
+
*/
|
|
118
|
+
async function purgeDurableCache(pathname) {
|
|
119
|
+
try {
|
|
120
|
+
const { purgeCache } = await import('@netlify/functions');
|
|
121
|
+
await purgeCache({ tags: [cacheTag(pathname)] });
|
|
122
|
+
} catch (error) {
|
|
123
|
+
console.error('[ruvyxa] could not purge ' + pathname + ' from the Netlify cache:', error);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
79
127
|
const readEntry = (htmlPath, revalidate) => {
|
|
80
128
|
const html = readFileSync(htmlPath, 'utf8');
|
|
81
129
|
const stale = Date.now() - statSync(htmlPath).mtimeMs >= revalidate * 1000;
|
|
@@ -107,12 +155,13 @@ const handler = createHandler({
|
|
|
107
155
|
return null;
|
|
108
156
|
}
|
|
109
157
|
},
|
|
110
|
-
writePrerendered: (pathname, html, revalidate) => {
|
|
158
|
+
writePrerendered: (pathname, html, revalidate, forced) => {
|
|
111
159
|
const relative = prerenderRelativePath(pathname);
|
|
112
160
|
if (relative === null) return;
|
|
113
161
|
const htmlPath = path.join(isrCacheDir, relative);
|
|
114
162
|
mkdirSync(path.dirname(htmlPath), { recursive: true });
|
|
115
163
|
writeFileSync(htmlPath, html, 'utf8');
|
|
164
|
+
if (forced === true) return purgeDurableCache(pathname);
|
|
116
165
|
},
|
|
117
166
|
// The project's own not-found page, pre-rendered by the build and carried
|
|
118
167
|
// inline in the manifest: an unmatched URL is answered with the page the
|
|
@@ -123,7 +172,34 @@ const handler = createHandler({
|
|
|
123
172
|
|
|
124
173
|
// Netlify Functions v2 — Web-standard Request/Response
|
|
125
174
|
export default async function(request, context) {
|
|
126
|
-
|
|
175
|
+
const response = await handler(request, context);
|
|
176
|
+
return withDurableCache(request, response);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Re-state a cacheable answer for Netlify's own cache, durably.
|
|
181
|
+
*
|
|
182
|
+
* \`Cache-Control\` reaches the browser and every CDN; \`Netlify-CDN-Cache-Control\`
|
|
183
|
+
* is read by Netlify alone and is the only place the \`durable\` directive means
|
|
184
|
+
* anything. Durable storage is what makes one render serve every edge node
|
|
185
|
+
* instead of one — and it is what \`purgeCache\` above can invalidate, so the two
|
|
186
|
+
* halves have to be added together or \`revalidatePath()\` purges nothing.
|
|
187
|
+
*
|
|
188
|
+
* Only a shared-cacheable response is re-stated: \`s-maxage\` is the marker the
|
|
189
|
+
* handler already puts on an ISR document, and \`no-store\` on everything a
|
|
190
|
+
* request produced for one visitor.
|
|
191
|
+
*/
|
|
192
|
+
function withDurableCache(request, response) {
|
|
193
|
+
const cacheControl = response.headers.get('cache-control');
|
|
194
|
+
if (!cacheControl || !cacheControl.includes('s-maxage')) return response;
|
|
195
|
+
const headers = new Headers(response.headers);
|
|
196
|
+
headers.set('netlify-cdn-cache-control', 'public, durable, ' + cacheControl);
|
|
197
|
+
headers.set('cache-tag', cacheTag(new URL(request.url).pathname));
|
|
198
|
+
return new Response(response.body, {
|
|
199
|
+
status: response.status,
|
|
200
|
+
statusText: response.statusText,
|
|
201
|
+
headers,
|
|
202
|
+
});
|
|
127
203
|
}
|
|
128
204
|
|
|
129
205
|
// Netlify Functions v2 config
|
|
@@ -141,8 +217,18 @@ export const config = ${JSON.stringify(functionConfig, null, 2)};
|
|
|
141
217
|
* `includedFiles` is what carries the deploy-time prerender output into the
|
|
142
218
|
* bundle. Netlify bundles the function with esbuild, which copies only what the
|
|
143
219
|
* module graph reaches, so the HTML `readPrerendered` falls back to was being
|
|
144
|
-
* dropped.
|
|
145
|
-
*
|
|
220
|
+
* dropped.
|
|
221
|
+
*
|
|
222
|
+
* The glob is relative to **the function's own directory**, not to the site's
|
|
223
|
+
* base directory. `zip-it-and-ship-it` sets
|
|
224
|
+
* `includedFilesBasePath = dirname(mainFile)` whenever `includedFiles` comes
|
|
225
|
+
* from an in-source `config` export, and every one of its bundlers passes that
|
|
226
|
+
* to `getPathsOfIncludedFiles`, which globs with `cwd: basePath`. Each artifact
|
|
227
|
+
* used to name its own site-relative path instead — which resolved to
|
|
228
|
+
* `<function>/functions/ruvyxa-handler/prerender/**`, matching nothing, in both
|
|
229
|
+
* the deploy-directory workflow and the project-root one, with a green build
|
|
230
|
+
* behind it. One path is now correct for both, which is why this takes no
|
|
231
|
+
* argument.
|
|
146
232
|
*/
|
|
147
233
|
interface NetlifyFunctionConfig {
|
|
148
234
|
path: string
|
|
@@ -165,13 +251,13 @@ function packageVersion(): string {
|
|
|
165
251
|
return metadata.version
|
|
166
252
|
}
|
|
167
253
|
|
|
168
|
-
function functionConfigFor(
|
|
254
|
+
function functionConfigFor(): NetlifyFunctionConfig {
|
|
169
255
|
return {
|
|
170
256
|
path: '/*',
|
|
171
257
|
preferStatic: true,
|
|
172
258
|
name: 'Ruvyxa SSR',
|
|
173
259
|
generator: `ruvyxa/${packageVersion()}`,
|
|
174
|
-
includedFiles: [
|
|
260
|
+
includedFiles: ['prerender/**'],
|
|
175
261
|
}
|
|
176
262
|
}
|
|
177
263
|
|
|
@@ -209,6 +295,10 @@ export function netlify(options: NetlifyAdapterOptions = {}): Adapter {
|
|
|
209
295
|
supports: ['ssr', 'ssg', 'csr', 'isr', 'ppr', 'api'],
|
|
210
296
|
build(ctx: BuildContext): AdapterOutput {
|
|
211
297
|
validateBuildContext(ctx, 'netlifyAdapter')
|
|
298
|
+
// Exactly one of the two ships: see `frameworksApi` above for why both
|
|
299
|
+
// at once is a site with two functions claiming the same path.
|
|
300
|
+
const projectConfig = options.projectConfig !== false
|
|
301
|
+
const frameworksApi = options.frameworksApi ?? !projectConfig
|
|
212
302
|
const functionsDir = options.functionsDir ?? `${ctx.outDir}/netlify/functions`
|
|
213
303
|
// Config files are committed or read on other machines; never embed the
|
|
214
304
|
// absolute build-machine outDir in them.
|
|
@@ -296,10 +386,7 @@ export function netlify(options: NetlifyAdapterOptions = {}): Adapter {
|
|
|
296
386
|
{
|
|
297
387
|
kind: 'function',
|
|
298
388
|
path: 'deploy/netlify/functions/ruvyxa-handler',
|
|
299
|
-
handlerSource: netlifyHandlerSource(
|
|
300
|
-
runtimePolicy,
|
|
301
|
-
functionConfigFor('functions/ruvyxa-handler'),
|
|
302
|
-
),
|
|
389
|
+
handlerSource: netlifyHandlerSource(runtimePolicy, functionConfigFor()),
|
|
303
390
|
},
|
|
304
391
|
// Netlify config for the deploy directory
|
|
305
392
|
{
|
|
@@ -310,17 +397,13 @@ export function netlify(options: NetlifyAdapterOptions = {}): Adapter {
|
|
|
310
397
|
// Frameworks API artifacts: Netlify discovers .netlify/v1/ at the
|
|
311
398
|
// project root automatically, so a fresh site needs only the publish
|
|
312
399
|
// directory set once in the dashboard — no committed config file.
|
|
313
|
-
...(
|
|
314
|
-
? [
|
|
315
|
-
: [
|
|
400
|
+
...(frameworksApi
|
|
401
|
+
? [
|
|
316
402
|
{
|
|
317
403
|
kind: 'function',
|
|
318
404
|
path: '.netlify/v1/functions/ruvyxa-handler',
|
|
319
405
|
scope: 'project',
|
|
320
|
-
handlerSource: netlifyHandlerSource(
|
|
321
|
-
runtimePolicy,
|
|
322
|
-
functionConfigFor('.netlify/v1/functions/ruvyxa-handler'),
|
|
323
|
-
),
|
|
406
|
+
handlerSource: netlifyHandlerSource(runtimePolicy, functionConfigFor()),
|
|
324
407
|
} satisfies AdapterArtifact,
|
|
325
408
|
{
|
|
326
409
|
kind: 'file',
|
|
@@ -328,8 +411,9 @@ export function netlify(options: NetlifyAdapterOptions = {}): Adapter {
|
|
|
328
411
|
scope: 'project',
|
|
329
412
|
contents: frameworksApiConfig + '\n',
|
|
330
413
|
} satisfies AdapterArtifact,
|
|
331
|
-
]
|
|
332
|
-
|
|
414
|
+
]
|
|
415
|
+
: []),
|
|
416
|
+
...(projectConfig
|
|
333
417
|
? [
|
|
334
418
|
{
|
|
335
419
|
kind: 'file',
|