@ruvyxa/adapter-netlify 1.1.2 → 1.1.4

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
@@ -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
- * Also emit a `netlify.toml` at the project root pointing Netlify at the
10
- * generated publish directory and functions. An existing project
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
- * Off by default: the adapter already emits the serverless function and
14
- * cache headers through Netlify's Frameworks API (`.netlify/v1/`, a
15
- * gitignored build artifact), so the only remaining setup is the publish
16
- * directory set once in the Netlify dashboard, or opt into this file.
17
- * @default false
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 immutable cache
23
- * headers for hashed client bundles. Netlify picks the directory up
24
- * automatically on the next deploy no config file at the project root.
25
- * @default true
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
  }
@@ -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;;;;;;;;;;OAUG;IACH,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB;;;;;;OAMG;IACH,aAAa,CAAC,EAAE,OAAO,CAAA;CACxB;AAuID;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,OAAO,CAAC,OAAO,GAAE,qBAA0B,GAAG,OAAO,CAwJpE;eAEc,OAAO"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,OAAO,EAAgD,MAAM,cAAc,CAAA;AAgBzF;;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;AA8ND;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,OAAO,CAAC,OAAO,GAAE,qBAA0B,GAAG,OAAO,CA8JpE;eAEc,OAAO"}
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readFileSync } from 'node:fs';
2
- import { CLIENT_BUNDLE_PREFIX, clientBuildOutput, DEFAULT_SECURITY_HEADERS, IMMUTABLE_CACHE_CONTROL, nonPublishableStrategies, projectRelativeOutDir, PUBLIC_ASSET_CACHE_CONTROL, publicAssetGlobs, runtimeBuildPolicy, validateBuildContext, } from '@ruvyxa/core';
2
+ import { CLIENT_BUNDLE_PREFIX, clientBuildOutput, documentCacheOptionsSource, isrTemporaryCacheSource, DEFAULT_SECURITY_HEADERS, IMMUTABLE_CACHE_CONTROL, nonPublishableStrategies, projectRelativeOutDir, PUBLIC_ASSET_CACHE_CONTROL, publicAssetGlobs, runtimeBuildPolicy, validateBuildContext, } from '@ruvyxa/core';
3
3
  /**
4
4
  * Netlify Functions v2 handler source code.
5
5
  *
@@ -7,13 +7,14 @@ import { CLIENT_BUNDLE_PREFIX, clientBuildOutput, DEFAULT_SECURITY_HEADERS, IMMU
7
7
  * handler using the Web-standard Request/Response API. Reads the route
8
8
  * manifest and handles SSR/API/ISR/PPR requests.
9
9
  */
