@jk2908/solas 0.12.0 → 0.13.2

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,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.2
4
+
5
+ ### Changed
6
+
7
+ - The Node adapter's response-stream loop and close handlers use declarations and `while (true)` for readability. No behaviour change.
8
+
9
+ ## 0.13.1
10
+
11
+ ### Fixes
12
+
13
+ - The Node adapter now aborts `request.signal` when the client disconnects, so streaming endpoints such as SSE can stop their producers.
14
+ - The Node adapter flushes response headers before streaming the body, so a delayed first chunk no longer leaves the client waiting with no response.
15
+ - The Node adapter cancels the response stream when the client disconnects, instead of continuing to write into a dead socket.
16
+
17
+ ## 0.13.0
18
+
19
+ ### Features
20
+
21
+ - 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.
22
+
23
+ ### Fixes
24
+
25
+ - Endpoint handlers with default or rest parameters now receive the request. Previously Solas passed it only when `Function.length` was non-zero.
26
+ - 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.
27
+
28
+ ### Changed
29
+
30
+ - The route finder caches import paths, and the dev watcher ignores `dist`, so large route graphs and long dev sessions do less repeated work.
31
+
3
32
  ## 0.12.0
4
33
 
5
34
  ### Features
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`.
@@ -540,7 +552,7 @@ Valid values are `debug`, `info`, `warn`, `error`, and `fatal`.
540
552
  - `error`: only errors
541
553
  - `fatal`: only fatal errors
542
554
 
543
- 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.
544
556
 
545
557
  ```ts
