@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,609 @@
1
+ // packages/server/src/deployment.ts
2
+ import {
3
+ randomUUID
4
+ } from "node:crypto";
5
+
6
+ // packages/server/src/production-hardening.ts
7
+ var nextShutdownHookId = 1;
8
+ var shutdownHooks = /* @__PURE__ */ new Map();
9
+ function registerShutdownHook(hook, options = {}) {
10
+ if (typeof hook !== "function") {
11
+ throw new TypeError(
12
+ "BCP Framework: shutdown hook must be a function."
13
+ );
14
+ }
15
+ const id = nextShutdownHookId++;
16
+ const name = normalizeHookName(
17
+ options.name,
18
+ id
19
+ );
20
+ shutdownHooks.set(
21
+ id,
22
+ {
23
+ id,
24
+ name,
25
+ hook
26
+ }
27
+ );
28
+ return () => {
29
+ shutdownHooks.delete(
30
+ id
31
+ );
32
+ };
33
+ }
34
+ function normalizeHookName(value, id) {
35
+ const normalized = value?.trim();
36
+ if (!normalized) {
37
+ return `hook-${id}`;
38
+ }
39
+ if (normalized.length > 128 || /[\r\n]/.test(
40
+ normalized
41
+ )) {
42
+ throw new TypeError(
43
+ "BCP Framework: shutdown hook name must be at most 128 characters without line breaks."
44
+ );
45
+ }
46
+ return normalized;
47
+ }
48
+
49
+ // packages/server/src/deployment.ts
50
+ var DEFAULT_SHUTDOWN_TIMEOUT_MS = 1e4;
51
+ var DEFAULT_READINESS_TIMEOUT_MS = 5e3;
52
+ var DEFAULT_SIGNALS = [
53
+ "SIGTERM",
54
+ "SIGINT"
55
+ ];
56
+ function createDeploymentRuntime(options) {
57
+ const environment = options.environment ?? process.env;
58
+ const now = options.now ?? Date.now;
59
+ const idFactory = options.idFactory ?? randomUUID;
60
+ const shutdownTimeoutMs = positiveInteger(
61
+ options.shutdownTimeoutMs ?? parseOptionalPositiveInteger(
62
+ environment.BCP_SHUTDOWN_TIMEOUT_MS
63
+ ) ?? DEFAULT_SHUTDOWN_TIMEOUT_MS,
64
+ "shutdownTimeoutMs"
65
+ );
66
+ const readinessTimeoutMs = positiveInteger(
67
+ options.readinessTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS,
68
+ "readinessTimeoutMs"
69
+ );
70
+ const startedAtMs = now();
71
+ assertTimestamp(
72
+ startedAtMs,
73
+ "runtime start timestamp"
74
+ );
75
+ const metadata = {
76
+ serviceName: normalizeName(
77
+ options.serviceName,
78
+ "serviceName"
79
+ ),
80
+ ...normalizeOptionalText(
81
+ options.version
82
+ ) ? {
83
+ version: normalizeOptionalText(
84
+ options.version
85
+ )
86
+ } : {},
87
+ deploymentId: normalizeName(
88
+ options.deploymentId ?? environment.BCP_DEPLOYMENT_ID ?? idFactory(),
89
+ "deploymentId"
90
+ ),
91
+ ...normalizeOptionalText(
92
+ options.instanceId ?? environment.BCP_INSTANCE_ID
93
+ ) ? {
94
+ instanceId: normalizeOptionalText(
95
+ options.instanceId ?? environment.BCP_INSTANCE_ID
96
+ )
97
+ } : {},
98
+ ...normalizeOptionalText(
99
+ options.release ?? environment.BCP_RELEASE
100
+ ) ? {
101
+ release: normalizeOptionalText(
102
+ options.release ?? environment.BCP_RELEASE
103
+ )
104
+ } : {},
105
+ ...normalizeOptionalText(
106
+ options.environmentName ?? environment.NODE_ENV
107
+ ) ? {
108
+ environment: normalizeOptionalText(
109
+ options.environmentName ?? environment.NODE_ENV
110
+ )
111
+ } : {},
112
+ startedAt: new Date(startedAtMs).toISOString(),
113
+ pid: process.pid,
114
+ nodeVersion: process.version,
115
+ platform: process.platform,
116
+ arch: process.arch
117
+ };
118
+ const abortController = new AbortController();
119
+ const resourceEntries = [];
120
+ const names = /* @__PURE__ */ new Set();
121
+ let state = "idle";
122
+ let startPromise;
123
+ let shutdownPromise;
124
+ const runtime = {
125
+ metadata,
126
+ get state() {
127
+ return state;
128
+ },
129
+ addResource(resource) {
130
+ if (state !== "idle") {
131
+ throw new Error(
132
+ "BCP Deployment: resources can only be registered before runtime start."
133
+ );
134
+ }
135
+ const name = normalizeName(
136
+ resource.name,
137
+ "resource name"
138
+ );
139
+ if (names.has(name)) {
140
+ throw new Error(
141
+ `BCP Deployment: resource "${name}" is already registered.`
142
+ );
143
+ }
144
+ const normalized = {
145
+ ...resource,
146
+ name
147
+ };
148
+ const entry = {
149
+ resource: normalized,
150
+ status: {
151
+ name,
152
+ state: "registered"
153
+ }
154
+ };
155
+ names.add(name);
156
+ resourceEntries.push(entry);
157
+ return () => {
158
+ if (state !== "idle") {
159
+ return;
160
+ }
161
+ const index = resourceEntries.indexOf(
162
+ entry
163
+ );
164
+ if (index >= 0) {
165
+ resourceEntries.splice(
166
+ index,
167
+ 1
168
+ );
169
+ names.delete(name);
170
+ }
171
+ };
172
+ },
173
+ resources() {
174
+ return resourceEntries.map(
175
+ (entry) => ({
176
+ ...entry.status
177
+ })
178
+ );
179
+ },
180
+ async start() {
181
+ if (state === "ready") {
182
+ return;
183
+ }
184
+ if (startPromise) {
185
+ return startPromise;
186
+ }
187
+ if (state === "draining" || state === "stopped") {
188
+ throw new Error(
189
+ "BCP Deployment: stopped runtime cannot be started again."
190
+ );
191
+ }
192
+ if (state === "failed") {
193
+ throw new Error(
194
+ "BCP Deployment: failed runtime cannot be started again."
195
+ );
196
+ }
197
+ startPromise = startResources();
198
+ return startPromise;
199
+ },
200
+ async readiness() {
201
+ const checkedAtMs = now();
202
+ assertTimestamp(
203
+ checkedAtMs,
204
+ "readiness timestamp"
205
+ );
206
+ const results = [];
207
+ for (const entry of resourceEntries) {
208
+ if (entry.status.state !== "started") {
209
+ results.push({
210
+ name: entry.resource.name,
211
+ ok: false,
212
+ durationMs: 0,
213
+ detail: `Resource state is ${entry.status.state}.`
214
+ });
215
+ continue;
216
+ }
217
+ if (!entry.resource.ready) {
218
+ results.push({
219
+ name: entry.resource.name,
220
+ ok: true,
221
+ durationMs: 0
222
+ });
223
+ continue;
224
+ }
225
+ const checkStarted = performance.now();
226
+ try {
227
+ const value = await withTimeout(
228
+ Promise.resolve(
229
+ entry.resource.ready(
230
+ createContext()
231
+ )
232
+ ),
233
+ readinessTimeoutMs,
234
+ `readiness check for ${entry.resource.name}`
235
+ );
236
+ const normalized = typeof value === "boolean" ? {
237
+ ok: value
238
+ } : value;
239
+ results.push({
240
+ name: entry.resource.name,
241
+ ok: normalized.ok === true,
242
+ durationMs: elapsedMilliseconds(
243
+ checkStarted
244
+ ),
245
+ ...normalized.detail ? {
246
+ detail: normalized.detail
247
+ } : {}
248
+ });
249
+ } catch (error) {
250
+ results.push({
251
+ name: entry.resource.name,
252
+ ok: false,
253
+ durationMs: elapsedMilliseconds(
254
+ checkStarted
255
+ ),
256
+ detail: errorMessage(error)
257
+ });
258
+ }
259
+ }
260
+ return {
261
+ ok: state === "ready" && results.every(
262
+ (item) => item.ok
263
+ ),
264
+ state,
265
+ checkedAt: new Date(
266
+ checkedAtMs
267
+ ).toISOString(),
268
+ resources: results
269
+ };
270
+ },
271
+ async diagnostics() {
272
+ const resources = [];
273
+ for (const entry of resourceEntries) {
274
+ let details;
275
+ let error;
276
+ if (entry.resource.diagnostics) {
277
+ try {
278
+ details = await entry.resource.diagnostics(
279
+ createContext()
280
+ );
281
+ } catch (diagnosticError) {
282
+ error = errorMessage(
283
+ diagnosticError
284
+ );
285
+ }
286
+ }
287
+ resources.push({
288
+ name: entry.resource.name,
289
+ lifecycle: {
290
+ ...entry.status
291
+ },
292
+ ...details ? {
293
+ details
294
+ } : {},
295
+ ...error ? {
296
+ error
297
+ } : {}
298
+ });
299
+ }
300
+ return {
301
+ metadata: {
302
+ ...metadata
303
+ },
304
+ state,
305
+ uptimeSeconds: Math.max(
306
+ 0,
307
+ (now() - startedAtMs) / 1e3
308
+ ),
309
+ resources
310
+ };
311
+ },
312
+ async shutdown(_options = {}) {
313
+ if (shutdownPromise) {
314
+ return shutdownPromise;
315
+ }
316
+ if (state === "stopped") {
317
+ return;
318
+ }
319
+ shutdownPromise = stopResources();
320
+ return shutdownPromise;
321
+ },
322
+ installSignalHandlers(signalOptions = {}) {
323
+ const signals = normalizeSignals(
324
+ signalOptions.signals ?? DEFAULT_SIGNALS
325
+ );
326
+ const handlers = /* @__PURE__ */ new Map();
327
+ for (const signal of signals) {
328
+ const handler = () => {
329
+ void runtime.shutdown({
330
+ reason: signal
331
+ }).then(
332
+ () => {
333
+ if (signalOptions.setExitCode !== false) {
334
+ process.exitCode = 0;
335
+ }
336
+ },
337
+ () => {
338
+ process.exitCode = 1;
339
+ }
340
+ );
341
+ };
342
+ handlers.set(
343
+ signal,
344
+ handler
345
+ );
346
+ process.on(
347
+ signal,
348
+ handler
349
+ );
350
+ }
351
+ return () => {
352
+ for (const [
353
+ signal,
354
+ handler
355
+ ] of handlers) {
356
+ process.off(
357
+ signal,
358
+ handler
359
+ );
360
+ }
361
+ handlers.clear();
362
+ };
363
+ },
364
+ registerShutdownHook(name = `deployment:${metadata.serviceName}`) {
365
+ return registerShutdownHook(
366
+ () => runtime.shutdown({
367
+ reason: "framework-shutdown"
368
+ }),
369
+ {
370
+ name
371
+ }
372
+ );
373
+ }
374
+ };
375
+ for (const resource of options.resources ?? []) {
376
+ runtime.addResource(resource);
377
+ }
378
+ return runtime;
379
+ async function startResources() {
380
+ state = "starting";
381
+ try {
382
+ for (const entry of resourceEntries) {
383
+ entry.status = {
384
+ name: entry.resource.name,
385
+ state: "starting"
386
+ };
387
+ try {
388
+ await entry.resource.start?.(
389
+ createContext()
390
+ );
391
+ entry.status = {
392
+ name: entry.resource.name,
393
+ state: "started",
394
+ startedAt: new Date(
395
+ now()
396
+ ).toISOString()
397
+ };
398
+ } catch (error) {
399
+ entry.status = {
400
+ name: entry.resource.name,
401
+ state: "failed",
402
+ error: errorMessage(error)
403
+ };
404
+ throw new Error(
405
+ `BCP Deployment: resource "${entry.resource.name}" failed to start. ${errorMessage(error)}`
406
+ );
407
+ }
408
+ }
409
+ state = "ready";
410
+ } catch (error) {
411
+ state = "failed";
412
+ abortController.abort(error);
413
+ await stopStartedResources(
414
+ shutdownTimeoutMs
415
+ );
416
+ throw error;
417
+ }
418
+ }
419
+ async function stopResources() {
420
+ state = "draining";
421
+ if (!abortController.signal.aborted) {
422
+ abortController.abort(
423
+ new Error(
424
+ "BCP Deployment: runtime is shutting down."
425
+ )
426
+ );
427
+ }
428
+ const failures = await stopStartedResources(
429
+ shutdownTimeoutMs
430
+ );
431
+ state = failures.length > 0 ? "failed" : "stopped";
432
+ if (failures.length > 0) {
433
+ throw new AggregateError(
434
+ failures,
435
+ "BCP Deployment: one or more resources failed to stop."
436
+ );
437
+ }
438
+ }
439
+ async function stopStartedResources(timeoutMs) {
440
+ const failures = [];
441
+ const deadline = Date.now() + timeoutMs;
442
+ for (const entry of [...resourceEntries].reverse()) {
443
+ if (entry.status.state !== "started") {
444
+ continue;
445
+ }
446
+ entry.status = {
447
+ ...entry.status,
448
+ state: "stopping"
449
+ };
450
+ try {
451
+ const remaining = Math.max(
452
+ 1,
453
+ deadline - Date.now()
454
+ );
455
+ await withTimeout(
456
+ Promise.resolve(
457
+ entry.resource.stop?.(
458
+ createContext()
459
+ )
460
+ ),
461
+ remaining,
462
+ `shutdown of ${entry.resource.name}`
463
+ );
464
+ entry.status = {
465
+ ...entry.status,
466
+ state: "stopped",
467
+ stoppedAt: new Date(
468
+ now()
469
+ ).toISOString()
470
+ };
471
+ } catch (error) {
472
+ const message = errorMessage(error);
473
+ entry.status = {
474
+ ...entry.status,
475
+ state: "failed",
476
+ error: message
477
+ };
478
+ failures.push(
479
+ new Error(
480
+ `BCP Deployment: resource "${entry.resource.name}" failed to stop. ${message}`
481
+ )
482
+ );
483
+ }
484
+ }
485
+ return failures;
486
+ }
487
+ function createContext() {
488
+ return {
489
+ metadata,
490
+ signal: abortController.signal
491
+ };
492
+ }
493
+ }
494
+ function createDeploymentReadinessResponse(runtime) {
495
+ return runtime.readiness().then(
496
+ (report) => Response.json(
497
+ report,
498
+ {
499
+ status: report.ok ? 200 : 503,
500
+ headers: {
501
+ "cache-control": "no-store"
502
+ }
503
+ }
504
+ )
505
+ );
506
+ }
507
+ async function createDeploymentDiagnosticsResponse(runtime) {
508
+ return Response.json(
509
+ await runtime.diagnostics(),
510
+ {
511
+ status: 200,
512
+ headers: {
513
+ "cache-control": "no-store"
514
+ }
515
+ }
516
+ );
517
+ }
518
+ function normalizeSignals(signals) {
519
+ return Array.from(
520
+ new Set(signals)
521
+ );
522
+ }
523
+ function normalizeName(value, field) {
524
+ const normalized = String(value ?? "").trim();
525
+ if (!normalized) {
526
+ throw new TypeError(
527
+ `BCP Deployment: ${field} must be a non-empty string.`
528
+ );
529
+ }
530
+ if (normalized.length > 256 || /[\r\n]/.test(normalized)) {
531
+ throw new TypeError(
532
+ `BCP Deployment: ${field} must be at most 256 characters without line breaks.`
533
+ );
534
+ }
535
+ return normalized;
536
+ }
537
+ function normalizeOptionalText(value) {
538
+ if (value === void 0) {
539
+ return void 0;
540
+ }
541
+ const normalized = value.trim();
542
+ return normalized || void 0;
543
+ }
544
+ function positiveInteger(value, field) {
545
+ if (!Number.isSafeInteger(value) || value <= 0) {
546
+ throw new TypeError(
547
+ `BCP Deployment: ${field} must be a positive safe integer.`
548
+ );
549
+ }
550
+ return value;
551
+ }
552
+ function parseOptionalPositiveInteger(value) {
553
+ if (value === void 0) {
554
+ return void 0;
555
+ }
556
+ const parsed = Number(value.trim());
557
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) {
558
+ throw new TypeError(
559
+ "BCP Deployment: BCP_SHUTDOWN_TIMEOUT_MS must be a positive safe integer."
560
+ );
561
+ }
562
+ return parsed;
563
+ }
564
+ function assertTimestamp(value, field) {
565
+ if (!Number.isFinite(value)) {
566
+ throw new TypeError(
567
+ `BCP Deployment: ${field} must be a finite number.`
568
+ );
569
+ }
570
+ }
571
+ function errorMessage(error) {
572
+ return error instanceof Error ? error.message : String(error);
573
+ }
574
+ function elapsedMilliseconds(startedAt) {
575
+ return Math.max(
576
+ 0,
577
+ performance.now() - startedAt
578
+ );
579
+ }
580
+ function withTimeout(promise, timeoutMs, label) {
581
+ return new Promise(
582
+ (resolve, reject) => {
583
+ const timer = setTimeout(
584
+ () => reject(
585
+ new Error(
586
+ `BCP Deployment: ${label} timed out after ${timeoutMs}ms.`
587
+ )
588
+ ),
589
+ timeoutMs
590
+ );
591
+ timer.unref?.();
592
+ promise.then(
593
+ (value) => {
594
+ clearTimeout(timer);
595
+ resolve(value);
596
+ },
597
+ (error) => {
598
+ clearTimeout(timer);
599
+ reject(error);
600
+ }
601
+ );
602
+ }
603
+ );
604
+ }
605
+ export {
606
+ createDeploymentDiagnosticsResponse,
607
+ createDeploymentReadinessResponse,
608
+ createDeploymentRuntime
609
+ };
@@ -0,0 +1,20 @@
1
+ export {
2
+ createDeploymentDiagnosticsResponse,
3
+ createDeploymentReadinessResponse,
4
+ createDeploymentRuntime,
5
+
6
+ type DeploymentDiagnosticsReport,
7
+ type DeploymentDiagnosticsResource,
8
+ type DeploymentMetadata,
9
+ type DeploymentReadinessItem,
10
+ type DeploymentReadinessReport,
11
+ type DeploymentReadinessResult,
12
+ type DeploymentResource,
13
+ type DeploymentResourceContext,
14
+ type DeploymentResourceStatus,
15
+ type DeploymentRuntime,
16
+ type DeploymentRuntimeOptions,
17
+ type DeploymentRuntimeState,
18
+ type DeploymentShutdownOptions,
19
+ type DeploymentSignalOptions,
20
+ } from "../../server/src/deployment.js";