10
- function netlifyHandlerSource(runtimePolicy, functionConfig) {
10
+ function netlifyHandlerSource(runtimePolicy, functionConfig, buildId) {
11
11
  return `import { createHandler, prerenderRelativePath } from './serverless-handler.mjs';
12
- import { applyPluginHttp, loadActionModule, loadRouteModule } from './route-modules.mjs';
12
+ import { applyPluginHttp, documentCacheHandler, loadActionModule, loadRouteModule } from './route-modules.mjs';
13
13
  // Netlify bundles the function with esbuild, so anything the deployed code
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';
@@ -26,10 +27,42 @@ const runtimePolicy = ${JSON.stringify(runtimePolicy ?? {})};
26
27
  // serves SSG pages from the publish directory before the function is ever
27
28
  // invoked (config.preferStatic).
28
29
  const prerenderDir = path.join(import.meta.dirname, 'prerender');
29
- // The function bundle directory is read-only at runtime; only the platform
30
- // tmp directory accepts writes. ISR revalidations land there and are read
31
- // back before the bundled deploy-time prerender output.
32
- const isrCacheDir = path.join(os.tmpdir(), 'ruvyxa-isr-cache');
30
+ ${isrTemporaryCacheSource(buildId)}
31
+
32
+ /**
33
+ * The cache tag a document is stored under, so one path can be dropped from
34
+ * Netlify's cache without dropping the site's.
35
+ *
36
+ * A tag list is comma-separated, so the path has to become one token — and the
37
+ * mapping to that token has to be injective, which folding every run of
38
+ * non-alphanumerics to a single \`-\` is not: \`/a/b\`, \`/a-b\` and \`/a_b\` all
39
+ * produced \`ruvyxa-a-b\`, so \`revalidatePath('/a/b')\` dropped two other pages
40
+ * from the edge as well. A digest is one token, is the same length whatever the
41
+ * path, and cannot collide by construction.
42
+ */
43
+ function cacheTag(pathname) {
44
+ return 'ruvyxa-' + createHash('sha256').update(pathname).digest('hex').slice(0, 32);
45
+ }
46
+
47
+ /**
48
+ * Drop this path from Netlify's edge, which the durable cache in front of this
49
+ * function would otherwise keep serving until its window expired.
50
+ *
51
+ * \`purgeCache\` infers the site and its credentials from the function runtime,
52
+ * so it needs no configuration — but it lives in \`@netlify/functions\`, which a
53
+ * project may not have installed. Imported at the moment it is needed and
54
+ * behind a catch, because a missing optional dependency must not take the whole
55
+ * function down at module load: every page would 500 to make one revalidation
56
+ * work.
57
+ */
58
+ async function purgeDurableCache(pathname) {
59
+ try {
60
+ const { purgeCache } = await import('@netlify/functions');
61
+ await purgeCache({ tags: [cacheTag(pathname)] });
62
+ } catch (error) {
63
+ console.error('[ruvyxa] could not purge ' + pathname + ' from the Netlify cache:', error);
64
+ }
65
+ }
33
66
 
34
67
  const readEntry = (htmlPath, revalidate) => {
35
68
  const html = readFileSync(htmlPath, 'utf8');
@@ -37,6 +70,35 @@ const readEntry = (htmlPath, revalidate) => {
37
70
  return { html, stale };
38
71
  };
39
72
 
73
+ // Named rather than passed inline, so a project that declares \`cache.handler\`
74
+ // can stand its own store in front of these. See \`documentCacheOptionsSource\`
75
+ // in \`@ruvyxa/core\`; the registry supplies \`documentCacheHandler\`.
76
+ const platformReadPrerendered = (pathname, revalidate = 60) => {
77
+ // prerenderRelativePath rejects any request path that cannot be mapped to a
78
+ // location inside the cache directories, so reads can never escape them.
79
+ const relative = prerenderRelativePath(pathname);
80
+ if (relative === null) return null;
81
+ try {
82
+ return readEntry(path.join(isrCacheDir, relative), revalidate);
83
+ } catch {
84
+ // fall through to the bundled prerender output
85
+ }
86
+ try {
87
+ return readEntry(path.join(prerenderDir, relative), revalidate);
88
+ } catch {
89
+ return null;
90
+ }
91
+ };
92
+
93
+ const platformWritePrerendered = (pathname, html, revalidate, forced) => {
94
+ const relative = prerenderRelativePath(pathname);
95
+ if (relative === null) return;
96
+ const htmlPath = path.join(isrCacheDir, relative);
97
+ mkdirSync(path.dirname(htmlPath), { recursive: true });
98
+ writeFileSync(htmlPath, html, 'utf8');
99
+ if (forced === true) return purgeDurableCache(pathname);
100
+ };
101
+
40
102
  const handler = createHandler({
41
103
  routes: manifest.routes,
42
104
  middleware: runtimePolicy.middleware,
@@ -46,39 +108,53 @@ const handler = createHandler({
46
108
  importAction: loadActionModule,
47
109
  pluginHttp: applyPluginHttp,
48
110
  security: runtimePolicy.security,
49
- readPrerendered: (pathname, revalidate = 60) => {
50
- // prerenderRelativePath rejects any request path that cannot be mapped to a
51
- // location inside the cache directories, so reads can never escape them.
52
- const relative = prerenderRelativePath(pathname);
53
- if (relative === null) return null;
54
- try {
55
- return readEntry(path.join(isrCacheDir, relative), revalidate);
56
- } catch {
57
- // fall through to the bundled prerender output
58
- }
59
- try {
60
- return readEntry(path.join(prerenderDir, relative), revalidate);
61
- } catch {
62
- return null;
63
- }
64
- },
65
- writePrerendered: (pathname, html, revalidate) => {
66
- const relative = prerenderRelativePath(pathname);
67
- if (relative === null) return;
68
- const htmlPath = path.join(isrCacheDir, relative);
69
- mkdirSync(path.dirname(htmlPath), { recursive: true });
70
- writeFileSync(htmlPath, html, 'utf8');
71
- },
111
+ ${documentCacheOptionsSource('platformReadPrerendered', 'platformWritePrerendered')}
72
112
  // The project's own not-found page, pre-rendered by the build and carried
73
113
  // inline in the manifest: an unmatched URL is answered with the page the
74
114
  // application actually wrote, on every host.
75
115
  notFoundDocument: manifest.notFoundDocument,
76
116
  supportedStrategies: ['ssr', 'ssg', 'csr', 'isr', 'ppr', 'api'],
117
+ // Netlify's own edge writes \`X-Nf-Client-Connection-IP\` and overwrites any
118
+ // inbound copy of it, so on this platform the header *is* the client.
119
+ // Declared here rather than assumed by the handler: the same handler runs on
120
+ // a self-hosted server where nothing writes it. Without a declaration
121
+ // identity fell through to a right-to-left scan of \`X-Forwarded-For\`, whose
122
+ // rightmost entry here is a fixed platform address — so the built-in \`rate\`
123
+ // middleware, the server-action rate limiter, and the replay quota all
124
+ // counted every visitor into one bucket.
125
+ clientIpHeaders: ['x-nf-client-connection-ip'],
77
126
  });
78
127
 
79
128
  // Netlify Functions v2 — Web-standard Request/Response
80
129
  export default async function(request, context) {
81
- return handler(request, context);
130
+ const response = await handler(request, context);
131
+ return withDurableCache(request, response);
132
+ }
133
+
134
+ /**
135
+ * Re-state a cacheable answer for Netlify's own cache, durably.
136
+ *
137
+ * \`Cache-Control\` reaches the browser and every CDN; \`Netlify-CDN-Cache-Control\`
138
+ * is read by Netlify alone and is the only place the \`durable\` directive means
139
+ * anything. Durable storage is what makes one render serve every edge node
140
+ * instead of one — and it is what \`purgeCache\` above can invalidate, so the two
141
+ * halves have to be added together or \`revalidatePath()\` purges nothing.
142
+ *
143
+ * Only a shared-cacheable response is re-stated: \`s-maxage\` is the marker the
144
+ * handler already puts on an ISR document, and \`no-store\` on everything a
145
+ * request produced for one visitor.
146
+ */
147
+ function withDurableCache(request, response) {
148
+ const cacheControl = response.headers.get('cache-control');
149
+ if (!cacheControl || !cacheControl.includes('s-maxage')) return response;
150
+ const headers = new Headers(response.headers);
151
+ headers.set('netlify-cdn-cache-control', 'public, durable, ' + cacheControl);
152
+ headers.set('cache-tag', cacheTag(new URL(request.url).pathname));
153
+ return new Response(response.body, {
154
+ status: response.status,
155
+ statusText: response.statusText,
156
+ headers,
157
+ });
82
158
  }
83
159
 
84
160
  // Netlify Functions v2 config
@@ -93,13 +169,13 @@ function packageVersion() {
93
169
  }
94
170
  return metadata.version;
95
171
  }
96
- function functionConfigFor(functionRoot) {
172
+ function functionConfigFor() {
97
173
  return {
98
174
  path: '/*',
99
175
  preferStatic: true,
100
176
  name: 'Ruvyxa SSR',
101
177
  generator: `ruvyxa/${packageVersion()}`,
102
- includedFiles: [`${functionRoot}/prerender/**`],
178
+ includedFiles: ['prerender/**'],
103
179
  };
104
180
  }
105
181
  /**
@@ -132,6 +208,10 @@ export function netlify(options = {}) {
132
208
  supports: ['ssr', 'ssg', 'csr', 'isr', 'ppr', 'api'],
133
209
  build(ctx) {
134
210
  validateBuildContext(ctx, 'netlifyAdapter');
211
+ // Exactly one of the two ships: see `frameworksApi` above for why both
212
+ // at once is a site with two functions claiming the same path.
213
+ const projectConfig = options.projectConfig !== false;
214
+ const frameworksApi = options.frameworksApi ?? !projectConfig;
135
215
  const functionsDir = options.functionsDir ?? `${ctx.outDir}/netlify/functions`;
136
216
  // Config files are committed or read on other machines; never embed the
137
217
  // absolute build-machine outDir in them.
@@ -204,7 +284,7 @@ export function netlify(options = {}) {
204
284
  {
205
285
  kind: 'function',
206
286
  path: 'deploy/netlify/functions/ruvyxa-handler',
207
- handlerSource: netlifyHandlerSource(runtimePolicy, functionConfigFor('functions/ruvyxa-handler')),
287
+ handlerSource: netlifyHandlerSource(runtimePolicy, functionConfigFor(), ctx.deployManifest?.buildId ?? ''),
208
288
  },
209
289
  // Netlify config for the deploy directory
210
290
  {
@@ -215,14 +295,13 @@ export function netlify(options = {}) {
215
295
  // Frameworks API artifacts: Netlify discovers .netlify/v1/ at the
216
296
  // project root automatically, so a fresh site needs only the publish
217
297
  // directory set once in the dashboard — no committed config file.
218
- ...(options.frameworksApi === false
219
- ? []
220
- : [
298
+ ...(frameworksApi
299
+ ? [
221
300
  {
222
301
  kind: 'function',
223
302
  path: '.netlify/v1/functions/ruvyxa-handler',
224
303
  scope: 'project',
225
- handlerSource: netlifyHandlerSource(runtimePolicy, functionConfigFor('.netlify/v1/functions/ruvyxa-handler')),
304
+ handlerSource: netlifyHandlerSource(runtimePolicy, functionConfigFor(), ctx.deployManifest?.buildId ?? ''),
226
305
  },
227
306
  {
228
307
  kind: 'file',
@@ -230,8 +309,9 @@ export function netlify(options = {}) {
230
309
  scope: 'project',
231
310
  contents: frameworksApiConfig + '\n',
232
311
  },
233
- ]),
234
- ...(options.projectConfig === true
312
+ ]
313
+ : []),
314
+ ...(projectConfig
235
315
  ? [
236
316
  {
237
317
  kind: 'file',
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-netlify",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
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.2"
59
+ "@ruvyxa/core": "^1.1.4"
60
60
  },
61
61
  "devDependencies": {
62
62
  "@types/node": "24.13.3"
package/src/index.ts CHANGED
@@ -4,6 +4,8 @@ import type { Adapter, AdapterArtifact, AdapterOutput, BuildContext } from '@ruv
4
4
  import {
5
5
  CLIENT_BUNDLE_PREFIX,
6
6
  clientBuildOutput,
7
+ documentCacheOptionsSource,
8
+ isrTemporaryCacheSource,
7
9
  DEFAULT_SECURITY_HEADERS,
8
10
  IMMUTABLE_CACHE_CONTROL,
9
11
  nonPublishableStrategies,
@@ -21,23 +23,35 @@ export interface NetlifyAdapterOptions {
21
23
  /** Custom Netlify functions directory. Defaults to `${outDir}/netlify/functions`. */
22
24
  functionsDir?: string
23
25
  /**
24
- * Also emit a `netlify.toml` at the project root pointing Netlify at the
25
- * generated publish directory and functions. An existing project
26
- * `netlify.toml` is never overwritten.
26
+ * Emit a `netlify.toml` at the project root naming the build command, the
27
+ * generated publish directory, the functions directory, and the headers. An
28
+ * existing project `netlify.toml` is never overwritten.
27
29
  *
28
- * Off by default: the adapter already emits the serverless function and
29
- * cache headers through Netlify's Frameworks API (`.netlify/v1/`, a
30
- * gitignored build artifact), so the only remaining setup is the publish
31
- * directory set once in the Netlify dashboard, or opt into this file.
32
- * @default false
30
+ * On by default, because it is the only mechanism that can answer the one
31
+ * question Netlify cannot learn from the build: **the publish directory**.
32
+ * The Frameworks API has no key for it, and a plugin cannot supply it without
33
+ * a `netlify.toml` of its own Netlify installs a plugin from its UI only
34
+ * when the plugin is listed in Netlify's own directory, and a community
35
+ * package has to be declared in `[[plugins]]` and installed as a
36
+ * devDependency. So the choice is not "config file or no config file"; it is
37
+ * "one generated file, or the same file written by hand".
38
+ *
39
+ * Netlify reads `netlify.toml` *before* running the build command, so this
40
+ * file has to be committed to take effect — the build writes it once and
41
+ * never again.
42
+ * @default true
33
43
  */
34
44
  projectConfig?: boolean
35
45
  /**
36
46
  * Emit the Netlify Frameworks API directory (`.netlify/v1/`) at the project
37
- * root during `ruvyxa build`: the SSR/API function and the immutable cache
38
- * headers for hashed client bundles. Netlify picks the directory up
39
- * automatically on the next deploy no config file at the project root.
40
- * @default true
47
+ * root during `ruvyxa build`: the SSR/API function and the cache headers.
48
+ *
49
+ * Defaults to the opposite of {@link projectConfig}, because the two are
50
+ * alternatives rather than layers. Shipping both puts two functions on the
51
+ * site — the one under `.netlify/v1/functions` and the one the `netlify.toml`
52
+ * `functions` directory declares — and both claim `path: '/*'`, so which of
53
+ * them answers a request is not a question with an answer.
54
+ * @default `!projectConfig`
41
55
  */
42
56
  frameworksApi?: boolean
43
57
  }
@@ -52,13 +66,15 @@ export interface NetlifyAdapterOptions {
52
66
  function netlifyHandlerSource(
53
67
  runtimePolicy: unknown,
54
68
  functionConfig: NetlifyFunctionConfig,
69
+ buildId: string,
55
70
  ): string {
56
71
  return `import { createHandler, prerenderRelativePath } from './serverless-handler.mjs';
57
- import { applyPluginHttp, loadActionModule, loadRouteModule } from './route-modules.mjs';
72
+ import { applyPluginHttp, documentCacheHandler, loadActionModule, loadRouteModule } from './route-modules.mjs';
58
73
  // Netlify bundles the function with esbuild, so anything the deployed code
59
74
  // needs must be reachable through the module graph. A sibling manifest.json
60
75
  // read from import.meta.dirname is not, and never reaches /var/task.
61
76
  import manifest from './manifest.mjs';
77
+ import { createHash } from 'node:crypto';
62
78
  import { readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
63
79
  import os from 'node:os';
64
80
  import path from 'node:path';
@@ -71,10 +87,42 @@ const runtimePolicy = ${JSON.stringify(runtimePolicy ?? {})};
71
87
  // serves SSG pages from the publish directory before the function is ever
72
88
  // invoked (config.preferStatic).
73
89
  const prerenderDir = path.join(import.meta.dirname, 'prerender');
74
- // The function bundle directory is read-only at runtime; only the platform
75
- // tmp directory accepts writes. ISR revalidations land there and are read
76
- // back before the bundled deploy-time prerender output.
77
- const isrCacheDir = path.join(os.tmpdir(), 'ruvyxa-isr-cache');
90
+ ${isrTemporaryCacheSource(buildId)}
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
+ }
78
126
 
79
127
  const readEntry = (htmlPath, revalidate) => {
80
128
  const html = readFileSync(htmlPath, 'utf8');
@@ -82,6 +130,35 @@ const readEntry = (htmlPath, revalidate) => {
82
130
  return { html, stale };
83
131
  };
84
132
 
133
+ // Named rather than passed inline, so a project that declares \`cache.handler\`
134
+ // can stand its own store in front of these. See \`documentCacheOptionsSource\`
135
+ // in \`@ruvyxa/core\`; the registry supplies \`documentCacheHandler\`.
136
+ const platformReadPrerendered = (pathname, revalidate = 60) => {
137
+ // prerenderRelativePath rejects any request path that cannot be mapped to a
138
+ // location inside the cache directories, so reads can never escape them.
139
+ const relative = prerenderRelativePath(pathname);
140
+ if (relative === null) return null;
141
+ try {
142
+ return readEntry(path.join(isrCacheDir, relative), revalidate);
143
+ } catch {
144
+ // fall through to the bundled prerender output
145
+ }
146
+ try {
147
+ return readEntry(path.join(prerenderDir, relative), revalidate);
148
+ } catch {
149
+ return null;
150
+ }
151
+ };
152
+
153
+ const platformWritePrerendered = (pathname, html, revalidate, forced) => {
154
+ const relative = prerenderRelativePath(pathname);
155
+ if (relative === null) return;
156
+ const htmlPath = path.join(isrCacheDir, relative);
157
+ mkdirSync(path.dirname(htmlPath), { recursive: true });
158
+ writeFileSync(htmlPath, html, 'utf8');
159
+ if (forced === true) return purgeDurableCache(pathname);
160
+ };
161
+
85
162
  const handler = createHandler({
86
163
  routes: manifest.routes,
87
164
  middleware: runtimePolicy.middleware,
@@ -91,39 +168,53 @@ const handler = createHandler({
91
168
  importAction: loadActionModule,
92
169
  pluginHttp: applyPluginHttp,
93
170
  security: runtimePolicy.security,
94
- readPrerendered: (pathname, revalidate = 60) => {
95
- // prerenderRelativePath rejects any request path that cannot be mapped to a
96
- // location inside the cache directories, so reads can never escape them.
97
- const relative = prerenderRelativePath(pathname);
98
- if (relative === null) return null;
99
- try {
100
- return readEntry(path.join(isrCacheDir, relative), revalidate);
101
- } catch {
102
- // fall through to the bundled prerender output
103
- }
104
- try {
105
- return readEntry(path.join(prerenderDir, relative), revalidate);
106
- } catch {
107
- return null;
108
- }
109
- },
110
- writePrerendered: (pathname, html, revalidate) => {
111
- const relative = prerenderRelativePath(pathname);
112
- if (relative === null) return;
113
- const htmlPath = path.join(isrCacheDir, relative);
114
- mkdirSync(path.dirname(htmlPath), { recursive: true });
115
- writeFileSync(htmlPath, html, 'utf8');
116
- },
171
+ ${documentCacheOptionsSource('platformReadPrerendered', 'platformWritePrerendered')}
117
172
  // The project's own not-found page, pre-rendered by the build and carried
118
173
  // inline in the manifest: an unmatched URL is answered with the page the
119
174
  // application actually wrote, on every host.
120
175
  notFoundDocument: manifest.notFoundDocument,
121
176
  supportedStrategies: ['ssr', 'ssg', 'csr', 'isr', 'ppr', 'api'],
177
+ // Netlify's own edge writes \`X-Nf-Client-Connection-IP\` and overwrites any
178
+ // inbound copy of it, so on this platform the header *is* the client.
179
+ // Declared here rather than assumed by the handler: the same handler runs on
180
+ // a self-hosted server where nothing writes it. Without a declaration
181
+ // identity fell through to a right-to-left scan of \`X-Forwarded-For\`, whose
182
+ // rightmost entry here is a fixed platform address — so the built-in \`rate\`
183
+ // middleware, the server-action rate limiter, and the replay quota all
184
+ // counted every visitor into one bucket.
185
+ clientIpHeaders: ['x-nf-client-connection-ip'],
122
186
  });
123
187
 
124
188
  // Netlify Functions v2 — Web-standard Request/Response
125
189
  export default async function(request, context) {
126
- return handler(request, context);
190
+ const response = await handler(request, context);
191
+ return withDurableCache(request, response);
192
+ }
193
+
194
+ /**
195
+ * Re-state a cacheable answer for Netlify's own cache, durably.
196
+ *
197
+ * \`Cache-Control\` reaches the browser and every CDN; \`Netlify-CDN-Cache-Control\`
198
+ * is read by Netlify alone and is the only place the \`durable\` directive means
199
+ * anything. Durable storage is what makes one render serve every edge node
200
+ * instead of one — and it is what \`purgeCache\` above can invalidate, so the two
201
+ * halves have to be added together or \`revalidatePath()\` purges nothing.
202
+ *
203
+ * Only a shared-cacheable response is re-stated: \`s-maxage\` is the marker the
204
+ * handler already puts on an ISR document, and \`no-store\` on everything a
205
+ * request produced for one visitor.
206
+ */
207
+ function withDurableCache(request, response) {
208
+ const cacheControl = response.headers.get('cache-control');
209
+ if (!cacheControl || !cacheControl.includes('s-maxage')) return response;
210
+ const headers = new Headers(response.headers);
211
+ headers.set('netlify-cdn-cache-control', 'public, durable, ' + cacheControl);
212
+ headers.set('cache-tag', cacheTag(new URL(request.url).pathname));
213
+ return new Response(response.body, {
214
+ status: response.status,
215
+ statusText: response.statusText,
216
+ headers,
217
+ });
127
218
  }
128
219
 
129
220
  // Netlify Functions v2 config
@@ -141,8 +232,18 @@ export const config = ${JSON.stringify(functionConfig, null, 2)};
141
232
  * `includedFiles` is what carries the deploy-time prerender output into the
142
233
  * bundle. Netlify bundles the function with esbuild, which copies only what the
143
234
  * module graph reaches, so the HTML `readPrerendered` falls back to was being
144
- * dropped. The globs resolve against the site's base directory, which is why
145
- * each artifact declares its own path instead of sharing one.
235
+ * dropped.
236
+ *
237
+ * The glob is relative to **the function's own directory**, not to the site's
238
+ * base directory. `zip-it-and-ship-it` sets
239
+ * `includedFilesBasePath = dirname(mainFile)` whenever `includedFiles` comes
240
+ * from an in-source `config` export, and every one of its bundlers passes that
241
+ * to `getPathsOfIncludedFiles`, which globs with `cwd: basePath`. Each artifact
242
+ * used to name its own site-relative path instead — which resolved to
243
+ * `<function>/functions/ruvyxa-handler/prerender/**`, matching nothing, in both
244
+ * the deploy-directory workflow and the project-root one, with a green build
245
+ * behind it. One path is now correct for both, which is why this takes no
246
+ * argument.
146
247
  */
147
248
  interface NetlifyFunctionConfig {
148
249
  path: string
@@ -165,13 +266,13 @@ function packageVersion(): string {
165
266
  return metadata.version
166
267
  }
167
268
 
168
- function functionConfigFor(functionRoot: string): NetlifyFunctionConfig {
269
+ function functionConfigFor(): NetlifyFunctionConfig {
169
270
  return {
170
271
  path: '/*',
171
272
  preferStatic: true,
172
273
  name: 'Ruvyxa SSR',
173
274
  generator: `ruvyxa/${packageVersion()}`,
174
- includedFiles: [`${functionRoot}/prerender/**`],
275
+ includedFiles: ['prerender/**'],
175
276
  }
176
277
  }
177
278
 
@@ -209,6 +310,10 @@ export function netlify(options: NetlifyAdapterOptions = {}): Adapter {
209
310
  supports: ['ssr', 'ssg', 'csr', 'isr', 'ppr', 'api'],
210
311
  build(ctx: BuildContext): AdapterOutput {
211
312
  validateBuildContext(ctx, 'netlifyAdapter')
313
+ // Exactly one of the two ships: see `frameworksApi` above for why both
314
+ // at once is a site with two functions claiming the same path.
315
+ const projectConfig = options.projectConfig !== false
316
+ const frameworksApi = options.frameworksApi ?? !projectConfig
212
317
  const functionsDir = options.functionsDir ?? `${ctx.outDir}/netlify/functions`
213
318
  // Config files are committed or read on other machines; never embed the
214
319
  // absolute build-machine outDir in them.
@@ -298,7 +403,8 @@ export function netlify(options: NetlifyAdapterOptions = {}): Adapter {
298
403
  path: 'deploy/netlify/functions/ruvyxa-handler',
299
404
  handlerSource: netlifyHandlerSource(
300
405
  runtimePolicy,
301
- functionConfigFor('functions/ruvyxa-handler'),
406
+ functionConfigFor(),
407
+ ctx.deployManifest?.buildId ?? '',
302
408
  ),
303
409
  },
304
410
  // Netlify config for the deploy directory
@@ -310,16 +416,16 @@ export function netlify(options: NetlifyAdapterOptions = {}): Adapter {
310
416
  // Frameworks API artifacts: Netlify discovers .netlify/v1/ at the
311
417
  // project root automatically, so a fresh site needs only the publish
312
418
  // directory set once in the dashboard — no committed config file.
313
- ...(options.frameworksApi === false
314
- ? []
315
- : [
419
+ ...(frameworksApi
420
+ ? [
316
421
  {
317
422
  kind: 'function',
318
423
  path: '.netlify/v1/functions/ruvyxa-handler',
319
424
  scope: 'project',
320
425
  handlerSource: netlifyHandlerSource(
321
426
  runtimePolicy,
322
- functionConfigFor('.netlify/v1/functions/ruvyxa-handler'),
427
+ functionConfigFor(),
428
+ ctx.deployManifest?.buildId ?? '',
323
429
  ),
324
430
  } satisfies AdapterArtifact,
325
431
  {
@@ -328,8 +434,9 @@ export function netlify(options: NetlifyAdapterOptions = {}): Adapter {
328
434
  scope: 'project',
329
435
  contents: frameworksApiConfig + '\n',
330
436
  } satisfies AdapterArtifact,
331
- ]),
332
- ...(options.projectConfig === true
437
+ ]
438
+ : []),
439
+ ...(projectConfig
333
440
  ? [
334
441
  {
335
442
  kind: 'file',