@netlify/dev 2.3.1 → 4.0.0

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/README.md CHANGED
@@ -1,7 +1,6 @@
1
1
  # @netlify/dev
2
2
 
3
- > [!WARNING]
4
- > This module is under active development and does **not** yet support all Netlify platform features.
3
+ > [!WARNING] This module is under active development and does **not** yet support all Netlify platform features.
5
4
 
6
5
  `@netlify/dev` is a local emulator for the Netlify production environment. While it can be used directly by advanced
7
6
  users, it is primarily designed as a foundational library for higher-level tools like the
package/dist/main.cjs CHANGED
@@ -38,7 +38,9 @@ var import_node_path2 = __toESM(require("path"), 1);
38
38
  var import_node_process2 = __toESM(require("process"), 1);
39
39
  var import_config = require("@netlify/config");
40
40
  var import_dev_utils = require("@netlify/dev-utils");
41
- var import_dev = require("@netlify/functions/dev");
41
+ var import_dev = require("@netlify/edge-functions/dev");
42
+ var import_dev2 = require("@netlify/functions/dev");
43
+ var import_headers = require("@netlify/headers");
42
44
  var import_redirects = require("@netlify/redirects");
43
45
  var import_static = require("@netlify/static");
44
46
 
@@ -53,7 +55,7 @@ var injectEnvVariables = async ({
53
55
  }) => {
54
56
  const results = {};
55
57
  let variables = baseVariables;
56
- if (netlifyAPI && accountSlug) {
58
+ if (netlifyAPI && siteID && accountSlug) {
57
59
  variables = await getEnvelopeEnv({
58
60
  accountId: accountSlug,
59
61
  api: netlifyAPI,
@@ -69,7 +71,8 @@ var injectEnvVariables = async ({
69
71
  isInternal,
70
72
  originalValue: envAPI.get(key),
71
73
  overriddenSources,
72
- usedSource
74
+ usedSource,
75
+ value: variable.value
73
76
  };
74
77
  if (!existsInProcess || isInternal) {
75
78
  envAPI.set(key, variable.value);
@@ -194,6 +197,10 @@ var isFile = async (path3) => {
194
197
  return false;
195
198
  };
196
199
 
200
+ // src/lib/request_id.ts
201
+ var import_ulid = require("ulid");
202
+ var generateRequestID = () => (0, import_ulid.ulid)();
203
+
197
204
  // src/lib/runtime.ts
198
205
  var import_node_path = __toESM(require("path"), 1);
199
206
  var import_node_process = __toESM(require("process"), 1);
@@ -260,79 +267,98 @@ var NetlifyDev = class {
260
267
  #apiHost;
261
268
  #apiScheme;
262
269
  #apiToken;
270
+ #cleanupJobs;
271
+ #edgeFunctionsHandler;
272
+ #functionsHandler;
273
+ #functionsServePath;
263
274
  #config;
264
275
  #features;
276
+ #headersHandler;
265
277
  #logger;
266
278
  #projectRoot;
267
- #runtime;
279
+ #redirectsHandler;
280
+ #server;
268
281
  #siteID;
282
+ #staticHandler;
283
+ #staticHandlerAdditionalDirectories;
269
284
  constructor(options) {
270
285
  if (options.apiURL) {
271
286
  const apiURL = new URL(options.apiURL);
272
287
  this.#apiHost = apiURL.host;
273
288
  this.#apiScheme = apiURL.protocol.slice(0, -1);
274
289
  }
290
+ const projectRoot = options.projectRoot ?? import_node_process2.default.cwd();
275
291
  this.#apiToken = options.apiToken;
292
+ this.#cleanupJobs = [];
276
293
  this.#features = {
277
294
  blobs: options.blobs?.enabled !== false,
295
+ edgeFunctions: options.edgeFunctions?.enabled !== false,
278
296
  environmentVariables: options.environmentVariables?.enabled !== false,
279
297
  functions: options.functions?.enabled !== false,
298
+ headers: options.headers?.enabled !== false,
280
299
  redirects: options.redirects?.enabled !== false,
281
300
  static: options.staticFiles?.enabled !== false
282
301
  };
302
+ this.#functionsServePath = import_node_path2.default.join(projectRoot, ".netlify", "functions-serve");
283
303
  this.#logger = options.logger ?? globalThis.console;
284
- this.#projectRoot = options.projectRoot ?? import_node_process2.default.cwd();
304
+ this.#server = options.serverAddress;
305
+ this.#projectRoot = projectRoot;
306
+ this.#staticHandlerAdditionalDirectories = options.staticFiles?.directories ?? [];
285
307
  }
286
- async handleInEphemeralDirectory(request, destPath) {
287
- const userFunctionsPath = this.#config?.config.functionsDirectory ?? import_node_path2.default.join(this.#projectRoot, "netlify/functions");
288
- const userFunctionsPathExists = await isDirectory(userFunctionsPath);
289
- const functions = this.#features.functions ? new import_dev.FunctionsHandler({
290
- config: this.#config,
291
- destPath,
292
- projectRoot: this.#projectRoot,
293
- settings: {},
294
- siteId: this.#siteID,
295
- timeouts: {},
296
- userFunctionsPath: userFunctionsPathExists ? userFunctionsPath : void 0
297
- }) : null;
298
- const redirects = this.#features.redirects ? new import_redirects.RedirectsHandler({
299
- configPath: this.#config?.configPath,
300
- configRedirects: this.#config?.config.redirects,
301
- jwtRoleClaim: "",
302
- jwtSecret: "",
303
- notFoundHandler,
304
- projectDir: this.#projectRoot
305
- }) : null;
306
- const staticFiles = this.#features.static ? new import_static.StaticHandler({
307
- directory: this.#config?.config.build.publish ?? this.#projectRoot
308
- }) : null;
309
- const functionMatch = await functions?.match(request);
308
+ async handleInEphemeralDirectory(request, destPath, options = {}) {
309
+ const edgeFunctionResponse = await this.#edgeFunctionsHandler?.handle(request.clone());
310
+ if (edgeFunctionResponse) {
311
+ return { response: edgeFunctionResponse, type: "edge-function" };
312
+ }
313
+ const functionMatch = await this.#functionsHandler?.match(request, destPath);
310
314
  if (functionMatch) {
311
315
  if (functionMatch.preferStatic) {
312
- const staticMatch2 = await staticFiles?.match(request);
316
+ const staticMatch2 = await this.#staticHandler?.match(request);
313
317
  if (staticMatch2) {
314
- return staticMatch2.handle();
318
+ const response = await staticMatch2.handle();
319
+ await this.#headersHandler?.apply(request, response, options.headersCollector);
320
+ return { response, type: "static" };
315
321
  }
316
322
  }
317
- return functionMatch.handle(request);
323
+ return { response: await functionMatch.handle(request), type: "function" };
318
324
  }
319
- const redirectMatch = await redirects?.match(request);
325
+ const redirectMatch = await this.#redirectsHandler?.match(request);
320
326
  if (redirectMatch) {
321
- const functionMatch2 = await functions?.match(new Request(redirectMatch.target));
327
+ const functionMatch2 = await this.#functionsHandler?.match(new Request(redirectMatch.target), destPath);
322
328
  if (functionMatch2 && !functionMatch2.preferStatic) {
323
- return functionMatch2.handle(request);
329
+ return { response: await functionMatch2.handle(request), type: "function" };
324
330
  }
325
- const response = await redirects?.handle(request, redirectMatch, async (maybeStaticFile) => {
326
- const staticMatch2 = await staticFiles?.match(maybeStaticFile);
327
- return staticMatch2?.handle;
328
- });
331
+ const response = await this.#redirectsHandler?.handle(
332
+ request,
333
+ redirectMatch,
334
+ async (maybeStaticFile) => {
335
+ const staticMatch2 = await this.#staticHandler?.match(maybeStaticFile);
336
+ if (!staticMatch2) {
337
+ return;
338
+ }
339
+ return async () => {
340
+ const response2 = await staticMatch2.handle();
341
+ await this.#headersHandler?.apply(new Request(redirectMatch.target), response2, options.headersCollector);
342
+ return response2;
343
+ };
344
+ }
345
+ );
329
346
  if (response) {
330
- return response;
347
+ return { response, type: "redirect" };
331
348
  }
332
349
  }
333
- const staticMatch = await staticFiles?.match(request);
350
+ const { pathname } = new URL(request.url);
351
+ if (pathname.startsWith("/.netlify/images")) {
352
+ this.#logger.error(
353
+ "The Netlify Image CDN is currently only supported in the Netlify CLI. Run `npx netlify dev` to get started."
354
+ );
355
+ return;
356
+ }
357
+ const staticMatch = await this.#staticHandler?.match(request);
334
358
  if (staticMatch) {
335
- return staticMatch.handle();
359
+ const response = await staticMatch.handle();
360
+ await this.#headersHandler?.apply(request, response, options.headersCollector);
361
+ return { response, type: "static" };
336
362
  }
337
363
  }
