@jk2908/solas 0.2.3 → 0.3.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.
Files changed (82) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +2 -0
  3. package/dist/cli.js +77 -83
  4. package/dist/error-boundary.d.ts +1 -1
  5. package/dist/error-boundary.js +1 -1
  6. package/dist/index.d.ts +3 -3
  7. package/dist/index.js +10 -14
  8. package/dist/internal/build.d.ts +1 -1
  9. package/dist/internal/build.js +4 -4
  10. package/dist/internal/codegen/config.d.ts +1 -1
  11. package/dist/internal/codegen/config.js +10 -10
  12. package/dist/internal/codegen/environments.js +22 -18
  13. package/dist/internal/codegen/manifest.d.ts +1 -1
  14. package/dist/internal/codegen/manifest.js +6 -7
  15. package/dist/internal/codegen/maps.d.ts +1 -1
  16. package/dist/internal/codegen/maps.js +38 -27
  17. package/dist/internal/codegen/utils.d.ts +20 -0
  18. package/dist/internal/codegen/utils.js +140 -1
  19. package/dist/internal/env/browser.js +20 -16
  20. package/dist/internal/env/request-context.d.ts +2 -2
  21. package/dist/internal/env/request-context.js +1 -1
  22. package/dist/internal/env/rsc.d.ts +8 -22
  23. package/dist/internal/env/rsc.js +38 -117
  24. package/dist/internal/env/ssr.js +9 -9
  25. package/dist/internal/env/utils.js +2 -2
  26. package/dist/internal/metadata.d.ts +2 -2
  27. package/dist/internal/metadata.js +18 -6
  28. package/dist/internal/navigation/http-exception-boundary.d.ts +2 -2
  29. package/dist/internal/navigation/http-exception-boundary.js +1 -1
  30. package/dist/internal/navigation/link.js +1 -1
  31. package/dist/internal/navigation/redirect-boundary.d.ts +1 -1
  32. package/dist/internal/navigation/redirect-boundary.js +1 -1
  33. package/dist/internal/navigation/redirect.js +1 -1
  34. package/dist/internal/navigation/use-search-params.js +4 -2
  35. package/dist/internal/prerender.d.ts +10 -1
  36. package/dist/internal/prerender.js +55 -5
  37. package/dist/internal/render/head.d.ts +4 -1
  38. package/dist/internal/render/head.js +37 -18
  39. package/dist/internal/render/tree.d.ts +1 -1
  40. package/dist/internal/render/tree.js +3 -3
  41. package/dist/internal/router/create-router.d.ts +2 -2
  42. package/dist/internal/router/create-router.js +1 -1
  43. package/dist/internal/router/prefetcher.d.ts +1 -1
  44. package/dist/internal/router/prefetcher.js +8 -3
  45. package/dist/internal/router/resolver.d.ts +29 -29
  46. package/dist/internal/router/resolver.js +4 -4
  47. package/dist/internal/router/router-context.d.ts +4 -0
  48. package/dist/internal/router/router-context.js +1 -0
  49. package/dist/internal/router/router-provider.d.ts +6 -2
  50. package/dist/internal/router/router-provider.js +38 -22
  51. package/dist/internal/router/router.d.ts +1 -1
  52. package/dist/internal/router/router.js +4 -4
  53. package/dist/internal/router/use-router.d.ts +5 -1
  54. package/dist/internal/router/use-router.js +1 -1
  55. package/dist/internal/router/utils.d.ts +1 -1
  56. package/dist/internal/server/actions.d.ts +30 -0
  57. package/dist/internal/server/actions.js +107 -0
  58. package/dist/internal/server/cookies.d.ts +1 -1
  59. package/dist/internal/server/cookies.js +3 -3
  60. package/dist/internal/server/dynamic.js +2 -2
  61. package/dist/internal/server/headers.js +2 -2
  62. package/dist/internal/server/url.js +14 -3
  63. package/dist/internal/ui/defaults/error.d.ts +1 -1
  64. package/dist/internal/ui/error-boundary.d.ts +1 -1
  65. package/dist/internal/ui/error-boundary.js +1 -1
  66. package/dist/navigation.d.ts +6 -6
  67. package/dist/navigation.js +6 -6
  68. package/dist/prerender.d.ts +1 -1
  69. package/dist/prerender.js +1 -1
  70. package/dist/router.d.ts +4 -4
  71. package/dist/router.js +4 -4
  72. package/dist/server.d.ts +4 -4
  73. package/dist/server.js +4 -4
  74. package/dist/solas.d.ts +1 -1
  75. package/dist/solas.js +1 -0
  76. package/dist/types.d.ts +6 -6
  77. package/dist/types.js +1 -1
  78. package/dist/utils/context.js +1 -1
  79. package/dist/utils/logger.js +2 -2
  80. package/package.json +3 -1
  81. package/dist/utils/format.d.ts +0 -6
  82. package/dist/utils/format.js +0 -72
