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 +1 -1
- package/server/boot/index.js +6 -1
- package/server/context.js +3 -0
- package/server/jobs/runner.js +27 -8
- package/server/lib/vault/jwt.js +34 -21
- package/server/lib/vault/secrets.js +2 -1
- package/server/plugins/bundle.js +122 -0
- package/server/plugins/discovery.js +26 -9
- package/server/plugins/graph.js +22 -8
- package/server/plugins/manager.js +150 -9
- package/server/router/api-router.js +4 -0
- package/server/router/routes.js +7 -2
package/package.json
CHANGED
package/server/boot/index.js
CHANGED
|
@@ -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, {
|
|
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
|
};
|
package/server/jobs/runner.js
CHANGED
|
@@ -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
|
-
|
|
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 };
|
package/server/lib/vault/jwt.js
CHANGED
|
@@ -1,19 +1,26 @@
|
|
|
1
|
-
import { SignJWT, jwtVerify
|
|
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
|
-
|
|
13
|
+
|
|
14
|
+
function requireJwtSecret(secret) {
|
|
12
15
|
if (!secret) {
|
|
13
|
-
throw new Error('JWT
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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(
|
package/server/plugins/graph.js
CHANGED
|
@@ -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
|
-
|
|
27
|
-
|
|
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
|
|
36
|
+
missing.push({ pluginId, capability, reason: 'missing provider' });
|
|
32
37
|
continue;
|
|
33
38
|
}
|
|
34
|
-
|
|
39
|
+
const providerId = String(provider.id);
|
|
40
|
+
if (!enabled.has(providerId)) {
|
|
35
41
|
missing.push({
|
|
36
|
-
pluginId
|
|
42
|
+
pluginId,
|
|
37
43
|
capability,
|
|
38
|
-
providerId
|
|
44
|
+
providerId,
|
|
39
45
|
reason: 'provider disabled',
|
|
40
46
|
});
|
|
41
47
|
continue;
|
|
42
48
|
}
|
|
43
|
-
if (
|
|
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
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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) {
|
package/server/router/routes.js
CHANGED
|
@@ -46,8 +46,7 @@ function prefixRoutePattern(pattern, prefix) {
|
|
|
46
46
|
if (pattern === '/') return normalized;
|
|
47
47
|
return normalized + pattern;
|
|
48
48
|
}
|
|
49
|
-
|
|
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,
|