338
364
  async getConfig() {
@@ -352,12 +378,17 @@ var NetlifyDev = class {
352
378
  });
353
379
  return config;
354
380
  }
355
- async handle(request) {
356
- const servePath = import_node_path2.default.join(this.#projectRoot, ".netlify", "functions-serve");
357
- await import_node_fs2.promises.mkdir(servePath, { recursive: true });
358
- const destPath = await import_node_fs2.promises.mkdtemp(import_node_path2.default.join(servePath, "_"));
381
+ async handle(request, options = {}) {
382
+ const result = await this.handleAndIntrospect(request, options);
383
+ return result?.response;
384
+ }
385
+ async handleAndIntrospect(request, options = {}) {
386
+ const requestID = generateRequestID();
387
+ request.headers.set("x-nf-request-id", requestID);
388
+ await import_node_fs2.promises.mkdir(this.#functionsServePath, { recursive: true });
389
+ const destPath = await import_node_fs2.promises.mkdtemp(import_node_path2.default.join(this.#functionsServePath, `${requestID}_`));
359
390
  try {
360
- return await this.handleInEphemeralDirectory(request, destPath);
391
+ return await this.handleInEphemeralDirectory(request, destPath, options);
361
392
  } finally {
362
393
  try {
363
394
  await import_node_fs2.promises.rm(destPath, { force: true, recursive: true });
@@ -382,9 +413,21 @@ var NetlifyDev = class {
382
413
  projectRoot: this.#projectRoot,
383
414
  siteID: siteID ?? "0"
384
415
  });
385
- this.#runtime = runtime;
386
- if (this.#features.environmentVariables && siteID) {
387
- await injectEnvVariables({
416
+ this.#cleanupJobs.push(() => runtime.stop());
417
+ let serverAddress;
418
+ if (typeof this.#server === "string") {
419
+ serverAddress = this.#server;
420
+ } else if (this.#features.edgeFunctions) {
421
+ const passthroughServer = new import_dev_utils.HTTPServer(async (req) => {
422
+ const res = await this.handle(req);
423
+ return res ?? new Response(null, { status: 404 });
424
+ });
425
+ this.#cleanupJobs.push(() => passthroughServer.stop());
426
+ serverAddress = await passthroughServer.start();
427
+ }
428
+ let envVariables = {};
429
+ if (this.#features.environmentVariables) {
430
+ envVariables = await injectEnvVariables({
388
431
  accountSlug: config?.siteInfo?.account_slug,
389
432
  baseVariables: config?.env || {},
390
433
  envAPI: runtime.env,
@@ -392,9 +435,74 @@ var NetlifyDev = class {
392
435
  siteID
393
436
  });
394
437
  }
438
+ if (this.#features.edgeFunctions && serverAddress !== void 0) {
439
+ const env = Object.entries(envVariables).reduce((acc, [key, variable]) => {
440
+ if (variable.usedSource === "account" || variable.usedSource === "addons" || variable.usedSource === "internal" || variable.usedSource === "ui" || variable.usedSource.startsWith(".env")) {
441
+ return {
442
+ ...acc,
443
+ [key]: variable.value
444
+ };
445
+ }
446
+ return acc;
447
+ }, {});
448
+ this.#edgeFunctionsHandler = new import_dev.EdgeFunctionsHandler({
449
+ configDeclarations: this.#config?.config.edge_functions ?? [],
450
+ directories: [this.#config?.config.build.edge_functions].filter(Boolean),
451
+ env,
452
+ geolocation: import_dev_utils.mockLocation,
453
+ logger: this.#logger,
454
+ originServerAddress: serverAddress,
455
+ siteID,
456
+ siteName: config?.siteInfo.name
457
+ });
458
+ }
459
+ if (this.#features.functions) {
460
+ const userFunctionsPath = this.#config?.config.functionsDirectory ?? import_node_path2.default.join(this.#projectRoot, "netlify/functions");
461
+ const userFunctionsPathExists = await isDirectory(userFunctionsPath);
462
+ this.#functionsHandler = new import_dev2.FunctionsHandler({
463
+ config: this.#config,
464
+ destPath: this.#functionsServePath,
465
+ geolocation: import_dev_utils.mockLocation,
466
+ projectRoot: this.#projectRoot,
467
+ settings: {},
468
+ siteId: this.#siteID,
469
+ timeouts: {},
470
+ userFunctionsPath: userFunctionsPathExists ? userFunctionsPath : void 0
471
+ });
472
+ }
473
+ if (this.#features.headers) {
474
+ this.#headersHandler = new import_headers.HeadersHandler({
475
+ configPath: this.#config?.configPath,
476
+ configHeaders: this.#config?.config.headers,
477
+ projectDir: this.#projectRoot,
478
+ publishDir: this.#config?.config.build.publish ?? void 0,
479
+ logger: this.#logger
480
+ });
481
+ }
482
+ if (this.#features.redirects) {
483
+ this.#redirectsHandler = new import_redirects.RedirectsHandler({
484
+ configPath: this.#config?.configPath,
485
+ configRedirects: this.#config?.config.redirects,
486
+ jwtRoleClaim: "",
487
+ jwtSecret: "",
488
+ notFoundHandler,
489
+ projectDir: this.#projectRoot
490
+ });
491
+ }
492
+ if (this.#features.static) {
493
+ this.#staticHandler = new import_static.StaticHandler({
494
+ directory: [
495
+ this.#config?.config.build.publish ?? this.#projectRoot,
496
+ ...this.#staticHandlerAdditionalDirectories
497
+ ]
498
+ });
499
+ }
500
+ return {
501
+ serverAddress
502
+ };
395
503
  }
396
504
  async stop() {
397
- await this.#runtime?.stop();
505
+ await Promise.allSettled(this.#cleanupJobs.map((task) => task()));
398
506
  }
399
507
  };
400
508
  // Annotate the CommonJS export names for ESM import in node:
package/dist/main.d.cts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Logger } from '@netlify/dev-utils';
2
+ import { HeadersCollector } from '@netlify/headers';
2
3
 