@@ -1,6 +1,8 @@
1
1
  import { useMemo, useSyncExternalStore } from 'react';
2
- import { Solas } from '../../solas';
2
+ import { useRouter } from '../../router.js';
3
+ import { Solas } from '../../solas.js';
3
4
  export function useSearchParams() {
5
+ const { url } = useRouter();
4
6
  const search = useSyncExternalStore(fn => {
5
7
  window.addEventListener('popstate', fn);
6
8
  window.addEventListener(Solas.Events.names.NAVIGATION, fn);
@@ -8,6 +10,6 @@ export function useSearchParams() {
8
10
  window.removeEventListener('popstate', fn);
9
11
  window.removeEventListener(Solas.Events.names.NAVIGATION, fn);
10
12
  };
11
- }, () => window.location.search, () => '');
13
+ }, () => window.location.search, () => url?.search);
12
14
  return useMemo(() => new URLSearchParams(search), [search]);
13
15
  }
@@ -1,4 +1,4 @@
1
- import type { BuildContext } from '../types';
1
+ import type { BuildContext } from '../types.js';
2
2
  export declare namespace Prerender {
3
3
  namespace Artifact {
4
4
  type Mode = 'full' | 'ppr';
@@ -16,6 +16,7 @@ export declare namespace Prerender {
16
16
  mode: Mode;
17
17
  createdAt: number;
18
18
  files?: File[];
19
+ fullPrerenderFilename?: string;
19
20
  };
20
21
  type Manifest = {
21
22
  generatedAt: number;
@@ -35,6 +36,14 @@ export declare namespace Prerender {
35
36
  * Get the file system path for storing prerender artifacts for a given route
36
37
  */
37
38
  function getPath(outDir: string, pathname: string): string;
39
+ /**
40
+ * Get the file system path for a single prerender artifact file under a route directory
41
+ */
42
+ function getFilePath(outDir: string, pathname: string, fileName: string): string;
43
+ /**
44
+ * Build a deterministic file name for a full prerender html artifact
45
+ */
46
+ function getFullHtmlFileName(html: string): string;
38
47
  /**
39
48
  * Load the prerender artifact manifest for faster runtime route mode checks
40
49
  */
@@ -1,9 +1,9 @@
1
1
  import path from 'node:path';
2
2
  import { compile } from 'path-to-regexp';
3
- import { Solas } from '../solas';
4
- import { Logger } from '../utils/logger';
5
- import { Time } from '../utils/time';
6
- import { toPathPattern } from './router/utils';
3
+ import { Solas } from '../solas.js';
4
+ import { Logger } from '../utils/logger.js';
5
+ import { Time } from '../utils/time.js';
6
+ import { toPathPattern } from './router/utils.js';
7
7
  const logger = new Logger();
8
8
  export { Prerender };
9
9
  var Prerender;
@@ -13,6 +13,15 @@ var Prerender;
13
13
  let Artifact;
14
14
  (function (Artifact) {
15
15
  const manifestCache = new Map();
16
+ /**
17
+ * Check whether a file name is safe to join under an artifact directory
18
+ */
19
+ function isArtifactFileName(value) {
20
+ return (typeof value === 'string' &&
21
+ value.length > 0 &&
22
+ path.basename(value) === value &&
23
+ !value.includes(path.sep));
24
+ }
16
25
  /**
17
26
  * Get the root directory path where prerender artifacts are stored,
18
27
  * based on the output directory specified in the configuration
@@ -43,6 +52,23 @@ var Prerender;
43
52
  return artifactPath;
44
53
  }
45
54
  Artifact.getPath = getPath;
55
+ /**
56
+ * Get the file system path for a single prerender artifact file under a route directory
57
+ */
58
+ function getFilePath(outDir, pathname, fileName) {
59
+ if (!isArtifactFileName(fileName)) {
60
+ throw new Error('[prerender] invalid artifact file name');
61
+ }
62
+ return path.join(getPath(outDir, pathname), fileName);
63
+ }
64
+ Artifact.getFilePath = getFilePath;
65
+ /**
66
+ * Build a deterministic file name for a full prerender html artifact
67
+ */
68
+ function getFullHtmlFileName(html) {
69
+ return `html.${Bun.hash(html).toString(16)}.html`;
70
+ }
71
+ Artifact.getFullHtmlFileName = getFullHtmlFileName;
46
72
  /**
47
73
  * Load the prerender artifact manifest for faster runtime route mode checks
48
74
  */
@@ -107,6 +133,11 @@ var Prerender;
107
133
  }
108
134
  }
109
135
  }
136
+ if (entry.fullPrerenderFilename !== undefined &&
137
+ !isArtifactFileName(entry.fullPrerenderFilename)) {
138
+ manifestCache.set(outDir, null);
139
+ return null;
140
+ }
110
141
  }
111
142
  const manifest = { generatedAt, routes };
112
143
  // cache validated manifest to avoid reparsing on every request
@@ -193,7 +224,12 @@ var Prerender;
193
224
  return null;
194
225
  if (mode !== 'full' && mode !== 'ppr')
195
226
  return null;
196
- return { schema, route, createdAt, mode };
227
+ return {
228
+ schema,
229
+ route,
230
+ createdAt,
231
+ mode,
232
+ };
197
233
  }
198
234
  catch {
199
235
  return null;
@@ -219,14 +255,22 @@ var Prerender;
219
255
  * into the prelude at the appropriate location (before </body> or </html>)
220
256
  */
221
257
  function composePreludeAndResume(prelude, resumeStream) {
258
+ // `prelude` is the static shell html as one complete string, usually shaped like
259
+ // `<html>...<body>static shell...</body></html>` or an html fragment with no close tags
222
260
  // search both cases to avoid duplicating the full string with toLowerCase
223
261
  const bodyClose = Math.max(prelude.lastIndexOf('</body>'), prelude.lastIndexOf('</BODY>'));
224
262
  const htmlClose = Math.max(prelude.lastIndexOf('</html>'), prelude.lastIndexOf('</HTML>'));
263
+ // prefer inserting before </body>, then before </html>, and fall back
264
+ // to appending when the prelude is only a fragment with no close tags
225
265
  const splitAt = bodyClose >= 0 ? bodyClose : htmlClose >= 0 ? htmlClose : prelude.length;
226
266
  return new ReadableStream({
227
267
  async start(controller) {
228
268
  // send everything before the closing tags so the resume stream can be injected
229
269
  controller.enqueue(encoder.encode(prelude.slice(0, splitAt)));
270
+ // resumeStream is the html React emits when it resumes postponed work for this page. Its
271
+ // first chunk begins with an extra `</body></html>` pair, then continues with the
272
+ // resumed scripts and markup for the unfinished work. Strip that leading
273
+ // pair once, then pass the rest through unchanged
230
274
  const reader = resumeStream.getReader();
231
275
  let strippedLeadingClose = false;
232
276
  try {
@@ -239,11 +283,17 @@ var Prerender;
239
283
  if (!strippedLeadingClose) {
240
284
  strippedLeadingClose = true;
241
285
  const text = decoder.decode(value);
286
+ // we already wrote the prelude up to the insertion point before its closing tags.
287
+ // React's first resumed chunk starts with an extra `</body></html>` pair,
288
+ // so strip that prefix and keep the rest of the chunk
242
289
  const trimmed = text.replace(/^\s*<\/body>\s*<\/html>/i, '');
243
290
  if (trimmed.length > 0)
244
291
  controller.enqueue(encoder.encode(trimmed));
245
292
  continue;
246
293
  }
294
+ // once the duplicated `</body></html>` prefix is removed, stop trying to
295
+ // interpret the stream and forward each remaining chunk exactly as React
296
+ // emitted it
247
297
  controller.enqueue(value);
248
298
  }
249
299
  }
@@ -1,4 +1,7 @@
1
- import { type Metadata as Collection } from '../metadata';
1
+ import { type Metadata as Collection } from '../metadata.js';
2
+ /**
3
+ * Renders title, meta, and link tags based on the provided metadata payload
4
+ */
2
5
  export declare function Head({ metadata: m }: {
3
6
  metadata?: Collection.Item | Promise<Collection.Item>;
4
7
  }): import("react/jsx-runtime").JSX.Element | null;
@@ -1,38 +1,57 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { use } from 'react';
3
- import { Solas } from '../../solas';
4
- import { Logger } from '../../utils/logger';
3
+ import { Solas } from '../../solas.js';
4
+ import { Logger } from '../../utils/logger.js';
5
5
  const logger = new Logger();
6
6
  const cache = new WeakMap();
7
+ /**
8
+ * Convert supported metadata primitives to string for meta tag content
9
+ */
10
+ function toContent(value) {
11
+ return typeof value === 'string' ||
12
+ typeof value === 'number' ||
13
+ typeof value === 'boolean'
14
+ ? String(value)
15
+ : undefined;
16
+ }
17
+ /**
18
+ * Convert a metadata item or promise of an item to a promise that always resolves
19
+ * successfully, caching the result for future use
20
+ */
21
+ function toSafeUsable(metadata) {
22
+ const cached = cache.get(metadata);
23
+ if (cached)
24
+ return cached;
25
+ const safe = Promise.resolve(metadata).catch(err => {
26
+ logger.error('[head] failed to resolve metadata', err);
27
+ return {};
28
+ });
29
+ cache.set(metadata, safe);
30
+ return safe;
31
+ }
32
+ /**
33
+ * Renders title, meta, and link tags based on the provided metadata payload
34
+ */
7
35
  export function Head({ metadata: m, }) {
8
36
  if (!m)
9
37
  return null;
10
38
  const metadata = use(toSafeUsable(m));
11
39
  return (_jsxs(_Fragment, { children: [
12
- _jsx("meta", { name: "generator", content: Solas.Config.NAME }), metadata.title && _jsx("title", { children: metadata.title.toString() }), metadata.meta?.map(meta => {
40
+ _jsx("meta", { name: "generator", content: Solas.Config.NAME }), toContent(metadata.title) !== undefined && (_jsx("title", { children: toContent(metadata.title) })), metadata.meta?.map(meta => {
13
41
  if ('charSet' in meta) {
14
42
  return _jsx("meta", { charSet: meta.charSet }, meta.charSet);
15
43
  }
16
44
  if ('name' in meta) {
17
- return (_jsx("meta", { name: meta.name, content: meta.content?.toString() }, meta.name));
45
+ return (_jsx("meta", { name: meta.name, content: toContent(meta.content) }, meta.name));
18
46
  }
19
47
  if ('httpEquiv' in meta) {
20
- return (_jsx("meta", { httpEquiv: meta.httpEquiv, content: meta.content?.toString() }, meta.httpEquiv));
48
+ return (_jsx("meta", { httpEquiv: meta.httpEquiv, content: toContent(meta.content) }, meta.httpEquiv));
21
49
  }
22
50
  if ('property' in meta) {
23
- return (_jsx("meta", { property: meta.property, content: meta.content?.toString() }, meta.property));
51
+ return (_jsx("meta", { property: meta.property, content: toContent(meta.content) }, meta.property));
24
52
  }
25
53
  return null;
26
- }), metadata.link?.map(link => (_jsx("link", { ...link }, `${link.rel}${link.href ?? ''}`)))] }));
27
- }
28
- function toSafeUsable(metadata) {
29
- const cached = cache.get(metadata);
30
- if (cached)
31
- return cached;
32
- const safe = Promise.resolve(metadata).catch(err => {
33
- logger.error('[head] failed to resolve metadata', err);
34
- return {};
35
- });
36
- cache.set(metadata, safe);
37
- return safe;
54
+ }), metadata.link?.map(link => (_jsx("link", { rel: link.rel, href: typeof link.href === 'string' ? link.href : undefined, as: typeof link.as === 'string' ? link.as : undefined, type: typeof link.type === 'string' ? link.type : undefined, media: typeof link.media === 'string' ? link.media : undefined, sizes: typeof link.sizes === 'string' ? link.sizes : undefined, crossOrigin: link.crossOrigin === 'anonymous' || link.crossOrigin === 'use-credentials'
55
+ ? link.crossOrigin
56
+ : undefined }, `${link.rel}${link.href ?? ''}`)))] }));
38
57
  }
@@ -1,4 +1,4 @@
1
- import type { Resolver } from '../router/resolver';
1
+ import type { Resolver } from '../router/resolver.js';
2
2
  type Match = NonNullable<Resolver.EnhancedMatch>;
3
3
  /**
4
4
  * Render the resolved route tree for a matched page
@@ -1,8 +1,8 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Suspense } from 'react';
3
- import { HttpException, isHttpException } from '../navigation/http-exception';
4
- import { HttpExceptionBoundary } from '../navigation/http-exception-boundary';
5
- import DefaultErr from '../ui/defaults/error';
3
+ import { HttpException, isHttpException } from '../navigation/http-exception.js';
4
+ import { HttpExceptionBoundary } from '../navigation/http-exception-boundary.js';
5
+ import DefaultErr from '../ui/defaults/error.js';
6
6
  /**
7
7
  * Render the resolved route tree for a matched page
8
8
  *
@@ -1,5 +1,5 @@
1
- import type { ImportMap, Manifest, PluginConfig, SolasRequest } from '../../types';
2
- import { Router } from './router';
1
+ import type { ImportMap, Manifest, PluginConfig, SolasRequest } from '../../types.js';
2
+ import { Router } from './router.js';
3
3
  /**
4
4
  * Create the application router from the generated manifest and import map
5
5
  */
@@ -1,4 +1,4 @@
1
- import { Router } from './router';
1
+ import { Router } from './router.js';
2
2
  function callEndpoint(fn, req) {
3
3
  // endpoint modules may export either a zero-arg handler or one that expects the request
4
4
  if (fn.length === 0)
@@ -16,7 +16,7 @@ export declare class Prefetcher {
16
16
  * Converts a url path to a cache key by normalising it
17
17
  * against a base url
18
18
  */
19
- static key(path: string, base: string): string;
19
+ static key(path: string, base: string): string | null;
20
20
  /**
21
21
  * Evicts the oldest entry from the cache
22
22
  */
@@ -11,9 +11,14 @@ export class Prefetcher {
11
11
  * against a base url
12
12
  */
13
13
  static key(path, base) {
14
- const url = new URL(path, base);
15
- // hash is client-only and never sent to the server, so exclude it
16
- return url.pathname + url.search;
14
+ try {
15
+ const url = new URL(path, base);
16
+ // hash is client-only and never sent to the server, so exclude it
17
+ return url.pathname + url.search;
18
+ }
19
+ catch {
20
+ return null;
21
+ }
17
22
  }
18
23
  /**
19
24
  * Evicts the oldest entry from the cache
@@ -1,7 +1,7 @@
1
- import type { ImportMap, Manifest, ManifestEntry, Primitive, View } from '../../types';
2
- import type { Router } from './router';
3
- import { Metadata } from '../metadata';
4
- import { HttpException } from '../navigation/http-exception';
1
+ import type { ImportMap, Manifest, ManifestEntry, Primitive, View } from '../../types.js';
2
+ import type { Router } from './router.js';
3
+ import { Metadata } from '../metadata.js';
4
+ import { HttpException } from '../navigation/http-exception.js';
5
5
  export declare namespace Resolver {
6
6
  type ReconciledMatch = ReturnType<Resolver['reconcile']>;
7
7
  type CachedEnhancedMatch = Omit<EnhancedMatch, 'params' | 'error'>;
@@ -55,7 +55,7 @@ export declare class Resolver {
55
55
  /**
56
56
  * Narrow down a route entry to a page entry if it exists
57
57
  */
58
- static narrow(entry?: ManifestEntry | ManifestEntry[]): import("../..").Segment | null;
58
+ static narrow(entry?: ManifestEntry | ManifestEntry[]): import("../../types.js").Segment | null;
59
59
  /**
60
60
  * Get the status code for a matched route that may or may not have errored
61
61
  */
@@ -64,8 +64,6 @@ export declare class Resolver {
64
64
  * Reconcile a router match against a manifest entry
65
65
  */
66
66
  reconcile(path: string, match: Router.Match | null, error?: Error): {
67
- params: Router.Params;
68
- error: Error | undefined;
69
67
  __id: string;
70
68
  __path: string;
71
69
  __params: string[];
@@ -85,9 +83,9 @@ export declare class Resolver {
85
83
  prerender: "full" | "ppr" | false;
86
84
  dynamic: boolean;
87
85
  wildcard: boolean;
86
+ params: Router.Params;
87
+ error: Error | undefined;
88
88
  } | {
89
- params: {};
90
- error: HttpException;
91
89
  __id: string;
92
90
  __path: string;
93
91
  __params: string[];
@@ -107,11 +105,32 @@ export declare class Resolver {
107
105
  prerender: "full" | "ppr" | false;
108
106
  dynamic: boolean;
109
107
  wildcard: boolean;
108
+ params: {};
109
+ error: HttpException;
110
110
  } | null;
111
111
  /**
112
112
  * Enhance a matched route with its associated components
113
113
  */
114
114
  enhance(match: Resolver.ReconciledMatch | null): {
115
+ __id: string;
116
+ __path: string;
117
+ __params: string[];
118
+ __kind: "$P";
119
+ __depth: number;
120
+ method: "get";
121
+ paths: {
122
+ layouts: (string | null)[];
123
+ '401s': (string | null)[];
124
+ '403s': (string | null)[];
125
+ '404s': (string | null)[];
126
+ '500s': (string | null)[];
127
+ loaders: (string | null)[];
128
+ middlewares: (string | null)[];
129
+ page?: string | null | undefined;
130
+ };
131
+ prerender: "full" | "ppr" | false;
132
+ dynamic: boolean;
133
+ wildcard: boolean;
115
134
  ui: {
116
135
  layouts: (View<{
117
136
  children?: import("react").ReactNode;
@@ -147,28 +166,9 @@ export declare class Resolver {
147
166
  metadata?: ((input: Metadata.Input<Router.Params, Error>) => Metadata.Task[]) | undefined;
148
167
  params: Router.Params | {};
149
168
  error: Error | HttpException | undefined;
150
- __id: string;
151
- __path: string;
152
- __params: string[];
153
- __kind: "$P";
154
- __depth: number;
155
- method: "get";
156
- paths: {
157
- layouts: (string | null)[];
158
- '401s': (string | null)[];
159
- '403s': (string | null)[];
160
- '404s': (string | null)[];
161
- '500s': (string | null)[];
162
- loaders: (string | null)[];
163
- middlewares: (string | null)[];
164
- page?: string | null | undefined;
165
- };
166
- prerender: "full" | "ppr" | false;
167
- dynamic: boolean;
168
- wildcard: boolean;
169
169
  } | null;
170
170
  /**
171
171
  * Find the closest ancestor entry for a given path and property
172
172
  */
173
- closest(path: string, property: string, value?: Omit<Primitive, 'undefined'>): import("../..").Segment | null;
173
+ closest(path: string, property: string, value?: Omit<Primitive, 'undefined'>): import("../../types.js").Segment | null;
174
174
  }
@@ -1,8 +1,8 @@
1
1
  import { lazy } from 'react';
2
- import { Logger } from '../../utils/logger';
3
- import { Build } from '../build';
4
- import { Metadata } from '../metadata';
5
- import { HttpException, isHttpException } from '../navigation/http-exception';
2
+ import { Logger } from '../../utils/logger.js';
3
+ import { Build } from '../build.js';
4
+ import { Metadata } from '../metadata.js';
5
+ import { HttpException, isHttpException } from '../navigation/http-exception.js';
6
6
  const logger = new Logger();
7
7
  const IS_DEV = import.meta.env.DEV;
8
8
  /**
@@ -8,4 +8,8 @@ export declare const RouterContext: import("react").Context<{
8
8
  go: (to: string, opts?: Navigation.GoOptions | undefined) => Promise<string>;
9
9
  prefetch: (path: string) => void;
10
10
  isNavigating: boolean;
11
+ url: {
12
+ pathname?: string | undefined;
13
+ search?: string | undefined;
14
+ };
11
15
  }>;
@@ -4,4 +4,5 @@ export const RouterContext = createContext({
4
4
  go: async () => '',
5
5
  prefetch: () => { },
6
6
  isNavigating: false,
7
+ url: {},
7
8
  });
@@ -1,6 +1,10 @@
1
- import type { RSCPayload } from '../env/rsc';
2
- export declare function RouterProvider({ children, setPayload, isNavigating }: {
1
+ import type { RSCPayload } from '../env/rsc.js';
2
+ export declare function RouterProvider({ children, setPayload, isNavigating, url }: {
3
3
  children: React.ReactNode;
4
4
  setPayload?: (payload: RSCPayload) => void;
5
5
  isNavigating?: boolean;
6
+ url?: {
7
+ pathname?: string;
8
+ search?: string;
9
+ };
6
10
  }): import("react/jsx-runtime").JSX.Element;
@@ -2,16 +2,16 @@
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { useCallback, useEffect, useMemo, useRef } from 'react';
4
4
  import { createFromFetch } from '@vitejs/plugin-rsc/browser';
5
- import { Solas } from '../../solas';
6
- import { Logger } from '../../utils/logger';
7
- import { Prefetcher } from './prefetcher';
8
- import { RouterContext } from './router-context';
5
+ import { Logger } from '../../utils/logger.js';
6
+ import { Solas } from '../../solas.js';
7
+ import { Prefetcher } from './prefetcher.js';
8
+ import { RouterContext } from './router-context.js';
9
9
  const DEFAULT_GO_CONFIG = {
10
10
  replace: false,
11
11
  };
12
12
  const logger = new Logger();
13
13
  const prefetcher = new Prefetcher();
14
- export function RouterProvider({ children, setPayload, isNavigating = false, }) {
14
+ export function RouterProvider({ children, setPayload, isNavigating = false, url, }) {
15
15
  // id to track active navigations
16
16
  const id = useRef(0);
17
17
  // abort controller for in-flight navigation
@@ -23,23 +23,34 @@ export function RouterProvider({ children, setPayload, isNavigating = false, })
23
23
  * @returns the path that was navigated to (relative to origin)
24
24
  */
25
25
  const go = useCallback(async (to, opts = {}) => {
26
+ // increment navigation id to invalidate any in-flight navigations
26
27
  id.current += 1;
27
28
  const navigationId = id.current;
29
+ // fallback for abort/error paths
30
+ let path = window.location.pathname + window.location.search;
31
+ const replace = opts?.replace ?? DEFAULT_GO_CONFIG.replace;
28
32
  controller.current?.abort();
29
33
  controller.current = null;
30
- const url = new URL(to, window.location.origin);
31
- const replace = opts?.replace ?? DEFAULT_GO_CONFIG.replace;
32
- if (opts?.query) {
33
- for (const [key, value] of Object.entries(opts.query)) {
34
- url.searchParams.set(key, String(value));
35
- }
36
- }
37
- const path = Prefetcher.key(url.toString(), window.location.origin);
38
34
  // distinguish an actual prior prefetch from a cache entry we create
39
35
  // opportunistically for this navigation
40
- const existing = prefetcher.has(path);
36
+ let existing = false;
41
37
  try {
38
+ const url = new URL(to, window.location.origin);
39
+ if (opts?.query) {
40
+ for (const [key, value] of Object.entries(opts.query)) {
41
+ url.searchParams.set(key, String(value));
42
+ }
43
+ }
44
+ const key = Prefetcher.key(url.toString(), window.location.origin);
45
+ if (!key)
46
+ throw new Error('Invalid navigation url');
47
+ // switch to the normalized target once the url is valid
48
+ path = key;
49
+ // if the target was already prefetched, use the cached response promise
50
+ // and set existing to true so we don't remove it from cache
51
+ // after navigation
42
52
  let promise = prefetcher.get(path);
53
+ existing = promise !== undefined;
43
54
  if (!promise) {
44
55
  const ctrl = new AbortController();
45
56
  controller.current = ctrl;
@@ -47,9 +58,8 @@ export function RouterProvider({ children, setPayload, isNavigating = false, })
47
58
  headers: { accept: 'text/x-component' },
48
59
  signal: ctrl.signal,
49
60
  });
50
- }
51
- if (!prefetcher.has(path))
52
61
  prefetcher.set(path, promise);
62
+ }
53
63
  // if another navigation has started since this one, ignore the result
54
64
  // and return early
55
65
  if (navigationId !== id.current)
@@ -61,7 +71,7 @@ export function RouterProvider({ children, setPayload, isNavigating = false, })
61
71
  createFromFetch(promise),
62
72
  ]);
63
73
  // use the final response url so client history matches server redirects
64
- const resolvedPath = Prefetcher.key(res.url, window.location.origin);
74
+ const resolvedPath = Prefetcher.key(res.url, window.location.origin) ?? path;
65
75
  // check again if another navigation has started while we were awaiting
66
76
  // the response
67
77
  if (navigationId !== id.current)
@@ -95,11 +105,11 @@ export function RouterProvider({ children, setPayload, isNavigating = false, })
95
105
  finally {
96
106
  if (navigationId === id.current)
97
107
  controller.current = null;
98
- // preserve entries that were already prefetched so nearby follow-up
99
- // navigations can still reuse them within the prefetch TTL window
108
+ // keep entries that were already in the prefetch cache before go() ran. Only remove
109
+ // the temporary cache entry go() created for its own in-flight dedupe
100
110
  if (!existing) {
101
- // entries created by go() only serve as in-flight dedupe for this
102
- // navigation (i.e. not intentionally prefetched)
111
+ // this fetch was not an intentional prefetch, so do not leave it behind
112
+ // as a reusable cache entry after navigation finishes
103
113
  prefetcher.remove(path);
104
114
  }
105
115
  }
@@ -111,6 +121,8 @@ export function RouterProvider({ children, setPayload, isNavigating = false, })
111
121
  */
112
122
  const prefetch = useCallback((path) => {
113
123
  const key = Prefetcher.key(path, window.location.origin);
124
+ if (!key)
125
+ return;
114
126
  if (prefetcher.has(key))
115
127
  return;
116
128
  prefetcher.set(key, fetch(key, { headers: { Accept: 'text/x-component' } }));
@@ -128,6 +140,10 @@ export function RouterProvider({ children, setPayload, isNavigating = false, })
128
140
  go,
129
141
  prefetch,
130
142
  isNavigating,
131
- }), [go, prefetch, isNavigating]);
143
+ url: {
144
+ pathname: url?.pathname,
145
+ search: url?.search,
146
+ },
147
+ }), [go, prefetch, isNavigating, url]);
132
148
  return _jsx(RouterContext, { value: value, children: children });
133
149
  }
@@ -1,4 +1,4 @@
1
- import type { HttpMethod, PluginConfig, SolasRequest } from '../../types';
1
+ import type { HttpMethod, PluginConfig, SolasRequest } from '../../types.js';
2
2
  export declare namespace Router {
3
3
  type Params = Record<string, string | string[]>;
4
4
  type Handler = (req: SolasRequest) => Response | Promise<Response>;
@@ -1,9 +1,9 @@
1
1
  import path from 'node:path';
2
2
  import { match as createMatch } from 'path-to-regexp';
3
- import { Solas } from '../../solas';
4
- import { getAlternatePathname, normalisePathname, toPathPattern } from './utils';
5
- import { maybeAction } from '../env/rsc';
6
- import { HttpException } from '../navigation/http-exception';
3
+ import { Solas } from '../../solas.js';
4
+ import { getAlternatePathname, normalisePathname, toPathPattern } from './utils.js';
5
+ import { HttpException } from '../navigation/http-exception.js';
6
+ import { maybeAction } from '../server/actions.js';
7
7
  /**
8
8
  * Handle routing and matching for server requests
9
9
  */
@@ -1,5 +1,9 @@
1
1
  export declare function useRouter(): {
2
- go: (to: string, opts?: import("./router-context").Navigation.GoOptions | undefined) => Promise<string>;
2
+ go: (to: string, opts?: import("./router-context.js").Navigation.GoOptions | undefined) => Promise<string>;
3
3
  prefetch: (path: string) => void;
4
4
  isNavigating: boolean;
5
+ url: {
6
+ pathname?: string | undefined;
7
+ search?: string | undefined;
8
+ };
5
9
  };
@@ -1,5 +1,5 @@
1
1
  import { use } from 'react';
2
- import { RouterContext } from './router-context';
2
+ import { RouterContext } from './router-context.js';
3
3
  export function useRouter() {
4
4
  return use(RouterContext);
5
5
  }
@@ -1,4 +1,4 @@
1
- import type { Route } from '../../types';
1
+ import type { Route } from '../../types.js';
2
2
  export type PathPattern = {
3
3
  path: string;
4
4
  wildcardNames: Set<string>;