@chidchanun/bcp 0.2.13 → 0.2.15

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,1225 @@
1
+ export type PluginServiceKey<T = unknown> =
2
+ string | symbol;
3
+
4
+ export type PluginState =
5
+ | "registered"
6
+ | "setting-up"
7
+ | "ready"
8
+ | "starting"
9
+ | "started"
10
+ | "stopping"
11
+ | "stopped"
12
+ | "failed";
13
+
14
+ export interface PluginConfigSchema<TConfig> {
15
+ parse(value: unknown): TConfig;
16
+ }
17
+
18
+ export type PluginConfigParser<TConfig> =
19
+ | PluginConfigSchema<TConfig>
20
+ | ((value: unknown) => TConfig);
21
+
22
+ export interface PluginServiceRegistry {
23
+ provide<T>(
24
+ key: PluginServiceKey<T>,
25
+ value: T,
26
+ options?: {
27
+ replace?: boolean;
28
+ }
29
+ ): void;
30
+ get<T>(key: PluginServiceKey<T>): T;
31
+ optional<T>(
32
+ key: PluginServiceKey<T>
33
+ ): T | undefined;
34
+ has(key: PluginServiceKey): boolean;
35
+ delete(key: PluginServiceKey): boolean;
36
+ keys(): PluginServiceKey[];
37
+ }
38
+
39
+ export type PluginHookHandler<TPayload = unknown> = (
40
+ payload: TPayload
41
+ ) => void | Promise<void>;
42
+
43
+ export interface PluginHookBus {
44
+ on<TPayload = unknown>(
45
+ name: string,
46
+ handler: PluginHookHandler<TPayload>
47
+ ): () => void;
48
+ emit<TPayload = unknown>(
49
+ name: string,
50
+ payload: TPayload
51
+ ): Promise<void>;
52
+ listenerCount(name: string): number;
53
+ clear(name?: string): void;
54
+ }
55
+
56
+ export interface PluginHostView {
57
+ readonly started: boolean;
58
+ plugin(name: string): PluginRecord | null;
59
+ plugins(): PluginRecord[];
60
+ }
61
+
62
+ export interface PluginContext<TConfig = unknown> {
63
+ readonly name: string;
64
+ readonly config: TConfig;
65
+ readonly services: PluginServiceRegistry;
66
+ readonly hooks: PluginHookBus;
67
+ readonly host: PluginHostView;
68
+ }
69
+
70
+ export interface PluginDefinition<TConfig = unknown> {
71
+ name: string;
72
+ version?: string;
73
+ requires?: readonly string[];
74
+ optional?: readonly string[];
75
+ config?: unknown;
76
+ schema?: PluginConfigParser<TConfig>;
77
+ setup?(
78
+ context: PluginContext<TConfig>
79
+ ): void | Promise<void>;
80
+ start?(
81
+ context: PluginContext<TConfig>
82
+ ): void | Promise<void>;
83
+ stop?(
84
+ context: PluginContext<TConfig>
85
+ ): void | Promise<void>;
86
+ dispose?(
87
+ context: PluginContext<TConfig>
88
+ ): void | Promise<void>;
89
+ }
90
+
91
+ export interface PluginModule {
92
+ name: string;
93
+ plugins: readonly PluginDefinition<any>[];
94
+ }
95
+
96
+ export interface PluginRecord {
97
+ name: string;
98
+ version?: string;
99
+ state: PluginState;
100
+ requires: string[];
101
+ optional: string[];
102
+ registeredAt: number;
103
+ setupAt?: number;
104
+ startedAt?: number;
105
+ stoppedAt?: number;
106
+ error?: string;
107
+ }
108
+
109
+ export interface PluginHostOptions {
110
+ plugins?: readonly PluginDefinition<any>[];
111
+ modules?: readonly PluginModule[];
112
+ configs?: Record<string, unknown>;
113
+ services?: Iterable<
114
+ readonly [PluginServiceKey, unknown]
115
+ >;
116
+ now?: () => number;
117
+ }
118
+
119
+ export interface PluginHost
120
+ extends PluginHostView {
121
+ readonly services: PluginServiceRegistry;
122
+ readonly hooks: PluginHookBus;
123
+ use(
124
+ extension:
125
+ | PluginDefinition<any>
126
+ | PluginModule
127
+ ): PluginHost;
128
+ resolveOrder(): string[];
129
+ setup(): Promise<void>;
130
+ start(): Promise<void>;
131
+ stop(): Promise<void>;
132
+ close(): Promise<void>;
133
+ }
134
+
135
+ interface InternalPlugin {
136
+ definition: PluginDefinition<any>;
137
+ record: PluginRecord;
138
+ context?: PluginContext<any>;
139
+ disposed: boolean;
140
+ }
141
+
142
+ export class PluginDependencyError
143
+ extends Error {
144
+ constructor(message: string) {
145
+ super(message);
146
+ this.name =
147
+ "PluginDependencyError";
148
+ }
149
+ }
150
+
151
+ export class PluginLifecycleError
152
+ extends Error {
153
+ readonly plugin: string;
154
+ readonly phase: string;
155
+ readonly cause: unknown;
156
+
157
+ constructor(
158
+ plugin: string,
159
+ phase: string,
160
+ cause: unknown
161
+ ) {
162
+ super(
163
+ `BCP Plugins: ${phase} failed for plugin "${plugin}": ${formatError(cause)}`
164
+ );
165
+ this.name =
166
+ "PluginLifecycleError";
167
+ this.plugin = plugin;
168
+ this.phase = phase;
169
+ this.cause = cause;
170
+ }
171
+ }
172
+
173
+ export function definePlugin<TConfig = unknown>(
174
+ definition: PluginDefinition<TConfig>
175
+ ): PluginDefinition<TConfig> {
176
+ validatePluginDefinition(
177
+ definition
178
+ );
179
+ return definition;
180
+ }
181
+
182
+ export function defineModule(
183
+ module: PluginModule
184
+ ): PluginModule {
185
+ if (!module || typeof module !== "object") {
186
+ throw new TypeError(
187
+ "BCP Plugins: module must be an object."
188
+ );
189
+ }
190
+ normalizeName(
191
+ module.name,
192
+ "module name"
193
+ );
194
+ if (!Array.isArray(module.plugins)) {
195
+ throw new TypeError(
196
+ "BCP Plugins: module plugins must be an array."
197
+ );
198
+ }
199
+ for (const plugin of module.plugins) {
200
+ validatePluginDefinition(plugin);
201
+ }
202
+ return module;
203
+ }
204
+
205
+ export function createPluginServiceRegistry(
206
+ initial?: Iterable<
207
+ readonly [PluginServiceKey, unknown]
208
+ >
209
+ ): PluginServiceRegistry {
210
+ const values =
211
+ new Map<PluginServiceKey, unknown>();
212
+
213
+ if (initial) {
214
+ for (const [key, value] of initial) {
215
+ assertServiceKey(key);
216
+ if (values.has(key)) {
217
+ throw new Error(
218
+ `BCP Plugins: duplicate initial service ${formatServiceKey(key)}.`
219
+ );
220
+ }
221
+ values.set(key, value);
222
+ }
223
+ }
224
+
225
+ const registry:
226
+ PluginServiceRegistry = {
227
+ provide<T>(
228
+ key: PluginServiceKey<T>,
229
+ value: T,
230
+ options: {
231
+ replace?: boolean;
232
+ } = {}
233
+ ): void {
234
+ assertServiceKey(key);
235
+ if (
236
+ values.has(key) &&
237
+ !options.replace
238
+ ) {
239
+ throw new Error(
240
+ `BCP Plugins: service ${formatServiceKey(key)} is already registered.`
241
+ );
242
+ }
243
+ values.set(key, value);
244
+ },
245
+
246
+ get<T>(key: PluginServiceKey<T>): T {
247
+ assertServiceKey(key);
248
+ if (!values.has(key)) {
249
+ throw new Error(
250
+ `BCP Plugins: service ${formatServiceKey(key)} is not registered.`
251
+ );
252
+ }
253
+ return values.get(key) as T;
254
+ },
255
+
256
+ optional<T>(
257
+ key: PluginServiceKey<T>
258
+ ): T | undefined {
259
+ assertServiceKey(key);
260
+ return values.get(key) as
261
+ T | undefined;
262
+ },
263
+
264
+ has(key: PluginServiceKey): boolean {
265
+ assertServiceKey(key);
266
+ return values.has(key);
267
+ },
268
+
269
+ delete(key: PluginServiceKey): boolean {
270
+ assertServiceKey(key);
271
+ return values.delete(key);
272
+ },
273
+
274
+ keys(): PluginServiceKey[] {
275
+ return [
276
+ ...values.keys(),
277
+ ];
278
+ },
279
+ };
280
+
281
+ return registry;
282
+ }
283
+
284
+ export function createPluginHookBus():
285
+ PluginHookBus {
286
+ const hooks =
287
+ new Map<
288
+ string,
289
+ Set<PluginHookHandler<any>>
290
+ >();
291
+
292
+ const bus:
293
+ PluginHookBus = {
294
+ on<TPayload = unknown>(
295
+ name: string,
296
+ handler: PluginHookHandler<TPayload>
297
+ ): () => void {
298
+ const normalized =
299
+ normalizeName(
300
+ name,
301
+ "hook name"
302
+ );
303
+ if (typeof handler !== "function") {
304
+ throw new TypeError(
305
+ "BCP Plugins: hook handler must be a function."
306
+ );
307
+ }
308
+ let group =
309
+ hooks.get(normalized);
310
+ if (!group) {
311
+ group = new Set();
312
+ hooks.set(
313
+ normalized,
314
+ group
315
+ );
316
+ }
317
+ group.add(
318
+ handler as PluginHookHandler<any>
319
+ );
320
+ return () => {
321
+ group?.delete(
322
+ handler as PluginHookHandler<any>
323
+ );
324
+ if (group?.size === 0) {
325
+ hooks.delete(normalized);
326
+ }
327
+ };
328
+ },
329
+
330
+ async emit<TPayload = unknown>(
331
+ name: string,
332
+ payload: TPayload
333
+ ): Promise<void> {
334
+ const normalized =
335
+ normalizeName(
336
+ name,
337
+ "hook name"
338
+ );
339
+ const group =
340
+ hooks.get(normalized);
341
+ if (!group) {
342
+ return;
343
+ }
344
+ for (const handler of [
345
+ ...group,
346
+ ]) {
347
+ await handler(payload);
348
+ }
349
+ },
350
+
351
+ listenerCount(name: string): number {
352
+ return hooks.get(
353
+ normalizeName(
354
+ name,
355
+ "hook name"
356
+ )
357
+ )?.size ?? 0;
358
+ },
359
+
360
+ clear(name?: string): void {
361
+ if (name === undefined) {
362
+ hooks.clear();
363
+ return;
364
+ }
365
+ hooks.delete(
366
+ normalizeName(
367
+ name,
368
+ "hook name"
369
+ )
370
+ );
371
+ },
372
+ };
373
+
374
+ return bus;
375
+ }
376
+
377
+ export function createPluginHost(
378
+ options: PluginHostOptions = {}
379
+ ): PluginHost {
380
+ const now =
381
+ options.now ?? Date.now;
382
+ const services =
383
+ createPluginServiceRegistry(
384
+ options.services
385
+ );
386
+ const hooks =
387
+ createPluginHookBus();
388
+ const entries =
389
+ new Map<string, InternalPlugin>();
390
+ const configs = {
391
+ ...(options.configs ?? {}),
392
+ };
393
+
394
+ let setupCompleted = false;
395
+ let started = false;
396
+ let closed = false;
397
+ let lifecycleActive = false;
398
+ let fatalError:
399
+ PluginLifecycleError | null =
400
+ null;
401
+
402
+ const host:
403
+ PluginHost = {
404
+ services,
405
+ hooks,
406
+
407
+ get started() {
408
+ return started;
409
+ },
410
+
411
+ use(extension) {
412
+ assertMutable();
413
+ if (isPluginModule(extension)) {
414
+ defineModule(extension);
415
+ for (const plugin of extension.plugins) {
416
+ register(plugin);
417
+ }
418
+ return host;
419
+ }
420
+ register(extension);
421
+ return host;
422
+ },
423
+
424
+ resolveOrder() {
425
+ return resolvePluginOrder(
426
+ entries
427
+ );
428
+ },
429
+
430
+ async setup() {
431
+ assertOpen();
432
+ assertHealthy();
433
+ if (setupCompleted) {
434
+ return;
435
+ }
436
+ assertNotActive();
437
+ lifecycleActive = true;
438
+ try {
439
+ const order =
440
+ resolvePluginOrder(
441
+ entries
442
+ );
443
+ for (const name of order) {
444
+ const entry =
445
+ requireInternal(name);
446
+ if (
447
+ entry.record.state !==
448
+ "registered"
449
+ ) {
450
+ continue;
451
+ }
452
+ entry.record.state =
453
+ "setting-up";
454
+ try {
455
+ const context =
456
+ createContext(entry);
457
+ entry.context = context;
458
+ await entry.definition.setup?.(
459
+ context
460
+ );
461
+ entry.record.state =
462
+ "ready";
463
+ entry.record.setupAt =
464
+ timestamp();
465
+ } catch (error) {
466
+ markFailed(
467
+ entry,
468
+ error
469
+ );
470
+ const lifecycleError =
471
+ error instanceof PluginLifecycleError
472
+ ? error
473
+ : new PluginLifecycleError(
474
+ name,
475
+ "setup",
476
+ error
477
+ );
478
+ fatalError =
479
+ lifecycleError;
480
+ await disposePrepared(
481
+ order,
482
+ name
483
+ );
484
+ throw lifecycleError;
485
+ }
486
+ }
487
+ setupCompleted = true;
488
+ } finally {
489
+ lifecycleActive = false;
490
+ }
491
+ },
492
+
493
+ async start() {
494
+ assertOpen();
495
+ assertHealthy();
496
+ if (started) {
497
+ return;
498
+ }
499
+ if (!setupCompleted) {
500
+ await host.setup();
501
+ }
502
+ assertHealthy();
503
+ assertNotActive();
504
+ lifecycleActive = true;
505
+ const order =
506
+ resolvePluginOrder(
507
+ entries
508
+ );
509
+ const startedNames:
510
+ string[] = [];
511
+ try {
512
+ for (const name of order) {
513
+ const entry =
514
+ requireInternal(name);
515
+ if (
516
+ entry.record.state !== "ready" &&
517
+ entry.record.state !== "stopped"
518
+ ) {
519
+ continue;
520
+ }
521
+ entry.record.state =
522
+ "starting";
523
+ try {
524
+ await entry.definition.start?.(
525
+ requireContext(entry)
526
+ );
527
+ entry.record.state =
528
+ "started";
529
+ entry.record.startedAt =
530
+ timestamp();
531
+ entry.record.error =
532
+ undefined;
533
+ startedNames.push(name);
534
+ } catch (error) {
535
+ markFailed(
536
+ entry,
537
+ error
538
+ );
539
+ const lifecycleError =
540
+ new PluginLifecycleError(
541
+ name,
542
+ "start",
543
+ error
544
+ );
545
+ fatalError =
546
+ lifecycleError;
547
+ await stopNames(
548
+ [
549
+ ...startedNames,
550
+ ].reverse()
551
+ );
552
+ throw lifecycleError;
553
+ }
554
+ }
555
+ started = true;
556
+ } finally {
557
+ lifecycleActive = false;
558
+ }
559
+ },
560
+
561
+ async stop() {
562
+ if (closed || !started) {
563
+ return;
564
+ }
565
+ assertNotActive();
566
+ lifecycleActive = true;
567
+ try {
568
+ const errors =
569
+ await stopNames(
570
+ resolvePluginOrder(
571
+ entries
572
+ ).reverse()
573
+ );
574
+ started = false;
575
+ if (errors.length > 0) {
576
+ throw new AggregateError(
577
+ errors,
578
+ "BCP Plugins: one or more plugin stop hooks failed."
579
+ );
580
+ }
581
+ } finally {
582
+ lifecycleActive = false;
583
+ }
584
+ },
585
+
586
+ async close() {
587
+ if (closed) {
588
+ return;
589
+ }
590
+
591
+ const errors:
592
+ unknown[] = [];
593
+
594
+ if (started) {
595
+ try {
596
+ await host.stop();
597
+ } catch (error) {
598
+ if (
599
+ error instanceof AggregateError
600
+ ) {
601
+ errors.push(
602
+ ...error.errors
603
+ );
604
+ } else {
605
+ errors.push(error);
606
+ }
607
+ }
608
+ }
609
+
610
+ assertNotActive();
611
+ lifecycleActive = true;
612
+ try {
613
+ const order =
614
+ resolvePluginOrder(
615
+ entries
616
+ ).reverse();
617
+ for (const name of order) {
618
+ const entry =
619
+ requireInternal(name);
620
+ if (
621
+ entry.disposed ||
622
+ !entry.context
623
+ ) {
624
+ continue;
625
+ }
626
+ try {
627
+ await entry.definition.dispose?.(
628
+ entry.context
629
+ );
630
+ } catch (error) {
631
+ errors.push(
632
+ new PluginLifecycleError(
633
+ name,
634
+ "dispose",
635
+ error
636
+ )
637
+ );
638
+ } finally {
639
+ entry.disposed = true;
640
+ }
641
+ }
642
+ hooks.clear();
643
+ closed = true;
644
+ } finally {
645
+ lifecycleActive = false;
646
+ }
647
+
648
+ if (errors.length > 0) {
649
+ throw new AggregateError(
650
+ errors,
651
+ "BCP Plugins: one or more plugin shutdown hooks failed."
652
+ );
653
+ }
654
+ },
655
+
656
+ plugin(name) {
657
+ const entry =
658
+ entries.get(
659
+ normalizeName(
660
+ name,
661
+ "plugin name"
662
+ )
663
+ );
664
+ return entry
665
+ ? cloneRecord(
666
+ entry.record
667
+ )
668
+ : null;
669
+ },
670
+
671
+ plugins() {
672
+ return [
673
+ ...entries.values(),
674
+ ]
675
+ .map(entry =>
676
+ cloneRecord(
677
+ entry.record
678
+ )
679
+ )
680
+ .sort((left, right) =>
681
+ left.registeredAt -
682
+ right.registeredAt ||
683
+ left.name.localeCompare(
684
+ right.name
685
+ )
686
+ );
687
+ },
688
+ };
689
+
690
+ for (const module of options.modules ?? []) {
691
+ host.use(module);
692
+ }
693
+ for (const plugin of options.plugins ?? []) {
694
+ host.use(plugin);
695
+ }
696
+
697
+ return host;
698
+
699
+ function register(
700
+ definition: PluginDefinition<any>
701
+ ): void {
702
+ validatePluginDefinition(
703
+ definition
704
+ );
705
+ const name =
706
+ normalizeName(
707
+ definition.name,
708
+ "plugin name"
709
+ );
710
+ if (entries.has(name)) {
711
+ throw new Error(
712
+ `BCP Plugins: plugin "${name}" is already registered.`
713
+ );
714
+ }
715
+ const requires =
716
+ normalizeDependencyList(
717
+ definition.requires,
718
+ name,
719
+ "requires"
720
+ );
721
+ const optional =
722
+ normalizeDependencyList(
723
+ definition.optional,
724
+ name,
725
+ "optional"
726
+ );
727
+ entries.set(
728
+ name,
729
+ {
730
+ definition: {
731
+ ...definition,
732
+ name,
733
+ requires,
734
+ optional,
735
+ },
736
+ record: {
737
+ name,
738
+ version:
739
+ normalizeOptionalVersion(
740
+ definition.version
741
+ ),
742
+ state:
743
+ "registered",
744
+ requires,
745
+ optional,
746
+ registeredAt:
747
+ timestamp(),
748
+ },
749
+ disposed: false,
750
+ }
751
+ );
752
+ }
753
+
754
+ function createContext(
755
+ entry: InternalPlugin
756
+ ): PluginContext<any> {
757
+ const rawConfig =
758
+ Object.prototype.hasOwnProperty.call(
759
+ configs,
760
+ entry.record.name
761
+ )
762
+ ? configs[entry.record.name]
763
+ : entry.definition.config;
764
+ const config =
765
+ parseConfig(
766
+ entry.definition.schema,
767
+ rawConfig,
768
+ entry.record.name
769
+ );
770
+ return {
771
+ name:
772
+ entry.record.name,
773
+ config,
774
+ services,
775
+ hooks,
776
+ host,
777
+ };
778
+ }
779
+
780
+ async function stopNames(
781
+ names: readonly string[]
782
+ ): Promise<unknown[]> {
783
+ const errors:
784
+ unknown[] = [];
785
+ for (const name of names) {
786
+ const entry =
787
+ requireInternal(name);
788
+ if (
789
+ entry.record.state !==
790
+ "started"
791
+ ) {
792
+ continue;
793
+ }
794
+ entry.record.state =
795
+ "stopping";
796
+ try {
797
+ await entry.definition.stop?.(
798
+ requireContext(entry)
799
+ );
800
+ entry.record.state =
801
+ "stopped";
802
+ entry.record.stoppedAt =
803
+ timestamp();
804
+ } catch (error) {
805
+ markFailed(
806
+ entry,
807
+ error
808
+ );
809
+ errors.push(
810
+ new PluginLifecycleError(
811
+ name,
812
+ "stop",
813
+ error
814
+ )
815
+ );
816
+ }
817
+ }
818
+ return errors;
819
+ }
820
+
821
+ async function disposePrepared(
822
+ order: readonly string[],
823
+ failedName: string
824
+ ): Promise<void> {
825
+ const index =
826
+ order.indexOf(failedName);
827
+ const names =
828
+ order.slice(
829
+ 0,
830
+ Math.max(0, index) + 1
831
+ ).reverse();
832
+
833
+ for (const name of names) {
834
+ const entry =
835
+ requireInternal(name);
836
+ if (
837
+ entry.disposed ||
838
+ !entry.context
839
+ ) {
840
+ continue;
841
+ }
842
+ try {
843
+ await entry.definition.dispose?.(
844
+ entry.context
845
+ );
846
+ } catch {
847
+ // Preserve the original setup failure.
848
+ } finally {
849
+ entry.disposed = true;
850
+ }
851
+ }
852
+ }
853
+
854
+ function requireInternal(
855
+ name: string
856
+ ): InternalPlugin {
857
+ const entry =
858
+ entries.get(name);
859
+ if (!entry) {
860
+ throw new Error(
861
+ `BCP Plugins: plugin "${name}" is not registered.`
862
+ );
863
+ }
864
+ return entry;
865
+ }
866
+
867
+ function timestamp(): number {
868
+ const value = now();
869
+ if (!Number.isFinite(value)) {
870
+ throw new TypeError(
871
+ "BCP Plugins: now() must return a finite number."
872
+ );
873
+ }
874
+ return value;
875
+ }
876
+
877
+ function assertOpen(): void {
878
+ if (closed) {
879
+ throw new Error(
880
+ "BCP Plugins: plugin host is closed."
881
+ );
882
+ }
883
+ }
884
+
885
+ function assertHealthy(): void {
886
+ if (fatalError) {
887
+ throw fatalError;
888
+ }
889
+ }
890
+
891
+ function assertMutable(): void {
892
+ assertOpen();
893
+ if (
894
+ setupCompleted ||
895
+ lifecycleActive ||
896
+ fatalError
897
+ ) {
898
+ throw new Error(
899
+ "BCP Plugins: plugins cannot be registered after setup begins or after a lifecycle failure."
900
+ );
901
+ }
902
+ }
903
+
904
+ function assertNotActive(): void {
905
+ if (lifecycleActive) {
906
+ throw new Error(
907
+ "BCP Plugins: another lifecycle transition is already running."
908
+ );
909
+ }
910
+ }
911
+ }
912
+
913
+ function resolvePluginOrder(
914
+ entries: Map<string, InternalPlugin>
915
+ ): string[] {
916
+ for (const entry of entries.values()) {
917
+ for (
918
+ const dependency
919
+ of entry.record.requires
920
+ ) {
921
+ if (!entries.has(dependency)) {
922
+ throw new PluginDependencyError(
923
+ `BCP Plugins: plugin "${entry.record.name}" requires missing plugin "${dependency}".`
924
+ );
925
+ }
926
+ }
927
+ }
928
+
929
+ const visiting =
930
+ new Set<string>();
931
+ const visited =
932
+ new Set<string>();
933
+ const order:
934
+ string[] = [];
935
+ const stack:
936
+ string[] = [];
937
+
938
+ const visit = (
939
+ name: string
940
+ ): void => {
941
+ if (visited.has(name)) {
942
+ return;
943
+ }
944
+ if (visiting.has(name)) {
945
+ const start =
946
+ stack.indexOf(name);
947
+ const cycle = [
948
+ ...stack.slice(
949
+ Math.max(0, start)
950
+ ),
951
+ name,
952
+ ];
953
+ throw new PluginDependencyError(
954
+ `BCP Plugins: dependency cycle detected: ${cycle.join(" -> ")}.`
955
+ );
956
+ }
957
+
958
+ const entry =
959
+ entries.get(name);
960
+ if (!entry) {
961
+ return;
962
+ }
963
+
964
+ visiting.add(name);
965
+ stack.push(name);
966
+
967
+ for (const dependency of [
968
+ ...entry.record.requires,
969
+ ...entry.record.optional.filter(
970
+ candidate =>
971
+ entries.has(candidate)
972
+ ),
973
+ ]) {
974
+ visit(dependency);
975
+ }
976
+
977
+ stack.pop();
978
+ visiting.delete(name);
979
+ visited.add(name);
980
+ order.push(name);
981
+ };
982
+
983
+ for (const name of entries.keys()) {
984
+ visit(name);
985
+ }
986
+
987
+ return order;
988
+ }
989
+
990
+ function validatePluginDefinition(
991
+ definition: PluginDefinition<any>
992
+ ): void {
993
+ if (
994
+ !definition ||
995
+ typeof definition !== "object"
996
+ ) {
997
+ throw new TypeError(
998
+ "BCP Plugins: plugin definition must be an object."
999
+ );
1000
+ }
1001
+ const name =
1002
+ normalizeName(
1003
+ definition.name,
1004
+ "plugin name"
1005
+ );
1006
+ normalizeDependencyList(
1007
+ definition.requires,
1008
+ name,
1009
+ "requires"
1010
+ );
1011
+ normalizeDependencyList(
1012
+ definition.optional,
1013
+ name,
1014
+ "optional"
1015
+ );
1016
+ for (const hook of [
1017
+ "setup",
1018
+ "start",
1019
+ "stop",
1020
+ "dispose",
1021
+ ] as const) {
1022
+ const value =
1023
+ definition[hook];
1024
+ if (
1025
+ value !== undefined &&
1026
+ typeof value !== "function"
1027
+ ) {
1028
+ throw new TypeError(
1029
+ `BCP Plugins: plugin "${name}" ${hook} must be a function.`
1030
+ );
1031
+ }
1032
+ }
1033
+ if (
1034
+ definition.schema !== undefined &&
1035
+ typeof definition.schema !== "function" &&
1036
+ (
1037
+ !definition.schema ||
1038
+ typeof definition.schema.parse !==
1039
+ "function"
1040
+ )
1041
+ ) {
1042
+ throw new TypeError(
1043
+ `BCP Plugins: plugin "${name}" schema must be a parser function or object with parse().`
1044
+ );
1045
+ }
1046
+ }
1047
+
1048
+ function normalizeDependencyList(
1049
+ value: readonly string[] | undefined,
1050
+ plugin: string,
1051
+ field: string
1052
+ ): string[] {
1053
+ if (value === undefined) {
1054
+ return [];
1055
+ }
1056
+ if (!Array.isArray(value)) {
1057
+ throw new TypeError(
1058
+ `BCP Plugins: plugin "${plugin}" ${field} must be an array.`
1059
+ );
1060
+ }
1061
+ const normalized =
1062
+ value.map(item =>
1063
+ normalizeName(
1064
+ item,
1065
+ `${field} dependency`
1066
+ )
1067
+ );
1068
+ if (
1069
+ new Set(normalized).size !==
1070
+ normalized.length
1071
+ ) {
1072
+ throw new Error(
1073
+ `BCP Plugins: plugin "${plugin}" ${field} contains duplicate dependencies.`
1074
+ );
1075
+ }
1076
+ if (normalized.includes(plugin)) {
1077
+ throw new PluginDependencyError(
1078
+ `BCP Plugins: plugin "${plugin}" cannot depend on itself.`
1079
+ );
1080
+ }
1081
+ return normalized;
1082
+ }
1083
+
1084
+ function parseConfig<TConfig>(
1085
+ parser: PluginConfigParser<TConfig> | undefined,
1086
+ raw: unknown,
1087
+ plugin: string
1088
+ ): TConfig {
1089
+ if (!parser) {
1090
+ return raw as TConfig;
1091
+ }
1092
+ try {
1093
+ return typeof parser === "function"
1094
+ ? parser(raw)
1095
+ : parser.parse(raw);
1096
+ } catch (error) {
1097
+ throw new PluginLifecycleError(
1098
+ plugin,
1099
+ "config",
1100
+ error
1101
+ );
1102
+ }
1103
+ }
1104
+
1105
+ function requireContext(
1106
+ entry: InternalPlugin
1107
+ ): PluginContext<any> {
1108
+ if (!entry.context) {
1109
+ throw new Error(
1110
+ `BCP Plugins: plugin "${entry.record.name}" has not been set up.`
1111
+ );
1112
+ }
1113
+ return entry.context;
1114
+ }
1115
+
1116
+ function markFailed(
1117
+ entry: InternalPlugin,
1118
+ error: unknown
1119
+ ): void {
1120
+ entry.record.state =
1121
+ "failed";
1122
+ entry.record.error =
1123
+ formatError(error);
1124
+ }
1125
+
1126
+ function cloneRecord(
1127
+ record: PluginRecord
1128
+ ): PluginRecord {
1129
+ return {
1130
+ ...record,
1131
+ requires: [
1132
+ ...record.requires,
1133
+ ],
1134
+ optional: [
1135
+ ...record.optional,
1136
+ ],
1137
+ };
1138
+ }
1139
+
1140
+ function isPluginModule(
1141
+ value:
1142
+ | PluginDefinition<any>
1143
+ | PluginModule
1144
+ ): value is PluginModule {
1145
+ return Boolean(
1146
+ value &&
1147
+ typeof value === "object" &&
1148
+ Array.isArray(
1149
+ (value as PluginModule).plugins
1150
+ )
1151
+ );
1152
+ }
1153
+
1154
+ function normalizeName(
1155
+ value: unknown,
1156
+ field: string
1157
+ ): string {
1158
+ const text =
1159
+ String(value ?? "").trim();
1160
+ if (!text) {
1161
+ throw new TypeError(
1162
+ `BCP Plugins: ${field} must be a non-empty string.`
1163
+ );
1164
+ }
1165
+ if (text.length > 200) {
1166
+ throw new TypeError(
1167
+ `BCP Plugins: ${field} must not exceed 200 characters.`
1168
+ );
1169
+ }
1170
+ return text;
1171
+ }
1172
+
1173
+ function normalizeOptionalVersion(
1174
+ value: string | undefined
1175
+ ): string | undefined {
1176
+ if (value === undefined) {
1177
+ return undefined;
1178
+ }
1179
+ return normalizeName(
1180
+ value,
1181
+ "plugin version"
1182
+ );
1183
+ }
1184
+
1185
+ function assertServiceKey(
1186
+ key: PluginServiceKey
1187
+ ): void {
1188
+ if (typeof key === "string") {
1189
+ normalizeName(
1190
+ key,
1191
+ "service key"
1192
+ );
1193
+ return;
1194
+ }
1195
+ if (typeof key !== "symbol") {
1196
+ throw new TypeError(
1197
+ "BCP Plugins: service key must be a string or symbol."
1198
+ );
1199
+ }
1200
+ }
1201
+
1202
+ function formatServiceKey(
1203
+ key: PluginServiceKey
1204
+ ): string {
1205
+ return typeof key === "symbol"
1206
+ ? String(key)
1207
+ : `"${key}"`;
1208
+ }
1209
+
1210
+ function formatError(
1211
+ error: unknown
1212
+ ): string {
1213
+ if (error instanceof Error) {
1214
+ return error.message || error.name;
1215
+ }
1216
+ if (typeof error === "string") {
1217
+ return error;
1218
+ }
1219
+ try {
1220
+ return JSON.stringify(error) ??
1221
+ String(error);
1222
+ } catch {
1223
+ return String(error);
1224
+ }
1225
+ }