@chidchanun/bcp 0.2.18 → 0.3.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.
@@ -0,0 +1,845 @@
1
+ import {
2
+ createDeploymentRuntime,
3
+
4
+ type DeploymentDiagnosticsReport,
5
+ type DeploymentMetadata,
6
+ type DeploymentReadinessReport,
7
+ type DeploymentResource,
8
+ type DeploymentRuntime,
9
+ type DeploymentRuntimeOptions,
10
+ type DeploymentShutdownOptions,
11
+ type DeploymentSignalOptions,
12
+ } from "./deployment.js";
13
+
14
+ import {
15
+ createPluginHost,
16
+
17
+ type PluginDefinition,
18
+ type PluginHookBus,
19
+ type PluginHost,
20
+ type PluginModule,
21
+ type PluginServiceKey,
22
+ type PluginServiceRegistry,
23
+ } from "./plugins.js";
24
+
25
+ import {
26
+ registerShutdownHook as registerProductionShutdownHook,
27
+ } from "./production-hardening.js";
28
+
29
+ const DEFAULT_APPLICATION_SIGNALS:
30
+ readonly NodeJS.Signals[] = [
31
+ "SIGTERM",
32
+ "SIGINT",
33
+ ];
34
+
35
+ export type ApplicationState =
36
+ | "created"
37
+ | "starting"
38
+ | "ready"
39
+ | "stopping"
40
+ | "stopped"
41
+ | "failed";
42
+
43
+ export interface ApplicationConfigSchema<TConfig> {
44
+ parse(value: unknown): TConfig;
45
+ }
46
+
47
+ export type ApplicationConfigParser<TConfig> =
48
+ | ApplicationConfigSchema<TConfig>
49
+ | ((value: unknown) => TConfig);
50
+
51
+ export interface ApplicationContext<TConfig = unknown> {
52
+ readonly name: string;
53
+ readonly version?: string;
54
+ readonly config: TConfig;
55
+ readonly services: PluginServiceRegistry;
56
+ readonly hooks: PluginHookBus;
57
+ readonly plugins: PluginHost;
58
+ readonly deployment: DeploymentRuntime;
59
+ readonly metadata: DeploymentMetadata;
60
+ readonly state: ApplicationState;
61
+ }
62
+
63
+ export interface ApplicationDefinition<TConfig = unknown> {
64
+ name: string;
65
+ version?: string;
66
+ config?: unknown;
67
+ schema?: ApplicationConfigParser<TConfig>;
68
+ plugins?: readonly PluginDefinition<any>[];
69
+ modules?: readonly PluginModule[];
70
+ pluginConfigs?: Record<string, unknown>;
71
+ services?: Iterable<
72
+ readonly [PluginServiceKey, unknown]
73
+ >;
74
+ resources?: readonly DeploymentResource[];
75
+ deployment?: Omit<
76
+ DeploymentRuntimeOptions,
77
+ "serviceName" | "version" | "resources"
78
+ >;
79
+ setup?(
80
+ context: ApplicationContext<TConfig>
81
+ ): void | Promise<void>;
82
+ start?(
83
+ context: ApplicationContext<TConfig>
84
+ ): void | Promise<void>;
85
+ stop?(
86
+ context: ApplicationContext<TConfig>
87
+ ): void | Promise<void>;
88
+ dispose?(
89
+ context: ApplicationContext<TConfig>
90
+ ): void | Promise<void>;
91
+ }
92
+
93
+ export interface Application<TConfig = unknown> {
94
+ readonly name: string;
95
+ readonly version?: string;
96
+ readonly state: ApplicationState;
97
+ readonly config: TConfig;
98
+ readonly context: ApplicationContext<TConfig>;
99
+ readonly services: PluginServiceRegistry;
100
+ readonly hooks: PluginHookBus;
101
+ readonly plugins: PluginHost;
102
+ readonly deployment: DeploymentRuntime;
103
+ use(
104
+ extension:
105
+ | PluginDefinition<any>
106
+ | PluginModule
107
+ ): Application<TConfig>;
108
+ provide<T>(
109
+ key: PluginServiceKey<T>,
110
+ value: T,
111
+ options?: {
112
+ replace?: boolean;
113
+ }
114
+ ): Application<TConfig>;
115
+ addResource(
116
+ resource: DeploymentResource
117
+ ): Application<TConfig>;
118
+ start(): Promise<void>;
119
+ stop(
120
+ options?: DeploymentShutdownOptions
121
+ ): Promise<void>;
122
+ shutdown(
123
+ options?: DeploymentShutdownOptions
124
+ ): Promise<void>;
125
+ close(
126
+ options?: DeploymentShutdownOptions
127
+ ): Promise<void>;
128
+ readiness(): Promise<DeploymentReadinessReport>;
129
+ diagnostics(): Promise<DeploymentDiagnosticsReport>;
130
+ installSignalHandlers(
131
+ options?: DeploymentSignalOptions
132
+ ): () => void;
133
+ registerShutdownHook(
134
+ name?: string
135
+ ): () => void;
136
+ }
137
+
138
+ export class ApplicationLifecycleError
139
+ extends Error {
140
+ readonly phase: string;
141
+ readonly cause: unknown;
142
+
143
+ constructor(
144
+ phase: string,
145
+ cause: unknown
146
+ ) {
147
+ super(
148
+ `BCP Application: ${phase} failed: ${formatError(cause)}`
149
+ );
150
+ this.name =
151
+ "ApplicationLifecycleError";
152
+ this.phase = phase;
153
+ this.cause = cause;
154
+ }
155
+ }
156
+
157
+ export function defineApp<TConfig = unknown>(
158
+ definition: ApplicationDefinition<TConfig>
159
+ ): ApplicationDefinition<TConfig> {
160
+ validateDefinition(definition);
161
+ return definition;
162
+ }
163
+
164
+ export function createApp<TConfig = unknown>(
165
+ definition: ApplicationDefinition<TConfig>
166
+ ): Application<TConfig> {
167
+ defineApp(definition);
168
+
169
+ const name =
170
+ normalizeName(
171
+ definition.name,
172
+ "application name"
173
+ );
174
+ const version =
175
+ normalizeOptionalText(
176
+ definition.version
177
+ );
178
+ const config =
179
+ parseConfig(
180
+ definition.schema,
181
+ definition.config
182
+ );
183
+ const plugins =
184
+ createPluginHost({
185
+ plugins:
186
+ definition.plugins,
187
+ modules:
188
+ definition.modules,
189
+ configs:
190
+ definition.pluginConfigs,
191
+ services:
192
+ definition.services,
193
+ });
194
+ const deployment =
195
+ createDeploymentRuntime({
196
+ ...(definition.deployment ?? {}),
197
+ serviceName:
198
+ name,
199
+ ...(version
200
+ ? {
201
+ version,
202
+ }
203
+ : {}),
204
+ });
205
+
206
+ let state:
207
+ ApplicationState =
208
+ "created";
209
+ let startPromise:
210
+ Promise<void> | null =
211
+ null;
212
+ let stopPromise:
213
+ Promise<void> | null =
214
+ null;
215
+ let setupCompleted = false;
216
+ let applicationResourceRegistered =
217
+ false;
218
+ let disposed = false;
219
+ let failure:
220
+ ApplicationLifecycleError | null =
221
+ null;
222
+
223
+ const context:
224
+ ApplicationContext<TConfig> = {
225
+ name,
226
+ ...(version
227
+ ? {
228
+ version,
229
+ }
230
+ : {}),
231
+ config,
232
+ services:
233
+ plugins.services,
234
+ hooks:
235
+ plugins.hooks,
236
+ plugins,
237
+ deployment,
238
+ get metadata() {
239
+ return deployment.metadata;
240
+ },
241
+ get state() {
242
+ return state;
243
+ },
244
+ };
245
+
246
+ deployment.addResource({
247
+ name:
248
+ "bcp:plugins",
249
+ async start() {
250
+ await plugins.start();
251
+ },
252
+ ready() {
253
+ return {
254
+ ok:
255
+ plugins.started,
256
+ detail:
257
+ plugins.started
258
+ ? "Plugin host started."
259
+ : "Plugin host is not started.",
260
+ };
261
+ },
262
+ async stop() {
263
+ await plugins.close();
264
+ },
265
+ diagnostics() {
266
+ return {
267
+ started:
268
+ plugins.started,
269
+ plugins:
270
+ plugins.plugins(),
271
+ };
272
+ },
273
+ });
274
+
275
+ for (
276
+ const resource
277
+ of definition.resources ?? []
278
+ ) {
279
+ deployment.addResource(resource);
280
+ }
281
+
282
+ const app:
283
+ Application<TConfig> = {
284
+ name,
285
+ ...(version
286
+ ? {
287
+ version,
288
+ }
289
+ : {}),
290
+ config,
291
+ context,
292
+ services:
293
+ plugins.services,
294
+ hooks:
295
+ plugins.hooks,
296
+ plugins,
297
+ deployment,
298
+
299
+ get state() {
300
+ return state;
301
+ },
302
+
303
+ use(extension) {
304
+ assertMutable();
305
+ plugins.use(extension);
306
+ return app;
307
+ },
308
+
309
+ provide<T>(
310
+ key: PluginServiceKey<T>,
311
+ value: T,
312
+ options: {
313
+ replace?: boolean;
314
+ } = {}
315
+ ) {
316
+ assertMutable();
317
+ plugins.services.provide(
318
+ key,
319
+ value,
320
+ options
321
+ );
322
+ return app;
323
+ },
324
+
325
+ addResource(resource) {
326
+ assertMutable();
327
+ deployment.addResource(
328
+ resource
329
+ );
330
+ return app;
331
+ },
332
+
333
+ async start() {
334
+ if (state === "ready") {
335
+ return;
336
+ }
337
+ if (state === "starting") {
338
+ if (!startPromise) {
339
+ throw new Error(
340
+ "BCP Application: startup promise is unavailable while starting."
341
+ );
342
+ }
343
+ return startPromise;
344
+ }
345
+ if (state === "stopping") {
346
+ throw new Error(
347
+ "BCP Application: application cannot start while stopping."
348
+ );
349
+ }
350
+ if (state === "stopped") {
351
+ throw new Error(
352
+ "BCP Application: a stopped application cannot be started again."
353
+ );
354
+ }
355
+ if (state === "failed") {
356
+ throw failure ??
357
+ new Error(
358
+ "BCP Application: failed application cannot be started again."
359
+ );
360
+ }
361
+
362
+ state =
363
+ "starting";
364
+ startPromise =
365
+ startApplication();
366
+ return startPromise;
367
+ },
368
+
369
+ stop(options = {}) {
370
+ return shutdownApplication(
371
+ options
372
+ );
373
+ },
374
+
375
+ shutdown(options = {}) {
376
+ return shutdownApplication(
377
+ options
378
+ );
379
+ },
380
+
381
+ close(options = {}) {
382
+ return shutdownApplication(
383
+ options
384
+ );
385
+ },
386
+
387
+ readiness() {
388
+ return deployment.readiness();
389
+ },
390
+
391
+ diagnostics() {
392
+ return deployment.diagnostics();
393
+ },
394
+
395
+ installSignalHandlers(
396
+ signalOptions:
397
+ DeploymentSignalOptions = {}
398
+ ) {
399
+ const signals =
400
+ Array.from(
401
+ new Set(
402
+ signalOptions.signals ??
403
+ DEFAULT_APPLICATION_SIGNALS
404
+ )
405
+ );
406
+ const handlers =
407
+ new Map<
408
+ NodeJS.Signals,
409
+ () => void
410
+ >();
411
+
412
+ for (const signal of signals) {
413
+ const handler = () => {
414
+ void app.shutdown({
415
+ reason:
416
+ signal,
417
+ }).then(
418
+ () => {
419
+ if (
420
+ signalOptions.setExitCode !==
421
+ false
422
+ ) {
423
+ process.exitCode = 0;
424
+ }
425
+ },
426
+ () => {
427
+ process.exitCode = 1;
428
+ }
429
+ );
430
+ };
431
+ handlers.set(
432
+ signal,
433
+ handler
434
+ );
435
+ process.on(
436
+ signal,
437
+ handler
438
+ );
439
+ }
440
+
441
+ return () => {
442
+ for (
443
+ const [
444
+ signal,
445
+ handler,
446
+ ]
447
+ of handlers
448
+ ) {
449
+ process.off(
450
+ signal,
451
+ handler
452
+ );
453
+ }
454
+ handlers.clear();
455
+ };
456
+ },
457
+
458
+ registerShutdownHook(hookName) {
459
+ return registerProductionShutdownHook(
460
+ () =>
461
+ app.shutdown({
462
+ reason:
463
+ "framework-shutdown",
464
+ }),
465
+ {
466
+ name:
467
+ hookName ??
468
+ `application:${name}`,
469
+ }
470
+ );
471
+ },
472
+ };
473
+
474
+ return app;
475
+
476
+ async function startApplication():
477
+ Promise<void> {
478
+ try {
479
+ if (!setupCompleted) {
480
+ await runHook(
481
+ "setup",
482
+ definition.setup
483
+ );
484
+ setupCompleted = true;
485
+ }
486
+
487
+ registerApplicationResource();
488
+ await deployment.start();
489
+ state =
490
+ "ready";
491
+ failure =
492
+ null;
493
+ } catch (error) {
494
+ const lifecycleError =
495
+ error instanceof ApplicationLifecycleError
496
+ ? error
497
+ : new ApplicationLifecycleError(
498
+ "start",
499
+ error
500
+ );
501
+ failure =
502
+ lifecycleError;
503
+ state =
504
+ "failed";
505
+ await cleanupAfterFailure();
506
+ throw lifecycleError;
507
+ } finally {
508
+ startPromise =
509
+ null;
510
+ }
511
+ }
512
+
513
+ async function shutdownApplication(
514
+ options: DeploymentShutdownOptions
515
+ ): Promise<void> {
516
+ if (state === "stopped") {
517
+ return;
518
+ }
519
+ if (state === "stopping") {
520
+ return stopPromise ??
521
+ Promise.resolve();
522
+ }
523
+
524
+ if (state === "starting") {
525
+ try {
526
+ await startPromise;
527
+ } catch {
528
+ // Startup cleanup already preserved the original failure.
529
+ }
530
+ }
531
+
532
+ state =
533
+ "stopping";
534
+ stopPromise =
535
+ stopApplication(options);
536
+ return stopPromise;
537
+ }
538
+
539
+ async function stopApplication(
540
+ options: DeploymentShutdownOptions
541
+ ): Promise<void> {
542
+ const errors:
543
+ unknown[] = [];
544
+
545
+ try {
546
+ if (
547
+ deployment.state !== "idle" &&
548
+ deployment.state !== "stopped"
549
+ ) {
550
+ try {
551
+ await deployment.shutdown(
552
+ options
553
+ );
554
+ } catch (error) {
555
+ errors.push(error);
556
+ }
557
+ } else {
558
+ try {
559
+ await plugins.close();
560
+ } catch (error) {
561
+ errors.push(error);
562
+ }
563
+ }
564
+
565
+ try {
566
+ await disposeApplication();
567
+ } catch (error) {
568
+ errors.push(error);
569
+ }
570
+
571
+ state =
572
+ "stopped";
573
+ } finally {
574
+ stopPromise =
575
+ null;
576
+ }
577
+
578
+ if (errors.length > 0) {
579
+ throw new AggregateError(
580
+ errors,
581
+ "BCP Application: one or more shutdown operations failed."
582
+ );
583
+ }
584
+ }
585
+
586
+ function registerApplicationResource():
587
+ void {
588
+ if (applicationResourceRegistered) {
589
+ return;
590
+ }
591
+
592
+ deployment.addResource({
593
+ name:
594
+ "bcp:application",
595
+ async start() {
596
+ await runHook(
597
+ "start",
598
+ definition.start
599
+ );
600
+ },
601
+ ready() {
602
+ return true;
603
+ },
604
+ async stop() {
605
+ await runHook(
606
+ "stop",
607
+ definition.stop
608
+ );
609
+ },
610
+ diagnostics() {
611
+ return {
612
+ name,
613
+ ...(version
614
+ ? {
615
+ version,
616
+ }
617
+ : {}),
618
+ state,
619
+ services:
620
+ plugins.services
621
+ .keys()
622
+ .map(
623
+ formatServiceKey
624
+ ),
625
+ pluginCount:
626
+ plugins.plugins().length,
627
+ };
628
+ },
629
+ });
630
+
631
+ applicationResourceRegistered =
632
+ true;
633
+ }
634
+
635
+ async function cleanupAfterFailure():
636
+ Promise<void> {
637
+ if (
638
+ deployment.state !== "idle" &&
639
+ deployment.state !== "stopped"
640
+ ) {
641
+ try {
642
+ await deployment.shutdown({
643
+ reason:
644
+ "application-start-failed",
645
+ });
646
+ } catch {
647
+ // Preserve the original startup failure.
648
+ }
649
+ }
650
+
651
+ try {
652
+ await plugins.close();
653
+ } catch {
654
+ // Preserve the original startup failure.
655
+ }
656
+
657
+ try {
658
+ await disposeApplication();
659
+ } catch {
660
+ // Preserve the original startup failure.
661
+ }
662
+ }
663
+
664
+ async function disposeApplication():
665
+ Promise<void> {
666
+ if (disposed) {
667
+ return;
668
+ }
669
+ disposed = true;
670
+ await runHook(
671
+ "dispose",
672
+ definition.dispose
673
+ );
674
+ }
675
+
676
+ async function runHook(
677
+ phase: string,
678
+ hook:
679
+ | ((
680
+ value: ApplicationContext<TConfig>
681
+ ) => void | Promise<void>)
682
+ | undefined
683
+ ): Promise<void> {
684
+ if (!hook) {
685
+ return;
686
+ }
687
+ try {
688
+ await hook(context);
689
+ } catch (error) {
690
+ throw new ApplicationLifecycleError(
691
+ phase,
692
+ error
693
+ );
694
+ }
695
+ }
696
+
697
+ function assertMutable(): void {
698
+ if (state !== "created") {
699
+ throw new Error(
700
+ "BCP Application: plugins, services and resources must be registered before start()."
701
+ );
702
+ }
703
+ }
704
+ }
705
+
706
+ function parseConfig<TConfig>(
707
+ parser:
708
+ ApplicationConfigParser<TConfig> |
709
+ undefined,
710
+ value: unknown
711
+ ): TConfig {
712
+ if (!parser) {
713
+ return value as TConfig;
714
+ }
715
+ if (typeof parser === "function") {
716
+ return parser(value);
717
+ }
718
+ if (
719
+ parser &&
720
+ typeof parser === "object" &&
721
+ typeof parser.parse === "function"
722
+ ) {
723
+ return parser.parse(value);
724
+ }
725
+ throw new TypeError(
726
+ "BCP Application: config schema must be a function or an object with parse()."
727
+ );
728
+ }
729
+
730
+ function validateDefinition<TConfig>(
731
+ definition: ApplicationDefinition<TConfig>
732
+ ): void {
733
+ if (
734
+ !definition ||
735
+ typeof definition !== "object"
736
+ ) {
737
+ throw new TypeError(
738
+ "BCP Application: definition must be an object."
739
+ );
740
+ }
741
+
742
+ normalizeName(
743
+ definition.name,
744
+ "application name"
745
+ );
746
+ normalizeOptionalText(
747
+ definition.version
748
+ );
749
+
750
+ if (
751
+ definition.plugins !== undefined &&
752
+ !Array.isArray(definition.plugins)
753
+ ) {
754
+ throw new TypeError(
755
+ "BCP Application: plugins must be an array."
756
+ );
757
+ }
758
+ if (
759
+ definition.modules !== undefined &&
760
+ !Array.isArray(definition.modules)
761
+ ) {
762
+ throw new TypeError(
763
+ "BCP Application: modules must be an array."
764
+ );
765
+ }
766
+ if (
767
+ definition.resources !== undefined &&
768
+ !Array.isArray(definition.resources)
769
+ ) {
770
+ throw new TypeError(
771
+ "BCP Application: resources must be an array."
772
+ );
773
+ }
774
+
775
+ for (
776
+ const [phase, hook]
777
+ of [
778
+ ["setup", definition.setup],
779
+ ["start", definition.start],
780
+ ["stop", definition.stop],
781
+ ["dispose", definition.dispose],
782
+ ] as const
783
+ ) {
784
+ if (
785
+ hook !== undefined &&
786
+ typeof hook !== "function"
787
+ ) {
788
+ throw new TypeError(
789
+ `BCP Application: ${phase} must be a function.`
790
+ );
791
+ }
792
+ }
793
+ }
794
+
795
+ function normalizeName(
796
+ value: unknown,
797
+ label: string
798
+ ): string {
799
+ if (typeof value !== "string") {
800
+ throw new TypeError(
801
+ `BCP Application: ${label} must be a string.`
802
+ );
803
+ }
804
+ const normalized =
805
+ value.trim();
806
+ if (!normalized) {
807
+ throw new TypeError(
808
+ `BCP Application: ${label} cannot be empty.`
809
+ );
810
+ }
811
+ return normalized;
812
+ }
813
+
814
+ function normalizeOptionalText(
815
+ value: unknown
816
+ ): string | undefined {
817
+ if (value === undefined) {
818
+ return undefined;
819
+ }
820
+ if (typeof value !== "string") {
821
+ throw new TypeError(
822
+ "BCP Application: optional text values must be strings."
823
+ );
824
+ }
825
+ return value.trim() || undefined;
826
+ }
827
+
828
+ function formatServiceKey(
829
+ key: PluginServiceKey
830
+ ): string {
831
+ return typeof key === "symbol"
832
+ ? key.description
833
+ ? `Symbol(${key.description})`
834
+ : key.toString()
835
+ : key;
836
+ }
837
+
838
+ function formatError(
839
+ value: unknown
840
+ ): string {
841
+ if (value instanceof Error) {
842
+ return value.message;
843
+ }
844
+ return String(value);
845
+ }