@superblocksteam/sdk 2.0.146 → 2.0.147

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.
Files changed (36) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/dev-utils/dev-server-metrics.d.mts +9 -0
  3. package/dist/dev-utils/dev-server-metrics.d.mts.map +1 -1
  4. package/dist/dev-utils/dev-server-metrics.mjs +14 -0
  5. package/dist/dev-utils/dev-server-metrics.mjs.map +1 -1
  6. package/dist/dev-utils/dev-server.d.mts +28 -0
  7. package/dist/dev-utils/dev-server.d.mts.map +1 -1
  8. package/dist/dev-utils/dev-server.mjs +55 -20
  9. package/dist/dev-utils/dev-server.mjs.map +1 -1
  10. package/dist/dev-utils/dev-server.process-errors.test.d.mts +2 -0
  11. package/dist/dev-utils/dev-server.process-errors.test.d.mts.map +1 -0
  12. package/dist/dev-utils/dev-server.process-errors.test.mjs +68 -0
  13. package/dist/dev-utils/dev-server.process-errors.test.mjs.map +1 -0
  14. package/dist/dev-utils/fatal-exit.d.mts +13 -0
  15. package/dist/dev-utils/fatal-exit.d.mts.map +1 -1
  16. package/dist/dev-utils/fatal-exit.mjs +103 -0
  17. package/dist/dev-utils/fatal-exit.mjs.map +1 -1
  18. package/dist/dev-utils/fatal-exit.test.mjs +138 -1
  19. package/dist/dev-utils/fatal-exit.test.mjs.map +1 -1
  20. package/dist/dev-utils/recoverable-build-error.d.mts +31 -0
  21. package/dist/dev-utils/recoverable-build-error.d.mts.map +1 -0
  22. package/dist/dev-utils/recoverable-build-error.mjs +92 -0
  23. package/dist/dev-utils/recoverable-build-error.mjs.map +1 -0
  24. package/dist/dev-utils/recoverable-build-error.test.d.mts +2 -0
  25. package/dist/dev-utils/recoverable-build-error.test.d.mts.map +1 -0
  26. package/dist/dev-utils/recoverable-build-error.test.mjs +176 -0
  27. package/dist/dev-utils/recoverable-build-error.test.mjs.map +1 -0
  28. package/package.json +6 -6
  29. package/src/dev-utils/dev-server-metrics.mts +19 -0
  30. package/src/dev-utils/dev-server.mts +85 -31
  31. package/src/dev-utils/dev-server.process-errors.test.mts +103 -0
  32. package/src/dev-utils/fatal-exit.mts +128 -0
  33. package/src/dev-utils/fatal-exit.test.mts +202 -0
  34. package/src/dev-utils/recoverable-build-error.mts +151 -0
  35. package/src/dev-utils/recoverable-build-error.test.mts +236 -0
  36. package/tsconfig.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@superblocksteam/sdk",
3
- "version": "2.0.146",
3
+ "version": "2.0.147",
4
4
  "description": "Superblocks JS SDK",
5
5
  "homepage": "https://www.superblocks.com",
6
6
  "license": "Superblocks Community Software License",
@@ -57,11 +57,11 @@
57
57
  "vite-tsconfig-paths": "^6.0.4",
58
58
  "winston": "^3.17.0",
59
59
  "yaml": "^2.7.1",
60
- "@superblocksteam/library-shared": "2.0.146",
61
- "@superblocksteam/shared": "0.9603.0",
62
- "@superblocksteam/telemetry": "2.0.146",
63
- "@superblocksteam/util": "2.0.146",
64
- "@superblocksteam/vite-plugin-file-sync": "2.0.146"
60
+ "@superblocksteam/library-shared": "2.0.147",
61
+ "@superblocksteam/shared": "0.9603.1",
62
+ "@superblocksteam/telemetry": "2.0.147",
63
+ "@superblocksteam/util": "2.0.147",
64
+ "@superblocksteam/vite-plugin-file-sync": "2.0.147"
65
65
  },
