@chidchanun/bcp 0.3.1 → 0.3.2

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,682 @@
1
+ import {
2
+ type ServiceContainer,
3
+ type ServiceProvider,
4
+ type ServiceToken,
5
+ } from "./container.js";
6
+
7
+ import {
8
+ type DeploymentResource,
9
+ } from "./deployment.js";
10
+
11
+ import {
12
+ type PluginDefinition,
13
+ type PluginHookBus,
14
+ type PluginServiceKey,
15
+ type PluginServiceRegistry,
16
+ } from "./plugins.js";
17
+
18
+ export const MODULE_V2_KIND =
19
+ "bcp-module-v2" as const;
20
+
21
+ export interface ModuleConfigSchema<TConfig> {
22
+ parse(value: unknown): TConfig;
23
+ }
24
+
25
+ export type ModuleConfigParser<TConfig> =
26
+ | ModuleConfigSchema<TConfig>
27
+ | ((value: unknown) => TConfig);
28
+
29
+ export interface ModuleContext<TConfig = unknown> {
30
+ readonly name: string;
31
+ readonly config: TConfig;
32
+ readonly container: ServiceContainer;
33
+ readonly services: PluginServiceRegistry;
34
+ readonly hooks: PluginHookBus;
35
+ }
36
+
37
+ export interface ModuleDefinition<TConfig = unknown> {
38
+ readonly kind: typeof MODULE_V2_KIND;
39
+ readonly name: string;
40
+ readonly version?: string;
41
+ readonly imports?: readonly ModuleDefinition<any>[];
42
+ readonly providers?: readonly ServiceProvider<any>[];
43
+ readonly exports?: readonly ServiceToken<any>[];
44
+ readonly plugins?: readonly PluginDefinition<any>[];
45
+ readonly services?: Iterable<
46
+ readonly [PluginServiceKey, unknown]
47
+ >;
48
+ readonly resources?: readonly DeploymentResource[];
49
+ readonly config?: unknown;
50
+ readonly schema?: ModuleConfigParser<TConfig>;
51
+ setup?(
52
+ context: ModuleContext<TConfig>
53
+ ): void | Promise<void>;
54
+ start?(
55
+ context: ModuleContext<TConfig>
56
+ ): void | Promise<void>;
57
+ stop?(
58
+ context: ModuleContext<TConfig>
59
+ ): void | Promise<void>;
60
+ dispose?(
61
+ context: ModuleContext<TConfig>
62
+ ): void | Promise<void>;
63
+ }
64
+
65
+ export interface ModuleInput<TConfig = unknown>
66
+ extends Omit<ModuleDefinition<TConfig>, "kind"> {
67
+ kind?: typeof MODULE_V2_KIND;
68
+ }
69
+
70
+ export interface ModuleRecord {
71
+ name: string;
72
+ version?: string;
73
+ imports: string[];
74
+ providers: string[];
75
+ exports: string[];
76
+ plugins: string[];
77
+ resources: string[];
78
+ }
79
+
80
+ export interface ModuleComposition {
81
+ readonly modules: readonly ModuleDefinition<any>[];
82
+ readonly providers: readonly ServiceProvider<any>[];
83
+ readonly plugins: readonly PluginDefinition<any>[];
84
+ readonly services: readonly (
85
+ readonly [PluginServiceKey, unknown]
86
+ )[];
87
+ readonly resources: readonly DeploymentResource[];
88
+ records(): ModuleRecord[];
89
+ exportedTokens(moduleName: string): ServiceToken<any>[];
90
+ createLifecycleResources(
91
+ context: {
92
+ container: ServiceContainer;
93
+ services: PluginServiceRegistry;
94
+ hooks: PluginHookBus;
95
+ }
96
+ ): DeploymentResource[];
97
+ }
98
+
99
+ export class ModuleDependencyError
100
+ extends Error {
101
+ constructor(message: string) {
102
+ super(message);
103
+ this.name =
104
+ "ModuleDependencyError";
105
+ }
106
+ }
107
+
108
+ export class ModuleLifecycleError
109
+ extends Error {
110
+ readonly module: string;
111
+ readonly phase: string;
112
+ readonly cause: unknown;
113
+
114
+ constructor(
115
+ moduleName: string,
116
+ phase: string,
117
+ cause: unknown
118
+ ) {
119
+ super(
120
+ `BCP Modules: ${phase} failed for module "${moduleName}": ${formatError(cause)}`
121
+ );
122
+ this.name =
123
+ "ModuleLifecycleError";
124
+ this.module = moduleName;
125
+ this.phase = phase;
126
+ this.cause = cause;
127
+ }
128
+ }
129
+
130
+ export function defineModule<TConfig = unknown>(
131
+ input: ModuleInput<TConfig>
132
+ ): ModuleDefinition<TConfig> {
133
+ const definition = {
134
+ ...input,
135
+ kind:
136
+ MODULE_V2_KIND,
137
+ } satisfies ModuleDefinition<TConfig>;
138
+
139
+ validateModuleDefinition(definition);
140
+ return definition;
141
+ }
142
+
143
+ export function isModuleDefinition(
144
+ value: unknown
145
+ ): value is ModuleDefinition<any> {
146
+ return Boolean(
147
+ value &&
148
+ typeof value === "object" &&
149
+ (
150
+ value as {
151
+ kind?: unknown;
152
+ }
153
+ ).kind === MODULE_V2_KIND
154
+ );
155
+ }
156
+
157
+ export function composeModules(
158
+ roots: readonly ModuleDefinition<any>[] = []
159
+ ): ModuleComposition {
160
+ const ordered =
161
+ resolveModuleOrder(roots);
162
+ const providers:
163
+ ServiceProvider<any>[] = [];
164
+ const plugins:
165
+ PluginDefinition<any>[] = [];
166
+ const services:
167
+ Array<
168
+ readonly [PluginServiceKey, unknown]
169
+ > = [];
170
+ const resources:
171
+ DeploymentResource[] = [];
172
+ const serviceKeys =
173
+ new Set<PluginServiceKey>();
174
+
175
+ for (const module of ordered) {
176
+ providers.push(
177
+ ...(module.providers ?? [])
178
+ );
179
+ plugins.push(
180
+ ...(module.plugins ?? [])
181
+ );
182
+ resources.push(
183
+ ...(module.resources ?? [])
184
+ );
185
+
186
+ for (
187
+ const entry
188
+ of module.services ?? []
189
+ ) {
190
+ const [key] = entry;
191
+ if (serviceKeys.has(key)) {
192
+ throw new ModuleDependencyError(
193
+ `BCP Modules: duplicate shared service ${formatServiceKey(key)} while composing module "${module.name}".`
194
+ );
195
+ }
196
+ serviceKeys.add(key);
197
+ services.push(entry);
198
+ }
199
+
200
+ validateModuleExports(
201
+ module
202
+ );
203
+ }
204
+
205
+ return {
206
+ modules:
207
+ ordered,
208
+ providers,
209
+ plugins,
210
+ services,
211
+ resources,
212
+
213
+ records() {
214
+ return ordered.map(
215
+ module => ({
216
+ name:
217
+ module.name,
218
+ ...(module.version
219
+ ? {
220
+ version:
221
+ module.version,
222
+ }
223
+ : {}),
224
+ imports:
225
+ (
226
+ module.imports ?? []
227
+ ).map(
228
+ dependency =>
229
+ dependency.name
230
+ ),
231
+ providers:
232
+ (
233
+ module.providers ?? []
234
+ ).map(
235
+ provider =>
236
+ provider.token.description
237
+ ),
238
+ exports:
239
+ (
240
+ module.exports ?? []
241
+ ).map(
242
+ token =>
243
+ token.description
244
+ ),
245
+ plugins:
246
+ (
247
+ module.plugins ?? []
248
+ ).map(
249
+ plugin =>
250
+ plugin.name
251
+ ),
252
+ resources:
253
+ (
254
+ module.resources ?? []
255
+ ).map(
256
+ resource =>
257
+ resource.name
258
+ ),
259
+ })
260
+ );
261
+ },
262
+
263
+ exportedTokens(moduleName) {
264
+ const normalized =
265
+ normalizeName(
266
+ moduleName,
267
+ "module name"
268
+ );
269
+ const module =
270
+ ordered.find(
271
+ entry =>
272
+ entry.name ===
273
+ normalized
274
+ );
275
+ if (!module) {
276
+ throw new ModuleDependencyError(
277
+ `BCP Modules: module "${normalized}" is not part of this composition.`
278
+ );
279
+ }
280
+ return [
281
+ ...(module.exports ?? []),
282
+ ];
283
+ },
284
+
285
+ createLifecycleResources(context) {
286
+ return ordered.flatMap(
287
+ module => [
288
+ ...(module.resources ?? []),
289
+ createModuleLifecycleResource(
290
+ module,
291
+ context
292
+ ),
293
+ ]
294
+ );
295
+ },
296
+ };
297
+ }
298
+
299
+ export function resolveModuleOrder(
300
+ roots: readonly ModuleDefinition<any>[]
301
+ ): ModuleDefinition<any>[] {
302
+ const byName =
303
+ new Map<string, ModuleDefinition<any>>();
304
+ const visiting =
305
+ new Set<string>();
306
+ const visited =
307
+ new Set<string>();
308
+ const order:
309
+ ModuleDefinition<any>[] = [];
310
+
311
+ const visit = (
312
+ module: ModuleDefinition<any>,
313
+ path: string[]
314
+ ): void => {
315
+ validateModuleDefinition(module);
316
+ const name = module.name;
317
+ const existing =
318
+ byName.get(name);
319
+
320
+ if (
321
+ existing &&
322
+ existing !== module
323
+ ) {
324
+ throw new ModuleDependencyError(
325
+ `BCP Modules: duplicate module name "${name}" refers to different definitions.`
326
+ );
327
+ }
328
+ byName.set(name, module);
329
+
330
+ if (visited.has(name)) {
331
+ return;
332
+ }
333
+ if (visiting.has(name)) {
334
+ throw new ModuleDependencyError(
335
+ `BCP Modules: circular module dependency detected: ${[
336
+ ...path,
337
+ name,
338
+ ].join(" -> ")}.`
339
+ );
340
+ }
341
+
342
+ visiting.add(name);
343
+ for (
344
+ const dependency
345
+ of module.imports ?? []
346
+ ) {
347
+ visit(
348
+ dependency,
349
+ [
350
+ ...path,
351
+ name,
352
+ ]
353
+ );
354
+ }
355
+ visiting.delete(name);
356
+ visited.add(name);
357
+ order.push(module);
358
+ };
359
+
360
+ for (const root of roots) {
361
+ visit(root, []);
362
+ }
363
+
364
+ return order;
365
+ }
366
+
367
+ function createModuleLifecycleResource(
368
+ module: ModuleDefinition<any>,
369
+ shared: {
370
+ container: ServiceContainer;
371
+ services: PluginServiceRegistry;
372
+ hooks: PluginHookBus;
373
+ }
374
+ ): DeploymentResource {
375
+ const config =
376
+ parseModuleConfig(
377
+ module.schema,
378
+ module.config
379
+ );
380
+ const context:
381
+ ModuleContext<any> = {
382
+ name:
383
+ module.name,
384
+ config,
385
+ container:
386
+ shared.container,
387
+ services:
388
+ shared.services,
389
+ hooks:
390
+ shared.hooks,
391
+ };
392
+ let setupComplete = false;
393
+ let disposed = false;
394
+
395
+ return {
396
+ name:
397
+ `bcp:module:${module.name}`,
398
+
399
+ async start() {
400
+ try {
401
+ if (!setupComplete) {
402
+ await runModuleHook(
403
+ module,
404
+ "setup",
405
+ module.setup,
406
+ context
407
+ );
408
+ setupComplete = true;
409
+ }
410
+ await runModuleHook(
411
+ module,
412
+ "start",
413
+ module.start,
414
+ context
415
+ );
416
+ } catch (error) {
417
+ if (!disposed) {
418
+ disposed = true;
419
+ try {
420
+ await runModuleHook(
421
+ module,
422
+ "dispose",
423
+ module.dispose,
424
+ context
425
+ );
426
+ } catch (disposeError) {
427
+ throw new AggregateError(
428
+ [
429
+ error,
430
+ disposeError,
431
+ ],
432
+ `BCP Modules: startup and cleanup failed for module "${module.name}".`
433
+ );
434
+ }
435
+ }
436
+ throw error;
437
+ }
438
+ },
439
+
440
+ ready() {
441
+ return true;
442
+ },
443
+
444
+ async stop() {
445
+ const errors:
446
+ unknown[] = [];
447
+ try {
448
+ await runModuleHook(
449
+ module,
450
+ "stop",
451
+ module.stop,
452
+ context
453
+ );
454
+ } catch (error) {
455
+ errors.push(error);
456
+ }
457
+
458
+ if (!disposed) {
459
+ disposed = true;
460
+ try {
461
+ await runModuleHook(
462
+ module,
463
+ "dispose",
464
+ module.dispose,
465
+ context
466
+ );
467
+ } catch (error) {
468
+ errors.push(error);
469
+ }
470
+ }
471
+
472
+ if (errors.length > 0) {
473
+ throw new AggregateError(
474
+ errors,
475
+ `BCP Modules: shutdown failed for module "${module.name}".`
476
+ );
477
+ }
478
+ },
479
+
480
+ diagnostics() {
481
+ return {
482
+ name:
483
+ module.name,
484
+ ...(module.version
485
+ ? {
486
+ version:
487
+ module.version,
488
+ }
489
+ : {}),
490
+ imports:
491
+ (
492
+ module.imports ?? []
493
+ ).map(
494
+ dependency =>
495
+ dependency.name
496
+ ),
497
+ exports:
498
+ (
499
+ module.exports ?? []
500
+ ).map(
501
+ token =>
502
+ token.description
503
+ ),
504
+ };
505
+ },
506
+ };
507
+ }
508
+
509
+ async function runModuleHook<TConfig>(
510
+ module: ModuleDefinition<TConfig>,
511
+ phase: string,
512
+ hook:
513
+ | ((
514
+ context: ModuleContext<TConfig>
515
+ ) => void | Promise<void>)
516
+ | undefined,
517
+ context: ModuleContext<TConfig>
518
+ ): Promise<void> {
519
+ if (!hook) {
520
+ return;
521
+ }
522
+ try {
523
+ await hook(context);
524
+ } catch (error) {
525
+ throw new ModuleLifecycleError(
526
+ module.name,
527
+ phase,
528
+ error
529
+ );
530
+ }
531
+ }
532
+
533
+ function validateModuleDefinition(
534
+ module: ModuleDefinition<any>
535
+ ): void {
536
+ if (
537
+ !module ||
538
+ typeof module !== "object"
539
+ ) {
540
+ throw new TypeError(
541
+ "BCP Modules: module must be an object."
542
+ );
543
+ }
544
+ if (module.kind !== MODULE_V2_KIND) {
545
+ throw new TypeError(
546
+ `BCP Modules: module kind must be "${MODULE_V2_KIND}". Use defineModule().`
547
+ );
548
+ }
549
+ normalizeName(
550
+ module.name,
551
+ "module name"
552
+ );
553
+
554
+ for (
555
+ const [label, value]
556
+ of [
557
+ ["imports", module.imports],
558
+ ["providers", module.providers],
559
+ ["exports", module.exports],
560
+ ["plugins", module.plugins],
561
+ ["resources", module.resources],
562
+ ] as const
563
+ ) {
564
+ if (
565
+ value !== undefined &&
566
+ !Array.isArray(value)
567
+ ) {
568
+ throw new TypeError(
569
+ `BCP Modules: ${label} must be an array.`
570
+ );
571
+ }
572
+ }
573
+
574
+ for (
575
+ const [phase, hook]
576
+ of [
577
+ ["setup", module.setup],
578
+ ["start", module.start],
579
+ ["stop", module.stop],
580
+ ["dispose", module.dispose],
581
+ ] as const
582
+ ) {
583
+ if (
584
+ hook !== undefined &&
585
+ typeof hook !== "function"
586
+ ) {
587
+ throw new TypeError(
588
+ `BCP Modules: ${phase} must be a function.`
589
+ );
590
+ }
591
+ }
592
+ }
593
+
594
+ function validateModuleExports(
595
+ module: ModuleDefinition<any>
596
+ ): void {
597
+ const available =
598
+ new Set<symbol>();
599
+
600
+ for (
601
+ const provider
602
+ of module.providers ?? []
603
+ ) {
604
+ available.add(
605
+ provider.token.id
606
+ );
607
+ }
608
+ for (
609
+ const dependency
610
+ of module.imports ?? []
611
+ ) {
612
+ for (
613
+ const token
614
+ of dependency.exports ?? []
615
+ ) {
616
+ available.add(token.id);
617
+ }
618
+ }
619
+
620
+ for (
621
+ const token
622
+ of module.exports ?? []
623
+ ) {
624
+ if (!available.has(token.id)) {
625
+ throw new ModuleDependencyError(
626
+ `BCP Modules: module "${module.name}" exports "${token.description}" but does not provide or import it.`
627
+ );
628
+ }
629
+ }
630
+ }
631
+
632
+ function parseModuleConfig<TConfig>(
633
+ parser:
634
+ ModuleConfigParser<TConfig> |
635
+ undefined,
636
+ value: unknown
637
+ ): TConfig {
638
+ if (!parser) {
639
+ return value as TConfig;
640
+ }
641
+ if (typeof parser === "function") {
642
+ return parser(value);
643
+ }
644
+ return parser.parse(value);
645
+ }
646
+
647
+ function normalizeName(
648
+ value: unknown,
649
+ label: string
650
+ ): string {
651
+ if (typeof value !== "string") {
652
+ throw new TypeError(
653
+ `BCP Modules: ${label} must be a string.`
654
+ );
655
+ }
656
+ const normalized =
657
+ value.trim();
658
+ if (!normalized) {
659
+ throw new TypeError(
660
+ `BCP Modules: ${label} cannot be empty.`
661
+ );
662
+ }
663
+ return normalized;
664
+ }
665
+
666
+ function formatServiceKey(
667
+ key: PluginServiceKey
668
+ ): string {
669
+ return typeof key === "symbol"
670
+ ? key.description
671
+ ? `Symbol(${key.description})`
672
+ : key.toString()
673
+ : key;
674
+ }
675
+
676
+ function formatError(
677
+ value: unknown
678
+ ): string {
679
+ return value instanceof Error
680
+ ? value.message
681
+ : String(value);
682
+ }