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.
Files changed (56) hide show
  1. package/client/fetcher.js +4 -1
  2. package/client/hooks/swr-compat.js +4 -2
  3. package/client/hooks/use-api-paginated.js +74 -0
  4. package/client/index.js +1 -0
  5. package/package.json +4 -3
  6. package/server/boot/index.js +22 -6
  7. package/server/build.js +73 -4
  8. package/server/cache/index.js +12 -2
  9. package/server/cache/ttl.js +24 -0
  10. package/server/config/duration.js +49 -0
  11. package/server/config/loader.js +4 -0
  12. package/server/config/modules/cache.js +7 -1
  13. package/server/config/modules/jobs.js +14 -1
  14. package/server/config/modules/mail.js +16 -1
  15. package/server/config/modules/mcp.js +5 -1
  16. package/server/config/modules/pages.js +1 -0
  17. package/server/config/modules/plugins.js +18 -0
  18. package/server/config/modules/queue.js +7 -1
  19. package/server/config/modules/secrets.js +145 -0
  20. package/server/config/modules/server.js +13 -1
  21. package/server/config/modules/session.js +5 -1
  22. package/server/config/modules/websocket.js +8 -0
  23. package/server/context.js +61 -2
  24. package/server/db/index.js +39 -9
  25. package/server/discovery.js +45 -3
  26. package/server/events/drivers/memory.js +22 -4
  27. package/server/events/drivers/redis.js +20 -5
  28. package/server/events/handler.js +21 -7
  29. package/server/events/index.js +2 -2
  30. package/server/index.js +2 -1
  31. package/server/internals.js +8 -0
  32. package/server/jobs/drivers/run-handler.js +3 -0
  33. package/server/jobs/runner.js +13 -4
  34. package/server/jobs/worker-config.js +58 -0
  35. package/server/jobs/worker-entry.js +7 -12
  36. package/server/lib/vault/index.js +20 -0
  37. package/server/lib/vault/secrets.js +149 -0
  38. package/server/meta.js +106 -0
  39. package/server/pages/build-plugins.js +2 -1
  40. package/server/pages/handler.js +23 -5
  41. package/server/pages/out-dir.js +103 -2
  42. package/server/pages/pages-router.js +3 -2
  43. package/server/pages/ssr.js +55 -18
  44. package/server/plugins/capabilities.js +66 -0
  45. package/server/plugins/discovery.js +111 -0
  46. package/server/plugins/graph.js +114 -0
  47. package/server/plugins/manager.js +210 -0
  48. package/server/plugins/manifest.js +94 -0
  49. package/server/router/api-router.js +41 -16
  50. package/server/router/routes.js +14 -2
  51. package/server/server.js +13 -1
  52. package/server/session/index.js +5 -1
  53. package/server/static/index.js +5 -2
  54. package/server/web-server.js +3 -3
  55. package/server/ws/realtime.js +24 -8
  56. 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
- return { ...DEFAULT_CORS, ...userConfig };
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 session = resolveSessionConfig(config.session, mode);
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
- ...extras,
131
+ ...normalizedExtras,
73
132
  };
74
133
  return ctx;
75
134
  }
76
135
 
77
- export { buildContext };
136
+ export { buildContext, buildErrResponse };
@@ -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
- const files = await discoverFiles(this.directory, { skipUnderscore: false });
14
- return files.map((f) => f.name);
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 ? { useNullAsDefault: true } : {}),
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
- if (!migrationsDir) return;
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
- if (!migrationsDir) throw new Error('No migrations dir configured');
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
  };
@@ -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(dir, { recursive = false, extensions = ['.js'], skipUnderscore = true, filter } = {}) {
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(dir, { recursive = false, extensions = ['.js'], skipUnderscore = true, filter, label = 'file' } = {}) {
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
- export { discoverFiles, discoverModules };
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
- this.subscriptions.push({ pattern, regex, handler });
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
- matching.map((sub) => sub.handler(eventName, payload)),
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 [${matching[i].pattern}] for event "${eventName}":`,
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
- this.subscriptions.push({ pattern, regex, handler });
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(matching.map((sub) => sub.handler(eventName, payload))).then((results) => {
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(
@@ -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') return item.handler;
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(items[i], filePath, items.length > 1 ? i : undefined);
58
- this._events.subscribe(event, (_eventName, payload) => {
59
- const ctx = buildContext(this._appContext, { event: { name: _eventName, payload } });
60
- handler(ctx);
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)` : ''}`);
@@ -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,
@@ -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