3
4
  interface Features {
4
5
  /**
@@ -7,7 +8,15 @@ interface Features {
7
8
  * {@link} https://docs.netlify.com/blobs/overview/
8
9
  */
9
10
  blobs?: {
10
- enabled: boolean;
11
+ enabled?: boolean;
12
+ };
13
+ /**
14
+ * Configuration options for environment variables.
15
+ *
16
+ * {@link} https://docs.netlify.com/edge-functions/overview/
17
+ */
18
+ edgeFunctions?: {
19
+ enabled?: boolean;
11
20
  };
12
21
  /**
13
22
  * Configuration options for environment variables.
@@ -15,7 +24,7 @@ interface Features {
15
24
  * {@link} https://docs.netlify.com/environment-variables/overview/
16
25
  */
17
26
  environmentVariables?: {
18
- enabled: boolean;
27
+ enabled?: boolean;
19
28
  };
20
29
  /**
21
30
  * Configuration options for Netlify Functions.
@@ -23,7 +32,15 @@ interface Features {
23
32
  * {@link} https://docs.netlify.com/functions/overview/
24
33
  */
25
34
  functions?: {
26
- enabled: boolean;
35
+ enabled?: boolean;
36
+ };
37
+ /**
38
+ * Configuration options for Netlify response headers.
39
+ *
40
+ * {@link} https://docs.netlify.com/routing/headers/
41
+ */
42
+ headers?: {
43
+ enabled?: boolean;
27
44
  };
28
45
  /**
29
46
  * Configuration options for Netlify redirects and rewrites.
@@ -31,13 +48,23 @@ interface Features {
31
48
  * {@link} https://docs.netlify.com/routing/redirects/
32
49
  */
33
50
  redirects?: {
34
- enabled: boolean;
51
+ enabled?: boolean;
35
52
  };
53
+ /**
54
+ * If your local development setup has its own HTTP server (e.g. Vite), set
55
+ * its address here.
56
+ */
57
+ serverAddress?: string;
36
58
  /**
37
59
  * Configuration options for serving static files.
38
60
  */
39
61
  staticFiles?: {
40
- enabled: boolean;
62
+ enabled?: boolean;
63
+ /**
64
+ * Additional list of directories where static files can be found. The
65
+ * `publish` directory configured on your site will be used automatically.
66
+ */
67
+ directories?: string[];
41
68
  };
42
69
  }
