@chidchanun/bcp 0.3.0 → 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,392 @@
1
+ // packages/server/src/modules.ts
2
+ var MODULE_V2_KIND = "bcp-module-v2";
3
+ var ModuleDependencyError = class extends Error {
4
+ constructor(message) {
5
+ super(message);
6
+ this.name = "ModuleDependencyError";
7
+ }
8
+ };
9
+ var ModuleLifecycleError = class extends Error {
10
+ module;
11
+ phase;
12
+ cause;
13
+ constructor(moduleName, phase, cause) {
14
+ super(
15
+ `BCP Modules: ${phase} failed for module "${moduleName}": ${formatError(cause)}`
16
+ );
17
+ this.name = "ModuleLifecycleError";
18
+ this.module = moduleName;
19
+ this.phase = phase;
20
+ this.cause = cause;
21
+ }
22
+ };
23
+ function defineModule(input) {
24
+ const definition = {
25
+ ...input,
26
+ kind: MODULE_V2_KIND
27
+ };
28
+ validateModuleDefinition(definition);
29
+ return definition;
30
+ }
31
+ function isModuleDefinition(value) {
32
+ return Boolean(
33
+ value && typeof value === "object" && value.kind === MODULE_V2_KIND
34
+ );
35
+ }
36
+ function composeModules(roots = []) {
37
+ const ordered = resolveModuleOrder(roots);
38
+ const providers = [];
39
+ const plugins = [];
40
+ const services = [];
41
+ const resources = [];
42
+ const serviceKeys = /* @__PURE__ */ new Set();
43
+ for (const module of ordered) {
44
+ providers.push(
45
+ ...module.providers ?? []
46
+ );
47
+ plugins.push(
48
+ ...module.plugins ?? []
49
+ );
50
+ resources.push(
51
+ ...module.resources ?? []
52
+ );
53
+ for (const entry of module.services ?? []) {
54
+ const [key] = entry;
55
+ if (serviceKeys.has(key)) {
56
+ throw new ModuleDependencyError(
57
+ `BCP Modules: duplicate shared service ${formatServiceKey(key)} while composing module "${module.name}".`
58
+ );
59
+ }
60
+ serviceKeys.add(key);
61
+ services.push(entry);
62
+ }
63
+ validateModuleExports(
64
+ module
65
+ );
66
+ }
67
+ return {
68
+ modules: ordered,
69
+ providers,
70
+ plugins,
71
+ services,
72
+ resources,
73
+ records() {
74
+ return ordered.map(
75
+ (module) => ({
76
+ name: module.name,
77
+ ...module.version ? {
78
+ version: module.version
79
+ } : {},
80
+ imports: (module.imports ?? []).map(
81
+ (dependency) => dependency.name
82
+ ),
83
+ providers: (module.providers ?? []).map(
84
+ (provider) => provider.token.description
85
+ ),
86
+ exports: (module.exports ?? []).map(
87
+ (token) => token.description
88
+ ),
89
+ plugins: (module.plugins ?? []).map(
90
+ (plugin) => plugin.name
91
+ ),
92
+ resources: (module.resources ?? []).map(
93
+ (resource) => resource.name
94
+ )
95
+ })
96
+ );
97
+ },
98
+ exportedTokens(moduleName) {
99
+ const normalized = normalizeName(
100
+ moduleName,
101
+ "module name"
102
+ );
103
+ const module = ordered.find(
104
+ (entry) => entry.name === normalized
105
+ );
106
+ if (!module) {
107
+ throw new ModuleDependencyError(
108
+ `BCP Modules: module "${normalized}" is not part of this composition.`
109
+ );
110
+ }
111
+ return [
112
+ ...module.exports ?? []
113
+ ];
114
+ },
115
+ createLifecycleResources(context) {
116
+ return ordered.flatMap(
117
+ (module) => [
118
+ ...module.resources ?? [],
119
+ createModuleLifecycleResource(
120
+ module,
121
+ context
122
+ )
123
+ ]
124
+ );
125
+ }
126
+ };
127
+ }
128
+ function resolveModuleOrder(roots) {
129
+ const byName = /* @__PURE__ */ new Map();
130
+ const visiting = /* @__PURE__ */ new Set();
131
+ const visited = /* @__PURE__ */ new Set();
132
+ const order = [];
133
+ const visit = (module, path) => {
134
+ validateModuleDefinition(module);
135
+ const name = module.name;
136
+ const existing = byName.get(name);
137
+ if (existing && existing !== module) {
138
+ throw new ModuleDependencyError(
139
+ `BCP Modules: duplicate module name "${name}" refers to different definitions.`
140
+ );
141
+ }
142
+ byName.set(name, module);
143
+ if (visited.has(name)) {
144
+ return;
145
+ }
146
+ if (visiting.has(name)) {
147
+ throw new ModuleDependencyError(
148
+ `BCP Modules: circular module dependency detected: ${[
149
+ ...path,
150
+ name
151
+ ].join(" -> ")}.`
152
+ );
153
+ }
154
+ visiting.add(name);
155
+ for (const dependency of module.imports ?? []) {
156
+ visit(
157
+ dependency,
158
+ [
159
+ ...path,
160
+ name
161
+ ]
162
+ );
163
+ }
164
+ visiting.delete(name);
165
+ visited.add(name);
166
+ order.push(module);
167
+ };
168
+ for (const root of roots) {
169
+ visit(root, []);
170
+ }
171
+ return order;
172
+ }
173
+ function createModuleLifecycleResource(module, shared) {
174
+ const config = parseModuleConfig(
175
+ module.schema,
176
+ module.config
177
+ );
178
+ const context = {
179
+ name: module.name,
180
+ config,
181
+ container: shared.container,
182
+ services: shared.services,
183
+ hooks: shared.hooks
184
+ };
185
+ let setupComplete = false;
186
+ let disposed = false;
187
+ return {
188
+ name: `bcp:module:${module.name}`,
189
+ async start() {
190
+ try {
191
+ if (!setupComplete) {
192
+ await runModuleHook(
193
+ module,
194
+ "setup",
195
+ module.setup,
196
+ context
197
+ );
198
+ setupComplete = true;
199
+ }
200
+ await runModuleHook(
201
+ module,
202
+ "start",
203
+ module.start,
204
+ context
205
+ );
206
+ } catch (error) {
207
+ if (!disposed) {
208
+ disposed = true;
209
+ try {
210
+ await runModuleHook(
211
+ module,
212
+ "dispose",
213
+ module.dispose,
214
+ context
215
+ );
216
+ } catch (disposeError) {
217
+ throw new AggregateError(
218
+ [
219
+ error,
220
+ disposeError
221
+ ],
222
+ `BCP Modules: startup and cleanup failed for module "${module.name}".`
223
+ );
224
+ }
225
+ }
226
+ throw error;
227
+ }
228
+ },
229
+ ready() {
230
+ return true;
231
+ },
232
+ async stop() {
233
+ const errors = [];
234
+ try {
235
+ await runModuleHook(
236
+ module,
237
+ "stop",
238
+ module.stop,
239
+ context
240
+ );
241
+ } catch (error) {
242
+ errors.push(error);
243
+ }
244
+ if (!disposed) {
245
+ disposed = true;
246
+ try {
247
+ await runModuleHook(
248
+ module,
249
+ "dispose",
250
+ module.dispose,
251
+ context
252
+ );
253
+ } catch (error) {
254
+ errors.push(error);
255
+ }
256
+ }
257
+ if (errors.length > 0) {
258
+ throw new AggregateError(
259
+ errors,
260
+ `BCP Modules: shutdown failed for module "${module.name}".`
261
+ );
262
+ }
263
+ },
264
+ diagnostics() {
265
+ return {
266
+ name: module.name,
267
+ ...module.version ? {
268
+ version: module.version
269
+ } : {},
270
+ imports: (module.imports ?? []).map(
271
+ (dependency) => dependency.name
272
+ ),
273
+ exports: (module.exports ?? []).map(
274
+ (token) => token.description
275
+ )
276
+ };
277
+ }
278
+ };
279
+ }
280
+ async function runModuleHook(module, phase, hook, context) {
281
+ if (!hook) {
282
+ return;
283
+ }
284
+ try {
285
+ await hook(context);
286
+ } catch (error) {
287
+ throw new ModuleLifecycleError(
288
+ module.name,
289
+ phase,
290
+ error
291
+ );
292
+ }
293
+ }
294
+ function validateModuleDefinition(module) {
295
+ if (!module || typeof module !== "object") {
296
+ throw new TypeError(
297
+ "BCP Modules: module must be an object."
298
+ );
299
+ }
300
+ if (module.kind !== MODULE_V2_KIND) {
301
+ throw new TypeError(
302
+ `BCP Modules: module kind must be "${MODULE_V2_KIND}". Use defineModule().`
303
+ );
304
+ }
305
+ normalizeName(
306
+ module.name,
307
+ "module name"
308
+ );
309
+ for (const [label, value] of [
310
+ ["imports", module.imports],
311
+ ["providers", module.providers],
312
+ ["exports", module.exports],
313
+ ["plugins", module.plugins],
314
+ ["resources", module.resources]
315
+ ]) {
316
+ if (value !== void 0 && !Array.isArray(value)) {
317
+ throw new TypeError(
318
+ `BCP Modules: ${label} must be an array.`
319
+ );
320
+ }
321
+ }
322
+ for (const [phase, hook] of [
323
+ ["setup", module.setup],
324
+ ["start", module.start],
325
+ ["stop", module.stop],
326
+ ["dispose", module.dispose]
327
+ ]) {
328
+ if (hook !== void 0 && typeof hook !== "function") {
329
+ throw new TypeError(
330
+ `BCP Modules: ${phase} must be a function.`
331
+ );
332
+ }
333
+ }
334
+ }
335
+ function validateModuleExports(module) {
336
+ const available = /* @__PURE__ */ new Set();
337
+ for (const provider of module.providers ?? []) {
338
+ available.add(
339
+ provider.token.id
340
+ );
341
+ }
342
+ for (const dependency of module.imports ?? []) {
343
+ for (const token of dependency.exports ?? []) {
344
+ available.add(token.id);
345
+ }
346
+ }
347
+ for (const token of module.exports ?? []) {
348
+ if (!available.has(token.id)) {
349
+ throw new ModuleDependencyError(
350
+ `BCP Modules: module "${module.name}" exports "${token.description}" but does not provide or import it.`
351
+ );
352
+ }
353
+ }
354
+ }
355
+ function parseModuleConfig(parser, value) {
356
+ if (!parser) {
357
+ return value;
358
+ }
359
+ if (typeof parser === "function") {
360
+ return parser(value);
361
+ }
362
+ return parser.parse(value);
363
+ }
364
+ function normalizeName(value, label) {
365
+ if (typeof value !== "string") {
366
+ throw new TypeError(
367
+ `BCP Modules: ${label} must be a string.`
368
+ );
369
+ }
370
+ const normalized = value.trim();
371
+ if (!normalized) {
372
+ throw new TypeError(
373
+ `BCP Modules: ${label} cannot be empty.`
374
+ );
375
+ }
376
+ return normalized;
377
+ }
378
+ function formatServiceKey(key) {
379
+ return typeof key === "symbol" ? key.description ? `Symbol(${key.description})` : key.toString() : key;
380
+ }
381
+ function formatError(value) {
382
+ return value instanceof Error ? value.message : String(value);
383
+ }
384
+ export {
385
+ MODULE_V2_KIND,
386
+ ModuleDependencyError,
387
+ ModuleLifecycleError,
388
+ composeModules,
389
+ defineModule,
390
+ isModuleDefinition,
391
+ resolveModuleOrder
392
+ };
@@ -0,0 +1,17 @@
1
+ export {
2
+ MODULE_V2_KIND,
3
+ ModuleDependencyError,
4
+ ModuleLifecycleError,
5
+ composeModules,
6
+ defineModule,
7
+ isModuleDefinition,
8
+ resolveModuleOrder,
9
+
10
+ type ModuleComposition,
11
+ type ModuleConfigParser,
12
+ type ModuleConfigSchema,
13
+ type ModuleContext,
14
+ type ModuleDefinition,
15
+ type ModuleInput,
16
+ type ModuleRecord,
17
+ } from "../../server/src/modules.js";
@@ -1,3 +1,12 @@
1
+ import {
2
+ createServiceContainer,
3
+
4
+ type ServiceContainer,
5
+ type ServiceProvider,
6
+ type ServiceScope,
7
+ type ServiceScopeOptions,
8
+ } from "./container.js";
9
+
1
10
  import {
2
11
  createDeploymentRuntime,
3
12
 
@@ -11,6 +20,14 @@ import {
11
20
  type DeploymentSignalOptions,
12
21
  } from "./deployment.js";
13
22
 
23
+ import {
24
+ composeModules,
25
+ isModuleDefinition,
26
+
27
+ type ModuleComposition,
28
+ type ModuleDefinition,
29
+ } from "./modules.js";
30
+
14
31
  import {
15
32
  createPluginHost,
16
33
 
@@ -52,6 +69,8 @@ export interface ApplicationContext<TConfig = unknown> {
52
69
  readonly name: string;
53
70
  readonly version?: string;
54
71
  readonly config: TConfig;
72
+ readonly container: ServiceContainer;
73
+ readonly modules: ModuleComposition;
55
74
  readonly services: PluginServiceRegistry;
56
75
  readonly hooks: PluginHookBus;
57
76
  readonly plugins: PluginHost;
@@ -65,8 +84,12 @@ export interface ApplicationDefinition<TConfig = unknown> {
65
84
  version?: string;
66
85
  config?: unknown;
67
86
  schema?: ApplicationConfigParser<TConfig>;
87
+ providers?: readonly ServiceProvider<any>[];
68
88
  plugins?: readonly PluginDefinition<any>[];
69
- modules?: readonly PluginModule[];
89
+ modules?: readonly (
90
+ | PluginModule
91
+ | ModuleDefinition<any>
92
+ )[];
70
93
  pluginConfigs?: Record<string, unknown>;
71
94
  services?: Iterable<
72
95
  readonly [PluginServiceKey, unknown]
@@ -96,6 +119,8 @@ export interface Application<TConfig = unknown> {
96
119
  readonly state: ApplicationState;
97
120
  readonly config: TConfig;
98
121
  readonly context: ApplicationContext<TConfig>;
122
+ readonly container: ServiceContainer;
123
+ readonly modules: ModuleComposition;
99
124
  readonly services: PluginServiceRegistry;
100
125
  readonly hooks: PluginHookBus;
101
126
  readonly plugins: PluginHost;
@@ -112,6 +137,15 @@ export interface Application<TConfig = unknown> {
112
137
  replace?: boolean;
113
138
  }
114
139
  ): Application<TConfig>;
140
+ register<T>(
141
+ provider: ServiceProvider<T>,
142
+ options?: {
143
+ replace?: boolean;
144
+ }
145
+ ): Application<TConfig>;
146
+ createScope(
147
+ options?: ServiceScopeOptions
148
+ ): ServiceScope;
115
149
  addResource(
116
150
  resource: DeploymentResource
117
151
  ): Application<TConfig>;
@@ -180,16 +214,42 @@ export function createApp<TConfig = unknown>(
180
214
  definition.schema,
181
215
  definition.config
182
216
  );
217
+ const moduleInputs =
218
+ definition.modules ?? [];
219
+ const modules =
220
+ composeModules(
221
+ moduleInputs.filter(
222
+ isModuleDefinition
223
+ )
224
+ );
225
+ const legacyModules =
226
+ moduleInputs.filter(
227
+ module =>
228
+ !isModuleDefinition(module)
229
+ ) as PluginModule[];
230
+ const container =
231
+ createServiceContainer({
232
+ name:
233
+ `${name}:container`,
234
+ providers: [
235
+ ...modules.providers,
236
+ ...(definition.providers ?? []),
237
+ ],
238
+ });
183
239
  const plugins =
184
240
  createPluginHost({
185
- plugins:
186
- definition.plugins,
241
+ plugins: [
242
+ ...modules.plugins,
243
+ ...(definition.plugins ?? []),
244
+ ],
187
245
  modules:
188
- definition.modules,
246
+ legacyModules,
189
247
  configs:
190
248
  definition.pluginConfigs,
191
- services:
192
- definition.services,
249
+ services: [
250
+ ...modules.services,
251
+ ...(definition.services ?? []),
252
+ ],
193
253
  });
194
254
  const deployment =
195
255
  createDeploymentRuntime({
@@ -229,6 +289,8 @@ export function createApp<TConfig = unknown>(
229
289
  }
230
290
  : {}),
231
291
  config,
292
+ container,
293
+ modules,
232
294
  services:
233
295
  plugins.services,
234
296
  hooks:
@@ -243,6 +305,38 @@ export function createApp<TConfig = unknown>(
243
305
  },
244
306
  };
245
307
 
308
+ deployment.addResource({
309
+ name:
310
+ "bcp:container",
311
+ ready() {
312
+ return {
313
+ ok:
314
+ container.state ===
315
+ "active",
316
+ detail:
317
+ `Service container is ${container.state}.`,
318
+ };
319
+ },
320
+ async stop() {
321
+ await container.dispose();
322
+ },
323
+ diagnostics() {
324
+ return {
325
+ state:
326
+ container.state,
327
+ providers:
328
+ container.graph().map(
329
+ node => ({
330
+ token:
331
+ node.description,
332
+ lifetime:
333
+ node.lifetime,
334
+ })
335
+ ),
336
+ };
337
+ },
338
+ });
339
+
246
340
  deployment.addResource({
247
341
  name:
248
342
  "bcp:plugins",
@@ -272,6 +366,19 @@ export function createApp<TConfig = unknown>(
272
366
  },
273
367
  });
274
368
 
369
+ for (
370
+ const resource
371
+ of modules.createLifecycleResources({
372
+ container,
373
+ services:
374
+ plugins.services,
375
+ hooks:
376
+ plugins.hooks,
377
+ })
378
+ ) {
379
+ deployment.addResource(resource);
380
+ }
381
+
275
382
  for (
276
383
  const resource
277
384
  of definition.resources ?? []
@@ -284,11 +391,13 @@ export function createApp<TConfig = unknown>(
284
391
  name,
285
392
  ...(version
286
393
  ? {
287
- version,
288
- }
394
+ version,
395
+ }
289
396
  : {}),
290
397
  config,
291
398
  context,
399
+ container,
400
+ modules,
292
401
  services:
293
402
  plugins.services,
294
403
  hooks:
@@ -322,6 +431,26 @@ export function createApp<TConfig = unknown>(
322
431
  return app;
323
432
  },
324
433
 
434
+ register<T>(
435
+ provider: ServiceProvider<T>,
436
+ options: {
437
+ replace?: boolean;
438
+ } = {}
439
+ ) {
440
+ assertMutable();
441
+ container.register(
442
+ provider,
443
+ options
444
+ );
445
+ return app;
446
+ },
447
+
448
+ createScope(options = {}) {
449
+ return container.createScope(
450
+ options
451
+ );
452
+ },
453
+
325
454
  addResource(resource) {
326
455
  assertMutable();
327
456
  deployment.addResource(
@@ -562,6 +691,14 @@ export function createApp<TConfig = unknown>(
562
691
  }
563
692
  }
564
693
 
694
+ if (container.state !== "disposed") {
695
+ try {
696
+ await container.dispose();
697
+ } catch (error) {
698
+ errors.push(error);
699
+ }
700
+ }
701
+
565
702
  try {
566
703
  await disposeApplication();
567
704
  } catch (error) {
@@ -612,8 +749,8 @@ export function createApp<TConfig = unknown>(
612
749
  name,
613
750
  ...(version
614
751
  ? {
615
- version,
616
- }
752
+ version,
753
+ }
617
754
  : {}),
618
755
  state,
619
756
  services:
@@ -622,6 +759,13 @@ export function createApp<TConfig = unknown>(
622
759
  .map(
623
760
  formatServiceKey
624
761
  ),
762
+ containerProviders:
763
+ container.graph().map(
764
+ node =>
765
+ node.description
766
+ ),
767
+ modules:
768
+ modules.records(),
625
769
  pluginCount:
626
770
  plugins.plugins().length,
627
771
  };
@@ -654,6 +798,14 @@ export function createApp<TConfig = unknown>(
654
798
  // Preserve the original startup failure.
655
799
  }
656
800
 
801
+ if (container.state !== "disposed") {
802
+ try {
803
+ await container.dispose();
804
+ } catch {
805
+ // Preserve the original startup failure.
806
+ }
807
+ }
808
+
657
809
  try {
658
810
  await disposeApplication();
659
811
  } catch {
@@ -697,7 +849,7 @@ export function createApp<TConfig = unknown>(
697
849
  function assertMutable(): void {
698
850
  if (state !== "created") {
699
851
  throw new Error(
700
- "BCP Application: plugins, services and resources must be registered before start()."
852
+ "BCP Application: plugins, services, providers and resources must be registered before start()."
701
853
  );
702
854
  }
703
855
  }
@@ -747,6 +899,14 @@ function validateDefinition<TConfig>(
747
899
  definition.version
748
900
  );
749
901
 
902
+ if (
903
+ definition.providers !== undefined &&
904
+ !Array.isArray(definition.providers)
905
+ ) {
906
+ throw new TypeError(
907
+ "BCP Application: providers must be an array."
908
+ );
909
+ }
750
910
  if (
751
911
  definition.plugins !== undefined &&
752
912
  !Array.isArray(definition.plugins)