@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,811 @@
1
+ // packages/server/src/plugins.ts
2
+ var PluginDependencyError = class extends Error {
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = "PluginDependencyError";
6
+ }
7
+ };
8
+ var PluginLifecycleError = class extends Error {
9
+ plugin;
10
+ phase;
11
+ cause;
12
+ constructor(plugin, phase, cause) {
13
+ super(
14
+ `BCP Plugins: ${phase} failed for plugin "${plugin}": ${formatError(cause)}`
15
+ );
16
+ this.name = "PluginLifecycleError";
17
+ this.plugin = plugin;
18
+ this.phase = phase;
19
+ this.cause = cause;
20
+ }
21
+ };
22
+ function definePlugin(definition) {
23
+ validatePluginDefinition(
24
+ definition
25
+ );
26
+ return definition;
27
+ }
28
+ function defineModule(module) {
29
+ if (!module || typeof module !== "object") {
30
+ throw new TypeError(
31
+ "BCP Plugins: module must be an object."
32
+ );
33
+ }
34
+ normalizeName(
35
+ module.name,
36
+ "module name"
37
+ );
38
+ if (!Array.isArray(module.plugins)) {
39
+ throw new TypeError(
40
+ "BCP Plugins: module plugins must be an array."
41
+ );
42
+ }
43
+ for (const plugin of module.plugins) {
44
+ validatePluginDefinition(plugin);
45
+ }
46
+ return module;
47
+ }
48
+ function createPluginServiceRegistry(initial) {
49
+ const values = /* @__PURE__ */ new Map();
50
+ if (initial) {
51
+ for (const [key, value] of initial) {
52
+ assertServiceKey(key);
53
+ if (values.has(key)) {
54
+ throw new Error(
55
+ `BCP Plugins: duplicate initial service ${formatServiceKey(key)}.`
56
+ );
57
+ }
58
+ values.set(key, value);
59
+ }
60
+ }
61
+ const registry = {
62
+ provide(key, value, options = {}) {
63
+ assertServiceKey(key);
64
+ if (values.has(key) && !options.replace) {
65
+ throw new Error(
66
+ `BCP Plugins: service ${formatServiceKey(key)} is already registered.`
67
+ );
68
+ }
69
+ values.set(key, value);
70
+ },
71
+ get(key) {
72
+ assertServiceKey(key);
73
+ if (!values.has(key)) {
74
+ throw new Error(
75
+ `BCP Plugins: service ${formatServiceKey(key)} is not registered.`
76
+ );
77
+ }
78
+ return values.get(key);
79
+ },
80
+ optional(key) {
81
+ assertServiceKey(key);
82
+ return values.get(key);
83
+ },
84
+ has(key) {
85
+ assertServiceKey(key);
86
+ return values.has(key);
87
+ },
88
+ delete(key) {
89
+ assertServiceKey(key);
90
+ return values.delete(key);
91
+ },
92
+ keys() {
93
+ return [
94
+ ...values.keys()
95
+ ];
96
+ }
97
+ };
98
+ return registry;
99
+ }
100
+ function createPluginHookBus() {
101
+ const hooks = /* @__PURE__ */ new Map();
102
+ const bus = {
103
+ on(name, handler) {
104
+ const normalized = normalizeName(
105
+ name,
106
+ "hook name"
107
+ );
108
+ if (typeof handler !== "function") {
109
+ throw new TypeError(
110
+ "BCP Plugins: hook handler must be a function."
111
+ );
112
+ }
113
+ let group = hooks.get(normalized);
114
+ if (!group) {
115
+ group = /* @__PURE__ */ new Set();
116
+ hooks.set(
117
+ normalized,
118
+ group
119
+ );
120
+ }
121
+ group.add(
122
+ handler
123
+ );
124
+ return () => {
125
+ group?.delete(
126
+ handler
127
+ );
128
+ if (group?.size === 0) {
129
+ hooks.delete(normalized);
130
+ }
131
+ };
132
+ },
133
+ async emit(name, payload) {
134
+ const normalized = normalizeName(
135
+ name,
136
+ "hook name"
137
+ );
138
+ const group = hooks.get(normalized);
139
+ if (!group) {
140
+ return;
141
+ }
142
+ for (const handler of [
143
+ ...group
144
+ ]) {
145
+ await handler(payload);
146
+ }
147
+ },
148
+ listenerCount(name) {
149
+ return hooks.get(
150
+ normalizeName(
151
+ name,
152
+ "hook name"
153
+ )
154
+ )?.size ?? 0;
155
+ },
156
+ clear(name) {
157
+ if (name === void 0) {
158
+ hooks.clear();
159
+ return;
160
+ }
161
+ hooks.delete(
162
+ normalizeName(
163
+ name,
164
+ "hook name"
165
+ )
166
+ );
167
+ }
168
+ };
169
+ return bus;
170
+ }
171
+ function createPluginHost(options = {}) {
172
+ const now = options.now ?? Date.now;
173
+ const services = createPluginServiceRegistry(
174
+ options.services
175
+ );
176
+ const hooks = createPluginHookBus();
177
+ const entries = /* @__PURE__ */ new Map();
178
+ const configs = {
179
+ ...options.configs ?? {}
180
+ };
181
+ let setupCompleted = false;
182
+ let started = false;
183
+ let closed = false;
184
+ let lifecycleActive = false;
185
+ let fatalError = null;
186
+ const host = {
187
+ services,
188
+ hooks,
189
+ get started() {
190
+ return started;
191
+ },
192
+ use(extension) {
193
+ assertMutable();
194
+ if (isPluginModule(extension)) {
195
+ defineModule(extension);
196
+ for (const plugin of extension.plugins) {
197
+ register(plugin);
198
+ }
199
+ return host;
200
+ }
201
+ register(extension);
202
+ return host;
203
+ },
204
+ resolveOrder() {
205
+ return resolvePluginOrder(
206
+ entries
207
+ );
208
+ },
209
+ async setup() {
210
+ assertOpen();
211
+ assertHealthy();
212
+ if (setupCompleted) {
213
+ return;
214
+ }
215
+ assertNotActive();
216
+ lifecycleActive = true;
217
+ try {
218
+ const order = resolvePluginOrder(
219
+ entries
220
+ );
221
+ for (const name of order) {
222
+ const entry = requireInternal(name);
223
+ if (entry.record.state !== "registered") {
224
+ continue;
225
+ }
226
+ entry.record.state = "setting-up";
227
+ try {
228
+ const context = createContext(entry);
229
+ entry.context = context;
230
+ await entry.definition.setup?.(
231
+ context
232
+ );
233
+ entry.record.state = "ready";
234
+ entry.record.setupAt = timestamp();
235
+ } catch (error) {
236
+ markFailed(
237
+ entry,
238
+ error
239
+ );
240
+ const lifecycleError = error instanceof PluginLifecycleError ? error : new PluginLifecycleError(
241
+ name,
242
+ "setup",
243
+ error
244
+ );
245
+ fatalError = lifecycleError;
246
+ await disposePrepared(
247
+ order,
248
+ name
249
+ );
250
+ throw lifecycleError;
251
+ }
252
+ }
253
+ setupCompleted = true;
254
+ } finally {
255
+ lifecycleActive = false;
256
+ }
257
+ },
258
+ async start() {
259
+ assertOpen();
260
+ assertHealthy();
261
+ if (started) {
262
+ return;
263
+ }
264
+ if (!setupCompleted) {
265
+ await host.setup();
266
+ }
267
+ assertHealthy();
268
+ assertNotActive();
269
+ lifecycleActive = true;
270
+ const order = resolvePluginOrder(
271
+ entries
272
+ );
273
+ const startedNames = [];
274
+ try {
275
+ for (const name of order) {
276
+ const entry = requireInternal(name);
277
+ if (entry.record.state !== "ready" && entry.record.state !== "stopped") {
278
+ continue;
279
+ }
280
+ entry.record.state = "starting";
281
+ try {
282
+ await entry.definition.start?.(
283
+ requireContext(entry)
284
+ );
285
+ entry.record.state = "started";
286
+ entry.record.startedAt = timestamp();
287
+ entry.record.error = void 0;
288
+ startedNames.push(name);
289
+ } catch (error) {
290
+ markFailed(
291
+ entry,
292
+ error
293
+ );
294
+ const lifecycleError = new PluginLifecycleError(
295
+ name,
296
+ "start",
297
+ error
298
+ );
299
+ fatalError = lifecycleError;
300
+ await stopNames(
301
+ [
302
+ ...startedNames
303
+ ].reverse()
304
+ );
305
+ throw lifecycleError;
306
+ }
307
+ }
308
+ started = true;
309
+ } finally {
310
+ lifecycleActive = false;
311
+ }
312
+ },
313
+ async stop() {
314
+ if (closed || !started) {
315
+ return;
316
+ }
317
+ assertNotActive();
318
+ lifecycleActive = true;
319
+ try {
320
+ const errors = await stopNames(
321
+ resolvePluginOrder(
322
+ entries
323
+ ).reverse()
324
+ );
325
+ started = false;
326
+ if (errors.length > 0) {
327
+ throw new AggregateError(
328
+ errors,
329
+ "BCP Plugins: one or more plugin stop hooks failed."
330
+ );
331
+ }
332
+ } finally {
333
+ lifecycleActive = false;
334
+ }
335
+ },
336
+ async close() {
337
+ if (closed) {
338
+ return;
339
+ }
340
+ const errors = [];
341
+ if (started) {
342
+ try {
343
+ await host.stop();
344
+ } catch (error) {
345
+ if (error instanceof AggregateError) {
346
+ errors.push(
347
+ ...error.errors
348
+ );
349
+ } else {
350
+ errors.push(error);
351
+ }
352
+ }
353
+ }
354
+ assertNotActive();
355
+ lifecycleActive = true;
356
+ try {
357
+ const order = resolvePluginOrder(
358
+ entries
359
+ ).reverse();
360
+ for (const name of order) {
361
+ const entry = requireInternal(name);
362
+ if (entry.disposed || !entry.context) {
363
+ continue;
364
+ }
365
+ try {
366
+ await entry.definition.dispose?.(
367
+ entry.context
368
+ );
369
+ } catch (error) {
370
+ errors.push(
371
+ new PluginLifecycleError(
372
+ name,
373
+ "dispose",
374
+ error
375
+ )
376
+ );
377
+ } finally {
378
+ entry.disposed = true;
379
+ }
380
+ }
381
+ hooks.clear();
382
+ closed = true;
383
+ } finally {
384
+ lifecycleActive = false;
385
+ }
386
+ if (errors.length > 0) {
387
+ throw new AggregateError(
388
+ errors,
389
+ "BCP Plugins: one or more plugin shutdown hooks failed."
390
+ );
391
+ }
392
+ },
393
+ plugin(name) {
394
+ const entry = entries.get(
395
+ normalizeName(
396
+ name,
397
+ "plugin name"
398
+ )
399
+ );
400
+ return entry ? cloneRecord(
401
+ entry.record
402
+ ) : null;
403
+ },
404
+ plugins() {
405
+ return [
406
+ ...entries.values()
407
+ ].map(
408
+ (entry) => cloneRecord(
409
+ entry.record
410
+ )
411
+ ).sort(
412
+ (left, right) => left.registeredAt - right.registeredAt || left.name.localeCompare(
413
+ right.name
414
+ )
415
+ );
416
+ }
417
+ };
418
+ for (const module of options.modules ?? []) {
419
+ host.use(module);
420
+ }
421
+ for (const plugin of options.plugins ?? []) {
422
+ host.use(plugin);
423
+ }
424
+ return host;
425
+ function register(definition) {
426
+ validatePluginDefinition(
427
+ definition
428
+ );
429
+ const name = normalizeName(
430
+ definition.name,
431
+ "plugin name"
432
+ );
433
+ if (entries.has(name)) {
434
+ throw new Error(
435
+ `BCP Plugins: plugin "${name}" is already registered.`
436
+ );
437
+ }
438
+ const requires = normalizeDependencyList(
439
+ definition.requires,
440
+ name,
441
+ "requires"
442
+ );
443
+ const optional = normalizeDependencyList(
444
+ definition.optional,
445
+ name,
446
+ "optional"
447
+ );
448
+ entries.set(
449
+ name,
450
+ {
451
+ definition: {
452
+ ...definition,
453
+ name,
454
+ requires,
455
+ optional
456
+ },
457
+ record: {
458
+ name,
459
+ version: normalizeOptionalVersion(
460
+ definition.version
461
+ ),
462
+ state: "registered",
463
+ requires,
464
+ optional,
465
+ registeredAt: timestamp()
466
+ },
467
+ disposed: false
468
+ }
469
+ );
470
+ }
471
+ function createContext(entry) {
472
+ const rawConfig = Object.prototype.hasOwnProperty.call(
473
+ configs,
474
+ entry.record.name
475
+ ) ? configs[entry.record.name] : entry.definition.config;
476
+ const config = parseConfig(
477
+ entry.definition.schema,
478
+ rawConfig,
479
+ entry.record.name
480
+ );
481
+ return {
482
+ name: entry.record.name,
483
+ config,
484
+ services,
485
+ hooks,
486
+ host
487
+ };
488
+ }
489
+ async function stopNames(names) {
490
+ const errors = [];
491
+ for (const name of names) {
492
+ const entry = requireInternal(name);
493
+ if (entry.record.state !== "started") {
494
+ continue;
495
+ }
496
+ entry.record.state = "stopping";
497
+ try {
498
+ await entry.definition.stop?.(
499
+ requireContext(entry)
500
+ );
501
+ entry.record.state = "stopped";
502
+ entry.record.stoppedAt = timestamp();
503
+ } catch (error) {
504
+ markFailed(
505
+ entry,
506
+ error
507
+ );
508
+ errors.push(
509
+ new PluginLifecycleError(
510
+ name,
511
+ "stop",
512
+ error
513
+ )
514
+ );
515
+ }
516
+ }
517
+ return errors;
518
+ }
519
+ async function disposePrepared(order, failedName) {
520
+ const index = order.indexOf(failedName);
521
+ const names = order.slice(
522
+ 0,
523
+ Math.max(0, index) + 1
524
+ ).reverse();
525
+ for (const name of names) {
526
+ const entry = requireInternal(name);
527
+ if (entry.disposed || !entry.context) {
528
+ continue;
529
+ }
530
+ try {
531
+ await entry.definition.dispose?.(
532
+ entry.context
533
+ );
534
+ } catch {
535
+ } finally {
536
+ entry.disposed = true;
537
+ }
538
+ }
539
+ }
540
+ function requireInternal(name) {
541
+ const entry = entries.get(name);
542
+ if (!entry) {
543
+ throw new Error(
544
+ `BCP Plugins: plugin "${name}" is not registered.`
545
+ );
546
+ }
547
+ return entry;
548
+ }
549
+ function timestamp() {
550
+ const value = now();
551
+ if (!Number.isFinite(value)) {
552
+ throw new TypeError(
553
+ "BCP Plugins: now() must return a finite number."
554
+ );
555
+ }
556
+ return value;
557
+ }
558
+ function assertOpen() {
559
+ if (closed) {
560
+ throw new Error(
561
+ "BCP Plugins: plugin host is closed."
562
+ );
563
+ }
564
+ }
565
+ function assertHealthy() {
566
+ if (fatalError) {
567
+ throw fatalError;
568
+ }
569
+ }
570
+ function assertMutable() {
571
+ assertOpen();
572
+ if (setupCompleted || lifecycleActive || fatalError) {
573
+ throw new Error(
574
+ "BCP Plugins: plugins cannot be registered after setup begins or after a lifecycle failure."
575
+ );
576
+ }
577
+ }
578
+ function assertNotActive() {
579
+ if (lifecycleActive) {
580
+ throw new Error(
581
+ "BCP Plugins: another lifecycle transition is already running."
582
+ );
583
+ }
584
+ }
585
+ }
586
+ function resolvePluginOrder(entries) {
587
+ for (const entry of entries.values()) {
588
+ for (const dependency of entry.record.requires) {
589
+ if (!entries.has(dependency)) {
590
+ throw new PluginDependencyError(
591
+ `BCP Plugins: plugin "${entry.record.name}" requires missing plugin "${dependency}".`
592
+ );
593
+ }
594
+ }
595
+ }
596
+ const visiting = /* @__PURE__ */ new Set();
597
+ const visited = /* @__PURE__ */ new Set();
598
+ const order = [];
599
+ const stack = [];
600
+ const visit = (name) => {
601
+ if (visited.has(name)) {
602
+ return;
603
+ }
604
+ if (visiting.has(name)) {
605
+ const start = stack.indexOf(name);
606
+ const cycle = [
607
+ ...stack.slice(
608
+ Math.max(0, start)
609
+ ),
610
+ name
611
+ ];
612
+ throw new PluginDependencyError(
613
+ `BCP Plugins: dependency cycle detected: ${cycle.join(" -> ")}.`
614
+ );
615
+ }
616
+ const entry = entries.get(name);
617
+ if (!entry) {
618
+ return;
619
+ }
620
+ visiting.add(name);
621
+ stack.push(name);
622
+ for (const dependency of [
623
+ ...entry.record.requires,
624
+ ...entry.record.optional.filter(
625
+ (candidate) => entries.has(candidate)
626
+ )
627
+ ]) {
628
+ visit(dependency);
629
+ }
630
+ stack.pop();
631
+ visiting.delete(name);
632
+ visited.add(name);
633
+ order.push(name);
634
+ };
635
+ for (const name of entries.keys()) {
636
+ visit(name);
637
+ }
638
+ return order;
639
+ }
640
+ function validatePluginDefinition(definition) {
641
+ if (!definition || typeof definition !== "object") {
642
+ throw new TypeError(
643
+ "BCP Plugins: plugin definition must be an object."
644
+ );
645
+ }
646
+ const name = normalizeName(
647
+ definition.name,
648
+ "plugin name"
649
+ );
650
+ normalizeDependencyList(
651
+ definition.requires,
652
+ name,
653
+ "requires"
654
+ );
655
+ normalizeDependencyList(
656
+ definition.optional,
657
+ name,
658
+ "optional"
659
+ );
660
+ for (const hook of [
661
+ "setup",
662
+ "start",
663
+ "stop",
664
+ "dispose"
665
+ ]) {
666
+ const value = definition[hook];
667
+ if (value !== void 0 && typeof value !== "function") {
668
+ throw new TypeError(
669
+ `BCP Plugins: plugin "${name}" ${hook} must be a function.`
670
+ );
671
+ }
672
+ }
673
+ if (definition.schema !== void 0 && typeof definition.schema !== "function" && (!definition.schema || typeof definition.schema.parse !== "function")) {
674
+ throw new TypeError(
675
+ `BCP Plugins: plugin "${name}" schema must be a parser function or object with parse().`
676
+ );
677
+ }
678
+ }
679
+ function normalizeDependencyList(value, plugin, field) {
680
+ if (value === void 0) {
681
+ return [];
682
+ }
683
+ if (!Array.isArray(value)) {
684
+ throw new TypeError(
685
+ `BCP Plugins: plugin "${plugin}" ${field} must be an array.`
686
+ );
687
+ }
688
+ const normalized = value.map(
689
+ (item) => normalizeName(
690
+ item,
691
+ `${field} dependency`
692
+ )
693
+ );
694
+ if (new Set(normalized).size !== normalized.length) {
695
+ throw new Error(
696
+ `BCP Plugins: plugin "${plugin}" ${field} contains duplicate dependencies.`
697
+ );
698
+ }
699
+ if (normalized.includes(plugin)) {
700
+ throw new PluginDependencyError(
701
+ `BCP Plugins: plugin "${plugin}" cannot depend on itself.`
702
+ );
703
+ }
704
+ return normalized;
705
+ }
706
+ function parseConfig(parser, raw, plugin) {
707
+ if (!parser) {
708
+ return raw;
709
+ }
710
+ try {
711
+ return typeof parser === "function" ? parser(raw) : parser.parse(raw);
712
+ } catch (error) {
713
+ throw new PluginLifecycleError(
714
+ plugin,
715
+ "config",
716
+ error
717
+ );
718
+ }
719
+ }
720
+ function requireContext(entry) {
721
+ if (!entry.context) {
722
+ throw new Error(
723
+ `BCP Plugins: plugin "${entry.record.name}" has not been set up.`
724
+ );
725
+ }
726
+ return entry.context;
727
+ }
728
+ function markFailed(entry, error) {
729
+ entry.record.state = "failed";
730
+ entry.record.error = formatError(error);
731
+ }
732
+ function cloneRecord(record) {
733
+ return {
734
+ ...record,
735
+ requires: [
736
+ ...record.requires
737
+ ],
738
+ optional: [
739
+ ...record.optional
740
+ ]
741
+ };
742
+ }
743
+ function isPluginModule(value) {
744
+ return Boolean(
745
+ value && typeof value === "object" && Array.isArray(
746
+ value.plugins
747
+ )
748
+ );
749
+ }
750
+ function normalizeName(value, field) {
751
+ const text = String(value ?? "").trim();
752
+ if (!text) {
753
+ throw new TypeError(
754
+ `BCP Plugins: ${field} must be a non-empty string.`
755
+ );
756
+ }
757
+ if (text.length > 200) {
758
+ throw new TypeError(
759
+ `BCP Plugins: ${field} must not exceed 200 characters.`
760
+ );
761
+ }
762
+ return text;
763
+ }
764
+ function normalizeOptionalVersion(value) {
765
+ if (value === void 0) {
766
+ return void 0;
767
+ }
768
+ return normalizeName(
769
+ value,
770
+ "plugin version"
771
+ );
772
+ }
773
+ function assertServiceKey(key) {
774
+ if (typeof key === "string") {
775
+ normalizeName(
776
+ key,
777
+ "service key"
778
+ );
779
+ return;
780
+ }
781
+ if (typeof key !== "symbol") {
782
+ throw new TypeError(
783
+ "BCP Plugins: service key must be a string or symbol."
784
+ );
785
+ }
786
+ }
787
+ function formatServiceKey(key) {
788
+ return typeof key === "symbol" ? String(key) : `"${key}"`;
789
+ }
790
+ function formatError(error) {
791
+ if (error instanceof Error) {
792
+ return error.message || error.name;
793
+ }
794
+ if (typeof error === "string") {
795
+ return error;
796
+ }
797
+ try {
798
+ return JSON.stringify(error) ?? String(error);
799
+ } catch {
800
+ return String(error);
801
+ }
802
+ }
803
+ export {
804
+ PluginDependencyError,
805
+ PluginLifecycleError,
806
+ createPluginHookBus,
807
+ createPluginHost,
808
+ createPluginServiceRegistry,
809
+ defineModule,
810
+ definePlugin
811
+ };