66
66
  "devDependencies": {
67
67
  "@eslint/js": "^9.39.2",
@@ -504,6 +504,25 @@ class DevServerMetrics {
504
504
  });
505
505
  }
506
506
 
507
+ /**
508
+ * Records a Vite/esbuild build error suppressed instead of exiting. This is
509
+ * the alertable replacement for the `dev_server_fatal_exit` log token when
510
+ * optimizeDeps or transform failures would previously crash the pod.
511
+ */
512
+ recordRecoverableBuildError(labels: {
513
+ category: string;
514
+ handler: RecoverableShutdownHandler;
515
+ }): void {
516
+ this.record(() => {
517
+ getMeter()
518
+ .createCounter("dev_server_recoverable_build_error_total", {
519
+ description:
520
+ "Count of Vite/esbuild build errors suppressed instead of exiting, by category and handler.",
521
+ })
522
+ .add(1, labels);
523
+ });
524
+ }
525
+
507
526
  /**
508
527
  * Records an ENOSPC disk-recovery attempt and its outcome. Lets dashboards
509
528
  * and alerts answer "how often is the live-edit PVC filling up, and is
@@ -76,6 +76,7 @@ import {
76
76
  } from "./fatal-exit.mjs";
77
77
  import { OPTIMIZE_DEPS_CONFIG } from "./optimize-deps-config.mjs";
78
78
  import { prepareInPlaceRestart } from "./prepare-in-place-restart.mjs";
79
+ import { handleRecoverableBuildError } from "./recoverable-build-error.mjs";
79
80
  import {
80
81
  formatViteDevServerStartedLog,
81
82
  logViteBuildError,
@@ -521,6 +522,73 @@ export function buildStatusPayload<T extends object>(
521
522
  return { ...base, serverErrors: devServerStatus?.serverErrors ?? [] };
522
523
  }
523
524
 
525
+ type ProcessErrorHandler = "uncaughtException" | "unhandledRejection";
526
+
527
+ // Both listeners log a recoverable shutdown, but with different wording, and
528
+ // these lines are what on-call greps when a pod stays up instead of restarting.
529
+ // Keeping the two strings side by side makes that difference deliberate.
530
+ const RECOVERABLE_SHUTDOWN_LOG_MESSAGE: Record<ProcessErrorHandler, string> = {
531
+ uncaughtException: "Ignoring recoverable shutdown error (uncaughtException)",
532
+ unhandledRejection:
533
+ "Ignoring recoverable shutdown rejection (unhandledRejection)",
534
+ };
535
+
536
+ /**
537
+ * Decides whether a process-level error keeps the pod alive, and performs the
538
+ * recording for the cases that do. Returns `true` when the caller must return
539
+ * early without taking the fatal-exit path.
540
+ *
541
+ * Extracted from the `uncaughtException` / `unhandledRejection` listeners so
542
+ * this decision is testable: registered listeners are effectively unreachable
543
+ * from unit tests, so an early `return` could be deleted with the whole suite
544
+ * staying green while the pod died on every recoverable build error.
545
+ */
546
+ export function handleNonFatalProcessError(params: {
547
+ devServerStatus?: { serverErrors: ServerError[] };
548
+ devServerMetrics: {
549
+ recordRecoverableBuildError: (labels: {
550
+ category: string;
551
+ handler: ProcessErrorHandler;
552
+ }) => void;
553
+ recordRecoverableShutdownError: (
554
+ handler: ProcessErrorHandler,
555
+ reason: unknown,
556
+ ) => void;
557
+ };
558
+ handler: ProcessErrorHandler;
559
+ logger: { warn: (message: string, meta?: unknown) => void };
560
+ reason: unknown;
561
+ }): boolean {
562
+ // A connection or server we depend on closing mid-flight (socket code 8/10,
563
+ // or Vite's ERR_CLOSED_SERVER from a call racing an in-place restart) is
564
+ // expected and recoverable, so it must not kill the pod. See
565
+ // isRecoverableShutdownError for the shapes and why their owning paths
566
+ // already treat them as non-fatal.
567
+ if (isRecoverableShutdownError(params.reason)) {
568
+ params.logger.warn(
569
+ RECOVERABLE_SHUTDOWN_LOG_MESSAGE[params.handler],
570
+ getErrorMeta(params.reason),
571
+ );
572
+ // Keep an alertable signal. Suppressing the fatal exit drops the
573
+ // dev_server_fatal_exit log token, so this counter is the only
574
+ // dashboard-queryable way to see these (and catch a regression in their
575
+ // rate) without a log search.
576
+ params.devServerMetrics.recordRecoverableShutdownError(
577
+ params.handler,
578
+ params.reason,
579
+ );
580
+ return true;
581
+ }
582
+
583
+ return handleRecoverableBuildError({
584
+ devServerStatus: params.devServerStatus,
585
+ devServerMetrics: params.devServerMetrics,
586
+ handler: params.handler,
587
+ logger: params.logger,
588
+ reason: params.reason,
589
+ });
590
+ }
591
+
524
592
  /**
525
593
  * Stable per-process identity for the dev-server runtime. `bootId` is generated
526
594
  * once at module load, so it changes on every process start (including an
@@ -1232,24 +1300,15 @@ export async function createDevServer({
1232
1300
  if (fatalExitHandled) {
1233
1301
  return;
1234
1302
  }
1235
- // A connection or server we depend on closing mid-flight (socket code 8/10,
1236
- // or Vite's ERR_CLOSED_SERVER from a call racing an in-place restart) is
1237
- // expected and recoverable, so it must not kill the pod. See
1238
- // isRecoverableShutdownError for the shapes and why their owning paths
1239
- // already treat them as non-fatal.
1240
- if (isRecoverableShutdownError(error)) {
1241
- logger.warn(
1242
- "Ignoring recoverable shutdown error (uncaughtException)",
1243
- getErrorMeta(error),
1244
- );
1245
- // Keep an alertable signal. Suppressing the fatal exit drops the
1246
- // dev_server_fatal_exit log token, so this counter is the only
1247
- // dashboard-queryable way to see these (and catch a regression in their
1248
- // rate) without a log search.
1249
- devServerMetrics.recordRecoverableShutdownError(
1250
- "uncaughtException",
1251
- error,
1252
- );
1303
+ if (
1304
+ handleNonFatalProcessError({
1305
+ devServerStatus,
1306
+ devServerMetrics,
1307
+ handler: "uncaughtException",
1308
+ logger,
1309
+ reason: error,
1310
+ })
1311
+ ) {
1253
1312
  return;
1254
1313
  }
1255
1314
  fatalExitHandled = true;
@@ -1286,20 +1345,15 @@ export async function createDevServer({
1286
1345
  if (fatalExitHandled) {
1287
1346
  return;
1288
1347
  }
1289
- // See the uncaughtException handler above: a connection/server close racing
1290
- // an in-place restart (socket code 8/10 or Vite's ERR_CLOSED_SERVER) is
1291
- // recoverable and must not wedge the pod (APPS-4797).
1292
- if (isRecoverableShutdownError(reason)) {
1293
- logger.warn(
1294
- "Ignoring recoverable shutdown rejection (unhandledRejection)",
1295
- getErrorMeta(reason),
1296
- );
1297
- // Alertable signal in place of the suppressed dev_server_fatal_exit log
1298
- // token; see the uncaughtException handler above.
1299
- devServerMetrics.recordRecoverableShutdownError(
1300
- "unhandledRejection",
1348
+ if (
1349
+ handleNonFatalProcessError({
1350
+ devServerStatus,
1351
+ devServerMetrics,
1352
+ handler: "unhandledRejection",
1353
+ logger,
1301
1354
  reason,
1302
- );
1355
+ })
1356
+ ) {
1303
1357
  return;
1304
1358
  }
1305
1359
  fatalExitHandled = true;
@@ -0,0 +1,103 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import type { ServerError } from "@superblocksteam/library-shared/types";
4
+
5
+ import { handleNonFatalProcessError } from "./dev-server.mjs";
6
+
7
+ /**
8
+ * The `uncaughtException` / `unhandledRejection` listeners decide whether an
9
+ * error keeps the pod alive or takes it through the fatal-exit path. That
10
+ * decision is what these tests pin: without it, deleting an early `return` in
11
+ * either listener leaves the whole suite green while the pod dies on every
12
+ * recoverable build error — the exact regression this PR exists to prevent.
13
+ */
14
+ describe("handleNonFatalProcessError", () => {
15
+ const makeParams = (
16
+ reason: unknown,
17
+ handler: "uncaughtException" | "unhandledRejection" = "unhandledRejection",
18
+ ) => {
19
+ const logger = { warn: vi.fn(), error: vi.fn() };
20
+ const devServerStatus = { serverErrors: [] as ServerError[] };
21
+ const devServerMetrics = {
22
+ recordRecoverableBuildError: vi.fn(),
23
+ recordRecoverableShutdownError: vi.fn(),
24
+ };
25
+ return { devServerMetrics, devServerStatus, handler, logger, reason };
26
+ };
27
+
28
+ it("keeps the pod alive for a Vite build error and records it for the editor", () => {
29
+ const params = makeParams({
30
+ errors: [{ text: 'Could not resolve "buffer/"' }],
31
+ });
32
+
33
+ expect(handleNonFatalProcessError(params)).toBe(true);
34
+ // Recorded so `/_sb_connect` can hand it to the editor overlay.
35
+ expect(params.devServerStatus.serverErrors).toHaveLength(1);
36
+ expect(
37
+ params.devServerMetrics.recordRecoverableBuildError,
38
+ ).toHaveBeenCalledWith({
39
+ category: "module_resolve",
40
+ handler: "unhandledRejection",
41
+ });
42
+ });
43
+
44
+ it("keeps the pod alive for a close racing an in-place restart", () => {
45
+ for (const code of [8, 10, "ERR_CLOSED_SERVER"]) {
46
+ const params = makeParams(Object.assign(new Error("closed"), { code }));
47
+ expect(handleNonFatalProcessError(params)).toBe(true);
48
+ expect(
49
+ params.devServerMetrics.recordRecoverableShutdownError,
50
+ ).toHaveBeenCalled();
51
+ }
52
+ });
53
+
54
+ it("preserves the distinct log wording each handler uses", () => {
55
+ // These lines are what on-call greps when a pod stays up instead of
56
+ // restarting, so the wording is part of the contract.
57
+ const onException = makeParams(
58
+ Object.assign(new Error("closed"), { code: 8 }),
59
+ "uncaughtException",
60
+ );
61
+ handleNonFatalProcessError(onException);
62
+ expect(onException.logger.warn).toHaveBeenCalledWith(
63
+ "Ignoring recoverable shutdown error (uncaughtException)",
64
+ expect.anything(),
65
+ );
66
+
67
+ const onRejection = makeParams(
68
+ Object.assign(new Error("closed"), { code: 8 }),
69
+ "unhandledRejection",
70
+ );
71
+ handleNonFatalProcessError(onRejection);
72
+ expect(onRejection.logger.warn).toHaveBeenCalledWith(
73
+ "Ignoring recoverable shutdown rejection (unhandledRejection)",
74
+ expect.anything(),
75
+ );
76
+ });
77
+
78
+ it("sends a genuine crash to the fatal path", () => {
79
+ const params = makeParams(new TypeError("cannot read properties of null"));
80
+
81
+ expect(handleNonFatalProcessError(params)).toBe(false);
82
+ expect(params.devServerStatus.serverErrors).toHaveLength(0);
83
+ expect(
84
+ params.devServerMetrics.recordRecoverableBuildError,
85
+ ).not.toHaveBeenCalled();
86
+ expect(
87
+ params.devServerMetrics.recordRecoverableShutdownError,
88
+ ).not.toHaveBeenCalled();
89
+ });
90
+
91
+ it("sends a malformed build-ish reason to the fatal path rather than throwing", () => {
92
+ // `isViteBuildError` accepts this on the message marker without
93
+ // re-validating `errors`. A throw here would run inside the listener before
94
+ // the fatal-exit line is written, killing the pod with no record of the real
95
+ // error, so the safe direction is a clean `false`.
96
+ const params = makeParams({
97
+ errors: [{ text: 42 }],
98
+ message: "Transform failed with 1 error",
99
+ });
100
+
101
+ expect(() => handleNonFatalProcessError(params)).not.toThrow();
102
+ });
103
+ });
@@ -162,3 +162,131 @@ export function isRecoverableShutdownError(reason: unknown): boolean {
162
162
  }