43
70
  interface NetlifyDevOptions extends Features {
@@ -46,15 +73,31 @@ interface NetlifyDevOptions extends Features {
46
73
  logger?: Logger;
47
74
  projectRoot?: string;
48
75
  }
76
+ interface HandleOptions {
77
+ /**
78
+ * An optional callback that will be called with every header (key and value)
79
+ * coming from header rules.
80
+ *
81
+ * {@link} https://docs.netlify.com/routing/headers/
82
+ */
83
+ headersCollector?: HeadersCollector;
84
+ }
85
+ type ResponseType = 'edge-function' | 'function' | 'redirect' | 'static';
49
86
  declare class NetlifyDev {
50
87
  #private;
51
88
  constructor(options: NetlifyDevOptions);
52
89
  private handleInEphemeralDirectory;
53
90
  private getConfig;
54
- handle(request: Request): Promise<Response | undefined>;
91
+ handle(request: Request, options?: HandleOptions): Promise<Response | undefined>;
92
+ handleAndIntrospect(request: Request, options?: HandleOptions): Promise<{
93
+ response: Response;
94
+ type: ResponseType;
95
+ } | undefined>;
55
96
  get siteIsLinked(): boolean;
56
- start(): Promise<void>;
97
+ start(): Promise<{
98
+ serverAddress: string | undefined;
99
+ }>;
57
100
  stop(): Promise<void>;
58
101
  }
59
102
 
60
- export { type Features, NetlifyDev };
103
+ export { type Features, NetlifyDev, type ResponseType };
package/dist/main.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Logger } from '@netlify/dev-utils';
2
+ import { HeadersCollector } from '@netlify/headers';
2
3
 
