@chidchanun/bcp 0.2.17 → 0.2.18

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.
@@ -0,0 +1,936 @@
1
+ import {
2
+ randomUUID,
3
+ } from "node:crypto";
4
+
5
+ import {
6
+ registerShutdownHook,
7
+ } from "./production-hardening.js";
8
+
9
+ export type DeploymentRuntimeState =
10
+ | "idle"
11
+ | "starting"
12
+ | "ready"
13
+ | "draining"
14
+ | "stopped"
15
+ | "failed";
16
+
17
+ export interface DeploymentMetadata {
18
+ serviceName: string;
19
+ version?: string;
20
+ deploymentId: string;
21
+ instanceId?: string;
22
+ release?: string;
23
+ environment?: string;
24
+ startedAt: string;
25
+ pid: number;
26
+ nodeVersion: string;
27
+ platform: NodeJS.Platform;
28
+ arch: string;
29
+ }
30
+
31
+ export interface DeploymentResourceContext {
32
+ readonly metadata: DeploymentMetadata;
33
+ readonly signal: AbortSignal;
34
+ }
35
+
36
+ export type DeploymentReadinessResult =
37
+ | boolean
38
+ | {
39
+ ok: boolean;
40
+ detail?: string;
41
+ };
42
+
43
+ export interface DeploymentResource {
44
+ name: string;
45
+ start?: (
46
+ context: DeploymentResourceContext
47
+ ) => void | Promise<void>;
48
+ ready?: (
49
+ context: DeploymentResourceContext
50
+ ) =>
51
+ | DeploymentReadinessResult
52
+ | Promise<DeploymentReadinessResult>;
53
+ stop?: (
54
+ context: DeploymentResourceContext
55
+ ) => void | Promise<void>;
56
+ diagnostics?: (
57
+ context: DeploymentResourceContext
58
+ ) =>
59
+ | Readonly<Record<string, unknown>>
60
+ | Promise<Readonly<Record<string, unknown>>>;
61
+ }
62
+
63
+ export interface DeploymentResourceStatus {
64
+ name: string;
65
+ state:
66
+ | "registered"
67
+ | "starting"
68
+ | "started"
69
+ | "stopping"
70
+ | "stopped"
71
+ | "failed";
72
+ startedAt?: string;
73
+ stoppedAt?: string;
74
+ error?: string;
75
+ }
76
+
77
+ export interface DeploymentReadinessItem {
78
+ name: string;
79
+ ok: boolean;
80
+ durationMs: number;
81
+ detail?: string;
82
+ }
83
+
84
+ export interface DeploymentReadinessReport {
85
+ ok: boolean;
86
+ state: DeploymentRuntimeState;
87
+ checkedAt: string;
88
+ resources: DeploymentReadinessItem[];
89
+ }
90
+
91
+ export interface DeploymentDiagnosticsResource {
92
+ name: string;
93
+ lifecycle: DeploymentResourceStatus;
94
+ details?: Readonly<Record<string, unknown>>;
95
+ error?: string;
96
+ }
97
+
98
+ export interface DeploymentDiagnosticsReport {
99
+ metadata: DeploymentMetadata;
100
+ state: DeploymentRuntimeState;
101
+ uptimeSeconds: number;
102
+ resources: DeploymentDiagnosticsResource[];
103
+ }
104
+
105
+ export interface DeploymentRuntimeOptions {
106
+ serviceName: string;
107
+ version?: string;
108
+ deploymentId?: string;
109
+ instanceId?: string;
110
+ release?: string;
111
+ environmentName?: string;
112
+ environment?: NodeJS.ProcessEnv;
113
+ shutdownTimeoutMs?: number;
114
+ readinessTimeoutMs?: number;
115
+ now?: () => number;
116
+ idFactory?: () => string;
117
+ resources?: readonly DeploymentResource[];
118
+ }
119
+
120
+ export interface DeploymentShutdownOptions {
121
+ reason?: string;
122
+ }
123
+
124
+ export interface DeploymentSignalOptions {
125
+ signals?: readonly NodeJS.Signals[];
126
+ setExitCode?: boolean;
127
+ }
128
+
129
+ export interface DeploymentRuntime {
130
+ readonly metadata: DeploymentMetadata;
131
+ readonly state: DeploymentRuntimeState;
132
+ addResource(resource: DeploymentResource): () => void;
133
+ resources(): DeploymentResourceStatus[];
134
+ start(): Promise<void>;
135
+ readiness(): Promise<DeploymentReadinessReport>;
136
+ diagnostics(): Promise<DeploymentDiagnosticsReport>;
137
+ shutdown(options?: DeploymentShutdownOptions): Promise<void>;
138
+ installSignalHandlers(options?: DeploymentSignalOptions): () => void;
139
+ registerShutdownHook(name?: string): () => void;
140
+ }
141
+
142
+ const DEFAULT_SHUTDOWN_TIMEOUT_MS =
143
+ 10_000;
144
+ const DEFAULT_READINESS_TIMEOUT_MS =
145
+ 5_000;
146
+ const DEFAULT_SIGNALS:
147
+ readonly NodeJS.Signals[] = [
148
+ "SIGTERM",
149
+ "SIGINT",
150
+ ];
151
+
152
+ export function createDeploymentRuntime(
153
+ options: DeploymentRuntimeOptions
154
+ ): DeploymentRuntime {
155
+ const environment =
156
+ options.environment ?? process.env;
157
+ const now =
158
+ options.now ?? Date.now;
159
+ const idFactory =
160
+ options.idFactory ?? randomUUID;
161
+ const shutdownTimeoutMs =
162
+ positiveInteger(
163
+ options.shutdownTimeoutMs ??
164
+ parseOptionalPositiveInteger(
165
+ environment.BCP_SHUTDOWN_TIMEOUT_MS
166
+ ) ??
167
+ DEFAULT_SHUTDOWN_TIMEOUT_MS,
168
+ "shutdownTimeoutMs"
169
+ );
170
+ const readinessTimeoutMs =
171
+ positiveInteger(
172
+ options.readinessTimeoutMs ??
173
+ DEFAULT_READINESS_TIMEOUT_MS,
174
+ "readinessTimeoutMs"
175
+ );
176
+ const startedAtMs = now();
177
+ assertTimestamp(
178
+ startedAtMs,
179
+ "runtime start timestamp"
180
+ );
181
+ const metadata:
182
+ DeploymentMetadata = {
183
+ serviceName:
184
+ normalizeName(
185
+ options.serviceName,
186
+ "serviceName"
187
+ ),
188
+ ...(normalizeOptionalText(
189
+ options.version
190
+ )
191
+ ? {
192
+ version:
193
+ normalizeOptionalText(
194
+ options.version
195
+ ),
196
+ }
197
+ : {}),
198
+ deploymentId:
199
+ normalizeName(
200
+ options.deploymentId ??
201
+ environment.BCP_DEPLOYMENT_ID ??
202
+ idFactory(),
203
+ "deploymentId"
204
+ ),
205
+ ...(normalizeOptionalText(
206
+ options.instanceId ??
207
+ environment.BCP_INSTANCE_ID
208
+ )
209
+ ? {
210
+ instanceId:
211
+ normalizeOptionalText(
212
+ options.instanceId ??
213
+ environment.BCP_INSTANCE_ID
214
+ ),
215
+ }
216
+ : {}),
217
+ ...(normalizeOptionalText(
218
+ options.release ??
219
+ environment.BCP_RELEASE
220
+ )
221
+ ? {
222
+ release:
223
+ normalizeOptionalText(
224
+ options.release ??
225
+ environment.BCP_RELEASE
226
+ ),
227
+ }
228
+ : {}),
229
+ ...(normalizeOptionalText(
230
+ options.environmentName ??
231
+ environment.NODE_ENV
232
+ )
233
+ ? {
234
+ environment:
235
+ normalizeOptionalText(
236
+ options.environmentName ??
237
+ environment.NODE_ENV
238
+ ),
239
+ }
240
+ : {}),
241
+ startedAt:
242
+ new Date(startedAtMs)
243
+ .toISOString(),
244
+ pid: process.pid,
245
+ nodeVersion:
246
+ process.version,
247
+ platform:
248
+ process.platform,
249
+ arch:
250
+ process.arch,
251
+ };
252
+ const abortController =
253
+ new AbortController();
254
+ const resourceEntries:
255
+ Array<{
256
+ resource: DeploymentResource;
257
+ status: DeploymentResourceStatus;
258
+ }> = [];
259
+ const names =
260
+ new Set<string>();
261
+ let state:
262
+ DeploymentRuntimeState =
263
+ "idle";
264
+ let startPromise:
265
+ Promise<void> | undefined;
266
+ let shutdownPromise:
267
+ Promise<void> | undefined;
268
+
269
+ const runtime:
270
+ DeploymentRuntime = {
271
+ metadata,
272
+ get state(): DeploymentRuntimeState {
273
+ return state;
274
+ },
275
+ addResource(
276
+ resource: DeploymentResource
277
+ ): () => void {
278
+ if (
279
+ state !== "idle"
280
+ ) {
281
+ throw new Error(
282
+ "BCP Deployment: resources can only be registered before runtime start."
283
+ );
284
+ }
285
+ const name =
286
+ normalizeName(
287
+ resource.name,
288
+ "resource name"
289
+ );
290
+ if (names.has(name)) {
291
+ throw new Error(
292
+ `BCP Deployment: resource "${name}" is already registered.`
293
+ );
294
+ }
295
+ const normalized:
296
+ DeploymentResource = {
297
+ ...resource,
298
+ name,
299
+ };
300
+ const entry = {
301
+ resource: normalized,
302
+ status: {
303
+ name,
304
+ state:
305
+ "registered" as const,
306
+ },
307
+ };
308
+ names.add(name);
309
+ resourceEntries.push(entry);
310
+ return () => {
311
+ if (state !== "idle") {
312
+ return;
313
+ }
314
+ const index =
315
+ resourceEntries.indexOf(
316
+ entry
317
+ );
318
+ if (index >= 0) {
319
+ resourceEntries.splice(
320
+ index,
321
+ 1
322
+ );
323
+ names.delete(name);
324
+ }
325
+ };
326
+ },
327
+ resources(): DeploymentResourceStatus[] {
328
+ return resourceEntries.map(
329
+ entry => ({
330
+ ...entry.status,
331
+ })
332
+ );
333
+ },
334
+ async start(): Promise<void> {
335
+ if (state === "ready") {
336
+ return;
337
+ }
338
+ if (startPromise) {
339
+ return startPromise;
340
+ }
341
+ if (
342
+ state === "draining" ||
343
+ state === "stopped"
344
+ ) {
345
+ throw new Error(
346
+ "BCP Deployment: stopped runtime cannot be started again."
347
+ );
348
+ }
349
+ if (state === "failed") {
350
+ throw new Error(
351
+ "BCP Deployment: failed runtime cannot be started again."
352
+ );
353
+ }
354
+
355
+ startPromise =
356
+ startResources();
357
+ return startPromise;
358
+ },
359
+ async readiness(): Promise<DeploymentReadinessReport> {
360
+ const checkedAtMs = now();
361
+ assertTimestamp(
362
+ checkedAtMs,
363
+ "readiness timestamp"
364
+ );
365
+ const results:
366
+ DeploymentReadinessItem[] = [];
367
+
368
+ for (const entry of resourceEntries) {
369
+ if (
370
+ entry.status.state !==
371
+ "started"
372
+ ) {
373
+ results.push({
374
+ name:
375
+ entry.resource.name,
376
+ ok: false,
377
+ durationMs: 0,
378
+ detail:
379
+ `Resource state is ${entry.status.state}.`,
380
+ });
381
+ continue;
382
+ }
383
+
384
+ if (!entry.resource.ready) {
385
+ results.push({
386
+ name:
387
+ entry.resource.name,
388
+ ok: true,
389
+ durationMs: 0,
390
+ });
391
+ continue;
392
+ }
393
+
394
+ const checkStarted =
395
+ performance.now();
396
+ try {
397
+ const value =
398
+ await withTimeout(
399
+ Promise.resolve(
400
+ entry.resource.ready(
401
+ createContext()
402
+ )
403
+ ),
404
+ readinessTimeoutMs,
405
+ `readiness check for ${entry.resource.name}`
406
+ );
407
+ const normalized =
408
+ typeof value ===
409
+ "boolean"
410
+ ? {
411
+ ok: value,
412
+ }
413
+ : value;
414
+ results.push({
415
+ name:
416
+ entry.resource.name,
417
+ ok:
418
+ normalized.ok ===
419
+ true,
420
+ durationMs:
421
+ elapsedMilliseconds(
422
+ checkStarted
423
+ ),
424
+ ...(normalized.detail
425
+ ? {
426
+ detail:
427
+ normalized.detail,
428
+ }
429
+ : {}),
430
+ });
431
+ } catch (error) {
432
+ results.push({
433
+ name:
434
+ entry.resource.name,
435
+ ok: false,
436
+ durationMs:
437
+ elapsedMilliseconds(
438
+ checkStarted
439
+ ),
440
+ detail:
441
+ errorMessage(error),
442
+ });
443
+ }
444
+ }
445
+
446
+ return {
447
+ ok:
448
+ state === "ready" &&
449
+ results.every(
450
+ item => item.ok
451
+ ),
452
+ state,
453
+ checkedAt:
454
+ new Date(
455
+ checkedAtMs
456
+ ).toISOString(),
457
+ resources:
458
+ results,
459
+ };
460
+ },
461
+ async diagnostics(): Promise<DeploymentDiagnosticsReport> {
462
+ const resources:
463
+ DeploymentDiagnosticsResource[] = [];
464
+
465
+ for (const entry of resourceEntries) {
466
+ let details:
467
+ Readonly<Record<string, unknown>> |
468
+ undefined;
469
+ let error:
470
+ string | undefined;
471
+ if (entry.resource.diagnostics) {
472
+ try {
473
+ details =
474
+ await entry.resource.diagnostics(
475
+ createContext()
476
+ );
477
+ } catch (diagnosticError) {
478
+ error =
479
+ errorMessage(
480
+ diagnosticError
481
+ );
482
+ }
483
+ }
484
+ resources.push({
485
+ name:
486
+ entry.resource.name,
487
+ lifecycle: {
488
+ ...entry.status,
489
+ },
490
+ ...(details
491
+ ? {
492
+ details,
493
+ }
494
+ : {}),
495
+ ...(error
496
+ ? {
497
+ error,
498
+ }
499
+ : {}),
500
+ });
501
+ }
502
+
503
+ return {
504
+ metadata: {
505
+ ...metadata,
506
+ },
507
+ state,
508
+ uptimeSeconds:
509
+ Math.max(
510
+ 0,
511
+ (
512
+ now() -
513
+ startedAtMs
514
+ ) /
515
+ 1000
516
+ ),
517
+ resources,
518
+ };
519
+ },
520
+ async shutdown(
521
+ _options: DeploymentShutdownOptions = {}
522
+ ): Promise<void> {
523
+ if (shutdownPromise) {
524
+ return shutdownPromise;
525
+ }
526
+ if (state === "stopped") {
527
+ return;
528
+ }
529
+ shutdownPromise =
530
+ stopResources();
531
+ return shutdownPromise;
532
+ },
533
+ installSignalHandlers(
534
+ signalOptions:
535
+ DeploymentSignalOptions = {}
536
+ ): () => void {
537
+ const signals =
538
+ normalizeSignals(
539
+ signalOptions.signals ??
540
+ DEFAULT_SIGNALS
541
+ );
542
+ const handlers =
543
+ new Map<
544
+ NodeJS.Signals,
545
+ () => void
546
+ >();
547
+
548
+ for (const signal of signals) {
549
+ const handler = () => {
550
+ void runtime.shutdown({
551
+ reason: signal,
552
+ }).then(
553
+ () => {
554
+ if (
555
+ signalOptions.setExitCode !==
556
+ false
557
+ ) {
558
+ process.exitCode = 0;
559
+ }
560
+ },
561
+ () => {
562
+ process.exitCode = 1;
563
+ }
564
+ );
565
+ };
566
+ handlers.set(
567
+ signal,
568
+ handler
569
+ );
570
+ process.on(
571
+ signal,
572
+ handler
573
+ );
574
+ }
575
+
576
+ return () => {
577
+ for (
578
+ const [
579
+ signal,
580
+ handler,
581
+ ]
582
+ of handlers
583
+ ) {
584
+ process.off(
585
+ signal,
586
+ handler
587
+ );
588
+ }
589
+ handlers.clear();
590
+ };
591
+ },
592
+ registerShutdownHook(
593
+ name =
594
+ `deployment:${metadata.serviceName}`
595
+ ): () => void {
596
+ return registerShutdownHook(
597
+ () =>
598
+ runtime.shutdown({
599
+ reason:
600
+ "framework-shutdown",
601
+ }),
602
+ {
603
+ name,
604
+ }
605
+ );
606
+ },
607
+ };
608
+
609
+ for (
610
+ const resource
611
+ of options.resources ?? []
612
+ ) {
613
+ runtime.addResource(resource);
614
+ }
615
+
616
+ return runtime;
617
+
618
+ async function startResources(): Promise<void> {
619
+ state = "starting";
620
+ try {
621
+ for (const entry of resourceEntries) {
622
+ entry.status = {
623
+ name:
624
+ entry.resource.name,
625
+ state: "starting",
626
+ };
627
+ try {
628
+ await entry.resource.start?.(
629
+ createContext()
630
+ );
631
+ entry.status = {
632
+ name:
633
+ entry.resource.name,
634
+ state: "started",
635
+ startedAt:
636
+ new Date(
637
+ now()
638
+ ).toISOString(),
639
+ };
640
+ } catch (error) {
641
+ entry.status = {
642
+ name:
643
+ entry.resource.name,
644
+ state: "failed",
645
+ error:
646
+ errorMessage(error),
647
+ };
648
+ throw new Error(
649
+ `BCP Deployment: resource "${entry.resource.name}" failed to start. ${errorMessage(error)}`
650
+ );
651
+ }
652
+ }
653
+ state = "ready";
654
+ } catch (error) {
655
+ state = "failed";
656
+ abortController.abort(error);
657
+ await stopStartedResources(
658
+ shutdownTimeoutMs
659
+ );
660
+ throw error;
661
+ }
662
+ }
663
+
664
+ async function stopResources(): Promise<void> {
665
+ state = "draining";
666
+ if (!abortController.signal.aborted) {
667
+ abortController.abort(
668
+ new Error(
669
+ "BCP Deployment: runtime is shutting down."
670
+ )
671
+ );
672
+ }
673
+ const failures =
674
+ await stopStartedResources(
675
+ shutdownTimeoutMs
676
+ );
677
+ state =
678
+ failures.length > 0
679
+ ? "failed"
680
+ : "stopped";
681
+ if (failures.length > 0) {
682
+ throw new AggregateError(
683
+ failures,
684
+ "BCP Deployment: one or more resources failed to stop."
685
+ );
686
+ }
687
+ }
688
+
689
+ async function stopStartedResources(
690
+ timeoutMs: number
691
+ ): Promise<Error[]> {
692
+ const failures:
693
+ Error[] = [];
694
+ const deadline =
695
+ Date.now() + timeoutMs;
696
+
697
+ for (
698
+ const entry
699
+ of [...resourceEntries].reverse()
700
+ ) {
701
+ if (
702
+ entry.status.state !==
703
+ "started"
704
+ ) {
705
+ continue;
706
+ }
707
+ entry.status = {
708
+ ...entry.status,
709
+ state: "stopping",
710
+ };
711
+ try {
712
+ const remaining =
713
+ Math.max(
714
+ 1,
715
+ deadline -
716
+ Date.now()
717
+ );
718
+ await withTimeout(
719
+ Promise.resolve(
720
+ entry.resource.stop?.(
721
+ createContext()
722
+ )
723
+ ),
724
+ remaining,
725
+ `shutdown of ${entry.resource.name}`
726
+ );
727
+ entry.status = {
728
+ ...entry.status,
729
+ state: "stopped",
730
+ stoppedAt:
731
+ new Date(
732
+ now()
733
+ ).toISOString(),
734
+ };
735
+ } catch (error) {
736
+ const message =
737
+ errorMessage(error);
738
+ entry.status = {
739
+ ...entry.status,
740
+ state: "failed",
741
+ error: message,
742
+ };
743
+ failures.push(
744
+ new Error(
745
+ `BCP Deployment: resource "${entry.resource.name}" failed to stop. ${message}`
746
+ )
747
+ );
748
+ }
749
+ }
750
+
751
+ return failures;
752
+ }
753
+
754
+ function createContext(): DeploymentResourceContext {
755
+ return {
756
+ metadata,
757
+ signal:
758
+ abortController.signal,
759
+ };
760
+ }
761
+ }
762
+
763
+ export function createDeploymentReadinessResponse(
764
+ runtime: DeploymentRuntime
765
+ ): Promise<Response> {
766
+ return runtime.readiness()
767
+ .then(
768
+ report =>
769
+ Response.json(
770
+ report,
771
+ {
772
+ status:
773
+ report.ok
774
+ ? 200
775
+ : 503,
776
+ headers: {
777
+ "cache-control":
778
+ "no-store",
779
+ },
780
+ }
781
+ )
782
+ );
783
+ }
784
+
785
+ export async function createDeploymentDiagnosticsResponse(
786
+ runtime: DeploymentRuntime
787
+ ): Promise<Response> {
788
+ return Response.json(
789
+ await runtime.diagnostics(),
790
+ {
791
+ status: 200,
792
+ headers: {
793
+ "cache-control":
794
+ "no-store",
795
+ },
796
+ }
797
+ );
798
+ }
799
+
800
+ function normalizeSignals(
801
+ signals: readonly NodeJS.Signals[]
802
+ ): NodeJS.Signals[] {
803
+ return Array.from(
804
+ new Set(signals)
805
+ );
806
+ }
807
+
808
+ function normalizeName(
809
+ value: string,
810
+ field: string
811
+ ): string {
812
+ const normalized =
813
+ String(value ?? "").trim();
814
+ if (!normalized) {
815
+ throw new TypeError(
816
+ `BCP Deployment: ${field} must be a non-empty string.`
817
+ );
818
+ }
819
+ if (
820
+ normalized.length > 256 ||
821
+ /[\r\n]/.test(normalized)
822
+ ) {
823
+ throw new TypeError(
824
+ `BCP Deployment: ${field} must be at most 256 characters without line breaks.`
825
+ );
826
+ }
827
+ return normalized;
828
+ }
829
+
830
+ function normalizeOptionalText(
831
+ value: string | undefined
832
+ ): string | undefined {
833
+ if (value === undefined) {
834
+ return undefined;
835
+ }
836
+ const normalized =
837
+ value.trim();
838
+ return normalized || undefined;
839
+ }
840
+
841
+ function positiveInteger(
842
+ value: number,
843
+ field: string
844
+ ): number {
845
+ if (
846
+ !Number.isSafeInteger(value) ||
847
+ value <= 0
848
+ ) {
849
+ throw new TypeError(
850
+ `BCP Deployment: ${field} must be a positive safe integer.`
851
+ );
852
+ }
853
+ return value;
854
+ }
855
+
856
+ function parseOptionalPositiveInteger(
857
+ value: string | undefined
858
+ ): number | undefined {
859
+ if (value === undefined) {
860
+ return undefined;
861
+ }
862
+ const parsed =
863
+ Number(value.trim());
864
+ if (
865
+ !Number.isSafeInteger(parsed) ||
866
+ parsed <= 0
867
+ ) {
868
+ throw new TypeError(
869
+ "BCP Deployment: BCP_SHUTDOWN_TIMEOUT_MS must be a positive safe integer."
870
+ );
871
+ }
872
+ return parsed;
873
+ }
874
+
875
+ function assertTimestamp(
876
+ value: number,
877
+ field: string
878
+ ): void {
879
+ if (!Number.isFinite(value)) {
880
+ throw new TypeError(
881
+ `BCP Deployment: ${field} must be a finite number.`
882
+ );
883
+ }
884
+ }
885
+
886
+ function errorMessage(
887
+ error: unknown
888
+ ): string {
889
+ return error instanceof Error
890
+ ? error.message
891
+ : String(error);
892
+ }
893
+
894
+ function elapsedMilliseconds(
895
+ startedAt: number
896
+ ): number {
897
+ return Math.max(
898
+ 0,
899
+ performance.now() - startedAt
900
+ );
901
+ }
902
+
903
+ function withTimeout<T>(
904
+ promise: Promise<T>,
905
+ timeoutMs: number,
906
+ label: string
907
+ ): Promise<T> {
908
+ return new Promise<T>(
909
+ (
910
+ resolve,
911
+ reject
912
+ ) => {
913
+ const timer =
914
+ setTimeout(
915
+ () =>
916
+ reject(
917
+ new Error(
918
+ `BCP Deployment: ${label} timed out after ${timeoutMs}ms.`
919
+ )
920
+ ),
921
+ timeoutMs
922
+ );
923
+ timer.unref?.();
924
+ promise.then(
925
+ value => {
926
+ clearTimeout(timer);
927
+ resolve(value);
928
+ },
929
+ error => {
930
+ clearTimeout(timer);
931
+ reject(error);
932
+ }
933
+ );
934
+ }
935
+ );
936
+ }