163
163
  return RECOVERABLE_SHUTDOWN_CODES.has(code);
164
164
  }
165
+
166
+ // Every alternative here has to be specific to Vite/esbuild output. A false
167
+ // positive marks a genuine crash recoverable, which keeps a broken pod alive
168
+ // and suppresses the `dev_server_fatal_exit` signal we alert on. That is why
169
+ // `Could not resolve` requires esbuild's quoted specifier
170
+ // (`Could not resolve "buffer/"`): the bare phrase also appears in package
171
+ // manager output ("Could not resolve dependency:") and DNS failures
172
+ // ("could not resolve host").
173
+ const VITE_BUILD_MESSAGE_MARKERS =
174
+ /Could not resolve ["']|Build failed with \d+ error|Pre-transform error|Failed to resolve import|Transform failed/i;
175
+
176
+ /**
177
+ * An esbuild `BuildFailure` carries `errors: Message[]`, where each `Message`
178
+ * has a `text` string. Requiring that inner shape keeps the check specific:
179
+ * a bare `errors` array is far too common to treat as proof of a build failure.
180
+ */
181
+ function hasEsbuildBuildFailureShape(reason: object): boolean {
182
+ const errors = (reason as { errors?: unknown }).errors;
183
+ return (
184
+ Array.isArray(errors) &&
185
+ errors.length > 0 &&
186
+ errors.every(
187
+ (entry) =>
188
+ typeof entry === "object" &&
189
+ entry !== null &&
190
+ typeof (entry as { text?: unknown }).text === "string",
191
+ )
192
+ );
193
+ }
194
+
195
+ /**
196
+ * Vite/esbuild build failures that should keep the dev-server process alive and
197
+ * surface through `devServerStatus.serverErrors` instead of `process.exit(1)`.
198
+ *
199
+ * Deliberately narrow: anything not positively identified as a build failure
200
+ * must stay fatal, so genuine crashes still exit and emit
201
+ * `dev_server_fatal_exit`. Rollup/Vite plugin errors are matched by their
202
+ * message rather than by structural fields like `id`/`plugin`/`loc`, which are
203
+ * far too common to be evidence of a build failure on their own.
204
+ */
205
+ export function isViteBuildError(reason: unknown): boolean {
206
+ if (typeof reason !== "object" || reason === null) {
207
+ if (typeof reason === "string") {
208
+ return VITE_BUILD_MESSAGE_MARKERS.test(reason);
209
+ }
210
+ return false;
211
+ }
212
+
213
+ if (hasEsbuildBuildFailureShape(reason)) {
214
+ return true;
215
+ }
216
+
217
+ const message =
218
+ reason instanceof Error
219
+ ? reason.message
220
+ : typeof (reason as { message?: unknown }).message === "string"
221
+ ? (reason as { message: string }).message
222
+ : undefined;
223
+
224
+ if (message && VITE_BUILD_MESSAGE_MARKERS.test(message)) {
225
+ return true;
226
+ }
227
+
228
+ return false;
229
+ }
230
+
231
+ /** Pull usable esbuild `Message.text` strings off any object-shaped reason. */
232
+ function extractEsbuildErrorTexts(reason: unknown): string[] {
233
+ if (typeof reason !== "object" || reason === null) {
234
+ return [];
235
+ }
236
+
237
+ const errors = (reason as { errors?: unknown }).errors;
238
+ if (!Array.isArray(errors)) {
239
+ return [];
240
+ }
241
+
242
+ // Read defensively. `isViteBuildError` can classify on a message marker alone
243
+ // without re-validating the array, so entries here may be anything. This
244
+ // function runs inside the `uncaughtException` / `unhandledRejection`
245
+ // listeners before the fatal-exit line is written, and a throw there tears the
246
+ // process down immediately.
247
+ return errors
248
+ .map((entry) =>
249
+ typeof entry === "object" &&
250
+ entry !== null &&
251
+ typeof (entry as { text?: unknown }).text === "string"
252
+ ? (entry as { text: string }).text.trim()
253
+ : "",
254
+ )
255
+ .filter((text) => text.length > 0);
256
+ }
257
+
258
+ /** Best-effort text extraction for Vite/esbuild rejection reasons. */
259
+ export function extractViteBuildErrorMessage(reason: unknown): string {
260
+ if (typeof reason === "string") {
261
+ return reason;
262
+ }
263
+
264
+ // Real optimizeDeps rejections are esbuild `BuildFailure` values — Error
265
+ // subclasses with `errors: Message[]`. Prefer those structured texts over the
266
+ // stack so the overlay and Clark see `Could not resolve "buffer/"`, not a
267
+ // noisy stack trace.
268
+ const esbuildTexts = extractEsbuildErrorTexts(reason);
269
+ if (esbuildTexts.length > 0) {
270
+ return esbuildTexts.join("\n");
271
+ }
272
+
273
+ if (reason instanceof Error) {
274
+ return reason.stack ?? reason.message;
275
+ }
276
+
277
+ if (typeof reason === "object" && reason !== null) {
278
+ const candidate = reason as { message?: unknown };
279
+
280
+ if (typeof candidate.message === "string" && candidate.message.trim()) {
281
+ return candidate.message;
282
+ }
283
+
284
+ try {
285
+ return JSON.stringify(reason);
286
+ } catch {
287
+ return String(reason);
288
+ }
289
+ }
290
+
291
+ return String(reason);
292
+ }
@@ -3,7 +3,9 @@ import { describe, expect, it } from "vitest";
3
3
  import {
4
4
  buildFatalExitLog,
5
5
  FATAL_EXIT_EVENT,
6
+ extractViteBuildErrorMessage,
6
7
  isRecoverableShutdownError,
8
+ isViteBuildError,
7
9
  parseIsWarm,
8
10
  } from "./fatal-exit.mjs";
9
11
 
@@ -127,3 +129,203 @@ describe("isRecoverableShutdownError", () => {
127
129
  expect(isRecoverableShutdownError({ code: "ERR_OTHER" })).toBe(false);
128
130
  });
129
131
  });
