arcway 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcway",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "A convention-based framework for building modular monoliths with strict domain boundaries.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,6 +19,9 @@ import { createViteDevRouter } from '../pages/vite-dev.js';
19
19
  import { resolvePagesOutDir } from '../pages/out-dir.js';
20
20
  import { collectRuntimeMeta } from '../meta.js';
21
21
  import { createPluginManager } from '../plugins/manager.js';
22
+ import { CallbackDispatcher, CallbackRegistry } from '../callbacks/index.js';
23
+ import { CallbackStore } from '../callbacks/store.js';
24
+ import { discoverRootCallbacks } from '../callbacks/discovery.js';
22
25
  import path from 'node:path';
23
26
 
24
27
  async function boot(options) {
@@ -35,6 +38,12 @@ async function boot(options) {
35
38
 
36
39
  const infrastructure = await createInfrastructure(config, { runMigrations: true });
37
40
  const { db, redis, queue, cache, files, mail, events, log } = infrastructure;
41
+ const callbackStore = new CallbackStore(db);
42
+ await callbackStore.init();
43
+ const callbacks = new CallbackRegistry({
44
+ store: callbackStore,
45
+ secret: config.vault?.values?.callback,
46
+ });
38
47
  const meta = await collectRuntimeMeta(rootDir);
39
48
 
40
49
  if (envFiles.length > 0) log.info('Env files loaded', { envFiles });
@@ -58,6 +67,7 @@ async function boot(options) {
58
67
  fileWatcher,
59
68
  meta,
60
69
  vault: config.vault,
70
+ callbacks,
61
71
  };
62
72
 
63
73
  plugins.log = log;
@@ -98,6 +108,7 @@ async function boot(options) {
98
108
  log,
99
109
  meta,
100
110
  vault: config.vault,
111
+ callbacks,
101
112
  workerPool,
102
113
  plugins,
103
114
  });
@@ -113,7 +124,7 @@ async function boot(options) {
113
124
  sessionConfig: config.session,
114
125
  });
115
126
  await apiRouter.init();
116
- if (fileWatcher) plugins.watch(fileWatcher, { apiRouter, jobRunner });
127
+ if (fileWatcher) plugins.watch(fileWatcher, { jobRunner });
117
128
 
118
129
  const pagesRouter = new PagesRouter(config, { rootDir, log, mode, fileWatcher, appContext });
119
130
  await pagesRouter.init();
@@ -128,7 +139,17 @@ async function boot(options) {
128
139
  db,
129
140
  redisClients: [{ name: 'redis', client: redis?.client }],
130
141
  };
131
- const systemRouter = new SystemRouter({ healthDeps });
142
+ const callbackHandlers = await discoverRootCallbacks(rootDir);
143
+ const pluginCallbacks = (await plugins.discoverCallbacks?.()) ?? new Map();
144
+ for (const [name, callback] of pluginCallbacks) callbackHandlers.set(name, callback);
145
+ const callbackDispatcher = new CallbackDispatcher({
146
+ appContext,
147
+ callbacks: callbackHandlers,
148
+ registry: callbacks,
149
+ sessionConfig: config.session,
150
+ log,
151
+ });
152
+ const systemRouter = new SystemRouter({ healthDeps, callbackDispatcher });
132
153
 
