@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,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";
@@ -20,6 +20,14 @@ import {
20
20
  type DeploymentSignalOptions,
21
21
  } from "./deployment.js";
22
22
 
23
+ import {
24
+ composeModules,
25
+ isModuleDefinition,
26
+
27
+ type ModuleComposition,
28
+ type ModuleDefinition,
29
+ } from "./modules.js";
30
+
23
31
  import {
24
32
  createPluginHost,
25
33
 
@@ -62,6 +70,7 @@ export interface ApplicationContext<TConfig = unknown> {
62
70
  readonly version?: string;
63
71
  readonly config: TConfig;
64
72
  readonly container: ServiceContainer;
73
+ readonly modules: ModuleComposition;
65
74
  readonly services: PluginServiceRegistry;
66
75
  readonly hooks: PluginHookBus;
67
76
  readonly plugins: PluginHost;
@@ -75,9 +84,12 @@ export interface ApplicationDefinition<TConfig = unknown> {
75
84
  version?: string;
76
85
  config?: unknown;
77
86
  schema?: ApplicationConfigParser<TConfig>;
78
- providers?: readonly ServiceProvider<unknown>[];
87
+ providers?: readonly ServiceProvider<any>[];
79
88
  plugins?: readonly PluginDefinition<any>[];
80
- modules?: readonly PluginModule[];
89
+ modules?: readonly (
90
+ | PluginModule
91
+ | ModuleDefinition<any>
92
+ )[];
81
93
  pluginConfigs?: Record<string, unknown>;
82
94
  services?: Iterable<
83
95
  readonly [PluginServiceKey, unknown]
@@ -108,6 +120,7 @@ export interface Application<TConfig = unknown> {
108
120
  readonly config: TConfig;
109
121
  readonly context: ApplicationContext<TConfig>;
110
122
  readonly container: ServiceContainer;
123
+ readonly modules: ModuleComposition;
111
124
  readonly services: PluginServiceRegistry;
112
125
  readonly hooks: PluginHookBus;
113
126
  readonly plugins: PluginHost;
@@ -201,23 +214,42 @@ export function createApp<TConfig = unknown>(
201
214
  definition.schema,
202
215
  definition.config
203
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[];
204
230
  const container =
205
231
  createServiceContainer({
206
232
  name:
207
233
  `${name}:container`,
208
- providers:
209
- definition.providers,
234
+ providers: [
235
+ ...modules.providers,
236
+ ...(definition.providers ?? []),
237
+ ],
210
238
  });
211
239
  const plugins =
212
240
  createPluginHost({
213
- plugins:
214
- definition.plugins,
241
+ plugins: [
242
+ ...modules.plugins,
243
+ ...(definition.plugins ?? []),
244
+ ],
215
245
  modules:
216
- definition.modules,
246
+ legacyModules,
217
247
  configs:
218
248
  definition.pluginConfigs,
219
- services:
220
- definition.services,
249
+ services: [
250
+ ...modules.services,
251
+ ...(definition.services ?? []),
252
+ ],
221
253
  });
222
254
  const deployment =
223
255
  createDeploymentRuntime({
@@ -258,6 +290,7 @@ export function createApp<TConfig = unknown>(
258
290
  : {}),
259
291
  config,
260
292
  container,
293
+ modules,
261
294
  services:
262
295
  plugins.services,
263
296
  hooks:
@@ -333,6 +366,19 @@ export function createApp<TConfig = unknown>(
333
366
  },
334
367
  });
335
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
+
336
382
  for (
337
383
  const resource
338
384
  of definition.resources ?? []
@@ -351,6 +397,7 @@ export function createApp<TConfig = unknown>(
351
397
  config,
352
398
  context,
353
399
  container,
400
+ modules,
354
401
  services:
355
402
  plugins.services,
356
403
  hooks:
@@ -717,6 +764,8 @@ export function createApp<TConfig = unknown>(
717
764
  node =>
718
765
  node.description
719
766
  ),
767
+ modules:
768
+ modules.records(),
720
769
  pluginCount:
721
770
  plugins.plugins().length,
722
771
  };