arcway 0.1.11 → 0.1.13

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.
@@ -3,7 +3,7 @@ import { WsManager } from './ws.js';
3
3
 
4
4
  const SOLO_CTX_KEY = '__provider_context__';
5
5
  const WS_CTX_KEY = '__ws_context__';
6
- const DEFAULT_CONFIG = { pathPrefix: '' };
6
+ const DEFAULT_CONFIG = { pathPrefix: '/api' };
7
7
 
8
8
  const ApiContext = (globalThis[SOLO_CTX_KEY] ??= createContext(DEFAULT_CONFIG));
9
9
  const WsContext = (globalThis[WS_CTX_KEY] ??= createContext(null));
@@ -18,8 +18,25 @@ function useWsManager() {
18
18
 
19
19
  const useSoloContext = useApiContext;
20
20
 
21
- function ApiProvider({ children, pathPrefix = '', headers, wsUrl }) {
22
- const config = useMemo(() => ({ pathPrefix, headers, wsUrl }), [pathPrefix, headers, wsUrl]);
21
+ function toWsProtocol(loc, wsPath) {
22
+ const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:';
23
+ return `${proto}//${loc.host}${wsPath}`;
24
+ }
25
+
26
+ function resolveWsUrl(input) {
27
+ if (typeof window === 'undefined') return null;
28
+ if (!input) return toWsProtocol(window.location, '/ws');
29
+ if (/^wss?:\/\//.test(input)) return input;
30
+ if (input.startsWith('/')) return toWsProtocol(window.location, input);
31
+ return input;
32
+ }
33
+
34
+ function ApiProvider({ children, pathPrefix = '/api', headers, wsUrl }) {
35
+ const resolvedWsUrl = useMemo(() => resolveWsUrl(wsUrl), [wsUrl]);
36
+ const config = useMemo(
37
+ () => ({ pathPrefix, headers, wsUrl: resolvedWsUrl }),
38
+ [pathPrefix, headers, resolvedWsUrl],
39
+ );
23
40
 
24
41
  const wsManagerRef = useRef(null);
25
42
  const wsManager = useMemo(() => {
@@ -27,11 +44,11 @@ function ApiProvider({ children, pathPrefix = '', headers, wsUrl }) {
27
44
  wsManagerRef.current.disconnect();
28
45
  wsManagerRef.current = null;
29
46
  }
30
- if (!wsUrl || typeof window === 'undefined') return null;
31
- const manager = new WsManager({ url: wsUrl });
47
+ if (!resolvedWsUrl) return null;
48
+ const manager = new WsManager({ url: resolvedWsUrl });
32
49
  wsManagerRef.current = manager;
33
50
  return manager;
34
- }, [wsUrl]);
51
+ }, [resolvedWsUrl]);
35
52
 
36
53
  useEffect(() => {
37
54
  if (wsManager) {
@@ -50,4 +67,12 @@ function ApiProvider({ children, pathPrefix = '', headers, wsUrl }) {
50
67
  const Provider = ApiProvider;
51
68
  const SoloProvider = ApiProvider;
52
69
 
53
- export { ApiProvider, Provider, SoloProvider, useApiContext, useSoloContext, useWsManager };
70
+ export {
71
+ ApiProvider,
72
+ Provider,
73
+ SoloProvider,
74
+ resolveWsUrl,
75
+ useApiContext,
76
+ useSoloContext,
77
+ useWsManager,
78
+ };
package/client/router.js CHANGED
@@ -1,5 +1,3 @@
1
- import { jsx, jsxs } from "react/jsx-runtime";
2
- import React from "react";
3
1
  import {
4
2
  createContext,
5
3
  useContext,
@@ -9,86 +7,102 @@ import {
9
7
  useMemo,
10
8
  useRef,
11
9
  useTransition,
12
- createElement
13
- } from "react";
10
+ createElement,
11
+ } from 'react';
14
12
  import {
15
13
  readClientManifest,
16
14
  loadPage,
17
15
  matchClientRoute,
18
16
  loadLoadingComponents,
19
- prefetchRoute
20
- } from "./page-loader.js";
21
- import { parseQuery } from "./query.js";
22
- const ROUTER_CTX_KEY = "__router_context__";
23
- const RouterContext = globalThis[ROUTER_CTX_KEY] ??= createContext(null);
17
+ prefetchRoute,
18
+ } from './page-loader.js';
19
+ import { parseQuery } from './query.js';
20
+
21
+ const ROUTER_CTX_KEY = '__router_context__';
22
+ const RouterContext = (globalThis[ROUTER_CTX_KEY] ??= createContext(null));
23
+
24
24
  function wrapInLayouts(element, layouts) {
25
25
  for (let i = layouts.length - 1; i >= 0; i--) {
26
26
  element = createElement(layouts[i], null, element);
27
27
  }
28
28
  return element;
29
29
  }
30
+
30
31
  function useRouter() {
31
32
  const ctx = useContext(RouterContext);
32
33
  if (!ctx) {
33
- throw new Error("useRouter must be used within a <Router> provider");
34
+ throw new Error('useRouter must be used within a <Router> provider');
34
35
  }
36
+
35
37
  return useMemo(
36
38
  () => ({
37
39
  pathname: ctx.pathname,
38
40
  params: ctx.params,
39
- query: typeof window !== "undefined" ? parseQuery(window.location.search) : {},
41
+ query: typeof window !== 'undefined' ? parseQuery(window.location.search) : {},
40
42
  push: (to, options) => ctx.navigate(to, { ...options, replace: false }),
41
43
  replace: (to, options) => ctx.navigate(to, { ...options, replace: true }),
42
44
  back: () => {
43
- if (typeof window !== "undefined") window.history.back();
45
+ if (typeof window !== 'undefined') window.history.back();
44
46
  },
45
47
  forward: () => {
46
- if (typeof window !== "undefined") window.history.forward();
48
+ if (typeof window !== 'undefined') window.history.forward();
47
49
  },
48
50
  refresh: () => {
49
- if (typeof window !== "undefined") window.location.reload();
50
- }
51
+ if (typeof window !== 'undefined') window.location.reload();
52
+ },
51
53
  }),
52
- [ctx.pathname, ctx.params, ctx.navigate]
54
+ // queryVersion bumps on every query-only navigation or same-path popstate,
55
+ // so memoised consumers (notably `useSearchParams`) re-read the URL.
56
+ [ctx.pathname, ctx.params, ctx.navigate, ctx.queryVersion],
53
57
  );
54
58
  }
59
+
55
60
  function usePathname() {
56
61
  return useRouter().pathname;
57
62
  }
63
+
58
64
  function useParams() {
59
65
  return useRouter().params;
60
66
  }
67
+
61
68
  function useSearchParams() {
62
69
  return useRouter().query;
63
70
  }
71
+
64
72
  function Router({
65
73
  initialPath,
66
74
  initialParams,
67
75
  initialComponent,
68
76
  initialLayouts,
69
77
  initialLoadings,
70
- children
78
+ children,
71
79
  }) {
72
80
  const [pathname, setPathname] = useState(
73
- () => initialPath ?? (typeof window !== "undefined" ? window.location.pathname : "/")
81
+ () => initialPath ?? (typeof window !== 'undefined' ? window.location.pathname : '/'),
74
82
  );
75
83
  const [pageState, setPageState] = useState({
76
84
  component: initialComponent ?? null,
77
85
  layouts: initialLayouts ?? [],
78
86
  loadings: initialLoadings ?? [],
79
- params: initialParams ?? {}
87
+ params: initialParams ?? {},
80
88
  });
81
89
  const [isNavigating, setIsNavigating] = useState(false);
82
90
  const [isPending, startTransition] = useTransition();
91
+ // Bumped on query-only navigations (both push/replace and popstate) so that
92
+ // `useRouter`/`useSearchParams` consumers re-render even though pathname and
93
+ // params are unchanged.
94
+ const [queryVersion, setQueryVersion] = useState(0);
83
95
  const manifestRef = useRef(null);
96
+
84
97
  useEffect(() => {
85
98
  manifestRef.current = readClientManifest();
86
99
  const onManifestUpdate = () => {
87
100
  manifestRef.current = readClientManifest();
88
101
  };
89
- window.addEventListener("manifest-update", onManifestUpdate);
90
- return () => window.removeEventListener("manifest-update", onManifestUpdate);
102
+ window.addEventListener('manifest-update', onManifestUpdate);
103
+ return () => window.removeEventListener('manifest-update', onManifestUpdate);
91
104
  }, []);
105
+
92
106
  const applyLoaded = useCallback((loaded, newPath) => {
93
107
  startTransition(() => {
94
108
  setPathname(newPath);
@@ -96,71 +110,114 @@ function Router({
96
110
  component: loaded.component,
97
111
  layouts: loaded.layouts,
98
112
  loadings: loaded.loadings,
99
- params: loaded.params
113
+ params: loaded.params,
100
114
  });
101
115
  setIsNavigating(false);
102
116
  });
103
117
  }, []);
118
+
104
119
  const navigateToPage = useCallback(
105
120
  async (to, options) => {
106
121
  const scroll = options?.scroll !== false;
107
122
  const replace = options?.replace === true;
108
- if (to === pathname) return;
123
+
124
+ // `matchClientRoute` only knows how to match pathnames, so any query
125
+ // string has to be stripped before the lookup. Splitting also lets us
126
+ // special-case same-path/different-query navigations as a history-only
127
+ // update — no bundle loading, no scroll reset, no full reload.
128
+ const qIdx = to.indexOf('?');
129
+ const pathOnly = qIdx === -1 ? to : to.slice(0, qIdx);
130
+ const search = qIdx === -1 ? '' : to.slice(qIdx);
131
+ const currentSearch =
132
+ typeof window !== 'undefined' ? window.location.search : '';
133
+
134
+ if (pathOnly === pathname && search === currentSearch) return;
135
+
136
+ if (pathOnly === pathname) {
137
+ if (replace) {
138
+ window.history.replaceState(null, '', to);
139
+ } else {
140
+ window.history.pushState(null, '', to);
141
+ }
142
+ setQueryVersion((v) => v + 1);
143
+ return;
144
+ }
145
+
109
146
  const manifest = manifestRef.current;
110
147
  if (!manifest) {
111
148
  window.location.href = to;
112
149
  return;
113
150
  }
114
- const matched = matchClientRoute(manifest, to);
151
+
152
+ const matched = matchClientRoute(manifest, pathOnly);
115
153
  if (!matched) {
116
154
  window.location.href = to;
117
155
  return;
118
156
  }
157
+
119
158
  setIsNavigating(true);
120
159
  if (replace) {
121
- window.history.replaceState(null, "", to);
160
+ window.history.replaceState(null, '', to);
122
161
  } else {
123
- window.history.pushState(null, "", to);
162
+ window.history.pushState(null, '', to);
124
163
  }
164
+
125
165
  const targetLoadings = await loadLoadingComponents(matched.route);
126
166
  if (targetLoadings.length > 0) {
127
- setPathname(to);
167
+ setPathname(pathOnly);
128
168
  setPageState((prev) => ({
129
169
  ...prev,
130
170
  loadings: targetLoadings,
131
- params: matched.params
171
+ params: matched.params,
132
172
  }));
133
173
  }
174
+
134
175
  try {
135
- const loaded = await loadPage(manifest, to);
176
+ const loaded = await loadPage(manifest, pathOnly);
136
177
  if (!loaded) {
137
178
  window.location.href = to;
138
179
  return;
139
180
  }
140
- applyLoaded(loaded, to);
181
+
182
+ applyLoaded(loaded, pathOnly);
183
+ if (search !== currentSearch) setQueryVersion((v) => v + 1);
184
+
141
185
  if (scroll) {
142
186
  window.scrollTo(0, 0);
143
187
  }
144
188
  } catch (err) {
145
- console.error("Client navigation failed, falling back to full page load:", err);
189
+ console.error('Client navigation failed, falling back to full page load:', err);
146
190
  window.location.href = to;
147
191
  }
148
192
  },
149
- [pathname, applyLoaded]
193
+ [pathname, applyLoaded],
150
194
  );
195
+
151
196
  useEffect(() => {
152
197
  async function onPopState() {
153
198
  const newPath = window.location.pathname;
199
+
200
+ // Same-path back/forward is a query-only history step (or identical URL);
201
+ // skip the page load and just force re-render of search-param readers.
202
+ if (newPath === pathname) {
203
+ setQueryVersion((v) => v + 1);
204
+ return;
205
+ }
206
+
154
207
  const manifest = manifestRef.current;
208
+
155
209
  if (!manifest) {
156
210
  setPathname(newPath);
157
211
  setPageState((prev) => ({ ...prev, params: {} }));
212
+ setQueryVersion((v) => v + 1);
158
213
  return;
159
214
  }
215
+
160
216
  try {
161
217
  const loaded = await loadPage(manifest, newPath);
162
218
  if (loaded) {
163
219
  applyLoaded(loaded, newPath);
220
+ setQueryVersion((v) => v + 1);
164
221
  } else {
165
222
  window.location.reload();
166
223
  }
@@ -168,60 +225,72 @@ function Router({
168
225
  window.location.reload();
169
226
  }
170
227
  }
171
- window.addEventListener("popstate", onPopState);
172
- return () => window.removeEventListener("popstate", onPopState);
173
- }, [applyLoaded]);
228
+
229
+ window.addEventListener('popstate', onPopState);
230
+ return () => window.removeEventListener('popstate', onPopState);
231
+ }, [pathname, applyLoaded]);
232
+
174
233
  const { component: PageComponent, layouts, loadings, params } = pageState;
234
+
175
235
  let content;
176
236
  if (PageComponent) {
177
- const inner = isNavigating && loadings.length > 0 ? createElement(loadings.at(-1)) : createElement(PageComponent, params);
237
+ const inner =
238
+ isNavigating && loadings.length > 0
239
+ ? createElement(loadings.at(-1))
240
+ : createElement(PageComponent, params);
178
241
  content = wrapInLayouts(inner, layouts);
179
242
  } else {
180
243
  content = children;
181
244
  }
182
- return /* @__PURE__ */ jsxs(
245
+
246
+ const progressBar =
247
+ isNavigating && loadings.length === 0 && !isPending
248
+ ? createElement('div', {
249
+ style: {
250
+ position: 'fixed',
251
+ top: 0,
252
+ left: 0,
253
+ width: '100%',
254
+ height: '2px',
255
+ backgroundColor: '#0070f3',
256
+ zIndex: 99999,
257
+ animation: 'nav-progress 1s ease-in-out infinite',
258
+ },
259
+ })
260
+ : null;
261
+
262
+ return createElement(
183
263
  RouterContext.Provider,
184
264
  {
185
265
  value: {
186
266
  pathname,
187
267
  params,
188
- navigate: navigateToPage
268
+ navigate: navigateToPage,
269
+ queryVersion,
189
270
  },
190
- children: [
191
- content,
192
- isNavigating && loadings.length === 0 && !isPending && /* @__PURE__ */ jsx(
193
- "div",
194
- {
195
- style: {
196
- position: "fixed",
197
- top: 0,
198
- left: 0,
199
- width: "100%",
200
- height: "2px",
201
- backgroundColor: "#0070f3",
202
- zIndex: 99999,
203
- animation: "nav-progress 1s ease-in-out infinite"
204
- }
205
- }
206
- )
207
- ]
208
- }
271
+ },
272
+ content,
273
+ progressBar,
209
274
  );
210
275
  }
211
- function Link({ href, children, onClick, scroll, replace, prefetch = "hover", ...rest }) {
276
+
277
+ function Link({ href, children, onClick, scroll, replace, prefetch = 'hover', ...rest }) {
212
278
  const router = useContext(RouterContext);
213
279
  const linkRef = useRef(null);
280
+
214
281
  const handleMouseEnter = useCallback(() => {
215
- if (prefetch !== "hover") return;
282
+ if (prefetch !== 'hover') return;
216
283
  const manifest = readClientManifest();
217
284
  if (manifest) {
218
285
  prefetchRoute(manifest, href);
219
286
  }
220
287
  }, [href, prefetch]);
288
+
221
289
  useEffect(() => {
222
- if (prefetch !== "viewport") return;
290
+ if (prefetch !== 'viewport') return;
223
291
  const el = linkRef.current;
224
- if (!el || typeof IntersectionObserver === "undefined") return;
292
+ if (!el || typeof IntersectionObserver === 'undefined') return;
293
+
225
294
  const observer = new IntersectionObserver(
226
295
  (entries) => {
227
296
  for (const entry of entries) {
@@ -235,40 +304,42 @@ function Link({ href, children, onClick, scroll, replace, prefetch = "hover", ..
235
304
  }
236
305
  }
237
306
  },
238
- { rootMargin: "200px" }
307
+ { rootMargin: '200px' },
239
308
  );
309
+
240
310
  observer.observe(el);
241
311
  return () => observer.disconnect();
242
312
  }, [href, prefetch]);
313
+
243
314
  function handleClick(e) {
244
315
  if (onClick) onClick(e);
245
316
  if (e.defaultPrevented) return;
246
317
  if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
247
318
  if (e.button !== 0) return;
248
- if (rest.target === "_blank" || rest.download !== void 0) return;
319
+ if (rest.target === '_blank' || rest.download !== undefined) return;
320
+
249
321
  try {
250
322
  const url = new URL(href, window.location.origin);
251
323
  if (url.origin !== window.location.origin) return;
252
324
  } catch {
253
325
  return;
254
326
  }
327
+
255
328
  e.preventDefault();
256
329
  if (router) {
257
330
  router.navigate(href, { scroll, replace });
258
331
  } else {
259
- window.history.pushState(null, "", href);
260
- window.dispatchEvent(new PopStateEvent("popstate"));
332
+ window.history.pushState(null, '', href);
333
+ window.dispatchEvent(new PopStateEvent('popstate'));
261
334
  }
262
335
  }
263
- return /* @__PURE__ */ jsx("a", { ref: linkRef, href, onClick: handleClick, onMouseEnter: handleMouseEnter, ...rest, children });
336
+
337
+ return createElement(
338
+ 'a',
339
+ { ref: linkRef, href, onClick: handleClick, onMouseEnter: handleMouseEnter, ...rest },
340
+ children,
341
+ );
264
342
  }
343
+
265
344
  const SoloRouter = Router;
266
- export {
267
- Link,
268
- Router,
269
- SoloRouter,
270
- useParams,
271
- usePathname,
272
- useRouter,
273
- useSearchParams
274
- };
345
+ export { Link, Router, SoloRouter, useParams, usePathname, useRouter, useSearchParams };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcway",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "A convention-based framework for building modular monoliths with strict domain boundaries.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -29,8 +29,10 @@
29
29
  "./ui/style-mira.css": "./client/ui/style-mira.css"
30
30
  },
31
31
  "scripts": {
32
- "prepare": "esbuild client/router.jsx --outfile=client/router.js --format=esm --jsx=automatic",
33
32
  "test": "vitest run --project=unit",
33
+ "test:watch": "VITEST_MAX_WORKERS=2 vitest --project=unit",
34
+ "test:serial": "VITEST_MAX_WORKERS=1 vitest run --project=unit",
35
+ "test:coverage": "vitest run --project=unit --coverage",
34
36
  "test:storybook": "TMPDIR=~/tmp PLAYWRIGHT_BROWSERS_PATH=~/.cache/playwright vitest run --project=storybook",
35
37
  "test:all": "TMPDIR=~/tmp PLAYWRIGHT_BROWSERS_PATH=~/.cache/playwright vitest run",
36
38
  "format": "prettier --write 'server/**/*.js' 'client/**/*.js' 'tests/**/*.js'",
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import fs from 'node:fs/promises';
3
+ import crypto from 'node:crypto';
3
4
  import {
4
5
  discoverPages,
5
6
  discoverLayouts,
@@ -38,65 +39,111 @@ async function buildPages(options) {
38
39
  if (pages.length === 0) {
39
40
  return { pageCount: 0, outDir, manifest: { entries: [], sharedChunks: [] } };
40
41
  }
41
- await fs.rm(outDir, { recursive: true, force: true });
42
- await fs.mkdir(path.join(outDir, 'server'), { recursive: true });
43
- await fs.mkdir(path.join(outDir, 'client'), { recursive: true });
44
- let fontFaceCss;
45
- let fontPreloadHtml;
46
- if (options.fonts && options.fonts.length > 0) {
47
- const publicDir = path.join(rootDir, 'public');
48
- const fontResult = await resolveFonts(options.fonts, publicDir);
49
- if (fontResult.fontFaceCss) fontFaceCss = fontResult.fontFaceCss;
50
- if (fontResult.preloadHtml) fontPreloadHtml = fontResult.preloadHtml;
51
- }
52
- const builds = [
53
- buildServerBundles(pages, outDir, serverTarget),
54
- buildLayoutServerBundles(layouts, outDir, serverTarget),
55
- buildMiddlewareServerBundles(middlewares, outDir, serverTarget),
56
- buildClientBundles(
42
+ // Build into a uniquely-suffixed staging directory and atomically swap into place
43
+ // only after every step succeeds. If the build fails partway through, the previous
44
+ // `outDir` is left intact so the server keeps serving the old pages instead of
45
+ // falling back to 404s. The random suffix keeps concurrent callers against the same
46
+ // `outDir` (e.g. parallel vitest workers) from racing on the same staging path.
47
+ const buildDir = outDir + '-next-' + crypto.randomBytes(6).toString('hex');
48
+ await fs.mkdir(path.join(buildDir, 'server'), { recursive: true });
49
+ await fs.mkdir(path.join(buildDir, 'client'), { recursive: true });
50
+ const builds = [];
51
+ try {
52
+ let fontFaceCss;
53
+ let fontPreloadHtml;
54
+ if (options.fonts && options.fonts.length > 0) {
55
+ const publicDir = path.join(rootDir, 'public');
56
+ const fontResult = await resolveFonts(options.fonts, publicDir);
57
+ if (fontResult.fontFaceCss) fontFaceCss = fontResult.fontFaceCss;
58
+ if (fontResult.preloadHtml) fontPreloadHtml = fontResult.preloadHtml;
59
+ }
60
+ builds.push(
61
+ buildServerBundles(pages, buildDir, serverTarget),
62
+ buildLayoutServerBundles(layouts, buildDir, serverTarget),
63
+ buildMiddlewareServerBundles(middlewares, buildDir, serverTarget),
64
+ buildClientBundles(
65
+ pages,
66
+ layouts,
67
+ buildDir,
68
+ clientTarget,
69
+ minify,
70
+ rootDir,
71
+ loadings,
72
+ minify ? 'production' : 'development',
73
+ devMode,
74
+ ),
75
+ buildErrorPageBundles(errorPages, buildDir, serverTarget),
76
+ buildCssBundle(pagesDir, buildDir, minify, stylesPath, fontFaceCss),
77
+ );
78
+ if (devMode) builds.push(buildHmrRuntimeBundle(buildDir));
79
+ // Fail fast: `Promise.all` rejects the moment any build throws, so a broken
80
+ // page produces a prompt error instead of waiting for slower sibling builds
81
+ // to finish. Cleanup of the staging dir is scheduled in the catch block.
82
+ const [
83
+ serverResult,
84
+ layoutServerResult,
85
+ middlewareServerResult,
86
+ clientResult,
87
+ errorBundles,
88
+ cssBundle,
89
+ ] = await Promise.all(builds);
90
+ const manifest = generateManifest(
57
91
  pages,
58
92
  layouts,
59
- outDir,
60
- clientTarget,
61
- minify,
62
- rootDir,
63
93
  loadings,
64
- minify ? 'production' : 'development',
65
- devMode,
66
- ),
67
- buildErrorPageBundles(errorPages, outDir, serverTarget),
68
- buildCssBundle(pagesDir, outDir, minify, stylesPath, fontFaceCss),
69
- ];
70
- if (devMode) builds.push(buildHmrRuntimeBundle(outDir));
71
- const [
72
- serverResult,
73
- layoutServerResult,
74
- middlewareServerResult,
75
- clientResult,
76
- errorBundles,
77
- cssBundle,
78
- ] = await Promise.all(builds);
79
- const manifest = generateManifest(
80
- pages,
81
- layouts,
82
- loadings,
83
- middlewares,
84
- serverResult,
85
- layoutServerResult,
86
- middlewareServerResult,
87
- clientResult,
88
- );
89
- if (errorBundles.error) manifest.errorBundle = errorBundles.error;
90
- if (errorBundles.notFound) manifest.notFoundBundle = errorBundles.notFound;
91
- if (cssBundle) manifest.cssBundle = cssBundle;
92
- if (fontPreloadHtml) manifest.fontPreloadHtml = fontPreloadHtml;
93
- await fs.writeFile(path.join(outDir, 'pages-manifest.json'), JSON.stringify(manifest, null, 2));
94
- return {
95
- pageCount: pages.length,
96
- outDir,
97
- manifest,
98
- ...(devMode && clientResult.metafile ? { clientMetafile: clientResult.metafile } : {}),
99
- };
94
+ middlewares,
95
+ serverResult,
96
+ layoutServerResult,
97
+ middlewareServerResult,
98
+ clientResult,
99
+ );
100
+ if (errorBundles.error) manifest.errorBundle = errorBundles.error;
101
+ if (errorBundles.notFound) manifest.notFoundBundle = errorBundles.notFound;
102
+ if (cssBundle) manifest.cssBundle = cssBundle;
103
+ if (fontPreloadHtml) manifest.fontPreloadHtml = fontPreloadHtml;
104
+ await fs.writeFile(
105
+ path.join(buildDir, 'pages-manifest.json'),
106
+ JSON.stringify(manifest, null, 2),
107
+ );
108
+ await fs.rm(outDir, { recursive: true, force: true });
109
+ await fs.rename(buildDir, outDir);
110
+ const clientMetafile =
111
+ devMode && clientResult.metafile
112
+ ? rewriteMetafilePaths(clientResult.metafile, buildDir, outDir)
113
+ : undefined;
114
+ return {
115
+ pageCount: pages.length,
116
+ outDir,
117
+ manifest,
118
+ ...(clientMetafile ? { clientMetafile } : {}),
119
+ };
120
+ } catch (err) {
121
+ // esbuild has no cancellation API, so sibling builds may still be writing
122
+ // into `buildDir` when we land here. Wait for every pending build to settle
123
+ // before removing the directory — otherwise `fs.rm` races the writes and
124
+ // fails with ENOTEMPTY. The cleanup is fire-and-forget so the caller sees
125
+ // the original error immediately; the unique staging-dir suffix means any
126
+ // lingering files can't pollute a subsequent `buildPages` call.
127
+ void Promise.allSettled(builds).then(() =>
128
+ fs.rm(buildDir, { recursive: true, force: true }).catch(() => {}),
129
+ );
130
+ throw err;
131
+ }
132
+ }
133
+ // esbuild's metafile keys reference the outdir it was given. After the staging
134
+ // directory is swapped into place, rewrite those keys to reference the final
135
+ // outDir so HMR diffs across successive builds compare like-for-like paths.
136
+ function rewriteMetafilePaths(metafile, fromDir, toDir) {
137
+ const outputs = {};
138
+ for (const [key, value] of Object.entries(metafile.outputs)) {
139
+ const abs = path.resolve(key);
140
+ const newKey =
141
+ abs === fromDir || abs.startsWith(fromDir + path.sep)
142
+ ? toDir + abs.slice(fromDir.length)
143
+ : abs;
144
+ outputs[newKey] = value;
145
+ }
146
+ return { ...metafile, outputs };
100
147
  }
101
148
  import { patternToFileName, layoutDirToFileName } from './build-server.js';
102
149
  import { generateHydrationEntry } from './build-client.js';
@@ -123,7 +123,12 @@ function diffClientMetafiles(oldMeta, newMeta, outDir) {
123
123
  }
124
124
  }
125
125
  if (newFiles.length === 0) {
126
- return { changedNavBundles: [], changedCssBundle: null, needsFullReload: false };
126
+ return {
127
+ changedNavBundles: [],
128
+ changedHydrationBundles: [],
129
+ changedCssBundle: null,
130
+ needsFullReload: false,
131
+ };
127
132
  }
128
133
  const getEntryNames = (meta) => {
129
134
  const names = new Set();
@@ -149,28 +154,29 @@ function diffClientMetafiles(oldMeta, newMeta, outDir) {
149
154
  }
150
155
  }
151
156
  const changedNavBundles = [];
157
+ const changedHydrationBundles = [];
152
158
  let changedCssBundle = null;
153
159
  for (const f of newFiles) {
154
160
  const rel = path.relative(outDir, f).replace(/\\/g, '/');
155
- if (/^client\/nav_/.test(rel)) {
161
+ if (/^client\/nav_.*\.js$/.test(rel)) {
156
162
  changedNavBundles.push(rel);
157
163
  } else if (rel.endsWith('.css')) {
158
164
  changedCssBundle = rel;
159
- }
160
- }
161
- const hasChangedHydrationBundles = newFiles.some((f) => {
162
- const rel = path.relative(outDir, f).replace(/\\/g, '/');
163
- return (
165
+ } else if (
164
166
  rel.startsWith('client/') &&
165
- !rel.startsWith('client/nav_') &&
166
167
  !rel.startsWith('client/chunks/') &&
167
- !rel.endsWith('.css') &&
168
168
  !rel.startsWith('client/hmr/')
169
- );
170
- });
171
- if (hasChangedHydrationBundles && changedNavBundles.length === 0) {
169
+ ) {
170
+ changedHydrationBundles.push(rel);
171
+ }
172
+ }
173
+ if (
174
+ changedNavBundles.length === 0 &&
175
+ changedHydrationBundles.length === 0 &&
176
+ !changedCssBundle
177
+ ) {
172
178
  needsFullReload = true;
173
179
  }
174
- return { changedNavBundles, changedCssBundle, needsFullReload };
180
+ return { changedNavBundles, changedHydrationBundles, changedCssBundle, needsFullReload };
175
181
  }
176
182
  export { buildHmrRuntimeBundle, buildHmrScript, diffClientMetafiles, reactRefreshPlugin };
@@ -63,7 +63,7 @@ function wrapWithProviders(createElement, element, pathname, params) {
63
63
  }
64
64
  const providerCtx = globalThis[PROVIDER_CTX_KEY];
65
65
  if (providerCtx) {
66
- const apiValue = { pathPrefix: '' };
66
+ const apiValue = { pathPrefix: '/api' };
67
67
  element = createElement(providerCtx.Provider, { value: apiValue }, element);
68
68
  }
69
69
  return element;
@@ -94,7 +94,7 @@ function buildHtmlShell({ headHtml, fontPreloadTags, cssLinkTag, bodysuffix }) {
94
94
  <html>
95
95
  <head>
96
96
  <meta charset="utf-8" />
97
- <meta name="viewport" content="width=device-width, initial-scale=1" />
97
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
98
98
  ${fontPreloadTags}
99
99
  ${cssLinkTag}
100
100
  ${headHtml}
@@ -266,6 +266,7 @@ async function renderErrorPage(
266
266
  export {
267
267
  buildCssLinkTag,
268
268
  buildFontPreloadTags,
269
+ buildHtmlShell,
269
270
  buildLiveReloadScript,
270
271
  buildScriptTags,
271
272
  loadComponent,
@@ -24,19 +24,20 @@ function createPagesWatcher(options) {
24
24
  const elapsed = Date.now() - start;
25
25
  if (handler.emitEvent && prevClientMetafile && result.clientMetafile && !isStructuralChange) {
26
26
  const diff = diffClientMetafiles(prevClientMetafile, result.clientMetafile, outDir);
27
+ const hmrModules = [...diff.changedNavBundles, ...diff.changedHydrationBundles];
27
28
  if (diff.needsFullReload) {
28
29
  handler.emitEvent({ type: 'reload' });
29
30
  log.info(`Pages rebuilt in ${elapsed}ms (full reload)`);
30
- } else if (diff.changedNavBundles.length > 0) {
31
+ } else if (hmrModules.length > 0) {
31
32
  const manifestJson = handler.getClientManifestJson?.();
32
33
  handler.emitEvent({
33
34
  type: 'hmr-update',
34
- modules: diff.changedNavBundles,
35
+ modules: hmrModules,
35
36
  timestamp: Date.now(),
36
37
  ...(manifestJson ? { manifest: manifestJson } : {}),
37
38
  });
38
39
  log.info(
39
- `Pages rebuilt in ${elapsed}ms (HMR: ${diff.changedNavBundles.length} module(s))`,
40
+ `Pages rebuilt in ${elapsed}ms (HMR: ${hmrModules.length} module(s))`,
40
41
  );
41
42
  } else if (diff.changedCssBundle) {
42
43
  handler.emitEvent({
package/client/router.jsx DELETED
@@ -1,303 +0,0 @@
1
- import React from 'react';
2
- import {
3
- createContext,
4
- useContext,
5
- useState,
6
- useEffect,
7
- useCallback,
8
- useMemo,
9
- useRef,
10
- useTransition,
11
- createElement,
12
- } from 'react';
13
- import {
14
- readClientManifest,
15
- loadPage,
16
- matchClientRoute,
17
- loadLoadingComponents,
18
- prefetchRoute,
19
- } from './page-loader.js';
20
- import { parseQuery } from './query.js';
21
-
22
- const ROUTER_CTX_KEY = '__router_context__';
23
- const RouterContext = (globalThis[ROUTER_CTX_KEY] ??= createContext(null));
24
-
25
- function wrapInLayouts(element, layouts) {
26
- for (let i = layouts.length - 1; i >= 0; i--) {
27
- element = createElement(layouts[i], null, element);
28
- }
29
- return element;
30
- }
31
-
32
- function useRouter() {
33
- const ctx = useContext(RouterContext);
34
- if (!ctx) {
35
- throw new Error('useRouter must be used within a <Router> provider');
36
- }
37
-
38
- return useMemo(
39
- () => ({
40
- pathname: ctx.pathname,
41
- params: ctx.params,
42
- query: typeof window !== 'undefined' ? parseQuery(window.location.search) : {},
43
- push: (to, options) => ctx.navigate(to, { ...options, replace: false }),
44
- replace: (to, options) => ctx.navigate(to, { ...options, replace: true }),
45
- back: () => {
46
- if (typeof window !== 'undefined') window.history.back();
47
- },
48
- forward: () => {
49
- if (typeof window !== 'undefined') window.history.forward();
50
- },
51
- refresh: () => {
52
- if (typeof window !== 'undefined') window.location.reload();
53
- },
54
- }),
55
- [ctx.pathname, ctx.params, ctx.navigate],
56
- );
57
- }
58
-
59
- function usePathname() {
60
- return useRouter().pathname;
61
- }
62
-
63
- function useParams() {
64
- return useRouter().params;
65
- }
66
-
67
- function useSearchParams() {
68
- return useRouter().query;
69
- }
70
-
71
- function Router({
72
- initialPath,
73
- initialParams,
74
- initialComponent,
75
- initialLayouts,
76
- initialLoadings,
77
- children,
78
- }) {
79
- const [pathname, setPathname] = useState(
80
- () => initialPath ?? (typeof window !== 'undefined' ? window.location.pathname : '/'),
81
- );
82
- const [pageState, setPageState] = useState({
83
- component: initialComponent ?? null,
84
- layouts: initialLayouts ?? [],
85
- loadings: initialLoadings ?? [],
86
- params: initialParams ?? {},
87
- });
88
- const [isNavigating, setIsNavigating] = useState(false);
89
- const [isPending, startTransition] = useTransition();
90
- const manifestRef = useRef(null);
91
-
92
- useEffect(() => {
93
- manifestRef.current = readClientManifest();
94
- const onManifestUpdate = () => {
95
- manifestRef.current = readClientManifest();
96
- };
97
- window.addEventListener('manifest-update', onManifestUpdate);
98
- return () => window.removeEventListener('manifest-update', onManifestUpdate);
99
- }, []);
100
-
101
- const applyLoaded = useCallback((loaded, newPath) => {
102
- startTransition(() => {
103
- setPathname(newPath);
104
- setPageState({
105
- component: loaded.component,
106
- layouts: loaded.layouts,
107
- loadings: loaded.loadings,
108
- params: loaded.params,
109
- });
110
- setIsNavigating(false);
111
- });
112
- }, []);
113
-
114
- const navigateToPage = useCallback(
115
- async (to, options) => {
116
- const scroll = options?.scroll !== false;
117
- const replace = options?.replace === true;
118
- if (to === pathname) return;
119
-
120
- const manifest = manifestRef.current;
121
- if (!manifest) {
122
- window.location.href = to;
123
- return;
124
- }
125
-
126
- const matched = matchClientRoute(manifest, to);
127
- if (!matched) {
128
- window.location.href = to;
129
- return;
130
- }
131
-
132
- setIsNavigating(true);
133
- if (replace) {
134
- window.history.replaceState(null, '', to);
135
- } else {
136
- window.history.pushState(null, '', to);
137
- }
138
-
139
- const targetLoadings = await loadLoadingComponents(matched.route);
140
- if (targetLoadings.length > 0) {
141
- setPathname(to);
142
- setPageState((prev) => ({
143
- ...prev,
144
- loadings: targetLoadings,
145
- params: matched.params,
146
- }));
147
- }
148
-
149
- try {
150
- const loaded = await loadPage(manifest, to);
151
- if (!loaded) {
152
- window.location.href = to;
153
- return;
154
- }
155
-
156
- applyLoaded(loaded, to);
157
-
158
- if (scroll) {
159
- window.scrollTo(0, 0);
160
- }
161
- } catch (err) {
162
- console.error('Client navigation failed, falling back to full page load:', err);
163
- window.location.href = to;
164
- }
165
- },
166
- [pathname, applyLoaded],
167
- );
168
-
169
- useEffect(() => {
170
- async function onPopState() {
171
- const newPath = window.location.pathname;
172
- const manifest = manifestRef.current;
173
-
174
- if (!manifest) {
175
- setPathname(newPath);
176
- setPageState((prev) => ({ ...prev, params: {} }));
177
- return;
178
- }
179
-
180
- try {
181
- const loaded = await loadPage(manifest, newPath);
182
- if (loaded) {
183
- applyLoaded(loaded, newPath);
184
- } else {
185
- window.location.reload();
186
- }
187
- } catch {
188
- window.location.reload();
189
- }
190
- }
191
-
192
- window.addEventListener('popstate', onPopState);
193
- return () => window.removeEventListener('popstate', onPopState);
194
- }, [applyLoaded]);
195
-
196
- const { component: PageComponent, layouts, loadings, params } = pageState;
197
-
198
- let content;
199
- if (PageComponent) {
200
- const inner = isNavigating && loadings.length > 0
201
- ? createElement(loadings.at(-1))
202
- : createElement(PageComponent, params);
203
- content = wrapInLayouts(inner, layouts);
204
- } else {
205
- content = children;
206
- }
207
-
208
- return (
209
- <RouterContext.Provider
210
- value={{
211
- pathname,
212
- params,
213
- navigate: navigateToPage,
214
- }}
215
- >
216
- {content}
217
- {isNavigating && loadings.length === 0 && !isPending && (
218
- <div
219
- style={{
220
- position: 'fixed',
221
- top: 0,
222
- left: 0,
223
- width: '100%',
224
- height: '2px',
225
- backgroundColor: '#0070f3',
226
- zIndex: 99999,
227
- animation: 'nav-progress 1s ease-in-out infinite',
228
- }}
229
- />
230
- )}
231
- </RouterContext.Provider>
232
- );
233
- }
234
-
235
- function Link({ href, children, onClick, scroll, replace, prefetch = 'hover', ...rest }) {
236
- const router = useContext(RouterContext);
237
- const linkRef = useRef(null);
238
-
239
- const handleMouseEnter = useCallback(() => {
240
- if (prefetch !== 'hover') return;
241
- const manifest = readClientManifest();
242
- if (manifest) {
243
- prefetchRoute(manifest, href);
244
- }
245
- }, [href, prefetch]);
246
-
247
- useEffect(() => {
248
- if (prefetch !== 'viewport') return;
249
- const el = linkRef.current;
250
- if (!el || typeof IntersectionObserver === 'undefined') return;
251
-
252
- const observer = new IntersectionObserver(
253
- (entries) => {
254
- for (const entry of entries) {
255
- if (entry.isIntersecting) {
256
- const manifest = readClientManifest();
257
- if (manifest) {
258
- prefetchRoute(manifest, href);
259
- }
260
- observer.unobserve(el);
261
- break;
262
- }
263
- }
264
- },
265
- { rootMargin: '200px' },
266
- );
267
-
268
- observer.observe(el);
269
- return () => observer.disconnect();
270
- }, [href, prefetch]);
271
-
272
- function handleClick(e) {
273
- if (onClick) onClick(e);
274
- if (e.defaultPrevented) return;
275
- if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
276
- if (e.button !== 0) return;
277
- if (rest.target === '_blank' || rest.download !== undefined) return;
278
-
279
- try {
280
- const url = new URL(href, window.location.origin);
281
- if (url.origin !== window.location.origin) return;
282
- } catch {
283
- return;
284
- }
285
-
286
- e.preventDefault();
287
- if (router) {
288
- router.navigate(href, { scroll, replace });
289
- } else {
290
- window.history.pushState(null, '', href);
291
- window.dispatchEvent(new PopStateEvent('popstate'));
292
- }
293
- }
294
-
295
- return (
296
- <a ref={linkRef} href={href} onClick={handleClick} onMouseEnter={handleMouseEnter} {...rest}>
297
- {children}
298
- </a>
299
- );
300
- }
301
-
302
- const SoloRouter = Router;
303
- export { Link, Router, SoloRouter, useParams, usePathname, useRouter, useSearchParams };