wrangler 2.0.2 → 2.0.6

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/src/bundle.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import assert from "node:assert";
2
2
  import * as fs from "node:fs";
3
+ import { builtinModules } from "node:module";
3
4
  import * as path from "node:path";
4
5
  import NodeGlobalsPolyfills from "@esbuild-plugins/node-globals-polyfill";
5
6
  import NodeModulesPolyfills from "@esbuild-plugins/node-modules-polyfill";
@@ -16,6 +17,33 @@ type BundleResult = {
16
17
  stop: (() => void) | undefined;
17
18
  };
18
19
 
20
+ /**
21
+ * Searches for any uses of node's builtin modules, and throws an error if it
22
+ * finds anything. This plugin is only used when nodeCompat is not enabled.
23
+ * Supports both regular node builtins, and the new "node:<MODULE>" format.
24
+ */
25
+ const checkForNodeBuiltinsPlugin = {
26
+ name: "checkForNodeBuiltins",
27
+ setup(build: esbuild.PluginBuild) {
28
+ build.onResolve(
29
+ {
30
+ filter: new RegExp(
31
+ "^(" +
32
+ builtinModules.join("|") +
33
+ "|" +
34
+ builtinModules.map((module) => "node:" + module).join("|") +
35
+ ")$"
36
+ ),
37
+ },
38
+ () => {
39
+ throw new Error(
40
+ `Detected a Node builtin module import while Node compatibility is disabled.\nAdd node_compat = true to your wrangler.toml file to enable Node compatibility.`
41
+ );
42
+ }
43
+ );
44
+ },
45
+ };
46
+
19
47
  /**
20
48
  * Generate a bundle for the worker identified by the arguments passed in.
21
49
  */
@@ -60,6 +88,7 @@ export async function bundleWorker(
60
88
  format: entry.format,
61
89
  rules,
62
90
  });
