@superblocksteam/sdk 2.0.117 → 2.0.118-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.
Files changed (79) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/cli-replacement/dev.d.mts +6 -0
  3. package/dist/cli-replacement/dev.d.mts.map +1 -1
  4. package/dist/cli-replacement/dev.mjs +1 -0
  5. package/dist/cli-replacement/dev.mjs.map +1 -1
  6. package/dist/client.d.ts +67 -6
  7. package/dist/client.d.ts.map +1 -1
  8. package/dist/client.js +94 -0
  9. package/dist/client.js.map +1 -1
  10. package/dist/dev-utils/dedupe-async.d.ts +16 -0
  11. package/dist/dev-utils/dedupe-async.d.ts.map +1 -0
  12. package/dist/dev-utils/dedupe-async.js +27 -0
  13. package/dist/dev-utils/dedupe-async.js.map +1 -0
  14. package/dist/dev-utils/dedupe-async.test.d.ts +2 -0
  15. package/dist/dev-utils/dedupe-async.test.d.ts.map +1 -0
  16. package/dist/dev-utils/dedupe-async.test.js +120 -0
  17. package/dist/dev-utils/dedupe-async.test.js.map +1 -0
  18. package/dist/dev-utils/dev-server-metrics.d.mts +95 -0
  19. package/dist/dev-utils/dev-server-metrics.d.mts.map +1 -0
  20. package/dist/dev-utils/dev-server-metrics.mjs +193 -0
  21. package/dist/dev-utils/dev-server-metrics.mjs.map +1 -0
  22. package/dist/dev-utils/dev-server-persist.test.mjs +117 -17
  23. package/dist/dev-utils/dev-server-persist.test.mjs.map +1 -1
  24. package/dist/dev-utils/dev-server.d.mts +19 -1
  25. package/dist/dev-utils/dev-server.d.mts.map +1 -1
  26. package/dist/dev-utils/dev-server.mjs +296 -28
  27. package/dist/dev-utils/dev-server.mjs.map +1 -1
  28. package/dist/flag.d.ts +1 -1
  29. package/dist/flag.d.ts.map +1 -1
  30. package/dist/flag.js +3 -3
  31. package/dist/flag.js.map +1 -1
  32. package/dist/index.d.ts +4 -3
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +3 -2
  35. package/dist/index.js.map +1 -1
  36. package/dist/sdk.d.ts +8 -1
  37. package/dist/sdk.d.ts.map +1 -1
  38. package/dist/sdk.js +24 -1
  39. package/dist/sdk.js.map +1 -1
  40. package/dist/sdk.test.js +102 -1
  41. package/dist/sdk.test.js.map +1 -1
  42. package/dist/telemetry/index.d.ts +2 -1
  43. package/dist/telemetry/index.d.ts.map +1 -1
  44. package/dist/telemetry/index.js +8 -1
  45. package/dist/telemetry/index.js.map +1 -1
  46. package/dist/telemetry/logging.d.ts +1 -2
  47. package/dist/telemetry/logging.d.ts.map +1 -1
  48. package/dist/telemetry/logging.js +1 -1
  49. package/dist/telemetry/logging.js.map +1 -1
  50. package/dist/telemetry/memory-metrics.d.ts +3 -0
  51. package/dist/telemetry/memory-metrics.d.ts.map +1 -0
  52. package/dist/telemetry/memory-metrics.js +59 -0
  53. package/dist/telemetry/memory-metrics.js.map +1 -0
  54. package/dist/types/common.d.ts +1 -1
  55. package/dist/types/common.d.ts.map +1 -1
  56. package/dist/version-control.d.mts.map +1 -1
  57. package/dist/version-control.mjs +14 -19
  58. package/dist/version-control.mjs.map +1 -1
  59. package/eslint.config.js +6 -0
  60. package/package.json +8 -8
  61. package/src/cli-replacement/dev.mts +8 -0
  62. package/src/client.ts +189 -6
  63. package/src/dev-utils/dedupe-async.test.ts +151 -0
  64. package/src/dev-utils/dedupe-async.ts +45 -0
  65. package/src/dev-utils/dev-server-metrics.mts +252 -0
  66. package/src/dev-utils/dev-server-persist.test.mts +170 -19
  67. package/src/dev-utils/dev-server.mts +366 -32
  68. package/src/flag.ts +4 -4
  69. package/src/index.ts +19 -1
  70. package/src/sdk.test.ts +145 -6
  71. package/src/sdk.ts +36 -0
  72. package/src/telemetry/index.ts +9 -1
  73. package/src/telemetry/logging.ts +2 -2
  74. package/src/telemetry/memory-metrics.ts +90 -0
  75. package/src/types/common.ts +1 -1
  76. package/src/version-control.mts +11 -30
  77. package/test/version-control.test.mts +0 -2
  78. package/tsconfig.tsbuildinfo +1 -1
  79. 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
- "--exclude",
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
- console.debug(
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
- console.debug(`[JWKS] Derived jwksUri=${jwksUri} from baseUrl=${baseUrl}`);
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
- async function gracefulShutdown({
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 { uploadURL: string };
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
- "Content-Type": "application/zstd",
704
- "Content-Length": String(archive.length),
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
- process.on("SIGINT", async () => {
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
- await gracefulShutdown({ logger, serverInitiated: false });
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", async () => {
1000
+ process.on("SIGTERM", () => {
733
1001
  logger.info("SIGTERM received");
734
- await gracefulShutdown({ logger, serverInitiated: false });
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", async () => {
1011
+ process.on("SIGABRT", () => {
738
1012
  logger.info("SIGABRT received");
739
- await gracefulShutdown({ logger, serverInitiated: false });
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({ logger, serverInitiated: false }).catch(
745
- (shutdownError) => {
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
- process.exit(1);
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
- httpServer = await app.listen(port);
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
- console.log("[warm] Pre-warming Vite dependency cache...");
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
- console.log(`[warm] Vite cache warmed in ${Date.now() - start}ms`);
1375
+ devServerMetrics.recordWarmPrewarm(Date.now() - start, "success");
1376
+ logger.info(`[warm] Vite cache warmed in ${Date.now() - start}ms`);
1057
1377
  } catch (error) {
1058
- console.error(
1059
- `[warm] Vite cache pre-warm failed (${Date.now() - start}ms) exiting for fast pod recycle:`,
1060
- error,
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
- console.debug(
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
- console.debug(
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
- console.debug(`Port resolution: successfully allocated port ${port}`);
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
  }