arcway 0.1.20 → 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/client/dynamic.js +48 -0
- package/client/router.js +13 -5
- package/package.json +3 -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 +110 -25
- package/server/pages/build-context.js +1 -1
- package/server/pages/build-css.js +23 -9
- package/server/pages/build-manifest.js +5 -4
- package/server/pages/build-server.js +80 -63
- package/server/pages/build.js +42 -4
- package/server/pages/chunk-graph.js +59 -0
- package/server/pages/compress.js +103 -0
- package/server/pages/discovery.js +99 -0
- package/server/pages/handler.js +107 -3
- package/server/pages/lazy-context.js +640 -0
- package/server/pages/pages-router.js +16 -21
- package/server/pages/ssr.js +16 -4
- package/server/pages/static.js +51 -4
- package/server/pages/watcher.js +59 -76
- package/server/static/index.js +1 -1
- 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.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { createElement, lazy, Suspense } from 'react';
|
|
2
|
+
|
|
3
|
+
const isServer = typeof window === 'undefined';
|
|
4
|
+
|
|
5
|
+
// `arcway/dynamic` — component-level lazy loading with an SSR escape hatch.
|
|
6
|
+
//
|
|
7
|
+
// Returns a React component. Default behavior mirrors `React.lazy` wrapped in
|
|
8
|
+
// a local `<Suspense>` so callers don't have to place their own boundary:
|
|
9
|
+
//
|
|
10
|
+
// const Heavy = dynamic(() => import('./heavy.js'), { loading: Skeleton });
|
|
11
|
+
//
|
|
12
|
+
// `ssr: false` disables server rendering of the lazy child: under Node the
|
|
13
|
+
// component short-circuits to the loading fallback (or null), and the loader
|
|
14
|
+
// is never invoked server-side. The chunk is therefore absent from the SSR
|
|
15
|
+
// payload and is fetched on the client on first mount.
|
|
16
|
+
export default function dynamic(loader, opts = {}) {
|
|
17
|
+
const { ssr = true, loading } = opts;
|
|
18
|
+
const Fallback = loading ?? null;
|
|
19
|
+
const fallbackElement = Fallback ? createElement(Fallback) : null;
|
|
20
|
+
|
|
21
|
+
if (!ssr) {
|
|
22
|
+
// On the server we want zero loader activity and zero Suspense churn —
|
|
23
|
+
// just the fallback (or empty). On the client we still lazy-load so the
|
|
24
|
+
// chunk stays out of the initial payload; Suspense wraps it locally.
|
|
25
|
+
if (isServer) {
|
|
26
|
+
const ServerStub = () => fallbackElement;
|
|
27
|
+
ServerStub.displayName = 'DynamicServerStub';
|
|
28
|
+
return ServerStub;
|
|
29
|
+
}
|
|
30
|
+
const Lazy = lazy(loader);
|
|
31
|
+
const ClientOnly = (props) =>
|
|
32
|
+
createElement(Suspense, { fallback: fallbackElement }, createElement(Lazy, props));
|
|
33
|
+
ClientOnly.displayName = 'DynamicClientOnly';
|
|
34
|
+
return ClientOnly;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Default SSR path: React.lazy + local Suspense. Under streaming SSR
|
|
38
|
+
// (renderToPipeableStream) the fallback is emitted first and the resolved
|
|
39
|
+
// chunk streams in when the loader promise settles. Under sync
|
|
40
|
+
// renderToString() the fallback is the final output, which is the expected
|
|
41
|
+
// behavior — pages needing a single-pass SSR render should use `ssr: false`
|
|
42
|
+
// or pre-import the module.
|
|
43
|
+
const Lazy = lazy(loader);
|
|
44
|
+
const Dynamic = (props) =>
|
|
45
|
+
createElement(Suspense, { fallback: fallbackElement }, createElement(Lazy, props));
|
|
46
|
+
Dynamic.displayName = 'Dynamic';
|
|
47
|
+
return Dynamic;
|
|
48
|
+
}
|
package/client/router.js
CHANGED
|
@@ -103,7 +103,16 @@ function Router({
|
|
|
103
103
|
return () => window.removeEventListener('manifest-update', onManifestUpdate);
|
|
104
104
|
}, []);
|
|
105
105
|
|
|
106
|
-
|
|
106
|
+
// `bumpQuery` controls whether `queryVersion` is incremented atomically
|
|
107
|
+
// with the page-state updates. It lives inside the same `startTransition`
|
|
108
|
+
// so React commits the new `pathname` and the re-read of
|
|
109
|
+
// `window.location.search` (triggered by `queryVersion`) in one go. Keeping
|
|
110
|
+
// the bump outside the transition raced at default priority and produced
|
|
111
|
+
// an interim render where `pathname` was stale but `useSearchParams` had
|
|
112
|
+
// already moved to the new query string — see the
|
|
113
|
+
// `query-version-outside-navigation-transition` bug report for the
|
|
114
|
+
// downstream data-corruption pattern that unlocked.
|
|
115
|
+
const applyLoaded = useCallback((loaded, newPath, bumpQuery = false) => {
|
|
107
116
|
startTransition(() => {
|
|
108
117
|
setPathname(newPath);
|
|
109
118
|
setPageState({
|
|
@@ -113,6 +122,7 @@ function Router({
|
|
|
113
122
|
params: loaded.params,
|
|
114
123
|
});
|
|
115
124
|
setIsNavigating(false);
|
|
125
|
+
if (bumpQuery) setQueryVersion((v) => v + 1);
|
|
116
126
|
});
|
|
117
127
|
}, []);
|
|
118
128
|
|
|
@@ -179,8 +189,7 @@ function Router({
|
|
|
179
189
|
return;
|
|
180
190
|
}
|
|
181
191
|
|
|
182
|
-
applyLoaded(loaded, pathOnly);
|
|
183
|
-
if (search !== currentSearch) setQueryVersion((v) => v + 1);
|
|
192
|
+
applyLoaded(loaded, pathOnly, search !== currentSearch);
|
|
184
193
|
|
|
185
194
|
if (scroll) {
|
|
186
195
|
window.scrollTo(0, 0);
|
|
@@ -216,8 +225,7 @@ function Router({
|
|
|
216
225
|
try {
|
|
217
226
|
const loaded = await loadPage(manifest, newPath);
|
|
218
227
|
if (loaded) {
|
|
219
|
-
applyLoaded(loaded, newPath);
|
|
220
|
-
setQueryVersion((v) => v + 1);
|
|
228
|
+
applyLoaded(loaded, newPath, true);
|
|
221
229
|
} else {
|
|
222
230
|
window.location.reload();
|
|
223
231
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "arcway",
|
|
3
|
-
"version": "0.1.
|
|
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",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"./internals": "./server/internals.js",
|
|
24
24
|
"./lib/vault": "./server/lib/vault/index.js",
|
|
25
25
|
"./lib/client": "./client/index.js",
|
|
26
|
+
"./dynamic": "./client/dynamic.js",
|
|
26
27
|
"./ui": "./client/ui/index.js",
|
|
27
28
|
"./middlewares": "./server/middlewares/index.js",
|
|
28
29
|
"./ui/theme.css": "./client/ui/theme.css",
|
|
@@ -78,6 +79,7 @@
|
|
|
78
79
|
"lucide-react": "^0.564.0",
|
|
79
80
|
"mailparser": "^3.9.3",
|
|
80
81
|
"nanoid": "^5.1.6",
|
|
82
|
+
"negotiator": "^1.0.0",
|
|
81
83
|
"nodemailer": "^8.0.1",
|
|
82
84
|
"p-limit": "^7.3.0",
|
|
83
85
|
"pg": "^8.13.0",
|
|
@@ -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 };
|
|
@@ -13,6 +13,12 @@ import {
|
|
|
13
13
|
restoreMultiFileCache,
|
|
14
14
|
storeMultiFileCache,
|
|
15
15
|
} from './build-cache.js';
|
|
16
|
+
import { computeEntryChunks } from './chunk-graph.js';
|
|
17
|
+
|
|
18
|
+
// Bump when the shape produced by `extractMetadata` / the serialized cache
|
|
19
|
+
// payload changes in a way that would make an old cache entry decode into a
|
|
20
|
+
// broken manifest. Prevents a stale v1 cache from ever satisfying a v2 build.
|
|
21
|
+
const METADATA_VERSION = 3;
|
|
16
22
|
|
|
17
23
|
// Build a deterministic plan of the hydration entries that will be written
|
|
18
24
|
// into the staging dir. Computed up-front so we can derive a cache key
|
|
@@ -93,6 +99,7 @@ function computeClientCoarseKey({
|
|
|
93
99
|
return sha256(
|
|
94
100
|
JSON.stringify({
|
|
95
101
|
esbuildVersion: ESBUILD_VERSION,
|
|
102
|
+
metadataVersion: METADATA_VERSION,
|
|
96
103
|
target: target ?? '',
|
|
97
104
|
minify: !!minify,
|
|
98
105
|
devMode: !!devMode,
|
|
@@ -112,7 +119,8 @@ function serializeMetadata(meta) {
|
|
|
112
119
|
navBundles: [...meta.navBundles.entries()],
|
|
113
120
|
layoutNavBundles: [...meta.layoutNavBundles.entries()],
|
|
114
121
|
loadingNavBundles: [...meta.loadingNavBundles.entries()],
|
|
115
|
-
|
|
122
|
+
entryChunks: [...meta.entryChunks.entries()],
|
|
123
|
+
entryCssChunks: [...meta.entryCssChunks.entries()],
|
|
116
124
|
};
|
|
117
125
|
}
|
|
118
126
|
|
|
@@ -122,7 +130,8 @@ function deserializeMetadata(raw) {
|
|
|
122
130
|
navBundles: new Map(raw.navBundles),
|
|
123
131
|
layoutNavBundles: new Map(raw.layoutNavBundles),
|
|
124
132
|
loadingNavBundles: new Map(raw.loadingNavBundles),
|
|
125
|
-
|
|
133
|
+
entryChunks: new Map(raw.entryChunks),
|
|
134
|
+
entryCssChunks: new Map(raw.entryCssChunks),
|
|
126
135
|
};
|
|
127
136
|
}
|
|
128
137
|
|
|
@@ -205,10 +214,18 @@ async function createClientBuildContext(
|
|
|
205
214
|
);
|
|
206
215
|
|
|
207
216
|
let disposed = false;
|
|
217
|
+
// Files scheduled for deletion at the end of the NEXT rebuild. The
|
|
218
|
+
// one-generation hold gives in-flight requests from the previous HTML a
|
|
219
|
+
// chance to still complete against the old hashed chunk before we unlink
|
|
220
|
+
// it. See the commentary on `collectStaleOutputs` for why this is
|
|
221
|
+
// necessary in dev but not in prod.
|
|
222
|
+
let pendingStale = new Set();
|
|
208
223
|
|
|
209
224
|
async function rebuild() {
|
|
210
225
|
const result = await ctx.rebuild();
|
|
211
226
|
const metadata = extractMetadata(result, plan, outDir);
|
|
227
|
+
await unlinkAll(pendingStale);
|
|
228
|
+
pendingStale = await collectStaleOutputs(result.metafile, clientDir);
|
|
212
229
|
return { ...metadata, metafile: result.metafile };
|
|
213
230
|
}
|
|
214
231
|
|
|
@@ -222,6 +239,62 @@ async function createClientBuildContext(
|
|
|
222
239
|
return { rebuild, dispose };
|
|
223
240
|
}
|
|
224
241
|
|
|
242
|
+
async function unlinkAll(absPaths) {
|
|
243
|
+
await Promise.all(
|
|
244
|
+
[...absPaths].map((p) =>
|
|
245
|
+
fs.unlink(p).catch((err) => {
|
|
246
|
+
// Missing files are expected when a subsequent rebuild reshapes the
|
|
247
|
+
// output set further before our GC pass runs; anything else is
|
|
248
|
+
// logged implicitly via the returned promise (non-fatal).
|
|
249
|
+
if (err?.code !== 'ENOENT') throw err;
|
|
250
|
+
}),
|
|
251
|
+
),
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Incremental esbuild builds reuse the same `outdir` across rebuilds (the
|
|
256
|
+
// context requires a stable outdir), so each new hashed chunk lands alongside
|
|
257
|
+
// its predecessor and `chunks/` grows forever. Compute the set of files
|
|
258
|
+
// currently on disk but *not* in the metafile's output set — those are the
|
|
259
|
+
// stale artifacts to GC on the next rebuild.
|
|
260
|
+
//
|
|
261
|
+
// Prod does not need this: `buildPages()` stages into a unique dir and does
|
|
262
|
+
// an atomic rename, so the served clientDir always contains exactly the
|
|
263
|
+
// current build's outputs.
|
|
264
|
+
async function collectStaleOutputs(metafile, clientDir) {
|
|
265
|
+
const currentOutputs = new Set(
|
|
266
|
+
Object.keys(metafile.outputs ?? {}).map((p) => path.resolve(p)),
|
|
267
|
+
);
|
|
268
|
+
// Scope the scan to directories esbuild actually writes to. This keeps
|
|
269
|
+
// sibling artifacts owned by other build steps (e.g. the HMR runtime under
|
|
270
|
+
// `client/hmr/`) out of the GC set, since they will never appear in the
|
|
271
|
+
// client esbuild metafile.
|
|
272
|
+
const dirs = new Set();
|
|
273
|
+
for (const outPath of currentOutputs) dirs.add(path.dirname(outPath));
|
|
274
|
+
|
|
275
|
+
const stale = new Set();
|
|
276
|
+
for (const dir of dirs) {
|
|
277
|
+
let entries;
|
|
278
|
+
try {
|
|
279
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
280
|
+
} catch (err) {
|
|
281
|
+
if (err?.code === 'ENOENT') continue;
|
|
282
|
+
throw err;
|
|
283
|
+
}
|
|
284
|
+
for (const entry of entries) {
|
|
285
|
+
if (!entry.isFile()) continue;
|
|
286
|
+
const abs = path.join(dir, entry.name);
|
|
287
|
+
if (!currentOutputs.has(abs)) stale.add(abs);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// `clientDir` is passed for future scoping hooks (e.g. whitelisting) but
|
|
291
|
+
// is intentionally unused today — the dirname set above already bounds the
|
|
292
|
+
// scan tightly and never escapes clientDir because every metafile output
|
|
293
|
+
// sits under it.
|
|
294
|
+
void clientDir;
|
|
295
|
+
return stale;
|
|
296
|
+
}
|
|
297
|
+
|
|
225
298
|
async function tryRestoreClientFromCache({ rootDir, clientDir, coarseKey }) {
|
|
226
299
|
const lookup = await lookupMultiFileCache({ rootDir, kind: 'client', coarseKey });
|
|
227
300
|
if (!lookup.hit) return { hit: false, bucket: lookup.bucket };
|
|
@@ -278,6 +351,7 @@ async function buildClientBundles(
|
|
|
278
351
|
loadings = [],
|
|
279
352
|
nodeEnv = 'production',
|
|
280
353
|
devMode = false,
|
|
354
|
+
limit = (fn) => fn(),
|
|
281
355
|
) {
|
|
282
356
|
const clientDir = path.join(outDir, 'client');
|
|
283
357
|
const tempDir = path.join(outDir, '.hydration-entries');
|
|
@@ -321,7 +395,7 @@ async function buildClientBundles(
|
|
|
321
395
|
reactAlias,
|
|
322
396
|
rootDir,
|
|
323
397
|
});
|
|
324
|
-
const result = await runClientEsbuild(plan, esbuildOptions, tempDir);
|
|
398
|
+
const result = await limit(() => runClientEsbuild(plan, esbuildOptions, tempDir));
|
|
325
399
|
const metadata = extractMetadata(result, plan, outDir);
|
|
326
400
|
|
|
327
401
|
if (useCache) {
|
|
@@ -346,32 +420,43 @@ function extractMetadata(result, plan, outDir) {
|
|
|
346
420
|
const navBundles = new Map();
|
|
347
421
|
const layoutNavBundles = new Map();
|
|
348
422
|
const loadingNavBundles = new Map();
|
|
349
|
-
|
|
423
|
+
// Map<outputKey, pattern> so we can look up which page pattern owns a given
|
|
424
|
+
// esbuild entry output when we walk the per-entry chunk closure below.
|
|
425
|
+
const entryKeyToPattern = new Map();
|
|
350
426
|
for (const [outputPath, meta] of Object.entries(result.metafile.outputs)) {
|
|
351
|
-
const relPath = path.relative(outDir, outputPath).replace(/\\/g, '/');
|
|
352
427
|
if (outputPath.endsWith('.map')) continue;
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
if (
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
} else if (plan.entryToLoadingDir.has(absEntry)) {
|
|
368
|
-
loadingNavBundles.set(plan.entryToLoadingDir.get(absEntry), relPath);
|
|
369
|
-
}
|
|
370
|
-
// pattern variable intentionally unused when none of the maps match
|
|
371
|
-
void pattern;
|
|
428
|
+
const relPath = path.relative(outDir, outputPath).replace(/\\/g, '/');
|
|
429
|
+
if (relPath.startsWith('client/chunks/')) continue;
|
|
430
|
+
if (!meta.entryPoint) continue;
|
|
431
|
+
const absEntry = path.resolve(meta.entryPoint);
|
|
432
|
+
if (plan.entryToPattern.has(absEntry)) {
|
|
433
|
+
const pattern = plan.entryToPattern.get(absEntry);
|
|
434
|
+
bundles.set(pattern, relPath);
|
|
435
|
+
entryKeyToPattern.set(outputPath, pattern);
|
|
436
|
+
} else if (plan.entryToNavPattern.has(absEntry)) {
|
|
437
|
+
navBundles.set(plan.entryToNavPattern.get(absEntry), relPath);
|
|
438
|
+
} else if (plan.entryToLayoutDir.has(absEntry)) {
|
|
439
|
+
layoutNavBundles.set(plan.entryToLayoutDir.get(absEntry), relPath);
|
|
440
|
+
} else if (plan.entryToLoadingDir.has(absEntry)) {
|
|
441
|
+
loadingNavBundles.set(plan.entryToLoadingDir.get(absEntry), relPath);
|
|
372
442
|
}
|
|
373
443
|
}
|
|
374
|
-
|
|
444
|
+
// Per-entry chunk closure keyed by page pattern so the manifest only lists
|
|
445
|
+
// the chunks a given page actually imports, rather than dumping every chunk
|
|
446
|
+
// from every entry on every page. Only hydration entries (mapped to a page
|
|
447
|
+
// pattern) participate — nav/layout/loading bundles are loaded lazily at
|
|
448
|
+
// navigation time and resolve their own chunks then.
|
|
449
|
+
const clientDir = path.join(outDir, 'client');
|
|
450
|
+
const perEntry = computeEntryChunks(result.metafile, clientDir);
|
|
451
|
+
const entryChunks = new Map();
|
|
452
|
+
const entryCssChunks = new Map();
|
|
453
|
+
for (const [entryKey, { js, css }] of perEntry.entries()) {
|
|
454
|
+
const pattern = entryKeyToPattern.get(entryKey);
|
|
455
|
+
if (!pattern) continue;
|
|
456
|
+
entryChunks.set(pattern, js);
|
|
457
|
+
entryCssChunks.set(pattern, css);
|
|
458
|
+
}
|
|
459
|
+
return { bundles, navBundles, layoutNavBundles, loadingNavBundles, entryChunks, entryCssChunks };
|
|
375
460
|
}
|
|
376
461
|
|
|
377
462
|
function generateHydrationEntry(componentPath, layouts = [], loadings = []) {
|
|
@@ -85,7 +85,7 @@ async function createPagesBuildContext(options) {
|
|
|
85
85
|
]);
|
|
86
86
|
|
|
87
87
|
if (pages.length === 0) {
|
|
88
|
-
lastResult = { pageCount: 0, outDir, manifest: { entries: []
|
|
88
|
+
lastResult = { pageCount: 0, outDir, manifest: { entries: [] } };
|
|
89
89
|
return lastResult;
|
|
90
90
|
}
|
|
91
91
|
|