91
+
63
92
  const result = await esbuild.build({
64
93
  ...getEntryPoint(entry.file, serveAssetsFromWorker),
65
94
  bundle: true,
@@ -87,7 +116,9 @@ export async function bundleWorker(
87
116
  moduleCollector.plugin,
88
117
  ...(nodeCompat
89
118
  ? [NodeGlobalsPolyfills({ buffer: true }), NodeModulesPolyfills()]
90
- : []),
119
+ : // we use checkForNodeBuiltinsPlugin to throw a nicer error
120
+ // if we find node builtins when nodeCompat isn't turned on
121
+ [checkForNodeBuiltinsPlugin]),
91
122
  ],
92
123
  ...(jsxFactory && { jsxFactory }),
93
124
  ...(jsxFragment && { jsxFragment }),
@@ -112,6 +112,9 @@ function addAuthorizationHeader(
112
112
  * doesn't return json. We inline the implementation and try not to share
113
113
  * any code with the other calls. We should push back on any new APIs that
114
114
  * try to introduce non-"standard" response structures.
115
+ *
116
+ * Note: any calls to fetchKVGetValue must call encodeURIComponent on key
117
+ * before passing it
115
118
  */
116
119
 
117
120
  export async function fetchKVGetValue(
@@ -161,7 +161,7 @@ export interface DevConfig {
161
161
  *
162
162
  * @default `8787`
163
163
  */
164
- port: number;
164
+ port: number | undefined;
165
165
 
166
166
  /**
167
167
  * Protocol that local wrangler dev server listens to requests on.
@@ -8,6 +8,24 @@ export interface Environment
8
8
  extends EnvironmentInheritable,
9
9
  EnvironmentNonInheritable {}
10
10
 
11
+ export type SimpleRoute = string;
12
+ export type ZoneIdRoute = {
13
+ pattern: string;
14
+ zone_id: string;
15
+ custom_domain?: boolean;
16
+ };
17
+ export type ZoneNameRoute = {
18
+ pattern: string;
19
+ zone_name: string;
20
+ custom_domain?: boolean;
21
+ };
22
+ export type CustomDomainRoute = { pattern: string; custom_domain: boolean };
23
+ export type Route =
24
+ | SimpleRoute
25
+ | ZoneIdRoute
26
+ | ZoneNameRoute
27
+ | CustomDomainRoute;
28
+
11
29
  /**
12
30
  * The `EnvironmentInheritable` interface declares all the configuration fields for an environment
13
31
  * that can be inherited (and overridden) from the top-level environment.
@@ -74,13 +92,7 @@ interface EnvironmentInheritable {
74
92
  *
75
93
  * @inheritable
76
94
  */
77
- routes:
78
- | (
79
- | string
80
- | { pattern: string; zone_id: string }
81
- | { pattern: string; zone_name: string }
82
- )[]
83
- | undefined;
95
+ routes: Route[] | undefined;
84
96
 
85
97
  /**
86
98
  * A route that your worker should be published to. Literally
@@ -91,13 +103,7 @@ interface EnvironmentInheritable {
91
103
  *
92
104
  * @inheritable
93
105
  */
94
- route:
95
- | (
96
- | string
97
- | { pattern: string; zone_id: string }
98
- | { pattern: string; zone_name: string }
99
- )
100
- | undefined;
106
+ route: Route | undefined;
101
107
 
102
108
  /**
103
109
  * Path to a custom tsconfig
@@ -236,6 +242,8 @@ interface EnvironmentNonInheritable {
236
242
  class_name: string;
237
243
  /** The script where the Durable Object is defined (if it's external to this worker) */
238
244
  script_name?: string;
245
+ /** The service environment of the script_name to bind to */
246
+ environment?: string;
239
247
  }[];
240
248
  };
241
249
 
@@ -279,6 +287,24 @@ interface EnvironmentNonInheritable {
279
287
  preview_bucket_name?: string;
280
288
  }[];
281
289
 
290
+ /**
291
+ * Specifies service bindings (worker-to-worker) that are bound to this Worker environment.
292
+ *
293
+ * NOTE: This field is not automatically inherited from the top level environment,
294
+ * and so must be specified in every named environment.
295
+ *
296
+ * @default `[]`
297
+ * @nonInheritable
298
+ */
299
+ services: {
300
+ /** The binding name used to refer to the bound service. */
301
+ binding: string;
302
+ /** The name of the service. */
303
+ service: string;
304
+ /** The environment of the service (e.g. production, staging, etc). */
305
+ environment?: string;
306
+ }[];
307
+
282
308
  /**
283
309
  * "Unsafe" tables for features that aren't directly supported by wrangler.
284
310
  *
@@ -352,7 +352,7 @@ function normalizeAndValidateDev(
352
352
  ): DevConfig {
353
353
  const {
354
354
  ip = "localhost",
355
- port = 8787,
355
+ port,
356
356
  local_protocol = "http",
357
357
  upstream_protocol = "https",
358
358
  host,
@@ -562,19 +562,37 @@ function normalizeAndValidateModulePaths(
562
562
  * or an object that looks like {pattern: string, zone_id: string }
563
563
  */
564
564
  function isValidRouteValue(item: unknown): boolean {
565
- return (
566
- !!item &&
567
- (typeof item === "string" ||
568
- (typeof item === "object" &&
569
- hasProperty(item, "pattern") &&
570
- typeof item.pattern === "string" &&
571
- // it could have a zone_name
572
- ((hasProperty(item, "zone_name") &&
573
- typeof item.zone_name === "string") ||
574
- // or a zone_id
575
- (hasProperty(item, "zone_id") && typeof item.zone_id === "string")) &&
576
- Object.keys(item).length === 2))
577
- );
565
+ if (!item) {
566
+ return false;
567
+ }
568
+ if (typeof item === "string") {
569
+ return true;
570
+ }
571
+ if (typeof item === "object") {
572
+ if (!hasProperty(item, "pattern") || typeof item.pattern !== "string") {
573
+ return false;
574
+ }
575
+
576
+ const otherKeys = Object.keys(item).length - 1; // minus one to subtract "pattern"
577
+
578
+ const hasZoneId =
579
+ hasProperty(item, "zone_id") && typeof item.zone_id === "string";
580
+ const hasZoneName =
581
+ hasProperty(item, "zone_name") && typeof item.zone_name === "string";
582
+ const hasCustomDomainFlag =
583
+ hasProperty(item, "custom_domain") &&
584
+ typeof item.custom_domain === "boolean";
585
+
586
+ if (otherKeys === 2 && hasCustomDomainFlag && (hasZoneId || hasZoneName)) {
587
+ return true;
588
+ } else if (
589
+ otherKeys === 1 &&
590
+ (hasZoneId || hasZoneName || hasCustomDomainFlag)
591
+ ) {
592
+ return true;
593
+ }
594
+ }
595
+ return false;
578
596
  }
579
597
 
580
598
  /**
@@ -583,7 +601,7 @@ function isValidRouteValue(item: unknown): boolean {
583
601
  const isRoute: ValidatorFn = (diagnostics, field, value) => {
584
602
  if (value !== undefined && !isValidRouteValue(value)) {
585
603
  diagnostics.errors.push(
586
- `Expected "${field}" to be either a string, or an object with shape { pattern, zone_id | zone_name }, but got ${JSON.stringify(
604
+ `Expected "${field}" to be either a string, or an object with shape { pattern, custom_domain, zone_id | zone_name }, but got ${JSON.stringify(
587
605
  value
588
606
  )}.`
589
607
  );
@@ -613,7 +631,7 @@ const isRouteArray: ValidatorFn = (diagnostics, field, value) => {
613
631
  }
614
632
  if (invalidRoutes.length > 0) {
615
633
  diagnostics.errors.push(
616
- `Expected "${field}" to be an array of either strings or objects with the shape { pattern, zone_id | zone_name }, but these weren't valid: ${JSON.stringify(
634
+ `Expected "${field}" to be an array of either strings or objects with the shape { pattern, custom_domain, zone_id | zone_name }, but these weren't valid: ${JSON.stringify(
617
635
  invalidRoutes,
618
636
  null,
619
637
  2
@@ -696,27 +714,12 @@ function normalizeAndValidateEnvironment(
696
714
  diagnostics,
697
715
  rawEnv,
698
716
  "experimental_services",
699
- `The "experimental_services" field is no longer supported. Instead, use [[unsafe.bindings]] to enable experimental features. Add this to your wrangler.toml:\n` +
700
- "```\n" +
701
- TOML.stringify({
702
- unsafe: {
703
- bindings: (rawEnv?.experimental_services || []).map(
704
- (serviceDefinition) => {
705
- return {
706
- name: serviceDefinition.name,
707
- type: "service",
708
- service: serviceDefinition.service,
709
- environment: serviceDefinition.environment,
710
- };
711
- }
712
- ),
713
- },
714
- }) +
715
- "```",
717
+ `The "experimental_services" field is no longer supported. Simply rename the [experimental_services] field to [services].`,
716
718
  true
717
719
  );
718
720
 
719
721
  experimental(diagnostics, rawEnv, "unsafe");
722
+ experimental(diagnostics, rawEnv, "services");
720
723
 
721
724
  const route = validateRoute(diagnostics, topLevelEnv, rawEnv);
722
725
 
@@ -880,6 +883,16 @@ function normalizeAndValidateEnvironment(
880
883
  validateBindingArray(envName, validateR2Binding),
881
884
  []
882
885
  ),
886
+ services: notInheritable(
887
+ diagnostics,
888
+ topLevelEnv,
889
+ rawConfig,
890
+ rawEnv,
891
+ envName,
892
+ "services",
893
+ validateBindingArray(envName, validateServiceBinding),
894
+ []
895
+ ),
883
896
  unsafe: notInheritable(
884
897
  diagnostics,
885
898
  topLevelEnv,
@@ -1014,7 +1027,7 @@ const validateRule: ValidatorFn = (diagnostics, field, value) => {
1014
1027
 
1015
1028
  if (!isOptionalProperty(rule, "fallthrough", "boolean")) {
1016
1029
  diagnostics.errors.push(
1017
- `binding should, optionally, have a boolean "fallthrough" field.`
1030
+ `the field "fallthrough", when present, should be a boolean.`
1018
1031
  );
1019
1032
  isValid = false;
1020
1033
  }
@@ -1135,7 +1148,7 @@ const validateDurableObjectBinding: ValidatorFn = (
1135
1148
  return false;
1136
1149
  }
1137
1150
 
1138
- // Durable Object bindings must have a name and class_name, and optionally a script_name.
1151
+ // Durable Object bindings must have a name and class_name, and optionally a script_name and an environment.
1139
1152
  let isValid = true;
1140
1153
  if (!isRequiredProperty(value, "name", "string")) {
1141
1154
  diagnostics.errors.push(`binding should have a string "name" field.`);
@@ -1147,7 +1160,21 @@ const validateDurableObjectBinding: ValidatorFn = (
1147
1160
  }
1148
1161
  if (!isOptionalProperty(value, "script_name", "string")) {
1149
1162
  diagnostics.errors.push(
1150
- `binding should, optionally, have a string "script_name" field.`
1163
+ `the field "script_name", when present, should be a string.`
1164
+ );
1165
+ isValid = false;
1166
+ }
1167
+ // environment requires a script_name
1168
+ if (!isOptionalProperty(value, "environment", "string")) {
1169
+ diagnostics.errors.push(
1170
+ `the field "environment", when present, should be a string.`
1171
+ );
1172
+ isValid = false;
1173
+ }
1174
+
1175
+ if ("environment" in value && !("script_name" in value)) {
1176
+ diagnostics.errors.push(
1177
+ `binding should have a "script_name" field if "environment" is present.`
1151
1178
  );
1152
1179
  isValid = false;
1153
1180
  }
@@ -1178,8 +1205,13 @@ const validateUnsafeBinding: ValidatorFn = (diagnostics, field, value) => {
1178
1205
  const safeBindings = [
1179
1206
  "plain_text",
1180
1207
  "json",
1208
+ "wasm_module",
1209
+ "data_blob",
1210
+ "text_blob",
1181
1211
  "kv_namespace",
1182
1212
  "durable_object_namespace",
1213
+ "r2_bucket",
1214
+ "service",
1183
1215
  ];
1184
1216
 
1185
1217
  if (safeBindings.includes(value.type)) {
@@ -1422,3 +1454,38 @@ const validateBindingsHaveUniqueNames = (
1422
1454
 
1423
1455
  return !hasDuplicates;
1424
1456
  };
1457
+ const validateServiceBinding: ValidatorFn = (diagnostics, field, value) => {
1458
+ if (typeof value !== "object" || value === null) {
1459
+ diagnostics.errors.push(
1460
+ `"services" bindings should be objects, but got ${JSON.stringify(value)}`
1461
+ );
1462
+ return false;
1463
+ }
1464
+ let isValid = true;
1465
+ // Service bindings must have a binding, service, and environment.
1466
+ if (!isRequiredProperty(value, "binding", "string")) {
1467
+ diagnostics.errors.push(
1468
+ `"${field}" bindings should have a string "binding" field but got ${JSON.stringify(
1469
+ value
1470
+ )}.`
1471
+ );
1472
+ isValid = false;
1473
+ }
1474
+ if (!isRequiredProperty(value, "service", "string")) {
1475
+ diagnostics.errors.push(
1476
+ `"${field}" bindings should have a string "service" field but got ${JSON.stringify(
1477
+ value
1478
+ )}.`
1479
+ );
1480
+ isValid = false;
1481
+ }
1482
+ if (!isOptionalProperty(value, "environment", "string")) {
1483
+ diagnostics.errors.push(
1484
+ `"${field}" bindings should have a string "environment" field but got ${JSON.stringify(
1485
+ value
1486
+ )}.`
1487
+ );
1488
+ isValid = false;
1489
+ }
1490
+ return isValid;
1491
+ };
@@ -32,20 +32,24 @@ export interface WorkerMetadata {
32
32
  compatibility_flags?: string[];
33
33
  usage_model?: "bundled" | "unbound";
34
34
  migrations?: CfDurableObjectMigrations;
35
+ // If you add any new binding types here, also add it to safeBindings
36
+ // under validateUnsafeBinding in config/validation.ts
35
37
  bindings: (
36
- | { type: "kv_namespace"; name: string; namespace_id: string }
37
38
  | { type: "plain_text"; name: string; text: string }
38
39
  | { type: "json"; name: string; json: unknown }
39
40
  | { type: "wasm_module"; name: string; part: string }
40
41
  | { type: "text_blob"; name: string; part: string }
41
42
  | { type: "data_blob"; name: string; part: string }
43
+ | { type: "kv_namespace"; name: string; namespace_id: string }
42
44
  | {
43
45
  type: "durable_object_namespace";
44
46
  name: string;
45
47
  class_name: string;
46
48
  script_name?: string;
49
+ environment?: string;
47
50
  }
48
51
  | { type: "r2_bucket"; name: string; bucket_name: string }
52
+ | { type: "service"; name: string; service: string; environment?: string }
49
53
  )[];
50
54
  }
51
55
 
@@ -67,6 +71,14 @@ export function createWorkerUploadForm(worker: CfWorkerInit): FormData {
67
71
 
68
72
  const metadataBindings: WorkerMetadata["bindings"] = [];
69
73
 
74
+ Object.entries(bindings.vars || {})?.forEach(([key, value]) => {
75
+ if (typeof value === "string") {
76
+ metadataBindings.push({ name: key, type: "plain_text", text: value });
77
+ } else {
78
+ metadataBindings.push({ name: key, type: "json", json: value });
79
+ }
80
+ });
81
+
70
82
  bindings.kv_namespaces?.forEach(({ id, binding }) => {
71
83
  metadataBindings.push({
72
84
  name: binding,
@@ -76,12 +88,13 @@ export function createWorkerUploadForm(worker: CfWorkerInit): FormData {
76
88
  });
77
89
 
78
90
  bindings.durable_objects?.bindings.forEach(
79
- ({ name, class_name, script_name }) => {
91
+ ({ name, class_name, script_name, environment }) => {
80
92
  metadataBindings.push({
81
93
  name,
82
94
  type: "durable_object_namespace",
83
95
  class_name: class_name,
84
96
  ...(script_name && { script_name }),
97
+ ...(environment && { environment }),
85
98
  });
86
99
  }
87
100
  );
@@ -94,12 +107,13 @@ export function createWorkerUploadForm(worker: CfWorkerInit): FormData {
94
107
  });
95
108
  });
96
109
 
97
- Object.entries(bindings.vars || {})?.forEach(([key, value]) => {
98
- if (typeof value === "string") {
99
- metadataBindings.push({ name: key, type: "plain_text", text: value });
100
- } else {
101
- metadataBindings.push({ name: key, type: "json", json: value });
102
- }
110
+ bindings.services?.forEach(({ binding, service, environment }) => {
111
+ metadataBindings.push({
112
+ name: binding,
113
+ type: "service",
114
+ service,
115
+ ...(environment && { environment }),
116
+ });
103
117
  });
104
118
 
105
119
  for (const [name, filePath] of Object.entries(bindings.wasm_modules || {})) {
package/src/dev/dev.tsx CHANGED
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import * as path from "node:path";
2
3
  import { watch } from "chokidar";
3
4
  import clipboardy from "clipboardy";
4
5
  import commandExists from "command-exists";
@@ -242,9 +243,11 @@ function useCustomBuild(
242
243
  persistent: true,
243
244
  ignoreInitial: true,
244
245
  }).on("all", (_event, filePath) => {
246
+ const relativeFile =
247
+ path.relative(expectedEntry.directory, expectedEntry.file) || ".";
245
248
  //TODO: we should buffer requests to the proxy until this completes
246
249
  logger.log(`The file ${filePath} changed, restarting build...`);
247
- runCustomBuild(expectedEntry.file, build).catch((err) => {
250
+ runCustomBuild(expectedEntry.file, relativeFile, build).catch((err) => {
248
251
  logger.error("Custom build failed:", err);
249
252
  });
250
253
  });
@@ -253,7 +256,7 @@ function useCustomBuild(
253
256
  return () => {
254
257
  watcher?.close();
255
258
  };
256
- }, [build, expectedEntry.file]);
259
+ }, [build, expectedEntry]);
257
260
  }
258
261
 
259
262
  function sleep(period: number) {
package/src/dev/local.tsx CHANGED
@@ -87,6 +87,11 @@ function useLocalWorker({
87
87
  '⎔ A "public" folder is not yet supported in local mode.'
88
88
  );
89
89
  }
90
+ if (bindings.services && bindings.services.length > 0) {
91
+ throw new Error(
92
+ "⎔ Service bindings are not yet supported in local mode."
93
+ );
94
+ }
90
95
 
91
96
  // In local mode, we want to copy all referenced modules into
92
97
  // the output bundle directory before starting up
@@ -297,6 +302,7 @@ function useLocalWorker({
297
302
  bindings.durable_objects?.bindings,
298
303
  bindings.kv_namespaces,
299
304
  bindings.vars,
305
+ bindings.services,
300
306
  compatibilityDate,
301
307
  compatibilityFlags,
302
308
  localPersistencePath,
package/src/entry.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert";
2
- import { existsSync } from "node:fs";
2
+ import { existsSync, statSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import * as esbuild from "esbuild";
5
5
  import { execaCommand } from "execa";
@@ -45,11 +45,16 @@ export async function getEntry(
45
45
  file = path.resolve(directory, config.main);
46
46
  }
47
47
 
48
- await runCustomBuild(file, config.build);
48
+ const relativeFile = path.relative(directory, file) || ".";
49
+ await runCustomBuild(file, relativeFile, config.build);
49
50
 
50
51
  if (fileExists(file) === false) {
51
52
  throw new Error(
52
- `Could not resolve "${path.relative(process.cwd(), file)}".`
53
+ getMissingEntryPointMessage(
54
+ `The entry-point file at "${relativeFile}" was not found.`,
55
+ file,
56
+ relativeFile
57
+ )
53
58
  );
54
59
  }
55
60
  const format = await guessWorkerFormat(
@@ -90,7 +95,8 @@ export async function getEntry(
90
95
  }
91
96
 
92
97
  export async function runCustomBuild(
93
- expectedEntry: string,
98
+ expectedEntryAbsolute: string,
99
+ expectedEntryRelative: string,
94
100
  build: Config["build"]
95
101
  ) {
96
102
  if (build?.command) {
@@ -105,12 +111,14 @@ export async function runCustomBuild(
105
111
  ...(build.cwd && { cwd: build.cwd }),
106
112
  });
107
113
 
108
- if (fileExists(expectedEntry) === false) {
114
+ if (fileExists(expectedEntryAbsolute) === false) {
109
115
  throw new Error(
110
- `Could not resolve "${path.relative(
111
- process.cwd(),
112
- expectedEntry
113
- )}" after running custom build: ${build.command}`
116
+ getMissingEntryPointMessage(
117
+ `The expected output file at "${expectedEntryRelative}" was not found after running custom build: ${build.command}.\n` +
118
+ "The `main` property in wrangler.toml should point to the file generated by the custom build.",
119
+ expectedEntryAbsolute,
120
+ expectedEntryRelative
121
+ )
114
122
  );
115
123
  }
116
124
  }
@@ -265,3 +273,50 @@ function generateAddScriptNameExamples(
265
273
  })
266
274
  .join("\n");
267
275
  }
276
+
277
+ /**
278
+ * Generate an appropriate message for when the entry-point is missing.
279
+ *
280
+ * To be more helpful to developers, we check whether there is a suitable file
281
+ * nearby to the expected file path.
282
+ */
283
+ function getMissingEntryPointMessage(
284
+ message: string,
285
+ absoluteEntryPointPath: string,
286
+ relativeEntryPointPath: string
287
+ ): string {
288
+ if (
289
+ existsSync(absoluteEntryPointPath) &&
290
+ statSync(absoluteEntryPointPath).isDirectory()
291
+ ) {
292
+ // The expected entry-point is a directory, so offer further guidance.
293
+ message += `\nThe provided entry-point path, "${relativeEntryPointPath}", points to a directory, rather than a file.\n`;
294
+
295
+ // Perhaps we can even guess what the correct path should be...
296
+ const possiblePaths: string[] = [];
297
+ for (const basenamePath of [
298
+ "worker",
299
+ "dist/worker",
300
+ "index",
301
+ "dist/index",
302
+ ]) {
303
+ for (const extension of [".ts", ".tsx", ".js", ".jsx"]) {
304
+ const filePath = basenamePath + extension;
305
+ if (fileExists(path.resolve(absoluteEntryPointPath, filePath))) {
306
+ possiblePaths.push(path.join(relativeEntryPointPath, filePath));
307
+ }
308
+ }
309
+ }
310
+
311
+ if (possiblePaths.length > 0) {
312
+ message +=
313
+ `\nDid you mean to set the main field to${
314
+ possiblePaths.length > 1 ? " one of" : ""
315
+ }:\n` +
316
+ "```\n" +
317
+ possiblePaths.map((filePath) => `main = "./${filePath}"\n`).join("") +
318
+ "```";
319
+ }
320
+ }
321
+ return message;
322
+ }