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.
@@ -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,
@@ -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
- if (!fs.existsSync(manifestPath)) {
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 = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
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 (manifest.entries.length === 0 && !manifest.notFoundBundle && !manifest.errorBundle) {
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, '&amp;')
272
+ .replace(/</g, '&lt;')
273
+ .replace(/>/g, '&gt;');
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);