@superblocksteam/sdk 2.0.119-next.1 → 2.0.120-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/cli-replacement/dev-s3-restore.test.mjs +116 -0
- package/dist/cli-replacement/dev-s3-restore.test.mjs.map +1 -1
- package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs +1 -0
- package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs.map +1 -1
- package/dist/cli-replacement/dev.d.mts +6 -0
- package/dist/cli-replacement/dev.d.mts.map +1 -1
- package/dist/cli-replacement/dev.mjs +77 -6
- package/dist/cli-replacement/dev.mjs.map +1 -1
- package/dist/client.d.ts +67 -6
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +94 -0
- package/dist/client.js.map +1 -1
- package/dist/dev-utils/dedupe-async.d.ts +16 -0
- package/dist/dev-utils/dedupe-async.d.ts.map +1 -0
- package/dist/dev-utils/dedupe-async.js +27 -0
- package/dist/dev-utils/dedupe-async.js.map +1 -0
- package/dist/dev-utils/dedupe-async.test.d.ts +2 -0
- package/dist/dev-utils/dedupe-async.test.d.ts.map +1 -0
- package/dist/dev-utils/dedupe-async.test.js +120 -0
- package/dist/dev-utils/dedupe-async.test.js.map +1 -0
- package/dist/dev-utils/dev-server-metrics.d.mts +95 -0
- package/dist/dev-utils/dev-server-metrics.d.mts.map +1 -0
- package/dist/dev-utils/dev-server-metrics.mjs +193 -0
- package/dist/dev-utils/dev-server-metrics.mjs.map +1 -0
- package/dist/dev-utils/dev-server-persist.test.mjs +117 -17
- package/dist/dev-utils/dev-server-persist.test.mjs.map +1 -1
- package/dist/dev-utils/dev-server.d.mts +19 -1
- package/dist/dev-utils/dev-server.d.mts.map +1 -1
- package/dist/dev-utils/dev-server.mjs +296 -28
- package/dist/dev-utils/dev-server.mjs.map +1 -1
- package/dist/flag.d.ts +1 -1
- package/dist/flag.d.ts.map +1 -1
- package/dist/flag.js +3 -3
- package/dist/flag.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/sdk.d.ts +8 -1
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +24 -1
- package/dist/sdk.js.map +1 -1
- package/dist/sdk.test.js +102 -1
- package/dist/sdk.test.js.map +1 -1
- package/dist/telemetry/index.d.ts +2 -1
- package/dist/telemetry/index.d.ts.map +1 -1
- package/dist/telemetry/index.js +8 -1
- package/dist/telemetry/index.js.map +1 -1
- package/dist/telemetry/logging.d.ts +1 -2
- package/dist/telemetry/logging.d.ts.map +1 -1
- package/dist/telemetry/logging.js +1 -1
- package/dist/telemetry/logging.js.map +1 -1
- package/dist/telemetry/memory-metrics.d.ts +3 -0
- package/dist/telemetry/memory-metrics.d.ts.map +1 -0
- package/dist/telemetry/memory-metrics.js +59 -0
- package/dist/telemetry/memory-metrics.js.map +1 -0
- package/dist/types/common.d.ts +1 -1
- package/dist/types/common.d.ts.map +1 -1
- package/dist/version-control.d.mts.map +1 -1
- package/dist/version-control.mjs +14 -19
- package/dist/version-control.mjs.map +1 -1
- package/eslint.config.js +6 -0
- package/package.json +8 -8
- package/src/cli-replacement/dev-s3-restore.test.mts +156 -0
- package/src/cli-replacement/dev-startup-git-before-dbfs-order.test.mts +1 -0
- package/src/cli-replacement/dev.mts +104 -14
- package/src/client.ts +189 -6
- package/src/dev-utils/dedupe-async.test.ts +151 -0
- package/src/dev-utils/dedupe-async.ts +45 -0
- package/src/dev-utils/dev-server-metrics.mts +252 -0
- package/src/dev-utils/dev-server-persist.test.mts +170 -19
- package/src/dev-utils/dev-server.mts +366 -32
- package/src/flag.ts +4 -4
- package/src/index.ts +19 -1
- package/src/sdk.test.ts +145 -6
- package/src/sdk.ts +36 -0
- package/src/telemetry/index.ts +9 -1
- package/src/telemetry/logging.ts +2 -2
- package/src/telemetry/memory-metrics.ts +90 -0
- package/src/types/common.ts +1 -1
- package/src/version-control.mts +11 -30
- package/test/version-control.test.mts +0 -2
- package/tsconfig.tsbuildinfo +1 -1
- package/turbo.json +1 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as child_process from "node:child_process";
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
2
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
3
3
|
import type http from "node:http";
|
|
4
4
|
import net from "node:net";
|
|
5
5
|
import os from "node:os";
|
|
@@ -37,6 +37,10 @@ import pkg from "../../package.json" with { type: "json" };
|
|
|
37
37
|
import type { SuperblocksSdk } from "../sdk.js";
|
|
38
38
|
import { getTracer } from "../telemetry/index.js";
|
|
39
39
|
import { getErrorMeta, getLogger } from "../telemetry/logging.js";
|
|
40
|
+
import {
|
|
41
|
+
startMemoryMetrics,
|
|
42
|
+
stopMemoryMetrics,
|
|
43
|
+
} from "../telemetry/memory-metrics.js";
|
|
40
44
|
import {
|
|
41
45
|
EDIT_APPLICATION_SCOPE,
|
|
42
46
|
type SuperblocksScopedJwtPayload,
|
|
@@ -48,6 +52,12 @@ import {
|
|
|
48
52
|
customComponentsPlugin,
|
|
49
53
|
isCustomComponentsEnabled,
|
|
50
54
|
} from "./custom-build.mjs";
|
|
55
|
+
import { dedupeAsync } from "./dedupe-async.js";
|
|
56
|
+
import {
|
|
57
|
+
type DevServerEndpoint,
|
|
58
|
+
type DevServerFailureType,
|
|
59
|
+
devServerMetrics,
|
|
60
|
+
} from "./dev-server-metrics.mjs";
|
|
51
61
|
import {
|
|
52
62
|
formatViteDevServerStartedLog,
|
|
53
63
|
logViteBuildError,
|
|
@@ -56,6 +66,60 @@ import { buildManifestStubPlugin } from "./vite-plugin-build-manifest-stub.mjs";
|
|
|
56
66
|
import { ddRumPlugin } from "./vite-plugin-dd-rum.mjs";
|
|
57
67
|
|
|
58
68
|
const tracer = getTracer();
|
|
69
|
+
const logger = getLogger();
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Maps a dev-server request URL onto a low-cardinality endpoint label for
|
|
73
|
+
* metrics. Returns undefined for non-`/_sb_*` URLs (Vite-proxied traffic).
|
|
74
|
+
*/
|
|
75
|
+
function endpointForUrl(
|
|
76
|
+
url: string | undefined,
|
|
77
|
+
): DevServerEndpoint | undefined {
|
|
78
|
+
if (!url) return undefined;
|
|
79
|
+
const path = url.split("?", 1)[0];
|
|
80
|
+
switch (path) {
|
|
81
|
+
case "/_sb_health":
|
|
82
|
+
return "_sb_health";
|
|
83
|
+
case "/_sb_connect":
|
|
84
|
+
return "_sb_connect";
|
|
85
|
+
case "/_sb_activate":
|
|
86
|
+
return "_sb_activate";
|
|
87
|
+
case "/_sb_disconnect":
|
|
88
|
+
return "_sb_disconnect";
|
|
89
|
+
case "/_sb_status":
|
|
90
|
+
return "_sb_status";
|
|
91
|
+
case "/_sb_activity":
|
|
92
|
+
return "_sb_activity";
|
|
93
|
+
case "/_sb_persist":
|
|
94
|
+
return "_sb_persist";
|
|
95
|
+
case "/_sb_ready":
|
|
96
|
+
return "_sb_ready";
|
|
97
|
+
default:
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Extracts a coarse failure-type label from `res.locals.sbFailureType`,
|
|
104
|
+
* which endpoints set just before they error out. Stays server-side
|
|
105
|
+
* rather than leaking through response headers.
|
|
106
|
+
*/
|
|
107
|
+
function failureTypeFromLocals(
|
|
108
|
+
locals: Record<string, unknown>,
|
|
109
|
+
): DevServerFailureType {
|
|
110
|
+
const value = locals?.sbFailureType;
|
|
111
|
+
if (typeof value !== "string") return "none";
|
|
112
|
+
switch (value) {
|
|
113
|
+
case "auth":
|
|
114
|
+
case "validation":
|
|
115
|
+
case "branch_mismatch":
|
|
116
|
+
case "vite_init":
|
|
117
|
+
case "internal":
|
|
118
|
+
return value;
|
|
119
|
+
default:
|
|
120
|
+
return "none";
|
|
121
|
+
}
|
|
122
|
+
}
|
|
59
123
|
|
|
60
124
|
function reactPlugin() {
|
|
61
125
|
return react({
|
|
@@ -123,6 +187,29 @@ function vitePlugins(
|
|
|
123
187
|
];
|
|
124
188
|
}
|
|
125
189
|
|
|
190
|
+
// .superblocks subdirectories that should survive pod recycles.
|
|
191
|
+
// Everything else under .superblocks/ is excluded automatically.
|
|
192
|
+
const PERSIST_SUPERBLOCKS_SAFELIST = new Set(["context", "drafts"]);
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Build tar --exclude flags for .superblocks/ subdirectories that are NOT
|
|
196
|
+
* in the safelist. If .superblocks/ doesn't exist we exclude it entirely.
|
|
197
|
+
*/
|
|
198
|
+
export function getSuperblocksTarExcludes(root: string): string[] {
|
|
199
|
+
const dotSuperblocks = path.join(root, ".superblocks");
|
|
200
|
+
let entries: string[];
|
|
201
|
+
try {
|
|
202
|
+
entries = readdirSync(dotSuperblocks, { withFileTypes: true })
|
|
203
|
+
.filter((d) => d.isDirectory())
|
|
204
|
+
.map((d) => d.name);
|
|
205
|
+
} catch {
|
|
206
|
+
return ["--exclude", ".superblocks"];
|
|
207
|
+
}
|
|
208
|
+
return entries
|
|
209
|
+
.filter((name) => !PERSIST_SUPERBLOCKS_SAFELIST.has(name))
|
|
210
|
+
.flatMap((name) => ["--exclude", `.superblocks/${name}`]);
|
|
211
|
+
}
|
|
212
|
+
|
|
126
213
|
export async function createWorkspacePersistArchive(root: string) {
|
|
127
214
|
// Warm pods already have template dependencies installed, so persist only
|
|
128
215
|
// user workspace files and let startup reconcile package drift if needed.
|
|
@@ -136,8 +223,7 @@ export async function createWorkspacePersistArchive(root: string) {
|
|
|
136
223
|
".git",
|
|
137
224
|
"--exclude",
|
|
138
225
|
"node_modules",
|
|
139
|
-
|
|
140
|
-
".superblocks",
|
|
226
|
+
...getSuperblocksTarExcludes(root),
|
|
141
227
|
"-C",
|
|
142
228
|
root,
|
|
143
229
|
".",
|
|
@@ -213,11 +299,64 @@ export async function createWorkspacePersistArchive(root: string) {
|
|
|
213
299
|
});
|
|
214
300
|
}
|
|
215
301
|
|
|
302
|
+
export function createWorkspacePersistUploadHeaders(
|
|
303
|
+
archiveLength: number,
|
|
304
|
+
uploadHeaders: Record<string, string> = {},
|
|
305
|
+
) {
|
|
306
|
+
const headers = new Headers(uploadHeaders);
|
|
307
|
+
if (!headers.has("Content-Type")) {
|
|
308
|
+
headers.set("Content-Type", "application/zstd");
|
|
309
|
+
}
|
|
310
|
+
headers.set("Content-Length", String(archiveLength));
|
|
311
|
+
return Object.fromEntries(headers.entries());
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function isWorkspacePersistUploadHeaders(
|
|
315
|
+
uploadHeaders: unknown,
|
|
316
|
+
): uploadHeaders is Record<string, string> | undefined {
|
|
317
|
+
if (uploadHeaders === undefined) {
|
|
318
|
+
return true;
|
|
319
|
+
}
|
|
320
|
+
if (
|
|
321
|
+
uploadHeaders === null ||
|
|
322
|
+
typeof uploadHeaders !== "object" ||
|
|
323
|
+
Array.isArray(uploadHeaders)
|
|
324
|
+
) {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
const seen = new Set<string>();
|
|
328
|
+
for (const [key, value] of Object.entries(uploadHeaders)) {
|
|
329
|
+
if (typeof key !== "string" || typeof value !== "string") {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
// Header names are case-insensitive. Two keys that differ only in casing
|
|
333
|
+
// (e.g. `Content-Type` + `content-type`) would be silently combined by
|
|
334
|
+
// `new Headers()` into a comma-joined value, which breaks S3 presigned
|
|
335
|
+
// PUTs that signed a single canonical value. Reject up front so the
|
|
336
|
+
// caller fixes the duplicate rather than getting a confusing S3 4xx.
|
|
337
|
+
const lower = key.toLowerCase();
|
|
338
|
+
if (seen.has(lower)) {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
seen.add(lower);
|
|
342
|
+
}
|
|
343
|
+
// Validate header name/value syntax by attempting to construct a Headers
|
|
344
|
+
// instance. Doing this in the validator (rather than letting it throw
|
|
345
|
+
// later inside `createWorkspacePersistUploadHeaders`) keeps the failure
|
|
346
|
+
// mode as a 400 before any archive work runs.
|
|
347
|
+
try {
|
|
348
|
+
new Headers(uploadHeaders as Record<string, string>);
|
|
349
|
+
} catch {
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
|
|
216
355
|
export const RESTART_EXIT_CODE = 98;
|
|
217
356
|
|
|
218
357
|
function getJwksUriWithBaseUrl(superblocksBaseUrl?: string): string {
|
|
219
358
|
if (process.env.SUPERBLOCKS_JWKS_URI) {
|
|
220
|
-
|
|
359
|
+
logger.debug(
|
|
221
360
|
`[JWKS] Using override SUPERBLOCKS_JWKS_URI=${process.env.SUPERBLOCKS_JWKS_URI}`,
|
|
222
361
|
);
|
|
223
362
|
return process.env.SUPERBLOCKS_JWKS_URI;
|
|
@@ -254,7 +393,7 @@ function getJwksUriWithBaseUrl(superblocksBaseUrl?: string): string {
|
|
|
254
393
|
jwksUri = "https://staging-cdn.superblocks.com/.well-known/jwks.json";
|
|
255
394
|
}
|
|
256
395
|
|
|
257
|
-
|
|
396
|
+
logger.debug(`[JWKS] Derived jwksUri=${jwksUri} from baseUrl=${baseUrl}`);
|
|
258
397
|
return jwksUri;
|
|
259
398
|
}
|
|
260
399
|
|
|
@@ -282,10 +421,57 @@ interface CreateDevServerOptions {
|
|
|
282
421
|
* causes 502s from the gateway during the warm -> full server transition.
|
|
283
422
|
*/
|
|
284
423
|
existingServer?: http.Server;
|
|
424
|
+
/**
|
|
425
|
+
* Wall-clock timestamp (Date.now()) when the warm-standby /_sb_activate POST
|
|
426
|
+
* was accepted. When provided, the dev server records the elapsed time from
|
|
427
|
+
* activation acceptance to full Express handler attachment as the
|
|
428
|
+
* `dev_server_warm_activation_handoff_duration_ms` histogram — the window
|
|
429
|
+
* during which the gateway can return 502s if the warm server stops
|
|
430
|
+
* responding before the full server takes over.
|
|
431
|
+
*/
|
|
432
|
+
warmActivationStart?: number;
|
|
285
433
|
}
|
|
286
434
|
|
|
287
435
|
let httpServer: http.Server;
|
|
288
436
|
|
|
437
|
+
/**
|
|
438
|
+
* Attaches a passive metrics-only listener for WebSocket upgrade attempts on
|
|
439
|
+
* the dev-server's HTTP socket.
|
|
440
|
+
*
|
|
441
|
+
* The outcome is a heuristic, not a direct accept/reject signal:
|
|
442
|
+
* - `accepted` = the socket closed cleanly (no `hadError`) or stayed open
|
|
443
|
+
* past the 30s defensive timer. A long-lived HMR connection
|
|
444
|
+
* is the typical success case.
|
|
445
|
+
* - `rejected` = the socket emitted `error` or closed with `hadError=true`.
|
|
446
|
+
* A clean rejection by the upstream WS layer (write 401 + end) can therefore
|
|
447
|
+
* bucket as `accepted` if the close event has no `hadError` flag — the WS
|
|
448
|
+
* handling layer does not surface a direct accept signal we can observe here.
|
|
449
|
+
*
|
|
450
|
+
* MUST be called only after Vite (or another component) has registered its own
|
|
451
|
+
* `'upgrade'` listener on the same server. Registering this listener first
|
|
452
|
+
* suppresses Node's default close-on-unhandled behavior, which would leave
|
|
453
|
+
* editor upgrade attempts hanging until client timeout in the pre-Vite window.
|
|
454
|
+
*/
|
|
455
|
+
function attachUpgradeMetricsListener(server: http.Server): void {
|
|
456
|
+
server.on("upgrade", (req, socket) => {
|
|
457
|
+
let recorded = false;
|
|
458
|
+
const recordOnce = (outcome: "accepted" | "rejected") => {
|
|
459
|
+
if (recorded) return;
|
|
460
|
+
recorded = true;
|
|
461
|
+
devServerMetrics.recordSocketUpgrade(outcome);
|
|
462
|
+
};
|
|
463
|
+
socket.once("close", (hadError: boolean) => {
|
|
464
|
+
recordOnce(hadError ? "rejected" : "accepted");
|
|
465
|
+
});
|
|
466
|
+
socket.once("error", () => recordOnce("rejected"));
|
|
467
|
+
// Defensive: if neither close nor error fires within 30s, count as accepted
|
|
468
|
+
// — a long-lived HMR connection is the success case for upgrades.
|
|
469
|
+
setTimeout(() => recordOnce("accepted"), 30_000).unref();
|
|
470
|
+
// Avoid lint warning for unused param without changing the listener shape.
|
|
471
|
+
void req;
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
289
475
|
// The "Dev Server" is a long-running HTTP server that manages the Vite server
|
|
290
476
|
export async function createDevServer({
|
|
291
477
|
root,
|
|
@@ -303,6 +489,7 @@ export async function createDevServer({
|
|
|
303
489
|
sdk,
|
|
304
490
|
superblocksBaseUrl: explicitBaseUrl,
|
|
305
491
|
existingServer,
|
|
492
|
+
warmActivationStart,
|
|
306
493
|
}: CreateDevServerOptions) {
|
|
307
494
|
const logger = getLogger(loggerOverride);
|
|
308
495
|
if (httpServer) {
|
|
@@ -346,7 +533,31 @@ export async function createDevServer({
|
|
|
346
533
|
// attached at the viteStartPromise call site.
|
|
347
534
|
vitePromise.catch(() => {});
|
|
348
535
|
|
|
349
|
-
|
|
536
|
+
// Dedupe re-entry. Multiple signals (SIGINT, SIGTERM, SIGABRT,
|
|
537
|
+
// uncaughtException) can fire in rapid succession during shutdown —
|
|
538
|
+
// particularly when a parent process kills the dev-server and the OS or
|
|
539
|
+
// test harness then sends a follow-up SIGINT. Without a dedupe, every
|
|
540
|
+
// signal handler re-runs lockService shutdown / vite close / httpServer
|
|
541
|
+
// close on already-freed native state, which surfaces as
|
|
542
|
+
// `double free or corruption (fasttop)` from glibc and SIGABRT killing
|
|
543
|
+
// the process before it can exit cleanly (hq-o55a).
|
|
544
|
+
//
|
|
545
|
+
// First-caller-wins on args: the second+ caller's `switchingTo` /
|
|
546
|
+
// `initiatedByEmail` are intentionally dropped (the first caller's run is
|
|
547
|
+
// already in flight; we don't restart cleanup with new args). The
|
|
548
|
+
// `/_sb_disconnect` HTTP handler that awaits `gracefulShutdown` therefore
|
|
549
|
+
// returns "ok" without applying its `switchingTo` if a signal beat it to
|
|
550
|
+
// the punch — the process exits regardless, so the dropped arg cannot
|
|
551
|
+
// drive downstream state in this process.
|
|
552
|
+
const gracefulShutdown = dedupeAsync(runGracefulShutdown, {
|
|
553
|
+
onDuplicate: (args) => {
|
|
554
|
+
logger.info(
|
|
555
|
+
`Shutdown already in progress; ignoring duplicate signal (source=${args.source ?? "unknown"})`,
|
|
556
|
+
);
|
|
557
|
+
},
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
async function runGracefulShutdown({
|
|
350
561
|
logger,
|
|
351
562
|
serverInitiated,
|
|
352
563
|
switchingTo,
|
|
@@ -356,8 +567,16 @@ export async function createDevServer({
|
|
|
356
567
|
serverInitiated: boolean;
|
|
357
568
|
switchingTo?: "local" | "cloud" | "none";
|
|
358
569
|
initiatedByEmail?: string;
|
|
359
|
-
|
|
570
|
+
/**
|
|
571
|
+
* Identifies what triggered the shutdown (signal name, "uncaughtException",
|
|
572
|
+
* or "/_sb_disconnect"). Threaded through so the dedupe-duplicate log can
|
|
573
|
+
* disambiguate "4 OS signals fired" from "1 signal fired and cleanup
|
|
574
|
+
* panicked 3 times".
|
|
575
|
+
*/
|
|
576
|
+
source?: string;
|
|
577
|
+
}): Promise<void> {
|
|
360
578
|
try {
|
|
579
|
+
stopMemoryMetrics();
|
|
361
580
|
await lockService?.shutdown({
|
|
362
581
|
serverInitiated,
|
|
363
582
|
switchingTo,
|
|
@@ -401,6 +620,29 @@ export async function createDevServer({
|
|
|
401
620
|
}),
|
|
402
621
|
);
|
|
403
622
|
|
|
623
|
+
// Per-request metrics for dev-server endpoints. Identify the endpoint by URL
|
|
624
|
+
// pattern, time the request, record duration + status + coarse failure type
|
|
625
|
+
// when the response finishes. Non-`/_sb_*` traffic (Vite proxied requests)
|
|
626
|
+
// is not instrumented here — Vite's own middleware handles those.
|
|
627
|
+
app.use((req, res, next) => {
|
|
628
|
+
const endpoint = endpointForUrl(req.url);
|
|
629
|
+
if (!endpoint) {
|
|
630
|
+
return next();
|
|
631
|
+
}
|
|
632
|
+
const startedAt = Date.now();
|
|
633
|
+
res.on("finish", () => {
|
|
634
|
+
const failureType = failureTypeFromLocals(res.locals);
|
|
635
|
+
devServerMetrics.recordEndpoint(
|
|
636
|
+
endpoint,
|
|
637
|
+
req.method,
|
|
638
|
+
res.statusCode,
|
|
639
|
+
Date.now() - startedAt,
|
|
640
|
+
failureType,
|
|
641
|
+
);
|
|
642
|
+
});
|
|
643
|
+
next();
|
|
644
|
+
});
|
|
645
|
+
|
|
404
646
|
app.use((req, res, next) => {
|
|
405
647
|
res.setHeader("Cache-Control", "no-store, max-age=0");
|
|
406
648
|
res.setHeader("x-csb-no-sw-proxy", "1");
|
|
@@ -514,6 +756,7 @@ export async function createDevServer({
|
|
|
514
756
|
if (result.success) {
|
|
515
757
|
return next();
|
|
516
758
|
} else {
|
|
759
|
+
res.locals.sbFailureType = "auth";
|
|
517
760
|
res.status(result.status).send(JSON.stringify({ error: result.error }));
|
|
518
761
|
}
|
|
519
762
|
};
|
|
@@ -537,6 +780,7 @@ export async function createDevServer({
|
|
|
537
780
|
logger.info(
|
|
538
781
|
`[dev-server-runtime] _sb_connect application mismatch: expected ${resourceConfig!.id}, got ${req.headers["x-superblocks-application-id"]}`,
|
|
539
782
|
);
|
|
783
|
+
res.locals.sbFailureType = "validation";
|
|
540
784
|
res.status(401).send(
|
|
541
785
|
JSON.stringify({
|
|
542
786
|
error: `Application ID header mismatch. Expected ${resourceConfig!.id}, received ${req.headers["x-superblocks-application-id"]}`,
|
|
@@ -559,6 +803,7 @@ export async function createDevServer({
|
|
|
559
803
|
logger.info(
|
|
560
804
|
`[dev-server-runtime] _sb_connect branch mismatch: expected ${currentBranch}, got ${incomingBranch}`,
|
|
561
805
|
);
|
|
806
|
+
res.locals.sbFailureType = "branch_mismatch";
|
|
562
807
|
res.status(401).send(
|
|
563
808
|
JSON.stringify({
|
|
564
809
|
error: `Dev server expects branch ${currentBranch}, but received ${incomingBranch}. Check your current branch with 'git branch' and try again.`,
|
|
@@ -579,6 +824,7 @@ export async function createDevServer({
|
|
|
579
824
|
"Vite server failed to initialize",
|
|
580
825
|
getErrorMeta(e as Error),
|
|
581
826
|
);
|
|
827
|
+
res.locals.sbFailureType = "vite_init";
|
|
582
828
|
res
|
|
583
829
|
.status(500)
|
|
584
830
|
.send(JSON.stringify({ error: "Dev server failed to initialize" }));
|
|
@@ -624,6 +870,7 @@ export async function createDevServer({
|
|
|
624
870
|
serverInitiated: true,
|
|
625
871
|
switchingTo,
|
|
626
872
|
initiatedByEmail: initiatedByEmail as string | undefined,
|
|
873
|
+
source: "/_sb_disconnect",
|
|
627
874
|
});
|
|
628
875
|
res.send("ok");
|
|
629
876
|
}
|
|
@@ -681,11 +928,21 @@ export async function createDevServer({
|
|
|
681
928
|
persistInProgress = true;
|
|
682
929
|
const persistStart = Date.now();
|
|
683
930
|
try {
|
|
684
|
-
const { uploadURL } = req.body as {
|
|
931
|
+
const { uploadURL, uploadHeaders } = req.body as {
|
|
932
|
+
uploadURL: string;
|
|
933
|
+
uploadHeaders?: unknown;
|
|
934
|
+
};
|
|
685
935
|
if (!uploadURL) {
|
|
686
936
|
res.status(400).json({ error: "uploadURL required" });
|
|
687
937
|
return;
|
|
688
938
|
}
|
|
939
|
+
if (!isWorkspacePersistUploadHeaders(uploadHeaders)) {
|
|
940
|
+
res.status(400).json({
|
|
941
|
+
error:
|
|
942
|
+
"uploadHeaders must be a string map with valid HTTP header names and values and no case-insensitive duplicate keys",
|
|
943
|
+
});
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
689
946
|
|
|
690
947
|
logger.info("/_sb_persist: archiving and uploading workspace to S3...");
|
|
691
948
|
|
|
@@ -699,10 +956,10 @@ export async function createDevServer({
|
|
|
699
956
|
const uploadResp = await fetch(uploadURL, {
|
|
700
957
|
method: "PUT",
|
|
701
958
|
body: archive,
|
|
702
|
-
headers:
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
959
|
+
headers: createWorkspacePersistUploadHeaders(
|
|
960
|
+
archive.length,
|
|
961
|
+
uploadHeaders,
|
|
962
|
+
),
|
|
706
963
|
signal: AbortSignal.timeout(30_000),
|
|
707
964
|
});
|
|
708
965
|
|
|
@@ -724,32 +981,63 @@ export async function createDevServer({
|
|
|
724
981
|
}
|
|
725
982
|
});
|
|
726
983
|
|
|
727
|
-
|
|
984
|
+
// Signal handlers attach `.catch` so a synchronous throw in
|
|
985
|
+
// `runGracefulShutdown` (e.g. logger init or lockService shutdown throwing
|
|
986
|
+
// before the first `await`) is logged rather than surfacing as an
|
|
987
|
+
// unhandled rejection across N handlers (one per fired signal). Symmetric
|
|
988
|
+
// with the `uncaughtException` handler below.
|
|
989
|
+
process.on("SIGINT", () => {
|
|
728
990
|
logger.info("SIGINT received");
|
|
729
|
-
|
|
991
|
+
gracefulShutdown({
|
|
992
|
+
logger,
|
|
993
|
+
serverInitiated: false,
|
|
994
|
+
source: "SIGINT",
|
|
995
|
+
}).catch((err) =>
|
|
996
|
+
logger.error("Error during SIGINT shutdown", getErrorMeta(err)),
|
|
997
|
+
);
|
|
730
998
|
});
|
|
731
999
|
|
|
732
|
-
process.on("SIGTERM",
|
|
1000
|
+
process.on("SIGTERM", () => {
|
|
733
1001
|
logger.info("SIGTERM received");
|
|
734
|
-
|
|
1002
|
+
gracefulShutdown({
|
|
1003
|
+
logger,
|
|
1004
|
+
serverInitiated: false,
|
|
1005
|
+
source: "SIGTERM",
|
|
1006
|
+
}).catch((err) =>
|
|
1007
|
+
logger.error("Error during SIGTERM shutdown", getErrorMeta(err)),
|
|
1008
|
+
);
|
|
735
1009
|
});
|
|
736
1010
|
|
|
737
|
-
process.on("SIGABRT",
|
|
1011
|
+
process.on("SIGABRT", () => {
|
|
738
1012
|
logger.info("SIGABRT received");
|
|
739
|
-
|
|
1013
|
+
gracefulShutdown({
|
|
1014
|
+
logger,
|
|
1015
|
+
serverInitiated: false,
|
|
1016
|
+
source: "SIGABRT",
|
|
1017
|
+
}).catch((err) =>
|
|
1018
|
+
logger.error("Error during SIGABRT shutdown", getErrorMeta(err)),
|
|
1019
|
+
);
|
|
740
1020
|
});
|
|
741
1021
|
|
|
1022
|
+
// `uncaughtException` always force-exits in `.finally`, even when the
|
|
1023
|
+
// underlying shutdown promise was already resolved by a prior signal. With
|
|
1024
|
+
// the dedupe in place, awaiting the cached resolved promise short-circuits
|
|
1025
|
+
// through `.then` without firing `.catch`; without the `.finally` the
|
|
1026
|
+
// process would linger in event-loop limbo until the OS killed it.
|
|
742
1027
|
process.on("uncaughtException", (error) => {
|
|
743
1028
|
logger.error(`Uncaught exception: ${error.message}`, getErrorMeta(error));
|
|
744
|
-
gracefulShutdown({
|
|
745
|
-
|
|
1029
|
+
gracefulShutdown({
|
|
1030
|
+
logger,
|
|
1031
|
+
serverInitiated: false,
|
|
1032
|
+
source: "uncaughtException",
|
|
1033
|
+
})
|
|
1034
|
+
.catch((shutdownError) => {
|
|
746
1035
|
logger.error(
|
|
747
1036
|
"Error during shutdown after uncaught exception:",
|
|
748
1037
|
shutdownError,
|
|
749
1038
|
);
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
);
|
|
1039
|
+
})
|
|
1040
|
+
.finally(() => process.exit(1));
|
|
753
1041
|
});
|
|
754
1042
|
|
|
755
1043
|
if (existingServer) {
|
|
@@ -758,12 +1046,19 @@ export async function createDevServer({
|
|
|
758
1046
|
existingServer.removeAllListeners("request");
|
|
759
1047
|
existingServer.on("request", app);
|
|
760
1048
|
httpServer = existingServer;
|
|
1049
|
+
if (warmActivationStart) {
|
|
1050
|
+
devServerMetrics.recordWarmHandoff(Date.now() - warmActivationStart);
|
|
1051
|
+
}
|
|
761
1052
|
logger.info(
|
|
762
1053
|
`Attached full dev server to existing HTTP server on port ${port}`,
|
|
763
1054
|
);
|
|
764
1055
|
} else {
|
|
765
1056
|
logger.info(`Starting HTTP server on port ${port}`);
|
|
766
|
-
|
|
1057
|
+
// `app.listen()` returns `http.Server` synchronously — the previous
|
|
1058
|
+
// `await` was a no-op on a non-Promise. The server isn't actually bound
|
|
1059
|
+
// until it emits `listening`; if downstream code ever depends on the
|
|
1060
|
+
// socket being bound, switch to `new Promise(r => httpServer.once("listening", r))`.
|
|
1061
|
+
httpServer = app.listen(port);
|
|
767
1062
|
logger.info(`HTTP server started successfully on port ${port}`);
|
|
768
1063
|
}
|
|
769
1064
|
|
|
@@ -781,6 +1076,7 @@ export async function createDevServer({
|
|
|
781
1076
|
return undefined;
|
|
782
1077
|
});
|
|
783
1078
|
|
|
1079
|
+
const viteEagerInitStart = Date.now();
|
|
784
1080
|
const viteStartPromise = startVite({
|
|
785
1081
|
port,
|
|
786
1082
|
app,
|
|
@@ -808,11 +1104,34 @@ export async function createDevServer({
|
|
|
808
1104
|
// below via the rejection branch of .then().
|
|
809
1105
|
viteStartPromise.then(
|
|
810
1106
|
(result) => {
|
|
1107
|
+
devServerMetrics.recordViteEagerInit(
|
|
1108
|
+
Date.now() - viteEagerInitStart,
|
|
1109
|
+
"success",
|
|
1110
|
+
);
|
|
811
1111
|
logger.info("Vite server initialized eagerly");
|
|
812
1112
|
viteResolve();
|
|
813
1113
|
viteCreationResults = result;
|
|
1114
|
+
// Start memory metrics only for claimed pods (after Vite init, not warm standby).
|
|
1115
|
+
// Gated by either the LD flag (cloud) or the SABS-injected env var (cloud-prem/local).
|
|
1116
|
+
if (
|
|
1117
|
+
featureFlags?.devServerMemoryMetricsEnabled() ||
|
|
1118
|
+
process.env.SUPERBLOCKS_DEV_SERVER_MEMORY_METRICS_ENABLED === "true"
|
|
1119
|
+
) {
|
|
1120
|
+
startMemoryMetrics();
|
|
1121
|
+
}
|
|
1122
|
+
// Attach the WebSocket upgrade-tracking listener AFTER Vite has
|
|
1123
|
+
// installed its own HMR upgrade handler. Registering an `upgrade`
|
|
1124
|
+
// listener pre-Vite would suppress Node's default close-on-unhandled
|
|
1125
|
+
// behavior, leaving editor upgrade attempts to hang until client
|
|
1126
|
+
// timeout. By deferring until Vite is up, our listener coexists with
|
|
1127
|
+
// the real handler instead of replacing the default close.
|
|
1128
|
+
attachUpgradeMetricsListener(httpServer);
|
|
814
1129
|
},
|
|
815
1130
|
(e) => {
|
|
1131
|
+
devServerMetrics.recordViteEagerInit(
|
|
1132
|
+
Date.now() - viteEagerInitStart,
|
|
1133
|
+
"error",
|
|
1134
|
+
);
|
|
816
1135
|
logger.error("Error initializing vite server", getErrorMeta(e));
|
|
817
1136
|
viteReject(e);
|
|
818
1137
|
},
|
|
@@ -1031,7 +1350,7 @@ const PRE_WARM_TIMEOUT_MS = 60_000;
|
|
|
1031
1350
|
|
|
1032
1351
|
export async function preWarmViteCache(root: string): Promise<void> {
|
|
1033
1352
|
const start = Date.now();
|
|
1034
|
-
|
|
1353
|
+
logger.info("[warm] Pre-warming Vite dependency cache...");
|
|
1035
1354
|
try {
|
|
1036
1355
|
const server = await createServer({
|
|
1037
1356
|
root,
|
|
@@ -1053,11 +1372,26 @@ export async function preWarmViteCache(root: string): Promise<void> {
|
|
|
1053
1372
|
await new Promise((r) => setTimeout(r, pollMs));
|
|
1054
1373
|
}
|
|
1055
1374
|
await server.close();
|
|
1056
|
-
|
|
1375
|
+
devServerMetrics.recordWarmPrewarm(Date.now() - start, "success");
|
|
1376
|
+
logger.info(`[warm] Vite cache warmed in ${Date.now() - start}ms`);
|
|
1057
1377
|
} catch (error) {
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1378
|
+
const durationMs = Date.now() - start;
|
|
1379
|
+
// Buffer the metric in case `flush()` is somehow drained later; in practice
|
|
1380
|
+
// we exit before activation, so this observation is also surfaced through
|
|
1381
|
+
// the structured log line below — telemetry isn't initialized yet in
|
|
1382
|
+
// warm-standby (no auth token until /_sb_activate), so `configureTelemetry`
|
|
1383
|
+
// can't run here, and synchronously POSTing an OTLP payload before exit
|
|
1384
|
+
// would block pod recycle on a network call that may itself be hung.
|
|
1385
|
+
devServerMetrics.recordWarmPrewarm(durationMs, "error");
|
|
1386
|
+
// Stable inline `key=value` marker so a log-based metric (Datadog
|
|
1387
|
+
// "Generate Metrics from Logs", Loki regex) can recover pre-warm failures
|
|
1388
|
+
// the OTel pipeline misses. The Logger.error signature accepts only
|
|
1389
|
+
// ErrorMeta (no arbitrary attributes) and must stay structurally
|
|
1390
|
+
// compatible with the sister Logger in vite-plugin-file-sync, so the
|
|
1391
|
+
// markers are embedded in the message body instead of passed as fields.
|
|
1392
|
+
logger.error(
|
|
1393
|
+
`[warm] Vite cache pre-warm failed — exiting for fast pod recycle metric=dev_server_warm_prewarm outcome=error duration_ms=${durationMs}`,
|
|
1394
|
+
getErrorMeta(error),
|
|
1061
1395
|
);
|
|
1062
1396
|
process.exit(1);
|
|
1063
1397
|
}
|
|
@@ -1071,7 +1405,7 @@ function getFreePort() {
|
|
|
1071
1405
|
server.listen(0, () => {
|
|
1072
1406
|
const address = server.address();
|
|
1073
1407
|
if (typeof address === "string") {
|
|
1074
|
-
|
|
1408
|
+
logger.debug(
|
|
1075
1409
|
`Port resolution: falling back to default HMR port ${DEFAULT_HMR_PORT}`,
|
|
1076
1410
|
);
|
|
1077
1411
|
resolve(DEFAULT_HMR_PORT);
|
|
@@ -1079,13 +1413,13 @@ function getFreePort() {
|
|
|
1079
1413
|
}
|
|
1080
1414
|
const port = address?.port;
|
|
1081
1415
|
if (!port) {
|
|
1082
|
-
|
|
1416
|
+
logger.debug(
|
|
1083
1417
|
`Port resolution: no port available, using default HMR port ${DEFAULT_HMR_PORT}`,
|
|
1084
1418
|
);
|
|
1085
1419
|
resolve(DEFAULT_HMR_PORT);
|
|
1086
1420
|
return;
|
|
1087
1421
|
}
|
|
1088
|
-
|
|
1422
|
+
logger.debug(`Port resolution: successfully allocated port ${port}`);
|
|
1089
1423
|
server.close(() => resolve(port));
|
|
1090
1424
|
});
|
|
1091
1425
|
});
|
package/src/flag.ts
CHANGED
|
@@ -17,10 +17,6 @@ export class FeatureFlags {
|
|
|
17
17
|
return this.flags["superblocks.git.split.large.steps.enabled"] ?? false;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
splitLargeApiStepsInNewEnabled(): boolean {
|
|
21
|
-
return this.flags["superblocks.git.split.large.steps.new.enabled"] ?? false;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
20
|
linesForLargeSteps(): number {
|
|
25
21
|
return (
|
|
26
22
|
this.flags["superblocks.git.split.large.step.lines"] ??
|
|
@@ -47,4 +43,8 @@ export class FeatureFlags {
|
|
|
47
43
|
includeDotSuperblocks(): boolean {
|
|
48
44
|
return this.flags["superblocks.deploy.include-dot-superblocks"] ?? false;
|
|
49
45
|
}
|
|
46
|
+
|
|
47
|
+
devServerMemoryMetricsEnabled(): boolean {
|
|
48
|
+
return this.flags["superblocks.dev-server.memory-metrics.enabled"] ?? false;
|
|
49
|
+
}
|
|
50
50
|
}
|