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