arcway 0.1.26 → 0.1.28

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.
Files changed (56) hide show
  1. package/client/fetcher.js +4 -1
  2. package/client/hooks/swr-compat.js +4 -2
  3. package/client/hooks/use-api-paginated.js +74 -0
  4. package/client/index.js +1 -0
  5. package/package.json +4 -3
  6. package/server/boot/index.js +22 -6
  7. package/server/build.js +73 -4
  8. package/server/cache/index.js +12 -2
  9. package/server/cache/ttl.js +24 -0
  10. package/server/config/duration.js +49 -0
  11. package/server/config/loader.js +4 -0
  12. package/server/config/modules/cache.js +7 -1
  13. package/server/config/modules/jobs.js +14 -1
  14. package/server/config/modules/mail.js +16 -1
  15. package/server/config/modules/mcp.js +5 -1
  16. package/server/config/modules/pages.js +1 -0
  17. package/server/config/modules/plugins.js +18 -0
  18. package/server/config/modules/queue.js +7 -1
  19. package/server/config/modules/secrets.js +145 -0
  20. package/server/config/modules/server.js +13 -1
  21. package/server/config/modules/session.js +5 -1
  22. package/server/config/modules/websocket.js +8 -0
  23. package/server/context.js +61 -2
  24. package/server/db/index.js +39 -9
  25. package/server/discovery.js +45 -3
  26. package/server/events/drivers/memory.js +22 -4
  27. package/server/events/drivers/redis.js +20 -5
  28. package/server/events/handler.js +21 -7
  29. package/server/events/index.js +2 -2
  30. package/server/index.js +2 -1
  31. package/server/internals.js +8 -0
  32. package/server/jobs/drivers/run-handler.js +3 -0
  33. package/server/jobs/runner.js +13 -4
  34. package/server/jobs/worker-config.js +58 -0
  35. package/server/jobs/worker-entry.js +7 -12
  36. package/server/lib/vault/index.js +20 -0
  37. package/server/lib/vault/secrets.js +149 -0
  38. package/server/meta.js +106 -0
  39. package/server/pages/build-plugins.js +2 -1
  40. package/server/pages/handler.js +23 -5
  41. package/server/pages/out-dir.js +103 -2
  42. package/server/pages/pages-router.js +3 -2
  43. package/server/pages/ssr.js +55 -18
  44. package/server/plugins/capabilities.js +66 -0
  45. package/server/plugins/discovery.js +111 -0
  46. package/server/plugins/graph.js +114 -0
  47. package/server/plugins/manager.js +210 -0
  48. package/server/plugins/manifest.js +94 -0
  49. package/server/router/api-router.js +41 -16
  50. package/server/router/routes.js +14 -2
  51. package/server/server.js +13 -1
  52. package/server/session/index.js +5 -1
  53. package/server/static/index.js +5 -2
  54. package/server/web-server.js +3 -3
  55. package/server/ws/realtime.js +24 -8
  56. package/server/ws/ws-router.js +24 -8
@@ -4,6 +4,13 @@ import { createRequire } from 'node:module';
4
4
  import { setSSRHeadData, clearSSRHeadData, renderHeadToString } from '#client/head.js';
5
5
  import { collectPublicEnv, buildEnvScriptTag } from '#client/env.js';
6
6
  import { buildHmrScript } from './hmr.js';
7
+
8
+ function getHtmlHeaders() {
9
+ return {
10
+ 'Content-Type': 'text/html; charset=utf-8',
11
+ 'Cache-Control': 'no-store',
12
+ };
13
+ }
7
14
  const REACT_INTERNALS_KEY = '__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE';
