arcway 0.4.14 → 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.14",
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++;
@@ -144,7 +150,8 @@ function createPagesHandler(options) {
144
150
  async function handle(req, res) {
145
151
  const method = req.method ?? 'GET';
146
152
  const url = req.url ?? '/';
147
- const pathname = url.split('?')[0];
153
+ const requestUrl = new URL(url, 'http://arcway.internal');
154
+ const pathname = requestUrl.pathname;
148
155
  if (method !== 'GET') return false;
149
156
  // Internal `/_arcway/*` endpoints (dev only) resolve before the route
150
157
  // matcher so they can never be shadowed by a user-defined page.
@@ -173,20 +180,32 @@ function createPagesHandler(options) {
173
180
  // last request before we resolve the route.
174
181
  if (!lazyContext) reload();
175
182
  refreshFromLazy();
176
- 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);
177
206
  if (!matched) {
178
- if (manifest.notFoundBundle) {
179
- await renderErrorPage(
180
- manifest.notFoundBundle,
181
- 404,
182
- { pathname },
183
- outDir,
184
- res,
185
- componentCache,
186
- manifest,
187
- cacheVersion,
188
- projectReact,
189
- );
207
+ if (rewrite || manifest.notFoundBundle) {
208
+ await renderNotFound(pathname);
190
209
  return true;
191
210
  }
192
211
  return false;
@@ -210,20 +229,19 @@ function createPagesHandler(options) {
210
229
  return true;
211
230
  }
212
231
  refreshFromLazy();
213
- const rematched = matchPageRoute(routes, pathname);
232
+ const rematched = matchPageRoute(routes, internalPathname);
214
233
  if (rematched) matched.route = rematched.route;
215
234
  }
235
+ requestContext.page.internalPathname = internalPathname;
236
+ requestContext.page.params = matched.params;
237
+ requestContext.page.query = matched.params;
216
238
  if (matched.route.middlewareServerBundles.length > 0) {
217
239
  const middlewareResult = await runPageMiddleware(
218
240
  matched.route,
219
- matched.params,
220
- pathname,
221
- req,
222
241
  outDir,
223
242
  componentCache,
224
243
  cacheVersion,
225
- sessionConfig,
226
- appContext,
244
+ requestContext,
227
245
  );
228
246
  if (middlewareResult) {
229
247
  if (middlewareResult.redirect) {
@@ -246,6 +264,37 @@ function createPagesHandler(options) {
246
264
  return true;
247
265
  }
248
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
+ };
249
298
  try {
250
299
  await renderPage(
251
300
  matched.route,
@@ -261,11 +310,35 @@ function createPagesHandler(options) {
261
310
  devMode,
262
311
  viteDev,
263
312
  (err) => renderRuntimeError(err, res),
313
+ pageProps,
314
+ {
315
+ status: loaderResult?.status,
316
+ headers: loaderResult?.headers,
317
+ },
264
318
  );
265
319
  } catch (err) {
266
320
  await renderRuntimeError(err, res);
267
321
  }
268
322
  return true;
323
+
324
+ async function renderNotFound(originalPathname) {
325
+ if (manifest.notFoundBundle) {
326
+ await renderErrorPage(
327
+ manifest.notFoundBundle,
328
+ 404,
329
+ { pathname: originalPathname },
330
+ outDir,
331
+ res,
332
+ componentCache,
333
+ manifest,
334
+ cacheVersion,
335
+ projectReact,
336
+ );
337
+ } else {
338
+ res.writeHead(404, getHtmlHeaders());
339
+ res.end('<h1>404 - Not Found</h1>');
340
+ }
341
+ }
269
342
  }
270
343
  return {
271
344
  handle,
@@ -329,6 +402,7 @@ function compileRoutes(manifest, { rootDir, outDir, viteDev } = {}) {
329
402
  middlewareServerBundles: entry.middlewareServerBundles ?? [],
330
403
  sharedChunks: entry.sharedChunks ?? [],
331
404
  sharedCssChunks: entry.sharedCssChunks ?? [],
405
+ hasLoader: entry.hasLoader === true,
332
406
  };
333
407
  if (viteDev) {
334
408
  route = buildViteRoute(route, { rootDir, outDir });
@@ -343,28 +417,51 @@ function matchPageRoute(routes, pathname) {
343
417
  if (!result) return null;
344
418
  return { route: result.match, params: result.params };
345
419
  }
346
- async function runPageMiddleware(
347
- route,
348
- params,
349
- 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({
350
441
  req,
351
- outDir,
352
- componentCache,
353
- cacheVersion,
442
+ pathname,
443
+ internalPathname,
444
+ params,
445
+ requestUrl,
354
446
  sessionConfig,
355
447
  appContext,
356
- ) {
448
+ }) {
357
449
  const headers = flattenHeaders(req.headers);
358
450
  const cookies = parseCookies(req);
359
451
  const session = await resolveSession(cookies, sessionConfig);
360
452
  const page = {
453
+ host: normalizeHost(headers.host),
361
454
  pathname,
455
+ internalPathname,
456
+ params,
362
457
  query: params,
458
+ search: requestUrl.search,
459
+ searchParams: requestUrl.searchParams,
363
460
  headers,
364
461
  cookies,
365
462
  session,
366
463
  };
367
- const ctx = appContext
464
+ return appContext
368
465
  ? {
369
466
  page,
370
467
  db: appContext.db,
@@ -377,34 +474,166 @@ async function runPageMiddleware(
377
474
  meta: appContext.meta,
378
475
  }
379
476
  : { page };
380
- for (const bundlePath of route.middlewareServerBundles) {
381
- const fullPath = path.join(outDir, bundlePath);
382
- const middlewareFn = await loadComponent(
383
- fullPath,
384
- `middleware:${bundlePath}`,
385
- componentCache,
386
- cacheVersion,
387
- );
388
- if (!middlewareFn || typeof middlewareFn !== 'function') {
389
- 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');
390
545
  }
391
- const result = await middlewareFn(ctx);
392
- if (result) {
393
- 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');
394
551
  }
552
+ validateLoaderHeaders(result.headers);
553
+ return result;
395
554
  }
396
- 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;
397
568
  }
398
- 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;
399
625
  if (viteDev) {
400
- return buildViteClientManifestJson(manifest, rootDir);
626
+ return buildViteClientManifestJson(clientVisibleManifest, rootDir);
401
627
  }
402
628
  const clientManifest = {
403
- cssBundle: manifest.cssBundle ? `/static/${manifest.cssBundle}` : null,
404
- routes: manifest.entries.map((entry) => {
629
+ cssBundle: clientVisibleManifest.cssBundle
630
+ ? `/static/${clientVisibleManifest.cssBundle}`
631
+ : null,
632
+ routes: clientVisibleManifest.entries.map((entry) => {
405
633
  const route = {
406
634
  pattern: entry.pattern,
407
635
  paramNames: entry.paramNames,
636
+ ...(entry.hasLoader ? { hasLoader: true } : {}),
408
637
  clientBundle: `/static/${entry.navBundle}`,
409
638
  layoutBundles: (entry.layoutClientBundles ?? []).map((b) => `/static/${b}`),
410
639
  loadingBundles: (entry.loadingClientBundles ?? []).map((b) => `/static/${b}`),
@@ -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
+ };
@@ -56,6 +56,16 @@ async function loadComponent(bundlePath, cacheKey, componentCache, cacheVersion)
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) {
@@ -153,15 +163,22 @@ async function renderPage(
153
163
  devMode,
154
164
  viteDev = false,
155
165
  onRenderFailure = null,
166
+ pageProps = params,
167
+ response = {},
156
168
  ) {
157
169
  const { createElement, renderToPipeableStream } = react;
158
170
  const bundlePath = path.join(outDir, route.serverBundle);
159
- const Component = await loadComponent(
160
- bundlePath,
161
- `page:${route.pattern}`,
162
- componentCache,
163
- cacheVersion,
164
- );
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;
165
182
  if (!Component || typeof Component !== 'function') {
166
183
  res.writeHead(500, getHtmlHeaders());
167
184
  res.end('<h1>500 - Page component is not a valid React component</h1>');
@@ -188,12 +205,13 @@ async function renderPage(
188
205
  const envScriptTag = buildEnvScriptTag(collectPublicEnv());
189
206
  const hmrTag = devMode && !viteDev ? buildHmrScript() : '';
190
207
  const liveReloadTag = devMode && !viteDev && !hmrTag ? buildLiveReloadScript() : '';
191
- 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');
192
210
  const headData = { meta: [], links: [] };
193
211
  setSSRHeadData(headData);
194
- let element = createElement(Component, params);
212
+ let element = createElement(Component, pageProps);
195
213
  for (let i = layoutComponents.length - 1; i >= 0; i--) {
196
- element = createElement(layoutComponents[i], null, element);
214
+ element = createElement(layoutComponents[i], pageProps, element);
197
215
  }
198
216
  element = wrapWithProviders(createElement, element, pathname, params);
199
217
  const { PassThrough } = await import('node:stream');
@@ -209,7 +227,10 @@ async function renderPage(
209
227
  // React may call onAllReady after onShellError but before the recovery
210
228
  // promise starts. Never let the failed stream win that race and commit 200.
211
229
  if (res.headersSent || closed || renderFailureHandled) return;
212
- res.writeHead(200, getHtmlHeaders());
230
+ res.writeHead(response.status ?? 200, {
231
+ ...getHtmlHeaders(),
232
+ ...response.headers,
233
+ });
213
234
  const headHtml = renderHeadToString(headData);
214
235
  const shell = buildHtmlShell({
215
236
  headHtml,
@@ -217,6 +238,7 @@ async function renderPage(
217
238
  cssLinkTag,
218
239
  bodysuffix: `${envScriptTag}
219
240
  <script id="__app_props" type="application/json">${propsJson}</script>
241
+ <script id="__app_route_params" type="application/json">${routeParamsJson}</script>
220
242
  <script id="__app_manifest" type="application/json">${clientManifestJson}</script>
221
243
  ${hmrTag}
222
244
  ${scriptTags}
@@ -372,6 +394,7 @@ export {
372
394
  buildRouteCssLinkTags,
373
395
  buildScriptTags,
374
396
  loadComponent,
397
+ loadPageModule,
375
398
  renderErrorPage,
376
399
  renderPage,
377
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) {