arcway 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcway",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
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) {
@@ -28,10 +31,19 @@ async function boot(options) {
28
31
  const envFiles = loadEnvFiles(rootDir, mode);
29
32
  const config = await makeConfig(rootDir, { overrides: options.configOverrides, mode });
30
33
 
31
- const plugins = await createPluginManager(config.plugins, { runLifecycle: false });
34
+ const plugins = await createPluginManager(config.plugins, {
35
+ runLifecycle: false,
36
+ bundle: mode === 'development',
37
+ });
32
38
 
33
39
  const infrastructure = await createInfrastructure(config, { runMigrations: true });
34
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
+ });
35
47
  const meta = await collectRuntimeMeta(rootDir);
36
48
 
37
49
  if (envFiles.length > 0) log.info('Env files loaded', { envFiles });
@@ -55,6 +67,7 @@ async function boot(options) {
55
67
  fileWatcher,
56
68
  meta,
57
69
  vault: config.vault,
70
+ callbacks,
58
71
  };
59
72
 
60
73
  plugins.log = log;
@@ -95,6 +108,7 @@ async function boot(options) {
95
108
  log,
96
109
  meta,
97
110
  vault: config.vault,
111
+ callbacks,
98
112
  workerPool,
99
113
  plugins,
100
114
  });
@@ -110,6 +124,7 @@ async function boot(options) {
110
124
  sessionConfig: config.session,
111
125
  });
112
126
  await apiRouter.init();
127
+ if (fileWatcher) plugins.watch(fileWatcher, { jobRunner });
113
128
 
114
129
  const pagesRouter = new PagesRouter(config, { rootDir, log, mode, fileWatcher, appContext });
115
130
  await pagesRouter.init();
@@ -124,7 +139,17 @@ async function boot(options) {
124
139
  db,
125
140
  redisClients: [{ name: 'redis', client: redis?.client }],
126
141
  };
127
- 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 });
128
153
 
