@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 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;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
- return handler(request, context);
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(functionRoot) {
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: [`${functionRoot}/prerender/**`],
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('functions/ruvyxa-handler')),
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
- ...(options.frameworksApi === false
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('.netlify/v1/functions/ruvyxa-handler')),
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
- ...(options.projectConfig === true
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":"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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.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.2"
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
- * 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.
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
- * 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
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 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
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
- return handler(request, context);
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. The globs resolve against the site's base directory, which is why
145
- * each artifact declares its own path instead of sharing one.
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(functionRoot: string): NetlifyFunctionConfig {
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: [`${functionRoot}/prerender/**`],
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
- ...(options.frameworksApi === false
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
- ...(options.projectConfig === true
414
+ ]
415
+ : []),
416
+ ...(projectConfig
333
417
  ? [
334
418
  {
335
419
  kind: 'file',