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.
- package/package.json +1 -1
- package/server/boot/index.js +28 -2
- package/server/build.js +0 -1
- package/server/callbacks/cleanup-job.js +12 -0
- package/server/callbacks/discovery.js +33 -0
- package/server/callbacks/index.js +173 -0
- package/server/callbacks/rate-limit.js +32 -0
- package/server/callbacks/store.js +121 -0
- package/server/callbacks/tokens.js +44 -0
- package/server/context.js +4 -0
- package/server/jobs/runner.js +54 -10
- package/server/jobs/worker-entry.js +9 -1
- package/server/lib/vault/jwt.js +34 -21
- package/server/lib/vault/secrets.js +3 -1
- package/server/plugins/bundle.js +122 -0
- package/server/plugins/discovery.js +29 -12
- package/server/plugins/graph.js +22 -8
- package/server/plugins/manager.js +158 -22
- package/server/router/api-router.js +5 -7
- package/server/router/routes.js +7 -2
- package/server/system-jobs/index.js +7 -0
- package/server/system-routes/index.js +15 -1
|
@@ -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);
|
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', '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
|
-
|
|
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 (
|
|
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
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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(
|
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 { 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
|
-
|
|
164
|
-
|
|
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
|
|
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);
|