@jk2908/solas 0.11.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.0
4
+
5
+ ### Features
6
+
7
+ - Client navigation now scrolls to the top of a route on push, scrolls to a hash target when the URL has one, and restores the previous position on back and forward. `router.go(...)` and `Link` accept a `scroll` option (`'top'`, `'none'`, or `{ restore: y }`) to control this.
8
+
9
+ ### Fixes
10
+
11
+ - Endpoint handlers with default or rest parameters now receive the request. Previously Solas passed it only when `Function.length` was non-zero.
12
+ - Request handling no longer resolves the base path and trailing slash twice per request: the dispatch layer passes its already-normalised path to the router.
13
+
14
+ ### Changed
15
+
16
+ - The route finder caches import paths, and the dev watcher ignores `dist`, so large route graphs and long dev sessions do less repeated work.
17
+
18
+ ## 0.12.0
19
+
20
+ ### Features
21
+
22
+ - The static adapter supports client-side navigation. The build emits each fully prerendered route's RSC payload as `<route>.rsc.txt` next to its HTML, and the browser router fetches it, so a static host needs no server to answer navigations.
23
+ - The active adapter's name is exposed as `import.meta.env.SOLAS_ADAPTER`, so apps can branch on the platform.
24
+
3
25
  ## 0.11.1
4
26
 
5
27
  ### Fixes
package/README.md CHANGED
@@ -213,6 +213,18 @@ That keeps your route params aligned across links, imperative navigation, metada
213
213
 
214
214
  `router.refresh()` clears the cached RSC response for the current path and fetches a fresh payload, so it is most useful for routes that render request-time data. `router.isNavigating` exposes pending client-side navigation state.
215
215
 
216
+ Navigation manages scroll for you: `Link` and `router.go(...)` scroll to the top of the new route, or to the target element when the URL has a hash. Back and forward restore the position each history entry was left at. Override this with the `scroll` option:
217
+
218
+ ```tsx
219
+ await router.go('/search', { query: { q }, scroll: 'none' })
220
+
221
+ <Link href="/results" scroll="none">
222
+ Keep position
223
+ </Link>
224
+ ```
225
+
226
+ `scroll` accepts `'top'` (the default), `'none'` to leave the position untouched, or `{ restore: y }` to restore a specific offset.
227
+
216
228
  ## Config
217
229
 
218
230
  All Solas options are passed to `solas()` inside `defineConfig`.
@@ -229,6 +241,8 @@ Solas resolves the origin in this order:
229
241
 
230
242
  The resolved origin is exposed to your app as `import.meta.env.VITE_APP_URL` and is used for build-time prerender requests and `sitemap` URLs. When `url` is set, it is also used as a trusted origin for CSRF.
231
243
 
244
+ The active adapter's name is exposed as `import.meta.env.SOLAS_ADAPTER`, so your app can branch on the platform it is deployed to.
245
+
232
246
  You usually only need `url` in production, where the Vite dev server origin is meaningless. For example, to generate a `sitemap.xml` with real URLs:
233
247
 
234
248
  ```ts
@@ -538,7 +552,7 @@ Valid values are `debug`, `info`, `warn`, `error`, and `fatal`.
538
552
  - `error`: only errors
539
553
  - `fatal`: only fatal errors
540
554
 
541
- This is mainly useful when debugging framework behaviour such as routing and prerendering. It is for Solas internals, not your app's general-purpose logging, and it does not control top-level CLI status output such as build and preview progress messages.
555
+ This is mainly useful when debugging framework behaviour such as routing and prerendering. It is for Solas internals, not your app's general-purpose logging, and it does not control top-level build and preview status output.
542
556
 
543
557
  ```ts