129
154
  const staticRouter = new StaticRouter({
130
155
  outDir: pagesOutDir,
@@ -166,6 +191,7 @@ async function boot(options) {
166
191
  if (workerPool) await workerPool.shutdown();
167
192
  await pagesRouter.close();
168
193
  await apiRouter.close();
194
+ if (fileWatcher) plugins.unwatch(fileWatcher);
169
195
  if (viteRouter) await viteRouter.close();
170
196
  await webServer.close();
171
197
  if (fileWatcher) await fileWatcher.close();
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 };
package/server/context.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as vault from './lib/vault/index.js';
2
+ import { createJwtCodec } from './lib/vault/jwt.js';
2
3
  import { wsBroadcastToPath, wsSendToSocket } from './ws/registry.js';
3
4
 
4
5
  function trackDb(db) {
@@ -114,9 +115,11 @@ function normalizeMeta(meta) {
114
115
 
115
116
  function buildVaultToolkit(vaultConfig) {
116
117
  if (!vaultConfig?.masterSecret) return vault;
118
+ const jwtCodec = createJwtCodec(vaultConfig.values?.jwt);
117
119
 
118
120
  return {
119
121
  ...vault,
122
+ ...jwtCodec,
120
123
  deriveKey: (label) => vault.deriveKey(vaultConfig.masterSecret, label),
121
124
  deriveKeyPair: (label, opts) => vault.deriveKeyPair(vaultConfig.masterSecret, label, opts),
122
125
  };
@@ -151,6 +154,7 @@ function buildContext(appContext, extras) {
151
154
  _dbStats: tracked.stats,
152
155
  ...normalizedExtras,
153
156
  };
157
+ ctx.callbacks = appContext.callbacks?.bind?.(ctx);
154
158
  ctx.plugins = appContext.plugins?.buildContextProxy?.(ctx);
155
159
  return ctx;
156
160
  }
@@ -5,8 +5,7 @@ import { toError, calculateBackoff } from './queue.js';
5
5
  import JobDispatcher from './drivers/memory-queue.js';
6
6
  import { SYSTEM_JOB_DOMAIN, registerSystemJobs } from '../system-jobs/index.js';
7
7
 
8
- async function discoverJobs(jobsDir) {
9
- const entries = await discoverModules(jobsDir, { recursive: true, label: 'job file' });
8
+ function buildJobsFromModules(entries) {
10
9
  const jobs = [];
11
10
  for (const { name, filePath, relativePath, module } of entries) {
12
11
  const job = module.default;
@@ -31,6 +30,11 @@ async function discoverJobs(jobsDir) {
31
30
  return jobs;
32
31
  }
33
32
 
33
+ async function discoverJobs(jobsDir) {
34
+ const entries = await discoverModules(jobsDir, { recursive: true, label: 'job file' });
35
+ return buildJobsFromModules(entries);
36
+ }
37
+
34
38
  class JobRunner {
35
39
  _dispatcher;
36
40
  _config;
@@ -42,11 +46,25 @@ class JobRunner {
42
46
  _timer = null;
43
47
  _started = false;
44
48
  _stopped = false;
49
+ _runGeneration = 0;
45
50
  _lastEnqueuedMinute = new Map();
46
51
 
47
52
  constructor(
48
53
  config,
49
- { 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
+ } = {},
50
68
  ) {
51
69
  this._config = config;
52
70
  this._log = log;
@@ -55,7 +73,19 @@ class JobRunner {
55
73
  staleTimeoutMs: config?.staleTimeoutMs,
56
74
  workerPool,
57
75
  });
58
- 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
+ };
59
89
  }
60
90
 
61
91
  get dispatcher() {
@@ -135,10 +165,23 @@ class JobRunner {
135
165
  }
136
166
  }
137
167
 
168
+ async reload() {
169
+ const wasStarted = this._started;
170
+ this.stop();
171
+ this._jobs = [];
172
+ this._cronEntries = [];
173
+ this._continuousJobs = [];
174
+ this._lastEnqueuedMinute = new Map();
175
+ this._dispatcher.registered.clear();
176
+ await this.init();
177
+ if (wasStarted) this.start();
178
+ }
179
+
138
180
  start(intervalMs) {
139
181
  if (this._started) return;
140
182
  this._started = true;
141
183
  this._stopped = false;
184
+ const generation = ++this._runGeneration;
142
185
  const pollMs = intervalMs ?? this._config?.pollIntervalMs ?? 60000;
143
186
  this._timer = setInterval(() => {
144
187
  this.tick().catch((err) => {
@@ -146,7 +189,7 @@ class JobRunner {
146
189
  });
147
190
  }, pollMs);
148
191
  for (const entry of this._continuousJobs) {
149
- this._runContinuousLoop(entry);
192
+ this._runContinuousLoop(entry, generation);
150
193
  }
151
194
 
152
195
  const parts = [];
@@ -158,6 +201,7 @@ class JobRunner {
158
201
  stop() {
159
202
  this._stopped = true;
160
203
  this._started = false;
204
+ this._runGeneration++;
161
205
  if (this._timer) {
162
206
  clearInterval(this._timer);
163
207
  this._timer = null;
@@ -188,12 +232,12 @@ class JobRunner {
188
232
  await this._dispatcher.process();
189
233
  }
190
234
 
191
- async _runContinuousLoop(entry) {
235
+ async _runContinuousLoop(entry, generation) {
192
236
  const backoffMs = this._config?.backoffMs ?? 1000;
193
237
  const cooldownMs = entry.cooldownMs ?? this._config?.cooldownMs ?? 1000;
194
238
  let consecutiveErrors = 0;
195
239
  console.log(`[job-runner] continuous: ${entry.qualifiedName} started`);
196
- while (!this._stopped) {
240
+ while (!this._stopped && generation === this._runGeneration) {
197
241
  try {
198
242
  const start = Date.now();
199
243
  const ctx = buildContext(entry.appContext, { payload: void 0 });
@@ -206,7 +250,7 @@ class JobRunner {
206
250
  if (cooldownMs > 0 && elapsed < cooldownMs) {
207
251
  const remaining = cooldownMs - elapsed;
208
252
  const deadline = Date.now() + remaining;
209
- while (!this._stopped && Date.now() < deadline) {
253
+ while (!this._stopped && generation === this._runGeneration && Date.now() < deadline) {
210
254
  await new Promise((r) => setTimeout(r, Math.min(100, deadline - Date.now())));
211
255
  }
212
256
  }
@@ -219,7 +263,7 @@ class JobRunner {
219
263
  error.message,
220
264
  );
221
265
  const deadline = Date.now() + delay;
222
- while (!this._stopped && Date.now() < deadline) {
266
+ while (!this._stopped && generation === this._runGeneration && Date.now() < deadline) {
223
267
  await new Promise((r) => setTimeout(r, Math.min(100, deadline - Date.now())));
224
268
  }
225
269
  }
@@ -228,4 +272,4 @@ class JobRunner {
228
272
  }
229
273
  }
230
274
 
231
- export { JobRunner, discoverJobs };
275
+ export { JobRunner, buildJobsFromModules, discoverJobs };