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 +69 -8
- package/package.json +1 -1
- package/server/boot/hooks.js +19 -0
- package/server/boot/index.js +18 -8
- package/server/context.js +10 -0
- package/server/db/index.js +9 -0
- package/server/events/handler.js +0 -1
- package/server/pages/arcway-endpoint.js +58 -0
- package/server/pages/build-client.js +2 -1
- package/server/pages/build-css.js +23 -9
- package/server/pages/build-server.js +80 -63
- package/server/pages/build.js +32 -3
- package/server/pages/discovery.js +99 -0
- package/server/pages/handler.js +103 -3
- package/server/pages/lazy-context.js +640 -0
- package/server/pages/pages-router.js +16 -21
- package/server/pages/watcher.js +59 -76
- package/server/ws/ws-router.js +45 -2
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
|
-
|
|
458
|
+
## Lifecycle Hooks
|
|
414
459
|
|
|
415
|
-
|
|
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
|
-
//
|
|
419
|
-
export default async (
|
|
471
|
+
// hooks/init.js — wrap services, register global resources
|
|
472
|
+
export default async (appContext) => {
|
|
473
|
+
appContext.db = wrapWithTenancy(appContext.db);
|
|
474
|
+
};
|
|
420
475
|
|
|
421
|
-
//
|
|
422
|
-
export default async (
|
|
476
|
+
// hooks/ready.js — post-boot work (warmups, one-shot recovery)
|
|
477
|
+
export default async (appContext) => {
|
|
478
|
+
await recoverOrphanedTasks(appContext);
|
|
479
|
+
};
|
|
423
480
|
|
|
424
|
-
//
|
|
425
|
-
export default async (
|
|
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
|
@@ -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 };
|
package/server/boot/index.js
CHANGED
|
@@ -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
|
|
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;
|
package/server/db/index.js
CHANGED
|
@@ -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 } : {}),
|
package/server/events/handler.js
CHANGED
|
@@ -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
|
|
77
|
-
// only on which tokens appear in scanned files — not on surrounding
|
|
78
|
-
// structure — so the digest captures the only source signal that
|
|
79
|
-
// matters for CSS output. Editing handler logic, comments, or
|
|
80
|
-
// leaves this digest unchanged and the CSS cache is reused.
|
|
81
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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(
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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(
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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(
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
};
|
package/server/pages/build.js
CHANGED
|
@@ -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 };
|