133
154
  const staticRouter = new StaticRouter({
134
155
  outDir: pagesOutDir,
package/server/build.js CHANGED
@@ -28,7 +28,6 @@ async function validateServerModules(config) {
28
28
  if (config?.api?.enabled !== false && config?.api?.dir) {
29
29
  await discoverRoutes(config.api.dir);
30
30
  await discoverMiddleware(config.api.dir);
31
- await plugins.discoverRoutes();
32
31
  }
33
32
 
34
33
  if (config?.events?.dir) {
@@ -0,0 +1,12 @@
1
+ import { CallbackStore } from './store.js';
2
+
3
+ const cleanupCallbacksJob = {
4
+ name: 'cleanup-callbacks',
5
+ schedule: '*/15 * * * *',
6
+ handler: async (ctx) => {
7
+ const store = new CallbackStore(ctx.db);
8
+ await store.cleanup();
9
+ },
10
+ };
11
+
12
+ export { cleanupCallbacksJob };
@@ -0,0 +1,33 @@
1
+ import path from 'node:path';
2
+ import { discoverModules } from '../discovery.js';
3
+
4
+ function callbackName(relativePath) {
5
+ return relativePath.replace(/\\/g, '/').replace(/\.js$/, '');
6
+ }
7
+
8
+ function buildCallbacksFromModules(entries, { prefix = '' } = {}) {
9
+ const callbacks = new Map();
10
+ for (const entry of entries) {
11
+ const handler = entry.module.default ?? entry.module.handler;
12
+ if (typeof handler !== 'function') {
13
+ throw new Error(`Callback at ${entry.filePath} must export a handler function`);
14
+ }
15
+ const name = `${prefix}${callbackName(entry.relativePath)}`;
16
+ callbacks.set(name, { name, filePath: entry.filePath, handler });
17
+ }
18
+ return callbacks;
19
+ }
20
+
21
+ async function discoverCallbacks(callbacksDir) {
22
+ const entries = await discoverModules(callbacksDir, {
23
+ recursive: true,
24
+ label: 'callback file',
25
+ });
26
+ return buildCallbacksFromModules(entries);
27
+ }
28
+
29
+ async function discoverRootCallbacks(rootDir) {
30
+ return discoverCallbacks(path.join(rootDir, 'callbacks'));
31
+ }
32
+
33
+ export { buildCallbacksFromModules, discoverCallbacks, discoverRootCallbacks };
@@ -0,0 +1,173 @@
1
+ import { serializeResponse, sendJson } from '../router/http-helpers.js';
2
+ import { buildSessionClearCookie, buildSessionSetCookie, sealSession } from '../session/index.js';
3
+ import { buildContext } from '../context.js';
4
+ import { CallbackRateLimiter } from './rate-limit.js';
5
+ import { sealCallbackToken, unsealCallbackToken } from './tokens.js';
6
+
7
+ function getCallbackToken(req, params) {
8
+ const state = req.query?.state ?? params.query?.get?.('state');
9
+ if (state) return state;
10
+ const pathToken = params.path && params.path !== '/' ? params.path.replace(/^\/+/, '') : '';
11
+ return pathToken ? decodeURIComponent(pathToken) : null;
12
+ }
13
+
14
+ function buildReqInfo(req) {
15
+ return {
16
+ id: req.id,
17
+ ip: req.ip,
18
+ method: req.method ?? 'GET',
19
+ path: (req.url ?? '/').split('?')[0],
20
+ query: req.query ?? {},
21
+ body: req.body,
22
+ rawBody: req.rawBody,
23
+ headers: req.flatHeaders ?? {},
24
+ cookies: req.cookies ?? {},
25
+ session: req.session,
26
+ };
27
+ }
28
+
29
+ class CallbackRegistry {
30
+ constructor({ store, secret }) {
31
+ this.store = store;
32
+ this.secret = secret;
33
+ }
34
+
35
+ bind(ctx) {
36
+ return {
37
+ register: async ({ handler, payload, oneTime = true, ttl } = {}) => {
38
+ if (!this.secret) throw new Error('Callback key is required');
39
+ const boundToSession =
40
+ oneTime !== false ? (ctx.req?.session?.id ?? ctx.req?.session?.userId ?? null) : null;
41
+ const row = await this.store.register({
42
+ handler,
43
+ payload,
44
+ oneTime,
45
+ ttl,
46
+ boundToSession,
47
+ });
48
+ return sealCallbackToken({ id: row.id, expiresAt: row.expiresAt }, this.secret);
49
+ },
50
+ };
51
+ }
52
+ }
53
+
54
+ class CallbackDispatcher {
55
+ constructor({ appContext, callbacks, registry, sessionConfig, log, rateLimiter } = {}) {
56
+ this.appContext = appContext;
57
+ this.callbacks = callbacks ?? new Map();
58
+ this.registry = registry;
59
+ this.sessionConfig = sessionConfig ?? null;
60
+ this.log = log;
61
+ this.rateLimiter = rateLimiter ?? new CallbackRateLimiter();
62
+ }
63
+
64
+ async handle(req, res, params) {
65
+ const rate = this.rateLimiter.check(req.ip);
66
+ if (!rate.allowed) {
67
+ sendJson(
68
+ res,
69
+ 429,
70
+ { error: { code: 'RATE_LIMITED', message: 'Callback rate limit exceeded' } },
71
+ {
72
+ 'X-RateLimit-Remaining': '0',
73
+ 'X-RateLimit-Reset': String(rate.resetAt),
74
+ 'Retry-After': String(Math.max(1, rate.resetAt - Math.ceil(Date.now() / 1000))),
75
+ },
76
+ );
77
+ return;
78
+ }
79
+
80
+ const token = getCallbackToken(req, params);
81
+ if (!token) {
82
+ sendJson(res, 400, {
83
+ error: { code: 'CALLBACK_TOKEN_REQUIRED', message: 'Callback token is required' },
84
+ });
85
+ return;
86
+ }
87
+
88
+ let sealed;
89
+ try {
90
+ sealed = await unsealCallbackToken(token, this.registry.secret);
91
+ } catch {
92
+ sendJson(res, 400, {
93
+ error: { code: 'INVALID_CALLBACK_TOKEN', message: 'Invalid callback token' },
94
+ });
95
+ return;
96
+ }
97
+
98
+ const row = await this.registry.store.consume(sealed.id);
99
+ if (!row) {
100
+ sendJson(res, 410, {
101
+ error: { code: 'CALLBACK_EXPIRED_OR_USED', message: 'Callback expired or already used' },
102
+ });
103
+ return;
104
+ }
105
+
106
+ if (row.boundToSession) {
107
+ const sessionId = req.session?.id ?? req.session?.userId ?? null;
108
+ if (sessionId !== row.boundToSession) {
109
+ sendJson(res, 403, {
110
+ error: { code: 'CALLBACK_SESSION_MISMATCH', message: 'Callback session mismatch' },
111
+ });
112
+ return;
113
+ }
114
+ }
115
+
116
+ const target = this.callbacks.get(row.target);
117
+ if (!target) {
118
+ sendJson(res, 404, {
119
+ error: { code: 'CALLBACK_HANDLER_NOT_FOUND', message: 'Callback handler not found' },
120
+ });
121
+ return;
122
+ }
123
+
124
+ const ctx = buildContext(
125
+ {
126
+ ...this.appContext,
127
+ log: this.log?.extend ? this.log.extend({ requestId: req.id }) : this.appContext.log,
128
+ },
129
+ {
130
+ req: buildReqInfo(req),
131
+ callback: { payload: row.payload, target: row.target, id: row.id },
132
+ },
133
+ );
134
+
135
+ let response;
136
+ try {
137
+ response = await target.handler(ctx);
138
+ } catch (err) {
139
+ this.log?.error?.('Callback handler error', { target: row.target, error: String(err) });
140
+ sendJson(res, 500, {
141
+ error: { code: 'CALLBACK_HANDLER_ERROR', message: 'Callback handler error' },
142
+ });
143
+ return;
144
+ }
145
+
146
+ const responseHeaders = { ...(response?.headers ?? {}) };
147
+ if (response?.redirect) {
148
+ res.writeHead(response.status ?? 302, { ...responseHeaders, Location: response.redirect });
149
+ res.end();
150
+ return;
151
+ }
152
+ if (response?.session !== void 0) {
153
+ if (!this.sessionConfig) {
154
+ throw new Error('Callback handler returned session but no session is configured');
155
+ }
156
+ responseHeaders['Set-Cookie'] =
157
+ response.session === null
158
+ ? buildSessionClearCookie(this.sessionConfig)
159
+ : buildSessionSetCookie(
160
+ await sealSession(response.session, this.sessionConfig),
161
+ this.sessionConfig,
162
+ );
163
+ }
164
+ await serializeResponse(
165
+ res,
166
+ response ?? { data: null },
167
+ responseHeaders,
168
+ response?.status ?? 200,
169
+ );
170
+ }
171
+ }
172
+
173
+ export { CallbackDispatcher, CallbackRegistry };
@@ -0,0 +1,32 @@
1
+ const DEFAULT_CALLBACK_RATE_LIMIT = Object.freeze({
2
+ max: 30,
3
+ windowMs: 60_000,
4
+ });
5
+
6
+ class CallbackRateLimiter {
7
+ constructor({
8
+ max = DEFAULT_CALLBACK_RATE_LIMIT.max,
9
+ windowMs = DEFAULT_CALLBACK_RATE_LIMIT.windowMs,
10
+ } = {}) {
11
+ this.max = max;
12
+ this.windowMs = windowMs;
13
+ this.buckets = new Map();
14
+ }
15
+
16
+ check(ip = 'unknown', now = Date.now()) {
17
+ const key = String(ip || 'unknown');
18
+ let bucket = this.buckets.get(key);
19
+ if (!bucket || bucket.resetAt <= now) {
20
+ bucket = { count: 0, resetAt: now + this.windowMs };
21
+ this.buckets.set(key, bucket);
22
+ }
23
+ bucket.count += 1;
24
+ return {
25
+ allowed: bucket.count <= this.max,
26
+ remaining: Math.max(0, this.max - bucket.count),
27
+ resetAt: Math.ceil(bucket.resetAt / 1000),
28
+ };
29
+ }
30
+ }
31
+
32
+ export { CallbackRateLimiter, DEFAULT_CALLBACK_RATE_LIMIT };
@@ -0,0 +1,121 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ const CALLBACK_TABLE = 'arcway_callbacks';
4
+ const DEFAULT_CALLBACK_TTL_SECONDS = 600;
5
+ const DEFAULT_CLEANUP_GRACE_SECONDS = 3600;
6
+
7
+ function toIso(value) {
8
+ return new Date(value).toISOString();
9
+ }
10
+
11
+ function serializePayload(payload) {
12
+ return JSON.stringify(payload ?? null);
13
+ }
14
+
15
+ function parseRow(row) {
16
+ if (!row) return null;
17
+ return {
18
+ id: row.id,
19
+ target: row.target,
20
+ payload: JSON.parse(row.payload),
21
+ oneTime: Boolean(row.one_time),
22
+ used: Boolean(row.used),
23
+ boundToSession: row.bound_to_session ?? null,
24
+ expiresAt: row.expires_at,
25
+ };
26
+ }
27
+
28
+ class CallbackStore {
29
+ constructor(db, { tableName = CALLBACK_TABLE } = {}) {
30
+ this.db = db;
31
+ this.tableName = tableName;
32
+ }
33
+
34
+ async init() {
35
+ if (!this.db) return;
36
+ const exists = await this.db.schema.hasTable(this.tableName);
37
+ if (exists) return;
38
+ await this.db.schema.createTable(this.tableName, (table) => {
39
+ table.string('id').primary();
40
+ table.string('target').notNullable().index();
41
+ table.text('payload').notNullable();
42
+ table.boolean('one_time').notNullable().defaultTo(true);
43
+ table.boolean('used').notNullable().defaultTo(false).index();
44
+ table.string('bound_to_session').nullable().index();
45
+ table.timestamp('expires_at').notNullable().index();
46
+ table.timestamp('created_at').defaultTo(this.db.fn.now());
47
+ table.timestamp('updated_at').defaultTo(this.db.fn.now());
48
+ });
49
+ }
50
+
51
+ async register({ handler, payload, oneTime = true, ttl, boundToSession }) {
52
+ if (!this.db) throw new Error('Callback registration requires a database');
53
+ if (!handler || typeof handler !== 'string') {
54
+ throw new Error('Callback handler must be a non-empty string');
55
+ }
56
+ const ttlSeconds = ttl ?? DEFAULT_CALLBACK_TTL_SECONDS;
57
+ if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) {
58
+ throw new Error('Callback ttl must be a positive number of seconds');
59
+ }
60
+
61
+ const now = Date.now();
62
+ const row = {
63
+ id: randomUUID(),
64
+ target: handler,
65
+ payload: serializePayload(payload),
66
+ one_time: oneTime !== false,
67
+ used: false,
68
+ bound_to_session: boundToSession ?? null,
69
+ expires_at: toIso(now + ttlSeconds * 1000),
70
+ created_at: toIso(now),
71
+ updated_at: toIso(now),
72
+ };
73
+ await this.db(this.tableName).insert(row);
74
+ return parseRow(row);
75
+ }
76
+
77
+ async claim(id, now = new Date()) {
78
+ const nowIso = toIso(now);
79
+ return this.db.transaction(async (trx) => {
80
+ const updated = await trx(this.tableName)
81
+ .where('id', id)
82
+ .andWhere('one_time', true)
83
+ .andWhere('used', false)
84
+ .andWhere('expires_at', '>', nowIso)
85
+ .update({ used: true, updated_at: nowIso });
86
+ if (updated === 0) return null;
87
+ const row = await trx(this.tableName).where('id', id).first();
88
+ return parseRow(row);
89
+ });
90
+ }
91
+
92
+ async getActive(id, now = new Date()) {
93
+ const row = await this.db(this.tableName)
94
+ .where('id', id)
95
+ .andWhere('one_time', false)
96
+ .andWhere('expires_at', '>', toIso(now))
97
+ .first();
98
+ if (!row || row.used) return null;
99
+ return parseRow(row);
100
+ }
101
+
102
+ async consume(id, now = new Date()) {
103
+ const claimed = await this.claim(id, now);
104
+ if (claimed) return claimed;
105
+ return this.getActive(id, now);
106
+ }
107
+
108
+ async cleanup({ graceSeconds = DEFAULT_CLEANUP_GRACE_SECONDS } = {}) {
109
+ const now = Date.now();
110
+ const expiredCutoff = toIso(now);
111
+ const usedCutoff = toIso(now - graceSeconds * 1000);
112
+ return this.db(this.tableName)
113
+ .where('expires_at', '<=', expiredCutoff)
114
+ .orWhere(function () {
115
+ this.where('used', true).andWhere('updated_at', '<=', usedCutoff);
116
+ })
117
+ .delete();
118
+ }
119
+ }
120
+
121
+ export { CALLBACK_TABLE, CallbackStore };
@@ -0,0 +1,44 @@
1
+ import { sealData, unsealData } from 'iron-session';
2
+
3
+ function requireCallbackKey(secret) {
4
+ if (!secret) {
5
+ throw new Error('Callback key is required');
6
+ }
7
+ return secret;
8
+ }
9
+
10
+ async function sealCallbackToken({ id, expiresAt }, secret) {
11
+ const exp = new Date(expiresAt).getTime();
12
+ if (!id || !Number.isFinite(exp)) {
13
+ throw new Error('Callback token requires id and expiresAt');
14
+ }
15
+ return sealData(
16
+ { id, exp },
17
+ {
18
+ password: requireCallbackKey(secret),
19
+ ttl: 0,
20
+ },
21
+ );
22
+ }
23
+
24
+ async function unsealCallbackToken(token, secret, now = Date.now()) {
25
+ let data;
26
+ try {
27
+ data = await unsealData(token, {
28
+ password: requireCallbackKey(secret),
29
+ ttl: 0,
30
+ });
31
+ } catch {
32
+ throw new Error('Invalid callback token');
33
+ }
34
+
35
+ if (!data || typeof data !== 'object' || typeof data.id !== 'string') {
36
+ throw new Error('Invalid callback token');
37
+ }
38
+ if (!Number.isFinite(data.exp) || data.exp <= now) {
39
+ throw new Error('Callback token expired');
40
+ }
41
+ return { id: data.id, exp: data.exp };
42
+ }
43
+
44
+ export { sealCallbackToken, unsealCallbackToken };
@@ -34,10 +34,19 @@ const SERVER_ONLY_PACKAGES = new Set([
34
34
  'commander',
35
35
  'glob',
36
36
  'esbuild',
37
+ 'vite',
38
+ '@vitejs/plugin-react',
39
+ '@babel/core',
40
+ '@babel/preset-typescript',
41
+ '@tailwindcss/node',
42
+ '@tailwindcss/vite',
37
43
  'postcss',
38
44
  '@tailwindcss/postcss',
39
45
  'tailwindcss',
40
46
  '@tailwindcss/oxide',
47
+ 'playwright',
48
+ 'playwright-core',
49
+ 'chromium-bidi',
41
50
  'tsx',
42
51
  'cache-manager',
43
52
  'rate-limiter-flexible',
package/server/context.js CHANGED
@@ -154,6 +154,7 @@ function buildContext(appContext, extras) {
154
154
  _dbStats: tracked.stats,
155
155
  ...normalizedExtras,
156
156
  };
157
+ ctx.callbacks = appContext.callbacks?.bind?.(ctx);
157
158
  ctx.plugins = appContext.plugins?.buildContextProxy?.(ctx);
158
159
  return ctx;
159
160
  }
@@ -3,7 +3,7 @@ import { discoverModules } from '../discovery.js';
3
3
  async function runSeeds(db, seedsDir) {
4
4
  const entries = await discoverModules(seedsDir, { label: 'seed file' });
5
5
  const results = [];
6
- for (const { name, filePath, module } of entries) {
6
+ for (const { name, module } of entries) {
7
7
  const seedFn = module.default;
8
8
  if (typeof seedFn !== 'function') {
9
9
  throw new Error(`Seed file "${name}" must export a default function`);
@@ -1,4 +1,3 @@
1
- import path from 'node:path';
2
1
  import { discoverModules } from '../discovery.js';
3
2
  import { buildContext } from '../context.js';
4
3
 
@@ -64,7 +63,7 @@ class EventHandler {
64
63
  const groupName =
65
64
  consumerGroup === false
66
65
  ? undefined
67
- : consumerGroup ?? `listener:${relativePath}:${items.length > 1 ? i : 0}`;
66
+ : (consumerGroup ?? `listener:${relativePath}:${items.length > 1 ? i : 0}`);
68
67
  this._events.subscribe(
69
68
  event,
70
69
  (payload, _eventName) => {
@@ -75,7 +74,9 @@ class EventHandler {
75
74
  );
76
75
  this._listeners.push({ event, fileName: relativePath });
77
76
  }
78
- this._log?.info(` ${relativePath} → ${event}${items.length > 1 ? ` (${items.length} listeners)` : ''}`);
77
+ this._log?.info(
78
+ ` ${relativePath} → ${event}${items.length > 1 ? ` (${items.length} listeners)` : ''}`,
79
+ );
79
80
  }
80
81
  }
81
82
 
@@ -51,7 +51,20 @@ class JobRunner {
51
51
 
52
52
  constructor(
53
53
  config,
54
- { db, queue, cache, files, mail, events, log, meta, vault, workerPool, plugins } = {},
54
+ {
55
+ db,
56
+ queue,
57
+ cache,
58
+ files,
59
+ mail,
60
+ events,
61
+ log,
62
+ meta,
63
+ vault,
64
+ callbacks,
65
+ workerPool,
66
+ plugins,
67
+ } = {},
55
68
  ) {
56
69
  this._config = config;
57
70
  this._log = log;
@@ -60,7 +73,19 @@ class JobRunner {
60
73
  staleTimeoutMs: config?.staleTimeoutMs,
61
74
  workerPool,
62
75
  });
63
- this._appContext = { db, queue, cache, files, mail, events, log, meta, vault, plugins };
76
+ this._appContext = {
77
+ db,
78
+ queue,
79
+ cache,
80
+ files,
81
+ mail,
82
+ events,
83
+ log,
84
+ meta,
85
+ vault,
86
+ callbacks,
87
+ plugins,
88
+ };
64
89
  }
65
90
 
66
91
  get dispatcher() {
@@ -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);
@@ -1,6 +1,5 @@
1
1
  import { Worker } from 'node:worker_threads';
2
2
  import { fileURLToPath } from 'node:url';
3
- import path from 'node:path';
4
3
 
5
4
  const WORKER_ENTRY_URL = new URL('./worker-entry.js', import.meta.url);
6
5
 
@@ -210,7 +209,10 @@ class WorkerPool {
210
209
  resolve();
211
210
  };
212
211
  const timer = setTimeout(() => {
213
- worker.terminate().catch(() => {}).finally(done);
212
+ worker
213
+ .terminate()
214
+ .catch(() => {})
215
+ .finally(done);
214
216
  }, gracefulExitMs);
215
217
  if (typeof timer.unref === 'function') timer.unref();
216
218
  worker.once('exit', done);
@@ -219,7 +221,10 @@ class WorkerPool {
219
221
  worker.postMessage({ id: 0, task: { __shutdown: true } });
220
222
  } catch {
221
223
  // Worker already down or channel closed — terminate to be safe.
222
- worker.terminate().catch(() => {}).finally(done);
224
+ worker
225
+ .terminate()
226
+ .catch(() => {})
227
+ .finally(done);
223
228
  }
224
229
  });
225
230
  }
@@ -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', 'jwt']);
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');
@@ -103,6 +103,7 @@ function deriveInfraSecret(master, name) {
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
105
  if (name === 'jwt') return base64Url(deriveBytes(master, name, 48));
106
+ if (name === 'callback') return base64Url(deriveBytes(master, name, 48));
106
107
  throw new Error(`Unknown Arcway infra secret namespace: ${name}`);
107
108
  }
108
109
 
@@ -1,7 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import { createMcpRuntime } from './runtime.js';
3
- import { createDebugHandler, isDebugRequest } from './debug-api.js';
4
- import { startMcpServer } from './server.js';
3
+ import { createDebugHandler } from './debug-api.js';
5
4
 
6
5
  const DEBUG_PREFIX = '/_mcp';
7
6
 
@@ -1,4 +1,3 @@
1
- import path from 'node:path';
2
1
  import { z } from 'zod';
3
2
  import { makeConfig } from '#server/config/loader.js';
4
3
  import { discoverRoutes } from '#server/router/routes.js';
@@ -44,7 +44,7 @@ async function hashFileContent(filePath) {
44
44
  }
45
45
  }
46
46
 
47
- function resolveInputPath(p, rootDir) {
47
+ function resolveInputPath(p) {
48
48
  if (path.isAbsolute(p)) return p;
49
49
  // esbuild metafile paths may be relative to cwd; try that first, then rootDir.
50
50
  const abs = path.resolve(p);
@@ -473,9 +473,7 @@ async function pruneStaleCache({ rootDir, kind }) {
473
473
  const metaPath = path.join(bucket, metaFile);
474
474
  const meta = await readJson(metaPath);
475
475
  const stale =
476
- !meta ||
477
- !Array.isArray(meta.inputs) ||
478
- (await hashInputs(meta.inputs)) !== meta.inputsHash;
476
+ !meta || !Array.isArray(meta.inputs) || (await hashInputs(meta.inputs)) !== meta.inputsHash;
479
477
 
480
478
  if (!stale) {
481
479
  kept += 1;
@@ -1,18 +1,27 @@
1
1
  import { createRequire } from 'node:module';
2
2
  import { NODE_BUILTINS, SERVER_ONLY_PACKAGES } from '../constants.js';
3
- function serverExternalsPlugin() {
3
+
4
+ function packageNameForImport(specifier) {
5
+ const parts = specifier.split('/');
6
+ return specifier.startsWith('@') && parts.length >= 2 ? `${parts[0]}/${parts[1]}` : parts[0];
7
+ }
8
+
9
+ function isBarePackageImport(specifier) {
10
+ if (/^[A-Za-z]:/.test(specifier)) return false;
11
+ if (specifier.startsWith('node:')) return false;
12
+ return /^[^.\/\#]/.test(specifier);
13
+ }
14
+
15
+ function packageExternalsPlugin({ name, shouldBundlePackage }) {
4
16
  const resolvedCache = new Map();
5
17
  return {
6
- name: 'arcway-server-externals',
18
+ name,
7
19
  setup(build) {
8
20
  build.onResolve({ filter: /^[^.\/\#]/ }, (args) => {
9
- if (/^[A-Za-z]:/.test(args.path)) return void 0;
10
- if (args.path.startsWith('node:')) return void 0;
11
- const parts = args.path.split('/');
12
- const pkgName =
13
- args.path.startsWith('@') && parts.length >= 2 ? `${parts[0]}/${parts[1]}` : parts[0];
21
+ if (!isBarePackageImport(args.path)) return void 0;
22
+ const pkgName = packageNameForImport(args.path);
14
23
  if (!resolvedCache.has(pkgName)) {
15
- resolvedCache.set(pkgName, needsBundling(pkgName, args.resolveDir));
24
+ resolvedCache.set(pkgName, shouldBundlePackage(pkgName, args.resolveDir));
16
25
  }
17
26
  if (resolvedCache.get(pkgName)) return void 0;
18
27
  return { path: args.path, external: true };
@@ -20,6 +29,21 @@ function serverExternalsPlugin() {
20
29
  },
21
30
  };
22
31
  }
32
+
33
+ function serverExternalsPlugin() {
34
+ return packageExternalsPlugin({
35
+ name: 'arcway-server-externals',
36
+ shouldBundlePackage: needsBundling,
37
+ });
38
+ }
39
+
40
+ function pluginPackageExternalsPlugin() {
41
+ return packageExternalsPlugin({
42
+ name: 'arcway-plugin-package-externals',
43
+ shouldBundlePackage: () => false,
44
+ });
45
+ }
46
+
23
47
  function needsBundling(pkgName, resolveDir) {
24
48
  try {
25
49
  const require2 = createRequire(resolveDir + '/');
@@ -44,9 +68,7 @@ function clientIsolationPlugin() {
44
68
  return void 0;
45
69
  });
46
70
  build.onResolve({ filter: /.*/ }, (args) => {
47
- const parts = args.path.split('/');
48
- const pkgName =
49
- args.path.startsWith('@') && parts.length >= 2 ? `${parts[0]}/${parts[1]}` : parts[0];
71
+ const pkgName = packageNameForImport(args.path);
50
72
  if (SERVER_ONLY_PACKAGES.has(pkgName)) {
51
73
  return { path: args.path, namespace: STUB_NAMESPACE };
52
74
  }
@@ -73,4 +95,9 @@ function clientIsolationPlugin() {
73
95
  },
74
96
  };
75
97
  }
76
- export { clientIsolationPlugin, serverExternalsPlugin };
98
+ export {
99
+ clientIsolationPlugin,
100
+ packageNameForImport,
101
+ pluginPackageExternalsPlugin,
102
+ serverExternalsPlugin,
103
+ };
@@ -391,7 +391,7 @@ async function runPageMiddleware(
391
391
  }
392
392
  return null;
393
393
  }
394
- function buildClientManifestJson(manifest, { rootDir, outDir, viteDev } = {}) {
394
+ function buildClientManifestJson(manifest, { rootDir, viteDev } = {}) {
395
395
  if (viteDev) {
396
396
  return buildViteClientManifestJson(manifest, rootDir);
397
397
  }
@@ -104,12 +104,7 @@ async function createLazyPagesContext(options) {
104
104
  { srcPath: path.resolve(m.filePath), built: false, stale: false },
105
105
  ]),
106
106
  ),
107
- loadings: new Map(
108
- loadings.map((l) => [
109
- l.dirPath,
110
- { srcPath: path.resolve(l.filePath) },
111
- ]),
112
- ),
107
+ loadings: new Map(loadings.map((l) => [l.dirPath, { srcPath: path.resolve(l.filePath) }])),
113
108
  stylesPath: stylesPath ? path.resolve(stylesPath) : null,
114
109
  version: 0,
115
110
  };
@@ -312,10 +307,7 @@ async function createLazyPagesContext(options) {
312
307
  }
313
308
  const existing = inFlightPages.get(pattern);
314
309
  if (existing) return existing;
315
- const task = Promise.all([
316
- limit(() => buildPageServer(entry)),
317
- ensureChainBuilt(entry),
318
- ])
310
+ const task = Promise.all([limit(() => buildPageServer(entry)), ensureChainBuilt(entry)])
319
311
  .then(() => {
320
312
  entry.layoutServerBundles = entry.layoutDirs
321
313
  .map((d) => manifest.layouts.get(d)?.serverBundle)
@@ -452,7 +444,6 @@ async function createLazyPagesContext(options) {
452
444
 
453
445
  const task = (async () => {
454
446
  const previousMetafile = currentClientMetafile;
455
- const previousCssBundle = manifest.cssBundle ?? null;
456
447
  const timestamp = Date.now();
457
448
 
458
449
  const [clientResult, cssBundle] = await Promise.all([
@@ -535,12 +526,7 @@ async function createLazyPagesContext(options) {
535
526
  if (disposed) throw new Error('Lazy pages context is disposed');
536
527
  if (inFlightRediscover) return inFlightRediscover;
537
528
  const task = (async () => {
538
- const [
539
- nextPages,
540
- nextLayouts,
541
- nextLoadings,
542
- nextMiddlewares,
543
- ] = await Promise.all([
529
+ const [nextPages, nextLayouts, nextLoadings, nextMiddlewares] = await Promise.all([
544
530
  discoverPages(pagesDir),
545
531
  discoverLayouts(pagesDir),
546
532
  discoverLoadings(pagesDir),
@@ -4,6 +4,7 @@ import { pathToFileURL } from 'node:url';
4
4
  import { build } from 'esbuild';
5
5
  import { discoverFiles } from '../discovery.js';
6
6
  import { loadModule } from '../module-loader.js';
7
+ import { pluginPackageExternalsPlugin } from '../pages/build-plugins.js';
7
8
 
8
9
  let bundleCounter = 0;
9
10
 
@@ -32,15 +33,15 @@ async function loadPluginBundle(packageDir) {
32
33
 
33
34
  const pluginJs = path.join(packageDir, 'plugin.js');
34
35
  const exportsJs = path.join(packageDir, 'exports.js');
35
- const routesDir = path.join(packageDir, 'routes');
36
36
  const jobsDir = path.join(packageDir, 'jobs');
37
+ const callbacksDir = path.join(packageDir, 'callbacks');
37
38
  const capabilitiesDir = path.join(packageDir, 'capabilities');
38
39
  const hooksDir = path.join(packageDir, 'hooks');
39
40
 
40
41
  const hasPluginJs = await exists(pluginJs);
41
42
  const hasExportsJs = await exists(exportsJs);
42
- const routes = await discoverFiles(routesDir, { recursive: true });
43
43
  const jobs = await discoverFiles(jobsDir, { recursive: true });
44
+ const callbacks = await discoverFiles(callbacksDir, { recursive: true });
44
45
  const capabilities = await discoverFiles(capabilitiesDir);
45
46
  const hooks = await discoverFiles(hooksDir);
46
47
 
@@ -48,12 +49,12 @@ async function loadPluginBundle(packageDir) {
48
49
  if (hasPluginJs) lines.push(importLine('pluginModule', pluginJs));
49
50
  if (hasExportsJs) lines.push(importLine('pluginExports', exportsJs, false));
50
51
 
51
- for (const [index, entry] of routes.entries()) {
52
- lines.push(importLine(`route${index}`, entry.filePath));
53
- }
54
52
  for (const [index, entry] of jobs.entries()) {
55
53
  lines.push(importLine(`job${index}`, entry.filePath));
56
54
  }
55
+ for (const [index, entry] of callbacks.entries()) {
56
+ lines.push(importLine(`callback${index}`, entry.filePath));
57
+ }
57
58
  for (const [index, entry] of capabilities.entries()) {
58
59
  lines.push(importLine(`capability${index}`, entry.filePath, false));
59
60
  }
@@ -66,10 +67,10 @@ async function loadPluginBundle(packageDir) {
66
67
  `export const exportsModule = ${hasExportsJs ? '{ default: pluginExports }' : 'null'};`,
67
68
  );
68
69
  lines.push(
69
- `export const routes = [${routes.map((entry, index) => moduleEntry(`route${index}`, entry)).join(',')}];`,
70
+ `export const jobs = [${jobs.map((entry, index) => moduleEntry(`job${index}`, entry)).join(',')}];`,
70
71
  );
71
72
  lines.push(
72
- `export const jobs = [${jobs.map((entry, index) => moduleEntry(`job${index}`, entry)).join(',')}];`,
73
+ `export const callbacks = [${callbacks.map((entry, index) => moduleEntry(`callback${index}`, entry)).join(',')}];`,
73
74
  );
74
75
  lines.push(
75
76
  `export const capabilities = {${capabilities
@@ -101,14 +102,15 @@ async function loadPluginBundle(packageDir) {
101
102
  platform: 'node',
102
103
  target: 'node20',
103
104
  logLevel: 'silent',
105
+ plugins: [pluginPackageExternalsPlugin()],
104
106
  });
105
107
 
106
108
  const bundled = await loadModule(outFile);
107
109
  return {
108
110
  module: bundled.module ?? {},
109
111
  exportsModule: bundled.exportsModule ?? null,
110
- routes: bundled.routes ?? [],
111
112
  jobs: bundled.jobs ?? [],
113
+ callbacks: bundled.callbacks ?? [],
112
114
  capabilities: bundled.capabilities ?? {},
113
115
  hooks: bundled.hooks ?? {},
114
116
  artifact: outFile,
@@ -90,7 +90,7 @@ async function loadPluginPackage(packageDir, { bundle = false } = {}) {
90
90
  rawManifest = await readJson(pluginJson, 'plugin.json');
91
91
  } else if (packageJson.arcway && typeof packageJson.arcway === 'object') {
92
92
  // Metadata-only plugin: the manifest lives in package.json's "arcway" block,
93
- // and code is discovered by folder convention (routes/, jobs/, capabilities/, hooks/).
93
+ // and code is discovered by folder convention (jobs/, callbacks/, capabilities/, hooks/).
94
94
  manifestPath = packageJsonPath;
95
95
  rawManifest = {};
96
96
  } else {
@@ -111,8 +111,8 @@ async function loadPluginPackage(packageDir, { bundle = false } = {}) {
111
111
  { packageName: packageJson.name },
112
112
  );
113
113
 
114
- const routesDir = path.join(packageDir, 'routes');
115
114
  const jobsDir = path.join(packageDir, 'jobs');
115
+ const callbacksDir = path.join(packageDir, 'callbacks');
116
116
  if (bundle && !bundled) bundled = await loadPluginBundle(packageDir);
117
117
  const exportsModule = bundled
118
118
  ? bundled.exportsModule
@@ -129,10 +129,10 @@ async function loadPluginPackage(packageDir, { bundle = false } = {}) {
129
129
  bundle: bundled ? { artifact: bundled.artifact } : null,
130
130
  manifestPath,
131
131
  exports: exportsModule ? getExports(exportsModule, exportsJs) : {},
132
- routesDir: (await exists(routesDir)) ? routesDir : null,
133
132
  jobsDir: (await exists(jobsDir)) ? jobsDir : null,
134
- bundledRoutes: bundled?.routes ?? null,
133
+ callbacksDir: (await exists(callbacksDir)) ? callbacksDir : null,
135
134
  bundledJobs: bundled?.jobs ?? null,
135
+ bundledCallbacks: bundled?.callbacks ?? null,
136
136
  capabilities:
137
137
  bundled?.capabilities ??
138
138
  (await loadConventionModules(path.join(packageDir, 'capabilities'), 'capability')),
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path';
2
- import { buildRoutesFromModules, discoverRoutes } from '../router/routes.js';
3
2
  import { buildJobsFromModules, discoverJobs } from '../jobs/runner.js';
3
+ import { buildCallbacksFromModules, discoverCallbacks } from '../callbacks/discovery.js';
4
4
  import { CapabilityRegistry } from './capabilities.js';
5
5
  import { discoverPlugins } from './discovery.js';
6
6
  import { buildProviderMap, cascadeDisabled, resolvePluginGraph } from './graph.js';
@@ -166,26 +166,6 @@ class PluginManager {
166
166
  };
167
167
  }
168
168
 
169
- async discoverRoutes() {
170
- const routes = [];
171
- for (const plugin of this.enabledPlugins) {
172
- const routeOptions = {
173
- prefix: `/_plugins/${plugin.id}`,
174
- plugin: {
175
- id: plugin.id,
176
- requires: plugin.manifest.requires,
177
- },
178
- };
179
- const discovered = plugin.bundledRoutes
180
- ? buildRoutesFromModules(plugin.bundledRoutes, routeOptions)
181
- : plugin.routesDir
182
- ? await discoverRoutes(plugin.routesDir, routeOptions)
183
- : [];
184
- routes.push(...discovered);
185
- }
186
- return routes;
187
- }
188
-
189
169
  async discoverJobs() {
190
170
  const jobs = [];
191
171
  for (const plugin of this.enabledPlugins) {
@@ -209,6 +189,22 @@ class PluginManager {
209
189
  return jobs;
210
190
  }
211
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
+
212
208
  async reload(ids) {
213
209
  if (this.config.enabled !== true) return { reloaded: [] };
214
210
 
@@ -298,7 +294,7 @@ class PluginManager {
298
294
  return ids;
299
295
  }
300
296
 
301
- watch(fileWatcher, { apiRouter, jobRunner } = {}) {
297
+ watch(fileWatcher, { jobRunner } = {}) {
302
298
  if (this.config.enabled !== true || !fileWatcher) return;
303
299
  fileWatcher.subscribe('plugins', {
304
300
  dirs: this.config.dirs,
@@ -307,7 +303,6 @@ class PluginManager {
307
303
  onChange: async (events) => {
308
304
  const changedIds = this.pluginIdsForChangedFiles(events);
309
305
  await this.reload(changedIds);
310
- await apiRouter?.reload?.();
311
306
  await jobRunner?.reload?.();
312
307
  },
313
308
  });
@@ -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);
@@ -358,12 +354,10 @@ class ApiRouter {
358
354
  }
359
355
 
360
356
  async _discover() {
361
- const [routes, middleware, pluginRoutes] = await Promise.all([
357
+ const [routes, middleware] = await Promise.all([
362
358
  discoverRoutes(this._config.dir),
363
359
  discoverMiddleware(this._config.dir),
364
- this._pluginManager?.discoverRoutes?.() ?? [],
365
360
  ]);
366
- routes.push(...pluginRoutes);
367
361
  sortBySpecificity(routes);
368
362
 
369
363
  applyPrefix(routes, middleware, this._prefix);
@@ -1,4 +1,3 @@
1
- import path from 'node:path';
2
1
  import fs from 'node:fs';
3
2
  import { serveStaticAsset, servePublicFile } from '../pages/static.js';
4
3
 
@@ -1,5 +1,12 @@
1
+ import { cleanupCallbacksJob } from '../callbacks/cleanup-job.js';
2
+
1
3
  const SYSTEM_JOB_DOMAIN = '__system';
2
4
  const systemJobRegistry = [
5
+ {
6
+ definition: cleanupCallbacksJob,
7
+ shouldRegister: (config) => Boolean(config.vault?.values?.callback && config.database),
8
+ description: 'Delete expired callback registrations',
9
+ },
3
10
  // Future system jobs are added here. Examples:
4
11
  //
5
12
  // {
@@ -5,9 +5,11 @@ const SYSTEM_PREFIX = '/_system/api';
5
5
  class SystemRouter {
6
6
  routes = [];
7
7
  _healthDeps = null;
8
+ _callbackDispatcher = null;
8
9
 
9
- constructor({ healthDeps } = {}) {
10
+ constructor({ healthDeps, callbackDispatcher } = {}) {
10
11
  this._healthDeps = healthDeps ?? null;
12
+ this._callbackDispatcher = callbackDispatcher ?? null;
11
13
  }
12
14
 
13
15
  register(route) {
@@ -23,6 +25,18 @@ class SystemRouter {
23
25
  return this._handleHealth(res);
24
26
  }
25
27
 
28
+ const pathname = url.split('?')[0];
29
+ if (
30
+ this._callbackDispatcher &&
31
+ (pathname === '/_system/callback' || pathname.startsWith('/_system/callback/'))
32
+ ) {
33
+ const fullPath = pathname;
34
+ const path = fullPath.slice('/_system/callback'.length) || '/';
35
+ const query = new URL(url, 'http://localhost').searchParams;
36
+ await this._callbackDispatcher.handle(req, res, { path, query, method });
37
+ return true;
38
+ }
39
+
26
40
  if (!url.startsWith(SYSTEM_PREFIX)) return false;
27
41
  const fullPath = url.split('?')[0];
28
42
  const routePath = fullPath.slice(SYSTEM_PREFIX.length) || '/';