arcway 0.1.12 → 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
@@ -51,7 +51,9 @@ function useRouter() {
51
51
  if (typeof window !== 'undefined') window.location.reload();
52
52
  },
53
53
  }),
54
- [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],
55
57
  );
56
58
  }
57
59
 
@@ -86,6 +88,10 @@ function Router({
86
88
  });
87
89
  const [isNavigating, setIsNavigating] = useState(false);
88
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);
89
95
  const manifestRef = useRef(null);
90
96
 
91
97
  useEffect(() => {
@@ -114,7 +120,28 @@ function Router({
114
120
  async (to, options) => {
115
121
  const scroll = options?.scroll !== false;
116
122
  const replace = options?.replace === true;
117
- 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
+ }
118
145
 
119
146
  const manifest = manifestRef.current;
120
147
  if (!manifest) {
@@ -122,7 +149,7 @@ function Router({
122
149
  return;
123
150
  }
124
151
 
125
- const matched = matchClientRoute(manifest, to);
152
+ const matched = matchClientRoute(manifest, pathOnly);
126
153
  if (!matched) {
127
154
  window.location.href = to;
128
155
  return;
@@ -137,7 +164,7 @@ function Router({
137
164
 
138
165
  const targetLoadings = await loadLoadingComponents(matched.route);
139
166
  if (targetLoadings.length > 0) {
140
- setPathname(to);
167
+ setPathname(pathOnly);
141
168
  setPageState((prev) => ({
142
169
  ...prev,
143
170
  loadings: targetLoadings,
@@ -146,13 +173,14 @@ function Router({
146
173
  }
147
174
 
148
175
  try {
149
- const loaded = await loadPage(manifest, to);
176
+ const loaded = await loadPage(manifest, pathOnly);
150
177
  if (!loaded) {
151
178
  window.location.href = to;
152
179
  return;
153
180
  }
154
181
 
155
- applyLoaded(loaded, to);
182
+ applyLoaded(loaded, pathOnly);
183
+ if (search !== currentSearch) setQueryVersion((v) => v + 1);
156
184
 
157
185
  if (scroll) {
158
186
  window.scrollTo(0, 0);
@@ -168,11 +196,20 @@ function Router({
168
196
  useEffect(() => {
169
197
  async function onPopState() {
170
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
+
171
207
  const manifest = manifestRef.current;
172
208
 
173
209
  if (!manifest) {
174
210
  setPathname(newPath);
175
211
  setPageState((prev) => ({ ...prev, params: {} }));
212
+ setQueryVersion((v) => v + 1);
176
213
  return;
177
214
  }
178
215
 
@@ -180,6 +217,7 @@ function Router({
180
217
  const loaded = await loadPage(manifest, newPath);
181
218
  if (loaded) {
182
219
  applyLoaded(loaded, newPath);
220
+ setQueryVersion((v) => v + 1);
183
221
  } else {
184
222
  window.location.reload();
185
223
  }
@@ -190,7 +228,7 @@ function Router({
190
228
 
191
229
  window.addEventListener('popstate', onPopState);
192
230
  return () => window.removeEventListener('popstate', onPopState);
193
- }, [applyLoaded]);
231
+ }, [pathname, applyLoaded]);
194
232
 
195
233
  const { component: PageComponent, layouts, loadings, params } = pageState;
196
234
 
@@ -228,6 +266,7 @@ function Router({
228
266
  pathname,
229
267
  params,
230
268
  navigate: navigateToPage,
269
+ queryVersion,
231
270
  },
232
271
  },
233
272
  content,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcway",
3
- "version": "0.1.12",
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",
@@ -30,6 +30,9 @@
30
30
  },
31
31
  "scripts": {
32
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",
33
36
  "test:storybook": "TMPDIR=~/tmp PLAYWRIGHT_BROWSERS_PATH=~/.cache/playwright vitest run --project=storybook",
34
37
  "test:all": "TMPDIR=~/tmp PLAYWRIGHT_BROWSERS_PATH=~/.cache/playwright vitest run",
35
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({