546
558
  export default defineConfig({
@@ -1,4 +1,3 @@
1
- import { once } from 'node:events';
2
1
  import http from 'node:http';
3
2
  import path from 'node:path';
4
3
  import { pathToFileURL } from 'node:url';
@@ -38,26 +37,59 @@ function nodeToWeb(req) {
38
37
  */
39
38
  async function webToNode(stream, res) {
40
39
  const reader = stream.getReader();
40
+ let closed = false;
41
+ // a client that goes away leaves nothing to write to; cancel the source so
42
+ // its producer stops instead of buffering into a dead socket
43
+ function onClose() {
44
+ closed = true;
45
+ void reader.cancel().catch(() => { });
46
+ }
47
+ res.once('close', onClose);
41
48
  try {
42
49
  while (true) {
50
+ if (closed)
51
+ break;
43
52
  const { done, value } = await reader.read();
44
53
  if (done)
45
54
  break;
46
- // `write` returns false when the socket buffer is full. That is not an
47
- // error: wait for the `'drain'` event before writing more, otherwise a
48
- // fast producer and a slow client would buffer the whole body in memory
49
- if (!res.write(value))
50
- await once(res, 'drain');
55
+ if (closed || res.destroyed || res.writableEnded)
56
+ break;
57
+ try {
58
+ // `write` returns false when the socket buffer is full. That is not an
59
+ // error: wait for the `'drain'` event before writing more, otherwise a
60
+ // fast producer and a slow client would buffer the whole body in memory
61
+ if (!res.write(value)) {
62
+ await new Promise(resolve => {
63
+ function settle() {
64
+ res.off('drain', settle);
65
+ res.off('close', settle);
66
+ resolve();
67
+ }
68
+ res.on('drain', settle);
69
+ res.on('close', settle);
70
+ });
71
+ }
72
+ }
73
+ catch {
74
+ // the socket died between the checks above
75
+ break;
76
+ }
51
77
  }
52
78
  }
53
79
  finally {
54
- reader.releaseLock();
80
+ res.off('close', onClose);
81
+ try {
82
+ reader.releaseLock();
83
+ }
84
+ catch {
85
+ // a pending read can outlive a cancel by a tick
86
+ }
55
87
  }
56
88
  }
57
89
  /**
58
90
  * Convert a Node request into a fetch `Request`, streaming the body when present.
59
91
  */
60
- function toRequest(req) {
92
+ function toRequest(req, signal) {
61
93
  const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
62
94
  const headers = new Headers();
63
95
  for (const [key, value] of Object.entries(req.headers)) {
@@ -77,6 +109,7 @@ function toRequest(req) {
77
109
  headers,
78
110
  body: hasBody ? nodeToWeb(req) : undefined,
79
111
  duplex: 'half',
112
+ signal,
80
113
  };
81
114
  return new Request(url, init);
82
115
  }
@@ -99,8 +132,12 @@ async function sendResponse(res, response) {
99
132
  res.end();
100
133
  return;
101
134
  }
135
+ // send headers now so a delayed first chunk (SSE, slow streams) does not
136
+ // leave the client waiting with no response
137
+ res.flushHeaders();
102
138
  await webToNode(response.body, res);
103
- res.end();
139
+ if (!res.destroyed)
140
+ res.end();
104
141
  }
105
142
  /**
106
143
  * Start a production HTTP server for a Node (or Bun) deployment.
@@ -121,16 +158,31 @@ async function adapter(options = {}) {
121
158
  const host = options.host ?? '0.0.0.0';
122
159
  const handler = await loadHandler(entry);
123
160
  const server = http.createServer(async (req, res) => {
161
+ const abort = new AbortController();
162
+ // `close` before the response has finished means the client went away;
163
+ // surface that through `req.signal` so long-lived work can stop
164
+ function onClose() {
165
+ if (!res.writableEnded)
166
+ abort.abort(new Error('client disconnected'));
167
+ }
168
+ res.once('close', onClose);
124
169
  try {
125
- const response = await handler.fetch(toRequest(req));
170
+ const response = await handler.fetch(toRequest(req, abort.signal));
126
171
  await sendResponse(res, response);
127
172
  }
128
173
  catch (err) {
129
174
  console.error('[solas] request failed', err);
175
+ if (res.headersSent) {
176
+ res.destroy();
177
+ return;
178
+ }
130
179
  res.statusCode = 500;
131
180
  res.setHeader('content-type', 'text/plain; charset=utf-8');
132
181
  res.end('Internal Server Error');
133
182
  }
183
+ finally {
184
+ res.off('close', onClose);
185
+ }
134
186
  });
135
187
  await new Promise(resolve => server.listen(port, host, resolve));
136
188
  options.onListen?.({ port, host });
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) {
@@ -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)
@@ -47,6 +47,62 @@ function performNavigationFetch(path, init) {
47
47
  ? fetch(getPayloadUrl(path), init)
48
48
  : fetch(path, { ...init, headers: { ...init?.headers, accept: 'text/x-component' } });
49
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
+ }
50
106
  export function BrowserRouterProvider({ children, setPayload, isNavigating = false, url, }) {
51
107
  const id = useRef(0);
52
108
  const controller = useRef(null);
@@ -60,6 +116,9 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
60
116
  * @returns The final path navigated to after any redirects, or the original path if navigation failed.
61
117
  */
62
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';
63
122
  id.current += 1;
64
123
  const navigationId = id.current;
65
124
  const currentPath = window.location.pathname + window.location.search;
@@ -81,6 +140,7 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
81
140
  throw new Error('[router.go]: external URLs are not supported. Use <a> instead');
82
141
  }
83
142
  const url = new URL(target, window.location.origin);
143
+ const hash = url.hash;
84
144
  const key = ResponseCache.toCacheKey(url.toString(), window.location.origin);
85
145
  if (!key)
86
146
  throw new Error('Invalid navigation url');
@@ -118,6 +178,7 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
118
178
  history.current.replaceState(resolvedPath);
119
179
  }
120
180
  setPayload?.(payload);
181
+ applyScroll(scrollIntent, hash);
121
182
  window.dispatchEvent(new CustomEvent(Events.names.NAVIGATION, {
122
183
  detail: { path: resolvedPath },
123
184
  }));
@@ -170,16 +231,17 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
170
231
  return Promise.resolve(currentPath);
171
232
  if (responseCache.has(key))
172
233
  responseCache.remove(key);
173
- return go(currentPath, {
174
- replace: true,
175
- });
234
+ // a refresh stays exactly where the user is
235
+ return go(currentPath, { replace: true, scroll: 'none' });
176
236
  }, [go]);
177
237
  useEffect(() => {
178
238
  const h = history.current;
179
239
  function handler() {
180
- h.onPopState();
240
+ const entry = h.onPopState();
241
+ // back/forward restores the position the entry was left at
181
242
  void go(BrowserRouter.toTarget(window.location.pathname + window.location.search), {
182
243
  replace: true,
244
+ scroll: { restore: entry?.scrollY ?? 0 },
183
245
  });
184
246
  }
185
247
  window.addEventListener('popstate', handler);
@@ -189,6 +251,31 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
189
251
  window.removeEventListener('popstate', handler);
190
252
  };
191
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
+ }, []);
192
279
  const value = useMemo(() => ({
193
280
  go,
194
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jk2908/solas",
3
- "version": "0.12.0",
3
+ "version": "0.13.2",
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",