3
4
  interface Features {
4
5
  /**
@@ -7,7 +8,15 @@ interface Features {
7
8
  * {@link} https://docs.netlify.com/blobs/overview/
8
9
  */
9
10
  blobs?: {
10
- enabled: boolean;
11
+ enabled?: boolean;
12
+ };
13
+ /**
14
+ * Configuration options for environment variables.
15
+ *
16
+ * {@link} https://docs.netlify.com/edge-functions/overview/
17
+ */
18
+ edgeFunctions?: {
19
+ enabled?: boolean;
11
20
  };
12
21
  /**
13
22
  * Configuration options for environment variables.
@@ -15,7 +24,7 @@ interface Features {
15
24
  * {@link} https://docs.netlify.com/environment-variables/overview/
16
25
  */
17
26
  environmentVariables?: {
18
- enabled: boolean;
27
+ enabled?: boolean;
19
28
  };
20
29
  /**
21
30
  * Configuration options for Netlify Functions.
@@ -23,7 +32,15 @@ interface Features {
23
32
  * {@link} https://docs.netlify.com/functions/overview/
24
33
  */
25
34
  functions?: {
26
- enabled: boolean;
35
+ enabled?: boolean;
36
+ };
37
+ /**
38
+ * Configuration options for Netlify response headers.
39
+ *
40
+ * {@link} https://docs.netlify.com/routing/headers/
41
+ */
42
+ headers?: {
43
+ enabled?: boolean;
27
44
  };
28
45
  /**
29
46
  * Configuration options for Netlify redirects and rewrites.
@@ -31,13 +48,23 @@ interface Features {
31
48
  * {@link} https://docs.netlify.com/routing/redirects/
32
49
  */
33
50
  redirects?: {
34
- enabled: boolean;
51
+ enabled?: boolean;
35
52
  };
53
+ /**
54
+ * If your local development setup has its own HTTP server (e.g. Vite), set
55
+ * its address here.
56
+ */
57
+ serverAddress?: string;
36
58
  /**
37
59
  * Configuration options for serving static files.
38
60
  */
39
61
  staticFiles?: {
40
- enabled: boolean;
62
+ enabled?: boolean;
63
+ /**
64
+ * Additional list of directories where static files can be found. The
65
+ * `publish` directory configured on your site will be used automatically.
66
+ */
67
+ directories?: string[];
41
68
  };
42
69
  }
