arcway 0.1.12 → 0.1.14

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/README.md CHANGED
@@ -120,7 +120,7 @@ export default {
120
120
  server: {
121
121
  port: 3000,
122
122
  shutdownTimeoutMs: 10_000,
123
- maxBodySize: 1_048_576, // 1 MB
123
+ maxBodySize: 26_214_400, // 25 MB
124
124
  },
125
125
  api: {
126
126
  pathPrefix: '', // Prefix all API routes (e.g., '/api')
package/client/head.js CHANGED
@@ -137,4 +137,4 @@ function renderHeadToString(headData) {
137
137
  return parts.join('\n');
138
138
  }
139
139
 
140
- export { Head, clearSSRHeadData, extractHeadChildren, renderHeadToString, setSSRHeadData };
140
+ export { Head, clearSSRHeadData, renderHeadToString, setSSRHeadData };
@@ -3,7 +3,7 @@ import { WsManager } from './ws.js';
3
3
 
4
4
  const SOLO_CTX_KEY = '__provider_context__';
5
5
  const WS_CTX_KEY = '__ws_context__';
6
- const DEFAULT_CONFIG = { pathPrefix: '' };
6
+ const DEFAULT_CONFIG = { pathPrefix: '/api' };
7
7
 
8
8
  const ApiContext = (globalThis[SOLO_CTX_KEY] ??= createContext(DEFAULT_CONFIG));
9
9
  const WsContext = (globalThis[WS_CTX_KEY] ??= createContext(null));
@@ -18,8 +18,25 @@ function useWsManager() {
18
18
 
19
19
  const useSoloContext = useApiContext;
20
20
 
21
- function ApiProvider({ children, pathPrefix = '', headers, wsUrl }) {
22
- const config = useMemo(() => ({ pathPrefix, headers, wsUrl }), [pathPrefix, headers, wsUrl]);
21
+ function toWsProtocol(loc, wsPath) {
22
+ const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:';
23
+ return `${proto}//${loc.host}${wsPath}`;
24
+ }
25
+
26
+ function resolveWsUrl(input) {
27
+ if (typeof window === 'undefined') return null;
28
+ if (!input) return toWsProtocol(window.location, '/ws');
29
+ if (/^wss?:\/\//.test(input)) return input;
30
+ if (input.startsWith('/')) return toWsProtocol(window.location, input);
31
+ return input;
32
+ }
33
+
34
+ function ApiProvider({ children, pathPrefix = '/api', headers, wsUrl }) {
35
+ const resolvedWsUrl = useMemo(() => resolveWsUrl(wsUrl), [wsUrl]);
36
+ const config = useMemo(
37
+ () => ({ pathPrefix, headers, wsUrl: resolvedWsUrl }),
38
+ [pathPrefix, headers, resolvedWsUrl],
39
+ );
23
40
 
24
41
  const wsManagerRef = useRef(null);
25
42
  const wsManager = useMemo(() => {
@@ -27,11 +44,11 @@ function ApiProvider({ children, pathPrefix = '', headers, wsUrl }) {
27
44
  wsManagerRef.current.disconnect();
28
45
  wsManagerRef.current = null;
29
46
  }
30
- if (!wsUrl || typeof window === 'undefined') return null;
31
- const manager = new WsManager({ url: wsUrl });
47
+ if (!resolvedWsUrl) return null;
48
+ const manager = new WsManager({ url: resolvedWsUrl });
32
49
  wsManagerRef.current = manager;
33
50
  return manager;
34
- }, [wsUrl]);
51
+ }, [resolvedWsUrl]);
35
52
 
36
53
  useEffect(() => {
37
54
  if (wsManager) {
@@ -50,4 +67,12 @@ function ApiProvider({ children, pathPrefix = '', headers, wsUrl }) {
50
67
  const Provider = ApiProvider;
51
68
  const SoloProvider = ApiProvider;
52
69
 
53
- export { ApiProvider, Provider, SoloProvider, useApiContext, useSoloContext, useWsManager };
70
+ export {
71
+ ApiProvider,
72
+ Provider,
73
+ SoloProvider,
74
+ resolveWsUrl,
75
+ useApiContext,
76
+ useSoloContext,
77
+ useWsManager,
78
+ };
package/client/router.js CHANGED
@@ -51,7 +51,9 @@ function useRouter() {
51
51
  if (typeof window !== 'undefined') window.location.reload();
52
52
  },
53
53
  }),
54
- [ctx.pathname, ctx.params, ctx.navigate],
54
+ // queryVersion bumps on every query-only navigation or same-path popstate,
55
+ // so memoised consumers (notably `useSearchParams`) re-read the URL.
56
+ [ctx.pathname, ctx.params, ctx.navigate, ctx.queryVersion],
55
57
  );
56
58
  }
57
59
 
@@ -86,6 +88,10 @@ function Router({
86
88
  });
87
89
  const [isNavigating, setIsNavigating] = useState(false);
88
90
  const [isPending, startTransition] = useTransition();
91
+ // Bumped on query-only navigations (both push/replace and popstate) so that
92
+ // `useRouter`/`useSearchParams` consumers re-render even though pathname and
93
+ // params are unchanged.
94
+ const [queryVersion, setQueryVersion] = useState(0);
89
95
  const manifestRef = useRef(null);
90
96
 
91
97
  useEffect(() => {
@@ -114,7 +120,28 @@ function Router({
114
120
  async (to, options) => {
115
121
  const scroll = options?.scroll !== false;
116
122
  const replace = options?.replace === true;
117
- if (to === pathname) return;
123
+
124
+ // `matchClientRoute` only knows how to match pathnames, so any query
125
+ // string has to be stripped before the lookup. Splitting also lets us
126
+ // special-case same-path/different-query navigations as a history-only
127
+ // update — no bundle loading, no scroll reset, no full reload.
128
+ const qIdx = to.indexOf('?');
129
+ const pathOnly = qIdx === -1 ? to : to.slice(0, qIdx);
130
+ const search = qIdx === -1 ? '' : to.slice(qIdx);
131
+ const currentSearch =
132
+ typeof window !== 'undefined' ? window.location.search : '';
133
+
134
+ if (pathOnly === pathname && search === currentSearch) return;
135
+
136
+ if (pathOnly === pathname) {
137
+ if (replace) {
138
+ window.history.replaceState(null, '', to);
139
+ } else {
140
+ window.history.pushState(null, '', to);
141
+ }
142
+ setQueryVersion((v) => v + 1);
143
+ return;
144
+ }
118
145
 
119
146
  const manifest = manifestRef.current;
120
147
  if (!manifest) {
@@ -122,7 +149,7 @@ function Router({
122
149
  return;
123
150
  }
124
151
 
125
- const matched = matchClientRoute(manifest, to);
152
+ const matched = matchClientRoute(manifest, pathOnly);
126
153
  if (!matched) {
127
154
  window.location.href = to;
128
155
  return;
@@ -137,7 +164,7 @@ function Router({
137
164
 
138
165
  const targetLoadings = await loadLoadingComponents(matched.route);
139
166
  if (targetLoadings.length > 0) {
140
- setPathname(to);
167
+ setPathname(pathOnly);
141
168
  setPageState((prev) => ({
142
169
  ...prev,
143
170
  loadings: targetLoadings,
@@ -146,13 +173,14 @@ function Router({
146
173
  }
147
174
 
148
175
  try {
149
- const loaded = await loadPage(manifest, to);
176
+ const loaded = await loadPage(manifest, pathOnly);
150
177
  if (!loaded) {
151
178
  window.location.href = to;
152
179
  return;
153
180
  }
154
181
 
155
- applyLoaded(loaded, to);
182
+ applyLoaded(loaded, pathOnly);
183
+ if (search !== currentSearch) setQueryVersion((v) => v + 1);
156
184
 
157
185
  if (scroll) {
158
186
  window.scrollTo(0, 0);
@@ -168,11 +196,20 @@ function Router({
168
196
  useEffect(() => {
169
197
  async function onPopState() {
170
198
  const newPath = window.location.pathname;
199
+
200
+ // Same-path back/forward is a query-only history step (or identical URL);
201
+ // skip the page load and just force re-render of search-param readers.
202
+ if (newPath === pathname) {
203
+ setQueryVersion((v) => v + 1);
204
+ return;
205
+ }
206
+
171
207
  const manifest = manifestRef.current;
172
208
 
173
209
  if (!manifest) {
174
210
  setPathname(newPath);
175
211
  setPageState((prev) => ({ ...prev, params: {} }));
212
+ setQueryVersion((v) => v + 1);
176
213
  return;
177
214
  }
178
215
 
@@ -180,6 +217,7 @@ function Router({
180
217
  const loaded = await loadPage(manifest, newPath);
181
218
  if (loaded) {
182
219
  applyLoaded(loaded, newPath);
220
+ setQueryVersion((v) => v + 1);
183
221
  } else {
184
222
  window.location.reload();
185
223
  }
@@ -190,7 +228,7 @@ function Router({
190
228
 
191
229
  window.addEventListener('popstate', onPopState);
192
230
  return () => window.removeEventListener('popstate', onPopState);
193
- }, [applyLoaded]);
231
+ }, [pathname, applyLoaded]);
194
232
 
195
233
  const { component: PageComponent, layouts, loadings, params } = pageState;
196
234
 
@@ -228,6 +266,7 @@ function Router({
228
266
  pathname,
229
267
  params,
230
268
  navigate: navigateToPage,
269
+ queryVersion,
231
270
  },
232
271
  },
233
272
  content,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcway",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "A convention-based framework for building modular monoliths with strict domain boundaries.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,6 +30,9 @@
30
30
  },
31
31
  "scripts": {
32
32
  "test": "vitest run --project=unit",
33
+ "test:watch": "VITEST_MAX_WORKERS=2 vitest --project=unit",
34
+ "test:serial": "VITEST_MAX_WORKERS=1 vitest run --project=unit",
35
+ "test:coverage": "vitest run --project=unit --coverage",
33
36
  "test:storybook": "TMPDIR=~/tmp PLAYWRIGHT_BROWSERS_PATH=~/.cache/playwright vitest run --project=storybook",
34
37
  "test:all": "TMPDIR=~/tmp PLAYWRIGHT_BROWSERS_PATH=~/.cache/playwright vitest run",
35
38
  "format": "prettier --write 'server/**/*.js' 'client/**/*.js' 'tests/**/*.js'",
@@ -1,16 +1,10 @@
1
1
  import { makeConfig } from '../config/loader.js';
2
- import { createDB } from '../db/index.js';
3
- import Redis from '../redis/index.js';
4
- import Events from '../events/index.js';
2
+ import { createInfrastructure, destroyInfrastructure } from './infrastructure.js';
5
3
  import { EventHandler } from '../events/handler.js';
6
4
  import { JobRunner } from '../jobs/runner.js';
7
- import Logger from '../logger/index.js';
5
+ import { WorkerPool } from '../jobs/worker-pool.js';
8
6
  import { loadEnvFiles } from '../env.js';
9
7
  import { McpRouter } from '../mcp/index.js';
10
- import Queue from '../queue/index.js';
11
- import Cache from '../cache/index.js';
12
- import Files from '../files/index.js';
13
- import { Mail } from '../mail/index.js';
14
8
  import WebServer from '../web-server.js';
15
9
  import { FileWatcher } from '../filewatcher/index.js';
16
10
  import { ApiRouter } from '../router/api-router.js';
@@ -27,30 +21,13 @@ async function boot(options) {
27
21
  const envFiles = loadEnvFiles(rootDir, mode);
28
22
  const config = await makeConfig(rootDir, { overrides: options.configOverrides, mode });
29
23
 
30
- const log = new Logger(config.logger);
24
+ const infrastructure = await createInfrastructure(config, { runMigrations: true });
25
+ const { db, redis, queue, cache, files, mail, events, log } = infrastructure;
26
+
31
27
  const mcpRouter = new McpRouter(config.mcp, { log });
32
28
 
33
29
  if (envFiles.length > 0) log.info('Env files loaded', { envFiles });
34
30
 
35
- const db = await createDB(config.database, { log });
36
- await db.runMigrations();
37
-
38
- const redis = new Redis(config.redis, { log });
39
- await redis.connect();
40
-
41
- const queue = new Queue(config.queue, { db, redis, log });
42
- await queue.init();
43
-
44
- const cache = new Cache(config.cache, { redis, log });
45
- await cache.init();
46
-
47
- const files = new Files(config.files, { log });
48
- await files.init();
49
-
50
- const mail = new Mail(config.mail, { db, log, queue });
51
-
52
- const events = new Events(config.events, { redis, log });
53
-
54
31
  const eventHandler = new EventHandler(config.events, {
55
32
  events,
56
33
  log,
@@ -58,7 +35,28 @@ async function boot(options) {
58
35
  });
59
36
  await eventHandler.init();
60
37
 
61
- const jobRunner = new JobRunner(config.jobs, { db, queue, cache, files, mail, events, log });
38
+ // Workers rebuild their own infrastructure from a JSON-safe config snapshot,
39
+ // so anything non-serializable on the main-thread config (streams, functions)
40
+ // must not ride through workerData. makeConfig already yields plain objects.
41
+ const workerPool =
42
+ config.jobs?.workerPoolSize > 0
43
+ ? new WorkerPool({
44
+ size: config.jobs.workerPoolSize,
45
+ workerData: { config },
46
+ defaultTimeoutMs: config.jobs?.staleTimeoutMs ?? null,
47
+ })
48
+ : null;
49
+
50
+ const jobRunner = new JobRunner(config.jobs, {
51
+ db,
52
+ queue,
53
+ cache,
54
+ files,
55
+ mail,
56
+ events,
57
+ log,
58
+ workerPool,
59
+ });
62
60
  await jobRunner.init();
63
61
 
64
62
  const fileWatcher = new FileWatcher(rootDir, { log });
@@ -115,13 +113,12 @@ async function boot(options) {
115
113
 
116
114
  const shutdown = async () => {
117
115
  await jobRunner.shutdown();
116
+ if (workerPool) await workerPool.shutdown();
118
117
  await pagesRouter.close();
119
118
  await apiRouter.close();
120
119
  await webServer.close();
121
120
  await fileWatcher.close();
122
- await events.disconnect();
123
- await redis.disconnect();
124
- await db.destroy();
121
+ await destroyInfrastructure(infrastructure);
125
122
  await mcpRouter.cleanup(rootDir);
126
123
  };
127
124
 
@@ -0,0 +1,47 @@
1
+ import { createDB } from '../db/index.js';
2
+ import Redis from '../redis/index.js';
3
+ import Queue from '../queue/index.js';
4
+ import Cache from '../cache/index.js';
5
+ import Files from '../files/index.js';
6
+ import { Mail } from '../mail/index.js';
7
+ import Events from '../events/index.js';
8
+ import Logger from '../logger/index.js';
9
+
10
+ // Shared service construction for both the main-thread boot path and per-job
11
+ // worker threads (see server/jobs/worker-entry.js). Workers call this with
12
+ // `runMigrations: false` — only the main process should touch schema.
13
+ async function createInfrastructure(config, { runMigrations = false } = {}) {
14
+ const log = new Logger(config.logger);
15
+
16
+ const db = await createDB(config.database, { log });
17
+ if (runMigrations) await db.runMigrations();
18
+
19
+ const redis = new Redis(config.redis, { log });
20
+ await redis.connect();
21
+
22
+ const queue = new Queue(config.queue, { db, redis, log });
23
+ await queue.init();
24
+
25
+ const cache = new Cache(config.cache, { redis, log });
26
+ await cache.init();
27
+
28
+ const files = new Files(config.files, { log });
29
+ await files.init();
30
+
31
+ const mail = new Mail(config.mail, { db, log, queue });
32
+
33
+ const events = new Events(config.events, { redis, log });
34
+
35
+ return { db, redis, queue, cache, files, mail, events, log };
36
+ }
37
+
38
+ async function destroyInfrastructure(services) {
39
+ if (!services) return;
40
+ // Order matches boot/index.js teardown: events → redis → db. Mail/queue/
41
+ // cache/files share redis/db and do not own independent connections.
42
+ await services.events?.disconnect?.();
43
+ await services.redis?.disconnect?.();
44
+ await services.db?.destroy?.();
45
+ }
46
+
47
+ export { createInfrastructure, destroyInfrastructure };
@@ -1,5 +1,14 @@
1
+ import os from 'node:os';
1
2
  import path from 'node:path';
2
3
 
4
+ // Leave one core free for the main thread (dispatch/polling/HTTP). 0 disables
5
+ // worker threads entirely and runs handlers inline on the main thread.
6
+ function defaultWorkerPoolSize() {
7
+ const cores =
8
+ typeof os.availableParallelism === 'function' ? os.availableParallelism() : os.cpus().length;
9
+ return Math.max(1, cores - 1);
10
+ }
11
+
3
12
  const DEFAULTS = {
4
13
  enabled: true,
5
14
  backoffMs: 1000,
@@ -14,6 +23,7 @@ function resolve(config, { rootDir } = {}) {
14
23
  if (jobs.dir && !path.isAbsolute(jobs.dir)) {
15
24
  jobs.dir = path.resolve(rootDir, jobs.dir);
16
25
  }
26
+ if (jobs.workerPoolSize === undefined) jobs.workerPoolSize = defaultWorkerPoolSize();
17
27
  return { ...config, jobs };
18
28
  }
19
29
 
@@ -1,7 +1,7 @@
1
1
  const DEFAULTS = {
2
2
  host: '0.0.0.0',
3
3
  port: 3000,
4
- maxBodySize: 1024 * 1024,
4
+ maxBodySize: 25 * 1024 * 1024,
5
5
  shutdownTimeoutMs: 10000,
6
6
  trustProxy: false,
7
7
  };
package/server/context.js CHANGED
@@ -64,4 +64,4 @@ function buildContext(appContext, extras) {
64
64
  return ctx;
65
65
  }
66
66
 
67
- export { buildContext, trackDb };
67
+ export { buildContext };
@@ -2,9 +2,7 @@ import { discoverGraphQL } from './discovery.js';
2
2
  import { mergeGraphQLSchemas, mergeGraphQLResolvers } from './merge.js';
3
3
  import { createLoaderFactory } from './loaders.js';
4
4
  import { createGraphQLHandler } from './handler.js';
5
- import { attachGraphQLSubscriptions } from './subscriptions.js';
6
5
  export {
7
- attachGraphQLSubscriptions,
8
6
  createGraphQLHandler,
9
7
  createLoaderFactory,
10
8
  discoverGraphQL,
@@ -1,7 +1,7 @@
1
- import { buildContext } from '../../context.js';
2
1
  import { validateEnqueue, toError, calculateBackoff } from '../queue.js';
3
2
  import { checkDbThroughput } from '../throughput.js';
4
3
  import LeaseManager from './lease.js';
4
+ import { runHandler } from './run-handler.js';
5
5
  const DEFAULT_STALE_TIMEOUT_MS = 5 * 60 * 1e3;
6
6
  class KnexJobQueue {
7
7
  db;
@@ -11,12 +11,14 @@ class KnexJobQueue {
11
11
  registered = new Map();
12
12
  _size = 0;
13
13
  leaseManager;
14
+ workerPool;
14
15
  constructor(db, options) {
15
16
  this.db = db;
16
17
  this.tableName = options?.tableName ?? 'arcway_jobs';
17
18
  this.backoffMs = options?.backoffMs ?? 1000;
18
19
  this.staleTimeoutMs = options?.staleTimeoutMs ?? DEFAULT_STALE_TIMEOUT_MS;
19
20
  this.leaseManager = new LeaseManager(db, { tableName: options?.leaseTableName });
21
+ this.workerPool = options?.workerPool ?? null;
20
22
  }
21
23
  /** Create the jobs table if it doesn't exist. Must be called before use. */
22
24
  async init() {
@@ -39,9 +41,9 @@ class KnexJobQueue {
39
41
  await this.leaseManager.init();
40
42
  await this.syncSize();
41
43
  }
42
- register(domain, definition, store) {
44
+ register(domain, definition, store, filePath) {
43
45
  const qualifiedName = `${domain}/${definition.name}`;
44
- this.registered.set(qualifiedName, { domain, definition, store });
46
+ this.registered.set(qualifiedName, { domain, definition, store, filePath });
45
47
  }
46
48
  async enqueue(qualifiedName, payload, options) {
47
49
  const { reg, validatedPayload, maxRetries, delay } = validateEnqueue(
@@ -163,8 +165,7 @@ class KnexJobQueue {
163
165
 
164
166
  try {
165
167
  console.log(`[job] ${qualifiedName} attempt ${attempt}/${maxAttempts}`);
166
- const ctx = buildContext(reg.store, { payload });
167
- await reg.definition.handler(ctx);
168
+ await runHandler(reg, payload, this.workerPool);
168
169
  console.log(`[job] ${qualifiedName} completed`);
169
170
  await this._updateJob(jobId, { status: 'completed', attempt });
170
171
  this._size--;
@@ -1,7 +1,7 @@
1
- import { buildContext } from '../../context.js';
2
1
  import { validateEnqueue, toError, calculateBackoff } from '../queue.js';
3
2
  import { MemoryThroughputTracker } from '../throughput.js';
4
3
  import LeaseManager from './lease.js';
4
+ import { runHandler } from './run-handler.js';
5
5
 
6
6
  class JobDispatcher {
7
7
  queue = [];
@@ -9,15 +9,17 @@ class JobDispatcher {
9
9
  backoffMs;
10
10
  throughputTracker = new MemoryThroughputTracker();
11
11
  leaseManager;
12
+ workerPool;
12
13
 
13
14
  constructor(options) {
14
15
  this.backoffMs = options?.backoffMs ?? 1000;
15
16
  this.leaseManager = new LeaseManager(null);
17
+ this.workerPool = options?.workerPool ?? null;
16
18
  }
17
19
 
18
- register(domain, definition, store) {
20
+ register(domain, definition, store, filePath) {
19
21
  const qualifiedName = `${domain}/${definition.name}`;
20
- this.registered.set(qualifiedName, { domain, definition, store });
22
+ this.registered.set(qualifiedName, { domain, definition, store, filePath });
21
23
  }
22
24
 
23
25
  async enqueue(qualifiedName, payload, options) {
@@ -89,8 +91,7 @@ class JobDispatcher {
89
91
 
90
92
  try {
91
93
  console.log(`[job] ${qualifiedName} attempt ${job.attempt}/${maxAttempts}`);
92
- const ctx = buildContext(reg.store, { payload: job.payload });
93
- await reg.definition.handler(ctx);
94
+ await runHandler(reg, job.payload, this.workerPool);
94
95
  job.status = 'completed';
95
96
  console.log(`[job] ${qualifiedName} completed`);
96
97
  this.throughputTracker.record(qualifiedName);
@@ -0,0 +1,22 @@
1
+ import { buildContext } from '../../context.js';
2
+
3
+ // Dispatch a job handler either inline (main thread, legacy / tests / continuous
4
+ // jobs) or through the WorkerPool. The worker builds its own ctx from the
5
+ // workerData config snapshot and calls handler(ctx) — see worker-entry.js.
6
+ // We only route through the pool when both a pool *and* a filePath are
7
+ // available; system jobs register without a filePath and stay inline on
8
+ // purpose (they use namespaced services that don't round-trip through
9
+ // workerData).
10
+ async function runHandler(reg, payload, workerPool) {
11
+ if (workerPool && reg.filePath) {
12
+ await workerPool.run(
13
+ { handlerPath: reg.filePath, payload, withContext: true },
14
+ { timeoutMs: reg.definition.staleTimeout },
15
+ );
16
+ return;
17
+ }
18
+ const ctx = buildContext(reg.store, { payload });
19
+ await reg.definition.handler(ctx);
20
+ }
21
+
22
+ export { runHandler };
@@ -21,7 +21,13 @@ async function discoverJobs(jobsDir) {
21
21
  if (!job.name) {
22
22
  job.name = relativePath.replace(/\\/g, '/').replace(/\.js$/, '');
23
23
  }
24
- jobs.push({ definition: job, fileName: name, cooldownMs: job.cooldownMs, staleTimeout: job.staleTimeout });
24
+ jobs.push({
25
+ definition: job,
26
+ fileName: name,
27
+ filePath,
28
+ cooldownMs: job.cooldownMs,
29
+ staleTimeout: job.staleTimeout,
30
+ });
25
31
  }
26
32
  return jobs;
27
33
  }
@@ -39,10 +45,14 @@ class JobRunner {
39
45
  _stopped = false;
40
46
  _lastEnqueuedMinute = new Map();
41
47
 
42
- constructor(config, { db, queue, cache, files, mail, events, log } = {}) {
48
+ constructor(config, { db, queue, cache, files, mail, events, log, workerPool } = {}) {
43
49
  this._config = config;
44
50
  this._log = log;
45
- this._dispatcher = new JobDispatcher({ backoffMs: config?.backoffMs, staleTimeoutMs: config?.staleTimeoutMs });
51
+ this._dispatcher = new JobDispatcher({
52
+ backoffMs: config?.backoffMs,
53
+ staleTimeoutMs: config?.staleTimeoutMs,
54
+ workerPool,
55
+ });
46
56
  this._appContext = { db, queue, cache, files, mail, events, log };
47
57
  }
48
58
 
@@ -62,10 +72,11 @@ class JobRunner {
62
72
  const jobsDir = this._config?.dir;
63
73
  if (!jobsDir) return;
64
74
 
65
- // Discover and register user jobs
75
+ // Discover and register user jobs. `filePath` is captured so the
76
+ // dispatcher can dynamic-import the handler inside a worker thread.
66
77
  const discovered = await discoverJobs(jobsDir);
67
- for (const { definition, fileName } of discovered) {
68
- this._dispatcher.register('app', definition, this._appContext);
78
+ for (const { definition, fileName, filePath } of discovered) {
79
+ this._dispatcher.register('app', definition, this._appContext, filePath);
69
80
  this._jobs.push({
70
81
  jobName: definition.name,
71
82
  fileName,