@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/dev-utils/dev-server-metrics.d.mts +9 -0
- package/dist/dev-utils/dev-server-metrics.d.mts.map +1 -1
- package/dist/dev-utils/dev-server-metrics.mjs +14 -0
- package/dist/dev-utils/dev-server-metrics.mjs.map +1 -1
- package/dist/dev-utils/dev-server.d.mts +28 -0
- package/dist/dev-utils/dev-server.d.mts.map +1 -1
- package/dist/dev-utils/dev-server.mjs +55 -20
- package/dist/dev-utils/dev-server.mjs.map +1 -1
- package/dist/dev-utils/dev-server.process-errors.test.d.mts +2 -0
- package/dist/dev-utils/dev-server.process-errors.test.d.mts.map +1 -0
- package/dist/dev-utils/dev-server.process-errors.test.mjs +68 -0
- package/dist/dev-utils/dev-server.process-errors.test.mjs.map +1 -0
- package/dist/dev-utils/fatal-exit.d.mts +13 -0
- package/dist/dev-utils/fatal-exit.d.mts.map +1 -1
- package/dist/dev-utils/fatal-exit.mjs +103 -0
- package/dist/dev-utils/fatal-exit.mjs.map +1 -1
- package/dist/dev-utils/fatal-exit.test.mjs +138 -1
- package/dist/dev-utils/fatal-exit.test.mjs.map +1 -1
- package/dist/dev-utils/recoverable-build-error.d.mts +31 -0
- package/dist/dev-utils/recoverable-build-error.d.mts.map +1 -0
- package/dist/dev-utils/recoverable-build-error.mjs +92 -0
- package/dist/dev-utils/recoverable-build-error.mjs.map +1 -0
- package/dist/dev-utils/recoverable-build-error.test.d.mts +2 -0
- package/dist/dev-utils/recoverable-build-error.test.d.mts.map +1 -0
- package/dist/dev-utils/recoverable-build-error.test.mjs +176 -0
- package/dist/dev-utils/recoverable-build-error.test.mjs.map +1 -0
- package/package.json +6 -6
- package/src/dev-utils/dev-server-metrics.mts +19 -0
- package/src/dev-utils/dev-server.mts +85 -31
- package/src/dev-utils/dev-server.process-errors.test.mts +103 -0
- package/src/dev-utils/fatal-exit.mts +128 -0
- package/src/dev-utils/fatal-exit.test.mts +202 -0
- package/src/dev-utils/recoverable-build-error.mts +151 -0
- package/src/dev-utils/recoverable-build-error.test.mts +236 -0
- package/tsconfig.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@superblocksteam/sdk",
|
|
3
|
-
"version": "2.0.
|
|
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.
|
|
61
|
-
"@superblocksteam/shared": "0.9603.
|
|
62
|
-
"@superblocksteam/telemetry": "2.0.
|
|
63
|
-
"@superblocksteam/util": "2.0.
|
|
64
|
-
"@superblocksteam/vite-plugin-file-sync": "2.0.
|
|
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
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
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
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
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
|
+
});
|