arcway 0.1.23 → 0.1.24

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.
@@ -36,6 +36,50 @@ function readClientManifest() {
36
36
  }
37
37
  }
38
38
 
39
+ function syncStylesForPath(manifest, pathname) {
40
+ if (typeof document === 'undefined' || !manifest) return;
41
+
42
+ const globalHref = manifest.cssBundle ?? null;
43
+ const matched = matchClientRoute(manifest, pathname);
44
+ const routeHrefs = matched?.route?.cssBundles ?? [];
45
+
46
+ const globalLinks = Array.from(
47
+ document.querySelectorAll('link[rel="stylesheet"][data-arcway-css="global"]'),
48
+ );
49
+ for (const link of globalLinks) {
50
+ if (!globalHref) continue;
51
+ const current = link.getAttribute('href')?.split('?')[0] ?? '';
52
+ if (current !== globalHref) link.setAttribute('href', globalHref);
53
+ }
54
+
55
+ const existingRouteLinks = Array.from(
56
+ document.querySelectorAll('link[rel="stylesheet"][data-arcway-css="route"]'),
57
+ );
58
+ const head = document.head || document.querySelector('head');
59
+ if (!head) return;
60
+
61
+ for (let i = 0; i < routeHrefs.length; i++) {
62
+ const href = routeHrefs[i];
63
+ const existing = existingRouteLinks[i];
64
+ if (existing) {
65
+ const current = existing.getAttribute('href')?.split('?')[0] ?? '';
66
+ if (current !== href) existing.setAttribute('href', href);
67
+ existing.setAttribute('data-arcway-css-index', String(i));
68
+ continue;
69
+ }
70
+ const link = document.createElement('link');
71
+ link.rel = 'stylesheet';
72
+ link.setAttribute('data-arcway-css', 'route');
73
+ link.setAttribute('data-arcway-css-index', String(i));
74
+ link.href = href;
75
+ head.appendChild(link);
76
+ }
77
+
78
+ for (let i = routeHrefs.length; i < existingRouteLinks.length; i++) {
79
+ existingRouteLinks[i].remove();
80
+ }
81
+ }
82
+
39
83
  function matchClientRoute(manifest, pathname) {
40
84
  for (const route of manifest.routes) {
41
85
  let compiled = patternCache.get(route.pattern);
@@ -109,4 +153,5 @@ export {
109
153
  matchClientRoute,
110
154
  prefetchRoute,
111
155
  readClientManifest,
156
+ syncStylesForPath,
112
157
  };
package/client/router.js CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  matchClientRoute,
16
16
  loadLoadingComponents,
17
17
  prefetchRoute,
18
+ syncStylesForPath,
18
19
  } from './page-loader.js';
19
20
  import { parseQuery } from './query.js';
20
21
 
@@ -72,6 +73,7 @@ function useSearchParams() {
72
73
  function Router({
73
74
  initialPath,
74
75
  initialParams,
76
+ initialPattern,
75
77
  initialComponent,
76
78
  initialLayouts,
77
79
  initialLoadings,
@@ -81,6 +83,7 @@ function Router({
81
83
  () => initialPath ?? (typeof window !== 'undefined' ? window.location.pathname : '/'),
82
84
  );
83
85
  const [pageState, setPageState] = useState({
86
+ pattern: initialPattern ?? null,
84
87
  component: initialComponent ?? null,
85
88
  layouts: initialLayouts ?? [],
86
89
  loadings: initialLoadings ?? [],
@@ -96,12 +99,24 @@ function Router({
96
99
 
97
100
  useEffect(() => {
98
101
  manifestRef.current = readClientManifest();
102
+ if (manifestRef.current) {
103
+ syncStylesForPath(manifestRef.current, pathname);
104
+ }
99
105
  const onManifestUpdate = () => {
100
106
  manifestRef.current = readClientManifest();
107
+ if (manifestRef.current) {
108
+ syncStylesForPath(manifestRef.current, window.location.pathname);
109
+ }
101
110
  };
102
111
  window.addEventListener('manifest-update', onManifestUpdate);
103
112
  return () => window.removeEventListener('manifest-update', onManifestUpdate);
104
- }, []);
113
+ }, [pathname]);
114
+
115
+ useEffect(() => {
116
+ if (manifestRef.current) {
117
+ syncStylesForPath(manifestRef.current, pathname);
118
+ }
119
+ }, [pathname]);
105
120
 
106
121
  // `bumpQuery` controls whether `queryVersion` is incremented atomically
107
122
  // with the page-state updates. It lives inside the same `startTransition`
@@ -116,6 +131,7 @@ function Router({
116
131
  startTransition(() => {
117
132
  setPathname(newPath);
118
133
  setPageState({
134
+ pattern: loaded.pattern ?? null,
119
135
  component: loaded.component,
120
136
  layouts: loaded.layouts,
121
137
  loadings: loaded.loadings,
@@ -177,6 +193,7 @@ function Router({
177
193
  setPathname(pathOnly);
178
194
  setPageState((prev) => ({
179
195
  ...prev,
196
+ pattern: matched.route.pattern,
180
197
  loadings: targetLoadings,
181
198
  params: matched.params,
182
199
  }));
@@ -217,7 +234,7 @@ function Router({
217
234
 
218
235
  if (!manifest) {
219
236
  setPathname(newPath);
220
- setPageState((prev) => ({ ...prev, params: {} }));
237
+ setPageState((prev) => ({ ...prev, pattern: null, params: {} }));
221
238
  setQueryVersion((v) => v + 1);
222
239
  return;
223
240
  }
@@ -238,6 +255,12 @@ function Router({
238
255
  return () => window.removeEventListener('popstate', onPopState);
239
256
  }, [pathname, applyLoaded]);
240
257
 
258
+ useEffect(() => {
259
+ if (typeof window !== 'undefined') {
260
+ window.__arcway_current_pattern__ = pageState.pattern ?? null;
261
+ }
262
+ }, [pageState.pattern]);
263
+
241
264
  const { component: PageComponent, layouts, loadings, params } = pageState;
242
265
 
243
266
  let content;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcway",
3
- "version": "0.1.23",
3
+ "version": "0.1.24",
4
4
  "description": "A convention-based framework for building modular monoliths with strict domain boundaries.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -47,8 +47,10 @@
47
47
  "dependencies": {
48
48
  "@aws-sdk/client-s3": "^3.987.0",
49
49
  "@babel/core": "^7.29.0",
50
+ "@vitejs/plugin-react": "^5.1.0",
50
51
  "@base-ui/react": "^1.2.0",
51
52
  "@modelcontextprotocol/sdk": "^1.26.0",
53
+ "@tailwindcss/vite": "^4.1.18",
52
54
  "@tailwindcss/postcss": "^4.1.18",
53
55
  "arktype": "^2.1.29",
54
56
  "bcryptjs": "^3.0.3",
@@ -99,6 +101,7 @@
99
101
  "tailwind-merge": "^3.4.1",
100
102
  "tailwindcss": "^4.1.18",
101
103
  "vaul": "^1.1.2",
104
+ "vite": "^7.1.12",
102
105
  "ws": "^8.19.0",
103
106
  "zod": "^3.24.0"
104
107
  },
@@ -14,6 +14,8 @@ import { StaticRouter } from '../static/index.js';
14
14
  import { WsRouter } from '../ws/ws-router.js';
15
15
  import { createWsBackplane } from '../ws/backplane.js';
16
16
  import { runHook } from './hooks.js';
17
+ import { createViteDevRouter } from '../pages/vite-dev.js';
18
+ import { resolvePagesOutDir } from '../pages/out-dir.js';
17
19
  import path from 'node:path';
18
20
 
19
21
  async function boot(options) {
@@ -85,6 +87,11 @@ async function boot(options) {
85
87
 
86
88
  const pagesRouter = new PagesRouter(config, { rootDir, log, mode, fileWatcher, appContext });
87
89
  await pagesRouter.init();
90
+ const pagesOutDir = resolvePagesOutDir(config, rootDir, mode);
91
+ const viteRouter =
92
+ mode === 'development' && config.pages?.vite?.enabled === true
93
+ ? await createViteDevRouter({ rootDir, log, config })
94
+ : null;
88
95
 
89
96
  const healthDeps = {
90
97
  db,
@@ -93,7 +100,7 @@ async function boot(options) {
93
100
  const systemRouter = new SystemRouter({ healthDeps });
94
101
 
95
102
  const staticRouter = new StaticRouter({
96
- outDir: config.pages?.outDir ?? path.resolve(rootDir, '.build/pages'),
103
+ outDir: pagesOutDir,
97
104
  publicDir: path.join(rootDir, 'public'),
98
105
  });
99
106
 
@@ -113,6 +120,7 @@ async function boot(options) {
113
120
  webServer.use(systemRouter, '/_system');
114
121
  webServer.use(apiRouter, apiRouter.prefix);
115
122
  webServer.use(wsRouter, config.websocket.path);
123
+ if (viteRouter) webServer.use(viteRouter);
116
124
  webServer.use(staticRouter);
117
125
  webServer.use(pagesRouter);
118
126
 
@@ -130,6 +138,7 @@ async function boot(options) {
130
138
  if (workerPool) await workerPool.shutdown();
131
139
  await pagesRouter.close();
132
140
  await apiRouter.close();
141
+ if (viteRouter) await viteRouter.close();
133
142
  await webServer.close();
134
143
  if (fileWatcher) await fileWatcher.close();
135
144
  await destroyInfrastructure(infrastructure);
package/server/build.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { makeConfig } from './config/loader.js';
2
2
  import { buildPages } from './pages/build.js';
3
+ import { resolvePagesOutDir } from './pages/out-dir.js';
3
4
 
4
5
  async function build(options) {
5
6
  const rootDir = options?.rootDir ?? process.cwd();
@@ -11,6 +12,7 @@ async function build(options) {
11
12
  if (pagesEnabled) {
12
13
  pages = await buildPages({
13
14
  rootDir,
15
+ outDir: resolvePagesOutDir(config, rootDir, 'production'),
14
16
  serverTarget: buildTarget,
15
17
  minify: true,
16
18
  fonts: config?.pages?.fonts,
@@ -4,16 +4,31 @@ const DEFAULTS = {
4
4
  enabled: true,
5
5
  dir: 'pages',
6
6
  outDir: '.build/pages',
7
+ devOutDir: '.build-dev/pages',
8
+ vite: {
9
+ enabled: false,
10
+ },
7
11
  };
8
12
 
9
13
  function resolve(config, { rootDir } = {}) {
10
- const pages = { ...DEFAULTS, ...config.pages };
14
+ const rawPages = config.pages ?? {};
15
+ const pages = {
16
+ ...DEFAULTS,
17
+ ...rawPages,
18
+ vite: {
19
+ ...DEFAULTS.vite,
20
+ ...(rawPages.vite ?? {}),
21
+ },
22
+ };
11
23
  if (pages.dir && !path.isAbsolute(pages.dir)) {
12
24
  pages.dir = path.resolve(rootDir, pages.dir);
13
25
  }
14
26
  if (pages.outDir && !path.isAbsolute(pages.outDir)) {
15
27
  pages.outDir = path.resolve(rootDir, pages.outDir);
16
28
  }
29
+ if (pages.devOutDir && !path.isAbsolute(pages.devOutDir)) {
30
+ pages.devOutDir = path.resolve(rootDir, pages.devOutDir);
31
+ }
17
32
  return { ...config, pages };
18
33
  }
19
34
 
@@ -263,7 +263,7 @@ async function restoreMultiFileCache({ cacheDir, outputs, destDir }) {
263
263
  const src = path.join(cacheDir, rel);
264
264
  const dst = path.join(destDir, rel);
265
265
  await fs.mkdir(path.dirname(dst), { recursive: true });
266
- await fs.cp(src, dst);
266
+ await cpAtomic(src, dst);
267
267
  }),
268
268
  );
269
269
  }
@@ -35,7 +35,7 @@ function planHydrationEntries(pages, layouts, loadings, tempDir) {
35
35
  const outName = patternToFileName(page.pattern);
36
36
  const layoutChain = resolveLayoutChain(page.pattern, layouts);
37
37
  const loadingChain = resolveLoadingChain(page.pattern, loadings);
38
- const content = generateHydrationEntry(page.filePath, layoutChain, loadingChain);
38
+ const content = generateHydrationEntry(page.filePath, layoutChain, loadingChain, page.pattern);
39
39
  const entryPath = path.join(tempDir, `${outName}.tsx`);
40
40
  files.push({ entryPath, content });
41
41
  entryPoints[outName] = entryPath;
@@ -284,6 +284,7 @@ async function collectStaleOutputs(metafile, clientDir) {
284
284
  for (const entry of entries) {
285
285
  if (!entry.isFile()) continue;
286
286
  const abs = path.join(dir, entry.name);
287
+ if (shouldPreserveClientAsset(abs, clientDir)) continue;
287
288
  if (!currentOutputs.has(abs)) stale.add(abs);
288
289
  }
289
290
  }
@@ -295,6 +296,14 @@ async function collectStaleOutputs(metafile, clientDir) {
295
296
  return stale;
296
297
  }
297
298
 
299
+ function shouldPreserveClientAsset(absPath, clientDir) {
300
+ const rel = path.relative(clientDir, absPath).replace(/\\/g, '/');
301
+ if (!rel || rel.startsWith('..')) return false;
302
+ if (rel.startsWith('hmr/')) return true;
303
+ if (/^styles-[a-f0-9]+\.css$/i.test(rel)) return true;
304
+ return false;
305
+ }
306
+
298
307
  async function tryRestoreClientFromCache({ rootDir, clientDir, coarseKey }) {
299
308
  const lookup = await lookupMultiFileCache({ rootDir, kind: 'client', coarseKey });
300
309
  if (!lookup.hit) return { hit: false, bucket: lookup.bucket };
@@ -459,7 +468,7 @@ function extractMetadata(result, plan, outDir) {
459
468
  return { bundles, navBundles, layoutNavBundles, loadingNavBundles, entryChunks, entryCssChunks };
460
469
  }
461
470
 
462
- function generateHydrationEntry(componentPath, layouts = [], loadings = []) {
471
+ function generateHydrationEntry(componentPath, layouts = [], loadings = [], pattern = '/') {
463
472
  const importPath = componentPath.replace(/\\/g, '/');
464
473
  const imports = [
465
474
  `import { hydrateRoot } from 'react-dom/client';`,
@@ -485,8 +494,19 @@ function generateHydrationEntry(componentPath, layouts = [], loadings = []) {
485
494
  const container = document.getElementById('__app');
486
495
  const propsEl = document.getElementById('__app_props');
487
496
  const props = propsEl ? JSON.parse(propsEl.textContent || '{}') : {};
497
+ const ROOT_KEY = '__arcway_root__';
498
+ 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>;
488
500
 
489
- hydrateRoot(container!, <ApiProvider><Router initialPath={window.location.pathname} initialParams={props} initialComponent={Component} initialLayouts={${layoutsArray}} initialLoadings={${loadingsArray}} /></ApiProvider>);
501
+ if (!container) {
502
+ throw new Error('Arcway hydrate entry could not find #__app');
503
+ }
504
+
505
+ if (!rootOwner[ROOT_KEY]) {
506
+ rootOwner[ROOT_KEY] = hydrateRoot(container, element);
507
+ } else {
508
+ rootOwner[ROOT_KEY].render(element);
509
+ }
490
510
  `;
491
511
  }
492
512
  export { buildClientBundles, createClientBuildContext, generateHydrationEntry };
@@ -15,6 +15,7 @@ import {
15
15
  } from './ssr.js';
16
16
  import { MIME_TYPES } from './static.js';
17
17
  import { createArcwayDevEndpoint } from './arcway-endpoint.js';
18
+ import { buildViteClientManifestJson, buildViteRoute } from './vite-dev.js';
18
19
  function createPagesHandler(options) {
19
20
  const rootDir = options.rootDir;
20
21
  const outDir = path.resolve(rootDir, options.outDir ?? '.build/pages');
@@ -23,6 +24,7 @@ function createPagesHandler(options) {
23
24
  const appContext = options.appContext ?? null;
24
25
  const mode = options.mode ?? 'production';
25
26
  const devMode = mode === 'development';
27
+ const viteDev = options.viteDev === true;
26
28
  const lazyContext = options.lazyContext ?? null;
27
29
  // Dev with a lazy context drives the handler off an in-memory manifest; no
28
30
  // disk `pages-manifest.json` is produced. Prod still reads from disk.
@@ -39,8 +41,12 @@ function createPagesHandler(options) {
39
41
  let manifest = lazyContext
40
42
  ? lazyContext.manifest
41
43
  : JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
42
- let routes = compileRoutes(manifest);
43
- let clientManifestJson = buildClientManifestJson(manifest);
44
+ let routes = compileRoutes(manifest, { rootDir, outDir, viteDev });
45
+ let clientManifestJson = buildClientManifestJson(manifest, {
46
+ rootDir,
47
+ outDir,
48
+ viteDev,
49
+ });
44
50
  let lastSeenVersion = lazyContext ? manifest.version : 0;
45
51
  const componentCache = new Map();
46
52
  let cacheVersion = 0;
@@ -60,8 +66,12 @@ function createPagesHandler(options) {
60
66
  if (!lazyContext) return;
61
67
  if (manifest.version === lastSeenVersion) return;
62
68
  lastSeenVersion = manifest.version;
63
- routes = compileRoutes(manifest);
64
- clientManifestJson = buildClientManifestJson(manifest);
69
+ routes = compileRoutes(manifest, { rootDir, outDir, viteDev });
70
+ clientManifestJson = buildClientManifestJson(manifest, {
71
+ rootDir,
72
+ outDir,
73
+ viteDev,
74
+ });
65
75
  componentCache.clear();
66
76
  cacheVersion++;
67
77
  }
@@ -79,8 +89,12 @@ function createPagesHandler(options) {
79
89
  }
80
90
  if (!fs.existsSync(manifestPath)) return;
81
91
  manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
82
- routes = compileRoutes(manifest);
83
- clientManifestJson = buildClientManifestJson(manifest);
92
+ routes = compileRoutes(manifest, { rootDir, outDir, viteDev });
93
+ clientManifestJson = buildClientManifestJson(manifest, {
94
+ rootDir,
95
+ outDir,
96
+ viteDev,
97
+ });
84
98
  componentCache.clear();
85
99
  cacheVersion++;
86
100
  }
@@ -104,7 +118,11 @@ function createPagesHandler(options) {
104
118
  });
105
119
  res.write('data: connected\n\n');
106
120
  const onUpdate = (event) => {
107
- res.write(`data: ${JSON.stringify(event)}
121
+ let payload = event;
122
+ if (event?.type === 'hmr-update' || event?.type === 'css-update') {
123
+ payload = { ...event, manifest: clientManifestJson };
124
+ }
125
+ res.write(`data: ${JSON.stringify(payload)}
108
126
 
109
127
  `);
110
128
  };
@@ -201,6 +219,7 @@ function createPagesHandler(options) {
201
219
  cacheVersion,
202
220
  projectReact,
203
221
  devMode,
222
+ viteDev,
204
223
  );
205
224
  } catch (err) {
206
225
  if (manifest.errorBundle && !res.headersSent) {
@@ -275,10 +294,10 @@ async function renderDevBuildError(
275
294
  res.end(`<h1>500 - Build Error</h1><pre>${escaped}</pre>`);
276
295
  }
277
296
  }
278
- function compileRoutes(manifest) {
297
+ function compileRoutes(manifest, { rootDir, outDir, viteDev } = {}) {
279
298
  const routes = manifest.entries.map((entry) => {
280
299
  const { regex, paramNames, catchAllParam } = compilePattern(entry.pattern);
281
- return {
300
+ let route = {
282
301
  pattern: entry.pattern,
283
302
  regex,
284
303
  paramNames,
@@ -290,6 +309,10 @@ function compileRoutes(manifest) {
290
309
  sharedChunks: entry.sharedChunks ?? [],
291
310
  sharedCssChunks: entry.sharedCssChunks ?? [],
292
311
  };
312
+ if (viteDev) {
313
+ route = buildViteRoute(route, { rootDir, outDir });
314
+ }
315
+ return route;
293
316
  });
294
317
  sortBySpecificity(routes);
295
318
  return routes;
@@ -350,8 +373,12 @@ async function runPageMiddleware(
350
373
  }
351
374
  return null;
352
375
  }
353
- function buildClientManifestJson(manifest) {
376
+ function buildClientManifestJson(manifest, { rootDir, outDir, viteDev } = {}) {
377
+ if (viteDev) {
378
+ return buildViteClientManifestJson(manifest, rootDir);
379
+ }
354
380
  const clientManifest = {
381
+ cssBundle: manifest.cssBundle ? `/static/${manifest.cssBundle}` : null,
355
382
  routes: manifest.entries.map((entry) => {
356
383
  const route = {
357
384
  pattern: entry.pattern,
@@ -359,6 +386,7 @@ function buildClientManifestJson(manifest) {
359
386
  clientBundle: `/static/${entry.navBundle}`,
360
387
  layoutBundles: (entry.layoutClientBundles ?? []).map((b) => `/static/${b}`),
361
388
  loadingBundles: (entry.loadingClientBundles ?? []).map((b) => `/static/${b}`),
389
+ cssBundles: (entry.sharedCssChunks ?? []).map((b) => `/static/${b}`),
362
390
  };
363
391
  if (entry.catchAllParam) {
364
392
  route.catchAllParam = entry.catchAllParam;
@@ -79,38 +79,65 @@ window.$RefreshReg$ = function() {};
79
79
  window.$RefreshSig$ = function() { return function(type) { return type; }; };
80
80
  window.__REACT_REFRESH__ = RefreshRuntime;
81
81
 
82
- var es = new EventSource('/__livereload');
83
- es.onmessage = function(e) {
84
- if (e.data === 'connected') return;
85
- var msg;
86
- try { msg = JSON.parse(e.data); } catch { window.location.reload(); return; }
87
- if (msg.type === 'reload') {
88
- window.location.reload();
89
- } else if (msg.type === 'hmr-update') {
90
- var imports = msg.modules.map(function(m) {
91
- return import('/static/' + m + '?t=' + msg.timestamp);
92
- });
93
- Promise.all(imports).then(function() {
94
- RefreshRuntime.performReactRefresh();
95
- if (msg.manifest) {
96
- var el = document.getElementById('__app_manifest');
97
- if (el) el.textContent = msg.manifest;
98
- window.dispatchEvent(new CustomEvent('manifest-update'));
99
- }
100
- }).catch(function(err) {
101
- console.warn('[HMR] Fast Refresh failed, doing full reload', err);
102
- window.location.reload();
103
- });
104
- } else if (msg.type === 'css-update') {
105
- var links = document.querySelectorAll('link[rel="stylesheet"][href*="/static/"]');
106
- links.forEach(function(link) {
107
- link.href = '/static/' + msg.cssBundle + '?t=' + msg.timestamp;
108
- });
82
+ var es = null;
83
+ var reconnectTimer = null;
84
+
85
+ function applyManifest(manifestJson) {
86
+ if (!manifestJson) return;
87
+ var el = document.getElementById('__app_manifest');
88
+ if (el) el.textContent = manifestJson;
89
+ window.dispatchEvent(new CustomEvent('manifest-update'));
90
+ }
91
+
92
+ function pickCurrentHmrModules(msg) {
93
+ var currentPattern = window.__arcway_current_pattern__;
94
+ if (currentPattern && msg.routeBundles && msg.routeBundles[currentPattern]) {
95
+ return ['/static/' + msg.routeBundles[currentPattern] + '?t=' + msg.timestamp];
109
96
  }
110
- };
111
- es.onerror = function() {
112
- setTimeout(function() { es.close(); }, 5000);
113
- };
97
+ return (msg.modules || []).map(function(m) {
98
+ return '/static/' + m + '?t=' + msg.timestamp;
99
+ });
100
+ }
101
+
102
+ function connect() {
103
+ if (es) es.close();
104
+ es = new EventSource('/__livereload');
105
+ es.onmessage = function(e) {
106
+ if (e.data === 'connected') return;
107
+ var msg;
108
+ try { msg = JSON.parse(e.data); } catch { window.location.reload(); return; }
109
+ if (msg.type === 'reload') {
110
+ window.location.reload();
111
+ } else if (msg.type === 'hmr-update') {
112
+ var imports = pickCurrentHmrModules(msg).map(function(specifier) {
113
+ return import(specifier);
114
+ });
115
+ Promise.all(imports).then(function() {
116
+ RefreshRuntime.performReactRefresh();
117
+ applyManifest(msg.manifest);
118
+ }).catch(function(err) {
119
+ console.warn('[HMR] Fast Refresh failed, doing full reload', err);
120
+ window.location.reload();
121
+ });
122
+ } else if (msg.type === 'css-update') {
123
+ var links = document.querySelectorAll('link[rel="stylesheet"][data-arcway-css="global"]');
124
+ links.forEach(function(link) {
125
+ link.href = '/static/' + msg.cssBundle + '?t=' + msg.timestamp;
126
+ });
127
+ applyManifest(msg.manifest);
128
+ }
129
+ };
130
+ es.onerror = function() {
131
+ if (reconnectTimer) return;
132
+ try { es.close(); } catch {}
133
+ reconnectTimer = setTimeout(function() {
134
+ reconnectTimer = null;
135
+ connect();
136
+ }, 1000);
137
+ };
138
+ }
139
+
140
+ connect();
114
141
  </script>`;
115
142
  }
116
143
  function diffClientMetafiles(oldMeta, newMeta, outDir) {
@@ -28,7 +28,8 @@ import {
28
28
  import { createClientBuildContext } from './build-client.js';
29
29
  import { buildCssBundle } from './build-css.js';
30
30
  import { resolveFonts } from './fonts.js';
31
- import { buildHmrRuntimeBundle } from './hmr.js';
31
+ import { buildHmrRuntimeBundle, diffClientMetafiles } from './hmr.js';
32
+ import { syncViteHydrationEntries } from './vite-dev.js';
32
33
 
33
34
  // Dev-mode build context that defers per-page/layout/middleware server-bundle
34
35
  // compilation to first request. Startup is cheap: discover routes, build the
@@ -50,6 +51,7 @@ async function createLazyPagesContext(options) {
50
51
  const clientTarget = options.clientTarget ?? 'es2022';
51
52
  const minify = options.minify ?? false;
52
53
  const devMode = options.devMode ?? true;
54
+ const viteDev = options.viteDev ?? false;
53
55
  const esbuildImpl = options.esbuild ?? esbuild;
54
56
  // Cap per-context server-bundle concurrency so a request burst after a
55
57
  // cold start can't spawn N pages × M esbuild Go workers simultaneously.
@@ -63,6 +65,7 @@ async function createLazyPagesContext(options) {
63
65
 
64
66
  let disposed = false;
65
67
  let clientCtx = null;
68
+ let currentClientMetafile = null;
66
69
  let fontFaceCss;
67
70
  let fontPreloadHtml;
68
71
 
@@ -101,6 +104,13 @@ async function createLazyPagesContext(options) {
101
104
  { srcPath: path.resolve(m.filePath), built: false, stale: false },
102
105
  ]),
103
106
  ),
107
+ loadings: new Map(
108
+ loadings.map((l) => [
109
+ l.dirPath,
110
+ { srcPath: path.resolve(l.filePath) },
111
+ ]),
112
+ ),
113
+ stylesPath: stylesPath ? path.resolve(stylesPath) : null,
104
114
  version: 0,
105
115
  };
106
116
 
@@ -120,11 +130,46 @@ async function createLazyPagesContext(options) {
120
130
  events.emit(event, payload);
121
131
  }
122
132
 
133
+ function applyClientResult(clientResult) {
134
+ for (const entry of manifest.entries) {
135
+ const clientBundle = clientResult.bundles.get(entry.pattern);
136
+ const navBundle = clientResult.navBundles.get(entry.pattern);
137
+ if (clientBundle) entry.clientBundle = clientBundle;
138
+ if (navBundle) entry.navBundle = navBundle;
139
+ entry.layoutClientBundles = entry.layoutDirs
140
+ .map((d) => clientResult.layoutNavBundles.get(d))
141
+ .filter((v) => v !== undefined);
142
+ entry.loadingClientBundles = entry.loadingDirs
143
+ .map((d) => clientResult.loadingNavBundles.get(d))
144
+ .filter((v) => v !== undefined);
145
+ entry.sharedChunks = clientResult.entryChunks?.get(entry.pattern) ?? [];
146
+ entry.sharedCssChunks = clientResult.entryCssChunks?.get(entry.pattern) ?? [];
147
+ }
148
+ currentClientMetafile = clientResult.metafile ?? currentClientMetafile;
149
+ }
150
+
123
151
  // Client bundle + CSS + error pages + HMR runtime are built eagerly at
124
152
  // startup. They're shared across every request and their compilation cost
125
153
  // is bounded (a single esbuild.context for the client, one esbuild.transform
126
154
  // for CSS), so there's no win in deferring them.
127
155
  async function eagerSharedBuilds() {
156
+ if (viteDev) {
157
+ const [errorBundles] = await Promise.all([
158
+ buildErrorPageBundles(errorPages, outDir, serverTarget, {
159
+ rootDir,
160
+ devMode,
161
+ minify,
162
+ limit,
163
+ }),
164
+ syncViteHydrationEntries({ manifest, outDir }),
165
+ ]);
166
+ if (errorBundles.error) manifest.errorBundle = errorBundles.error;
167
+ if (errorBundles.notFound) manifest.notFoundBundle = errorBundles.notFound;
168
+ bumpVersion();
169
+ emit('update', { type: 'startup', version: manifest.version });
170
+ return { clientMetafile: null };
171
+ }
172
+
128
173
  clientCtx = await createClientBuildContext(
129
174
  pages,
130
175
  layouts,
@@ -151,20 +196,7 @@ async function createLazyPagesContext(options) {
151
196
  // Fold client bundle output into the manifest so renderPage() can find
152
197
  // navBundle / layoutClientBundles / shared chunks immediately, the same
153
198
  // as it would from a disk-backed prod manifest.
154
- for (const entry of manifest.entries) {
155
- const clientBundle = clientResult.bundles.get(entry.pattern);
156
- const navBundle = clientResult.navBundles.get(entry.pattern);
157
- if (clientBundle) entry.clientBundle = clientBundle;
158
- if (navBundle) entry.navBundle = navBundle;
159
- entry.layoutClientBundles = entry.layoutDirs
160
- .map((d) => clientResult.layoutNavBundles.get(d))
161
- .filter((v) => v !== undefined);
162
- entry.loadingClientBundles = entry.loadingDirs
163
- .map((d) => clientResult.loadingNavBundles.get(d))
164
- .filter((v) => v !== undefined);
165
- entry.sharedChunks = clientResult.entryChunks?.get(entry.pattern) ?? [];
166
- entry.sharedCssChunks = clientResult.entryCssChunks?.get(entry.pattern) ?? [];
167
- }
199
+ applyClientResult(clientResult);
168
200
  if (cssBundle) manifest.cssBundle = cssBundle;
169
201
  if (errorBundles.error) manifest.errorBundle = errorBundles.error;
170
202
  if (errorBundles.notFound) manifest.notFoundBundle = errorBundles.notFound;
@@ -314,13 +346,55 @@ async function createLazyPagesContext(options) {
314
346
  });
315
347
  }
316
348
 
317
- // Mark every manifest entry affected by a source change as stale. Does not
318
- // trigger rebuilds directly — the next `ensurePageBuilt()` call (driven by
319
- // an incoming request) handles the actual work. Structural changes
320
- // (add/remove files) are not handled here; the watcher will re-init the
321
- // context or extend the manifest in phase 5.
322
- function invalidate(filePath) {
349
+ function markAllEntriesStale() {
350
+ let touched = false;
351
+ const affected = { pages: [], layouts: [], middlewares: [] };
352
+ for (const entry of manifest.entries) {
353
+ if (!entry.stale || entry.built) {
354
+ entry.stale = true;
355
+ entry.built = false;
356
+ touched = true;
357
+ }
358
+ affected.pages.push(entry.pattern);
359
+ }
360
+ for (const [dirPath, layout] of manifest.layouts.entries()) {
361
+ if (!layout.stale || layout.built) {
362
+ layout.stale = true;
363
+ layout.built = false;
364
+ touched = true;
365
+ }
366
+ affected.layouts.push(dirPath);
367
+ }
368
+ for (const [dirPath, mw] of manifest.middlewares.entries()) {
369
+ if (!mw.stale || mw.built) {
370
+ mw.stale = true;
371
+ mw.built = false;
372
+ touched = true;
373
+ }
374
+ affected.middlewares.push(dirPath);
375
+ }
376
+ return { touched, affected };
377
+ }
378
+
379
+ function shouldInvalidateWholeTree(filePath, affected) {
380
+ if (
381
+ affected.pages.length > 0 ||
382
+ affected.layouts.length > 0 ||
383
+ affected.middlewares.length > 0
384
+ ) {
385
+ return false;
386
+ }
387
+ const rel = path.relative(rootDir, filePath).replace(/\\/g, '/');
388
+ if (!rel || rel.startsWith('..')) return false;
389
+ return /^(components|hooks|client|packages)\//.test(rel);
390
+ }
391
+
392
+ function markAffectedStale(filePath) {
323
393
  const affected = mapFileToAffected(filePath, manifest);
394
+ if (shouldInvalidateWholeTree(filePath, affected)) {
395
+ return markAllEntriesStale();
396
+ }
397
+
324
398
  let touched = false;
325
399
  for (const pattern of affected.pages) {
326
400
  const entry = findEntry(pattern);
@@ -346,6 +420,16 @@ async function createLazyPagesContext(options) {
346
420
  touched = true;
347
421
  }
348
422
  }
423
+ return { touched, affected };
424
+ }
425
+
426
+ // Mark every manifest entry affected by a source change as stale. Does not
427
+ // trigger rebuilds directly — the next `ensurePageBuilt()` call (driven by
428
+ // an incoming request) handles the actual work. Structural changes
429
+ // (add/remove files) are not handled here; the watcher will re-init the
430
+ // context or extend the manifest in phase 5.
431
+ function invalidate(filePath) {
432
+ const { touched, affected } = markAffectedStale(filePath);
349
433
  if (touched) {
350
434
  bumpVersion();
351
435
  emit('update', { type: 'invalidate', filePath, affected, version: manifest.version });
@@ -353,6 +437,87 @@ async function createLazyPagesContext(options) {
353
437
  return { touched, affected };
354
438
  }
355
439
 
440
+ let inFlightClientRefresh = null;
441
+
442
+ async function handleSourceChange(filePath) {
443
+ if (disposed) throw new Error('Lazy pages context is disposed');
444
+ const { touched, affected } = markAffectedStale(filePath);
445
+ if (!touched) return { touched: false, affected, hmr: null };
446
+ if (viteDev) {
447
+ bumpVersion();
448
+ emit('update', { type: 'invalidate', filePath, affected, version: manifest.version });
449
+ return { touched: true, affected, hmr: null };
450
+ }
451
+ if (inFlightClientRefresh) return inFlightClientRefresh;
452
+
453
+ const task = (async () => {
454
+ const previousMetafile = currentClientMetafile;
455
+ const previousCssBundle = manifest.cssBundle ?? null;
456
+ const timestamp = Date.now();
457
+
458
+ const [clientResult, cssBundle] = await Promise.all([
459
+ clientCtx?.rebuild() ?? Promise.resolve(null),
460
+ buildCssBundle(pagesDir, outDir, minify, stylesPath, fontFaceCss, rootDir),
461
+ ]);
462
+
463
+ if (clientResult) {
464
+ applyClientResult(clientResult);
465
+ }
466
+ if (cssBundle) manifest.cssBundle = cssBundle;
467
+
468
+ bumpVersion();
469
+ emit('update', { type: 'invalidate', filePath, affected, version: manifest.version });
470
+
471
+ let hmr = null;
472
+ if (clientResult && previousMetafile) {
473
+ const diff = diffClientMetafiles(previousMetafile, clientResult.metafile, outDir);
474
+ const modules = [...diff.changedHydrationBundles, ...diff.changedNavBundles];
475
+ const routeBundles = {};
476
+ for (const pattern of affected.pages) {
477
+ const entry = findEntry(pattern);
478
+ if (entry?.clientBundle) routeBundles[pattern] = entry.clientBundle;
479
+ }
480
+ if (diff.needsFullReload) {
481
+ hmr = { type: 'reload', timestamp };
482
+ emit('update', {
483
+ type: 'reload',
484
+ filePath,
485
+ reason: 'client-bundle-drift',
486
+ timestamp,
487
+ version: manifest.version,
488
+ });
489
+ } else if (modules.length > 0) {
490
+ hmr = { type: 'hmr-update', modules, routeBundles, timestamp };
491
+ emit('update', {
492
+ type: 'hmr-update',
493
+ filePath,
494
+ modules,
495
+ routeBundles,
496
+ timestamp,
497
+ version: manifest.version,
498
+ });
499
+ }
500
+ }
501
+
502
+ if (manifest.cssBundle) {
503
+ emit('update', {
504
+ type: 'css-update',
505
+ filePath,
506
+ cssBundle: manifest.cssBundle,
507
+ timestamp,
508
+ version: manifest.version,
509
+ });
510
+ }
511
+
512
+ return { touched: true, affected, hmr };
513
+ })().finally(() => {
514
+ inFlightClientRefresh = null;
515
+ });
516
+
517
+ inFlightClientRefresh = task;
518
+ return task;
519
+ }
520
+
356
521
  // ── Structural rediscovery ─────────────────────────────────────────────
357
522
  // `invalidate()` handles content edits to known files; `rediscover()`
358
523
  // handles add/delete of pages, layouts, and middlewares. Re-runs the same
@@ -385,6 +550,8 @@ async function createLazyPagesContext(options) {
385
550
  const result = {
386
551
  added: { pages: [], layouts: [], middlewares: [] },
387
552
  removed: { pages: [], layouts: [], middlewares: [] },
553
+ addedLoadings: [],
554
+ removedLoadings: [],
388
555
  staleByCascade: [],
389
556
  clientRebuilt: false,
390
557
  version: manifest.version,
@@ -406,13 +573,20 @@ async function createLazyPagesContext(options) {
406
573
  const addedMiddlewares = nextMiddlewares.filter((m) => !currentMwDirs.has(m.dirPath));
407
574
  const removedMiddlewares = [...currentMwDirs].filter((d) => !nextMwDirs.has(d));
408
575
 
576
+ const currentLoadingDirs = new Set(manifest.loadings.keys());
577
+ const nextLoadingDirs = new Set(nextLoadings.map((l) => l.dirPath));
578
+ const addedLoadings = nextLoadings.filter((l) => !currentLoadingDirs.has(l.dirPath));
579
+ const removedLoadings = [...currentLoadingDirs].filter((d) => !nextLoadingDirs.has(d));
580
+
409
581
  const noOp =
410
582
  addedPages.length === 0 &&
411
583
  removedPages.length === 0 &&
412
584
  addedLayouts.length === 0 &&
413
585
  removedLayouts.length === 0 &&
414
586
  addedMiddlewares.length === 0 &&
415
- removedMiddlewares.length === 0;
587
+ removedMiddlewares.length === 0 &&
588
+ addedLoadings.length === 0 &&
589
+ removedLoadings.length === 0;
416
590
  if (noOp) return result;
417
591
 
418
592
  // ── Apply layout add/remove ──────────────────────────────────────
@@ -445,6 +619,17 @@ async function createLazyPagesContext(options) {
445
619
  result.removed.middlewares.push(dirPath);
446
620
  }
447
621
 
622
+ for (const loading of addedLoadings) {
623
+ manifest.loadings.set(loading.dirPath, {
624
+ srcPath: path.resolve(loading.filePath),
625
+ });
626
+ result.addedLoadings.push(loading.dirPath);
627
+ }
628
+ for (const dirPath of removedLoadings) {
629
+ manifest.loadings.delete(dirPath);
630
+ result.removedLoadings.push(dirPath);
631
+ }
632
+
448
633
  // ── Apply page add/remove ────────────────────────────────────────
449
634
  const addedPagePatterns = new Set();
450
635
  for (const page of addedPages) {
@@ -494,8 +679,10 @@ async function createLazyPagesContext(options) {
494
679
  result.added.pages.length > 0 ||
495
680
  result.removed.pages.length > 0 ||
496
681
  result.added.layouts.length > 0 ||
497
- result.removed.layouts.length > 0;
498
- if (needClientRebuild) {
682
+ result.removed.layouts.length > 0 ||
683
+ result.addedLoadings.length > 0 ||
684
+ result.removedLoadings.length > 0;
685
+ if (needClientRebuild && !viteDev) {
499
686
  if (clientCtx) {
500
687
  try {
501
688
  await clientCtx.dispose();
@@ -515,22 +702,14 @@ async function createLazyPagesContext(options) {
515
702
  devMode,
516
703
  );
517
704
  const clientResult = await clientCtx.rebuild();
518
- for (const entry of manifest.entries) {
519
- const clientBundle = clientResult.bundles.get(entry.pattern);
520
- const navBundle = clientResult.navBundles.get(entry.pattern);
521
- if (clientBundle) entry.clientBundle = clientBundle;
522
- if (navBundle) entry.navBundle = navBundle;
523
- entry.layoutClientBundles = entry.layoutDirs
524
- .map((d) => clientResult.layoutNavBundles.get(d))
525
- .filter((v) => v !== undefined);
526
- entry.loadingClientBundles = entry.loadingDirs
527
- .map((d) => clientResult.loadingNavBundles.get(d))
528
- .filter((v) => v !== undefined);
529
- entry.sharedChunks = clientResult.entryChunks?.get(entry.pattern) ?? [];
530
- entry.sharedCssChunks = clientResult.entryCssChunks?.get(entry.pattern) ?? [];
531
- }
705
+ applyClientResult(clientResult);
532
706
  result.clientRebuilt = true;
533
707
  }
708
+ if (viteDev) {
709
+ const nextStylesPath = await discoverStyles(pagesDir);
710
+ manifest.stylesPath = nextStylesPath ? path.resolve(nextStylesPath) : null;
711
+ await syncViteHydrationEntries({ manifest, outDir });
712
+ }
534
713
 
535
714
  // ── Version bump + event emission ────────────────────────────────
536
715
  bumpVersion();
@@ -553,6 +732,12 @@ async function createLazyPagesContext(options) {
553
732
  for (const dirPath of result.removed.middlewares) {
554
733
  emit('update', { type: 'remove-middleware', dirPath, version: manifest.version });
555
734
  }
735
+ for (const dirPath of result.addedLoadings) {
736
+ emit('update', { type: 'add-loading', dirPath, version: manifest.version });
737
+ }
738
+ for (const dirPath of result.removedLoadings) {
739
+ emit('update', { type: 'remove-loading', dirPath, version: manifest.version });
740
+ }
556
741
  if (result.clientRebuilt) {
557
742
  emit('update', { type: 'client-rebuilt', version: manifest.version });
558
743
  }
@@ -603,9 +788,11 @@ async function createLazyPagesContext(options) {
603
788
  stale: m.stale,
604
789
  ...(m.lastBuiltAt ? { lastBuiltAt: m.lastBuiltAt } : {}),
605
790
  })),
791
+ loadings: Array.from(manifest.loadings.keys()).map((dirPath) => ({ dirPath })),
606
792
  ...(manifest.errorBundle ? { errorBundle: manifest.errorBundle } : {}),
607
793
  ...(manifest.notFoundBundle ? { notFoundBundle: manifest.notFoundBundle } : {}),
608
794
  ...(manifest.cssBundle ? { cssBundle: manifest.cssBundle } : {}),
795
+ ...(manifest.stylesPath ? { stylesPath: manifest.stylesPath } : {}),
609
796
  };
610
797
  }
611
798
 
@@ -627,12 +814,13 @@ async function createLazyPagesContext(options) {
627
814
  ensureLayoutBuilt,
628
815
  ensureMiddlewareBuilt,
629
816
  invalidate,
817
+ handleSourceChange,
630
818
  rediscover,
631
819
  dispose,
632
820
  on,
633
821
  getManifestJson,
634
822
  get clientMetafile() {
635
- return startup.clientMetafile;
823
+ return currentClientMetafile ?? startup.clientMetafile;
636
824
  },
637
825
  };
638
826
  }
@@ -0,0 +1,11 @@
1
+ import path from 'node:path';
2
+
3
+ function resolvePagesOutDir(config, rootDir, mode = 'production') {
4
+ const pages = config?.pages ?? {};
5
+ const isDev = mode === 'development';
6
+ const prodOutDir = pages.outDir ?? path.resolve(rootDir, '.build/pages');
7
+ const devOutDir = pages.devOutDir ?? path.resolve(rootDir, '.build-dev/pages');
8
+ return isDev ? devOutDir : prodOutDir;
9
+ }
10
+
11
+ export { resolvePagesOutDir };
@@ -1,6 +1,7 @@
1
1
  import { createPagesHandler } from './handler.js';
2
2
  import { createLazyPagesContext } from './lazy-context.js';
3
3
  import { createPagesWatcher } from './watcher.js';
4
+ import { resolvePagesOutDir } from './out-dir.js';
4
5
 
5
6
  class PagesRouter {
6
7
  config;
@@ -24,6 +25,8 @@ class PagesRouter {
24
25
  async init() {
25
26
  const { config, rootDir, log, mode, fileWatcher } = this;
26
27
  const isDev = mode === 'development';
28
+ const viteDev = isDev && config.pages?.vite?.enabled === true;
29
+ const outDir = resolvePagesOutDir(config, rootDir, mode);
27
30
 
28
31
  if (isDev) {
29
32
  // Dev mode: `createLazyPagesContext()` builds only the shared bits
@@ -35,9 +38,11 @@ class PagesRouter {
35
38
  this.lazyContext = await createLazyPagesContext({
36
39
  rootDir,
37
40
  pagesDir: config.pages.dir,
41
+ outDir,
38
42
  fonts: config.pages?.fonts,
39
43
  minify: false,
40
44
  devMode: true,
45
+ viteDev,
41
46
  });
42
47
  } catch (err) {
43
48
  log.error('Pages lazy context init failed', { error: String(err) });
@@ -46,9 +51,11 @@ class PagesRouter {
46
51
 
47
52
  this.handler = createPagesHandler({
48
53
  rootDir,
54
+ outDir,
49
55
  session: config.session,
50
56
  appContext: this.appContext,
51
57
  mode,
58
+ viteDev,
52
59
  ...(this.lazyContext ? { lazyContext: this.lazyContext } : {}),
53
60
  });
54
61
 
@@ -70,12 +70,14 @@ function wrapWithProviders(createElement, element, pathname, params) {
70
70
  }
71
71
  function buildCssLinkTag(manifest) {
72
72
  if (!manifest.cssBundle) return '';
73
- return `<link rel="stylesheet" href="/static/${manifest.cssBundle}" />`;
73
+ return `<link rel="stylesheet" data-arcway-css="global" href="/static/${manifest.cssBundle}" />`;
74
74
  }
75
75
  function buildRouteCssLinkTags(route) {
76
76
  const chunks = route?.sharedCssChunks ?? [];
77
77
  if (chunks.length === 0) return '';
78
- return chunks.map((c) => `<link rel="stylesheet" href="/static/${c}" />`).join('\n');
78
+ return chunks
79
+ .map((c) => `<link rel="stylesheet" data-arcway-css="route" href="/static/${c}" />`)
80
+ .join('\n');
79
81
  }
80
82
  function buildFontPreloadTags(manifest) {
81
83
  return manifest.fontPreloadHtml ?? '';
@@ -97,6 +99,19 @@ function buildScriptTags(route) {
97
99
  tags.push(`<script type="module" src="/static/${route.clientBundle}"></script>`);
98
100
  return tags.join('\n');
99
101
  }
102
+ function buildViteScriptTags(route) {
103
+ return [
104
+ `<script type="module">
105
+ import RefreshRuntime from "/@react-refresh";
106
+ RefreshRuntime.injectIntoGlobalHook(window);
107
+ window.$RefreshReg$ = () => {};
108
+ window.$RefreshSig$ = () => (type) => type;
109
+ window.__vite_plugin_react_preamble_installed__ = true;
110
+ </script>`,
111
+ `<script type="module" src="/@vite/client"></script>`,
112
+ `<script type="module" src="${route.clientBundle}"></script>`,
113
+ ].join('\n');
114
+ }
100
115
  function buildHtmlShell({ headHtml, fontPreloadTags, cssLinkTag, bodysuffix }) {
101
116
  return {
102
117
  head: `<!DOCTYPE html>
@@ -128,6 +143,7 @@ async function renderPage(
128
143
  cacheVersion,
129
144
  react,
130
145
  devMode,
146
+ viteDev = false,
131
147
  ) {
132
148
  const { createElement, renderToPipeableStream } = react;
133
149
  const bundlePath = path.join(outDir, route.serverBundle);
@@ -155,14 +171,14 @@ async function renderPage(
155
171
  layoutComponents.push(Layout);
156
172
  }
157
173
  }
158
- const scriptTags = buildScriptTags(route);
159
- const manifestCssTag = buildCssLinkTag(manifest);
160
- const routeCssTags = buildRouteCssLinkTags(route);
174
+ const scriptTags = viteDev ? buildViteScriptTags(route) : buildScriptTags(route);
175
+ const manifestCssTag = viteDev ? '' : buildCssLinkTag(manifest);
176
+ const routeCssTags = viteDev ? '' : buildRouteCssLinkTags(route);
161
177
  const cssLinkTag = [manifestCssTag, routeCssTags].filter(Boolean).join('\n');
162
178
  const fontPreloadTags = buildFontPreloadTags(manifest);
163
179
  const envScriptTag = buildEnvScriptTag(collectPublicEnv());
164
- const hmrTag = devMode ? buildHmrScript() : '';
165
- const liveReloadTag = devMode && !hmrTag ? buildLiveReloadScript() : '';
180
+ const hmrTag = devMode && !viteDev ? buildHmrScript() : '';
181
+ const liveReloadTag = devMode && !viteDev && !hmrTag ? buildLiveReloadScript() : '';
166
182
  const propsJson = JSON.stringify(params).replace(/</g, '\\u003c');
167
183
  const headData = { meta: [], links: [] };
168
184
  setSSRHeadData(headData);
@@ -0,0 +1,246 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs/promises';
3
+ import { createRequire } from 'node:module';
4
+ import reactPlugin from '@vitejs/plugin-react';
5
+ import tailwindcss from '@tailwindcss/vite';
6
+ import { createServer as createViteServer } from 'vite';
7
+ import { patternToFileName } from './build-server.js';
8
+
9
+ function toPosixPath(value) {
10
+ return value.replace(/\\/g, '/');
11
+ }
12
+
13
+ function toViteModuleUrl(rootDir, filePath) {
14
+ if (!filePath) return null;
15
+ const abs = path.resolve(filePath);
16
+ const rel = path.relative(rootDir, abs);
17
+ if (!rel.startsWith('..') && !path.isAbsolute(rel)) {
18
+ return `/${toPosixPath(rel)}`;
19
+ }
20
+ return `/@fs/${toPosixPath(abs)}`;
21
+ }
22
+
23
+ function getViteEntryPath(outDir, pattern) {
24
+ return path.join(outDir, '.vite-entries', `${patternToFileName(pattern)}.tsx`);
25
+ }
26
+
27
+ function buildViteHydrationEntry({
28
+ componentPath,
29
+ layouts = [],
30
+ loadings = [],
31
+ pattern = '/',
32
+ globalStylesPath,
33
+ }) {
34
+ const imports = [
35
+ `import { hydrateRoot } from 'react-dom/client';`,
36
+ `import { ApiProvider, Router } from 'arcway/lib/client';`,
37
+ ];
38
+
39
+ if (globalStylesPath) {
40
+ imports.push(`import '${toPosixPath(path.resolve(globalStylesPath))}';`);
41
+ }
42
+
43
+ imports.push(`import Component from '${toPosixPath(path.resolve(componentPath))}';`);
44
+
45
+ const layoutNames = [];
46
+ for (let i = 0; i < layouts.length; i++) {
47
+ imports.push(`import Layout${i} from '${toPosixPath(path.resolve(layouts[i]))}';`);
48
+ layoutNames.push(`Layout${i}`);
49
+ }
50
+
51
+ const loadingNames = [];
52
+ for (let i = 0; i < loadings.length; i++) {
53
+ imports.push(`import Loading${i} from '${toPosixPath(path.resolve(loadings[i]))}';`);
54
+ loadingNames.push(`Loading${i}`);
55
+ }
56
+
57
+ const layoutsArray = layoutNames.length > 0 ? `[${layoutNames.join(', ')}]` : '[]';
58
+ const loadingsArray = loadingNames.length > 0 ? `[${loadingNames.join(', ')}]` : '[]';
59
+
60
+ return `${imports.join('\n')}
61
+
62
+ const container = document.getElementById('__app');
63
+ const propsEl = document.getElementById('__app_props');
64
+ const props = propsEl ? JSON.parse(propsEl.textContent || '{}') : {};
65
+ const ROOT_KEY = '__arcway_root__';
66
+ const rootOwner = window;
67
+ const element = <ApiProvider><Router initialPath={window.location.pathname} initialParams={props} initialPattern={${JSON.stringify(pattern)}} initialComponent={Component} initialLayouts={${layoutsArray}} initialLoadings={${loadingsArray}} /></ApiProvider>;
68
+
69
+ if (!container) {
70
+ throw new Error('Arcway Vite hydrate entry could not find #__app');
71
+ }
72
+
73
+ if (!rootOwner[ROOT_KEY]) {
74
+ rootOwner[ROOT_KEY] = hydrateRoot(container, element);
75
+ } else {
76
+ rootOwner[ROOT_KEY].render(element);
77
+ }
78
+ `;
79
+ }
80
+
81
+ async function syncViteHydrationEntries({ manifest, outDir }) {
82
+ const viteDir = path.join(outDir, '.vite-entries');
83
+ await fs.mkdir(viteDir, { recursive: true });
84
+
85
+ const expected = new Set();
86
+
87
+ for (const entry of manifest.entries) {
88
+ const entryPath = getViteEntryPath(outDir, entry.pattern);
89
+ const content = buildViteHydrationEntry({
90
+ componentPath: entry.srcPath,
91
+ layouts: entry.layoutDirs
92
+ .map((dirPath) => manifest.layouts.get(dirPath)?.srcPath)
93
+ .filter((value) => value !== undefined),
94
+ loadings: entry.loadingDirs
95
+ .map((dirPath) => manifest.loadings.get(dirPath)?.srcPath)
96
+ .filter((value) => value !== undefined),
97
+ pattern: entry.pattern,
98
+ globalStylesPath: manifest.stylesPath,
99
+ });
100
+ await fs.mkdir(path.dirname(entryPath), { recursive: true });
101
+ await fs.writeFile(entryPath, content);
102
+ expected.add(toPosixPath(path.relative(viteDir, entryPath)));
103
+ }
104
+
105
+ const existing = await listFiles(viteDir);
106
+ await Promise.all(
107
+ existing
108
+ .filter((name) => !expected.has(name))
109
+ .map((name) => fs.rm(path.join(viteDir, name), { force: true })),
110
+ );
111
+ }
112
+
113
+ async function listFiles(rootDir, currentDir = rootDir) {
114
+ const entries = await fs.readdir(currentDir, { withFileTypes: true }).catch(() => []);
115
+ const files = [];
116
+ for (const entry of entries) {
117
+ const abs = path.join(currentDir, entry.name);
118
+ if (entry.isDirectory()) {
119
+ const nested = await listFiles(rootDir, abs);
120
+ files.push(...nested);
121
+ continue;
122
+ }
123
+ files.push(toPosixPath(path.relative(rootDir, abs)));
124
+ }
125
+ return files;
126
+ }
127
+
128
+ function buildViteClientManifestJson(manifest, rootDir) {
129
+ const clientManifest = {
130
+ cssBundle: null,
131
+ routes: manifest.entries.map((entry) => {
132
+ const route = {
133
+ pattern: entry.pattern,
134
+ paramNames: entry.paramNames,
135
+ clientBundle: toViteModuleUrl(rootDir, entry.srcPath),
136
+ layoutBundles: entry.layoutDirs
137
+ .map((dirPath) => manifest.layouts.get(dirPath)?.srcPath)
138
+ .filter(Boolean)
139
+ .map((filePath) => toViteModuleUrl(rootDir, filePath)),
140
+ loadingBundles: entry.loadingDirs
141
+ .map((dirPath) => manifest.loadings.get(dirPath)?.srcPath)
142
+ .filter(Boolean)
143
+ .map((filePath) => toViteModuleUrl(rootDir, filePath)),
144
+ cssBundles: [],
145
+ };
146
+ if (entry.catchAllParam) route.catchAllParam = entry.catchAllParam;
147
+ return route;
148
+ }),
149
+ };
150
+ return JSON.stringify(clientManifest).replace(/</g, '\\u003c');
151
+ }
152
+
153
+ function buildViteRoute(route, { rootDir, outDir }) {
154
+ return {
155
+ ...route,
156
+ clientBundle: toViteModuleUrl(rootDir, getViteEntryPath(outDir, route.pattern)),
157
+ sharedChunks: ['/@vite/client'],
158
+ sharedCssChunks: [],
159
+ };
160
+ }
161
+
162
+ function resolveAppAliases(rootDir) {
163
+ const appRequire = createRequire(path.join(rootDir, 'package.json'));
164
+ const aliases = [];
165
+
166
+ try {
167
+ const reactRoot = path.dirname(appRequire.resolve('react/package.json'));
168
+ aliases.push({ find: /^react$/, replacement: toPosixPath(path.join(reactRoot, 'index.js')) });
169
+ aliases.push({ find: /^react\/jsx-runtime$/, replacement: toPosixPath(path.join(reactRoot, 'jsx-runtime.js')) });
170
+ aliases.push({ find: /^react\/jsx-dev-runtime$/, replacement: toPosixPath(path.join(reactRoot, 'jsx-dev-runtime.js')) });
171
+ } catch {}
172
+
173
+ try {
174
+ const reactDomRoot = path.dirname(appRequire.resolve('react-dom/package.json'));
175
+ aliases.push({ find: /^react-dom$/, replacement: toPosixPath(path.join(reactDomRoot, 'index.js')) });
176
+ aliases.push({ find: /^react-dom\/client$/, replacement: toPosixPath(path.join(reactDomRoot, 'client.js')) });
177
+ } catch {}
178
+
179
+ try {
180
+ aliases.push({ find: /^swr$/, replacement: toPosixPath(appRequire.resolve('swr')) });
181
+ } catch {}
182
+
183
+ return aliases;
184
+ }
185
+
186
+ async function createViteDevRouter({ rootDir, log, config }) {
187
+ const appAliases = resolveAppAliases(rootDir);
188
+ const vite = await createViteServer({
189
+ root: rootDir,
190
+ appType: 'custom',
191
+ server: {
192
+ middlewareMode: true,
193
+ },
194
+ resolve: {
195
+ alias: appAliases,
196
+ dedupe: ['react', 'react-dom', 'swr'],
197
+ },
198
+ optimizeDeps: {
199
+ include: ['react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime', 'swr'],
200
+ },
201
+ plugins: [reactPlugin(), tailwindcss()],
202
+ clearScreen: false,
203
+ });
204
+
205
+ const internalPrefixes = ['/@vite/', '/@id/', '/@react-refresh', '/@fs/', '/node_modules/.vite/'];
206
+ const sourceExt = /\.(?:[cm]?[jt]sx?|css|pcss|postcss|json|svg)$/i;
207
+
208
+ async function handle(req, res) {
209
+ const url = req.url ?? '/';
210
+ const pathname = url.split('?')[0];
211
+ const shouldDelegate =
212
+ internalPrefixes.some((prefix) => pathname.startsWith(prefix)) ||
213
+ sourceExt.test(pathname) ||
214
+ pathname.startsWith('/.build/pages/.vite-entries/');
215
+
216
+ if (!shouldDelegate) return false;
217
+
218
+ return new Promise((resolve, reject) => {
219
+ vite.middlewares(req, res, (err) => {
220
+ if (err) {
221
+ reject(err);
222
+ return;
223
+ }
224
+ resolve(res.writableEnded || res.headersSent);
225
+ });
226
+ });
227
+ }
228
+
229
+ return {
230
+ vite,
231
+ handle,
232
+ async close() {
233
+ await vite.close();
234
+ log?.info?.('Vite dev server closed');
235
+ },
236
+ };
237
+ }
238
+
239
+ export {
240
+ buildViteClientManifestJson,
241
+ buildViteRoute,
242
+ createViteDevRouter,
243
+ getViteEntryPath,
244
+ syncViteHydrationEntries,
245
+ toViteModuleUrl,
246
+ };
@@ -36,7 +36,9 @@ function createPagesWatcher(options) {
36
36
  }
37
37
  for (const e of events) {
38
38
  if (e.event !== 'change') continue;
39
- const result = lazyContext.invalidate(e.path);
39
+ const result = lazyContext.handleSourceChange
40
+ ? await lazyContext.handleSourceChange(e.path)
41
+ : lazyContext.invalidate(e.path);
40
42
  if (result.touched) {
41
43
  log.info(`Pages: invalidated ${e.relativePath} (${formatAffected(result.affected)})`);
42
44
  }
package/server/watcher.js CHANGED
@@ -3,13 +3,9 @@ import path from 'node:path';
3
3
  function startWatcher(options) {
4
4
  const dirs = options.dirs ?? [
5
5
  'api',
6
- 'pages',
7
- 'components',
8
- 'hooks',
9
6
  'lib',
10
7
  'listeners',
11
8
  'jobs',
12
- 'packages',
13
9
  'server',
14
10
  'templates',
15
11
  'migrations',