132
+
133
+ describe("isViteBuildError", () => {
134
+ it("matches esbuild BuildFailure shapes", () => {
135
+ expect(
136
+ isViteBuildError({
137
+ errors: [
138
+ {
139
+ text: 'Could not resolve "buffer/"',
140
+ },
141
+ ],
142
+ warnings: [],
143
+ }),
144
+ ).toBe(true);
145
+ });
146
+
147
+ it("matches Rollup/Vite plugin errors via their message", () => {
148
+ expect(
149
+ isViteBuildError(
150
+ Object.assign(
151
+ new Error('Failed to resolve import "buffer/" from "helpers.js"'),
152
+ {
153
+ frame: "5: import buffer from 'buffer/'",
154
+ id: "/app/node_modules/plotly.js/src/traces/image/helpers.js",
155
+ loc: { column: 0, file: "helpers.js", line: 5 },
156
+ plugin: "vite:import-analysis",
157
+ },
158
+ ),
159
+ ),
160
+ ).toBe(true);
161
+ });
162
+
163
+ it("does not match non-build rejections that merely carry Rollup-ish fields", () => {
164
+ // `id` is ubiquitous in this process (RuntimeErrorData, ConsoleLogEntry,
165
+ // socket payloads), and `typeof null === "object"` makes a bare `loc` check
166
+ // match too. Structural fields alone must never mark a rejection
167
+ // recoverable: that would swallow genuine crashes and suppress the
168
+ // dev_server_fatal_exit signal we alert on.
169
+ expect(isViteBuildError({ id: "abc123", message: "boom" })).toBe(false);
170
+ expect(isViteBuildError({ loc: null })).toBe(false);
171
+ expect(isViteBuildError({ plugin: "some-plugin" })).toBe(false);
172
+ expect(isViteBuildError({ frame: "..." })).toBe(false);
173
+ });
174
+
175
+ it("ignores esbuild-ish objects whose errors array is not esbuild messages", () => {
176
+ expect(isViteBuildError({ errors: [] })).toBe(false);
177
+ expect(isViteBuildError({ errors: ["nope"] })).toBe(false);
178
+ });
179
+
180
+ it("keeps AggregateError fatal even though it carries an errors array", () => {
181
+ // The only thing separating an AggregateError from an esbuild BuildFailure
182
+ // is the `typeof entry.text === "string"` requirement. Without this test,
183
+ // simplifying that shape check would silently reclassify every
184
+ // `Promise.any` rejection and undici `AggregateError [ECONNREFUSED]` as a
185
+ // recoverable build error — keeping a genuinely broken pod alive and
186
+ // dropping it out of the fatal-exit population we alert on.
187
+ expect(
188
+ isViteBuildError(
189
+ new AggregateError(
190
+ [
191
+ new Error("connect ECONNREFUSED"),
192
+ new Error("connect ECONNREFUSED"),
193
+ ],
194
+ "All promises were rejected",
195
+ ),
196
+ ),
197
+ ).toBe(false);
198
+ expect(
199
+ isViteBuildError({
200
+ errors: [new Error("connect ECONNREFUSED 127.0.0.1:5432")],
201
+ }),
202
+ ).toBe(false);
203
+ });
204
+
205
+ it("matches known Vite/esbuild message markers", () => {
206
+ expect(
207
+ isViteBuildError(
208
+ 'Build failed with 1 error:\nCould not resolve "buffer/"',
209
+ ),
210
+ ).toBe(true);
211
+ expect(
212
+ isViteBuildError("Pre-transform error: Failed to resolve import"),
213
+ ).toBe(true);
214
+ });
215
+
216
+ it("does not match plain runtime errors", () => {
217
+ expect(isViteBuildError(new TypeError("Cannot read property 'x'"))).toBe(
218
+ false,
219
+ );
220
+ expect(isViteBuildError(undefined)).toBe(false);
221
+ });
222
+
223
+ it("requires esbuild's quoted specifier before trusting 'Could not resolve'", () => {
224
+ // esbuild always quotes the specifier (`Could not resolve "buffer/"`).
225
+ // Matching the bare phrase pulls in package-manager and DNS failures, and
226
+ // calling those recoverable would keep a genuinely broken pod alive while
227
+ // suppressing the dev_server_fatal_exit signal we alert on.
228
+ expect(
229
+ isViteBuildError(
230
+ new Error("Could not resolve dependency: peer react@^18.0.0"),
231
+ ),
232
+ ).toBe(false);
233
+ expect(
234
+ isViteBuildError(
235
+ "getaddrinfo ENOTFOUND: could not resolve host cdn.example.com",
236
+ ),
237
+ ).toBe(false);
238
+ expect(isViteBuildError('Could not resolve "buffer/"')).toBe(true);
239
+ });
240
+ });
241
+
242
+ describe("extractViteBuildErrorMessage", () => {
243
+ it("joins esbuild error texts", () => {
244
+ expect(
245
+ extractViteBuildErrorMessage({
246
+ errors: [{ text: 'Could not resolve "buffer/"' }],
247
+ }),
248
+ ).toBe('Could not resolve "buffer/"');
249
+ });
250
+
251
+ it("prefers errors[].text over stack for esbuild BuildFailure-shaped Errors", () => {
252
+ // Real optimizeDeps rejections are Error subclasses carrying `errors:
253
+ // Message[]`. The old `instanceof Error` early return sent the stack to the
254
+ // overlay instead of the resolve/transform line Clark needs.
255
+ class FakeBuildFailure extends Error {
256
+ errors: Array<{ text: string }>;
257
+ constructor(message: string, errors: Array<{ text: string }>) {
258
+ super(message);
259
+ this.errors = errors;
260
+ }
261
+ }
262
+
263
+ const reason = new FakeBuildFailure("Build failed with 1 error", [
264
+ { text: 'Could not resolve "buffer/"' },
265
+ ]);
266
+
267
+ expect(extractViteBuildErrorMessage(reason)).toBe(
268
+ 'Could not resolve "buffer/"',
269
+ );
270
+ expect(extractViteBuildErrorMessage(reason)).not.toMatch(/^\s*at /m);
271
+ });
272
+
273
+ it("still uses stack for plain Errors without structured texts", () => {
274
+ const err = new Error("something broke");
275
+ expect(extractViteBuildErrorMessage(err)).toContain("something broke");
276
+ });
277
+
278
+ // This function runs inside the `uncaughtException` / `unhandledRejection`
279
+ // listeners, before the fatal-exit line is written. A throw here does not
280
+ // degrade to the fatal path — Node tears the process down immediately, so the
281
+ // real error never reaches the logs and the misleading TypeError shows up in
282
+ // its place. `isViteBuildError` accepts a message-marker match without
283
+ // re-validating `errors`, so a malformed `errors` array does reach us.
284
+ it("survives an errors array that is not esbuild messages", () => {
285
+ for (const errors of [
286
+ [{ text: 42 }],
287
+ [null],
288
+ [undefined],
289
+ ["plain string"],
290
+ [{ text: { nested: true } }],
291
+ ]) {
292
+ expect(() =>
293
+ extractViteBuildErrorMessage({
294
+ errors,
295
+ message: "Transform failed with 1 error",
296
+ }),
297
+ ).not.toThrow();
298
+ }
299
+ });
300
+
301
+ it("falls back to the message when no entry carries usable text", () => {
302
+ // Returning "" here would be classified, counted, and then rendered
303
+ // nowhere: the UI dispatches `loadError(rawError)` and an empty string is
304
+ // falsy, so the overlay never appears.
305
+ expect(
306
+ extractViteBuildErrorMessage({
307
+ errors: [{}],
308
+ message: "Build failed with 1 error",
309
+ }),
310
+ ).toBe("Build failed with 1 error");
311
+ expect(
312
+ extractViteBuildErrorMessage({
313
+ errors: [{ text: 42 }],
314
+ message: "Transform failed with 1 error",
315
+ }),
316
+ ).toBe("Transform failed with 1 error");
317
+ });
318
+
319
+ it("still prefers usable entry text over the outer message", () => {
320
+ expect(
321
+ extractViteBuildErrorMessage({
322
+ errors: [{ text: 'Could not resolve "buffer/"' }, { text: 42 }],
323
+ message: "Build failed with 1 error",
324
+ }),
325
+ ).toBe('Could not resolve "buffer/"');
326
+ });
327
+
328
+ it("never returns an empty string for an object with no usable text at all", () => {
329
+ expect(extractViteBuildErrorMessage({ errors: [{}] })).not.toBe("");
330
+ });
331
+ });