544
558
  export default defineConfig({
@@ -707,7 +721,9 @@ export default defineConfig({
707
721
  })
708
722
  ```
709
723
 
710
- `vite build` writes the site to `build/` (override with `outDir`): the client assets, each route as `<route>.html` (or `<route>/index.html` under `trailingSlash: 'always'`), plus `sitemap.xml` and `robots.txt`. Serve that directory with anything — `npx serve build`, GitHub Pages, Netlify, an S3 bucket, nginx.
724
+ `vite build` writes the site to `build/` (override with `outDir`): the client assets, each route as `<route>.html` (or `<route>/index.html` under `trailingSlash: 'always'`), its RSC payload as `<route>.rsc.txt`, plus `sitemap.xml` and `robots.txt`. Serve that directory with anything — `npx serve build`, GitHub Pages, Netlify, an S3 bucket, nginx.
725
+
726
+ Client-side navigation works with no server: the browser router fetches each route's `<route>.rsc.txt` payload instead of asking a server for `text/x-component`.
711
727
 
712
728
  Because it forces full prerendering, routes that need a server are not a fit: per-route PPR or SSR opt-ins and dynamic routes without `params()`.
713
729
 
package/dist/index.js CHANGED
@@ -249,6 +249,20 @@ function solas(c) {
249
249
  viteConfig.publicDir ??= Config.PUBLIC_DIR;
250
250
  viteConfig.server ??= {};
251
251
  viteConfig.server.port ??= 8787;
252
+ // build output can be large and is rewritten during a session; keep it
253
+ // out of the watcher so route-change filtering does not process it.
254
+ // `.solas` stays watched on purpose: Vite needs those generated-file
255
+ // events to reload the route graph during dev
256
+ viteConfig.server.watch ??= {};
257
+ const watchIgnored = viteConfig.server.watch.ignored;
258
+ viteConfig.server.watch.ignored = [
259
+ ...(Array.isArray(watchIgnored)
260
+ ? watchIgnored
261
+ : watchIgnored
262
+ ? [watchIgnored]
263
+ : []),
264
+ `**/${Config.OUT_DIR}/**`,
265
+ ];
252
266
  // derive the public url from vite's dev server settings when no
253
267
  // origin was set explicitly via the url option or VITE_APP_URL
254
268
  if (resolvedUrl === undefined) {
@@ -261,6 +275,7 @@ function solas(c) {
261
275
  viteConfig.define ??= {};
262
276
  viteConfig.define['import.meta.env.VITE_APP_URL'] = JSON.stringify(resolvedUrl);
263
277
  viteConfig.define['import.meta.env.SOLAS_VERSION'] = JSON.stringify(pkg.version);
278
+ viteConfig.define['import.meta.env.SOLAS_ADAPTER'] = JSON.stringify(config.adapter?.name ?? '');
264
279
  viteConfig.optimizeDeps ??= {};
265
280
  viteConfig.optimizeDeps.exclude = [
266
281
  ...new Set([
@@ -335,6 +350,7 @@ function solas(c) {
335
350
  precompress: config.precompress,
336
351
  trailingSlash: config.trailingSlash,
337
352
  url: resolvedUrl,
353
+ adapter: config.adapter?.name ?? '',
338
354
  }));
339
355
  },
340
356
  buildApp: {
@@ -28,13 +28,17 @@ export declare class BrowserRouterHistory {
28
28
  */
29
29
  get current(): BrowserRouter.HistoryEntry;
30
30
  /**
31
- * Pushes a new history entry and advances the index.
31
+ * Push a new history entry and advances the index.
32
32
  */
33
33
  pushState(path: string): BrowserRouter.HistoryEntry;
34
34
  /**
35
35
  * Replaces the current history entry without changing length.
36
36
  */
37
37
  replaceState(path: string): BrowserRouter.HistoryEntry;
38
+ /**
39
+ * Store the current window scroll position on the active entry.
40
+ */
41
+ saveScroll(): void;
38
42
  /**
39
43
  * Applies relative history movement and updates local index.
40
44
  */
@@ -11,6 +11,7 @@ export class BrowserRouterHistory {
11
11
  pathname: url.pathname,
12
12
  search: url.search ?? '',
13
13
  hash: url.hash ?? '',
14
+ scrollY: 0,
14
15
  };
15
16
  this.#entries = [entry];
16
17
  this.#index = 0;
@@ -40,9 +41,12 @@ export class BrowserRouterHistory {
40
41
  return this.#entries[this.#index] ?? null;
41
42
  }
42
43
  /**
43
- * Pushes a new history entry and advances the index.
44
+ * Push a new history entry and advances the index.
44
45
  */
45
46
  pushState(path) {
47
+ // remember where the user was before leaving the current entry so a
48
+ // later back navigation can restore it
49
+ this.saveScroll();
46
50
  if (this.#index < this.#entries.length - 1) {
47
51
  // drop forward entries when pushing from the middle
48
52
  this.#entries = this.#entries.slice(0, this.#index + 1);
@@ -52,6 +56,7 @@ export class BrowserRouterHistory {
52
56
  pathname: window.location.pathname,
53
57
  search: window.location.search,
54
58
  hash: window.location.hash,
59
+ scrollY: 0,
55
60
  };
56
61
  this.#entries.push(entry);
57
62
  this.#index++;
@@ -66,6 +71,8 @@ export class BrowserRouterHistory {
66
71
  pathname: window.location.pathname,
67
72
  search: window.location.search,
68
73
  hash: window.location.hash,
74
+ // replacing the url does not move the page, so keep the position
75
+ scrollY: this.#entries[this.#index]?.scrollY ?? 0,
69
76
  };
70
77
  if (this.#entries.length === 0) {
71
78
  this.#entries = [entry];
@@ -75,6 +82,15 @@ export class BrowserRouterHistory {
75
82
  this.#entries[this.#index] = entry;
76
83
  return entry;
77
84
  }
85
+ /**
86
+ * Store the current window scroll position on the active entry.
87
+ */
88
+ saveScroll() {
89
+ const current = this.#entries[this.#index];
90
+ if (!current)
91
+ return;
92
+ current.scrollY = window.scrollY;
93
+ }
78
94
  /**
79
95
  * Applies relative history movement and updates local index.
80
96
  */
@@ -82,6 +98,8 @@ export class BrowserRouterHistory {
82
98
  if (!Number.isInteger(delta) || delta === 0) {
83
99
  return this.current;
84
100
  }
101
+ // capture the outgoing position before the browser moves
102
+ this.saveScroll();
85
103
  const nextIndex = Math.max(0, Math.min(this.#index + delta, this.#entries.length - 1));
86
104
  if (nextIndex !== this.#index)
87
105
  this.#index = nextIndex;
@@ -96,31 +114,30 @@ export class BrowserRouterHistory {
96
114
  pathname: window.location.pathname,
97
115
  search: window.location.search,
98
116
  hash: window.location.hash,
117
+ // unknown entries start at the top
118
+ scrollY: 0,
99
119
  };
100
120
  const prev = this.#entries[this.#index - 1];
101
121
  const next = this.#entries[this.#index + 1];
102
- if (prev &&
103
- prev.pathname === current.pathname &&
104
- prev.search === current.search &&
105
- prev.hash === current.hash) {
122
+ if (prev && matches(prev, current)) {
106
123
  this.#index--;
124
+ // restore the position this entry had when it was left
125
+ current.scrollY = prev.scrollY;
107
126
  this.#entries[this.#index] = current;
108
127
  return current;
109
128
  }
110
- if (next &&
111
- next.pathname === current.pathname &&
112
- next.search === current.search &&
113
- next.hash === current.hash) {
129
+ if (next && matches(next, current)) {
114
130
  this.#index++;
131
+ current.scrollY = next.scrollY;
115
132
  this.#entries[this.#index] = current;
116
133
  return current;
117
134
  }
118
135
  // fallback: locate an existing matching entry
119
- const index = this.#entries.findIndex(entry => entry.pathname === current.pathname &&
120
- entry.search === current.search &&
121
- entry.hash === current.hash);
136
+ const index = this.#entries.findIndex(entry => matches(entry, current));
122
137
  if (index >= 0) {
138
+ const entry = this.#entries[index];
123
139
  this.#index = index;
140
+ current.scrollY = entry?.scrollY ?? 0;
124
141
  this.#entries[index] = current;
125
142
  return current;
126
143
  }
@@ -129,3 +146,9 @@ export class BrowserRouterHistory {
129
146
  return current;
130
147
  }
131
148
  }
149
+ /**
150
+ * Whether two history entries point at the same location.
151
+ */
152
+ function matches(a, b) {
153
+ return a.pathname === b.pathname && a.search === b.search && a.hash === b.hash;
154
+ }
@@ -13,7 +13,7 @@ function guard(path, prefetcher) {
13
13
  return;
14
14
  prefetcher(path);
15
15
  }
16
- export function Link({ children, href, params, prefetch = 'none', query, ...rest }) {
16
+ export function Link({ children, href, params, prefetch = 'none', query, scroll, ...rest }) {
17
17
  const { go, prefetch: prefetcher } = useRouter();
18
18
  const timer = useRef(null);
19
19
  // track whether the link is meant to be handled by the router, to avoid
@@ -46,7 +46,7 @@ export function Link({ children, href, params, prefetch = 'none', query, ...rest
46
46
  if (handled.current)
47
47
  return;
48
48
  e.preventDefault();
49
- go(href, { params, query });
49
+ go(href, { params, query, scroll });
50
50
  }, onFocus: e => {
51
51
  rest.onFocus?.(e);
52
52
  if (e.defaultPrevented)
@@ -28,6 +28,81 @@ const DEFAULT_GO_CONFIG = {
28
28
  };
29
29
  const logger = new Logger();
30
30
  const responseCache = new ResponseCache();
31
+ const STATIC = import.meta.env.SOLAS_ADAPTER === 'static';
32
+ /**
33
+ * The URL to fetch the response for a navigation. On a static host that is the
34
+ * pre-generated RSC payload; otherwise the path itself carries the RSC response
35
+ * for an `Accept: text/x-component` request.
36
+ */
37
+ function getPayloadUrl(path) {
38
+ const url = new URL(path, window.location.origin);
39
+ url.pathname = url.pathname.endsWith('/')
40
+ ? `${url.pathname}index.rsc.txt`
41
+ : `${url.pathname}.rsc.txt`;
42
+ url.search = '';
43
+ return url;
44
+ }
45
+ function performNavigationFetch(path, init) {
46
+ return STATIC
47
+ ? fetch(getPayloadUrl(path), init)
48
+ : fetch(path, { ...init, headers: { ...init?.headers, accept: 'text/x-component' } });
49
+ }
50
+ const MAX_HASH_ATTEMPTS = 20;
51
+ function nextFrame(callback) {
52
+ if (typeof requestAnimationFrame === 'function') {
53
+ requestAnimationFrame(callback);
54
+ return;
55
+ }
56
+ setTimeout(callback, 0);
57
+ }
58
+ /**
59
+ * Scroll to the element a hash points at, retrying for a few frames because the
60
+ * incoming tree may still be committing when the navigation resolves.
61
+ */
62
+ function scrollToHash(hash, attempts = MAX_HASH_ATTEMPTS) {
63
+ let id;
64
+ try {
65
+ id = decodeURIComponent(hash.replace(/^#/, ''));
66
+ }
67
+ catch {
68
+ id = hash.replace(/^#/, '');
69
+ }
70
+ if (!id) {
71
+ window.scrollTo(0, 0);
72
+ return;
73
+ }
74
+ const element = document.getElementById(id) ?? document.getElementsByName(id)[0] ?? null;
75
+ if (element) {
76
+ element.scrollIntoView();
77
+ return;
78
+ }
79
+ if (attempts <= 0)
80
+ return;
81
+ nextFrame(() => scrollToHash(hash, attempts - 1));
82
+ }
83
+ /**
84
+ * Apply the scroll position for a completed navigation. Push navigations go to
85
+ * the top (or the target hash), back/forward restore the saved position, and
86
+ * refreshes leave the page where it is.
87
+ */
88
+ function applyScroll(intent, hash) {
89
+ if (typeof window === 'undefined')
90
+ return;
91
+ if (intent === 'none')
92
+ return;
93
+ if (typeof window.scrollTo !== 'function')
94
+ return;
95
+ if (typeof intent === 'object') {
96
+ // wait for the restored tree to commit so it has its full height
97
+ nextFrame(() => nextFrame(() => window.scrollTo(0, intent.restore)));
98
+ return;
99
+ }
100
+ if (hash) {
101
+ scrollToHash(hash);
102
+ return;
103
+ }
104
+ window.scrollTo(0, 0);
105
+ }
31
106
  export function BrowserRouterProvider({ children, setPayload, isNavigating = false, url, }) {
32
107
  const id = useRef(0);
33
108
  const controller = useRef(null);
@@ -41,6 +116,9 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
41
116
  * @returns The final path navigated to after any redirects, or the original path if navigation failed.
42
117
  */
43
118
  const go = useCallback(async (to, opts = {}) => {
119
+ // capture before the first await so a mutated options object cannot
120
+ // change how this navigation scrolls
121
+ const scrollIntent = opts.scroll ?? 'top';
44
122
  id.current += 1;
45
123
  const navigationId = id.current;
46
124
  const currentPath = window.location.pathname + window.location.search;
@@ -62,6 +140,7 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
62
140
  throw new Error('[router.go]: external URLs are not supported. Use <a> instead');
63
141
  }
64
142
  const url = new URL(target, window.location.origin);
143
+ const hash = url.hash;
65
144
  const key = ResponseCache.toCacheKey(url.toString(), window.location.origin);
66
145
  if (!key)
67
146
  throw new Error('Invalid navigation url');
@@ -79,10 +158,7 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
79
158
  if (!promise) {
80
159
  const ctrl = new AbortController();
81
160
  controller.current = ctrl;
82
- promise = fetch(path, {
83
- headers: { accept: 'text/x-component' },
84
- signal: ctrl.signal,
85
- });
161
+ promise = performNavigationFetch(path, { signal: ctrl.signal });
86
162
  responseCache.set(path, promise);
87
163
  }
88
164
  if (navigationId !== id.current)
@@ -91,13 +167,18 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
91
167
  promise,
92
168
  createFromFetch(promise),
93
169
  ]);
94
- const resolvedPath = ResponseCache.toCacheKey(res.url, window.location.origin) ?? path;
170
+ // on a static host the payload comes from `<path>.rsc.txt`, so the
171
+ // response URL is not the page path
172
+ const resolvedPath = STATIC
173
+ ? path
174
+ : (ResponseCache.toCacheKey(res.url, window.location.origin) ?? path);
95
175
  if (navigationId !== id.current)
96
176
  return resolvedPath;
97
177
  if (resolvedPath !== path) {
98
178
  history.current.replaceState(resolvedPath);
99
179
  }
100
180
  setPayload?.(payload);
181
+ applyScroll(scrollIntent, hash);
101
182
  window.dispatchEvent(new CustomEvent(Events.names.NAVIGATION, {
102
183
  detail: { path: resolvedPath },
103
184
  }));
@@ -136,7 +217,7 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
136
217
  return;
137
218
  if (responseCache.has(key))
138
219
  return;
139
- responseCache.set(key, fetch(key, { headers: { Accept: 'text/x-component' } }));
220
+ responseCache.set(key, performNavigationFetch(key));
140
221
  }, []);
141
222
  /**
142
223
  * Refreshes the current page by re-fetching the RSC response for the current path and updating the
@@ -150,16 +231,17 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
150
231
  return Promise.resolve(currentPath);
151
232
  if (responseCache.has(key))
152
233
  responseCache.remove(key);
153
- return go(currentPath, {
154
- replace: true,
155
- });
234
+ // a refresh stays exactly where the user is
235
+ return go(currentPath, { replace: true, scroll: 'none' });
156
236
  }, [go]);
157
237
  useEffect(() => {
158
238
  const h = history.current;
159
239
  function handler() {
160
- h.onPopState();
240
+ const entry = h.onPopState();
241
+ // back/forward restores the position the entry was left at
161
242
  void go(BrowserRouter.toTarget(window.location.pathname + window.location.search), {
162
243
  replace: true,
244
+ scroll: { restore: entry?.scrollY ?? 0 },
163
245
  });
164
246
  }
165
247
  window.addEventListener('popstate', handler);
@@ -169,6 +251,31 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
169
251
  window.removeEventListener('popstate', handler);
170
252
  };
171
253
  }, [go]);
254
+ // take over scroll restoration so back/forward can restore saved positions
255
+ useEffect(() => {
256
+ // remember the browser's own setting so an unmount can hand it back
257
+ const previous = 'scrollRestoration' in window.history ? window.history.scrollRestoration : undefined;
258
+ if (previous !== undefined) {
259
+ window.history.scrollRestoration = 'manual';
260
+ }
261
+ let pending = false;
262
+ function onScroll() {
263
+ if (pending)
264
+ return;
265
+ pending = true;
266
+ nextFrame(() => {
267
+ pending = false;
268
+ history.current.saveScroll();
269
+ });
270
+ }
271
+ window.addEventListener('scroll', onScroll, { passive: true });
272
+ return () => {
273
+ window.removeEventListener('scroll', onScroll);
274
+ if (previous !== undefined) {
275
+ window.history.scrollRestoration = previous;
276
+ }
277
+ };
278
+ }, []);
172
279
  const value = useMemo(() => ({
173
280
  go,
174
281
  prefetch,
@@ -5,10 +5,25 @@ export type Path = keyof Solas.Routes & string;
5
5
  type Replace = {
6
6
  replace?: boolean;
7
7
  };
8
+ /**
9
+ * Where to position the page after a navigation.
10
+ *
11
+ * - `'top'`: scroll to the target hash when the URL has one, otherwise the top.
12
+ * - `'none'`: leave the scroll position untouched.
13
+ * - `{ restore: number }`: restore a specific offset. Used by the router for
14
+ * back and forward.
15
+ */
16
+ export type ScrollIntent = 'top' | 'none' | {
17
+ restore: number;
18
+ };
8
19
  export type GoOptions = {
9
20
  replace?: boolean;
10
21
  query?: Query;
11
22
  params?: Params;
23
+ /**
24
+ * Where to position the page after navigating. Defaults to `'top'`.
25
+ */
26
+ scroll?: ScrollIntent;
12
27
  };
13
28
  export type Context = {
14
29
  go: Go;
@@ -29,6 +44,7 @@ export type HistoryEntry = {
29
44
  pathname: string;
30
45
  search: string;
31
46
  hash: string;
47
+ scrollY: number;
32
48
  };
33
49
  /**
34
50
  * These targets are used as-is. They are not matched against the route table,
@@ -97,6 +113,7 @@ export type Target = ExternalTarget | TargetSuffix<ResolvedPath<Path>>;
97
113
  type TargetConfig = {
98
114
  params?: never;
99
115
  query?: Query;
116
+ scroll?: ScrollIntent;
100
117
  };
101
118
  /**
102
119
  * Extra options for callers who pass a route pattern and params separately.
@@ -119,6 +136,7 @@ type TargetConfig = {
119
136
  */
120
137
  type PatternConfig<TPath extends Path> = {
121
138
  query?: Query;
139
+ scroll?: ScrollIntent;
122
140
  } & (Solas.Routes[TPath] extends {
123
141
  params: infer TParams extends Params;
124
142
  } ? {
@@ -104,6 +104,7 @@ function buildSegment(dir, page, chains) {
104
104
  export class Finder {
105
105
  buildContext;
106
106
  config;
107
+ static #importPaths = new Map();
107
108
  constructor(buildContext, config) {
108
109
  this.buildContext = buildContext;
109
110
  this.config = config;
@@ -143,11 +144,19 @@ export class Finder {
143
144
  */
144
145
  static getImportPath(file) {
145
146
  const cwd = process.cwd();
147
+ const key = `${cwd}\0${file}`;
148
+ const cached = Finder.#importPaths.get(key);
149
+ if (cached !== undefined)
150
+ return cached;
146
151
  const generatedDir = path.join(cwd, Config.GENERATED_DIR);
147
- return path
152
+ const result = path
148
153
  .relative(generatedDir, path.resolve(cwd, file))
149
154
  .replace(/\\/g, '/')
150
155
  .replace(/\.(t|j)sx?$/, '');
156
+ // shared shells, layouts and boundaries are resolved once per segment, so
157
+ // cache the result for the rest of the build
158
+ Finder.#importPaths.set(key, result);
159
+ return result;
151
160
  }
152
161
  /**
153
162
  * Entry point: scan the app directory then process the result
@@ -6,7 +6,7 @@ import * as Config from '../../config.js';
6
6
  import * as RuntimeManifest from '../../manifest.js';
7
7
  import { createHttpRouter } from '../http-router/create-http-router.js';
8
8
  import { HttpRouter } from '../http-router/router.js';
9
- import { isRscRequest, normaliseTrailingSlash } from '../http-router/utils.js';
9
+ import { isRscRequest, normaliseTrailingSlash, resolveRequestPath, } from '../http-router/utils.js';
10
10
  import { Metadata } from '../metadata.js';
11
11
  import { HttpException, isHttpException, toHttpException, toHttpExceptionLike, } from '../navigation/http-exception.js';
12
12
  import { isRedirect, toRedirect } from '../navigation/redirect.js';
@@ -230,8 +230,8 @@ export function createHandler(config, manifest, importMap, options = {}) {
230
230
  req[Config.REQUEST_META_KEY].error = undefined;
231
231
  }
232
232
  }
233
- // prerender artifact requests bypass the normal document path so the cli
234
- // gets structured JSON instead of a rendered html response
233
+ // prerender artifact requests bypass the normal document path so the
234
+ // prerenderer gets structured JSON instead of a rendered html response
235
235
  if (req.headers.get(`x-${Config.SLUG}-prerender`) === '1' &&
236
236
  req.headers.get(`x-${Config.SLUG}-prerender-artifact`) === '1') {
237
237
  try {
@@ -391,16 +391,9 @@ export function createHandler(config, manifest, importMap, options = {}) {
391
391
  // fast path
392
392
  if (method !== 'GET' && method !== 'HEAD')
393
393
  return httpRouter.fetch(req);
394
- const routedPath = stripBasePath(url.pathname, BASE_PATH);
395
- const canonicalPath = routedPath == null
396
- ? null
397
- : config.trailingSlash === 'ignore'
398
- ? routedPath
399
- : normaliseTrailingSlash(routedPath, config.trailingSlash);
400
- const canonicalPathname = canonicalPath == null ? null : applyBasePath(canonicalPath, BASE_PATH);
394
+ const { routedPath, path: canonicalPath, pathname: canonicalPathname, } = resolveRequestPath(url.pathname, BASE_PATH, config.trailingSlash);
401
395
  if (canonicalPathname != null &&
402
396
  (method === 'GET' || method === 'HEAD') &&
403
- config.trailingSlash !== 'ignore' &&
404
397
  canonicalPathname !== url.pathname) {
405
398
  url.pathname = canonicalPathname;
406
399
  return Response.redirect(url.toString(), 308);
@@ -426,7 +419,10 @@ export function createHandler(config, manifest, importMap, options = {}) {
426
419
  return asset;
427
420
  return HttpRouter.serveStatic(assets, asset, req, config.precompress);
428
421
  }
429
- return httpRouter.fetch(req);
422
+ // the path is already canonical here: a mismatched shape redirected above,
423
+ // so hand the resolved path to the router rather than letting it recompute
424
+ // the base path and trailing slash a second time
425
+ return httpRouter.fetch(req, canonicalPath);
430
426
  }
431
427
  // vite-plugin-rsc entrypoint
432
428
  return {
@@ -1,9 +1,9 @@
1
1
  import { HttpRouter } from './router.js';
2
2
  import { isRscRequest } from './utils.js';
3
3
  function callEndpoint(fn, req) {
4
- // endpoint modules may export either a zero-arg handler or one that expects the request
5
- if (fn.length === 0)
6
- return fn();
4
+ // always pass the request; handlers that do not need it simply ignore the
5
+ // argument. relying on `fn.length` silently dropped the request for
6
+ // default/rest parameters and compiled handlers
7
7
  return fn(req);
8
8
  }
9
9
  function createHandlerGroups(manifest) {
@@ -62,7 +62,7 @@ export declare class HttpRouter {
62
62
  /**
63
63
  * Handle an incoming request.
64
64
  */
65
- fetch(req: Request): Promise<Response>;
65
+ fetch(req: Request, resolvedPath?: string | null): Promise<Response>;
66
66
  /**
67
67
  * Serve a file with optional compression content negotiation.
68
68
  */
@@ -1,10 +1,10 @@
1
1
  import { match as createMatch } from 'path-to-regexp';
2
- import { applyBasePath, normaliseBasePath, stripBasePath } from '../../utils/base-path.js';
2
+ import { normaliseBasePath } from '../../utils/base-path.js';
3
3
  import * as Config from '../../config.js';
4
4
  import { HttpException } from '../navigation/http-exception.js';
5
5
  import { maybeAction } from '../server/actions.js';
6
6
  import { enforce } from '../server/csrf.js';
7
- import { getAlternatePathname, normaliseTrailingSlash, toPathPattern } from './utils.js';
7
+ import { getAlternatePathname, normaliseTrailingSlash, resolveRequestPath, toPathPattern, } from './utils.js';
8
8
  const BASE_PATH = normaliseBasePath(import.meta.env.BASE_URL);
9
9
  /**
10
10
  * Handle routing and matching for server requests.
@@ -162,20 +162,18 @@ export class HttpRouter {
162
162
  /**
163
163
  * Handle an incoming request.
164
164
  */
165
- async fetch(req) {
166
- const url = new URL(req.url);
167
- const trailingSlash = this.opts.trailingSlash ?? 'never';
168
- const routedPath = stripBasePath(url.pathname, BASE_PATH);
169
- const path = routedPath == null
170
- ? null
171
- : trailingSlash === 'ignore'
172
- ? routedPath
173
- : normaliseTrailingSlash(routedPath, trailingSlash);
174
- let match = null;
175
- let action = false;
176
- try {
165
+ async fetch(req, resolvedPath) {
166
+ // callers that have already applied the base-path and trailing-slash
167
+ // policy (the RSC dispatch layer) pass the resolved path in, so the
168
+ // request does not pay for that normalisation twice
169
+ let path = resolvedPath;
170
+ if (path === undefined) {
171
+ const url = new URL(req.url);
172
+ const trailingSlash = this.opts.trailingSlash ?? 'never';
173
+ const resolved = resolveRequestPath(url.pathname, BASE_PATH, trailingSlash);
174
+ path = resolved.path;
177
175
  const method = req.method.toUpperCase();
178
- const canonicalPathname = path == null ? null : applyBasePath(path, BASE_PATH);
176
+ const canonicalPathname = resolved.pathname;
179
177
  if (canonicalPathname != null &&
180
178
  (method === 'GET' || method === 'HEAD') &&
181
179
  canonicalPathname !== url.pathname) {
@@ -187,6 +185,11 @@ export class HttpRouter {
187
185
  url.pathname = canonicalPathname;
188
186
  req = new Request(url.toString(), req);
189
187
  }
188
+ }
189
+ let match = null;
190
+ let action = false;
191
+ try {
192
+ const method = req.method.toUpperCase();
190
193
  const { action: isAction, formData: parsedFormData } = await maybeAction(req);
191
194
  action = isAction;
192
195
  // action requests stay on the same path, but we match them like GET so
@@ -265,7 +268,10 @@ export class HttpRouter {
265
268
  }
266
269
  }
267
270
  }
268
- if (!(await assets.exists(resolved))) {
271
+ // a precompressed variant is only emitted for an asset that existed, so a
272
+ // brotli hit above already proves the file is there. skipping the second
273
+ // lookup saves a round trip on stores where `exists` is a remote call
274
+ if (encoding === null && !(await assets.exists(resolved))) {
269
275
  return new Response('Not found', { status: 404 });
270
276
  }
271
277
  // get mime type from original path, not compressed variant
@@ -11,6 +11,20 @@ export type PathPattern = {
11
11
  path: string;
12
12
  wildcardNames: Set<string>;
13
13
  };
14
+ /**
15
+ * Resolve an incoming request pathname against the base path and trailing-slash
16
+ * policy. Both the dispatch layer and the router use this so the two never
17
+ * disagree about what a canonical path looks like.
18
+ *
19
+ * `routedPath` is the raw app-relative path, `path` is what the router matches,
20
+ * and `pathname` is the canonical URL pathname including the base path, so the
21
+ * caller can tell whether a redirect is needed.
22
+ */
23
+ export declare function resolveRequestPath(pathname: string, base: string | null | undefined, trailingSlash?: PluginConfig['trailingSlash']): {
24
+ routedPath: string | null;
25
+ path: string | null;
26
+ pathname: string | null;
27
+ };
14
28
  /**
15
29
  * Convert an internal route string into a path-to-regexp pattern and collect
16
30
  * the wildcard param names used in that pattern.
@@ -1,3 +1,4 @@
1
+ import { applyBasePath, stripBasePath } from '../../utils/base-path.js';
1
2
  /**
2
3
  * Whether a request wants the RSC payload rather than a document.
3
4
  *
@@ -9,6 +10,28 @@ export function isRscRequest(req) {
9
10
  const accept = req.headers.get('accept') ?? '';
10
11
  return accept.includes('text/x-component') && !accept.includes('text/html');
11
12
  }
13
+ /**
14
+ * Resolve an incoming request pathname against the base path and trailing-slash
15
+ * policy. Both the dispatch layer and the router use this so the two never
16
+ * disagree about what a canonical path looks like.
17
+ *
18
+ * `routedPath` is the raw app-relative path, `path` is what the router matches,
19
+ * and `pathname` is the canonical URL pathname including the base path, so the
20
+ * caller can tell whether a redirect is needed.
21
+ */
22
+ export function resolveRequestPath(pathname, base, trailingSlash = 'never') {
23
+ const routedPath = stripBasePath(pathname, base);
24
+ const path = routedPath == null
25
+ ? null
26
+ : trailingSlash === 'ignore'
27
+ ? routedPath
28
+ : normaliseTrailingSlash(routedPath, trailingSlash);
29
+ return {
30
+ routedPath,
31
+ path,
32
+ pathname: path == null ? null : applyBasePath(path, base),
33
+ };
34
+ }
12
35
  /**
13
36
  * Escape a literal path segment so it can be used safely in a
14
37
  * path-to-regexp pattern.
@@ -114,6 +114,14 @@ export async function postbuild(cwd = process.cwd()) {
114
114
  const staticPath = path.join(staticRoot, Prerender.staticRoutePath(route, staticMode));
115
115
  await fs.mkdir(path.dirname(staticPath), { recursive: true });
116
116
  await fs.writeFile(staticPath, artifact.html);
117
+ // a static host has no server to answer an RSC navigation, so emit
118
+ // the payload next to the HTML for the client router to fetch
119
+ if (manifest.adapter === 'static') {
120
+ await fs.writeFile(staticPath.replace(/\.html$/, '.rsc.txt'), await Prerender.getRscPayload(app, route, {
121
+ origin: manifest.url,
122
+ base: manifest.base,
123
+ }));
124
+ }
117
125
  artfifactManifest[route] = {
118
126
  mode: artifact.mode,
119
127
  files: ['html'],
@@ -122,6 +122,17 @@ export declare function get(app: {
122
122
  route: string;
123
123
  artifact: any;
124
124
  }>;
125
+ /**
126
+ * Fetch the RSC payload for a route, the response a client would get for an RSC
127
+ * navigation. Written alongside a fully prerendered route's HTML so a static
128
+ * host can serve client-side navigation without a server.
129
+ */
130
+ export declare function getRscPayload(app: {
131
+ fetch: (req: Request) => Promise<Response>;
132
+ }, route: string, opts: {
133
+ origin?: string;
134
+ base?: string;
135
+ }): Promise<string>;
125
136
  /**
126
137
  * Run the prerendering process for a list of routes with a specified concurrency limit,
127
138
  * by calling the 'get' function for each route and yielding the results as they become available.
@@ -331,6 +331,19 @@ export async function get(app, route, opts) {
331
331
  return { route, status: res.status };
332
332
  return { route, artifact: await res.json() };
333
333
  }
334
+ /**
335
+ * Fetch the RSC payload for a route, the response a client would get for an RSC
336
+ * navigation. Written alongside a fully prerendered route's HTML so a static
337
+ * host can serve client-side navigation without a server.
338
+ */
339
+ export async function getRscPayload(app, route, opts) {
340
+ const url = `${opts.origin ?? `http://${Config.SLUG}.local`}${applyBasePath(route, opts.base)}`;
341
+ const res = await app.fetch(new Request(url, { headers: { Accept: 'text/x-component' } }));
342
+ if (!(res instanceof Response) || !res.ok) {
343
+ throw new Error(`[prerender] RSC payload for ${route} was not served`);
344
+ }
345
+ return res.text();
346
+ }
334
347
  /**
335
348
  * Run the prerendering process for a list of routes with a specified concurrency limit,
336
349
  * by calling the 'get' function for each route and yielding the results as they become available.
package/dist/types.d.ts CHANGED
@@ -130,6 +130,7 @@ export type BuildManifest = {
130
130
  precompress: boolean;
131
131
  trailingSlash: (typeof Config.TRAILING_SLASH_MODES)[number];
132
132
  url?: string;
133
+ adapter?: string;
133
134
  };
134
135
  export declare namespace Route {
135
136
  type ParamsOf<TRoute> = TRoute extends {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jk2908/solas",
3
- "version": "0.11.1",
3
+ "version": "0.13.0",
4
4
  "description": "A Vite + React meta-framework exploring streaming, Server Components, and partial prerendering. Designed for simplicity and lightness",
5
5
  "keywords": [
6
6
  "framework",