arcway 0.4.13 → 0.4.15

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/client/router.js CHANGED
@@ -23,9 +23,9 @@ import { parseQuery, stringifyQuery } from './query.js';
23
23
  const ROUTER_CTX_KEY = '__router_context__';
24
24
  const RouterContext = (globalThis[ROUTER_CTX_KEY] ??= createContext(null));
25
25
 
26
- function wrapInLayouts(element, layouts) {
26
+ function wrapInLayouts(element, layouts, props = {}) {
27
27
  for (let i = layouts.length - 1; i >= 0; i--) {
28
- element = createElement(layouts[i], null, element);
28
+ element = createElement(layouts[i], props, element);
29
29
  }
30
30
  return element;
31
31
  }
@@ -56,8 +56,10 @@ function useRouter() {
56
56
  pathname: ctx.pathname,
57
57
  params: ctx.params,
58
58
  query: typeof window !== 'undefined' ? parseQuery(window.location.search) : {},
59
- push: (to, options) => ctx.navigate(normalizeNavigateTarget(to), { ...options, replace: false }),
60
- replace: (to, options) => ctx.navigate(normalizeNavigateTarget(to), { ...options, replace: true }),
59
+ push: (to, options) =>
60
+ ctx.navigate(normalizeNavigateTarget(to), { ...options, replace: false }),
61
+ replace: (to, options) =>
62
+ ctx.navigate(normalizeNavigateTarget(to), { ...options, replace: true }),
61
63
  back: () => {
62
64
  if (typeof window !== 'undefined') window.history.back();
63
65
  },
@@ -77,6 +79,7 @@ function useRouter() {
77
79
  function Router({
78
80
  initialPath,
79
81
  initialParams,
82
+ initialPageProps,
80
83
  initialPattern,
81
84
  initialComponent,
82
85
  initialLayouts,
@@ -92,6 +95,7 @@ function Router({
92
95
  layouts: initialLayouts ?? [],
93
96
  loadings: initialLoadings ?? [],
94
97
  params: initialParams ?? {},
98
+ props: initialPageProps ?? initialParams ?? {},
95
99
  });
96
100
  const [isNavigating, setIsNavigating] = useState(false);
97
101
  const [isPending, startTransition] = useTransition();
@@ -140,6 +144,7 @@ function Router({
140
144
  layouts: loaded.layouts,
141
145
  loadings: loaded.loadings,
142
146
  params: loaded.params,
147
+ props: loaded.params,
143
148
  });
144
149
  setIsNavigating(false);
145
150
  if (bumpQuery) setQueryVersion((v) => v + 1);
@@ -158,8 +163,7 @@ function Router({
158
163
  const qIdx = to.indexOf('?');
159
164
  const pathOnly = qIdx === -1 ? to : to.slice(0, qIdx);
160
165
  const search = qIdx === -1 ? '' : to.slice(qIdx);
161
- const currentSearch =
162
- typeof window !== 'undefined' ? window.location.search : '';
166
+ const currentSearch = typeof window !== 'undefined' ? window.location.search : '';
163
167
 
164
168
  if (pathOnly === pathname && search === currentSearch) return;
165
169
 
@@ -184,6 +188,10 @@ function Router({
184
188
  window.location.href = to;
185
189
  return;
186
190
  }
191
+ if (matched.route.hasLoader) {
192
+ window.location.href = to;
193
+ return;
194
+ }
187
195
 
188
196
  setIsNavigating(true);
189
197
  if (replace) {
@@ -200,6 +208,7 @@ function Router({
200
208
  pattern: matched.route.pattern,
201
209
  loadings: targetLoadings,
202
210
  params: matched.params,
211
+ props: matched.params,
203
212
  }));
204
213
  }
205
214
 
@@ -244,6 +253,11 @@ function Router({
244
253
  }
245
254
 
246
255
  try {
256
+ const matched = matchClientRoute(manifest, newPath);
257
+ if (matched?.route.hasLoader) {
258
+ window.location.reload();
259
+ return;
260
+ }
247
261
  const loaded = await loadPage(manifest, newPath);
248
262
  if (loaded) {
249
263
  applyLoaded(loaded, newPath, true);
@@ -265,15 +279,15 @@ function Router({
265
279
  }
266
280
  }, [pageState.pattern]);
267
281
 
268
- const { component: PageComponent, layouts, loadings, params } = pageState;
282
+ const { component: PageComponent, layouts, loadings, params, props } = pageState;
269
283
 
270
284
  let content;
271
285
  if (PageComponent) {
272
286
  const inner =
273
287
  isNavigating && loadings.length > 0
274
288
  ? createElement(loadings.at(-1))
275
- : createElement(PageComponent, params);
276
- content = wrapInLayouts(inner, layouts);
289
+ : createElement(PageComponent, props);
290
+ content = wrapInLayouts(inner, layouts, props);
277
291
  } else {
278
292
  content = children;
279
293
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcway",
3
- "version": "0.4.13",
3
+ "version": "0.4.15",
4
4
  "description": "A convention-based framework for building modular monoliths with strict domain boundaries.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -72,6 +72,22 @@ function resolve(config, { rootDir } = {}) {
72
72
  ...(hmr ? { hmr } : {}),
73
73
  },
74
74
  };
75
+ if (pages.rewrite != null) {
76
+ if (
77
+ typeof pages.rewrite !== 'object' ||
78
+ Array.isArray(pages.rewrite) ||
79
+ typeof pages.rewrite.handler !== 'function' ||
80
+ typeof pages.rewrite.reservedPrefix !== 'string' ||
81
+ pages.rewrite.reservedPrefix === '/' ||
82
+ !pages.rewrite.reservedPrefix.startsWith('/') ||
83
+ pages.rewrite.reservedPrefix.endsWith('/') ||
84
+ /[?#\s]/.test(pages.rewrite.reservedPrefix)
85
+ ) {
86
+ throw new TypeError(
87
+ 'Invalid config: pages.rewrite requires a handler function and a reservedPrefix such as "/_sites"',
88
+ );
89
+ }
90
+ }
75
91
  if (pages.dir && !path.isAbsolute(pages.dir)) {
76
92
  pages.dir = path.resolve(rootDir, pages.dir);
77
93
  }
@@ -6,6 +6,7 @@ import { resolveLayoutChain, resolveLoadingChain } from './discovery.js';
6
6
  import { clientIsolationPlugin } from './build-plugins.js';
7
7
  import { patternToFileName, layoutDirToFileName } from './build-server.js';
8
8
  import { reactRefreshPlugin } from './hmr.js';
9
+ import { serverLoaderClientPlugin } from './server-loader-transform.js';
9
10
  import {
10
11
  sha256,
11
12
  ESBUILD_VERSION,
@@ -68,7 +69,14 @@ function planHydrationEntries(pages, layouts, loadings, tempDir) {
68
69
  entryPoints[loadingName] = navPath;
69
70
  entryToLoadingDir.set(navPath, loading.dirPath);
70
71
  }
71
- return { files, entryPoints, entryToPattern, entryToNavPattern, entryToLayoutDir, entryToLoadingDir };
72
+ return {
73
+ files,
74
+ entryPoints,
75
+ entryToPattern,
76
+ entryToNavPattern,
77
+ entryToLayoutDir,
78
+ entryToLoadingDir,
79
+ };
72
80
  }
73
81
 
74
82
  function resolveReactAlias(rootDir) {
@@ -144,6 +152,7 @@ function buildClientEsbuildOptions({
144
152
  nodeEnv,
145
153
  reactAlias,
146
154
  rootDir,
155
+ pagePaths,
147
156
  }) {
148
157
  return {
149
158
  entryPoints,
@@ -162,7 +171,11 @@ function buildClientEsbuildOptions({
162
171
  metafile: true,
163
172
  alias: reactAlias,
164
173
  define: { 'process.env.NODE_ENV': JSON.stringify(nodeEnv) },
165
- plugins: [...(devMode ? [reactRefreshPlugin(rootDir)] : []), clientIsolationPlugin()],
174
+ plugins: [
175
+ ...(devMode ? [reactRefreshPlugin(rootDir)] : []),
176
+ serverLoaderClientPlugin(pagePaths),
177
+ clientIsolationPlugin(),
178
+ ],
166
179
  };
167
180
  }
168
181
 
@@ -210,6 +223,7 @@ async function createClientBuildContext(
210
223
  nodeEnv,
211
224
  reactAlias,
212
225
  rootDir,
226
+ pagePaths: pages.map((page) => page.filePath),
213
227
  }),
214
228
  );
215
229
 
@@ -262,9 +276,7 @@ async function unlinkAll(absPaths) {
262
276
  // an atomic rename, so the served clientDir always contains exactly the
263
277
  // current build's outputs.
264
278
  async function collectStaleOutputs(metafile, clientDir) {
265
- const currentOutputs = new Set(
266
- Object.keys(metafile.outputs ?? {}).map((p) => path.resolve(p)),
267
- );
279
+ const currentOutputs = new Set(Object.keys(metafile.outputs ?? {}).map((p) => path.resolve(p)));
268
280
  // Scope the scan to directories esbuild actually writes to. This keeps
269
281
  // sibling artifacts owned by other build steps (e.g. the HMR runtime under
270
282
  // `client/hmr/`) out of the GC set, since they will never appear in the
@@ -315,14 +327,7 @@ async function tryRestoreClientFromCache({ rootDir, clientDir, coarseKey }) {
315
327
  return { hit: true, bucket: lookup.bucket, metadata: deserializeMetadata(lookup.meta.metadata) };
316
328
  }
317
329
 
318
- async function storeClientInCache({
319
- bucket,
320
- coarseKey,
321
- clientDir,
322
- tempDir,
323
- result,
324
- metadata,
325
- }) {
330
+ async function storeClientInCache({ bucket, coarseKey, clientDir, tempDir, result, metadata }) {
326
331
  const outputs = Object.keys(result.metafile.outputs)
327
332
  .map((p) => path.relative(clientDir, path.resolve(p)).replace(/\\/g, '/'))
328
333
  .filter((rel) => !rel.startsWith('..'));
@@ -403,6 +408,7 @@ async function buildClientBundles(
403
408
  nodeEnv,
404
409
  reactAlias,
405
410
  rootDir,
411
+ pagePaths: pages.map((page) => page.filePath),
406
412
  });
407
413
  const result = await limit(() => runClientEsbuild(plan, esbuildOptions, tempDir));
408
414
  const metadata = extractMetadata(result, plan, outDir);
@@ -494,9 +500,11 @@ function generateHydrationEntry(componentPath, layouts = [], loadings = [], patt
494
500
  const container = document.getElementById('__app');
495
501
  const propsEl = document.getElementById('__app_props');
496
502
  const props = propsEl ? JSON.parse(propsEl.textContent || '{}') : {};
503
+ const routeParamsEl = document.getElementById('__app_route_params');
504
+ const routeParams = routeParamsEl ? JSON.parse(routeParamsEl.textContent || '{}') : props;
497
505
  const ROOT_KEY = '__arcway_root__';
498
506
  const rootOwner = window;
499
- const element = <ApiProvider><Router initialPath={window.location.pathname} initialParams={props} initialPattern={${JSON.stringify(pattern)}} initialComponent={Component} initialLayouts={${layoutsArray}} initialLoadings={${loadingsArray}} /></ApiProvider>;
507
+ const element = <ApiProvider><Router initialPath={window.location.pathname} initialParams={routeParams} initialPageProps={props} initialPattern={${JSON.stringify(pattern)}} initialComponent={Component} initialLayouts={${layoutsArray}} initialLoadings={${loadingsArray}} /></ApiProvider>;
500
508
 
501
509
  if (!container) {
502
510
  throw new Error('Arcway hydrate entry could not find #__app');
@@ -39,6 +39,7 @@ function generateManifest(
39
39
  entries.push({
40
40
  pattern: page.pattern,
41
41
  paramNames: page.paramNames,
42
+ ...(page.hasLoader ? { hasLoader: true } : {}),
42
43
  ...(page.catchAllParam ? { catchAllParam: page.catchAllParam } : {}),
43
44
  serverBundle,
44
45
  clientBundle,
@@ -7,6 +7,7 @@ import {
7
7
  sortBySpecificity,
8
8
  matchPattern,
9
9
  } from '../router/routes.js';
10
+ import { hasServerLoaderExport } from './server-loader-transform.js';
10
11
  function dirDepth(dirPath) {
11
12
  return dirPath === '' ? 0 : dirPath.split(path.sep).length;
12
13
  }
@@ -49,6 +50,7 @@ async function discoverPages(pagesDir) {
49
50
  const fileName = path.basename(filePath);
50
51
  if (fileName.startsWith('_')) continue;
51
52
  const relPath = path.relative(pagesDir, filePath);
53
+ const source = await fs.readFile(filePath, 'utf8');
52
54
  const urlPattern = filePathToPattern(relPath);
53
55
  const { regex, paramNames, catchAllParam } = compilePattern(urlPattern);
54
56
  pages.push({
@@ -57,6 +59,7 @@ async function discoverPages(pagesDir) {
57
59
  paramNames,
58
60
  ...(catchAllParam ? { catchAllParam } : {}),
59
61
  filePath,
62
+ hasLoader: await hasServerLoaderExport(source, filePath),
60
63
  });
61
64
  }
62
65
  sortBySpecificity(pages);
@@ -125,9 +128,7 @@ function mapFileToAffected(filePath, manifest) {
125
128
  }
126
129
 
127
130
  const layoutEntries = asDirPathList(manifest.layouts);
128
- const hitLayout = layoutEntries.find(
129
- (l) => l.srcPath && path.resolve(l.srcPath) === target,
130
- );
131
+ const hitLayout = layoutEntries.find((l) => l.srcPath && path.resolve(l.srcPath) === target);
131
132
  if (hitLayout) {
132
133
  result.layouts.push(hitLayout.dirPath);
133
134
  for (const entry of entries) {
@@ -139,9 +140,7 @@ function mapFileToAffected(filePath, manifest) {
139
140
  }
140
141
 
141
142
  const middlewareEntries = asDirPathList(manifest.middlewares);
142
- const hitMw = middlewareEntries.find(
143
- (m) => m.srcPath && path.resolve(m.srcPath) === target,
144
- );
143
+ const hitMw = middlewareEntries.find((m) => m.srcPath && path.resolve(m.srcPath) === target);
145
144
  if (hitMw) {
146
145
  result.middlewares.push(hitMw.dirPath);
147
146
  for (const entry of entries) {
@@ -179,6 +178,7 @@ function buildPageManifestEntry(page, { layouts, middlewares, loadings }) {
179
178
  paramNames: page.paramNames,
180
179
  ...(page.catchAllParam ? { catchAllParam: page.catchAllParam } : {}),
181
180
  srcPath: path.resolve(page.filePath),
181
+ ...(page.hasLoader ? { hasLoader: true } : {}),
182
182
  layoutDirs: resolveLayoutChain(page.pattern, layouts).map((l) => l.dirPath),
183
183
  middlewareDirs: resolvePageMiddlewareChain(page.pattern, middlewares).map((m) => m.dirPath),
184
184
  loadingDirs: resolveLoadingChain(page.pattern, loadings).map((l) => l.dirPath),
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'node:fs';
3
+ import { domainToASCII } from 'node:url';
3
4
  import { createRequire } from 'node:module';
4
5
  import { EventEmitter } from 'node:events';
5
6
  import { compilePattern, sortBySpecificity, matchPattern } from '../router/routes.js';
@@ -9,6 +10,7 @@ import {
9
10
  buildRouteCssLinkTags,
10
11
  buildScriptTags,
11
12
  loadComponent,
13
+ loadPageModule,
12
14
  renderErrorPage,
13
15
  renderPage,
14
16
  syncReactInternals,
@@ -33,6 +35,7 @@ function createPagesHandler(options) {
33
35
  let manifestPath = path.join(outDir, 'pages-manifest.json');
34
36
  const sessionConfig = options.session;
35
37
  const appContext = options.appContext ?? null;
38
+ const rewrite = options.rewrite ?? null;
36
39
  const mode = options.mode ?? 'production';
37
40
  const devMode = mode === 'development';
38
41
  const viteDev = options.viteDev === true;
@@ -57,6 +60,7 @@ function createPagesHandler(options) {
57
60
  rootDir,
58
61
  outDir,
59
62
  viteDev,
63
+ reservedPrefix: rewrite?.reservedPrefix,
60
64
  });
61
65
  let lastSeenVersion = lazyContext ? manifest.version : 0;
62
66
  const componentCache = new Map();
@@ -82,6 +86,7 @@ function createPagesHandler(options) {
82
86
  rootDir,
83
87
  outDir,
84
88
  viteDev,
89
+ reservedPrefix: rewrite?.reservedPrefix,
85
90
  });
86
91
  componentCache.clear();
87
92
  cacheVersion++;
@@ -110,6 +115,7 @@ function createPagesHandler(options) {
110
115
  rootDir,
111
116
  outDir,
112
117
  viteDev,
118
+ reservedPrefix: rewrite?.reservedPrefix,
113
119
  });
114
120
  componentCache.clear();
115
121
  cacheVersion++;
@@ -118,10 +124,34 @@ function createPagesHandler(options) {
118
124
  if (!reloadEmitter) return;
119
125
  reloadEmitter.emit('update', event);
120
126
  }
127
+ async function renderRuntimeError(err, res) {
128
+ if (manifest.errorBundle && !res.headersSent) {
129
+ const errorMessage = devMode
130
+ ? err instanceof Error
131
+ ? err.stack || err.message
132
+ : String(err)
133
+ : 'An unexpected error occurred';
134
+ await renderErrorPage(
135
+ manifest.errorBundle,
136
+ 500,
137
+ { error: errorMessage },
138
+ outDir,
139
+ res,
140
+ componentCache,
141
+ manifest,
142
+ cacheVersion,
143
+ projectReact,
144
+ );
145
+ } else if (!res.headersSent) {
146
+ res.writeHead(500, getHtmlHeaders());
147
+ res.end('<h1>500 - Internal Server Error</h1>');
148
+ }
149
+ }
121
150
  async function handle(req, res) {
122
151
  const method = req.method ?? 'GET';
123
152
  const url = req.url ?? '/';
124
- const pathname = url.split('?')[0];
153
+ const requestUrl = new URL(url, 'http://arcway.internal');
154
+ const pathname = requestUrl.pathname;
125
155
  if (method !== 'GET') return false;
126
156
  // Internal `/_arcway/*` endpoints (dev only) resolve before the route
127
157
  // matcher so they can never be shadowed by a user-defined page.
@@ -150,20 +180,32 @@ function createPagesHandler(options) {
150
180
  // last request before we resolve the route.
151
181
  if (!lazyContext) reload();
152
182
  refreshFromLazy();
153
- const matched = matchPageRoute(routes, pathname);
183
+ let requestContext;
184
+ let rewritten;
185
+ try {
186
+ requestContext = await createPageRequestContext({
187
+ req,
188
+ pathname,
189
+ internalPathname: pathname,
190
+ params: {},
191
+ requestUrl,
192
+ sessionConfig,
193
+ appContext,
194
+ });
195
+ rewritten = await resolvePageRewrite(rewrite, requestContext);
196
+ } catch (err) {
197
+ await renderRuntimeError(err, res);
198
+ return true;
199
+ }
200
+ if (rewritten.notFound) {
201
+ await renderNotFound(pathname);
202
+ return true;
203
+ }
204
+ const internalPathname = rewritten.pathname;
205
+ let matched = matchPageRoute(routes, internalPathname);
154
206
  if (!matched) {
155
- if (manifest.notFoundBundle) {
156
- await renderErrorPage(
157
- manifest.notFoundBundle,
158
- 404,
159
- { pathname },
160
- outDir,
161
- res,
162
- componentCache,
163
- manifest,
164
- cacheVersion,
165
- projectReact,
166
- );
207
+ if (rewrite || manifest.notFoundBundle) {
208
+ await renderNotFound(pathname);
167
209
  return true;
168
210
  }
169
211
  return false;
@@ -187,20 +229,19 @@ function createPagesHandler(options) {
187
229
  return true;
188
230
  }
189
231
  refreshFromLazy();
190
- const rematched = matchPageRoute(routes, pathname);
232
+ const rematched = matchPageRoute(routes, internalPathname);
191
233
  if (rematched) matched.route = rematched.route;
192
234
  }
235
+ requestContext.page.internalPathname = internalPathname;
236
+ requestContext.page.params = matched.params;
237
+ requestContext.page.query = matched.params;
193
238
  if (matched.route.middlewareServerBundles.length > 0) {
194
239
  const middlewareResult = await runPageMiddleware(
195
240
  matched.route,
196
- matched.params,
197
- pathname,
198
- req,
199
241
  outDir,
200
242
  componentCache,
201
243
  cacheVersion,
202
- sessionConfig,
203
- appContext,
244
+ requestContext,
204
245
  );
205
246
  if (middlewareResult) {
206
247
  if (middlewareResult.redirect) {
@@ -223,6 +264,37 @@ function createPagesHandler(options) {
223
264
  return true;
224
265
  }
225
266
  }
267
+ let loaderResult;
268
+ try {
269
+ loaderResult = await runPageLoader(
270
+ matched.route,
271
+ matched.params,
272
+ outDir,
273
+ componentCache,
274
+ cacheVersion,
275
+ requestContext,
276
+ );
277
+ } catch (err) {
278
+ await renderRuntimeError(err, res);
279
+ return true;
280
+ }
281
+ if (loaderResult?.redirect) {
282
+ res.writeHead(loaderResult.status ?? 302, {
283
+ Location: loaderResult.redirect,
284
+ ...loaderResult.headers,
285
+ });
286
+ res.end();
287
+ return true;
288
+ }
289
+ if (loaderResult?.notFound) {
290
+ await renderNotFound(pathname);
291
+ return true;
292
+ }
293
+ const pageProps = {
294
+ ...matched.params,
295
+ ...(loaderResult?.props ?? {}),
296
+ ...(loaderResult?.metadata === undefined ? {} : { metadata: loaderResult.metadata }),
297
+ };
226
298
  try {
227
299
  await renderPage(
228
300
  matched.route,
@@ -237,18 +309,24 @@ function createPagesHandler(options) {
237
309
  projectReact,
238
310
  devMode,
239
311
  viteDev,
312
+ (err) => renderRuntimeError(err, res),
313
+ pageProps,
314
+ {
315
+ status: loaderResult?.status,
316
+ headers: loaderResult?.headers,
317
+ },
240
318
  );
241
319
  } catch (err) {
242
- if (manifest.errorBundle && !res.headersSent) {
243
- const errorMessage = devMode
244
- ? err instanceof Error
245
- ? err.stack || err.message
246
- : String(err)
247
- : 'An unexpected error occurred';
320
+ await renderRuntimeError(err, res);
321
+ }
322
+ return true;
323
+
324
+ async function renderNotFound(originalPathname) {
325
+ if (manifest.notFoundBundle) {
248
326
  await renderErrorPage(
249
- manifest.errorBundle,
250
- 500,
251
- { error: errorMessage },
327
+ manifest.notFoundBundle,
328
+ 404,
329
+ { pathname: originalPathname },
252
330
  outDir,
253
331
  res,
254
332
  componentCache,
@@ -256,12 +334,11 @@ function createPagesHandler(options) {
256
334
  cacheVersion,
257
335
  projectReact,
258
336
  );
259
- } else if (!res.headersSent) {
260
- res.writeHead(500, getHtmlHeaders());
261
- res.end('<h1>500 - Internal Server Error</h1>');
337
+ } else {
338
+ res.writeHead(404, getHtmlHeaders());
339
+ res.end('<h1>404 - Not Found</h1>');
262
340
  }
263
341
  }
264
- return true;
265
342
  }
266
343
  return {
267
344
  handle,
@@ -325,6 +402,7 @@ function compileRoutes(manifest, { rootDir, outDir, viteDev } = {}) {
325
402
  middlewareServerBundles: entry.middlewareServerBundles ?? [],
326
403
  sharedChunks: entry.sharedChunks ?? [],
327
404
  sharedCssChunks: entry.sharedCssChunks ?? [],
405
+ hasLoader: entry.hasLoader === true,
328
406
  };
329
407
  if (viteDev) {
330
408
  route = buildViteRoute(route, { rootDir, outDir });
@@ -339,28 +417,51 @@ function matchPageRoute(routes, pathname) {
339
417
  if (!result) return null;
340
418
  return { route: result.match, params: result.params };
341
419
  }
342
- async function runPageMiddleware(
343
- route,
344
- params,
345
- pathname,
420
+ async function runPageMiddleware(route, outDir, componentCache, cacheVersion, ctx) {
421
+ for (const bundlePath of route.middlewareServerBundles) {
422
+ const fullPath = path.join(outDir, bundlePath);
423
+ const middlewareFn = await loadComponent(
424
+ fullPath,
425
+ `middleware:${bundlePath}`,
426
+ componentCache,
427
+ cacheVersion,
428
+ );
429
+ if (!middlewareFn || typeof middlewareFn !== 'function') {
430
+ continue;
431
+ }
432
+ const result = await middlewareFn(ctx);
433
+ if (result) {
434
+ return result;
435
+ }
436
+ }
437
+ return null;
438
+ }
439
+
440
+ async function createPageRequestContext({
346
441
  req,
347
- outDir,
348
- componentCache,
349
- cacheVersion,
442
+ pathname,
443
+ internalPathname,
444
+ params,
445
+ requestUrl,
350
446
  sessionConfig,
351
447
  appContext,
352
- ) {
448
+ }) {
353
449
  const headers = flattenHeaders(req.headers);
354
450
  const cookies = parseCookies(req);
355
451
  const session = await resolveSession(cookies, sessionConfig);
356
452
  const page = {
453
+ host: normalizeHost(headers.host),
357
454
  pathname,
455
+ internalPathname,
456
+ params,
358
457
  query: params,
458
+ search: requestUrl.search,
459
+ searchParams: requestUrl.searchParams,
359
460
  headers,
360
461
  cookies,
361
462
  session,
362
463
  };
363
- const ctx = appContext
464
+ return appContext
364
465
  ? {
365
466
  page,
366
467
  db: appContext.db,
@@ -373,34 +474,166 @@ async function runPageMiddleware(
373
474
  meta: appContext.meta,
374
475
  }
375
476
  : { page };
376
- for (const bundlePath of route.middlewareServerBundles) {
377
- const fullPath = path.join(outDir, bundlePath);
378
- const middlewareFn = await loadComponent(
379
- fullPath,
380
- `middleware:${bundlePath}`,
381
- componentCache,
382
- cacheVersion,
383
- );
384
- if (!middlewareFn || typeof middlewareFn !== 'function') {
385
- continue;
477
+ }
478
+
479
+ function normalizeHost(value) {
480
+ if (typeof value !== 'string' || !value || /[\s,/@?#]/.test(value)) return null;
481
+ const match = value.match(/^([^:]+?)(?::([0-9]+))?$/);
482
+ if (!match) return null;
483
+ const port = match[2] ? Number(match[2]) : null;
484
+ if (port !== null && (port < 1 || port > 65535)) return null;
485
+ const source = match[1].endsWith('.') ? match[1].slice(0, -1) : match[1];
486
+ const host = domainToASCII(source.toLowerCase());
487
+ if (
488
+ !host ||
489
+ host.length > 253 ||
490
+ !host
491
+ .split('.')
492
+ .every(
493
+ (label) =>
494
+ label.length > 0 && label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label),
495
+ )
496
+ ) {
497
+ return null;
498
+ }
499
+ return host;
500
+ }
501
+
502
+ async function resolvePageRewrite(rewrite, ctx) {
503
+ if (!rewrite) return { pathname: ctx.page.pathname };
504
+ const { reservedPrefix, handler } = rewrite;
505
+ if (
506
+ !ctx.page.host ||
507
+ ctx.page.pathname === reservedPrefix ||
508
+ ctx.page.pathname.startsWith(`${reservedPrefix}/`)
509
+ ) {
510
+ return { notFound: true };
511
+ }
512
+ const result = await handler(ctx);
513
+ if (!result) return { pathname: ctx.page.pathname };
514
+ if (result.notFound === true) return { notFound: true };
515
+ if (
516
+ typeof result.pathname !== 'string' ||
517
+ (result.pathname !== reservedPrefix && !result.pathname.startsWith(`${reservedPrefix}/`)) ||
518
+ /[?#]/.test(result.pathname)
519
+ ) {
520
+ throw new TypeError('Page rewrite must target its configured reservedPrefix');
521
+ }
522
+ return { pathname: result.pathname };
523
+ }
524
+
525
+ async function runPageLoader(route, params, outDir, componentCache, cacheVersion, ctx) {
526
+ const bundlePath = path.join(outDir, route.serverBundle);
527
+ const pageModule = await loadPageModule(
528
+ bundlePath,
529
+ `page-module:${route.pattern}`,
530
+ componentCache,
531
+ cacheVersion,
532
+ );
533
+ if (pageModule.loader === undefined) return null;
534
+ if (typeof pageModule.loader !== 'function') {
535
+ throw new TypeError(`Page loader for ${route.pattern} must be a function`);
536
+ }
537
+ const result = await pageModule.loader(ctx);
538
+ if (result == null) return { props: {} };
539
+ if (typeof result !== 'object' || Array.isArray(result)) {
540
+ throw new TypeError(`Page loader for ${route.pattern} must return an object`);
541
+ }
542
+ if (result.redirect !== undefined) {
543
+ if (typeof result.redirect !== 'string' || !result.redirect) {
544
+ throw new TypeError('Page loader redirect must be a non-empty string');
386
545
  }
387
- const result = await middlewareFn(ctx);
388
- if (result) {
389
- return result;
546
+ if (
547
+ result.status !== undefined &&
548
+ (!Number.isInteger(result.status) || result.status < 300 || result.status > 399)
549
+ ) {
550
+ throw new TypeError('Page loader redirect status must be an integer from 300 to 399');
390
551
  }
552
+ validateLoaderHeaders(result.headers);
553
+ return result;
391
554
  }
392
- return null;
555
+ if (result.notFound === true) return result;
556
+ assertJsonSerializable(result.props ?? {}, 'loader props');
557
+ if (result.metadata !== undefined) {
558
+ assertJsonSerializable(result.metadata, 'loader metadata');
559
+ }
560
+ if (
561
+ result.status !== undefined &&
562
+ (!Number.isInteger(result.status) || result.status < 200 || result.status > 599)
563
+ ) {
564
+ throw new TypeError('Page loader status must be an integer from 200 to 599');
565
+ }
566
+ validateLoaderHeaders(result.headers);
567
+ return result;
393
568
  }
394
- function buildClientManifestJson(manifest, { rootDir, viteDev } = {}) {
569
+
570
+ function validateLoaderHeaders(headers) {
571
+ if (headers === undefined) return;
572
+ if (!headers || typeof headers !== 'object' || Array.isArray(headers)) {
573
+ throw new TypeError('Page loader headers must be an object');
574
+ }
575
+ const forbidden = new Set([
576
+ 'connection',
577
+ 'content-length',
578
+ 'keep-alive',
579
+ 'proxy-authenticate',
580
+ 'proxy-authorization',
581
+ 'set-cookie',
582
+ 'te',
583
+ 'trailer',
584
+ 'transfer-encoding',
585
+ 'upgrade',
586
+ ]);
587
+ for (const [name, value] of Object.entries(headers)) {
588
+ if (forbidden.has(name.toLowerCase())) {
589
+ throw new TypeError(`Page loader may not set the ${name} header`);
590
+ }
591
+ if (typeof value !== 'string' && typeof value !== 'number') {
592
+ throw new TypeError('Page loader header values must be strings or numbers');
593
+ }
594
+ }
595
+ }
596
+
597
+ function assertJsonSerializable(value, label, seen = new Set()) {
598
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return;
599
+ if (typeof value === 'number' && Number.isFinite(value)) return;
600
+ if (typeof value !== 'object') {
601
+ throw new TypeError(`${label} must contain only JSON-serializable values`);
602
+ }
603
+ if (
604
+ seen.has(value) ||
605
+ (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype)
606
+ ) {
607
+ throw new TypeError(`${label} must contain only JSON-serializable values`);
608
+ }
609
+ seen.add(value);
610
+ for (const entry of Array.isArray(value) ? value : Object.values(value)) {
611
+ assertJsonSerializable(entry, label, seen);
612
+ }
613
+ seen.delete(value);
614
+ }
615
+ function buildClientManifestJson(manifest, { rootDir, viteDev, reservedPrefix } = {}) {
616
+ const clientVisibleManifest = reservedPrefix
617
+ ? {
618
+ ...manifest,
619
+ entries: manifest.entries.filter(
620
+ (entry) =>
621
+ entry.pattern !== reservedPrefix && !entry.pattern.startsWith(`${reservedPrefix}/`),
622
+ ),
623
+ }
624
+ : manifest;
395
625
  if (viteDev) {
396
- return buildViteClientManifestJson(manifest, rootDir);
626
+ return buildViteClientManifestJson(clientVisibleManifest, rootDir);
397
627
  }
398
628
  const clientManifest = {
399
- cssBundle: manifest.cssBundle ? `/static/${manifest.cssBundle}` : null,
400
- routes: manifest.entries.map((entry) => {
629
+ cssBundle: clientVisibleManifest.cssBundle
630
+ ? `/static/${clientVisibleManifest.cssBundle}`
631
+ : null,
632
+ routes: clientVisibleManifest.entries.map((entry) => {
401
633
  const route = {
402
634
  pattern: entry.pattern,
403
635
  paramNames: entry.paramNames,
636
+ ...(entry.hasLoader ? { hasLoader: true } : {}),
404
637
  clientBundle: `/static/${entry.navBundle}`,
405
638
  layoutBundles: (entry.layoutClientBundles ?? []).map((b) => `/static/${b}`),
406
639
  loadingBundles: (entry.loadingClientBundles ?? []).map((b) => `/static/${b}`),
@@ -381,6 +381,26 @@ async function createLazyPagesContext(options) {
381
381
  return /^(components|hooks|client|packages)\//.test(rel);
382
382
  }
383
383
 
384
+ function isSpecialPageSource(filePath) {
385
+ const absolutePath = path.resolve(filePath);
386
+ return [errorPages.errorPage, errorPages.notFoundPage]
387
+ .filter(Boolean)
388
+ .some((pagePath) => path.resolve(pagePath) === absolutePath);
389
+ }
390
+
391
+ async function rebuildErrorPages() {
392
+ const errorBundles = await buildErrorPageBundles(errorPages, outDir, serverTarget, {
393
+ rootDir,
394
+ devMode,
395
+ minify,
396
+ limit,
397
+ });
398
+ if (errorBundles.error) manifest.errorBundle = errorBundles.error;
399
+ else delete manifest.errorBundle;
400
+ if (errorBundles.notFound) manifest.notFoundBundle = errorBundles.notFound;
401
+ else delete manifest.notFoundBundle;
402
+ }
403
+
384
404
  function markAffectedStale(filePath) {
385
405
  const affected = mapFileToAffected(filePath, manifest);
386
406
  if (shouldInvalidateWholeTree(filePath, affected)) {
@@ -433,12 +453,21 @@ async function createLazyPagesContext(options) {
433
453
 
434
454
  async function handleSourceChange(filePath) {
435
455
  if (disposed) throw new Error('Lazy pages context is disposed');
456
+ const specialPageSource = isSpecialPageSource(filePath);
457
+ const rebuildSpecialPages =
458
+ specialPageSource ||
459
+ shouldInvalidateWholeTree(filePath, { pages: [], layouts: [], middlewares: [] });
436
460
  const { touched, affected } = markAffectedStale(filePath);
437
- if (!touched) return { touched: false, affected, hmr: null };
461
+ if (!touched && !rebuildSpecialPages) return { touched: false, affected, hmr: null };
438
462
  if (viteDev) {
463
+ if (rebuildSpecialPages) await rebuildErrorPages();
439
464
  bumpVersion();
440
465
  emit('update', { type: 'invalidate', filePath, affected, version: manifest.version });
441
- return { touched: true, affected, hmr: null };
466
+ const hmr = specialPageSource ? { type: 'reload', timestamp: Date.now() } : null;
467
+ if (hmr) {
468
+ emit('update', { ...hmr, filePath, reason: 'special-page', version: manifest.version });
469
+ }
470
+ return { touched: true, affected, hmr };
442
471
  }
443
472
  if (inFlightClientRefresh) return inFlightClientRefresh;
444
473
 
@@ -449,6 +478,7 @@ async function createLazyPagesContext(options) {
449
478
  const [clientResult, cssBundle] = await Promise.all([
450
479
  clientCtx?.rebuild() ?? Promise.resolve(null),
451
480
  buildCssBundle(pagesDir, outDir, minify, stylesPath, fontFaceCss, rootDir),
481
+ rebuildSpecialPages ? rebuildErrorPages() : Promise.resolve(),
452
482
  ]);
453
483
 
454
484
  if (clientResult) {
@@ -459,8 +489,15 @@ async function createLazyPagesContext(options) {
459
489
  bumpVersion();
460
490
  emit('update', { type: 'invalidate', filePath, affected, version: manifest.version });
461
491
 
462
- let hmr = null;
463
- if (clientResult && previousMetafile) {
492
+ let hmr = specialPageSource ? { type: 'reload', timestamp } : null;
493
+ if (hmr) {
494
+ emit('update', {
495
+ ...hmr,
496
+ filePath,
497
+ reason: 'special-page',
498
+ version: manifest.version,
499
+ });
500
+ } else if (clientResult && previousMetafile) {
464
501
  const diff = diffClientMetafiles(previousMetafile, clientResult.metafile, outDir);
465
502
  const modules = [...diff.changedHydrationBundles, ...diff.changedNavBundles];
466
503
  const routeBundles = {};
@@ -55,6 +55,7 @@ class PagesRouter {
55
55
  ...(isDev ? { outDir } : { resolveOutDir }),
56
56
  session: config.session,
57
57
  appContext: this.appContext,
58
+ rewrite: config.pages?.rewrite,
58
59
  mode,
59
60
  viteDev,
60
61
  ...(this.lazyContext ? { lazyContext: this.lazyContext } : {}),
@@ -0,0 +1,191 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs/promises';
3
+ import { parseAsync, transformAsync } from '@babel/core';
4
+
5
+ function exportedName(specifier) {
6
+ if (specifier?.exported?.type === 'Identifier') return specifier.exported.name;
7
+ if (specifier?.exported?.type === 'StringLiteral') return specifier.exported.value;
8
+ return null;
9
+ }
10
+
11
+ async function hasServerLoaderExport(code, filename) {
12
+ if (!code.includes('loader') && !/\bexport\s*\*/.test(code)) return false;
13
+ const ast = await parseAsync(code, {
14
+ filename,
15
+ babelrc: false,
16
+ configFile: false,
17
+ parserOpts: { plugins: ['jsx', 'typescript'] },
18
+ });
19
+ return ast.program.body.some((node) => {
20
+ // A star re-export can acquire a loader through any depth of barrel module.
21
+ // Treat it conservatively so client navigation can never bypass a loader
22
+ // that is only visible after module resolution.
23
+ if (node.type === 'ExportAllDeclaration') return true;
24
+ if (node.type !== 'ExportNamedDeclaration') return false;
25
+ if (node.declaration?.type === 'FunctionDeclaration') {
26
+ return node.declaration.id?.name === 'loader';
27
+ }
28
+ if (node.declaration?.type === 'VariableDeclaration') {
29
+ return node.declaration.declarations.some(
30
+ (item) => item.id.type === 'Identifier' && item.id.name === 'loader',
31
+ );
32
+ }
33
+ return node.specifiers.some((specifier) => exportedName(specifier) === 'loader');
34
+ });
35
+ }
36
+
37
+ function rememberDependencies(targetPath, state) {
38
+ const dependencies = state.file.get('arcwayLoaderDependencies') ?? new Set();
39
+ targetPath.traverse({
40
+ ReferencedIdentifier(identifierPath) {
41
+ const binding = identifierPath.scope.getBinding(identifierPath.node.name);
42
+ if (binding?.scope.path.isProgram()) dependencies.add(identifierPath.node.name);
43
+ },
44
+ });
45
+ state.file.set('arcwayLoaderDependencies', dependencies);
46
+ }
47
+
48
+ function removeBinding(binding, state) {
49
+ if (!binding) return;
50
+ const bindingPath = binding.path;
51
+ rememberDependencies(bindingPath, state);
52
+ if (bindingPath.isFunctionDeclaration() || bindingPath.isClassDeclaration()) {
53
+ bindingPath.remove();
54
+ return;
55
+ }
56
+ if (bindingPath.isVariableDeclarator()) {
57
+ const declarationPath = bindingPath.parentPath;
58
+ bindingPath.remove();
59
+ if (declarationPath.node?.declarations.length === 0) declarationPath.remove();
60
+ }
61
+ }
62
+
63
+ function stripLoaderExportPlugin() {
64
+ return {
65
+ visitor: {
66
+ ExportAllDeclaration(exportPath) {
67
+ // Page named exports have no browser runtime contract. Removing a star
68
+ // export is the only safe client transform because its export names can
69
+ // change through transitive barrels without changing this page file.
70
+ exportPath.remove();
71
+ },
72
+ ExportNamedDeclaration(exportPath, state) {
73
+ const declaration = exportPath.node.declaration;
74
+ if (declaration?.type === 'FunctionDeclaration' && declaration.id?.name === 'loader') {
75
+ rememberDependencies(exportPath.get('declaration'), state);
76
+ exportPath.remove();
77
+ return;
78
+ }
79
+ if (declaration?.type === 'VariableDeclaration') {
80
+ for (const declaratorPath of exportPath.get('declaration.declarations')) {
81
+ if (
82
+ declaratorPath.node.id.type === 'Identifier' &&
83
+ declaratorPath.node.id.name === 'loader'
84
+ ) {
85
+ rememberDependencies(declaratorPath, state);
86
+ }
87
+ }
88
+ declaration.declarations = declaration.declarations.filter(
89
+ (item) => item.id.type !== 'Identifier' || item.id.name !== 'loader',
90
+ );
91
+ if (declaration.declarations.length === 0) exportPath.remove();
92
+ return;
93
+ }
94
+ exportPath.node.specifiers = exportPath.node.specifiers.filter((specifier) => {
95
+ if (exportedName(specifier) !== 'loader') return true;
96
+ if (!exportPath.node.source && specifier.local?.name) {
97
+ const names = state.file.get('arcwayLoaderBindings') ?? [];
98
+ names.push(specifier.local.name);
99
+ state.file.set('arcwayLoaderBindings', names);
100
+ }
101
+ return false;
102
+ });
103
+ if (exportPath.node.specifiers.length === 0 && !declaration) exportPath.remove();
104
+ },
105
+ Program: {
106
+ exit(programPath, state) {
107
+ for (const name of state.file.get('arcwayLoaderBindings') ?? []) {
108
+ const binding = programPath.scope.getBinding(name);
109
+ removeBinding(binding, state);
110
+ }
111
+
112
+ const pending = state.file.get('arcwayLoaderDependencies') ?? new Set();
113
+ const inspected = new Set();
114
+ while (pending.size > 0) {
115
+ programPath.scope.crawl();
116
+ const name = pending.values().next().value;
117
+ pending.delete(name);
118
+ if (inspected.has(name)) continue;
119
+ inspected.add(name);
120
+ const binding = programPath.scope.getBinding(name);
121
+ if (binding && !binding.referenced) removeBinding(binding, state);
122
+ }
123
+
124
+ programPath.scope.crawl();
125
+ for (const importPath of programPath.get('body')) {
126
+ if (!importPath.isImportDeclaration()) continue;
127
+ const hadSpecifiers = importPath.node.specifiers.length > 0;
128
+ for (const specifierPath of importPath.get('specifiers')) {
129
+ const binding = programPath.scope.getBinding(specifierPath.node.local.name);
130
+ if (!binding?.referenced) specifierPath.remove();
131
+ }
132
+ if (hadSpecifiers && importPath.node.specifiers.length === 0) importPath.remove();
133
+ }
134
+ },
135
+ },
136
+ },
137
+ };
138
+ }
139
+
140
+ async function stripServerLoaderExport(code, filename) {
141
+ if (!code.includes('loader') && !/\bexport\s*\*/.test(code)) return code;
142
+ const result = await transformAsync(code, {
143
+ filename,
144
+ babelrc: false,
145
+ configFile: false,
146
+ sourceMaps: false,
147
+ parserOpts: {
148
+ plugins: ['jsx', 'typescript'],
149
+ },
150
+ plugins: [stripLoaderExportPlugin],
151
+ });
152
+ return result?.code ?? code;
153
+ }
154
+
155
+ function serverLoaderClientPlugin(pagePaths) {
156
+ const pages = new Set(pagePaths.map((filePath) => path.resolve(filePath)));
157
+ return {
158
+ name: 'arcway-server-page-loader',
159
+ setup(build) {
160
+ build.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, async (args) => {
161
+ if (!pages.has(path.resolve(args.path))) return null;
162
+ const source = await fs.readFile(args.path, 'utf8');
163
+ const contents = await stripServerLoaderExport(source, args.path);
164
+ const extension = path.extname(args.path).toLowerCase();
165
+ const loader = extension === '.ts' ? 'ts' : extension === '.tsx' ? 'tsx' : 'jsx';
166
+ return { contents, loader };
167
+ });
168
+ },
169
+ };
170
+ }
171
+
172
+ function serverLoaderVitePlugin(pagesRoot) {
173
+ const pagesDir = `${path.resolve(pagesRoot)}${path.sep}`;
174
+ return {
175
+ name: 'arcway-server-page-loader',
176
+ enforce: 'pre',
177
+ async transform(code, id) {
178
+ const filename = id.split('?')[0];
179
+ if (!filename.startsWith(pagesDir) || !/\.[cm]?[jt]sx?$/.test(filename)) return null;
180
+ const transformed = await stripServerLoaderExport(code, filename);
181
+ return transformed === code ? null : { code: transformed, map: null };
182
+ },
183
+ };
184
+ }
185
+
186
+ export {
187
+ hasServerLoaderExport,
188
+ serverLoaderClientPlugin,
189
+ serverLoaderVitePlugin,
190
+ stripServerLoaderExport,
191
+ };
@@ -19,10 +19,7 @@ function syncReactInternals(projectReactModule) {
19
19
  const projectInternals = projectReactModule[REACT_INTERNALS_KEY];
20
20
  const fwInternals = fwReact[REACT_INTERNALS_KEY];
21
21
  if (projectInternals && fwInternals && projectInternals !== fwInternals) {
22
- const keys = new Set([
23
- ...Object.keys(projectInternals),
24
- ...Object.keys(fwInternals),
25
- ]);
22
+ const keys = new Set([...Object.keys(projectInternals), ...Object.keys(fwInternals)]);
26
23
  for (const key of keys) {
27
24
  Object.defineProperty(fwInternals, key, {
28
25
  get() {
@@ -51,11 +48,24 @@ async function loadComponent(bundlePath, cacheKey, componentCache, cacheVersion)
51
48
  componentCache.set(cacheKey, Component);
52
49
  return Component;
53
50
  } catch (error) {
54
- console.error(`[arcway] failed to load component ${cacheKey} from ${bundlePath}:`, error && error.stack ? error.stack : error);
51
+ console.error(
52
+ `[arcway] failed to load component ${cacheKey} from ${bundlePath}:`,
53
+ error && error.stack ? error.stack : error,
54
+ );
55
55
  // Component failed to load (missing file, syntax error) — caller handles null
56
56
  return null;
57
57
  }
58
58
  }
59
+ async function loadPageModule(bundlePath, cacheKey, componentCache, cacheVersion) {
60
+ if (componentCache.has(cacheKey)) {
61
+ return componentCache.get(cacheKey);
62
+ }
63
+ const fileUrl = pathToFileURL(bundlePath).href;
64
+ const specifier = cacheVersion > 0 ? `${fileUrl}?v=${cacheVersion}` : fileUrl;
65
+ const mod = await import(specifier);
66
+ componentCache.set(cacheKey, mod);
67
+ return mod;
68
+ }
59
69
  const ROUTER_CTX_KEY = '__router_context__';
60
70
  const PROVIDER_CTX_KEY = '__provider_context__';
61
71
  function wrapWithProviders(createElement, element, pathname, params) {
@@ -152,15 +162,23 @@ async function renderPage(
152
162
  react,
153
163
  devMode,
154
164
  viteDev = false,
165
+ onRenderFailure = null,
166
+ pageProps = params,
167
+ response = {},
155
168
  ) {
156
169
  const { createElement, renderToPipeableStream } = react;
157
170
  const bundlePath = path.join(outDir, route.serverBundle);
158
- const Component = await loadComponent(
159
- bundlePath,
160
- `page:${route.pattern}`,
161
- componentCache,
162
- cacheVersion,
163
- );
171
+ const legacyCachedComponent = componentCache.get(`page:${route.pattern}`);
172
+ const Component = legacyCachedComponent
173
+ ? legacyCachedComponent
174
+ : (
175
+ await loadPageModule(
176
+ bundlePath,
177
+ `page-module:${route.pattern}`,
178
+ componentCache,
179
+ cacheVersion,
180
+ )
181
+ ).default;
164
182
  if (!Component || typeof Component !== 'function') {
165
183
  res.writeHead(500, getHtmlHeaders());
166
184
  res.end('<h1>500 - Page component is not a valid React component</h1>');
@@ -187,21 +205,32 @@ async function renderPage(
187
205
  const envScriptTag = buildEnvScriptTag(collectPublicEnv());
188
206
  const hmrTag = devMode && !viteDev ? buildHmrScript() : '';
189
207
  const liveReloadTag = devMode && !viteDev && !hmrTag ? buildLiveReloadScript() : '';
190
- const propsJson = JSON.stringify(params).replace(/</g, '\\u003c');
208
+ const propsJson = JSON.stringify(pageProps).replace(/</g, '\\u003c');
209
+ const routeParamsJson = JSON.stringify(params).replace(/</g, '\\u003c');
191
210
  const headData = { meta: [], links: [] };
192
211
  setSSRHeadData(headData);
193
- let element = createElement(Component, params);
212
+ let element = createElement(Component, pageProps);
194
213
  for (let i = layoutComponents.length - 1; i >= 0; i--) {
195
- element = createElement(layoutComponents[i], null, element);
214
+ element = createElement(layoutComponents[i], pageProps, element);
196
215
  }
197
216
  element = wrapWithProviders(createElement, element, pathname, params);
198
217
  const { PassThrough } = await import('node:stream');
199
218
  let pass = null;
200
219
  let closed = false;
220
+ let renderFailureHandled = false;
221
+ let resolveRender;
222
+ const renderComplete = new Promise((resolve) => {
223
+ resolveRender = resolve;
224
+ });
201
225
  const { pipe, abort } = renderToPipeableStream(element, {
202
226
  onAllReady() {
203
- if (res.headersSent || closed) return;
204
- res.writeHead(200, getHtmlHeaders());
227
+ // React may call onAllReady after onShellError but before the recovery
228
+ // promise starts. Never let the failed stream win that race and commit 200.
229
+ if (res.headersSent || closed || renderFailureHandled) return;
230
+ res.writeHead(response.status ?? 200, {
231
+ ...getHtmlHeaders(),
232
+ ...response.headers,
233
+ });
205
234
  const headHtml = renderHeadToString(headData);
206
235
  const shell = buildHtmlShell({
207
236
  headHtml,
@@ -209,6 +238,7 @@ async function renderPage(
209
238
  cssLinkTag,
210
239
  bodysuffix: `${envScriptTag}
211
240
  <script id="__app_props" type="application/json">${propsJson}</script>
241
+ <script id="__app_route_params" type="application/json">${routeParamsJson}</script>
212
242
  <script id="__app_manifest" type="application/json">${clientManifestJson}</script>
213
243
  ${hmrTag}
214
244
  ${scriptTags}
@@ -225,19 +255,38 @@ ${liveReloadTag}`,
225
255
  res.end();
226
256
  }
227
257
  clearSSRHeadData();
258
+ resolveRender();
228
259
  });
229
260
  pipe(pass);
230
261
  },
231
262
  onError(err) {
232
- clearSSRHeadData();
233
263
  // A client disconnect aborts the render and surfaces here; that's a normal
234
264
  // peer disconnect, not a server fault — don't log it or write to a dead res.
235
265
  if (closed) return;
236
266
  console.error('SSR render error:', err);
237
- if (!res.headersSent) {
267
+ },
268
+ onShellError(err) {
269
+ clearSSRHeadData();
270
+ if (closed || res.headersSent || renderFailureHandled) return;
271
+ renderFailureHandled = true;
272
+
273
+ if (!onRenderFailure) {
238
274
  res.writeHead(500, getHtmlHeaders());
239
275
  res.end('<h1>500 - Render Error</h1>');
276
+ resolveRender();
277
+ return;
240
278
  }
279
+
280
+ Promise.resolve()
281
+ .then(() => onRenderFailure(err))
282
+ .catch((renderError) => {
283
+ console.error('SSR error-page render error:', renderError);
284
+ if (!closed && !res.headersSent) {
285
+ res.writeHead(500, getHtmlHeaders());
286
+ res.end('<h1>500 - Internal Server Error</h1>');
287
+ }
288
+ })
289
+ .finally(resolveRender);
241
290
  },
242
291
  });
243
292
  // Client gone (mid-stream, or before the shell was ready): stop React from
@@ -247,7 +296,9 @@ ${liveReloadTag}`,
247
296
  closed = true;
248
297
  abort();
249
298
  if (pass) pass.destroy();
299
+ resolveRender();
250
300
  });
301
+ await renderComplete;
251
302
  }
252
303
  async function renderErrorPage(
253
304
  bundlePath,
@@ -289,6 +340,10 @@ async function renderErrorPage(
289
340
  const { PassThrough } = await import('node:stream');
290
341
  let pass = null;
291
342
  let closed = false;
343
+ let resolveRender;
344
+ const renderComplete = new Promise((resolve) => {
345
+ resolveRender = resolve;
346
+ });
292
347
  const { pipe, abort } = renderToPipeableStream(element, {
293
348
  onAllReady() {
294
349
  if (res.headersSent || closed) return;
@@ -306,6 +361,7 @@ async function renderErrorPage(
306
361
  res.end();
307
362
  }
308
363
  clearSSRHeadData();
364
+ resolveRender();
309
365
  });
310
366
  pipe(pass);
311
367
  },
@@ -319,6 +375,7 @@ async function renderErrorPage(
319
375
  `<h1>${statusCode} - ${statusCode === 404 ? 'Not Found' : 'Internal Server Error'}</h1>`,
320
376
  );
321
377
  }
378
+ resolveRender();
322
379
  },
323
380
  });
324
381
  res.on('close', () => {
@@ -326,7 +383,9 @@ async function renderErrorPage(
326
383
  closed = true;
327
384
  abort();
328
385
  if (pass) pass.destroy();
386
+ resolveRender();
329
387
  });
388
+ await renderComplete;
330
389
  }
331
390
  export {
332
391
  buildCssLinkTag,
@@ -335,6 +394,7 @@ export {
335
394
  buildRouteCssLinkTags,
336
395
  buildScriptTags,
337
396
  loadComponent,
397
+ loadPageModule,
338
398
  renderErrorPage,
339
399
  renderPage,
340
400
  syncReactInternals,
@@ -6,6 +6,7 @@ import tailwindcss from '@tailwindcss/vite';
6
6
  import { createServer as createViteServer } from 'vite';
7
7
  import { patternToFileName } from './build-server.js';
8
8
  import { closeServer, createHttpServer, listen } from '../server.js';
9
+ import { serverLoaderVitePlugin } from './server-loader-transform.js';
9
10
 
10
11
  function toPosixPath(value) {
11
12
  return value.replace(/\\/g, '/');
@@ -72,9 +73,11 @@ function buildViteHydrationEntry({
72
73
  const container = document.getElementById('__app');
73
74
  const propsEl = document.getElementById('__app_props');
74
75
  const props = propsEl ? JSON.parse(propsEl.textContent || '{}') : {};
76
+ const routeParamsEl = document.getElementById('__app_route_params');
77
+ const routeParams = routeParamsEl ? JSON.parse(routeParamsEl.textContent || '{}') : props;
75
78
  const ROOT_KEY = '__arcway_root__';
76
79
  const rootOwner = window;
77
- const element = <ApiProvider><Router initialPath={window.location.pathname} initialParams={props} initialPattern={${JSON.stringify(pattern)}} initialComponent={Component} initialLayouts={${layoutsArray}} initialLoadings={${loadingsArray}} /></ApiProvider>;
80
+ const element = <ApiProvider><Router initialPath={window.location.pathname} initialParams={routeParams} initialPageProps={props} initialPattern={${JSON.stringify(pattern)}} initialComponent={Component} initialLayouts={${layoutsArray}} initialLoadings={${loadingsArray}} /></ApiProvider>;
78
81
 
79
82
  if (!container) {
80
83
  throw new Error('Arcway Vite hydrate entry could not find #__app');
@@ -153,6 +156,7 @@ function buildViteClientManifestJson(manifest, rootDir) {
153
156
  const route = {
154
157
  pattern: entry.pattern,
155
158
  paramNames: entry.paramNames,
159
+ ...(entry.hasLoader ? { hasLoader: true } : {}),
156
160
  clientBundle: toViteModuleUrl(rootDir, entry.srcPath),
157
161
  layoutBundles: entry.layoutDirs
158
162
  .map((dirPath) => manifest.layouts.get(dirPath)?.srcPath)
@@ -187,14 +191,26 @@ function resolveAppAliases(rootDir) {
187
191
  try {
188
192
  const reactRoot = path.dirname(appRequire.resolve('react/package.json'));
189
193
  aliases.push({ find: /^react$/, replacement: toPosixPath(path.join(reactRoot, 'index.js')) });
190
- aliases.push({ find: /^react\/jsx-runtime$/, replacement: toPosixPath(path.join(reactRoot, 'jsx-runtime.js')) });
191
- aliases.push({ find: /^react\/jsx-dev-runtime$/, replacement: toPosixPath(path.join(reactRoot, 'jsx-dev-runtime.js')) });
194
+ aliases.push({
195
+ find: /^react\/jsx-runtime$/,
196
+ replacement: toPosixPath(path.join(reactRoot, 'jsx-runtime.js')),
197
+ });
198
+ aliases.push({
199
+ find: /^react\/jsx-dev-runtime$/,
200
+ replacement: toPosixPath(path.join(reactRoot, 'jsx-dev-runtime.js')),
201
+ });
192
202
  } catch {}
193
203
 
194
204
  try {
195
205
  const reactDomRoot = path.dirname(appRequire.resolve('react-dom/package.json'));
196
- aliases.push({ find: /^react-dom$/, replacement: toPosixPath(path.join(reactDomRoot, 'index.js')) });
197
- aliases.push({ find: /^react-dom\/client$/, replacement: toPosixPath(path.join(reactDomRoot, 'client.js')) });
206
+ aliases.push({
207
+ find: /^react-dom$/,
208
+ replacement: toPosixPath(path.join(reactDomRoot, 'index.js')),
209
+ });
210
+ aliases.push({
211
+ find: /^react-dom\/client$/,
212
+ replacement: toPosixPath(path.join(reactDomRoot, 'client.js')),
213
+ });
198
214
  } catch {}
199
215
 
200
216
  try {
@@ -251,7 +267,11 @@ async function createViteDevRouter({ rootDir, log, config }) {
251
267
  '@radix-ui/react-use-is-hydrated',
252
268
  ],
253
269
  },
254
- plugins: [reactPlugin(), tailwindcss()],
270
+ plugins: [
271
+ serverLoaderVitePlugin(config?.pages?.dir ?? path.resolve(rootDir, 'pages')),
272
+ reactPlugin(),
273
+ tailwindcss(),
274
+ ],
255
275
  clearScreen: false,
256
276
  });
257
277
  } catch (error) {