@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
@@ -0,0 +1,151 @@
1
+ import type {
2
+ DevServerBuildError,
3
+ ServerError,
4
+ } from "@superblocksteam/library-shared/types";
5
+
6
+ import {
7
+ extractViteBuildErrorMessage,
8
+ isViteBuildError,
9
+ } from "./fatal-exit.mjs";
10
+ import {
11
+ diagnoseViteBuildLogMessage,
12
+ truncateViteRawLog,
13
+ } from "./vite-dev-server-diagnostics.mjs";
14
+
15
+ /** Maximum build errors retained on `devServerStatus.serverErrors`. */
16
+ export const MAX_DEV_SERVER_BUILD_ERRORS = 5;
17
+
18
+ type DevServerStatus = { serverErrors: ServerError[] };
19
+
20
+ export function buildDevServerBuildErrorDedupeKey(
21
+ entry: Pick<DevServerBuildError, "category" | "unresolvedSpecifier">,
22
+ ): string {
23
+ return `${entry.category}:${entry.unresolvedSpecifier ?? ""}`;
24
+ }
25
+
26
+ export function buildDevServerBuildError(reason: unknown): DevServerBuildError {
27
+ const fullMessage = extractViteBuildErrorMessage(reason);
28
+ // Classify against the full text so a specifier past the cap is still found,
29
+ // but store the capped copy: this string is persisted on `serverErrors`,
30
+ // logged, sent over `/_sb_connect`, and rendered in the editor overlay.
31
+ const diagnostic = diagnoseViteBuildLogMessage(fullMessage);
32
+ const rawError = truncateViteRawLog(fullMessage);
33
+
34
+ if (diagnostic.category === "module_resolve") {
35
+ return {
36
+ type: "dev-server/build",
37
+ timestamp: new Date().toISOString(),
38
+ category: diagnostic.category,
39
+ unresolvedSpecifier: diagnostic.unresolvedSpecifier,
40
+ rawError,
41
+ };
42
+ }
43
+
44
+ return {
45
+ type: "dev-server/build",
46
+ timestamp: new Date().toISOString(),
47
+ category: diagnostic.category,
48
+ rawError,
49
+ };
50
+ }
51
+
52
+ export function upsertDevServerBuildError(
53
+ devServerStatus: DevServerStatus,
54
+ entry: DevServerBuildError,
55
+ ): void {
56
+ const dedupeKey = buildDevServerBuildErrorDedupeKey(entry);
57
+ devServerStatus.serverErrors = devServerStatus.serverErrors.filter(
58
+ (existing) =>
59
+ existing.type !== "dev-server/build" ||
60
+ buildDevServerBuildErrorDedupeKey(existing) !== dedupeKey,
61
+ );
62
+ devServerStatus.serverErrors.push(entry);
63
+
64
+ // Cap build errors only. `serverErrors` is shared with the startup path,
65
+ // which pushes a dependency-install/upgrade entry once per pod before Vite
66
+ // serves anything; those sit at the front and can never be re-reported, so a
67
+ // whole-array cap keeping the newest entries would evict them first.
68
+ let excess =
69
+ devServerStatus.serverErrors.filter(
70
+ (existing) => existing.type === "dev-server/build",
71
+ ).length - MAX_DEV_SERVER_BUILD_ERRORS;
72
+ if (excess > 0) {
73
+ devServerStatus.serverErrors = devServerStatus.serverErrors.filter(
74
+ (existing) => {
75
+ if (excess > 0 && existing.type === "dev-server/build") {
76
+ excess -= 1;
77
+ return false;
78
+ }
79
+ return true;
80
+ },
81
+ );
82
+ }
83
+ }
84
+
85
+ type RecoverableBuildErrorHandler = "uncaughtException" | "unhandledRejection";
86
+
87
+ type RecoverableBuildErrorMetrics = {
88
+ recordRecoverableBuildError: (labels: {
89
+ category: string;
90
+ handler: RecoverableBuildErrorHandler;
91
+ }) => void;
92
+ };
93
+
94
+ /**
95
+ * When a Vite/esbuild build error reaches the process-level handler, keep the
96
+ * pod alive and record it for the editor's `/_sb_connect` readiness path.
97
+ */
98
+ export function handleRecoverableBuildError(params: {
99
+ devServerStatus?: DevServerStatus;
100
+ devServerMetrics: RecoverableBuildErrorMetrics;
101
+ handler: RecoverableBuildErrorHandler;
102
+ logger: {
103
+ warn: (message: string, meta?: unknown) => void;
104
+ };
105
+ reason: unknown;
106
+ }): boolean {
107
+ // Never throw. This runs inside the process-level `uncaughtException` /
108
+ // `unhandledRejection` listeners, ahead of the caller's `fatalExitHandled`
109
+ // latch and its fatal-exit log line. Node does not degrade a throw from those
110
+ // listeners to the normal fatal path — it tears the process down immediately,
111
+ // losing the real error, the metric, and `gracefulShutdown`, and leaving a
112
+ // misleading TypeError in their place. Returning `false` instead routes the
113
+ // caller to its fatal path, which logs the original reason.
114
+ //
115
+ // `isViteBuildError` classifies on a message marker without re-validating
116
+ // `errors`, so a malformed array reaches the extraction below; that specific
117
+ // hazard is handled in `extractViteBuildErrorMessage`, and this guard covers
118
+ // whatever else the diagnostic, upsert, and metric steps can raise.
119
+ try {
120
+ if (!isViteBuildError(params.reason)) {
121
+ return false;
122
+ }
123
+
124
+ const entry = buildDevServerBuildError(params.reason);
125
+ if (params.devServerStatus) {
126
+ upsertDevServerBuildError(params.devServerStatus, entry);
127
+ }
128
+
129
+ params.devServerMetrics.recordRecoverableBuildError({
130
+ category: entry.category,
131
+ handler: params.handler,
132
+ });
133
+
134
+ params.logger.warn(
135
+ `Ignoring recoverable Vite build error (${params.handler})`,
136
+ { error: entry },
137
+ );
138
+
139
+ return true;
140
+ } catch (recordError) {
141
+ // Falling through to the fatal path is the safe direction: the pod exits
142
+ // and the caller logs the original reason. Note the bookkeeping failure
143
+ // separately so a malformed-reason bug stays diagnosable instead of looking
144
+ // like a plain crash.
145
+ params.logger.warn(
146
+ `Failed to record recoverable Vite build error (${params.handler}); treating as fatal`,
147
+ { recordError },
148
+ );
149
+ return false;
150
+ }
151
+ }
@@ -0,0 +1,236 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import type {
4
+ DevServerBuildError,
5
+ ServerError,
6
+ } from "@superblocksteam/library-shared/types";
7
+
8
+ import {
9
+ buildDevServerBuildError,
10
+ buildDevServerBuildErrorDedupeKey,
11
+ handleRecoverableBuildError,
12
+ MAX_DEV_SERVER_BUILD_ERRORS,
13
+ upsertDevServerBuildError,
14
+ } from "./recoverable-build-error.mjs";
15
+ import { VITE_ERROR_RAW_LOG_MAX_CHARS } from "./vite-dev-server-diagnostics.mjs";
16
+
17
+ describe("buildDevServerBuildError", () => {
18
+ it("classifies module resolve failures with the unresolved specifier", () => {
19
+ const entry = buildDevServerBuildError({
20
+ errors: [{ text: 'Could not resolve "buffer/"' }],
21
+ });
22
+ expect(entry.type).toBe("dev-server/build");
23
+ expect(entry.category).toBe("module_resolve");
24
+ expect(entry.unresolvedSpecifier).toBe("buffer/");
25
+ expect(entry.rawError).toContain("buffer/");
26
+ });
27
+
28
+ it("truncates rawError to the same cap the logging pipeline uses", () => {
29
+ // rawError is persisted on serverErrors, logged, shipped over
30
+ // /_sb_connect, and rendered in the editor overlay. A pathological stack or
31
+ // a failure listing hundreds of specifiers must not reach any of those
32
+ // unbounded.
33
+ const entry = buildDevServerBuildError({
34
+ errors: [
35
+ {
36
+ text: `Could not resolve "buffer/"\n${"x".repeat(
37
+ VITE_ERROR_RAW_LOG_MAX_CHARS * 2,
38
+ )}`,
39
+ },
40
+ ],
41
+ });
42
+
43
+ expect(entry.rawError.length).toBeLessThan(
44
+ VITE_ERROR_RAW_LOG_MAX_CHARS + 100,
45
+ );
46
+ expect(entry.rawError).toContain("(truncated)");
47
+ // Classification still reads the full text, so the specifier survives even
48
+ // when it sits past the cap.
49
+ expect(entry.category).toBe("module_resolve");
50
+ expect(entry.unresolvedSpecifier).toBe("buffer/");
51
+ });
52
+ });
53
+
54
+ describe("upsertDevServerBuildError", () => {
55
+ it("dedupes by category and unresolved specifier and caps the list", () => {
56
+ const devServerStatus = { serverErrors: [] as ServerError[] };
57
+ const first = buildDevServerBuildError({
58
+ errors: [{ text: 'Could not resolve "buffer/"' }],
59
+ });
60
+ const second = buildDevServerBuildError({
61
+ errors: [{ text: 'Could not resolve "buffer/"' }],
62
+ });
63
+
64
+ upsertDevServerBuildError(devServerStatus, first);
65
+ upsertDevServerBuildError(devServerStatus, second);
66
+ expect(devServerStatus.serverErrors).toHaveLength(1);
67
+ expect(devServerStatus.serverErrors[0]?.timestamp).toBe(second.timestamp);
68
+
69
+ for (let index = 0; index < MAX_DEV_SERVER_BUILD_ERRORS + 2; index++) {
70
+ upsertDevServerBuildError(
71
+ devServerStatus,
72
+ buildDevServerBuildError({
73
+ errors: [{ text: `Could not resolve "pkg-${index}/"` }],
74
+ }),
75
+ );
76
+ }
77
+ expect(devServerStatus.serverErrors.length).toBeLessThanOrEqual(
78
+ MAX_DEV_SERVER_BUILD_ERRORS,
79
+ );
80
+ });
81
+
82
+ it("uses a stable dedupe key", () => {
83
+ expect(
84
+ buildDevServerBuildErrorDedupeKey({
85
+ category: "module_resolve",
86
+ unresolvedSpecifier: "buffer/",
87
+ }),
88
+ ).toBe("module_resolve:buffer/");
89
+ });
90
+
91
+ it("keeps startup errors when a burst of build errors exceeds the cap", () => {
92
+ // `serverErrors` is shared with the startup path, which pushes a
93
+ // `dependency-install` / `dependency-upgrade` entry once per pod before
94
+ // Vite ever serves a request. Those sit at the front of the array, so a
95
+ // cap that counted every entry and kept only the newest would evict the
96
+ // install failure first — the one entry the user most needs to act on, and
97
+ // one that can never be re-reported.
98
+ const installError: ServerError = {
99
+ type: "dev-server/dependency-install",
100
+ timestamp: new Date("2026-01-01T00:00:00.000Z").toISOString(),
101
+ category: "not_in_registry",
102
+ message: "npm ERR! 404 Not Found - GET https://registry/plotly.js",
103
+ } as ServerError;
104
+ const devServerStatus = { serverErrors: [installError] };
105
+
106
+ for (let index = 0; index < MAX_DEV_SERVER_BUILD_ERRORS + 3; index++) {
107
+ upsertDevServerBuildError(
108
+ devServerStatus,
109
+ buildDevServerBuildError({
110
+ errors: [{ text: `Could not resolve "pkg-${index}/"` }],
111
+ }),
112
+ );
113
+ }
114
+
115
+ expect(devServerStatus.serverErrors).toContain(installError);
116
+ expect(
117
+ devServerStatus.serverErrors.filter(
118
+ (entry) => entry.type === "dev-server/build",
119
+ ),
120
+ ).toHaveLength(MAX_DEV_SERVER_BUILD_ERRORS);
121
+ });
122
+
123
+ it("evicts the oldest build error rather than an arbitrary entry", () => {
124
+ const devServerStatus = { serverErrors: [] as ServerError[] };
125
+ for (let index = 0; index < MAX_DEV_SERVER_BUILD_ERRORS + 1; index++) {
126
+ upsertDevServerBuildError(
127
+ devServerStatus,
128
+ buildDevServerBuildError({
129
+ errors: [{ text: `Could not resolve "pkg-${index}/"` }],
130
+ }),
131
+ );
132
+ }
133
+
134
+ const specifiers = devServerStatus.serverErrors.map(
135
+ (entry) => (entry as DevServerBuildError).unresolvedSpecifier,
136
+ );
137
+ expect(specifiers).not.toContain("pkg-0/");
138
+ expect(specifiers).toContain(`pkg-${MAX_DEV_SERVER_BUILD_ERRORS}/`);
139
+ });
140
+ });
141
+
142
+ describe("handleRecoverableBuildError", () => {
143
+ it("records a build error and returns true for esbuild failures", () => {
144
+ const devServerStatus = { serverErrors: [] as ServerError[] };
145
+ const recordRecoverableBuildError = vi.fn();
146
+ const metrics = {
147
+ recordRecoverableBuildError,
148
+ };
149
+
150
+ const handled = handleRecoverableBuildError({
151
+ devServerStatus,
152
+ devServerMetrics: metrics,
153
+ handler: "unhandledRejection",
154
+ logger: { warn: vi.fn() },
155
+ reason: {
156
+ errors: [{ text: 'Could not resolve "buffer/"' }],
157
+ },
158
+ });
159
+
160
+ expect(handled).toBe(true);
161
+ expect(devServerStatus.serverErrors).toHaveLength(1);
162
+ expect(recordRecoverableBuildError).toHaveBeenCalledWith({
163
+ category: "module_resolve",
164
+ handler: "unhandledRejection",
165
+ });
166
+ });
167
+
168
+ // This runs inside the `uncaughtException` / `unhandledRejection` listeners
169
+ // before the caller writes its fatal-exit line. An escaping throw there does
170
+ // not degrade to the fatal path: Node tears the process down immediately, so
171
+ // the real error never reaches the logs, no metric is emitted, and
172
+ // `gracefulShutdown` never runs. Returning false instead routes the caller to
173
+ // its normal fatal path, which logs the original reason.
174
+ it("falls back to the fatal path instead of throwing when recording fails", () => {
175
+ const logger = { warn: vi.fn() };
176
+ const handled = handleRecoverableBuildError({
177
+ devServerStatus: { serverErrors: [] as ServerError[] },
178
+ devServerMetrics: {
179
+ recordRecoverableBuildError: () => {
180
+ throw new Error("meter unavailable");
181
+ },
182
+ },
183
+ handler: "uncaughtException",
184
+ logger,
185
+ reason: { errors: [{ text: 'Could not resolve "buffer/"' }] },
186
+ });
187
+
188
+ expect(handled).toBe(false);
189
+ // The bookkeeping failure is noted separately so a malformed reason is
190
+ // diagnosable rather than looking like a plain crash.
191
+ expect(logger.warn).toHaveBeenCalled();
192
+ });
193
+
194
+ it("keeps the recoverable path for a reason whose errors array is malformed", () => {
195
+ // `isViteBuildError` accepts this via the message marker without
196
+ // re-validating `errors`, which is how a malformed array reaches the
197
+ // extraction step.
198
+ const devServerStatus = { serverErrors: [] as ServerError[] };
199
+ const logger = { warn: vi.fn() };
200
+
201
+ const handled = handleRecoverableBuildError({
202
+ devServerStatus,
203
+ devServerMetrics: { recordRecoverableBuildError: vi.fn() },
204
+ handler: "unhandledRejection",
205
+ logger,
206
+ reason: {
207
+ errors: [{ text: 42 }],
208
+ message: "Transform failed with 1 error",
209
+ },
210
+ });
211
+
212
+ expect(handled).toBe(true);
213
+ expect(devServerStatus.serverErrors).toHaveLength(1);
214
+ expect(
215
+ (devServerStatus.serverErrors[0] as { rawError: string }).rawError,
216
+ ).toContain("Transform failed");
217
+ });
218
+
219
+ it("returns false for non-build errors", () => {
220
+ const devServerStatus = { serverErrors: [] as ServerError[] };
221
+ const metrics = {
222
+ recordRecoverableBuildError: vi.fn(),
223
+ };
224
+
225
+ expect(
226
+ handleRecoverableBuildError({
227
+ devServerStatus,
228
+ devServerMetrics: metrics,
229
+ handler: "uncaughtException",
230
+ logger: { warn: vi.fn() },
231
+ reason: new TypeError("boom"),
232
+ }),
233
+ ).toBe(false);
234
+ expect(devServerStatus.serverErrors).toHaveLength(0);
235
+ });
236
+ });