arcway 0.2.0 → 0.3.0

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.
@@ -4,6 +4,8 @@ import { serializeError } from './worker-pool.js';
4
4
  import { resolveWorkerConfig } from './worker-config.js';
5
5
  import { buildContext } from '../context.js';
6
6
  import { createInfrastructure, destroyInfrastructure } from '../boot/infrastructure.js';
7
+ import { CallbackRegistry } from '../callbacks/index.js';
8
+ import { CallbackStore } from '../callbacks/store.js';
7
9
 
8
10
  // Worker-side entry for the jobs WorkerPool. Each worker owns its own DB /
9
11
  // redis / queue / cache / files / mail / events / log — built lazily on the
@@ -70,8 +72,14 @@ async function runTask(task) {
70
72
  // and any future cpu-only caller) gets the raw payload — no DB spin-up.
71
73
  if (withContext) {
72
74
  const services = await ensureInfrastructure();
75
+ const callbackStore = new CallbackStore(services.db);
76
+ await callbackStore.init();
77
+ const callbacks = new CallbackRegistry({
78
+ store: callbackStore,
79
+ secret: resolvedConfig?.vault?.values?.callback,
80
+ });
73
81
  const ctx = buildContext(
74
- { ...services, meta: workerData?.meta, vault: resolvedConfig?.vault },
82
+ { ...services, meta: workerData?.meta, vault: resolvedConfig?.vault, callbacks },
75
83
  { payload },
76
84
  );
77
85
  return fn(ctx);
@@ -1,19 +1,26 @@
1
- import { SignJWT, jwtVerify, errors } from 'jose';
1
+ import { SignJWT, jwtVerify } from 'jose';
2
+
2
3
  function secretToKey(secret) {
3
4
  return new TextEncoder().encode(secret);
4
5
  }
6
+
5
7
  function parseExpiration(expiresIn) {
6
8
  if (typeof expiresIn === 'number') {
7
9
  return `${expiresIn}s`;
8
10
  }
9
11
  return expiresIn;
10
12
  }
11
- async function jwtEncode(payload, secret, options) {
13
+
14
+ function requireJwtSecret(secret) {
12
15
  if (!secret) {
13
- throw new Error('JWT secret is required');
16
+ throw new Error('JWT key is required');
14
17
  }
18
+ return secret;
19
+ }
20
+
21
+ async function encodeWithSecret(payload, secret, options) {
15
22
  const alg = options?.algorithm ?? 'HS256';
16
- const key = secretToKey(secret);
23
+ const key = secretToKey(requireJwtSecret(secret));
17
24
  let builder = new SignJWT(payload).setProtectedHeader({ alg }).setIssuedAt();
18
25
  if (options?.expiresIn !== void 0) {
19
26
  builder = builder.setExpirationTime(parseExpiration(options.expiresIn));
@@ -26,11 +33,9 @@ async function jwtEncode(payload, secret, options) {
26
33
  }
27
34
  return builder.sign(key);
28
35
  }
29
- async function jwtDecode(token, secret, options) {
30
- if (!secret) {
31
- throw new Error('JWT secret is required');
32
- }
33
- const key = secretToKey(secret);
36
+
37
+ async function decodeWithSecret(token, secret, options) {
38
+ const key = secretToKey(requireJwtSecret(secret));
34
39
  const algorithms = options?.algorithms ?? ['HS256'];
35
40
  try {
36
41
  const { payload } = await jwtVerify(token, key, {
@@ -39,17 +44,25 @@ async function jwtDecode(token, secret, options) {
39
44
  audience: options?.audience,
40
45
  });
41
46
  return payload;
42
- } catch (err) {
43
- if (err instanceof errors.JWTExpired) {
44
- throw new Error('JWT token has expired');
45
- }
46
- if (err instanceof errors.JWSSignatureVerificationFailed) {
47
- throw new Error('JWT signature verification failed');
48
- }
49
- if (err instanceof errors.JWTClaimValidationFailed) {
50
- throw new Error(`JWT claim validation failed: ${err.message}`);
51
- }
52
- throw new Error(`JWT verification failed: ${err.message}`);
47
+ } catch {
48
+ throw new Error('JWT verification failed');
53
49
  }
54
50
  }
55
- export { jwtDecode, jwtEncode };
51
+
52
+ function createJwtCodec(secret) {
53
+ const jwtSecret = requireJwtSecret(secret);
54
+ return {
55
+ jwtEncode: (payload, options) => encodeWithSecret(payload, jwtSecret, options),
56
+ jwtDecode: (token, options) => decodeWithSecret(token, jwtSecret, options),
57
+ };
58
+ }
59
+
60
+ async function jwtEncode(_payload, _options) {
61
+ throw new Error('JWT key is required; use ctx.vault.jwtEncode() with configured vault');
62
+ }
63
+
64
+ async function jwtDecode(_token, _options) {
65
+ throw new Error('JWT key is required; use ctx.vault.jwtDecode() with configured vault');
66
+ }
67
+
68
+ export { createJwtCodec, jwtDecode, jwtEncode };
@@ -5,7 +5,7 @@ const MASTER_BYTES = 32;
5
5
  const VAULT_KEY_BYTES = 32;
6
6
  const HKDF_SALT = 'arcway:infra-secrets:v1';
7
7
 
8
- const SECRET_NAMES = Object.freeze(['session', 'plugin-vault', 'mcp-api']);
8
+ const SECRET_NAMES = Object.freeze(['session', 'plugin-vault', 'mcp-api', 'jwt', 'callback']);
9
9
 
10
10
  function base64Url(buffer) {
11
11
  return Buffer.from(buffer).toString('base64url');
@@ -102,6 +102,8 @@ function deriveInfraSecret(master, name) {
102
102
  if (name === 'session') return base64Url(deriveBytes(master, name, 48));
103
103
  if (name === 'plugin-vault') return formatVaultKey(deriveBytes(master, name, VAULT_KEY_BYTES));
104
104
  if (name === 'mcp-api') return base64Url(deriveBytes(master, name, 48));
105
+ if (name === 'jwt') return base64Url(deriveBytes(master, name, 48));
106
+ if (name === 'callback') return base64Url(deriveBytes(master, name, 48));
105
107
  throw new Error(`Unknown Arcway infra secret namespace: ${name}`);
106
108
  }
107
109
 
@@ -0,0 +1,122 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { build } from 'esbuild';
5
+ import { discoverFiles } from '../discovery.js';
6
+ import { loadModule } from '../module-loader.js';
7
+
8
+ let bundleCounter = 0;
9
+
10
+ async function exists(filePath) {
11
+ try {
12
+ await fs.access(filePath);
13
+ return true;
14
+ } catch (err) {
15
+ if (err.code === 'ENOENT') return false;
16
+ throw err;
17
+ }
18
+ }
19
+
20
+ function importLine(name, filePath, namespace = true) {
21
+ const imported = namespace ? `* as ${name}` : name;
22
+ return `import ${imported} from ${JSON.stringify(filePath)};`;
23
+ }
24
+
25
+ function moduleEntry(name, entry) {
26
+ return `{ name: ${JSON.stringify(entry.name)}, filePath: ${JSON.stringify(entry.filePath)}, relativePath: ${JSON.stringify(entry.relativePath)}, module: ${name} }`;
27
+ }
28
+
29
+ async function loadPluginBundle(packageDir) {
30
+ const bundleDir = path.join(packageDir, '.arcway', 'plugin-bundles');
31
+ await fs.mkdir(bundleDir, { recursive: true });
32
+
33
+ const pluginJs = path.join(packageDir, 'plugin.js');
34
+ const exportsJs = path.join(packageDir, 'exports.js');
35
+ const jobsDir = path.join(packageDir, 'jobs');
36
+ const callbacksDir = path.join(packageDir, 'callbacks');
37
+ const capabilitiesDir = path.join(packageDir, 'capabilities');
38
+ const hooksDir = path.join(packageDir, 'hooks');
39
+
40
+ const hasPluginJs = await exists(pluginJs);
41
+ const hasExportsJs = await exists(exportsJs);
42
+ const jobs = await discoverFiles(jobsDir, { recursive: true });
43
+ const callbacks = await discoverFiles(callbacksDir, { recursive: true });
44
+ const capabilities = await discoverFiles(capabilitiesDir);
45
+ const hooks = await discoverFiles(hooksDir);
46
+
47
+ const lines = [];
48
+ if (hasPluginJs) lines.push(importLine('pluginModule', pluginJs));
49
+ if (hasExportsJs) lines.push(importLine('pluginExports', exportsJs, false));
50
+
51
+ for (const [index, entry] of jobs.entries()) {
52
+ lines.push(importLine(`job${index}`, entry.filePath));
53
+ }
54
+ for (const [index, entry] of callbacks.entries()) {
55
+ lines.push(importLine(`callback${index}`, entry.filePath));
56
+ }
57
+ for (const [index, entry] of capabilities.entries()) {
58
+ lines.push(importLine(`capability${index}`, entry.filePath, false));
59
+ }
60
+ for (const [index, entry] of hooks.entries()) {
61
+ lines.push(importLine(`hook${index}`, entry.filePath, false));
62
+ }
63
+
64
+ lines.push(`export const module = ${hasPluginJs ? 'pluginModule' : '{}'};`);
65
+ lines.push(
66
+ `export const exportsModule = ${hasExportsJs ? '{ default: pluginExports }' : 'null'};`,
67
+ );
68
+ lines.push(
69
+ `export const jobs = [${jobs.map((entry, index) => moduleEntry(`job${index}`, entry)).join(',')}];`,
70
+ );
71
+ lines.push(
72
+ `export const callbacks = [${callbacks.map((entry, index) => moduleEntry(`callback${index}`, entry)).join(',')}];`,
73
+ );
74
+ lines.push(
75
+ `export const capabilities = {${capabilities
76
+ .map((entry, index) => {
77
+ const name = path.basename(entry.name, path.extname(entry.name));
78
+ return `${JSON.stringify(name)}: capability${index}`;
79
+ })
80
+ .join(',')}};`,
81
+ );
82
+ lines.push(
83
+ `export const hooks = {${hooks
84
+ .map((entry, index) => {
85
+ const name = path.basename(entry.name, path.extname(entry.name));
86
+ return `${JSON.stringify(name)}: hook${index}`;
87
+ })
88
+ .join(',')}};`,
89
+ );
90
+
91
+ const counter = ++bundleCounter;
92
+ const entryPath = path.join(bundleDir, `entry-${counter}.mjs`);
93
+ const outFile = path.join(bundleDir, `bundle-${counter}.mjs`);
94
+ await fs.writeFile(entryPath, lines.join('\n'));
95
+
96
+ await build({
97
+ entryPoints: [entryPath],
98
+ outfile: outFile,
99
+ bundle: true,
100
+ format: 'esm',
101
+ platform: 'node',
102
+ target: 'node20',
103
+ logLevel: 'silent',
104
+ });
105
+
106
+ const bundled = await loadModule(outFile);
107
+ return {
108
+ module: bundled.module ?? {},
109
+ exportsModule: bundled.exportsModule ?? null,
110
+ jobs: bundled.jobs ?? [],
111
+ callbacks: bundled.callbacks ?? [],
112
+ capabilities: bundled.capabilities ?? {},
113
+ hooks: bundled.hooks ?? {},
114
+ artifact: outFile,
115
+ };
116
+ }
117
+
118
+ function fileUrl(filePath) {
119
+ return pathToFileURL(filePath).href;
120
+ }
121
+
122
+ export { fileUrl, loadPluginBundle };
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { discoverModules, discoverPackageDirectories } from '../discovery.js';
4
4
  import { loadModule } from '../module-loader.js';
5
5
  import { validateManifest } from './manifest.js';
6
+ import { loadPluginBundle } from './bundle.js';
6
7
 
7
8
  async function exists(filePath) {
8
9
  try {
@@ -60,7 +61,7 @@ async function loadConventionModules(dir, label) {
60
61
  return map;
61
62
  }
62
63
 
63
- async function loadPluginPackage(packageDir) {
64
+ async function loadPluginPackage(packageDir, { bundle = false } = {}) {
64
65
  const packageJsonPath = path.join(packageDir, 'package.json');
65
66
  if (!(await exists(packageJsonPath))) {
66
67
  throw new Error(`Plugin package at ${packageDir} must include package.json`);
@@ -72,18 +73,24 @@ async function loadPluginPackage(packageDir) {
72
73
  const exportsJs = path.join(packageDir, 'exports.js');
73
74
  let manifestPath;
74
75
  let module = {};
76
+ let bundled = null;
75
77
  let rawManifest;
76
78
 
77
79
  if (await exists(pluginJs)) {
78
80
  manifestPath = pluginJs;
79
- module = await loadModule(pluginJs, import.meta.url);
81
+ if (bundle) {
82
+ bundled = await loadPluginBundle(packageDir);
83
+ module = bundled.module;
84
+ } else {
85
+ module = await loadModule(pluginJs, import.meta.url);
86
+ }
80
87
  rawManifest = getManifest(module, pluginJs);
81
88
  } else if (await exists(pluginJson)) {
82
89
  manifestPath = pluginJson;
83
90
  rawManifest = await readJson(pluginJson, 'plugin.json');
84
91
  } else if (packageJson.arcway && typeof packageJson.arcway === 'object') {
85
92
  // Metadata-only plugin: the manifest lives in package.json's "arcway" block,
86
- // and code is discovered by folder convention (routes/, jobs/, capabilities/, hooks/).
93
+ // and code is discovered by folder convention (jobs/, callbacks/, capabilities/, hooks/).
87
94
  manifestPath = packageJsonPath;
88
95
  rawManifest = {};
89
96
  } else {
@@ -104,11 +111,14 @@ async function loadPluginPackage(packageDir) {
104
111
  { packageName: packageJson.name },
105
112
  );
106
113
 
107
- const routesDir = path.join(packageDir, 'routes');
108
114
  const jobsDir = path.join(packageDir, 'jobs');
109
- const exportsModule = (await exists(exportsJs))
110
- ? await loadModule(exportsJs, import.meta.url)
111
- : null;
115
+ const callbacksDir = path.join(packageDir, 'callbacks');
116
+ if (bundle && !bundled) bundled = await loadPluginBundle(packageDir);
117
+ const exportsModule = bundled
118
+ ? bundled.exportsModule
119
+ : (await exists(exportsJs))
120
+ ? await loadModule(exportsJs, import.meta.url)
121
+ : null;
112
122
 
113
123
  return {
114
124
  id: manifest.id,
@@ -116,16 +126,23 @@ async function loadPluginPackage(packageDir) {
116
126
  packageJson,
117
127
  manifest,
118
128
  module,
129
+ bundle: bundled ? { artifact: bundled.artifact } : null,
119
130
  manifestPath,
120
131
  exports: exportsModule ? getExports(exportsModule, exportsJs) : {},
121
- routesDir: (await exists(routesDir)) ? routesDir : null,
122
132
  jobsDir: (await exists(jobsDir)) ? jobsDir : null,
123
- capabilities: await loadConventionModules(path.join(packageDir, 'capabilities'), 'capability'),
124
- hooks: await loadConventionModules(path.join(packageDir, 'hooks'), 'lifecycle hook'),
133
+ callbacksDir: (await exists(callbacksDir)) ? callbacksDir : null,
134
+ bundledJobs: bundled?.jobs ?? null,
135
+ bundledCallbacks: bundled?.callbacks ?? null,
136
+ capabilities:
137
+ bundled?.capabilities ??
138
+ (await loadConventionModules(path.join(packageDir, 'capabilities'), 'capability')),
139
+ hooks:
140
+ bundled?.hooks ??
141
+ (await loadConventionModules(path.join(packageDir, 'hooks'), 'lifecycle hook')),
125
142
  };
126
143
  }
127
144
 
128
- async function discoverPlugins(dirs) {
145
+ async function discoverPlugins(dirs, options = {}) {
129
146
  const plugins = [];
130
147
  const seen = new Map();
131
148
 
@@ -135,7 +152,7 @@ async function discoverPlugins(dirs) {
135
152
  label: 'plugin',
136
153
  });
137
154
  for (const entry of packages) {
138
- const plugin = await loadPluginPackage(entry.dir);
155
+ const plugin = await loadPluginPackage(entry.dir, options);
139
156
  if (!plugin) continue;
140
157
  if (seen.has(plugin.id)) {
141
158
  throw new Error(
@@ -18,29 +18,43 @@ function buildProviderMap(plugins) {
18
18
  function resolvePluginGraph(plugins, enabledIds) {
19
19
  const allProviders = buildProviderMap(plugins);
20
20
  const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
21
- const enabled = new Set(enabledIds);
21
+ const enabled = new Set([...enabledIds].map((id) => String(id)));
22
22
  const missing = [];
23
23
  const edges = new Map();
24
24
 
25
25
  for (const plugin of plugins) {
26
- edges.set(plugin.id, new Set());
27
- if (!enabled.has(plugin.id)) continue;
26
+ const pluginId = String(plugin.id);
27
+ edges.set(pluginId, new Set());
28
+ }
29
+
30
+ for (const plugin of plugins) {
31
+ const pluginId = String(plugin.id);
32
+ if (!enabled.has(pluginId)) continue;
28
33
  for (const capability of requiredCapabilities(plugin.manifest.requires)) {
29
34
  const provider = allProviders.get(capability);
30
35
  if (!provider) {
31
- missing.push({ pluginId: plugin.id, capability, reason: 'missing provider' });
36
+ missing.push({ pluginId, capability, reason: 'missing provider' });
32
37
  continue;
33
38
  }
34
- if (!enabled.has(provider.id)) {
39
+ const providerId = String(provider.id);
40
+ if (!enabled.has(providerId)) {
35
41
  missing.push({
36
- pluginId: plugin.id,
42
+ pluginId,
37
43
  capability,
38
- providerId: provider.id,
44
+ providerId,
39
45
  reason: 'provider disabled',
40
46
  });
41
47
  continue;
42
48
  }
43
- if (provider.id !== plugin.id) edges.get(provider.id).add(plugin.id);
49
+ if (providerId !== pluginId) {
50
+ const providerEdges = edges.get(providerId);
51
+ if (!providerEdges) {
52
+ throw new Error(
53
+ `Plugin ${pluginId} requires capability ${capability} from unknown provider ${providerId}`,
54
+ );
55
+ }
56
+ providerEdges.add(pluginId);
57
+ }
44
58
  }
45
59
  }
46
60
 
@@ -1,5 +1,6 @@
1
- import { discoverRoutes } from '../router/routes.js';
2
- import { discoverJobs } from '../jobs/runner.js';
1
+ import path from 'node:path';
2
+ import { buildJobsFromModules, discoverJobs } from '../jobs/runner.js';
3
+ import { buildCallbacksFromModules, discoverCallbacks } from '../callbacks/discovery.js';
3
4
  import { CapabilityRegistry } from './capabilities.js';
4
5
  import { discoverPlugins } from './discovery.js';
5
6
  import { buildProviderMap, cascadeDisabled, resolvePluginGraph } from './graph.js';
@@ -26,6 +27,29 @@ function capabilityFactory(plugin, capability) {
26
27
  );
27
28
  }
28
29
 
30
+ function collectDependentIds(plugins, ids) {
31
+ const affected = new Set(ids);
32
+ const providers = buildProviderMap(plugins);
33
+ let changed = true;
34
+
35
+ while (changed) {
36
+ changed = false;
37
+ for (const plugin of plugins) {
38
+ if (affected.has(plugin.id)) continue;
39
+ for (const capability of requiredCapabilities(plugin.manifest.requires)) {
40
+ const provider = providers.get(capability);
41
+ if (provider && affected.has(provider.id)) {
42
+ affected.add(plugin.id);
43
+ changed = true;
44
+ break;
45
+ }
46
+ }
47
+ }
48
+ }
49
+
50
+ return affected;
51
+ }
52
+
29
53
  function bindPluginExports(plugin, ctx) {
30
54
  return new Proxy(
31
55
  {},
@@ -56,10 +80,11 @@ function bindPluginExports(plugin, ctx) {
56
80
  }
57
81
 
58
82
  class PluginManager {
59
- constructor(config, { log, runLifecycle = true } = {}) {
83
+ constructor(config, { log, runLifecycle = true, bundle = false } = {}) {
60
84
  this.config = config ?? { enabled: false, dirs: [] };
61
85
  this.log = log;
62
86
  this.runLifecycle = runLifecycle;
87
+ this.bundle = bundle;
63
88
  this.plugins = [];
64
89
  this.enabled = new Set();
65
90
  this.status = new Map();
@@ -70,7 +95,7 @@ class PluginManager {
70
95
  async init() {
71
96
  if (this.config.enabled !== true) return this;
72
97
 
73
- this.plugins = await discoverPlugins(this.config.dirs);
98
+ this.plugins = await discoverPlugins(this.config.dirs, { bundle: this.bundle });
74
99
  const enablements = configuredEnablements(this.config);
75
100
  this.enabled = new Set(
76
101
  this.plugins
@@ -141,27 +166,14 @@ class PluginManager {
141
166
  };
142
167
  }
143
168
 
144
- async discoverRoutes() {
145
- const routes = [];
146
- for (const plugin of this.enabledPlugins) {
147
- if (!plugin.routesDir) continue;
148
- const discovered = await discoverRoutes(plugin.routesDir, {
149
- prefix: `/_plugins/${plugin.id}`,
150
- plugin: {
151
- id: plugin.id,
152
- requires: plugin.manifest.requires,
153
- },
154
- });
155
- routes.push(...discovered);
156
- }
157
- return routes;
158
- }
159
-
160
169
  async discoverJobs() {
161
170
  const jobs = [];
162
171
  for (const plugin of this.enabledPlugins) {
163
- if (!plugin.jobsDir) continue;
164
- const discovered = await discoverJobs(plugin.jobsDir);
172
+ const discovered = plugin.bundledJobs
173
+ ? buildJobsFromModules(plugin.bundledJobs)
174
+ : plugin.jobsDir
175
+ ? await discoverJobs(plugin.jobsDir)
176
+ : [];
165
177
  for (const job of discovered) {
166
178
  const definition = {
167
179
  ...job.definition,
@@ -177,6 +189,130 @@ class PluginManager {
177
189
  return jobs;
178
190
  }
179
191
 
192
+ async discoverCallbacks() {
193
+ const callbacks = new Map();
194
+ for (const plugin of this.enabledPlugins) {
195
+ const discovered = plugin.bundledCallbacks
196
+ ? buildCallbacksFromModules(plugin.bundledCallbacks)
197
+ : plugin.callbacksDir
198
+ ? await discoverCallbacks(plugin.callbacksDir)
199
+ : new Map();
200
+ for (const [name, callback] of discovered) {
201
+ const target = `plugins/${plugin.id}/${name}`;
202
+ callbacks.set(target, { ...callback, name: target, plugin });
203
+ }
204
+ }
205
+ return callbacks;
206
+ }
207
+
208
+ async reload(ids) {
209
+ if (this.config.enabled !== true) return { reloaded: [] };
210
+
211
+ const requested = ids ? new Set(Array.isArray(ids) ? ids : [ids]) : null;
212
+ const oldPlugins = this.plugins;
213
+ const oldEnabledIds = new Set(
214
+ oldPlugins
215
+ .filter((plugin) => this.status.get(plugin.id) === 'enabled')
216
+ .map((plugin) => plugin.id),
217
+ );
218
+ let affected = requested ? collectDependentIds(oldPlugins, requested) : new Set();
219
+
220
+ const discovered = await discoverPlugins(this.config.dirs, { bundle: this.bundle });
221
+ const discoveredIds = new Set(discovered.map((plugin) => plugin.id));
222
+ const enablements = configuredEnablements(this.config);
223
+ const nextEnabled = new Set();
224
+ for (const plugin of discovered) {
225
+ if (
226
+ this.enabled.has(plugin.id) ||
227
+ (!oldPlugins.some((old) => old.id === plugin.id) &&
228
+ pluginEnabledByDefault(plugin, enablements))
229
+ ) {
230
+ nextEnabled.add(plugin.id);
231
+ }
232
+ }
233
+
234
+ if (!requested) {
235
+ affected = new Set([...oldPlugins.map((plugin) => plugin.id), ...discoveredIds]);
236
+ } else {
237
+ for (const id of requested) {
238
+ if (!discoveredIds.has(id) && !oldPlugins.some((plugin) => plugin.id === id)) {
239
+ affected = new Set([...oldPlugins.map((plugin) => plugin.id), ...discoveredIds]);
240
+ break;
241
+ }
242
+ }
243
+ affected = collectDependentIds(discovered, affected);
244
+ }
245
+
246
+ const oldAffectedEnabled = [...this.loadOrder]
247
+ .filter((plugin) => affected.has(plugin.id) && oldEnabledIds.has(plugin.id))
248
+ .reverse();
249
+ for (const plugin of oldAffectedEnabled) {
250
+ if (this.runLifecycle) await this.#runLifecycle(plugin, 'onDisable');
251
+ }
252
+
253
+ this.plugins = discovered;
254
+ this.enabled = nextEnabled;
255
+ this.#rebuildGraph();
256
+
257
+ for (const plugin of this.loadOrder) {
258
+ if (!affected.has(plugin.id)) continue;
259
+ if (this.runLifecycle) {
260
+ await this.#runLifecycle(plugin, 'onLoad');
261
+ await this.#runLifecycle(plugin, 'onEnable');
262
+ }
263
+ this.log?.info?.(`Plugin reloaded: ${plugin.id}`);
264
+ }
265
+
266
+ return {
267
+ reloaded: [...affected].filter((id) => discoveredIds.has(id)),
268
+ removed: [...affected].filter((id) => !discoveredIds.has(id)),
269
+ };
270
+ }
271
+
272
+ pluginIdsForChangedFiles(events) {
273
+ const ids = new Set();
274
+ for (const event of events) {
275
+ const filePath = event.path;
276
+ const existing = this.plugins.find(
277
+ (plugin) => filePath === plugin.dir || filePath.startsWith(plugin.dir + path.sep),
278
+ );
279
+ if (existing) {
280
+ ids.add(existing.id);
281
+ continue;
282
+ }
283
+
284
+ const rootDir = (this.config.dirs ?? []).find((dir) => {
285
+ const relative = path.relative(dir, filePath);
286
+ return relative && !relative.startsWith('..') && !path.isAbsolute(relative);
287
+ });
288
+ if (!rootDir) continue;
289
+ const [packageDirName] = path.relative(rootDir, filePath).split(path.sep);
290
+ if (!packageDirName) continue;
291
+ if (!this.plugins.some((plugin) => plugin.id === packageDirName)) return null;
292
+ ids.add(packageDirName);
293
+ }
294
+ return ids;
295
+ }
296
+
297
+ watch(fileWatcher, { jobRunner } = {}) {
298
+ if (this.config.enabled !== true || !fileWatcher) return;
299
+ fileWatcher.subscribe('plugins', {
300
+ dirs: this.config.dirs,
301
+ extensions: ['.js', '.json'],
302
+ debounceMs: 200,
303
+ onChange: async (events) => {
304
+ const changedIds = this.pluginIdsForChangedFiles(events);
305
+ await this.reload(changedIds);
306
+ await jobRunner?.reload?.();
307
+ },
308
+ });
309
+ this.log?.info?.('Plugins: watching for changes');
310
+ }
311
+
312
+ unwatch(fileWatcher) {
313
+ fileWatcher?.unsubscribe?.('plugins');
314
+ }
315
+
180
316
  #rebuildGraph() {
181
317
  const providers = buildProviderMap(this.plugins);
182
318
  for (const plugin of this.plugins) {
@@ -39,7 +39,6 @@ class ApiRouter {
39
39
  _appContext;
40
40
  _sessionConfig;
41
41
  _redis;
42
- _pluginManager;
43
42
  _routes = [];
44
43
  _middleware = [];
45
44
  _prefix = '';
@@ -54,7 +53,6 @@ class ApiRouter {
54
53
  this._appContext = appContext ?? null;
55
54
  this._sessionConfig = sessionConfig ?? null;
56
55
  this._redis = appContext?.redis ?? null;
57
- this._pluginManager = appContext?.plugins ?? null;
58
56
  }
59
57
 
60
58
  get routes() {
@@ -103,7 +101,6 @@ class ApiRouter {
103
101
  const chainedHandler = buildMiddlewareChain(middlewareFns, route.config.handler);
104
102
  const ctx = this._buildCtx(reqInfo);
105
103
  try {
106
- if (route.plugin) this._pluginManager?.injectCapabilities(ctx, route.plugin.requires);
107
104
  return await chainedHandler(ctx);
108
105
  } catch (err) {
109
106
  this._log.error(`Handler error in ${route.method} ${route.pattern}`, {
@@ -265,7 +262,6 @@ class ApiRouter {
265
262
 
266
263
  let response;
267
264
  try {
268
- if (route.plugin) this._pluginManager?.injectCapabilities(ctx, route.plugin.requires);
269
265
  response = await chainedHandler(ctx);
270
266
  } catch (err) {
271
267
  const errorMsg = toErrorMessage(err);
@@ -347,6 +343,10 @@ class ApiRouter {
347
343
  }
348
344
  }
349
345
 
346
+ async reload() {
347
+ return this._reload();
348
+ }
349
+
350
350
  async close() {
351
351
  if (this._mode === 'development' && this._fileWatcher) {
352
352
  this._fileWatcher.unsubscribe('routes');
@@ -354,12 +354,10 @@ class ApiRouter {
354
354
  }
355
355
 
356
356
  async _discover() {
357
- const [routes, middleware, pluginRoutes] = await Promise.all([
357
+ const [routes, middleware] = await Promise.all([
358
358
  discoverRoutes(this._config.dir),
359
359
  discoverMiddleware(this._config.dir),
360
- this._pluginManager?.discoverRoutes?.() ?? [],
361
360
  ]);
362
- routes.push(...pluginRoutes);
363
361
  sortBySpecificity(routes);
364
362
 
365
363
  applyPrefix(routes, middleware, this._prefix);