arcway 0.1.21 → 0.1.23
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/watcher.js +15 -2
- package/server/ws/ws-router.js +45 -2
|
@@ -92,6 +92,103 @@ async function discoverErrorPages(pagesDir) {
|
|
|
92
92
|
}
|
|
93
93
|
return result;
|
|
94
94
|
}
|
|
95
|
+
// Given a changed file path and an in-memory pages manifest, return the set of
|
|
96
|
+
// manifest entries whose server bundles must be rebuilt. Used by the lazy dev
|
|
97
|
+
// context (#250) so a watcher file-change event can mark only the affected
|
|
98
|
+
// pages/layouts/middlewares stale rather than forcing a full-graph rebuild.
|
|
99
|
+
//
|
|
100
|
+
// Cascade rules:
|
|
101
|
+
// - Page source edited → the one page whose `srcPath` matches.
|
|
102
|
+
// - Layout source edited → the layout itself plus every descendant page
|
|
103
|
+
// (any entry whose `layoutDirs` contains that layout's `dirPath`).
|
|
104
|
+
// - Middleware source edited → the middleware itself plus every descendant
|
|
105
|
+
// page (any entry whose `middlewareDirs` contains that middleware's
|
|
106
|
+
// `dirPath`).
|
|
107
|
+
// - Anything else → empty. Callers decide whether unrelated file changes
|
|
108
|
+
// warrant a full re-discovery (e.g. a new page file appeared).
|
|
109
|
+
//
|
|
110
|
+
// All path comparisons go through `path.resolve()` to normalize mixed
|
|
111
|
+
// absolute/relative inputs. `manifest.layouts` and `manifest.middlewares` may
|
|
112
|
+
// be supplied as Maps (the lazy context shape) or plain arrays of
|
|
113
|
+
// `{ dirPath, srcPath }` tuples — both are handled.
|
|
114
|
+
function mapFileToAffected(filePath, manifest) {
|
|
115
|
+
const result = { pages: [], layouts: [], middlewares: [] };
|
|
116
|
+
if (!filePath || !manifest) return result;
|
|
117
|
+
const target = path.resolve(filePath);
|
|
118
|
+
const entries = manifest.entries ?? [];
|
|
119
|
+
|
|
120
|
+
for (const entry of entries) {
|
|
121
|
+
if (entry.srcPath && path.resolve(entry.srcPath) === target) {
|
|
122
|
+
result.pages.push(entry.pattern);
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const layoutEntries = asDirPathList(manifest.layouts);
|
|
128
|
+
const hitLayout = layoutEntries.find(
|
|
129
|
+
(l) => l.srcPath && path.resolve(l.srcPath) === target,
|
|
130
|
+
);
|
|
131
|
+
if (hitLayout) {
|
|
132
|
+
result.layouts.push(hitLayout.dirPath);
|
|
133
|
+
for (const entry of entries) {
|
|
134
|
+
if ((entry.layoutDirs ?? []).includes(hitLayout.dirPath)) {
|
|
135
|
+
result.pages.push(entry.pattern);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const middlewareEntries = asDirPathList(manifest.middlewares);
|
|
142
|
+
const hitMw = middlewareEntries.find(
|
|
143
|
+
(m) => m.srcPath && path.resolve(m.srcPath) === target,
|
|
144
|
+
);
|
|
145
|
+
if (hitMw) {
|
|
146
|
+
result.middlewares.push(hitMw.dirPath);
|
|
147
|
+
for (const entry of entries) {
|
|
148
|
+
if ((entry.middlewareDirs ?? []).includes(hitMw.dirPath)) {
|
|
149
|
+
result.pages.push(entry.pattern);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Accept either Map<dirPath, { srcPath, … }> (lazy context shape) or a plain
|
|
159
|
+
// array of `{ dirPath, srcPath }` tuples (what `discoverLayouts` emits) so the
|
|
160
|
+
// helper works from either side of the refactor without adaptor code.
|
|
161
|
+
function asDirPathList(source) {
|
|
162
|
+
if (!source) return [];
|
|
163
|
+
if (Array.isArray(source)) return source;
|
|
164
|
+
if (source instanceof Map) {
|
|
165
|
+
return Array.from(source, ([dirPath, value]) => ({ dirPath, ...value }));
|
|
166
|
+
}
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Construct an in-memory manifest entry for a page. Shared between the
|
|
171
|
+
// lazy context's initial discovery pass and `rediscover()` so both code
|
|
172
|
+
// paths produce structurally identical entries (same chain resolution,
|
|
173
|
+
// same default flags). Resolves layout/middleware/loading dirs against
|
|
174
|
+
// the discovered chain lists rather than the in-memory manifest, which
|
|
175
|
+
// keeps the helper pure.
|
|
176
|
+
function buildPageManifestEntry(page, { layouts, middlewares, loadings }) {
|
|
177
|
+
return {
|
|
178
|
+
pattern: page.pattern,
|
|
179
|
+
paramNames: page.paramNames,
|
|
180
|
+
...(page.catchAllParam ? { catchAllParam: page.catchAllParam } : {}),
|
|
181
|
+
srcPath: path.resolve(page.filePath),
|
|
182
|
+
layoutDirs: resolveLayoutChain(page.pattern, layouts).map((l) => l.dirPath),
|
|
183
|
+
middlewareDirs: resolvePageMiddlewareChain(page.pattern, middlewares).map((m) => m.dirPath),
|
|
184
|
+
loadingDirs: resolveLoadingChain(page.pattern, loadings).map((l) => l.dirPath),
|
|
185
|
+
sharedChunks: [],
|
|
186
|
+
sharedCssChunks: [],
|
|
187
|
+
built: false,
|
|
188
|
+
stale: false,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
95
192
|
async function discoverStyles(pagesDir) {
|
|
96
193
|
const stylesPath = path.join(pagesDir, '_styles.css');
|
|
97
194
|
try {
|
|
@@ -107,12 +204,14 @@ function matchPage(pages, pathname) {
|
|
|
107
204
|
return { page: result.match, params: result.params };
|
|
108
205
|
}
|
|
109
206
|
export {
|
|
207
|
+
buildPageManifestEntry,
|
|
110
208
|
discoverErrorPages,
|
|
111
209
|
discoverLayouts,
|
|
112
210
|
discoverLoadings,
|
|
113
211
|
discoverPageMiddleware,
|
|
114
212
|
discoverPages,
|
|
115
213
|
discoverStyles,
|
|
214
|
+
mapFileToAffected,
|
|
116
215
|
matchPage,
|
|
117
216
|
resolveLayoutChain,
|
|
118
217
|
resolveLoadingChain,
|
package/server/pages/handler.js
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
syncReactInternals,
|
|
15
15
|
} from './ssr.js';
|
|
16
16
|
import { MIME_TYPES } from './static.js';
|
|
17
|
+
import { createArcwayDevEndpoint } from './arcway-endpoint.js';
|
|
17
18
|
function createPagesHandler(options) {
|
|
18
19
|
const rootDir = options.rootDir;
|
|
19
20
|
const outDir = path.resolve(rootDir, options.outDir ?? '.build/pages');
|
|
@@ -22,7 +23,10 @@ function createPagesHandler(options) {
|
|
|
22
23
|
const appContext = options.appContext ?? null;
|
|
23
24
|
const mode = options.mode ?? 'production';
|
|
24
25
|
const devMode = mode === 'development';
|
|
25
|
-
|
|
26
|
+
const lazyContext = options.lazyContext ?? null;
|
|
27
|
+
// Dev with a lazy context drives the handler off an in-memory manifest; no
|
|
28
|
+
// disk `pages-manifest.json` is produced. Prod still reads from disk.
|
|
29
|
+
if (!lazyContext && !fs.existsSync(manifestPath)) {
|
|
26
30
|
return null;
|
|
27
31
|
}
|
|
28
32
|
const projectRequire = createRequire(path.join(options.rootDir, 'package.json'));
|
|
@@ -32,16 +36,47 @@ function createPagesHandler(options) {
|
|
|
32
36
|
renderToPipeableStream: projectRequire('react-dom/server').renderToPipeableStream,
|
|
33
37
|
};
|
|
34
38
|
syncReactInternals(projectReactModule);
|
|
35
|
-
let manifest =
|
|
39
|
+
let manifest = lazyContext
|
|
40
|
+
? lazyContext.manifest
|
|
41
|
+
: JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
36
42
|
let routes = compileRoutes(manifest);
|
|
37
43
|
let clientManifestJson = buildClientManifestJson(manifest);
|
|
44
|
+
let lastSeenVersion = lazyContext ? manifest.version : 0;
|
|
38
45
|
const componentCache = new Map();
|
|
39
46
|
let cacheVersion = 0;
|
|
40
47
|
const reloadEmitter = devMode ? new EventEmitter() : null;
|
|
41
|
-
if (
|
|
48
|
+
if (
|
|
49
|
+
!lazyContext &&
|
|
50
|
+
manifest.entries.length === 0 &&
|
|
51
|
+
!manifest.notFoundBundle &&
|
|
52
|
+
!manifest.errorBundle
|
|
53
|
+
) {
|
|
42
54
|
return null;
|
|
43
55
|
}
|
|
56
|
+
// Keep `routes` / `clientManifestJson` in sync with the lazy context's
|
|
57
|
+
// mutable manifest. Every `built` / `invalidate` event bumps the version,
|
|
58
|
+
// which is our cheap change signal — recompile only when it moves.
|
|
59
|
+
function refreshFromLazy() {
|
|
60
|
+
if (!lazyContext) return;
|
|
61
|
+
if (manifest.version === lastSeenVersion) return;
|
|
62
|
+
lastSeenVersion = manifest.version;
|
|
63
|
+
routes = compileRoutes(manifest);
|
|
64
|
+
clientManifestJson = buildClientManifestJson(manifest);
|
|
65
|
+
componentCache.clear();
|
|
66
|
+
cacheVersion++;
|
|
67
|
+
}
|
|
68
|
+
if (lazyContext) {
|
|
69
|
+
lazyContext.on('update', (event) => {
|
|
70
|
+
refreshFromLazy();
|
|
71
|
+
if (reloadEmitter) reloadEmitter.emit('update', event);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const arcwayEndpoint = lazyContext ? createArcwayDevEndpoint(lazyContext) : null;
|
|
44
75
|
function reload() {
|
|
76
|
+
if (lazyContext) {
|
|
77
|
+
refreshFromLazy();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
45
80
|
if (!fs.existsSync(manifestPath)) return;
|
|
46
81
|
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
|
|
47
82
|
routes = compileRoutes(manifest);
|
|
@@ -58,6 +93,9 @@ function createPagesHandler(options) {
|
|
|
58
93
|
const url = req.url ?? '/';
|
|
59
94
|
const pathname = url.split('?')[0];
|
|
60
95
|
if (method !== 'GET') return false;
|
|
96
|
+
// Internal `/_arcway/*` endpoints (dev only) resolve before the route
|
|
97
|
+
// matcher so they can never be shadowed by a user-defined page.
|
|
98
|
+
if (arcwayEndpoint && arcwayEndpoint(req, res)) return true;
|
|
61
99
|
if (reloadEmitter && pathname === '/__livereload') {
|
|
62
100
|
res.writeHead(200, {
|
|
63
101
|
'Content-Type': 'text/event-stream',
|
|
@@ -74,6 +112,9 @@ function createPagesHandler(options) {
|
|
|
74
112
|
req.on('close', () => reloadEmitter.off('update', onUpdate));
|
|
75
113
|
return true;
|
|
76
114
|
}
|
|
115
|
+
// Pick up any invalidations or completed builds that landed since the
|
|
116
|
+
// last request before we resolve the route.
|
|
117
|
+
refreshFromLazy();
|
|
77
118
|
const matched = matchPageRoute(routes, pathname);
|
|
78
119
|
if (!matched) {
|
|
79
120
|
if (manifest.notFoundBundle) {
|
|
@@ -92,6 +133,28 @@ function createPagesHandler(options) {
|
|
|
92
133
|
}
|
|
93
134
|
return false;
|
|
94
135
|
}
|
|
136
|
+
// Lazy dev: trigger on-demand build of this page's server bundle +
|
|
137
|
+
// every ancestor layout/middleware before we attempt to load any
|
|
138
|
+
// component. ensurePageBuilt() coalesces concurrent calls internally.
|
|
139
|
+
if (lazyContext) {
|
|
140
|
+
try {
|
|
141
|
+
await lazyContext.ensurePageBuilt(matched.route.pattern);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
await renderDevBuildError(
|
|
144
|
+
err,
|
|
145
|
+
res,
|
|
146
|
+
manifest,
|
|
147
|
+
outDir,
|
|
148
|
+
componentCache,
|
|
149
|
+
cacheVersion,
|
|
150
|
+
projectReact,
|
|
151
|
+
);
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
refreshFromLazy();
|
|
155
|
+
const rematched = matchPageRoute(routes, pathname);
|
|
156
|
+
if (rematched) matched.route = rematched.route;
|
|
157
|
+
}
|
|
95
158
|
if (matched.route.middlewareServerBundles.length > 0) {
|
|
96
159
|
const middlewareResult = await runPageMiddleware(
|
|
97
160
|
matched.route,
|
|
@@ -175,6 +238,43 @@ function createPagesHandler(options) {
|
|
|
175
238
|
: {}),
|
|
176
239
|
};
|
|
177
240
|
}
|
|
241
|
+
// Render a dev-only build-error page. Preferred path: use the project's
|
|
242
|
+
// `_error.jsx` bundle so devs see the same shell they'd see for runtime errors,
|
|
243
|
+
// with the esbuild message in the `error` prop. Fallback: plain-text 500 when
|
|
244
|
+
// no errorBundle is available or the response has already started.
|
|
245
|
+
async function renderDevBuildError(
|
|
246
|
+
err,
|
|
247
|
+
res,
|
|
248
|
+
manifest,
|
|
249
|
+
outDir,
|
|
250
|
+
componentCache,
|
|
251
|
+
cacheVersion,
|
|
252
|
+
projectReact,
|
|
253
|
+
) {
|
|
254
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
255
|
+
if (manifest.errorBundle && !res.headersSent) {
|
|
256
|
+
await renderErrorPage(
|
|
257
|
+
manifest.errorBundle,
|
|
258
|
+
500,
|
|
259
|
+
{ error: errorMessage },
|
|
260
|
+
outDir,
|
|
261
|
+
res,
|
|
262
|
+
componentCache,
|
|
263
|
+
manifest,
|
|
264
|
+
cacheVersion,
|
|
265
|
+
projectReact,
|
|
266
|
+
);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (!res.headersSent) {
|
|
270
|
+
const escaped = String(errorMessage)
|
|
271
|
+
.replace(/&/g, '&')
|
|
272
|
+
.replace(/</g, '<')
|
|
273
|
+
.replace(/>/g, '>');
|
|
274
|
+
res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
275
|
+
res.end(`<h1>500 - Build Error</h1><pre>${escaped}</pre>`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
178
278
|
function compileRoutes(manifest) {
|
|
179
279
|
const routes = manifest.entries.map((entry) => {
|
|
180
280
|
const { regex, paramNames, catchAllParam } = compilePattern(entry.pattern);
|