arcway 0.1.21 → 0.1.22

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
@@ -96,6 +96,10 @@ project-root/
96
96
  ├── listeners/ # Event subscribers (folder path = event name)
97
97
  │ └── users/
98
98
  │ └── created.js # Handles 'users/created' event
99
+ ├── hooks/ # Lifecycle hooks (init / ready / shutdown)
100
+ │ ├── init.js # Runs before listeners/jobs/routers are wired
101
+ │ ├── ready.js # Runs after the server is listening
102
+ │ └── shutdown.js # Runs at the top of graceful shutdown
99
103
  ├── jobs/ # Background job definitions
100
104
  │ └── send-welcome-email.js
101
105
  ├── migrations/ # Database migrations (timestamp-ordered)
@@ -135,6 +139,15 @@ export default {
135
139
  database: {
136
140
  client: 'postgres', // 'sqlite', 'postgres', 'mysql'
137
141
  connection: 'postgres://user:pass@localhost/mydb',
142
+ hooks: { // Forwarded verbatim to knex — postProcessResponse,
143
+ // wrapIdentifier, asyncStackTraces, log, debug.
144
+ postProcessResponse: (result, ctx) => {
145
+ if (ctx?.table === 'users' && Array.isArray(result)) {
146
+ return result.map((row) => ({ ...row, createdAt: new Date(row.created_at) }));
147
+ }
148
+ return result;
149
+ },
150
+ },
138
151
  },
139
152
  session: {
140
153
  password: 'at-least-32-character-secret-here!!',
@@ -253,6 +266,38 @@ export const POST = {
253
266
  };
254
267
  ```
255
268
 
269
+ ### Database hooks & per-table decoding
270
+
271
+ Knex's `postProcessResponse` hook (plus `wrapIdentifier`, `log`, `debug`, and
272
+ `asyncStackTraces`) can be set under `database.hooks` in `arcway.config.js` and
273
+ are forwarded to the underlying knex instance.
274
+
275
+ To make `postProcessResponse` useful without threading context at every call
276
+ site, arcway auto-stamps `.queryContext({ table })` on every `ctx.db(name)`
277
+ builder. Your hook can key on `ctx.table` to decode rows centrally:
278
+
279
+ ```js
280
+ // arcway.config.js
281
+ database: {
282
+ hooks: {
283
+ postProcessResponse: (result, ctx) => {
284
+ if (!ctx?.table || !Array.isArray(result)) return result;
285
+ const decode = decoders[ctx.table];
286
+ return decode ? result.map(decode) : result;
287
+ },
288
+ },
289
+ },
290
+ ```
291
+
292
+ `ctx.db.raw(...)`, `ctx.db({ alias: ... })`, and subquery builders are not
293
+ stamped — hooks should pass those through with `if (!ctx?.table) return result`.
294
+ To carry additional context alongside the auto-stamp, merge instead of replace:
295
+
296
+ ```js
297
+ const builder = ctx.db('orders');
298
+ builder.queryContext({ ...builder.queryContext(), userId: ctx.req.session.userId });
299
+ ```
300
+
256
301
  ### Handler Context
257
302
 
258
303
  Every route handler receives a `ctx` object with infrastructure and request data:
@@ -410,21 +455,37 @@ export default async (ctx) => {
410
455
 
411
456
  The listener context includes all infrastructure (`db`, `events`, `cache`, `queue`, `files`, `mail`, `log`) plus `event: { name, payload }`.
412
457
 
413
- ### System Lifecycle Events
458
+ ## Lifecycle Hooks
414
459
 
415
- Special listeners in `listeners/system/` handle lifecycle events:
460
+ Lifecycle hooks live in the top-level `hooks/` directory. Each file default-exports an `async` function that receives the **real, mutable `appContext`** — the same object downstream subsystems (listeners, jobs, routes) read services from at call time. Mutations (e.g. wrapping `appContext.db`) persist for the lifetime of the process.
461
+
462
+ ```
463
+ project-root/
464
+ └── hooks/
465
+ ├── init.js — after infrastructure, before listeners/jobs/routers are wired
466
+ ├── ready.js — after the HTTP server is listening and the job runner has started
467
+ └── shutdown.js — at the top of graceful shutdown, before any teardown
468
+ ```
416
469
 
417
470
  ```js
418
- // listeners/system/init.js — runs before server starts
419
- export default async (ctx) => { /* setup */ };
471
+ // hooks/init.js — wrap services, register global resources
472
+ export default async (appContext) => {
473
+ appContext.db = wrapWithTenancy(appContext.db);
474
+ };
420
475
 
421
- // listeners/system/ready.js — runs after server is listening
422
- export default async (ctx) => { /* post-boot */ };
476
+ // hooks/ready.js — post-boot work (warmups, one-shot recovery)
477
+ export default async (appContext) => {
478
+ await recoverOrphanedTasks(appContext);
479
+ };
423
480
 
424
- // listeners/system/shutdown.js — runs during graceful shutdown
425
- export default async (ctx) => { /* cleanup */ };
481
+ // hooks/shutdown.js — flush and cleanup before infrastructure is destroyed
482
+ export default async (appContext) => {
483
+ await appContext.cache.flush();
484
+ };
426
485
  ```
427
486
 
487
+ Hooks are optional — missing files are silently skipped. An existing file must default-export a function; otherwise boot fails fast.
488
+
428
489
  ## Jobs
429
490
 
430
491
  Background jobs support one-off queuing and cron scheduling.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcway",
3
- "version": "0.1.21",
3
+ "version": "0.1.22",
4
4
  "description": "A convention-based framework for building modular monoliths with strict domain boundaries.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,19 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs/promises';
3
+ import { loadModule } from '../module-loader.js';
4
+
5
+ async function runHook(name, appContext, rootDir) {
6
+ const filePath = path.join(rootDir, 'hooks', `${name}.js`);
7
+ try {
8
+ await fs.access(filePath);
9
+ } catch {
10
+ return;
11
+ }
12
+ const mod = await loadModule(filePath);
13
+ if (typeof mod.default !== 'function') {
14
+ throw new Error(`hooks/${name}.js must default-export an async function`);
15
+ }
16
+ await mod.default(appContext);
17
+ }
18
+
19
+ export { runHook };
@@ -13,6 +13,7 @@ import { SystemRouter } from '../system-routes/index.js';
13
13
  import { StaticRouter } from '../static/index.js';
14
14
  import { WsRouter } from '../ws/ws-router.js';
15
15
  import { createWsBackplane } from '../ws/backplane.js';
16
+ import { runHook } from './hooks.js';
16
17
  import path from 'node:path';
17
18
 
18
19
  async function boot(options) {
@@ -25,14 +26,25 @@ async function boot(options) {
25
26
  const infrastructure = await createInfrastructure(config, { runMigrations: true });
26
27
  const { db, redis, queue, cache, files, mail, events, log } = infrastructure;
27
28
 
28
- const mcpRouter = new McpRouter(config.mcp, { log });
29
-
30
29
  if (envFiles.length > 0) log.info('Env files loaded', { envFiles });
31
30
 
31
+ const fileWatcher = mode === 'development' ? new FileWatcher(rootDir, { log }) : null;
32
+ if (fileWatcher) await fileWatcher.start();
33
+
34
+ // Single mutable appContext reference. Handed to every subsystem that needs
35
+ // framework services and to the init/ready/shutdown hooks. Mutations from
36
+ // the init hook (e.g. wrapping `appContext.db`) persist for process lifetime
37
+ // because downstream consumers read properties off this reference at use time.
38
+ const appContext = { db, redis, events, queue, cache, files, mail, log, fileWatcher };
39
+
40
+ await runHook('init', appContext, rootDir);
41
+
42
+ const mcpRouter = new McpRouter(config.mcp, { log });
43
+
32
44
  const eventHandler = new EventHandler(config.events, {
33
45
  events,
34
46
  log,
35
- appContext: { db, events, queue, cache, files, mail, log },
47
+ appContext,
36
48
  });
37
49
  await eventHandler.init();
38
50
 
@@ -60,11 +72,6 @@ async function boot(options) {
60
72
  });
61
73
  await jobRunner.init();
62
74
 
63
- const fileWatcher = mode === 'development' ? new FileWatcher(rootDir, { log }) : null;
64
- if (fileWatcher) await fileWatcher.start();
65
-
66
- const appContext = { db, redis, events, queue, cache, files, mail, log, fileWatcher };
67
-
68
75
  // ── Routers ──
69
76
 
70
77
  const apiRouter = new ApiRouter(config.api, {
@@ -115,7 +122,10 @@ async function boot(options) {
115
122
 
116
123
  jobRunner.start();
117
124
 
125
+ await runHook('ready', appContext, rootDir);
126
+
118
127
  const shutdown = async () => {
128
+ await runHook('shutdown', appContext, rootDir);
119
129
  await jobRunner.shutdown();
120
130
  if (workerPool) await workerPool.shutdown();
121
131
  await pagesRouter.close();
package/server/context.js CHANGED
@@ -18,6 +18,16 @@ function trackDb(db) {
18
18
  apply(target, thisArg, args) {
19
19
  const builder = Reflect.apply(target, thisArg, args);
20
20
  if (builder && typeof builder.then === 'function' && typeof builder.select === 'function') {
21
+ // Auto-stamp `.queryContext({ table })` for the canonical `db(name)` form
22
+ // so `postProcessResponse(result, ctx)` hooks can route on table without
23
+ // every call site having to thread it manually. Only stamp when the
24
+ // first arg is a string table name — db({ alias: … }) / db.raw(…) /
25
+ // subquery forms intentionally carry no table. Guard on queryContext
26
+ // existence so mock builders without it (see context tests) still work.
27
+ const [name] = args;
28
+ if (typeof name === 'string' && typeof builder.queryContext === 'function') {
29
+ builder.queryContext({ table: name });
30
+ }
21
31
  return wrapBuilder(builder, stats);
22
32
  }
23
33
  return builder;
@@ -26,12 +26,21 @@ async function createDB(config, { log } = {}) {
26
26
  const client = config.client;
27
27
  const isSqlite = client === 'better-sqlite3' || client === 'sqlite3';
28
28
  const connection = config.connection;
29
+ // Knex top-level hooks (postProcessResponse, wrapIdentifier, asyncStackTraces,
30
+ // log, debug) control per-query/row transformations and are forwarded verbatim
31
+ // from `config.hooks`. `hooks` is spread *first* so arcway's own keys
32
+ // (client/connection/useNullAsDefault) always win — user error can't reroute
33
+ // the client or break the sqlite default. The canonical use case is a
34
+ // `postProcessResponse(result, ctx)` that reads `ctx.table` (auto-stamped in
35
+ // context.js) to decode rows without per-call-site boilerplate.
36
+ const hooks = config.hooks ?? {};
29
37
 
30
38
  if (isSqlite && connection?.filename) {
31
39
  fsSync.mkdirSync(path.dirname(connection.filename), { recursive: true });
32
40
  }
33
41
 
34
42
  const db = knex({
43
+ ...hooks,
35
44
  client,
36
45
  connection,
37
46
  ...(isSqlite ? { useNullAsDefault: true } : {}),
@@ -51,7 +51,6 @@ class EventHandler {
51
51
  if (!exported) throw new Error(`Listener file at ${filePath} must have a default export`);
52
52
 
53
53
  const event = listenerPathToEvent(relativePath);
54
- if (event.startsWith('system/') || event === 'system') continue;
55
54
 
56
55
  const items = Array.isArray(exported) ? exported : [exported];
57
56
  for (let i = 0; i < items.length; i++) {
@@ -0,0 +1,58 @@
1
+ // Dev-only internal endpoints under `/_arcway/*`. A thin sync view over the
2
+ // lazy build context: manifest.json is a single JSON snapshot; `events` is an
3
+ // SSE stream of `update` events (built / invalidate / startup). Consumers
4
+ // include dev overlays, prefetchers, and any tooling that wants real-time
5
+ // build state without polling.
6
+ //
7
+ // The endpoint returns `true` if it handled the request, so the caller can
8
+ // chain it in front of the route matcher and bail out before any page
9
+ // rendering happens. Anything outside `/_arcway/*` is a pass-through.
10
+
11
+ function createArcwayDevEndpoint(lazyContext) {
12
+ return function handle(req, res) {
13
+ const method = req.method ?? 'GET';
14
+ if (method !== 'GET') return false;
15
+ const url = req.url ?? '/';
16
+ const pathname = url.split('?')[0];
17
+
18
+ if (pathname === '/_arcway/manifest.json') {
19
+ const snapshot = lazyContext.getManifestJson();
20
+ const body = JSON.stringify(snapshot);
21
+ res.writeHead(200, {
22
+ 'Content-Type': 'application/json; charset=utf-8',
23
+ 'Cache-Control': 'no-store',
24
+ 'Content-Length': Buffer.byteLength(body),
25
+ });
26
+ res.end(body);
27
+ return true;
28
+ }
29
+
30
+ if (pathname === '/_arcway/events') {
31
+ res.writeHead(200, {
32
+ 'Content-Type': 'text/event-stream; charset=utf-8',
33
+ 'Cache-Control': 'no-cache',
34
+ Connection: 'keep-alive',
35
+ // Disables proxy buffering (nginx, etc.) so events arrive in real
36
+ // time in dev. Harmless when no proxy sits in front.
37
+ 'X-Accel-Buffering': 'no',
38
+ });
39
+ // Retry directive + initial comment keeps EventSource from opening a
40
+ // second connection if the first one stalls before the first event.
41
+ res.write('retry: 2000\n\n');
42
+ res.write(': connected\n\n');
43
+
44
+ const off = lazyContext.on('update', (event) => {
45
+ if (res.writableEnded) return;
46
+ res.write(`event: update\ndata: ${JSON.stringify(event)}\n\n`);
47
+ });
48
+ req.on('close', () => {
49
+ off();
50
+ });
51
+ return true;
52
+ }
53
+
54
+ return false;
55
+ };
56
+ }
57
+
58
+ export { createArcwayDevEndpoint };
@@ -351,6 +351,7 @@ async function buildClientBundles(
351
351
  loadings = [],
352
352
  nodeEnv = 'production',
353
353
  devMode = false,
354
+ limit = (fn) => fn(),
354
355
  ) {
355
356
  const clientDir = path.join(outDir, 'client');
356
357
  const tempDir = path.join(outDir, '.hydration-entries');
@@ -394,7 +395,7 @@ async function buildClientBundles(
394
395
  reactAlias,
395
396
  rootDir,
396
397
  });
397
- const result = await runClientEsbuild(plan, esbuildOptions, tempDir);
398
+ const result = await limit(() => runClientEsbuild(plan, esbuildOptions, tempDir));
398
399
  const metadata = extractMetadata(result, plan, outDir);
399
400
 
400
401
  if (useCache) {
@@ -53,7 +53,7 @@ function computeCssCoarseKey({ minify, fontFaceCss, stylesPath }) {
53
53
  );
54
54
  }
55
55
 
56
- async function buildCssBundle(pagesDir, outDir, minify, stylesPath, fontFaceCss, rootDir) {
56
+ async function buildCssBundle(pagesDir, outDir, minify, stylesPath, fontFaceCss, rootDir, limit = (fn) => fn()) {
57
57
  try {
58
58
  await fs.access(pagesDir);
59
59
  } catch {
@@ -61,6 +61,12 @@ async function buildCssBundle(pagesDir, outDir, minify, stylesPath, fontFaceCss,
61
61
  }
62
62
 
63
63
  const clientDir = path.join(outDir, 'client');
64
+ // rootDir == the project root — same directory tailwind v4's auto-content
65
+ // detection walks up to find (it stops at the nearest package.json). Keep
66
+ // the scan root separate from the cache-root default: if the caller didn't
67
+ // pass rootDir, fall back to pagesDir for scanning (narrower, safe for
68
+ // tests) while still letting the cache default to outDir's parent.
69
+ const tokenScanRoot = rootDir ?? pagesDir;
64
70
  rootDir = rootDir ?? path.dirname(outDir);
65
71
  const coarseKey = computeCssCoarseKey({ minify, fontFaceCss, stylesPath });
66
72
 
@@ -73,14 +79,22 @@ async function buildCssBundle(pagesDir, outDir, minify, stylesPath, fontFaceCss,
73
79
  for (const f of cssFiles) inputs.push(f);
74
80
 
75
81
  // Virtual input: a digest over every string-literal token found in the
76
- // JSX/TS source tree under pagesDir. Tailwind's class-name emission depends
77
- // only on which tokens appear in scanned files — not on surrounding code
78
- // structure — so the digest captures the only source signal that actually
79
- // matters for CSS output. Editing handler logic, comments, or whitespace
80
- // leaves this digest unchanged and the CSS cache is reused.
81
- const classTokensDigest = await hashClassTokens(pagesDir);
82
+ // JSX/TS source tree under tokenScanRoot. Tailwind's class-name emission
83
+ // depends only on which tokens appear in scanned files — not on surrounding
84
+ // code structure — so the digest captures the only source signal that
85
+ // actually matters for CSS output. Editing handler logic, comments, or
86
+ // whitespace leaves this digest unchanged and the CSS cache is reused.
87
+ //
88
+ // Must match tailwind v4's effective scan scope: auto-content detection
89
+ // walks up from the CSS entry file to the nearest package.json, so a new
90
+ // arbitrary-value class in a sibling dir (e.g. components/ alongside pages/)
91
+ // IS picked up by tailwind but must also be reflected in this digest —
92
+ // otherwise an incremental rebuild hits the cache and silently restores
93
+ // stale CSS missing the new rule. Scanning rootDir (the project root)
94
+ // covers pagesDir + every sibling source tree, matching tailwind's scope.
95
+ const classTokensDigest = await hashClassTokens(tokenScanRoot);
82
96
  const virtualInputs = [
83
- { name: `${path.resolve(pagesDir)}::class-tokens`, digest: classTokensDigest },
97
+ { name: `${path.resolve(tokenScanRoot)}::class-tokens`, digest: classTokensDigest },
84
98
  ];
85
99
 
86
100
  const lookup = await lookupMultiFileCache({ rootDir, kind: 'css', coarseKey, virtualInputs });
@@ -112,7 +126,7 @@ async function buildCssBundle(pagesDir, outDir, minify, stylesPath, fontFaceCss,
112
126
  });
113
127
  let css = fontFaceCss ? `${fontFaceCss}\n\n${result.css}` : result.css;
114
128
  if (minify) {
115
- const minified = await esbuild.transform(css, { loader: 'css', minify: true });
129
+ const minified = await limit(() => esbuild.transform(css, { loader: 'css', minify: true }));
116
130
  css = minified.code;
117
131
  }
118
132
  const hash = crypto.createHash('sha256').update(css).digest('hex').slice(0, 8);
@@ -45,76 +45,89 @@ function plainEsbuildOptions(entry, outFile, target) {
45
45
  };
46
46
  }
47
47
 
48
- async function buildServerBundles(pages, outDir, target, { rootDir, devMode, minify } = {}) {
48
+ // Identity wrapper for the `limit` option. When `buildPages()` passes a shared
49
+ // `p-limit` instance, every esbuild invocation inside this file passes through
50
+ // it so the whole build respects a single concurrency budget. When callers (or
51
+ // direct tests) omit `limit`, the identity wrapper keeps today's unbounded
52
+ // fan-out intact.
53
+ const identityLimit = (fn) => fn();
54
+
55
+ async function buildServerBundles(pages, outDir, target, { rootDir, devMode, minify, limit = identityLimit } = {}) {
49
56
  const serverDir = path.join(outDir, 'server');
50
57
  const resultMap = new Map();
51
58
  await Promise.all(
52
- pages.map(async (page) => {
53
- const outName = patternToFileName(page.pattern);
54
- const outFile = path.join(serverDir, `${outName}.js`);
55
- await buildWithCache({
56
- rootDir: rootDir ?? outDir,
57
- kind: 'server',
58
- entryPath: page.filePath,
59
- outFile,
60
- esbuildOptions: jsxEsbuildOptions(page.filePath, outFile, target),
61
- esbuild,
62
- devMode,
63
- minify,
64
- });
65
- resultMap.set(page.pattern, path.relative(outDir, outFile).replace(/\\/g, '/'));
66
- }),
59
+ pages.map((page) =>
60
+ limit(async () => {
61
+ const outName = patternToFileName(page.pattern);
62
+ const outFile = path.join(serverDir, `${outName}.js`);
63
+ await buildWithCache({
64
+ rootDir: rootDir ?? outDir,
65
+ kind: 'server',
66
+ entryPath: page.filePath,
67
+ outFile,
68
+ esbuildOptions: jsxEsbuildOptions(page.filePath, outFile, target),
69
+ esbuild,
70
+ devMode,
71
+ minify,
72
+ });
73
+ resultMap.set(page.pattern, path.relative(outDir, outFile).replace(/\\/g, '/'));
74
+ }),
75
+ ),
67
76
  );
68
77
  return resultMap;
69
78
  }
70
79
 
71
- async function buildLayoutServerBundles(layouts, outDir, target, { rootDir, devMode, minify } = {}) {
80
+ async function buildLayoutServerBundles(layouts, outDir, target, { rootDir, devMode, minify, limit = identityLimit } = {}) {
72
81
  const serverDir = path.join(outDir, 'server');
73
82
  const resultMap = new Map();
74
83
  await Promise.all(
75
- layouts.map(async (layout) => {
76
- const outName = layoutDirToFileName(layout.dirPath);
77
- const outFile = path.join(serverDir, `_layout_${outName}.js`);
78
- await buildWithCache({
79
- rootDir: rootDir ?? outDir,
80
- kind: 'layout',
81
- entryPath: layout.filePath,
82
- outFile,
83
- esbuildOptions: jsxEsbuildOptions(layout.filePath, outFile, target),
84
- esbuild,
85
- devMode,
86
- minify,
87
- });
88
- resultMap.set(layout.dirPath, path.relative(outDir, outFile).replace(/\\/g, '/'));
89
- }),
84
+ layouts.map((layout) =>
85
+ limit(async () => {
86
+ const outName = layoutDirToFileName(layout.dirPath);
87
+ const outFile = path.join(serverDir, `_layout_${outName}.js`);
88
+ await buildWithCache({
89
+ rootDir: rootDir ?? outDir,
90
+ kind: 'layout',
91
+ entryPath: layout.filePath,
92
+ outFile,
93
+ esbuildOptions: jsxEsbuildOptions(layout.filePath, outFile, target),
94
+ esbuild,
95
+ devMode,
96
+ minify,
97
+ });
98
+ resultMap.set(layout.dirPath, path.relative(outDir, outFile).replace(/\\/g, '/'));
99
+ }),
100
+ ),
90
101
  );
91
102
  return resultMap;
92
103
  }
93
104
 
94
- async function buildMiddlewareServerBundles(middlewares, outDir, target, { rootDir, devMode, minify } = {}) {
105
+ async function buildMiddlewareServerBundles(middlewares, outDir, target, { rootDir, devMode, minify, limit = identityLimit } = {}) {
95
106
  const serverDir = path.join(outDir, 'server');
96
107
  const resultMap = new Map();
97
108
  await Promise.all(
98
- middlewares.map(async (mw) => {
99
- const outName = layoutDirToFileName(mw.dirPath);
100
- const outFile = path.join(serverDir, `_middleware_${outName}.js`);
101
- await buildWithCache({
102
- rootDir: rootDir ?? outDir,
103
- kind: 'middleware',
104
- entryPath: mw.filePath,
105
- outFile,
106
- esbuildOptions: plainEsbuildOptions(mw.filePath, outFile, target),
107
- esbuild,
108
- devMode,
109
- minify,
110
- });
111
- resultMap.set(mw.dirPath, path.relative(outDir, outFile).replace(/\\/g, '/'));
112
- }),
109
+ middlewares.map((mw) =>
110
+ limit(async () => {
111
+ const outName = layoutDirToFileName(mw.dirPath);
112
+ const outFile = path.join(serverDir, `_middleware_${outName}.js`);
113
+ await buildWithCache({
114
+ rootDir: rootDir ?? outDir,
115
+ kind: 'middleware',
116
+ entryPath: mw.filePath,
117
+ outFile,
118
+ esbuildOptions: plainEsbuildOptions(mw.filePath, outFile, target),
119
+ esbuild,
120
+ devMode,
121
+ minify,
122
+ });
123
+ resultMap.set(mw.dirPath, path.relative(outDir, outFile).replace(/\\/g, '/'));
124
+ }),
125
+ ),
113
126
  );
114
127
  return resultMap;
115
128
  }
116
129
 
117
- async function buildErrorPageBundles(errorPages, outDir, target, { rootDir, devMode, minify } = {}) {
130
+ async function buildErrorPageBundles(errorPages, outDir, target, { rootDir, devMode, minify, limit = identityLimit } = {}) {
118
131
  const serverDir = path.join(outDir, 'server');
119
132
  const result = {};
120
133
  const builds = [];
@@ -125,20 +138,22 @@ async function buildErrorPageBundles(errorPages, outDir, target, { rootDir, devM
125
138
  builds.push({ filePath: errorPages.notFoundPage, outName: '_404', key: 'notFound' });
126
139
  }
127
140
  await Promise.all(
128
- builds.map(async (build) => {
129
- const outFile = path.join(serverDir, `${build.outName}.js`);
130
- await buildWithCache({
131
- rootDir: rootDir ?? outDir,
132
- kind: 'errorPage',
133
- entryPath: build.filePath,
134
- outFile,
135
- esbuildOptions: jsxEsbuildOptions(build.filePath, outFile, target),
136
- esbuild,
137
- devMode,
138
- minify,
139
- });
140
- result[build.key] = path.relative(outDir, outFile);
141
- }),
141
+ builds.map((build) =>
142
+ limit(async () => {
143
+ const outFile = path.join(serverDir, `${build.outName}.js`);
144
+ await buildWithCache({
145
+ rootDir: rootDir ?? outDir,
146
+ kind: 'errorPage',
147
+ entryPath: build.filePath,
148
+ outFile,
149
+ esbuildOptions: jsxEsbuildOptions(build.filePath, outFile, target),
150
+ esbuild,
151
+ devMode,
152
+ minify,
153
+ });
154
+ result[build.key] = path.relative(outDir, outFile);
155
+ }),
156
+ ),
142
157
  );
143
158
  return result;
144
159
  }
@@ -148,6 +163,8 @@ export {
148
163
  buildLayoutServerBundles,
149
164
  buildMiddlewareServerBundles,
150
165
  buildServerBundles,
166
+ jsxEsbuildOptions,
151
167
  layoutDirToFileName,
152
168
  patternToFileName,
169
+ plainEsbuildOptions,
153
170
  };
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'node:fs/promises';
3
3
  import crypto from 'node:crypto';
4
+ import pLimit from 'p-limit';
4
5
  import {
5
6
  discoverPages,
6
7
  discoverLayouts,
@@ -22,6 +23,32 @@ import { resolveFonts } from './fonts.js';
22
23
  import { buildHmrRuntimeBundle } from './hmr.js';
23
24
  import { enforceCacheBudget } from './build-cache.js';
24
25
  import { precompressAssets } from './compress.js';
26
+ // Resolve the effective build-concurrency bound for a `buildPages()` call.
27
+ // Precedence: explicit `options.concurrency` → `ARCWAY_BUILD_CONCURRENCY` env
28
+ // → `Infinity` (unbounded, today's behavior). Only positive integers count;
29
+ // anything else (NaN, 0, negatives, floats, non-numeric strings) falls through
30
+ // so a typo can't accidentally throttle a real user's build.
31
+ function resolveBuildConcurrency(optionValue) {
32
+ if (Number.isInteger(optionValue) && optionValue > 0) return optionValue;
33
+ const envRaw = process.env.ARCWAY_BUILD_CONCURRENCY;
34
+ if (envRaw) {
35
+ const trimmed = envRaw.trim();
36
+ if (/^\d+$/.test(trimmed)) {
37
+ const n = Number.parseInt(trimmed, 10);
38
+ if (Number.isInteger(n) && n > 0) return n;
39
+ }
40
+ }
41
+ return Infinity;
42
+ }
43
+
44
+ // When concurrency is Infinity we return a zero-overhead identity wrapper so
45
+ // `limit(fn)` is byte-equivalent to calling `fn()` directly — no `p-limit`
46
+ // queue allocation, no semantic change from today's unbounded `Promise.all`.
47
+ function createLimit(concurrency) {
48
+ if (concurrency === Infinity) return (fn) => fn();
49
+ return pLimit(concurrency);
50
+ }
51
+
25
52
  async function buildPages(options) {
26
53
  const { rootDir } = options;
27
54
  const pagesDir = options.pagesDir ?? path.join(rootDir, 'pages');
@@ -30,6 +57,7 @@ async function buildPages(options) {
30
57
  const clientTarget = options.clientTarget ?? 'es2022';
31
58
  const minify = options.minify ?? true;
32
59
  const devMode = options.devMode ?? false;
60
+ const limit = createLimit(resolveBuildConcurrency(options.concurrency));
33
61
  const [pages, layouts, loadings, middlewares, errorPages, stylesPath] = await Promise.all([
34
62
  discoverPages(pagesDir),
35
63
  discoverLayouts(pagesDir),
@@ -59,7 +87,7 @@ async function buildPages(options) {
59
87
  if (fontResult.fontFaceCss) fontFaceCss = fontResult.fontFaceCss;
60
88
  if (fontResult.preloadHtml) fontPreloadHtml = fontResult.preloadHtml;
61
89
  }
62
- const serverCacheOpts = { rootDir, devMode, minify };
90
+ const serverCacheOpts = { rootDir, devMode, minify, limit };
63
91
  builds.push(
64
92
  buildServerBundles(pages, buildDir, serverTarget, serverCacheOpts),
65
93
  buildLayoutServerBundles(layouts, buildDir, serverTarget, serverCacheOpts),
@@ -74,9 +102,10 @@ async function buildPages(options) {
74
102
  loadings,
75
103
  minify ? 'production' : 'development',
76
104
  devMode,
105
+ limit,
77
106
  ),
78
107
  buildErrorPageBundles(errorPages, buildDir, serverTarget, serverCacheOpts),
79
- buildCssBundle(pagesDir, buildDir, minify, stylesPath, fontFaceCss, rootDir),
108
+ buildCssBundle(pagesDir, buildDir, minify, stylesPath, fontFaceCss, rootDir, limit),
80
109
  );
81
110
  if (devMode) builds.push(buildHmrRuntimeBundle(buildDir));
82
111
  // Fail fast: `Promise.all` rejects the moment any build throws, so a broken
@@ -192,4 +221,4 @@ function rewriteMetafilePaths(metafile, fromDir, toDir) {
192
221
  return { ...metafile, outputs };
193
222
  }
194
223
  import { patternToFileName } from './build-server.js';
195
- export { buildPages, patternToFileName, buildPagesIdle };
224
+ export { buildPages, patternToFileName, buildPagesIdle, resolveBuildConcurrency, createLimit };