arcway 0.2.0 → 0.2.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcway",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "A convention-based framework for building modular monoliths with strict domain boundaries.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -28,7 +28,10 @@ async function boot(options) {
28
28
  const envFiles = loadEnvFiles(rootDir, mode);
29
29
  const config = await makeConfig(rootDir, { overrides: options.configOverrides, mode });
30
30
 
31
- const plugins = await createPluginManager(config.plugins, { runLifecycle: false });
31
+ const plugins = await createPluginManager(config.plugins, {
32
+ runLifecycle: false,
33
+ bundle: mode === 'development',
34
+ });
32
35
 
33
36
  const infrastructure = await createInfrastructure(config, { runMigrations: true });
34
37
  const { db, redis, queue, cache, files, mail, events, log } = infrastructure;
@@ -110,6 +113,7 @@ async function boot(options) {
110
113
  sessionConfig: config.session,
111
114
  });
112
115
  await apiRouter.init();
116
+ if (fileWatcher) plugins.watch(fileWatcher, { apiRouter, jobRunner });
113
117
 
114
118
  const pagesRouter = new PagesRouter(config, { rootDir, log, mode, fileWatcher, appContext });
115
119
  await pagesRouter.init();
@@ -166,6 +170,7 @@ async function boot(options) {
166
170
  if (workerPool) await workerPool.shutdown();
167
171
  await pagesRouter.close();
168
172
  await apiRouter.close();
173
+ if (fileWatcher) plugins.unwatch(fileWatcher);
169
174
  if (viteRouter) await viteRouter.close();
170
175
  await webServer.close();
171
176
  if (fileWatcher) await fileWatcher.close();
package/server/context.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as vault from './lib/vault/index.js';
2
+ import { createJwtCodec } from './lib/vault/jwt.js';
2
3
  import { wsBroadcastToPath, wsSendToSocket } from './ws/registry.js';
3
4
 
4
5
  function trackDb(db) {
@@ -114,9 +115,11 @@ function normalizeMeta(meta) {
114
115
 
115
116
  function buildVaultToolkit(vaultConfig) {
116
117
  if (!vaultConfig?.masterSecret) return vault;
118
+ const jwtCodec = createJwtCodec(vaultConfig.values?.jwt);
117
119
 
118
120
  return {
119
121
  ...vault,
122
+ ...jwtCodec,
120
123
  deriveKey: (label) => vault.deriveKey(vaultConfig.masterSecret, label),
121
124
  deriveKeyPair: (label, opts) => vault.deriveKeyPair(vaultConfig.masterSecret, label, opts),
122
125
  };
@@ -5,8 +5,7 @@ import { toError, calculateBackoff } from './queue.js';
5
5
  import JobDispatcher from './drivers/memory-queue.js';
6
6
  import { SYSTEM_JOB_DOMAIN, registerSystemJobs } from '../system-jobs/index.js';
7
7
 
8
- async function discoverJobs(jobsDir) {
9
- const entries = await discoverModules(jobsDir, { recursive: true, label: 'job file' });
8
+ function buildJobsFromModules(entries) {
10
9
  const jobs = [];
11
10
  for (const { name, filePath, relativePath, module } of entries) {
12
11
  const job = module.default;
@@ -31,6 +30,11 @@ async function discoverJobs(jobsDir) {
31
30
  return jobs;
32
31
  }
33
32
 
33
+ async function discoverJobs(jobsDir) {
34
+ const entries = await discoverModules(jobsDir, { recursive: true, label: 'job file' });
35
+ return buildJobsFromModules(entries);
36
+ }
37
+
34
38
  class JobRunner {
35
39
  _dispatcher;
36
40
  _config;
@@ -42,6 +46,7 @@ class JobRunner {
42
46
  _timer = null;
43
47
  _started = false;
44
48
  _stopped = false;
49
+ _runGeneration = 0;
45
50
  _lastEnqueuedMinute = new Map();
46
51
 
47
52
  constructor(
@@ -135,10 +140,23 @@ class JobRunner {
135
140
  }
136
141
  }
137
142
 
143
+ async reload() {
144
+ const wasStarted = this._started;
145
+ this.stop();
146
+ this._jobs = [];
147
+ this._cronEntries = [];
148
+ this._continuousJobs = [];
149
+ this._lastEnqueuedMinute = new Map();
150
+ this._dispatcher.registered.clear();
151
+ await this.init();
152
+ if (wasStarted) this.start();
153
+ }
154
+
138
155
  start(intervalMs) {
139
156
  if (this._started) return;
140
157
  this._started = true;
141
158
  this._stopped = false;
159
+ const generation = ++this._runGeneration;
142
160
  const pollMs = intervalMs ?? this._config?.pollIntervalMs ?? 60000;
143
161
  this._timer = setInterval(() => {
144
162
  this.tick().catch((err) => {
@@ -146,7 +164,7 @@ class JobRunner {
146
164
  });
147
165
  }, pollMs);
148
166
  for (const entry of this._continuousJobs) {
149
- this._runContinuousLoop(entry);
167
+ this._runContinuousLoop(entry, generation);
150
168
  }
151
169
 
152
170
  const parts = [];
@@ -158,6 +176,7 @@ class JobRunner {
158
176
  stop() {
159
177
  this._stopped = true;
160
178
  this._started = false;
179
+ this._runGeneration++;
161
180
  if (this._timer) {
162
181
  clearInterval(this._timer);
163
182
  this._timer = null;
@@ -188,12 +207,12 @@ class JobRunner {
188
207
  await this._dispatcher.process();
189
208
  }
190
209
 
191
- async _runContinuousLoop(entry) {
210
+ async _runContinuousLoop(entry, generation) {
192
211
  const backoffMs = this._config?.backoffMs ?? 1000;
193
212
  const cooldownMs = entry.cooldownMs ?? this._config?.cooldownMs ?? 1000;
194
213
  let consecutiveErrors = 0;
195
214
  console.log(`[job-runner] continuous: ${entry.qualifiedName} started`);
196
- while (!this._stopped) {
215
+ while (!this._stopped && generation === this._runGeneration) {
197
216
  try {
198
217
  const start = Date.now();
199
218
  const ctx = buildContext(entry.appContext, { payload: void 0 });
@@ -206,7 +225,7 @@ class JobRunner {
206
225
  if (cooldownMs > 0 && elapsed < cooldownMs) {
207
226
  const remaining = cooldownMs - elapsed;
208
227
  const deadline = Date.now() + remaining;
209
- while (!this._stopped && Date.now() < deadline) {
228
+ while (!this._stopped && generation === this._runGeneration && Date.now() < deadline) {
210
229
  await new Promise((r) => setTimeout(r, Math.min(100, deadline - Date.now())));
211
230
  }
212
231
  }
@@ -219,7 +238,7 @@ class JobRunner {
219
238
  error.message,
220
239
  );
221
240
  const deadline = Date.now() + delay;
222
- while (!this._stopped && Date.now() < deadline) {
241
+ while (!this._stopped && generation === this._runGeneration && Date.now() < deadline) {
223
242
  await new Promise((r) => setTimeout(r, Math.min(100, deadline - Date.now())));
224
243
  }
225
244
  }
@@ -228,4 +247,4 @@ class JobRunner {
228
247
  }
229
248
  }
230
249
 
231
- export { JobRunner, discoverJobs };
250
+ export { JobRunner, buildJobsFromModules, discoverJobs };
@@ -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']);
9
9
 
10
10
  function base64Url(buffer) {
11
11
  return Buffer.from(buffer).toString('base64url');
@@ -102,6 +102,7 @@ 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));
105
106
  throw new Error(`Unknown Arcway infra secret namespace: ${name}`);
106
107
  }
107
108
 
@@ -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 routesDir = path.join(packageDir, 'routes');
36
+ const jobsDir = path.join(packageDir, 'jobs');
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 routes = await discoverFiles(routesDir, { recursive: true });
43
+ const jobs = await discoverFiles(jobsDir, { 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 routes.entries()) {
52
+ lines.push(importLine(`route${index}`, entry.filePath));
53
+ }
54
+ for (const [index, entry] of jobs.entries()) {
55
+ lines.push(importLine(`job${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 routes = [${routes.map((entry, index) => moduleEntry(`route${index}`, entry)).join(',')}];`,
70
+ );
71
+ lines.push(
72
+ `export const jobs = [${jobs.map((entry, index) => moduleEntry(`job${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
+ routes: bundled.routes ?? [],
111
+ jobs: bundled.jobs ?? [],
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,11 +73,17 @@ 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;
@@ -106,9 +113,12 @@ async function loadPluginPackage(packageDir) {
106
113
 
107
114
  const routesDir = path.join(packageDir, 'routes');
108
115
  const jobsDir = path.join(packageDir, 'jobs');
109
- const exportsModule = (await exists(exportsJs))
110
- ? await loadModule(exportsJs, import.meta.url)
111
- : null;
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
132
  routesDir: (await exists(routesDir)) ? routesDir : null,
122
133
  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'),
134
+ bundledRoutes: bundled?.routes ?? null,
135
+ bundledJobs: bundled?.jobs ?? 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 { buildRoutesFromModules, discoverRoutes } from '../router/routes.js';
3
+ import { buildJobsFromModules, discoverJobs } from '../jobs/runner.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
@@ -144,14 +169,18 @@ class PluginManager {
144
169
  async discoverRoutes() {
145
170
  const routes = [];
146
171
  for (const plugin of this.enabledPlugins) {
147
- if (!plugin.routesDir) continue;
148
- const discovered = await discoverRoutes(plugin.routesDir, {
172
+ const routeOptions = {
149
173
  prefix: `/_plugins/${plugin.id}`,
150
174
  plugin: {
151
175
  id: plugin.id,
152
176
  requires: plugin.manifest.requires,
153
177
  },
154
- });
178
+ };
179
+ const discovered = plugin.bundledRoutes
180
+ ? buildRoutesFromModules(plugin.bundledRoutes, routeOptions)
181
+ : plugin.routesDir
182
+ ? await discoverRoutes(plugin.routesDir, routeOptions)
183
+ : [];
155
184
  routes.push(...discovered);
156
185
  }
157
186
  return routes;
@@ -160,8 +189,11 @@ class PluginManager {
160
189
  async discoverJobs() {
161
190
  const jobs = [];
162
191
  for (const plugin of this.enabledPlugins) {
163
- if (!plugin.jobsDir) continue;
164
- const discovered = await discoverJobs(plugin.jobsDir);
192
+ const discovered = plugin.bundledJobs
193
+ ? buildJobsFromModules(plugin.bundledJobs)
194
+ : plugin.jobsDir
195
+ ? await discoverJobs(plugin.jobsDir)
196
+ : [];
165
197
  for (const job of discovered) {
166
198
  const definition = {
167
199
  ...job.definition,
@@ -177,6 +209,115 @@ class PluginManager {
177
209
  return jobs;
178
210
  }
179
211
 
212
+ async reload(ids) {
213
+ if (this.config.enabled !== true) return { reloaded: [] };
214
+
215
+ const requested = ids ? new Set(Array.isArray(ids) ? ids : [ids]) : null;
216
+ const oldPlugins = this.plugins;
217
+ const oldEnabledIds = new Set(
218
+ oldPlugins
219
+ .filter((plugin) => this.status.get(plugin.id) === 'enabled')
220
+ .map((plugin) => plugin.id),
221
+ );
222
+ let affected = requested ? collectDependentIds(oldPlugins, requested) : new Set();
223
+
224
+ const discovered = await discoverPlugins(this.config.dirs, { bundle: this.bundle });
225
+ const discoveredIds = new Set(discovered.map((plugin) => plugin.id));
226
+ const enablements = configuredEnablements(this.config);
227
+ const nextEnabled = new Set();
228
+ for (const plugin of discovered) {
229
+ if (
230
+ this.enabled.has(plugin.id) ||
231
+ (!oldPlugins.some((old) => old.id === plugin.id) &&
232
+ pluginEnabledByDefault(plugin, enablements))
233
+ ) {
234
+ nextEnabled.add(plugin.id);
235
+ }
236
+ }
237
+
238
+ if (!requested) {
239
+ affected = new Set([...oldPlugins.map((plugin) => plugin.id), ...discoveredIds]);
240
+ } else {
241
+ for (const id of requested) {
242
+ if (!discoveredIds.has(id) && !oldPlugins.some((plugin) => plugin.id === id)) {
243
+ affected = new Set([...oldPlugins.map((plugin) => plugin.id), ...discoveredIds]);
244
+ break;
245
+ }
246
+ }
247
+ affected = collectDependentIds(discovered, affected);
248
+ }
249
+
250
+ const oldAffectedEnabled = [...this.loadOrder]
251
+ .filter((plugin) => affected.has(plugin.id) && oldEnabledIds.has(plugin.id))
252
+ .reverse();
253
+ for (const plugin of oldAffectedEnabled) {
254
+ if (this.runLifecycle) await this.#runLifecycle(plugin, 'onDisable');
255
+ }
256
+
257
+ this.plugins = discovered;
258
+ this.enabled = nextEnabled;
259
+ this.#rebuildGraph();
260
+
261
+ for (const plugin of this.loadOrder) {
262
+ if (!affected.has(plugin.id)) continue;
263
+ if (this.runLifecycle) {
264
+ await this.#runLifecycle(plugin, 'onLoad');
265
+ await this.#runLifecycle(plugin, 'onEnable');
266
+ }
267
+ this.log?.info?.(`Plugin reloaded: ${plugin.id}`);
268
+ }
269
+
270
+ return {
271
+ reloaded: [...affected].filter((id) => discoveredIds.has(id)),
272
+ removed: [...affected].filter((id) => !discoveredIds.has(id)),
273
+ };
274
+ }
275
+
276
+ pluginIdsForChangedFiles(events) {
277
+ const ids = new Set();
278
+ for (const event of events) {
279
+ const filePath = event.path;
280
+ const existing = this.plugins.find(
281
+ (plugin) => filePath === plugin.dir || filePath.startsWith(plugin.dir + path.sep),
282
+ );
283
+ if (existing) {
284
+ ids.add(existing.id);
285
+ continue;
286
+ }
287
+
288
+ const rootDir = (this.config.dirs ?? []).find((dir) => {
289
+ const relative = path.relative(dir, filePath);
290
+ return relative && !relative.startsWith('..') && !path.isAbsolute(relative);
291
+ });
292
+ if (!rootDir) continue;
293
+ const [packageDirName] = path.relative(rootDir, filePath).split(path.sep);
294
+ if (!packageDirName) continue;
295
+ if (!this.plugins.some((plugin) => plugin.id === packageDirName)) return null;
296
+ ids.add(packageDirName);
297
+ }
298
+ return ids;
299
+ }
300
+
301
+ watch(fileWatcher, { apiRouter, jobRunner } = {}) {
302
+ if (this.config.enabled !== true || !fileWatcher) return;
303
+ fileWatcher.subscribe('plugins', {
304
+ dirs: this.config.dirs,
305
+ extensions: ['.js', '.json'],
306
+ debounceMs: 200,
307
+ onChange: async (events) => {
308
+ const changedIds = this.pluginIdsForChangedFiles(events);
309
+ await this.reload(changedIds);
310
+ await apiRouter?.reload?.();
311
+ await jobRunner?.reload?.();
312
+ },
313
+ });
314
+ this.log?.info?.('Plugins: watching for changes');
315
+ }
316
+
317
+ unwatch(fileWatcher) {
318
+ fileWatcher?.unsubscribe?.('plugins');
319
+ }
320
+
180
321
  #rebuildGraph() {
181
322
  const providers = buildProviderMap(this.plugins);
182
323
  for (const plugin of this.plugins) {
@@ -347,6 +347,10 @@ class ApiRouter {
347
347
  }
348
348
  }
349
349
 
350
+ async reload() {
351
+ return this._reload();
352
+ }
353
+
350
354
  async close() {
351
355
  if (this._mode === 'development' && this._fileWatcher) {
352
356
  this._fileWatcher.unsubscribe('routes');
@@ -46,8 +46,7 @@ function prefixRoutePattern(pattern, prefix) {
46
46
  if (pattern === '/') return normalized;
47
47
  return normalized + pattern;
48
48
  }
49
- async function discoverRoutes(apiDir, { prefix = '', plugin } = {}) {
50
- const entries = await discoverModules(apiDir, { recursive: true, label: 'route file' });
49
+ function buildRoutesFromModules(entries, { prefix = '', plugin } = {}) {
51
50
  const routes = [];
52
51
  for (const { filePath, relativePath, module } of entries) {
53
52
  const urlPattern = prefixRoutePattern(filePathToPattern(relativePath), prefix);
@@ -84,6 +83,11 @@ async function discoverRoutes(apiDir, { prefix = '', plugin } = {}) {
84
83
  sortBySpecificity(routes);
85
84
  return routes;
86
85
  }
86
+
87
+ async function discoverRoutes(apiDir, { prefix = '', plugin } = {}) {
88
+ const entries = await discoverModules(apiDir, { recursive: true, label: 'route file' });
89
+ return buildRoutesFromModules(entries, { prefix, plugin });
90
+ }
87
91
  function matchRoute(routes, method, pathname) {
88
92
  const upperMethod = method.toUpperCase();
89
93
  const methodRoutes = routes.filter((r) => r.method === upperMethod);
@@ -106,6 +110,7 @@ function matchRoutesByPath(routes, pathname) {
106
110
  }
107
111
  export {
108
112
  compilePattern,
113
+ buildRoutesFromModules,
109
114
  discoverRoutes,
110
115
  filePathToPattern,
111
116
  matchPattern,