8
15
  function syncReactInternals(projectReactModule) {
9
16
  try {
@@ -154,7 +161,7 @@ async function renderPage(
154
161
  cacheVersion,
155
162
  );
156
163
  if (!Component || typeof Component !== 'function') {
157
- res.writeHead(500, { 'Content-Type': 'text/html' });
164
+ res.writeHead(500, getHtmlHeaders());
158
165
  res.end('<h1>500 - Page component is not a valid React component</h1>');
159
166
  return;
160
167
  }
@@ -188,10 +195,12 @@ async function renderPage(
188
195
  }
189
196
  element = wrapWithProviders(createElement, element, pathname, params);
190
197
  const { PassThrough } = await import('node:stream');
191
- const { pipe } = renderToPipeableStream(element, {
198
+ let pass = null;
199
+ let closed = false;
200
+ const { pipe, abort } = renderToPipeableStream(element, {
192
201
  onAllReady() {
193
- if (res.headersSent) return;
194
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
202
+ if (res.headersSent || closed) return;
203
+ res.writeHead(200, getHtmlHeaders());
195
204
  const headHtml = renderHeadToString(headData);
196
205
  const shell = buildHtmlShell({
197
206
  headHtml,
@@ -205,24 +214,39 @@ ${scriptTags}
205
214
  ${liveReloadTag}`,
206
215
  });
207
216
  res.write(shell.head);
208
- const pass = new PassThrough();
209
- pass.on('data', (chunk) => res.write(chunk));
217
+ pass = new PassThrough();
218
+ pass.on('data', (chunk) => {
219
+ if (!closed && !res.writableEnded) res.write(chunk);
220
+ });
210
221
  pass.on('end', () => {
211
- res.write(shell.tail);
222
+ if (!closed && !res.writableEnded) {
223
+ res.write(shell.tail);
224
+ res.end();
225
+ }
212
226
  clearSSRHeadData();
213
- res.end();
214
227
  });
215
228
  pipe(pass);
216
229
  },
217
230
  onError(err) {
218
231
  clearSSRHeadData();
232
+ // A client disconnect aborts the render and surfaces here; that's a normal
233
+ // peer disconnect, not a server fault — don't log it or write to a dead res.
234
+ if (closed) return;
219
235
  console.error('SSR render error:', err);
220
236
  if (!res.headersSent) {
221
- res.writeHead(500, { 'Content-Type': 'text/html' });
237
+ res.writeHead(500, getHtmlHeaders());
222
238
  res.end('<h1>500 - Render Error</h1>');
223
239
  }
224
240
  },
225
241
  });
242
+ // Client gone (mid-stream, or before the shell was ready): stop React from
243
+ // rendering into a dead response and tear down the PassThrough.
244
+ res.on('close', () => {
245
+ if (closed) return;
246
+ closed = true;
247
+ abort();
248
+ if (pass) pass.destroy();
249
+ });
226
250
  }
227
251
  async function renderErrorPage(
228
252
  bundlePath,
@@ -244,7 +268,7 @@ async function renderErrorPage(
244
268
  cacheVersion,
245
269
  );
246
270
  if (!Component || typeof Component !== 'function') {
247
- res.writeHead(statusCode, { 'Content-Type': 'text/html' });
271
+ res.writeHead(statusCode, getHtmlHeaders());
248
272
  res.end(
249
273
  `<h1>${statusCode} - ${statusCode === 404 ? 'Not Found' : 'Internal Server Error'}</h1>`,
250
274
  );
@@ -262,33 +286,46 @@ async function renderErrorPage(
262
286
  {},
263
287
  );
264
288
  const { PassThrough } = await import('node:stream');
265
- const { pipe } = renderToPipeableStream(element, {
289
+ let pass = null;
290
+ let closed = false;
291
+ const { pipe, abort } = renderToPipeableStream(element, {
266
292
  onAllReady() {
267
- if (res.headersSent) return;
293
+ if (res.headersSent || closed) return;
268
294
  const headHtml = renderHeadToString(headData);
269
- res.writeHead(statusCode, { 'Content-Type': 'text/html; charset=utf-8' });
295
+ res.writeHead(statusCode, getHtmlHeaders());
270
296
  const shell = buildHtmlShell({ headHtml, fontPreloadTags, cssLinkTag });
271
297
  res.write(shell.head);
272
- const pass = new PassThrough();
273
- pass.on('data', (chunk) => res.write(chunk));
298
+ pass = new PassThrough();
299
+ pass.on('data', (chunk) => {
300
+ if (!closed && !res.writableEnded) res.write(chunk);
301
+ });
274
302
  pass.on('end', () => {
275
- res.write(shell.tail);
303
+ if (!closed && !res.writableEnded) {
304
+ res.write(shell.tail);
305
+ res.end();
306
+ }
276
307
  clearSSRHeadData();
277
- res.end();
278
308
  });
279
309
  pipe(pass);
280
310
  },
281
311
  onError(err) {
282
312
  clearSSRHeadData();
313
+ if (closed) return;
283
314
  console.error('Error page render error:', err);
284
315
  if (!res.headersSent) {
285
- res.writeHead(statusCode, { 'Content-Type': 'text/html' });
316
+ res.writeHead(statusCode, getHtmlHeaders());
286
317
  res.end(
287
318
  `<h1>${statusCode} - ${statusCode === 404 ? 'Not Found' : 'Internal Server Error'}</h1>`,
288
319
  );
289
320
  }
290
321
  },
291
322
  });
323
+ res.on('close', () => {
324
+ if (closed) return;
325
+ closed = true;
326
+ abort();
327
+ if (pass) pass.destroy();
328
+ });
292
329
  }
293
330
  export {
294
331
  buildCssLinkTag,
@@ -0,0 +1,66 @@
1
+ import { requiredCapabilities } from './manifest.js';
2
+
3
+ class CapabilityNotAvailable extends Error {
4
+ constructor(capability, reason = 'missing or disabled') {
5
+ super(`Capability not available: ${capability} (${reason})`);
6
+ this.name = 'CapabilityNotAvailable';
7
+ this.capability = capability;
8
+ this.reason = reason;
9
+ }
10
+ }
11
+
12
+ class CapabilityRegistry {
13
+ #providers = new Map();
14
+
15
+ register(pluginId, capability, factory) {
16
+ if (typeof factory !== 'function') {
17
+ throw new Error(
18
+ `Capability ${capability} from plugin ${pluginId} must be registered with a factory`,
19
+ );
20
+ }
21
+ if (this.#providers.has(capability)) {
22
+ const existing = this.#providers.get(capability);
23
+ throw new Error(
24
+ `Capability ${capability} is already provided by plugin ${existing.pluginId}`,
25
+ );
26
+ }
27
+ this.#providers.set(capability, { pluginId, factory, enabled: true });
28
+ }
29
+
30
+ setPluginEnabled(pluginId, enabled) {
31
+ for (const provider of this.#providers.values()) {
32
+ if (provider.pluginId === pluginId) provider.enabled = enabled;
33
+ }
34
+ }
35
+
36
+ has(capability) {
37
+ const provider = this.#providers.get(capability);
38
+ return provider?.enabled === true;
39
+ }
40
+
41
+ inject(ctx, requires) {
42
+ for (const capability of requiredCapabilities(requires)) {
43
+ const provider = this.#providers.get(capability);
44
+ if (!provider) throw new CapabilityNotAvailable(capability, 'missing provider');
45
+ if (!provider.enabled) throw new CapabilityNotAvailable(capability, 'provider disabled');
46
+ ctx[capability] = provider.factory({
47
+ capability,
48
+ providerPluginId: provider.pluginId,
49
+ req: ctx.req,
50
+ session: ctx.req?.session,
51
+ ctx,
52
+ });
53
+ }
54
+ return ctx;
55
+ }
56
+
57
+ list() {
58
+ return [...this.#providers.entries()].map(([capability, provider]) => ({
59
+ capability,
60
+ pluginId: provider.pluginId,
61
+ enabled: provider.enabled,
62
+ }));
63
+ }
64
+ }
65
+
66
+ export { CapabilityNotAvailable, CapabilityRegistry };
@@ -0,0 +1,111 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { discoverPackageDirectories } from '../discovery.js';
4
+ import { loadModule } from '../module-loader.js';
5
+ import { validateManifest } from './manifest.js';
6
+
7
+ async function exists(filePath) {
8
+ try {
9
+ await fs.access(filePath);
10
+ return true;
11
+ } catch (err) {
12
+ if (err.code === 'ENOENT') return false;
13
+ throw err;
14
+ }
15
+ }
16
+
17
+ async function readJson(filePath, label) {
18
+ try {
19
+ return JSON.parse(await fs.readFile(filePath, 'utf8'));
20
+ } catch (err) {
21
+ throw new Error(`Failed to read ${label} at ${filePath}: ${err}`);
22
+ }
23
+ }
24
+
25
+ function getManifest(module, manifestPath) {
26
+ const manifest = module.manifest ?? module.default?.manifest ?? module.default;
27
+ if (!manifest || typeof manifest !== 'object') {
28
+ throw new Error(`Plugin manifest at ${manifestPath} must export a manifest object`);
29
+ }
30
+ return manifest;
31
+ }
32
+
33
+ async function loadPluginPackage(packageDir) {
34
+ const packageJsonPath = path.join(packageDir, 'package.json');
35
+ if (!(await exists(packageJsonPath))) {
36
+ throw new Error(`Plugin package at ${packageDir} must include package.json`);
37
+ }
38
+ const packageJson = await readJson(packageJsonPath, 'package.json');
39
+
40
+ const pluginJs = path.join(packageDir, 'plugin.js');
41
+ const pluginJson = path.join(packageDir, 'plugin.json');
42
+ let manifestPath;
43
+ let module = {};
44
+ let rawManifest;
45
+
46
+ if (await exists(pluginJs)) {
47
+ manifestPath = pluginJs;
48
+ module = await loadModule(pluginJs, import.meta.url);
49
+ rawManifest = getManifest(module, pluginJs);
50
+ } else if (await exists(pluginJson)) {
51
+ manifestPath = pluginJson;
52
+ rawManifest = await readJson(pluginJson, 'plugin.json');
53
+ } else {
54
+ throw new Error(`Plugin package at ${packageDir} must include plugin.js or plugin.json`);
55
+ }
56
+
57
+ const manifest = validateManifest(
58
+ {
59
+ version: packageJson.version,
60
+ name: packageJson.name,
61
+ ...rawManifest,
62
+ },
63
+ { packageName: packageJson.name },
64
+ );
65
+
66
+ const routesDir = path.join(packageDir, 'routes');
67
+ const jobsDir = path.join(packageDir, 'jobs');
68
+ const migrationsDir =
69
+ typeof manifest.contributes.migrations === 'string'
70
+ ? path.join(packageDir, manifest.contributes.migrations)
71
+ : path.join(packageDir, 'migrations');
72
+
73
+ return {
74
+ id: manifest.id,
75
+ dir: packageDir,
76
+ packageJson,
77
+ manifest,
78
+ module,
79
+ manifestPath,
80
+ routesDir: (await exists(routesDir)) ? routesDir : null,
81
+ jobsDir: (await exists(jobsDir)) ? jobsDir : null,
82
+ migrationsDir: (await exists(migrationsDir)) ? migrationsDir : null,
83
+ };
84
+ }
85
+
86
+ async function discoverPlugins(dirs) {
87
+ const plugins = [];
88
+ const seen = new Map();
89
+
90
+ for (const dir of dirs ?? []) {
91
+ const packages = await discoverPackageDirectories(dir, {
92
+ markerFiles: ['plugin.js', 'plugin.json'],
93
+ label: 'plugin',
94
+ });
95
+ for (const entry of packages) {
96
+ const plugin = await loadPluginPackage(entry.dir);
97
+ if (seen.has(plugin.id)) {
98
+ throw new Error(
99
+ `Duplicate plugin id ${plugin.id} in ${seen.get(plugin.id)} and ${plugin.dir}`,
100
+ );
101
+ }
102
+ seen.set(plugin.id, plugin.dir);
103
+ plugins.push(plugin);
104
+ }
105
+ }
106
+
107
+ plugins.sort((a, b) => a.id.localeCompare(b.id));
108
+ return plugins;
109
+ }
110
+
111
+ export { discoverPlugins, loadPluginPackage };
@@ -0,0 +1,114 @@
1
+ import { requiredCapabilities } from './manifest.js';
2
+
3
+ function buildProviderMap(plugins) {
4
+ const providers = new Map();
5
+ for (const plugin of plugins) {
6
+ for (const capability of plugin.manifest.provides) {
7
+ if (providers.has(capability)) {
8
+ throw new Error(
9
+ `Capability ${capability} is provided by both ${providers.get(capability).id} and ${plugin.id}`,
10
+ );
11
+ }
12
+ providers.set(capability, plugin);
13
+ }
14
+ }
15
+ return providers;
16
+ }
17
+
18
+ function resolvePluginGraph(plugins, enabledIds) {
19
+ const allProviders = buildProviderMap(plugins);
20
+ const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
21
+ const enabled = new Set(enabledIds);
22
+ const missing = [];
23
+ const edges = new Map();
24
+
25
+ for (const plugin of plugins) {
26
+ edges.set(plugin.id, new Set());
27
+ if (!enabled.has(plugin.id)) continue;
28
+ for (const capability of requiredCapabilities(plugin.manifest.requires)) {
29
+ const provider = allProviders.get(capability);
30
+ if (!provider) {
31
+ missing.push({ pluginId: plugin.id, capability, reason: 'missing provider' });
32
+ continue;
33
+ }
34
+ if (!enabled.has(provider.id)) {
35
+ missing.push({
36
+ pluginId: plugin.id,
37
+ capability,
38
+ providerId: provider.id,
39
+ reason: 'provider disabled',
40
+ });
41
+ continue;
42
+ }
43
+ if (provider.id !== plugin.id) edges.get(provider.id).add(plugin.id);
44
+ }
45
+ }
46
+
47
+ if (missing.length > 0) {
48
+ const first = missing[0];
49
+ const provider = first.providerId ? ` from ${first.providerId}` : '';
50
+ throw new Error(
51
+ `Plugin ${first.pluginId} requires capability ${first.capability}${provider}: ${first.reason}`,
52
+ );
53
+ }
54
+
55
+ const indegree = new Map([...enabled].map((id) => [id, 0]));
56
+ for (const [providerId, dependents] of edges) {
57
+ if (!enabled.has(providerId)) continue;
58
+ for (const dependentId of dependents) {
59
+ if (enabled.has(dependentId)) indegree.set(dependentId, indegree.get(dependentId) + 1);
60
+ }
61
+ }
62
+
63
+ const ready = [...indegree.entries()]
64
+ .filter(([, degree]) => degree === 0)
65
+ .map(([id]) => id)
66
+ .sort();
67
+ const orderedIds = [];
68
+
69
+ while (ready.length > 0) {
70
+ const id = ready.shift();
71
+ orderedIds.push(id);
72
+ for (const dependentId of [...(edges.get(id) ?? [])].sort()) {
73
+ if (!enabled.has(dependentId)) continue;
74
+ indegree.set(dependentId, indegree.get(dependentId) - 1);
75
+ if (indegree.get(dependentId) === 0) ready.push(dependentId);
76
+ }
77
+ ready.sort();
78
+ }
79
+
80
+ if (orderedIds.length !== enabled.size) {
81
+ throw new Error('Plugin capability graph contains a cycle');
82
+ }
83
+
84
+ return {
85
+ providers: allProviders,
86
+ order: orderedIds.map((id) => byId.get(id)),
87
+ edges,
88
+ };
89
+ }
90
+
91
+ function cascadeDisabled(plugins, disabledIds) {
92
+ const providers = buildProviderMap(plugins);
93
+ const disabled = new Map([...disabledIds].map((id) => [id, 'disabled']));
94
+ let changed = true;
95
+
96
+ while (changed) {
97
+ changed = false;
98
+ for (const plugin of plugins) {
99
+ if (disabled.has(plugin.id)) continue;
100
+ for (const capability of requiredCapabilities(plugin.manifest.requires)) {
101
+ const provider = providers.get(capability);
102
+ if (!provider || disabled.has(provider.id)) {
103
+ disabled.set(plugin.id, `disabled (missing dependency: ${capability})`);
104
+ changed = true;
105
+ break;
106
+ }
107
+ }
108
+ }
109
+ }
110
+
111
+ return disabled;
112
+ }
113
+
114
+ export { buildProviderMap, cascadeDisabled, resolvePluginGraph };
@@ -0,0 +1,210 @@
1
+ import { discoverRoutes } from '../router/routes.js';
2
+ import { discoverJobs } from '../jobs/runner.js';
3
+ import { CapabilityRegistry } from './capabilities.js';
4
+ import { discoverPlugins } from './discovery.js';
5
+ import { buildProviderMap, cascadeDisabled, resolvePluginGraph } from './graph.js';
6
+ import { requiredCapabilities } from './manifest.js';
7
+
8
+ function configuredEnablements(config) {
9
+ const enablements = config?.enablements ?? config?.enabledPlugins ?? {};
10
+ return enablements && typeof enablements === 'object' ? enablements : {};
11
+ }
12
+
13
+ function pluginEnabledByDefault(plugin, enablements) {
14
+ if (Object.prototype.hasOwnProperty.call(enablements, plugin.id)) {
15
+ return enablements[plugin.id] === true;
16
+ }
17
+ return plugin.manifest.defaultEnabled === true;
18
+ }
19
+
20
+ function capabilityFactory(plugin, capability) {
21
+ return (
22
+ plugin.module.capabilities?.[capability] ??
23
+ plugin.module.default?.capabilities?.[capability] ??
24
+ plugin.module[`create${capability[0]?.toUpperCase() ?? ''}${capability.slice(1)}`]
25
+ );
26
+ }
27
+
28
+ class PluginManager {
29
+ constructor(config, { log, runLifecycle = true } = {}) {
30
+ this.config = config ?? { enabled: false, dirs: [] };
31
+ this.log = log;
32
+ this.runLifecycle = runLifecycle;
33
+ this.plugins = [];
34
+ this.enabled = new Set();
35
+ this.status = new Map();
36
+ this.loadOrder = [];
37
+ this.capabilities = new CapabilityRegistry();
38
+ }
39
+
40
+ async init() {
41
+ if (this.config.enabled !== true) return this;
42
+
43
+ this.plugins = await discoverPlugins(this.config.dirs);
44
+ const enablements = configuredEnablements(this.config);
45
+ this.enabled = new Set(
46
+ this.plugins
47
+ .filter((plugin) => pluginEnabledByDefault(plugin, enablements))
48
+ .map((plugin) => plugin.id),
49
+ );
50
+
51
+ this.#rebuildGraph();
52
+
53
+ for (const plugin of this.loadOrder) {
54
+ if (this.runLifecycle) {
55
+ await this.#runLifecycle(plugin, 'onLoad');
56
+ await this.#runLifecycle(plugin, 'onEnable');
57
+ }
58
+ this.log?.info?.(`Plugin enabled: ${plugin.id}`);
59
+ }
60
+ return this;
61
+ }
62
+
63
+ get enabledPlugins() {
64
+ return this.loadOrder;
65
+ }
66
+
67
+ getPlugin(id) {
68
+ return this.plugins.find((plugin) => plugin.id === id) ?? null;
69
+ }
70
+
71
+ async enable(id) {
72
+ const plugin = this.getPlugin(id);
73
+ if (!plugin) throw new Error(`Unknown plugin: ${id}`);
74
+ this.enabled.add(id);
75
+ this.#rebuildGraph();
76
+ if (this.runLifecycle) await this.#runLifecycle(plugin, 'onEnable');
77
+ }
78
+
79
+ async runEnabledLifecycle() {
80
+ for (const plugin of this.loadOrder) {
81
+ await this.#runLifecycle(plugin, 'onLoad');
82
+ await this.#runLifecycle(plugin, 'onEnable');
83
+ }
84
+ this.runLifecycle = true;
85
+ }
86
+
87
+ async disable(id) {
88
+ const plugin = this.getPlugin(id);
89
+ if (!plugin) throw new Error(`Unknown plugin: ${id}`);
90
+ this.enabled.delete(id);
91
+ if (this.runLifecycle) await this.#runLifecycle(plugin, 'onDisable');
92
+ this.#rebuildGraph();
93
+ }
94
+
95
+ injectCapabilities(ctx, requires) {
96
+ return this.capabilities.inject(ctx, requires);
97
+ }
98
+
99
+ async discoverRoutes() {
100
+ const routes = [];
101
+ for (const plugin of this.enabledPlugins) {
102
+ if (!plugin.routesDir) continue;
103
+ const discovered = await discoverRoutes(plugin.routesDir, {
104
+ prefix: `/plugins/${plugin.id}`,
105
+ plugin: {
106
+ id: plugin.id,
107
+ requires: plugin.manifest.requires,
108
+ },
109
+ });
110
+ routes.push(...discovered);
111
+ }
112
+ return routes;
113
+ }
114
+
115
+ async discoverJobs() {
116
+ const jobs = [];
117
+ for (const plugin of this.enabledPlugins) {
118
+ if (!plugin.jobsDir) continue;
119
+ const discovered = await discoverJobs(plugin.jobsDir);
120
+ for (const job of discovered) {
121
+ const definition = {
122
+ ...job.definition,
123
+ name: `plugins/${plugin.id}/${job.definition.name}`,
124
+ plugin: {
125
+ id: plugin.id,
126
+ requires: plugin.manifest.requires,
127
+ },
128
+ };
129
+ jobs.push({ ...job, definition, filePath: null });
130
+ }
131
+ }
132
+ return jobs;
133
+ }
134
+
135
+ migrations() {
136
+ return this.enabledPlugins
137
+ .filter((plugin) => plugin.migrationsDir)
138
+ .map((plugin) => ({
139
+ pluginId: plugin.id,
140
+ dir: plugin.migrationsDir,
141
+ contributes: plugin.manifest.contributes.migrations,
142
+ }));
143
+ }
144
+
145
+ #rebuildGraph() {
146
+ const providers = buildProviderMap(this.plugins);
147
+ for (const plugin of this.plugins) {
148
+ if (!this.enabled.has(plugin.id)) continue;
149
+ for (const capability of requiredCapabilities(plugin.manifest.requires)) {
150
+ if (!providers.has(capability)) {
151
+ throw new Error(
152
+ `Plugin ${plugin.id} requires capability ${capability}: missing provider`,
153
+ );
154
+ }
155
+ }
156
+ }
157
+
158
+ const disabled = cascadeDisabled(
159
+ this.plugins,
160
+ this.plugins.filter((plugin) => !this.enabled.has(plugin.id)).map((plugin) => plugin.id),
161
+ );
162
+ this.status = new Map();
163
+ for (const plugin of this.plugins) {
164
+ this.status.set(plugin.id, disabled.get(plugin.id) ?? 'enabled');
165
+ }
166
+
167
+ const effectiveEnabled = new Set(
168
+ this.plugins
169
+ .filter((plugin) => this.status.get(plugin.id) === 'enabled')
170
+ .map((plugin) => plugin.id),
171
+ );
172
+ const graph = resolvePluginGraph(this.plugins, effectiveEnabled);
173
+ this.loadOrder = graph.order;
174
+
175
+ this.capabilities = new CapabilityRegistry();
176
+ for (const plugin of this.loadOrder) {
177
+ for (const capability of plugin.manifest.provides) {
178
+ const factory = capabilityFactory(plugin, capability);
179
+ if (!factory) {
180
+ throw new Error(
181
+ `Plugin ${plugin.id} provides ${capability} but does not export a factory`,
182
+ );
183
+ }
184
+ this.capabilities.register(plugin.id, capability, factory);
185
+ }
186
+ }
187
+ for (const plugin of this.plugins) {
188
+ this.capabilities.setPluginEnabled(plugin.id, this.status.get(plugin.id) === 'enabled');
189
+ }
190
+ }
191
+
192
+ async #runLifecycle(plugin, name) {
193
+ const hook = plugin.manifest.lifecycle?.[name];
194
+ if (!hook) return;
195
+ const fn =
196
+ typeof hook === 'function' ? hook : (plugin.module[hook] ?? plugin.module.default?.[hook]);
197
+ if (typeof fn !== 'function') {
198
+ throw new Error(`Plugin ${plugin.id} lifecycle.${name} does not resolve to a function`);
199
+ }
200
+ await fn({ plugin, manager: this });
201
+ }
202
+ }
203
+
204
+ async function createPluginManager(config, options) {
205
+ const manager = new PluginManager(config, options);
206
+ await manager.init();
207
+ return manager;
208
+ }
209
+
210
+ export { PluginManager, createPluginManager };