@chidchanun/bcp 0.2.19 → 0.3.1

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