43
70
  interface NetlifyDevOptions extends Features {
@@ -46,15 +73,31 @@ interface NetlifyDevOptions extends Features {
46
73
  logger?: Logger;
47
74
  projectRoot?: string;
48
75
  }
76
+ interface HandleOptions {
77
+ /**
78
+ * An optional callback that will be called with every header (key and value)
79
+ * coming from header rules.
80
+ *
81
+ * {@link} https://docs.netlify.com/routing/headers/
82
+ */
83
+ headersCollector?: HeadersCollector;
84
+ }
85
+ type ResponseType = 'edge-function' | 'function' | 'redirect' | 'static';
49
86
  declare class NetlifyDev {
50
87
  #private;
51
88
  constructor(options: NetlifyDevOptions);
52
89
  private handleInEphemeralDirectory;
53
90
  private getConfig;
54
- handle(request: Request): Promise<Response | undefined>;
91
+ handle(request: Request, options?: HandleOptions): Promise<Response | undefined>;
92
+ handleAndIntrospect(request: Request, options?: HandleOptions): Promise<{
93
+ response: Response;
94
+ type: ResponseType;
95
+ } | undefined>;
55
96
  get siteIsLinked(): boolean;
56
- start(): Promise<void>;
97
+ start(): Promise<{
98
+ serverAddress: string | undefined;
99
+ }>;
57
100
  stop(): Promise<void>;
58
101
  }
59
102
 
60
- export { type Features, NetlifyDev };
103
+ export { type Features, NetlifyDev, type ResponseType };
package/dist/main.js CHANGED
@@ -1,10 +1,12 @@
1
1
  // src/main.ts
2
- import { promises as fs2 } from "node:fs";
3
- import path2 from "node:path";
4
- import process2 from "node:process";
2
+ import { promises as fs2 } from "fs";
3
+ import path2 from "path";
4
+ import process2 from "process";
5
5
  import { resolveConfig } from "@netlify/config";
6
- import { ensureNetlifyIgnore, getAPIToken, LocalState } from "@netlify/dev-utils";
6
+ import { ensureNetlifyIgnore, getAPIToken, mockLocation, LocalState, HTTPServer } from "@netlify/dev-utils";
7
+ import { EdgeFunctionsHandler } from "@netlify/edge-functions/dev";
7
8
  import { FunctionsHandler } from "@netlify/functions/dev";
9
+ import { HeadersHandler } from "@netlify/headers";
8
10
  import { RedirectsHandler } from "@netlify/redirects";
9
11
  import { StaticHandler } from "@netlify/static";
10
12
 
@@ -19,7 +21,7 @@ var injectEnvVariables = async ({
19
21
  }) => {
20
22
  const results = {};
21
23
  let variables = baseVariables;
22
- if (netlifyAPI && accountSlug) {
24
+ if (netlifyAPI && siteID && accountSlug) {
23
25
  variables = await getEnvelopeEnv({
24
26
  accountId: accountSlug,
25
27
  api: netlifyAPI,
@@ -35,7 +37,8 @@ var injectEnvVariables = async ({
35
37
  isInternal,
36
38
  originalValue: envAPI.get(key),
37
39
  overriddenSources,
38
- usedSource
40
+ usedSource,
41
+ value: variable.value
39
42
  };
40
43
  if (!existsInProcess || isInternal) {
41
44
  envAPI.set(key, variable.value);
@@ -142,7 +145,7 @@ var getEnvelopeEnv = async ({
142
145
  };
143
146
 
144
147
  // src/lib/fs.ts
145
- import { promises as fs } from "node:fs";
148
+ import { promises as fs } from "fs";
146
149
  var isDirectory = async (path3) => {
147
150
  try {
148
151
  const stat = await fs.stat(path3);
@@ -160,9 +163,13 @@ var isFile = async (path3) => {
160
163
  return false;
161
164
  };
162
165
 
166
+ // src/lib/request_id.ts
167
+ import { ulid } from "ulid";
168
+ var generateRequestID = () => ulid();
169
+
163
170
  // src/lib/runtime.ts
164
- import path from "node:path";
165
- import process from "node:process";
171
+ import path from "path";
172
+ import process from "process";
166
173
  import { BlobsServer } from "@netlify/blobs/server";
167
174
  import { startRuntime } from "@netlify/runtime";
168
175
  var restoreEnvironment = (snapshot) => {
@@ -226,79 +233,98 @@ var NetlifyDev = class {
226
233
  #apiHost;
227
234
  #apiScheme;
228
235
  #apiToken;
236
+ #cleanupJobs;
237
+ #edgeFunctionsHandler;
238
+ #functionsHandler;
239
+ #functionsServePath;
229
240
  #config;
230
241
  #features;
242
+ #headersHandler;
231
243
  #logger;
232
244
  #projectRoot;
233
- #runtime;
245
+ #redirectsHandler;
246
+ #server;
234
247
  #siteID;
248
+ #staticHandler;
249
+ #staticHandlerAdditionalDirectories;
235
250
  constructor(options) {
236
251
  if (options.apiURL) {
237
252
  const apiURL = new URL(options.apiURL);
238
253
  this.#apiHost = apiURL.host;
239
254
  this.#apiScheme = apiURL.protocol.slice(0, -1);
240
255
  }
256
+ const projectRoot = options.projectRoot ?? process2.cwd();
241
257
  this.#apiToken = options.apiToken;
258
+ this.#cleanupJobs = [];
242
259
  this.#features = {
243
260
  blobs: options.blobs?.enabled !== false,
261
+ edgeFunctions: options.edgeFunctions?.enabled !== false,
244
262
  environmentVariables: options.environmentVariables?.enabled !== false,
245
263
  functions: options.functions?.enabled !== false,
264
+ headers: options.headers?.enabled !== false,
246
265
  redirects: options.redirects?.enabled !== false,
247
266
  static: options.staticFiles?.enabled !== false
248
267
  };
268
+ this.#functionsServePath = path2.join(projectRoot, ".netlify", "functions-serve");
249
269
  this.#logger = options.logger ?? globalThis.console;
250
- this.#projectRoot = options.projectRoot ?? process2.cwd();
270
+ this.#server = options.serverAddress;
271
+ this.#projectRoot = projectRoot;
272
+ this.#staticHandlerAdditionalDirectories = options.staticFiles?.directories ?? [];
251
273
  }
252
- async handleInEphemeralDirectory(request, destPath) {
253
- const userFunctionsPath = this.#config?.config.functionsDirectory ?? path2.join(this.#projectRoot, "netlify/functions");
254
- const userFunctionsPathExists = await isDirectory(userFunctionsPath);
255
- const functions = this.#features.functions ? new FunctionsHandler({
256
- config: this.#config,
257
- destPath,
258
- projectRoot: this.#projectRoot,
259
- settings: {},
260
- siteId: this.#siteID,
261
- timeouts: {},
262
- userFunctionsPath: userFunctionsPathExists ? userFunctionsPath : void 0
263
- }) : null;
264
- const redirects = this.#features.redirects ? new RedirectsHandler({
265
- configPath: this.#config?.configPath,
266
- configRedirects: this.#config?.config.redirects,
267
- jwtRoleClaim: "",
268
- jwtSecret: "",
269
- notFoundHandler,
270
- projectDir: this.#projectRoot
271
- }) : null;
272
- const staticFiles = this.#features.static ? new StaticHandler({
273
- directory: this.#config?.config.build.publish ?? this.#projectRoot
274
- }) : null;
275
- const functionMatch = await functions?.match(request);
274
+ async handleInEphemeralDirectory(request, destPath, options = {}) {
275
+ const edgeFunctionResponse = await this.#edgeFunctionsHandler?.handle(request.clone());
276
+ if (edgeFunctionResponse) {
277
+ return { response: edgeFunctionResponse, type: "edge-function" };
278
+ }
279
+ const functionMatch = await this.#functionsHandler?.match(request, destPath);
276
280
  if (functionMatch) {
277
281
  if (functionMatch.preferStatic) {
278
- const staticMatch2 = await staticFiles?.match(request);
282
+ const staticMatch2 = await this.#staticHandler?.match(request);
279
283
  if (staticMatch2) {
280
- return staticMatch2.handle();
284
+ const response = await staticMatch2.handle();
285
+ await this.#headersHandler?.apply(request, response, options.headersCollector);
286
+ return { response, type: "static" };
281
287
  }
282
288
  }
283
- return functionMatch.handle(request);
289
+ return { response: await functionMatch.handle(request), type: "function" };
284
290
  }
285
- const redirectMatch = await redirects?.match(request);
291
+ const redirectMatch = await this.#redirectsHandler?.match(request);
286
292
  if (redirectMatch) {
287
- const functionMatch2 = await functions?.match(new Request(redirectMatch.target));
293
+ const functionMatch2 = await this.#functionsHandler?.match(new Request(redirectMatch.target), destPath);
288
294
  if (functionMatch2 && !functionMatch2.preferStatic) {
289
- return functionMatch2.handle(request);
295
+ return { response: await functionMatch2.handle(request), type: "function" };
290
296
  }
291
- const response = await redirects?.handle(request, redirectMatch, async (maybeStaticFile) => {
292
- const staticMatch2 = await staticFiles?.match(maybeStaticFile);
293
- return staticMatch2?.handle;
294
- });
297
+ const response = await this.#redirectsHandler?.handle(
298
+ request,
299
+ redirectMatch,
300
+ async (maybeStaticFile) => {
301
+ const staticMatch2 = await this.#staticHandler?.match(maybeStaticFile);
302
+ if (!staticMatch2) {
303
+ return;
304
+ }
305
+ return async () => {
306
+ const response2 = await staticMatch2.handle();
307
+ await this.#headersHandler?.apply(new Request(redirectMatch.target), response2, options.headersCollector);
308
+ return response2;
309
+ };
310
+ }
311
+ );
295
312
  if (response) {
296
- return response;
313
+ return { response, type: "redirect" };
297
314
  }
298
315
  }
299
- const staticMatch = await staticFiles?.match(request);
316
+ const { pathname } = new URL(request.url);
317
+ if (pathname.startsWith("/.netlify/images")) {
318
+ this.#logger.error(
319
+ "The Netlify Image CDN is currently only supported in the Netlify CLI. Run `npx netlify dev` to get started."
320
+ );
321
+ return;
322
+ }
323
+ const staticMatch = await this.#staticHandler?.match(request);
300
324
  if (staticMatch) {
301
- return staticMatch.handle();
325
+ const response = await staticMatch.handle();
326
+ await this.#headersHandler?.apply(request, response, options.headersCollector);
327
+ return { response, type: "static" };
302
328
  }
303
329
  }
304
330
  async getConfig() {
@@ -318,12 +344,17 @@ var NetlifyDev = class {
318
344
  });
319
345
  return config;
320
346
  }
321
- async handle(request) {
322
- const servePath = path2.join(this.#projectRoot, ".netlify", "functions-serve");
323
- await fs2.mkdir(servePath, { recursive: true });
324
- const destPath = await fs2.mkdtemp(path2.join(servePath, "_"));
347
+ async handle(request, options = {}) {
348
+ const result = await this.handleAndIntrospect(request, options);
349
+ return result?.response;
350
+ }
351
+ async handleAndIntrospect(request, options = {}) {
352
+ const requestID = generateRequestID();
353
+ request.headers.set("x-nf-request-id", requestID);
354
+ await fs2.mkdir(this.#functionsServePath, { recursive: true });
355
+ const destPath = await fs2.mkdtemp(path2.join(this.#functionsServePath, `${requestID}_`));
325
356
  try {
326
- return await this.handleInEphemeralDirectory(request, destPath);
357
+ return await this.handleInEphemeralDirectory(request, destPath, options);
327
358
  } finally {
328
359
  try {
329
360
  await fs2.rm(destPath, { force: true, recursive: true });
@@ -348,9 +379,21 @@ var NetlifyDev = class {
348
379
  projectRoot: this.#projectRoot,
349
380
  siteID: siteID ?? "0"
350
381
  });
351
- this.#runtime = runtime;
352
- if (this.#features.environmentVariables && siteID) {
353
- await injectEnvVariables({
382
+ this.#cleanupJobs.push(() => runtime.stop());
383
+ let serverAddress;
384
+ if (typeof this.#server === "string") {
385
+ serverAddress = this.#server;
386
+ } else if (this.#features.edgeFunctions) {
387
+ const passthroughServer = new HTTPServer(async (req) => {
388
+ const res = await this.handle(req);
389
+ return res ?? new Response(null, { status: 404 });
390
+ });
391
+ this.#cleanupJobs.push(() => passthroughServer.stop());
392
+ serverAddress = await passthroughServer.start();
393
+ }
394
+ let envVariables = {};
395
+ if (this.#features.environmentVariables) {
396
+ envVariables = await injectEnvVariables({
354
397
  accountSlug: config?.siteInfo?.account_slug,
355
398
  baseVariables: config?.env || {},
356
399
  envAPI: runtime.env,
@@ -358,9 +401,74 @@ var NetlifyDev = class {
358
401
  siteID
359
402
  });
360
403
  }
404
+ if (this.#features.edgeFunctions && serverAddress !== void 0) {
405
+ const env = Object.entries(envVariables).reduce((acc, [key, variable]) => {
406
+ if (variable.usedSource === "account" || variable.usedSource === "addons" || variable.usedSource === "internal" || variable.usedSource === "ui" || variable.usedSource.startsWith(".env")) {
407
+ return {
408
+ ...acc,
409
+ [key]: variable.value
410
+ };
411
+ }
412
+ return acc;
413
+ }, {});
414
+ this.#edgeFunctionsHandler = new EdgeFunctionsHandler({
415
+ configDeclarations: this.#config?.config.edge_functions ?? [],
416
+ directories: [this.#config?.config.build.edge_functions].filter(Boolean),
417
+ env,
418
+ geolocation: mockLocation,
419
+ logger: this.#logger,
420
+ originServerAddress: serverAddress,
421
+ siteID,
422
+ siteName: config?.siteInfo.name
423
+ });
424
+ }
425
+ if (this.#features.functions) {
426
+ const userFunctionsPath = this.#config?.config.functionsDirectory ?? path2.join(this.#projectRoot, "netlify/functions");
427
+ const userFunctionsPathExists = await isDirectory(userFunctionsPath);
428
+ this.#functionsHandler = new FunctionsHandler({
429
+ config: this.#config,
430
+ destPath: this.#functionsServePath,
431
+ geolocation: mockLocation,
432
+ projectRoot: this.#projectRoot,
433
+ settings: {},
434
+ siteId: this.#siteID,
435
+ timeouts: {},
436
+ userFunctionsPath: userFunctionsPathExists ? userFunctionsPath : void 0
437
+ });
438
+ }
439
+ if (this.#features.headers) {
440
+ this.#headersHandler = new HeadersHandler({
441
+ configPath: this.#config?.configPath,
442
+ configHeaders: this.#config?.config.headers,
443
+ projectDir: this.#projectRoot,
444
+ publishDir: this.#config?.config.build.publish ?? void 0,
445
+ logger: this.#logger
446
+ });
447
+ }
448
+ if (this.#features.redirects) {
449
+ this.#redirectsHandler = new RedirectsHandler({
450
+ configPath: this.#config?.configPath,
451
+ configRedirects: this.#config?.config.redirects,
452
+ jwtRoleClaim: "",
453
+ jwtSecret: "",
454
+ notFoundHandler,
455
+ projectDir: this.#projectRoot
456
+ });
457
+ }
458
+ if (this.#features.static) {
459
+ this.#staticHandler = new StaticHandler({
460
+ directory: [
461
+ this.#config?.config.build.publish ?? this.#projectRoot,
462
+ ...this.#staticHandlerAdditionalDirectories
463
+ ]
464
+ });
465
+ }
466
+ return {
467
+ serverAddress
468
+ };
361
469
  }
362
470
  async stop() {
363
- await this.#runtime?.stop();
471
+ await Promise.allSettled(this.#cleanupJobs.map((task) => task()));
364
472
  }
365
473
  };
366
474
  export {
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@netlify/dev",
3
- "version": "2.3.1",
3
+ "version": "4.0.0",
4
4
  "description": "Emulation of the Netlify environment for local development",
5
5
  "type": "module",
6
6
  "engines": {
7
- "node": "^14.16.0 || >=16.0.0"
7
+ "node": ">=20.6.1"
8
8
  },
9
9
  "main": "./dist/main.cjs",
10
10
  "module": "./dist/main.js",
@@ -46,19 +46,21 @@
46
46
  },
47
47
  "author": "Netlify Inc.",
48
48
  "devDependencies": {
49
- "@netlify/api": "^14.0.2",
50
- "@netlify/types": "1.2.0",
51
- "tmp-promise": "^3.0.3",
49
+ "@netlify/api": "^14.0.3",
50
+ "@netlify/types": "2.0.1",
52
51
  "tsup": "^8.0.0",
53
52
  "vitest": "^3.0.0"
54
53
  },
55
54
  "dependencies": {
56
- "@netlify/blobs": "9.1.2",
57
- "@netlify/config": "^23.0.7",
58
- "@netlify/dev-utils": "2.2.0",
59
- "@netlify/functions": "3.1.10",
60
- "@netlify/redirects": "1.1.4",
61
- "@netlify/runtime": "2.2.2",
62
- "@netlify/static": "1.1.4"
55
+ "@netlify/blobs": "9.1.4",
56
+ "@netlify/config": "^23.0.8",
57
+ "@netlify/dev-utils": "3.1.0",
58
+ "@netlify/edge-functions": "2.13.0",
59
+ "@netlify/functions": "4.1.0",
60
+ "@netlify/headers": "2.0.0",
61
+ "@netlify/redirects": "3.0.0",
62
+ "@netlify/runtime": "4.0.0",
63
+ "@netlify/static": "3.0.0",
64
+ "ulid": "^3.0.0"
63
65
  }
64
66
  }