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
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
deriveInfraSecrets,
|
|
5
|
+
encodeMasterSecret,
|
|
6
|
+
normalizeMasterSecret,
|
|
7
|
+
validateVaultKey,
|
|
8
|
+
} from '../../lib/vault/secrets.js';
|
|
9
|
+
|
|
10
|
+
const DEFAULT_KEY_FILE = '.build/arcway-master-secret.key';
|
|
11
|
+
|
|
12
|
+
const EXPLICIT_ENV = Object.freeze({
|
|
13
|
+
session: 'SESSION_SECRET',
|
|
14
|
+
'plugin-vault': 'PLUGIN_VAULT_KEY',
|
|
15
|
+
'mcp-api': 'MCP_API_KEY',
|
|
16
|
+
'pty-api': 'PTY_API_KEY',
|
|
17
|
+
'pty-server-api': 'PTY_SERVER_API_KEY',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function isProduction(mode) {
|
|
21
|
+
return mode === 'production';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readKeyFile(filePath) {
|
|
25
|
+
try {
|
|
26
|
+
const stat = fs.statSync(filePath);
|
|
27
|
+
if ((stat.mode & 0o077) !== 0) {
|
|
28
|
+
throw new Error('Arcway master secret keyfile permissions must be 0600');
|
|
29
|
+
}
|
|
30
|
+
return fs.readFileSync(filePath, 'utf8').trim();
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (error.code === 'ENOENT') return null;
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function createDevKeyFile(filePath) {
|
|
38
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
39
|
+
const encoded = encodeMasterSecret();
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const fd = fs.openSync(
|
|
43
|
+
filePath,
|
|
44
|
+
fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY,
|
|
45
|
+
0o600,
|
|
46
|
+
);
|
|
47
|
+
try {
|
|
48
|
+
fs.writeFileSync(fd, `${encoded}\n`, 'utf8');
|
|
49
|
+
} finally {
|
|
50
|
+
fs.closeSync(fd);
|
|
51
|
+
}
|
|
52
|
+
return encoded;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (error.code === 'EEXIST') return readKeyFile(filePath);
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function loadMasterSecret(rawSecrets, { rootDir, mode, env }) {
|
|
60
|
+
const configured = rawSecrets.master ?? env.ARCWAY_MASTER_SECRET;
|
|
61
|
+
if (configured) return { value: configured, source: 'config' };
|
|
62
|
+
|
|
63
|
+
if (isProduction(mode)) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
'ARCWAY_MASTER_SECRET is required when arcway.config secrets are enabled in production',
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const keyFile = path.resolve(rootDir, rawSecrets.keyFile ?? DEFAULT_KEY_FILE);
|
|
70
|
+
const existing = readKeyFile(keyFile);
|
|
71
|
+
if (existing) return { value: existing, source: 'keyfile', keyFile };
|
|
72
|
+
|
|
73
|
+
return { value: createDevKeyFile(keyFile), source: 'generated-keyfile', keyFile };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function explicitValue(name, rawSecrets, env) {
|
|
77
|
+
const explicit = rawSecrets.explicit?.[name];
|
|
78
|
+
if (explicit !== undefined && explicit !== null && explicit !== '') return explicit;
|
|
79
|
+
|
|
80
|
+
const envName = EXPLICIT_ENV[name];
|
|
81
|
+
const fromEnv = envName ? env[envName] : undefined;
|
|
82
|
+
return fromEnv || undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function validateExplicit(name, value) {
|
|
86
|
+
if (name === 'plugin-vault') return validateVaultKey(value);
|
|
87
|
+
return String(value);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function resolve(config, { rootDir, mode } = {}) {
|
|
91
|
+
if (!config.secrets) return config;
|
|
92
|
+
|
|
93
|
+
const env = config.secrets.env ?? process.env;
|
|
94
|
+
const rawSecrets = config.secrets;
|
|
95
|
+
const master = loadMasterSecret(rawSecrets, { rootDir, mode, env });
|
|
96
|
+
normalizeMasterSecret(master.value);
|
|
97
|
+
|
|
98
|
+
const derived = deriveInfraSecrets(master.value);
|
|
99
|
+
const values = { ...derived };
|
|
100
|
+
const explicit = {};
|
|
101
|
+
const previousValues = {};
|
|
102
|
+
|
|
103
|
+
const previousMaster = rawSecrets.previous ?? env.ARCWAY_MASTER_SECRET_PREVIOUS;
|
|
104
|
+
if (previousMaster) {
|
|
105
|
+
normalizeMasterSecret(previousMaster, 'Previous Arcway master secret');
|
|
106
|
+
Object.assign(previousValues, deriveInfraSecrets(previousMaster));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const name of Object.keys(EXPLICIT_ENV)) {
|
|
110
|
+
const value = explicitValue(name, rawSecrets, env);
|
|
111
|
+
if (!value) continue;
|
|
112
|
+
|
|
113
|
+
explicit[name] = validateExplicit(name, value);
|
|
114
|
+
values[name] = explicit[name];
|
|
115
|
+
if (derived[name] !== explicit[name]) {
|
|
116
|
+
previousValues[name] = explicit[name];
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const vapidPublic = rawSecrets.explicit?.vapidPublic ?? env.VAPID_PUBLIC_KEY;
|
|
121
|
+
const vapidPrivate = rawSecrets.explicit?.vapidPrivate ?? env.VAPID_PRIVATE_KEY;
|
|
122
|
+
if (vapidPublic || vapidPrivate) {
|
|
123
|
+
if (!vapidPublic || !vapidPrivate) {
|
|
124
|
+
throw new Error('VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY must be provided together');
|
|
125
|
+
}
|
|
126
|
+
explicit.vapid = { publicKey: String(vapidPublic), privateKey: String(vapidPrivate) };
|
|
127
|
+
values.vapid = explicit.vapid;
|
|
128
|
+
previousValues.vapid = explicit.vapid;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
...config,
|
|
133
|
+
secrets: {
|
|
134
|
+
enabled: true,
|
|
135
|
+
keyFile: master.keyFile,
|
|
136
|
+
source: master.source,
|
|
137
|
+
values,
|
|
138
|
+
derived,
|
|
139
|
+
explicit,
|
|
140
|
+
previousValues,
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export default resolve;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { normalizeDurationMs, normalizeDurationSeconds } from '../duration.js';
|
|
2
|
+
|
|
1
3
|
const DEFAULTS = {
|
|
2
4
|
host: '0.0.0.0',
|
|
3
5
|
port: 3000,
|
|
@@ -19,11 +21,21 @@ function resolveCors(config, mode) {
|
|
|
19
21
|
if (config === false) return void 0;
|
|
20
22
|
if (config === void 0 && mode === 'production') return void 0;
|
|
21
23
|
const userConfig = typeof config === 'object' ? config : {};
|
|
22
|
-
|
|
24
|
+
const resolved = { ...DEFAULT_CORS, ...userConfig };
|
|
25
|
+
if (resolved.maxAge !== undefined) {
|
|
26
|
+
resolved.maxAge = normalizeDurationSeconds(resolved.maxAge, 'server.cors.maxAge');
|
|
27
|
+
}
|
|
28
|
+
return resolved;
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
function resolve(config, { mode } = {}) {
|
|
26
32
|
const server = { ...DEFAULTS, ...config.server };
|
|
33
|
+
if (server.shutdownTimeoutMs !== undefined) {
|
|
34
|
+
server.shutdownTimeoutMs = normalizeDurationMs(
|
|
35
|
+
server.shutdownTimeoutMs,
|
|
36
|
+
'server.shutdownTimeoutMs',
|
|
37
|
+
);
|
|
38
|
+
}
|
|
27
39
|
server.cors = resolveCors(server.cors, mode);
|
|
28
40
|
return { ...config, server };
|
|
29
41
|
}
|
|
@@ -2,7 +2,11 @@ import { resolveSessionConfig } from '../../session/index.js';
|
|
|
2
2
|
|
|
3
3
|
function resolve(config, { mode } = {}) {
|
|
4
4
|
if (!config.session) return config;
|
|
5
|
-
const
|
|
5
|
+
const sessionInput = {
|
|
6
|
+
...config.session,
|
|
7
|
+
password: config.session.password || config.secrets?.values?.session,
|
|
8
|
+
};
|
|
9
|
+
const session = resolveSessionConfig(sessionInput, mode);
|
|
6
10
|
return { ...config, session };
|
|
7
11
|
}
|
|
8
12
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { normalizeDurationMs } from '../duration.js';
|
|
2
|
+
|
|
1
3
|
const DEFAULTS = {
|
|
2
4
|
path: '/ws',
|
|
3
5
|
pingIntervalMs: 30000,
|
|
@@ -8,6 +10,12 @@ const VALID_DRIVERS = new Set(['memory', 'redis']);
|
|
|
8
10
|
|
|
9
11
|
function resolve(config) {
|
|
10
12
|
const websocket = { ...DEFAULTS, ...config.websocket };
|
|
13
|
+
if (websocket.pingIntervalMs !== undefined) {
|
|
14
|
+
websocket.pingIntervalMs = normalizeDurationMs(
|
|
15
|
+
websocket.pingIntervalMs,
|
|
16
|
+
'websocket.pingIntervalMs',
|
|
17
|
+
);
|
|
18
|
+
}
|
|
11
19
|
if (!VALID_DRIVERS.has(websocket.driver)) {
|
|
12
20
|
throw new Error(
|
|
13
21
|
`Invalid config: websocket.driver="${websocket.driver}" must be one of: ${[...VALID_DRIVERS].join(', ')}`,
|
package/server/context.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import * as vault from './lib/vault/index.js';
|
|
2
|
+
import { wsBroadcastToPath, wsSendToSocket } from './ws/registry.js';
|
|
3
|
+
|
|
1
4
|
function trackDb(db) {
|
|
2
5
|
if (!db || typeof db !== 'function' && typeof db !== 'object') return { db, stats: { queries: 0, durationMs: 0 } };
|
|
3
6
|
const stats = { queries: 0, durationMs: 0 };
|
|
@@ -58,8 +61,60 @@ function wrapBuilder(builder, stats) {
|
|
|
58
61
|
return builder;
|
|
59
62
|
}
|
|
60
63
|
|
|
64
|
+
function buildErrResponse(input, status = 400) {
|
|
65
|
+
if (typeof input === 'string') {
|
|
66
|
+
return { status, error: { message: input } };
|
|
67
|
+
}
|
|
68
|
+
if (input instanceof Error) {
|
|
69
|
+
return { status, error: { message: input.message } };
|
|
70
|
+
}
|
|
71
|
+
if (input && typeof input === 'object') {
|
|
72
|
+
const { status: explicitStatus, ...error } = input;
|
|
73
|
+
return { status: explicitStatus ?? status, error };
|
|
74
|
+
}
|
|
75
|
+
return { status, error: { message: 'An error occurred' } };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function normalizeEvent(event) {
|
|
79
|
+
if (event === undefined) return undefined;
|
|
80
|
+
return {
|
|
81
|
+
name: event?.name ?? null,
|
|
82
|
+
payload: Object.prototype.hasOwnProperty.call(event || {}, 'payload')
|
|
83
|
+
? (event.payload ?? null)
|
|
84
|
+
: null,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function normalizeMeta(meta) {
|
|
89
|
+
return {
|
|
90
|
+
builtAt: meta?.builtAt ?? null,
|
|
91
|
+
version: meta?.version ?? null,
|
|
92
|
+
buildNumber: meta?.buildNumber ?? null,
|
|
93
|
+
branch: meta?.branch ?? null,
|
|
94
|
+
tag: meta?.tag ?? null,
|
|
95
|
+
dirty: meta?.dirty ?? null,
|
|
96
|
+
environment: meta?.environment ?? process.env.NODE_ENV ?? 'development',
|
|
97
|
+
nodeVersion: meta?.nodeVersion ?? process.version,
|
|
98
|
+
shortSha: meta?.shortSha ?? null,
|
|
99
|
+
sha: meta?.sha ?? null,
|
|
100
|
+
packageVersion: meta?.packageVersion ?? null,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
61
104
|
function buildContext(appContext, extras) {
|
|
62
105
|
const tracked = trackDb(appContext.db);
|
|
106
|
+
const normalizedExtras = { ...extras };
|
|
107
|
+
if (Object.prototype.hasOwnProperty.call(normalizedExtras, 'event')) {
|
|
108
|
+
normalizedExtras.event = normalizeEvent(normalizedExtras.event);
|
|
109
|
+
}
|
|
110
|
+
const req = normalizedExtras.req;
|
|
111
|
+
const ws =
|
|
112
|
+
req?.socketId && req?.path
|
|
113
|
+
? {
|
|
114
|
+
send: (response) => wsSendToSocket(req.socketId, req.path, response),
|
|
115
|
+
broadcast: (response) => wsBroadcastToPath(req.path, response),
|
|
116
|
+
}
|
|
117
|
+
: null;
|
|
63
118
|
const ctx = {
|
|
64
119
|
db: tracked.db,
|
|
65
120
|
log: appContext.log,
|
|
@@ -68,10 +123,14 @@ function buildContext(appContext, extras) {
|
|
|
68
123
|
queue: appContext.queue,
|
|
69
124
|
files: appContext.files,
|
|
70
125
|
mail: appContext.mail,
|
|
126
|
+
meta: normalizeMeta(appContext.meta),
|
|
127
|
+
vault,
|
|
128
|
+
ws,
|
|
129
|
+
err: (input, status) => buildErrResponse(input, status),
|
|
71
130
|
_dbStats: tracked.stats,
|
|
72
|
-
...
|
|
131
|
+
...normalizedExtras,
|
|
73
132
|
};
|
|
74
133
|
return ctx;
|
|
75
134
|
}
|
|
76
135
|
|
|
77
|
-
export { buildContext };
|
|
136
|
+
export { buildContext, buildErrResponse };
|
package/server/db/index.js
CHANGED
|
@@ -6,18 +6,39 @@ import { loadModule } from '../module-loader.js';
|
|
|
6
6
|
import { introspectSchema, generateSchemaMarkdown } from './schema/index.js';
|
|
7
7
|
|
|
8
8
|
class MigrationSource {
|
|
9
|
-
constructor(directory) {
|
|
9
|
+
constructor(directory, pluginMigrations = []) {
|
|
10
10
|
this.directory = directory;
|
|
11
|
+
this.pluginMigrations = pluginMigrations;
|
|
12
|
+
this.migrationPaths = new Map();
|
|
11
13
|
}
|
|
12
14
|
async getMigrations() {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
this.migrationPaths.clear();
|
|
16
|
+
const migrations = [];
|
|
17
|
+
|
|
18
|
+
if (this.directory) {
|
|
19
|
+
const files = await discoverFiles(this.directory, { skipUnderscore: false });
|
|
20
|
+
for (const file of files) {
|
|
21
|
+
migrations.push(file.name);
|
|
22
|
+
this.migrationPaths.set(file.name, file.filePath);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
for (const plugin of this.pluginMigrations ?? []) {
|
|
27
|
+
const files = await discoverFiles(plugin.dir, { skipUnderscore: false });
|
|
28
|
+
for (const file of files) {
|
|
29
|
+
const name = `plugins/${plugin.pluginId}/${file.name}`;
|
|
30
|
+
migrations.push(name);
|
|
31
|
+
this.migrationPaths.set(name, file.filePath);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return migrations;
|
|
15
36
|
}
|
|
16
37
|
getMigrationName(migration) {
|
|
17
38
|
return migration;
|
|
18
39
|
}
|
|
19
40
|
async getMigration(migration) {
|
|
20
|
-
const filePath = path.resolve(this.directory, migration);
|
|
41
|
+
const filePath = this.migrationPaths.get(migration) ?? path.resolve(this.directory, migration);
|
|
21
42
|
return loadModule(filePath, import.meta.url);
|
|
22
43
|
}
|
|
23
44
|
}
|
|
@@ -43,7 +64,9 @@ async function createDB(config, { log } = {}) {
|
|
|
43
64
|
...hooks,
|
|
44
65
|
client,
|
|
45
66
|
connection,
|
|
46
|
-
...(isSqlite
|
|
67
|
+
...(isSqlite
|
|
68
|
+
? { useNullAsDefault: config.sqlite?.useNullAsDefault ?? config.useNullAsDefault ?? true }
|
|
69
|
+
: {}),
|
|
47
70
|
});
|
|
48
71
|
|
|
49
72
|
try {
|
|
@@ -55,6 +78,9 @@ async function createDB(config, { log } = {}) {
|
|
|
55
78
|
}
|
|
56
79
|
|
|
57
80
|
if (isSqlite) {
|
|
81
|
+
if (config.sqlite?.foreignKeys) {
|
|
82
|
+
await db.raw('PRAGMA foreign_keys = ON');
|
|
83
|
+
}
|
|
58
84
|
await db.raw('PRAGMA journal_mode = WAL');
|
|
59
85
|
await db.raw('PRAGMA busy_timeout = 5000');
|
|
60
86
|
await db.raw('PRAGMA synchronous = NORMAL');
|
|
@@ -72,9 +98,10 @@ async function createDB(config, { log } = {}) {
|
|
|
72
98
|
|
|
73
99
|
db.runMigrations = async () => {
|
|
74
100
|
const migrationsDir = config.dir;
|
|
75
|
-
|
|
101
|
+
const pluginMigrations = config.pluginMigrations ?? [];
|
|
102
|
+
if (!migrationsDir && pluginMigrations.length === 0) return;
|
|
76
103
|
const [batch, migrations] = await db.migrate.latest({
|
|
77
|
-
migrationSource: new MigrationSource(migrationsDir),
|
|
104
|
+
migrationSource: new MigrationSource(migrationsDir, pluginMigrations),
|
|
78
105
|
});
|
|
79
106
|
if (migrations.length > 0) {
|
|
80
107
|
log?.info(`Migrations (batch ${batch}): ${migrations.join(', ')}`);
|
|
@@ -85,9 +112,12 @@ async function createDB(config, { log } = {}) {
|
|
|
85
112
|
|
|
86
113
|
db.runRollback = async () => {
|
|
87
114
|
const migrationsDir = config.dir;
|
|
88
|
-
|
|
115
|
+
const pluginMigrations = config.pluginMigrations ?? [];
|
|
116
|
+
if (!migrationsDir && pluginMigrations.length === 0) {
|
|
117
|
+
throw new Error('No migrations dir configured');
|
|
118
|
+
}
|
|
89
119
|
const [batch, entries] = await db.migrate.rollback({
|
|
90
|
-
migrationSource: new MigrationSource(migrationsDir),
|
|
120
|
+
migrationSource: new MigrationSource(migrationsDir, pluginMigrations),
|
|
91
121
|
});
|
|
92
122
|
return { batch, log: entries };
|
|
93
123
|
};
|
package/server/discovery.js
CHANGED
|
@@ -8,7 +8,10 @@ import { loadModule } from './module-loader.js';
|
|
|
8
8
|
* Skips files starting with underscore by default.
|
|
9
9
|
* Returns sorted array of { name, filePath, relativePath }.
|
|
10
10
|
*/
|
|
11
|
-
async function discoverFiles(
|
|
11
|
+
async function discoverFiles(
|
|
12
|
+
dir,
|
|
13
|
+
{ recursive = false, extensions = ['.js'], skipUnderscore = true, filter } = {},
|
|
14
|
+
) {
|
|
12
15
|
if (!dir) return [];
|
|
13
16
|
|
|
14
17
|
let files;
|
|
@@ -49,7 +52,10 @@ async function discoverFiles(dir, { recursive = false, extensions = ['.js'], ski
|
|
|
49
52
|
* Returns sorted array of { name, filePath, relativePath, module }.
|
|
50
53
|
* Throws with a descriptive message if any file fails to import.
|
|
51
54
|
*/
|
|
52
|
-
async function discoverModules(
|
|
55
|
+
async function discoverModules(
|
|
56
|
+
dir,
|
|
57
|
+
{ recursive = false, extensions = ['.js'], skipUnderscore = true, filter, label = 'file' } = {},
|
|
58
|
+
) {
|
|
53
59
|
const files = await discoverFiles(dir, { recursive, extensions, skipUnderscore, filter });
|
|
54
60
|
const results = [];
|
|
55
61
|
for (const entry of files) {
|
|
@@ -64,4 +70,40 @@ async function discoverModules(dir, { recursive = false, extensions = ['.js'], s
|
|
|
64
70
|
return results;
|
|
65
71
|
}
|
|
66
72
|
|
|
67
|
-
|
|
73
|
+
async function discoverPackageDirectories(
|
|
74
|
+
dir,
|
|
75
|
+
{ markerFiles = ['package.json'], label = 'package' } = {},
|
|
76
|
+
) {
|
|
77
|
+
if (!dir) return [];
|
|
78
|
+
|
|
79
|
+
let entries;
|
|
80
|
+
try {
|
|
81
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
82
|
+
} catch (err) {
|
|
83
|
+
if (err.code === 'ENOENT') return [];
|
|
84
|
+
throw err;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const packages = [];
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
if (!entry.isDirectory() || entry.name.startsWith('_')) continue;
|
|
90
|
+
const packageDir = path.join(dir, entry.name);
|
|
91
|
+
const hasMarker = await Promise.all(
|
|
92
|
+
markerFiles.map(async (marker) => {
|
|
93
|
+
try {
|
|
94
|
+
await fs.access(path.join(packageDir, marker));
|
|
95
|
+
return true;
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if (err.code === 'ENOENT') return false;
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
if (!hasMarker.some(Boolean)) continue;
|
|
103
|
+
packages.push({ name: entry.name, dir: packageDir, label });
|
|
104
|
+
}
|
|
105
|
+
packages.sort((a, b) => a.name.localeCompare(b.name));
|
|
106
|
+
return packages;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export { discoverFiles, discoverModules, discoverPackageDirectories };
|
|
@@ -1,22 +1,40 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { patternToRegex } from '../pattern.js';
|
|
2
3
|
class MemoryTransport {
|
|
3
4
|
subscriptions = [];
|
|
4
5
|
/** Subscribe to events matching a pattern. */
|
|
5
|
-
subscribe(pattern, handler) {
|
|
6
|
+
subscribe(pattern, handler, options = {}) {
|
|
6
7
|
const regex = patternToRegex(pattern);
|
|
7
|
-
|
|
8
|
+
const subscription = { pattern, regex, handler, consumerGroup: options.consumerGroup };
|
|
9
|
+
this.subscriptions.push(subscription);
|
|
10
|
+
return () => {
|
|
11
|
+
const index = this.subscriptions.indexOf(subscription);
|
|
12
|
+
if (index !== -1) this.subscriptions.splice(index, 1);
|
|
13
|
+
};
|
|
8
14
|
}
|
|
9
15
|
/** Emit an event. Handlers run asynchronously — fire-and-forget. */
|
|
10
16
|
async emit(eventName, payload) {
|
|
11
17
|
const matching = this.subscriptions.filter((sub) => sub.regex.test(eventName));
|
|
12
18
|
if (matching.length === 0) return;
|
|
19
|
+
const meta = { id: randomUUID() };
|
|
20
|
+
const grouped = [];
|
|
21
|
+
const seenGroups = new Set();
|
|
22
|
+
for (const sub of matching) {
|
|
23
|
+
if (!sub.consumerGroup) {
|
|
24
|
+
grouped.push(sub);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (seenGroups.has(sub.consumerGroup)) continue;
|
|
28
|
+
seenGroups.add(sub.consumerGroup);
|
|
29
|
+
grouped.push(sub);
|
|
30
|
+
}
|
|
13
31
|
Promise.allSettled(
|
|
14
|
-
|
|
32
|
+
grouped.map((sub) => sub.handler(payload, eventName, meta)),
|
|
15
33
|
).then((results) => {
|
|
16
34
|
for (let i = 0; i < results.length; i++) {
|
|
17
35
|
if (results[i].status === 'rejected') {
|
|
18
36
|
console.error(
|
|
19
|
-
`Event listener error [${
|
|
37
|
+
`Event listener error [${grouped[i].pattern}] for event "${eventName}":`,
|
|
20
38
|
results[i].reason,
|
|
21
39
|
);
|
|
22
40
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
1
2
|
import { patternToRegex } from '../pattern.js';
|
|
2
3
|
class RedisTransport {
|
|
3
4
|
pub;
|
|
@@ -10,17 +11,22 @@ class RedisTransport {
|
|
|
10
11
|
this.sub = sub;
|
|
11
12
|
this.prefix = `${keyPrefix}events:`;
|
|
12
13
|
}
|
|
13
|
-
subscribe(pattern, handler) {
|
|
14
|
+
subscribe(pattern, handler, options = {}) {
|
|
14
15
|
const regex = patternToRegex(pattern);
|
|
15
|
-
|
|
16
|
+
const subscription = { pattern, regex, handler, consumerGroup: options.consumerGroup };
|
|
17
|
+
this.subscriptions.push(subscription);
|
|
16
18
|
if (!this.listening) {
|
|
17
19
|
this.listening = true;
|
|
18
20
|
this.startListening();
|
|
19
21
|
}
|
|
22
|
+
return () => {
|
|
23
|
+
const index = this.subscriptions.indexOf(subscription);
|
|
24
|
+
if (index !== -1) this.subscriptions.splice(index, 1);
|
|
25
|
+
};
|
|
20
26
|
}
|
|
21
27
|
async emit(eventName, payload) {
|
|
22
28
|
const channel = `${this.prefix}${eventName}`;
|
|
23
|
-
const message = JSON.stringify({ event: eventName, payload });
|
|
29
|
+
const message = JSON.stringify({ id: randomUUID(), event: eventName, payload });
|
|
24
30
|
await this.pub.publish(channel, message);
|
|
25
31
|
}
|
|
26
32
|
startListening() {
|
|
@@ -36,10 +42,19 @@ class RedisTransport {
|
|
|
36
42
|
console.warn('[RedisTransport] Malformed event message, skipping');
|
|
37
43
|
return;
|
|
38
44
|
}
|
|
39
|
-
const { event: eventName, payload } = parsed;
|
|
45
|
+
const { id, event: eventName, payload } = parsed;
|
|
40
46
|
const matching = this.subscriptions.filter((sub) => sub.regex.test(eventName));
|
|
41
47
|
if (matching.length === 0) return;
|
|
42
|
-
Promise.allSettled(
|
|
48
|
+
Promise.allSettled(
|
|
49
|
+
matching.map(async (sub) => {
|
|
50
|
+
if (sub.consumerGroup) {
|
|
51
|
+
const claimKey = `${this.prefix}consumer-groups:${sub.consumerGroup}:${id}`;
|
|
52
|
+
const claimed = await this.pub.set(claimKey, '1', 'PX', 60 * 60 * 1000, 'NX');
|
|
53
|
+
if (claimed !== 'OK') return;
|
|
54
|
+
}
|
|
55
|
+
await sub.handler(payload, eventName, { id });
|
|
56
|
+
}),
|
|
57
|
+
).then((results) => {
|
|
43
58
|
for (let i = 0; i < results.length; i++) {
|
|
44
59
|
if (results[i].status === 'rejected') {
|
|
45
60
|
console.error(
|
package/server/events/handler.js
CHANGED
|
@@ -11,8 +11,10 @@ function listenerPathToEvent(relativePath) {
|
|
|
11
11
|
|
|
12
12
|
function validateHandler(item, filePath, index) {
|
|
13
13
|
const label = index !== undefined ? `[${index}]` : '';
|
|
14
|
-
if (typeof item === 'function') return item;
|
|
15
|
-
if (typeof item === 'object' && item !== null && typeof item.handler === 'function')
|
|
14
|
+
if (typeof item === 'function') return { handler: item };
|
|
15
|
+
if (typeof item === 'object' && item !== null && typeof item.handler === 'function') {
|
|
16
|
+
return { handler: item.handler, consumerGroup: item.consumerGroup };
|
|
17
|
+
}
|
|
16
18
|
throw new Error(
|
|
17
19
|
`Listener at ${filePath}${label} must be a function or an object with a "handler" function`,
|
|
18
20
|
);
|
|
@@ -54,11 +56,23 @@ class EventHandler {
|
|
|
54
56
|
|
|
55
57
|
const items = Array.isArray(exported) ? exported : [exported];
|
|
56
58
|
for (let i = 0; i < items.length; i++) {
|
|
57
|
-
const handler = validateHandler(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
const { handler, consumerGroup } = validateHandler(
|
|
60
|
+
items[i],
|
|
61
|
+
filePath,
|
|
62
|
+
items.length > 1 ? i : undefined,
|
|
63
|
+
);
|
|
64
|
+
const groupName =
|
|
65
|
+
consumerGroup === false
|
|
66
|
+
? undefined
|
|
67
|
+
: consumerGroup ?? `listener:${relativePath}:${items.length > 1 ? i : 0}`;
|
|
68
|
+
this._events.subscribe(
|
|
69
|
+
event,
|
|
70
|
+
(payload, _eventName) => {
|
|
71
|
+
const ctx = buildContext(this._appContext, { event: { name: _eventName, payload } });
|
|
72
|
+
return handler(ctx);
|
|
73
|
+
},
|
|
74
|
+
{ consumerGroup: groupName },
|
|
75
|
+
);
|
|
62
76
|
this._listeners.push({ event, fileName: relativePath });
|
|
63
77
|
}
|
|
64
78
|
this._log?.info(` ${relativePath} → ${event}${items.length > 1 ? ` (${items.length} listeners)` : ''}`);
|
package/server/events/index.js
CHANGED
|
@@ -18,8 +18,8 @@ class Events {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
subscribe(pattern, handler) {
|
|
22
|
-
this._transport.subscribe(pattern, handler);
|
|
21
|
+
subscribe(pattern, handler, options) {
|
|
22
|
+
return this._transport.subscribe(pattern, handler, options);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
async emit(eventName, payload) {
|
package/server/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
export { type } from 'arktype';
|
|
2
2
|
export { buildContext } from './context.js';
|
|
3
|
-
export * as vault from './lib/vault/index.js';
|
|
4
3
|
export { default as boot } from './boot.js';
|
|
4
|
+
export { CapabilityNotAvailable, CapabilityRegistry } from './plugins/capabilities.js';
|
|
5
|
+
export { PluginManager, createPluginManager } from './plugins/manager.js';
|
|
5
6
|
export {
|
|
6
7
|
Arcway,
|
|
7
8
|
Solo,
|
package/server/internals.js
CHANGED
|
@@ -42,6 +42,9 @@ import { createRateLimitMiddleware } from './rate-limit/index.js';
|
|
|
42
42
|
import { createHttpServer, listen, closeServer } from './server.js';
|
|
43
43
|
import { startWatcher } from './watcher.js';
|
|
44
44
|
import { build } from './build.js';
|
|
45
|
+
import { CapabilityNotAvailable, CapabilityRegistry } from './plugins/capabilities.js';
|
|
46
|
+
import { discoverPlugins } from './plugins/discovery.js';
|
|
47
|
+
import { PluginManager, createPluginManager } from './plugins/manager.js';
|
|
45
48
|
import {
|
|
46
49
|
discoverPages,
|
|
47
50
|
discoverLayouts,
|
|
@@ -68,6 +71,8 @@ import { attachGraphQLSubscriptions } from './graphql/subscriptions.js';
|
|
|
68
71
|
export {
|
|
69
72
|
ApiRouter,
|
|
70
73
|
Cache,
|
|
74
|
+
CapabilityNotAvailable,
|
|
75
|
+
CapabilityRegistry,
|
|
71
76
|
makeConfig,
|
|
72
77
|
Files,
|
|
73
78
|
KnexQueueDriver,
|
|
@@ -101,9 +106,11 @@ export {
|
|
|
101
106
|
createLoaderFactory,
|
|
102
107
|
createPagesHandler,
|
|
103
108
|
createPagesWatcher,
|
|
109
|
+
createPluginManager,
|
|
104
110
|
createRateLimitMiddleware,
|
|
105
111
|
createWebSocketServer,
|
|
106
112
|
discoverJobs,
|
|
113
|
+
discoverPlugins,
|
|
107
114
|
EventHandler,
|
|
108
115
|
discoverErrorPages,
|
|
109
116
|
discoverGraphQL,
|
|
@@ -127,6 +134,7 @@ export {
|
|
|
127
134
|
mergeWebSocketHandlers,
|
|
128
135
|
parseCron,
|
|
129
136
|
patternToFileName,
|
|
137
|
+
PluginManager,
|
|
130
138
|
resolveLayoutChain,
|
|
131
139
|
resolveSessionConfig,
|
|
132
140
|
runSeeds,
|
|
@@ -16,6 +16,9 @@ async function runHandler(reg, payload, workerPool) {
|
|
|
16
16
|
return;
|
|
17
17
|
}
|
|
18
18
|
const ctx = buildContext(reg.store, { payload });
|
|
19
|
+
if (reg.definition.plugin) {
|
|
20
|
+
reg.store.plugins?.injectCapabilities(ctx, reg.definition.plugin.requires);
|
|
21
|
+
}
|
|
19
22
|
await reg.definition.handler(ctx);
|
|
20
23
|
}
|
|
21
24
|
|