@jk2908/solas 0.3.7 → 0.4.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 +24 -0
- package/README.md +66 -6
- package/dist/index.d.ts +2 -2
- package/dist/index.js +75 -6
- package/dist/internal/browser-router/link.d.ts +1 -1
- package/dist/internal/browser-router/link.js +1 -1
- package/dist/internal/browser-router/router.d.ts +2 -165
- package/dist/internal/browser-router/router.js +3 -99
- package/dist/internal/browser-router/shared.d.ts +169 -0
- package/dist/internal/browser-router/shared.js +71 -0
- package/dist/internal/browser-router/use-router.d.ts +1 -1
- package/dist/internal/build.js +14 -14
- package/dist/internal/codegen/environments.js +5 -4
- package/dist/internal/env/browser.js +11 -9
- package/dist/internal/env/rsc.d.ts +2 -2
- package/dist/internal/env/rsc.js +170 -86
- package/dist/internal/http-router/create-http-router.d.ts +1 -1
- package/dist/internal/http-router/create-http-router.js +4 -2
- package/dist/internal/http-router/router.d.ts +4 -14
- package/dist/internal/http-router/router.js +32 -59
- package/dist/internal/navigation/http-exception.d.ts +8 -4
- package/dist/internal/navigation/http-exception.js +46 -6
- package/dist/internal/postbuild.d.ts +1 -0
- package/dist/{cli/build.js → internal/postbuild.js} +13 -48
- package/dist/internal/prerender.d.ts +4 -19
- package/dist/internal/prerender.js +8 -98
- package/dist/internal/public-files.d.ts +18 -0
- package/dist/internal/public-files.js +63 -0
- package/dist/internal/render/tree.d.ts +0 -3
- package/dist/internal/render/tree.js +1 -6
- package/dist/internal/resolver.d.ts +31 -23
- package/dist/internal/server/actions.d.ts +2 -5
- package/dist/internal/server/actions.js +4 -35
- package/dist/internal/server/csrf.d.ts +14 -0
- package/dist/internal/server/csrf.js +98 -0
- package/dist/internal/ui/defaults/error.d.ts +2 -0
- package/dist/internal/ui/defaults/error.js +1 -1
- package/dist/navigation.d.ts +1 -1
- package/dist/router.d.ts +1 -0
- package/dist/router.js +1 -0
- package/dist/solas.d.ts +12 -1
- package/dist/solas.js +116 -1
- package/dist/types.d.ts +27 -5
- package/dist/utils/base-path.d.ts +14 -0
- package/dist/utils/base-path.js +85 -0
- package/dist/utils/export-reader.d.ts +6 -1
- package/dist/utils/export-reader.js +24 -15
- package/package.json +3 -6
- package/dist/cli/build.d.ts +0 -7
- package/dist/cli/dev.d.ts +0 -4
- package/dist/cli/dev.js +0 -13
- package/dist/cli/preview.d.ts +0 -1
- package/dist/cli/preview.js +0 -47
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +0 -28
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0 - 2026-05-11
|
|
4
|
+
|
|
5
|
+
- Added CSRF protection for server actions and `+endpoint` handlers, plus a new `trustedOrigins` config option for tightly scoped cross-origin browser submissions. The checks are proxy-aware and use browser request headers when available.
|
|
6
|
+
- Added Vite `base` support across server routing, prerendering, and browser navigation, so apps mounted under a subpath resolve routes and generated asset URLs correctly.
|
|
7
|
+
- Changed static file handling so copied `public` files are served from the application root, while framework-generated files now live under the reserved `/_solas/*` path.
|
|
8
|
+
- Breaking: removed the `solas` CLI compatibility layer and switched the documented app scripts to Bun-backed Vite commands (`bunx --bun vite dev`, `build`, and `preview`).
|
|
9
|
+
- Moved Solas post-build work into the Vite plugin lifecycle, so prerendering, runtime manifest emission, sitemap generation, and precompression now run after the full app build instead of through an outer CLI wrapper.
|
|
10
|
+
- Added `Solas.Runtime.Manifest` and `Solas.Runtime.loadManifest(...)` for runtime artifact and public-file lookups, while keeping artifact-specific manifest types and helpers under `Prerender.Artifact`. The runtime manifest now lives at `dist/.solas/runtime-manifest.json` instead of under `.solas/ppr`.
|
|
11
|
+
- Stopped serialising stack traces in `HttpExceptionLike`, so server-rendered error payloads no longer include stacks.
|
|
12
|
+
|
|
13
|
+
## 0.3.9 - 2026-05-07
|
|
14
|
+
|
|
15
|
+
- Split shared `BrowserRouter` navigation types and target-building helpers into a dedicated internal module, so generated environments and type-only imports no longer need to pull through the full browser router runtime.
|
|
16
|
+
- Made the `solas()` plugin config argument optional.
|
|
17
|
+
|
|
18
|
+
## 0.3.8 - 2026-04-30
|
|
19
|
+
|
|
20
|
+
- Improved route module type safety for params, metadata, and static params, and ensured HTTP error boundaries receive route params too.
|
|
21
|
+
- Moved initial route-graph generation to Vite's `buildStart()` hook for more reliable build setup.
|
|
22
|
+
- Exported `HttpExceptionLike` from the public navigation api for typing serialised HTTP-style errors.
|
|
23
|
+
- Improved tree-shaking by keeping HMR-only browser runtime code out of non-HMR builds.
|
|
24
|
+
- Switched build-time export loading to Vite's module loader, so route exports resolve through Vite transforms and aliasing during builds.
|
|
25
|
+
- Fixed `abort(...)` during rendering so surfaced HTTP exceptions again resolve through the nearest matching boundary instead of failing as generic production render errors. This fixes a regression introduced in `0.3.7` when the outer `Suspense` was removed, while keeping that `Suspense` removed.
|
|
26
|
+
|
|
3
27
|
## 0.3.7 - 2026-04-25
|
|
4
28
|
|
|
5
29
|
- Fixed shell rendering so routes without a root `+loading` fallback no longer wrap the entire document in `Suspense`, which removes misplaced `<!--html-->`, `<!--head-->`, and `<!--body-->` markers from streamed HTML.
|
package/README.md
CHANGED
|
@@ -288,6 +288,32 @@ export default defineConfig({
|
|
|
288
288
|
})
|
|
289
289
|
```
|
|
290
290
|
|
|
291
|
+
### `trustedOrigins`
|
|
292
|
+
|
|
293
|
+
Use `trustedOrigins` to allow specific origins to make cross-origin browser submissions to your app.
|
|
294
|
+
|
|
295
|
+
Default: `[]`
|
|
296
|
+
|
|
297
|
+
Solas protects server actions and `+endpoint` handlers against CSRF.
|
|
298
|
+
|
|
299
|
+
Server actions are always `POST` requests. `+endpoint` handlers are protected on `POST`, `PUT`, `PATCH`, and `DELETE` requests.
|
|
300
|
+
|
|
301
|
+
By default, only same-origin browser requests are allowed. Add a trusted origin when a third-party service needs to submit through the user's browser, such as a payment gateway or identity provider.
|
|
302
|
+
|
|
303
|
+
Each value must be a complete origin including protocol:
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
export default defineConfig({
|
|
307
|
+
plugins: [
|
|
308
|
+
solas({
|
|
309
|
+
trustedOrigins: ['https://payments.example.com', 'https://login.example.com'],
|
|
310
|
+
}),
|
|
311
|
+
],
|
|
312
|
+
})
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Only add origins you completely trust. These origins are treated as allowed browser sources for unsafe requests.
|
|
316
|
+
|
|
291
317
|
### `sitemap`
|
|
292
318
|
|
|
293
319
|
Use `sitemap` to generate a `sitemap.xml` at build time.
|
|
@@ -372,15 +398,49 @@ Add scripts to your app:
|
|
|
372
398
|
```json
|
|
373
399
|
{
|
|
374
400
|
"scripts": {
|
|
375
|
-
"dev": "
|
|
376
|
-
"build": "
|
|
377
|
-
"preview": "
|
|
401
|
+
"dev": "bunx --bun vite dev",
|
|
402
|
+
"build": "bunx --bun vite build",
|
|
403
|
+
"preview": "bunx --bun vite preview"
|
|
378
404
|
}
|
|
379
405
|
}
|
|
380
406
|
```
|
|
381
407
|
|
|
382
408
|
## Commands
|
|
383
409
|
|
|
384
|
-
- `
|
|
385
|
-
- `
|
|
386
|
-
- `
|
|
410
|
+
- `bunx --bun vite dev` starts the development server.
|
|
411
|
+
- `bunx --bun vite build` creates a production build. Solas finalizes that build by prerendering configured routes, writing the runtime manifest, generating `sitemap.xml` when enabled, and precompressing output when enabled.
|
|
412
|
+
- `bunx --bun vite preview` serves the built app for local verification.
|
|
413
|
+
|
|
414
|
+
## Security
|
|
415
|
+
|
|
416
|
+
### CSRF
|
|
417
|
+
|
|
418
|
+
Solas protects server actions and `+endpoint` handlers against CSRF.
|
|
419
|
+
|
|
420
|
+
Server actions are always `POST` requests. `+endpoint` handlers are protected on browser-initiated `POST`, `PUT`, `PATCH`, and `DELETE` requests.
|
|
421
|
+
|
|
422
|
+
By default, browser requests must be same-origin.
|
|
423
|
+
|
|
424
|
+
When available, Solas checks browser provenance using:
|
|
425
|
+
|
|
426
|
+
- `Sec-Fetch-Site`
|
|
427
|
+
- `Origin`
|
|
428
|
+
- `Referer`
|
|
429
|
+
|
|
430
|
+
Solas also considers the effective request origin when your app is behind a proxy by using `X-Forwarded-Host`, `X-Forwarded-Proto`, and `config.url` when present.
|
|
431
|
+
|
|
432
|
+
If you need to allow a trusted third-party browser POST source, configure it explicitly:
|
|
433
|
+
|
|
434
|
+
```ts
|
|
435
|
+
export default defineConfig({
|
|
436
|
+
plugins: [
|
|
437
|
+
solas({
|
|
438
|
+
trustedOrigins: ['https://payments.example.com'],
|
|
439
|
+
}),
|
|
440
|
+
],
|
|
441
|
+
})
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
Requests from non-browser callers that do not send browser provenance headers are allowed by default, so typical server-to-server integrations and webhooks continue to work.
|
|
445
|
+
|
|
446
|
+
Cookie-backed app mutations should keep the default same-origin protection and only use `trustedOrigins` for narrowly scoped integrations that genuinely need cross-origin browser submissions.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type PluginOption } from 'vite';
|
|
2
2
|
import type { PluginConfig } from './types.js';
|
|
3
|
-
declare function solas(c
|
|
3
|
+
declare function solas(c?: PluginConfig): PluginOption[];
|
|
4
4
|
export default solas;
|
|
5
5
|
export type * from './solas.d.ts';
|
|
6
6
|
export { Solas } from './solas.js';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fsSync from 'node:fs';
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { createServer, loadConfigFromFile, } from 'vite';
|
|
4
5
|
import rsc from '@vitejs/plugin-rsc';
|
|
5
6
|
import { ExportReader } from './utils/export-reader.js';
|
|
6
7
|
import { Logger } from './utils/logger.js';
|
|
@@ -11,17 +12,20 @@ import { writeBrowserEntry, writeRSCEntry, writeSSREntry, } from './internal/cod
|
|
|
11
12
|
import { writeManifest } from './internal/codegen/manifest.js';
|
|
12
13
|
import { writeMaps } from './internal/codegen/maps.js';
|
|
13
14
|
import { writeTypes } from './internal/codegen/types.js';
|
|
15
|
+
import { postbuild } from './internal/postbuild.js';
|
|
16
|
+
import { collect as collectPublicFiles } from './internal/public-files.js';
|
|
14
17
|
import { Solas } from './solas.js';
|
|
15
18
|
const DEFAULT_CONFIG = {
|
|
16
19
|
precompress: true,
|
|
17
20
|
prerender: false,
|
|
21
|
+
trustedOrigins: [],
|
|
18
22
|
trailingSlash: 'never',
|
|
19
23
|
};
|
|
20
24
|
function solas(c) {
|
|
21
25
|
const config = Solas.Config.validate({
|
|
22
26
|
...DEFAULT_CONFIG,
|
|
23
27
|
...c,
|
|
24
|
-
url: c
|
|
28
|
+
url: c?.url ?? process.env.VITE_APP_URL?.toString(),
|
|
25
29
|
});
|
|
26
30
|
if (config.logger?.level)
|
|
27
31
|
Logger.defaultLevel = config.logger.level;
|
|
@@ -167,11 +171,48 @@ function solas(c) {
|
|
|
167
171
|
rebuildReason = `${event}: ${path.relative(WATCH_CWD, file)}`;
|
|
168
172
|
queue();
|
|
169
173
|
}, 75);
|
|
174
|
+
let resolvedViteConfig = null;
|
|
175
|
+
let utilityServer = null;
|
|
176
|
+
async function getUtilityServer() {
|
|
177
|
+
if (utilityServer)
|
|
178
|
+
return utilityServer;
|
|
179
|
+
if (!resolvedViteConfig)
|
|
180
|
+
throw new Error('Vite config not resolved yet');
|
|
181
|
+
const loaded = await loadConfigFromFile({
|
|
182
|
+
command: resolvedViteConfig.command,
|
|
183
|
+
mode: resolvedViteConfig.mode,
|
|
184
|
+
}, resolvedViteConfig.configFile, resolvedViteConfig.root);
|
|
185
|
+
const config = loaded?.config ?? {};
|
|
186
|
+
// recursively flatten and remove any instances of this plugin
|
|
187
|
+
const plugins = (config.plugins ?? []).flatMap(function flatten(plugin) {
|
|
188
|
+
if (!plugin)
|
|
189
|
+
return [];
|
|
190
|
+
if (Array.isArray(plugin))
|
|
191
|
+
return plugin.flatMap(flatten);
|
|
192
|
+
if (typeof plugin === 'object' &&
|
|
193
|
+
'name' in plugin &&
|
|
194
|
+
plugin.name === Solas.Config.NAME) {
|
|
195
|
+
return [];
|
|
196
|
+
}
|
|
197
|
+
return [plugin];
|
|
198
|
+
});
|
|
199
|
+
utilityServer = await createServer({
|
|
200
|
+
...config,
|
|
201
|
+
configFile: false,
|
|
202
|
+
root: resolvedViteConfig.root,
|
|
203
|
+
mode: resolvedViteConfig.mode,
|
|
204
|
+
server: {
|
|
205
|
+
...config.server,
|
|
206
|
+
middlewareMode: true,
|
|
207
|
+
},
|
|
208
|
+
plugins,
|
|
209
|
+
appType: 'custom',
|
|
210
|
+
});
|
|
211
|
+
return utilityServer;
|
|
212
|
+
}
|
|
170
213
|
const plugin = {
|
|
171
214
|
name: Solas.Config.NAME,
|
|
172
|
-
enforce: 'pre',
|
|
173
215
|
async config(viteConfig) {
|
|
174
|
-
await build();
|
|
175
216
|
const pkg = JSON.parse(fsSync.readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
|
|
176
217
|
if (typeof pkg.name !== 'string' || pkg.name.length === 0) {
|
|
177
218
|
throw new Error(`Missing ${Solas.Config.NAME} package name`);
|
|
@@ -181,7 +222,11 @@ function solas(c) {
|
|
|
181
222
|
}
|
|
182
223
|
viteConfig.build ??= {};
|
|
183
224
|
viteConfig.build.outDir = Solas.Config.OUT_DIR;
|
|
225
|
+
// keep framework files under one reserved url prefix
|
|
226
|
+
viteConfig.build.assetsDir = Solas.Config.ASSETS_DIR;
|
|
184
227
|
viteConfig.build.emptyOutDir = true;
|
|
228
|
+
// let users move the source public folder if they want
|
|
229
|
+
viteConfig.publicDir ??= Solas.Config.PUBLIC_DIR;
|
|
185
230
|
viteConfig.server ??= {};
|
|
186
231
|
viteConfig.server.port = config.port ?? viteConfig.server.port ?? 8787;
|
|
187
232
|
viteConfig.define ??= {};
|
|
@@ -210,6 +255,10 @@ function solas(c) {
|
|
|
210
255
|
'.solas': path.resolve(process.cwd(), Solas.Config.GENERATED_DIR),
|
|
211
256
|
};
|
|
212
257
|
},
|
|
258
|
+
configResolved(resolvedConfig) {
|
|
259
|
+
resolvedViteConfig = resolvedConfig;
|
|
260
|
+
buildContext.command = resolvedConfig.command;
|
|
261
|
+
},
|
|
213
262
|
configureServer(server) {
|
|
214
263
|
logger.info('[configureServer]', `Watching for changes in ./${Solas.Config.APP_DIR}...`);
|
|
215
264
|
server.watcher
|
|
@@ -219,9 +268,22 @@ function solas(c) {
|
|
|
219
268
|
.on('addDir', (p) => rebuild('addDir', p))
|
|
220
269
|
.on('unlinkDir', (p) => rebuild('unlinkDir', p));
|
|
221
270
|
},
|
|
271
|
+
async buildStart() {
|
|
272
|
+
logger.info('[buildStart]', 'building route graph...');
|
|
273
|
+
// create and attach server instance for ExportReader.value to use when
|
|
274
|
+
// loading modules
|
|
275
|
+
if (buildContext.command === 'build') {
|
|
276
|
+
const server = await getUtilityServer();
|
|
277
|
+
buildContext.exportReader.loadModule = server.ssrLoadModule.bind(server);
|
|
278
|
+
}
|
|
279
|
+
await build();
|
|
280
|
+
},
|
|
222
281
|
async closeBundle() {
|
|
223
|
-
if (
|
|
224
|
-
|
|
282
|
+
if (utilityServer) {
|
|
283
|
+
const server = utilityServer;
|
|
284
|
+
utilityServer = null;
|
|
285
|
+
await server.close();
|
|
286
|
+
}
|
|
225
287
|
// resolve sitemap routes
|
|
226
288
|
let sitemapRoutes = [];
|
|
227
289
|
if (config.sitemap && config.url) {
|
|
@@ -238,13 +300,20 @@ function solas(c) {
|
|
|
238
300
|
// write build manifest
|
|
239
301
|
const generatedDir = path.join(process.cwd(), Solas.Config.GENERATED_DIR);
|
|
240
302
|
await Bun.write(path.join(generatedDir, 'build.json'), JSON.stringify({
|
|
303
|
+
base: resolvedViteConfig?.base ?? '/',
|
|
304
|
+
publicFiles: await collectPublicFiles(resolvedViteConfig?.publicDir),
|
|
241
305
|
prerenderRoutes: Array.from(buildContext.prerenderRoutes),
|
|
242
306
|
sitemapRoutes,
|
|
243
307
|
precompress: config.precompress,
|
|
244
308
|
trailingSlash: config.trailingSlash,
|
|
245
309
|
url: config.url,
|
|
246
310
|
}));
|
|
247
|
-
|
|
311
|
+
},
|
|
312
|
+
buildApp: {
|
|
313
|
+
order: 'post',
|
|
314
|
+
async handler() {
|
|
315
|
+
await postbuild(resolvedViteConfig?.root ?? process.cwd());
|
|
316
|
+
},
|
|
248
317
|
},
|
|
249
318
|
};
|
|
250
319
|
return [
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import { useEffect, useRef } from 'react';
|
|
4
|
-
import { BrowserRouter } from './
|
|
4
|
+
import { BrowserRouter } from './shared.js';
|
|
5
5
|
import { useRouter } from './use-router.js';
|
|
6
6
|
function guard(path, prefetcher) {
|
|
7
7
|
const connection = window.navigator.connection;
|
|
@@ -1,169 +1,6 @@
|
|
|
1
1
|
import type { RscPayload } from '../env/rsc.js';
|
|
2
|
-
import {
|
|
3
|
-
export
|
|
4
|
-
export type Params = Record<string, string>;
|
|
5
|
-
export type Query = Record<string, string | number | boolean>;
|
|
6
|
-
export type Path = keyof Solas.Routes & string;
|
|
7
|
-
type Replace = {
|
|
8
|
-
replace?: boolean;
|
|
9
|
-
};
|
|
10
|
-
export type GoOptions = {
|
|
11
|
-
replace?: boolean;
|
|
12
|
-
query?: Query;
|
|
13
|
-
params?: Params;
|
|
14
|
-
};
|
|
15
|
-
/**
|
|
16
|
-
* These targets are used as-is. They are not matched against the route table,
|
|
17
|
-
* so this covers normal external URLs and hash-only links
|
|
18
|
-
*/
|
|
19
|
-
export type ExternalTarget = `${string}:${string}` | `//${string}` | `#${string}`;
|
|
20
|
-
export function isHashOnlyTarget(target: string): boolean;
|
|
21
|
-
export function isExternalTarget(target: string, origin: string): boolean;
|
|
22
|
-
/**
|
|
23
|
-
* Turn a route pattern into the real path shape a caller can use. In practice,
|
|
24
|
-
* every ':param' or '*' part becomes a plain string slot
|
|
25
|
-
*
|
|
26
|
-
* @example
|
|
27
|
-
* ```ts
|
|
28
|
-
* // '/p/:id' becomes '/p/${string}'
|
|
29
|
-
* // '/test/*' becomes '/test/${string}'
|
|
30
|
-
* // '/posts' stays '/posts'
|
|
31
|
-
* ```
|
|
32
|
-
*
|
|
33
|
-
* @example
|
|
34
|
-
* ```ts
|
|
35
|
-
* type A = ResolvedPath<'/posts/:id'>
|
|
36
|
-
* // '/posts/${string}'
|
|
37
|
-
* ```
|
|
38
|
-
*
|
|
39
|
-
* @example
|
|
40
|
-
* ```ts
|
|
41
|
-
* type B = ResolvedPath<'/docs/*'>
|
|
42
|
-
* // '/docs/${string}'
|
|
43
|
-
* ```
|
|
44
|
-
*/
|
|
45
|
-
export type ResolvedPath<TPath extends string> = TPath extends `${infer Start}:${string}/${infer Rest}` ? `${Start}${string}/${ResolvedPath<Rest>}` : TPath extends `${infer Start}:${string}` ? `${Start}${string}` : TPath extends `${infer Start}*${infer Rest}` ? `${Start}${string}${ResolvedPath<Rest>}` : TPath;
|
|
46
|
-
/**
|
|
47
|
-
* Once we have a real path, also allow the usual query-string and hash forms
|
|
48
|
-
*
|
|
49
|
-
* @example
|
|
50
|
-
* ```ts
|
|
51
|
-
* type A = TargetSuffix<'/posts/123'>
|
|
52
|
-
* // '/posts/123' | '/posts/123?${string}' | '/posts/123#${string}' | '/posts/123?${string}#${string}'
|
|
53
|
-
* ```
|
|
54
|
-
*/
|
|
55
|
-
export type TargetSuffix<TPath extends string> = TPath | `${TPath}?${string}` | `${TPath}#${string}` | `${TPath}?${string}#${string}`;
|
|
56
|
-
/**
|
|
57
|
-
* This is the final string form a caller can navigate to. It can be an external
|
|
58
|
-
* URL, or a concrete URL that matches one of the known routes
|
|
59
|
-
*
|
|
60
|
-
* @example
|
|
61
|
-
* ```ts
|
|
62
|
-
* type A = Target
|
|
63
|
-
* // 'https://example.com'
|
|
64
|
-
* // '#intro'
|
|
65
|
-
* // '/posts/123'
|
|
66
|
-
* // '/posts/123?draft=true'
|
|
67
|
-
* ```
|
|
68
|
-
*/
|
|
69
|
-
export type Target = ExternalTarget | TargetSuffix<ResolvedPath<Path>>;
|
|
70
|
-
/**
|
|
71
|
-
* Extra options for callers who already have a finished target string
|
|
72
|
-
*
|
|
73
|
-
* @example
|
|
74
|
-
* ```ts
|
|
75
|
-
* const a: TargetConfig = { query: { page: 2 } }
|
|
76
|
-
* // params is rejected here because the path is already complete
|
|
77
|
-
* ```
|
|
78
|
-
*/
|
|
79
|
-
type TargetConfig = {
|
|
80
|
-
params?: never;
|
|
81
|
-
query?: Query;
|
|
82
|
-
};
|
|
83
|
-
/**
|
|
84
|
-
* Extra options for callers who pass a route pattern and params separately.
|
|
85
|
-
* If the route definition says that route needs params, this type makes
|
|
86
|
-
* those params required. If the route has no params, it rejects them
|
|
87
|
-
*
|
|
88
|
-
* @example
|
|
89
|
-
* ```ts
|
|
90
|
-
* // if Solas.Routes['/posts/:id'] is { params: { id: string } }
|
|
91
|
-
* type A = PatternConfig<'/posts/:id'>
|
|
92
|
-
* // { query?: Query } & { params: { id: string } }
|
|
93
|
-
* ```
|
|
94
|
-
*
|
|
95
|
-
* @example
|
|
96
|
-
* ```ts
|
|
97
|
-
* // if Solas.Routes['/about'] has no params field
|
|
98
|
-
* type B = PatternConfig<'/about'>
|
|
99
|
-
* // { query?: Query } & { params?: never }
|
|
100
|
-
* ```
|
|
101
|
-
*/
|
|
102
|
-
type PatternConfig<TPath extends Path> = {
|
|
103
|
-
query?: Query;
|
|
104
|
-
} & (Solas.Routes[TPath] extends {
|
|
105
|
-
params: infer TParams extends Params;
|
|
106
|
-
} ? {
|
|
107
|
-
params: TParams;
|
|
108
|
-
} : {
|
|
109
|
-
params?: never;
|
|
110
|
-
});
|
|
111
|
-
/**
|
|
112
|
-
* Typed <Link /> props, using `href` instead of the internal `to` name
|
|
113
|
-
*
|
|
114
|
-
* `query` is always allowed
|
|
115
|
-
* `params` are only allowed when `href` is a known route pattern
|
|
116
|
-
*
|
|
117
|
-
* @example
|
|
118
|
-
* ```ts
|
|
119
|
-
* const a: LinkProps = { href: '/posts/:id', params: { id: '123' } }
|
|
120
|
-
* const b: LinkProps = { href: '/posts/123?draft=true' }
|
|
121
|
-
* ```
|
|
122
|
-
*/
|
|
123
|
-
export type LinkProps = ({
|
|
124
|
-
href: Target;
|
|
125
|
-
} & TargetConfig) | (keyof Solas.Routes extends never ? never : {
|
|
126
|
-
[TPath in Path]: {
|
|
127
|
-
href: TPath;
|
|
128
|
-
} & PatternConfig<TPath>;
|
|
129
|
-
}[Path]);
|
|
130
|
-
/**
|
|
131
|
-
* Typed input for router.go(), using the same route rules as <Link />
|
|
132
|
-
*
|
|
133
|
-
* @example
|
|
134
|
-
* ```ts
|
|
135
|
-
* go('/p/post-2')
|
|
136
|
-
* go('/?foo=bar', { replace: true })
|
|
137
|
-
* ```
|
|
138
|
-
*
|
|
139
|
-
* @example
|
|
140
|
-
* ```ts
|
|
141
|
-
* go('/p/:id', { params: { id: 'post-2' }, replace: true })
|
|
142
|
-
* ```
|
|
143
|
-
*
|
|
144
|
-
* The last overload is the fallback for plain `string` values. The
|
|
145
|
-
* `string extends TTo` check stops that fallback from taking over
|
|
146
|
-
* when TypeScript already knows the caller passed a more specific
|
|
147
|
-
* string literal
|
|
148
|
-
*
|
|
149
|
-
* @example
|
|
150
|
-
* ```ts
|
|
151
|
-
* declare const dynamicPath: string
|
|
152
|
-
* go(dynamicPath, { replace: true })
|
|
153
|
-
* ```
|
|
154
|
-
*/
|
|
155
|
-
export type Go = {
|
|
156
|
-
<TTo extends Path>(to: TTo, opts?: PatternConfig<TTo> & Replace): Promise<string>;
|
|
157
|
-
<TTo extends Target>(to: TTo, opts?: TargetConfig & Replace): Promise<string>;
|
|
158
|
-
<TTo extends string>(to: string extends TTo ? TTo : never, opts?: GoOptions): Promise<string>;
|
|
159
|
-
};
|
|
160
|
-
/**
|
|
161
|
-
* Convert a route pattern and params into a real path string. This is used internally
|
|
162
|
-
* to implement <Link /> and router.go
|
|
163
|
-
*/
|
|
164
|
-
export function toTarget(path: string, params?: Record<string, string>, query?: BrowserRouter.Query): string;
|
|
165
|
-
export {};
|
|
166
|
-
}
|
|
2
|
+
import { BrowserRouter } from './shared.js';
|
|
3
|
+
export { BrowserRouter } from './shared.js';
|
|
167
4
|
export declare const BrowserRouterContext: import("react").Context<{
|
|
168
5
|
go: BrowserRouter.Go;
|
|
169
6
|
prefetch: (path: string) => void;
|
|
@@ -4,70 +4,9 @@ import { createContext, useCallback, useEffect, useMemo, useRef } from 'react';
|
|
|
4
4
|
import { createFromFetch } from '@vitejs/plugin-rsc/browser';
|
|
5
5
|
import { Logger } from '../../utils/logger.js';
|
|
6
6
|
import { Solas } from '../../solas.js';
|
|
7
|
-
import { Prefetcher } from '
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
(function (BrowserRouter) {
|
|
11
|
-
function isHashOnlyTarget(target) {
|
|
12
|
-
return target.startsWith('#');
|
|
13
|
-
}
|
|
14
|
-
BrowserRouter.isHashOnlyTarget = isHashOnlyTarget;
|
|
15
|
-
function isExternalTarget(target, origin) {
|
|
16
|
-
if (isHashOnlyTarget(target))
|
|
17
|
-
return false;
|
|
18
|
-
try {
|
|
19
|
-
return new URL(target, origin).origin !== origin;
|
|
20
|
-
}
|
|
21
|
-
catch {
|
|
22
|
-
return false;
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
BrowserRouter.isExternalTarget = isExternalTarget;
|
|
26
|
-
/**
|
|
27
|
-
* Convert a route pattern and params into a real path string. This is used internally
|
|
28
|
-
* to implement <Link /> and router.go
|
|
29
|
-
*/
|
|
30
|
-
function toTarget(path, params, query) {
|
|
31
|
-
// keep track of which params were consumed by named `:param` slots
|
|
32
|
-
const used = new Set();
|
|
33
|
-
// replace each named route param with its URL-encoded value
|
|
34
|
-
let to = path.replaceAll(/:([A-Za-z0-9_]+)/g, (_, key) => {
|
|
35
|
-
const value = params?.[key];
|
|
36
|
-
if (value == null) {
|
|
37
|
-
throw new Error(`[Link]: missing route param: ${key}`);
|
|
38
|
-
}
|
|
39
|
-
used.add(key);
|
|
40
|
-
return encodeURIComponent(value);
|
|
41
|
-
});
|
|
42
|
-
if (to.includes('*')) {
|
|
43
|
-
// wildcard routes use the one param that was not already matched by a named slot
|
|
44
|
-
const remaining = Object.entries(params ?? {}).filter(([key]) => !used.has(key));
|
|
45
|
-
if (remaining.length !== 1) {
|
|
46
|
-
throw new Error('[Link]: wildcard routes require exactly one unmatched param');
|
|
47
|
-
}
|
|
48
|
-
// encode each path segment separately so embedded '/' still acts like a path separator
|
|
49
|
-
to = to.replace('*', remaining[0][1].split('/').map(encodeURIComponent).join('/'));
|
|
50
|
-
}
|
|
51
|
-
if (!query)
|
|
52
|
-
return to;
|
|
53
|
-
// split the URL up so new query params can be merged without losing an existing hash
|
|
54
|
-
const hashIndex = to.indexOf('#');
|
|
55
|
-
const hash = hashIndex >= 0 ? to.slice(hashIndex) : '';
|
|
56
|
-
const pathWithSearch = hashIndex >= 0 ? to.slice(0, hashIndex) : to;
|
|
57
|
-
const searchIndex = pathWithSearch.indexOf('?');
|
|
58
|
-
const pathname = searchIndex >= 0 ? pathWithSearch.slice(0, searchIndex) : pathWithSearch;
|
|
59
|
-
const currentSearch = searchIndex >= 0 ? pathWithSearch.slice(searchIndex + 1) : '';
|
|
60
|
-
const search = new URLSearchParams(currentSearch);
|
|
61
|
-
// later values win, so passed query props overwrite any existing query string values
|
|
62
|
-
for (const [key, value] of Object.entries(query)) {
|
|
63
|
-
search.set(key, String(value));
|
|
64
|
-
}
|
|
65
|
-
const value = search.toString();
|
|
66
|
-
// rebuild the URL in the same order: pathname, optional query string, then hash
|
|
67
|
-
return `${pathname}${value.length > 0 ? `?${value}` : ''}${hash}`;
|
|
68
|
-
}
|
|
69
|
-
BrowserRouter.toTarget = toTarget;
|
|
70
|
-
})(BrowserRouter || (BrowserRouter = {}));
|
|
7
|
+
import { Prefetcher } from '../prefetcher.js';
|
|
8
|
+
import { BrowserRouter } from './shared.js';
|
|
9
|
+
export { BrowserRouter } from './shared.js';
|
|
71
10
|
export const BrowserRouterContext = createContext({
|
|
72
11
|
go: async () => '',
|
|
73
12
|
prefetch: () => { },
|
|
@@ -80,28 +19,16 @@ const DEFAULT_GO_CONFIG = {
|
|
|
80
19
|
const logger = new Logger();
|
|
81
20
|
const prefetcher = new Prefetcher();
|
|
82
21
|
export function BrowserRouterProvider({ children, setPayload, isNavigating = false, url, }) {
|
|
83
|
-
// id to track active navigations
|
|
84
22
|
const id = useRef(0);
|
|
85
|
-
// abort controller for in-flight navigation
|
|
86
23
|
const controller = useRef(null);
|
|
87
|
-
/**
|
|
88
|
-
* Navigate to a new route
|
|
89
|
-
* @param to the destination url (absolute or relative to origin)
|
|
90
|
-
* @param opts navigation options
|
|
91
|
-
* @returns the path that was navigated to (relative to origin)
|
|
92
|
-
*/
|
|
93
24
|
const go = useCallback(async (to, opts = {}) => {
|
|
94
|
-
// increment navigation id to invalidate any in-flight navigations
|
|
95
25
|
id.current += 1;
|
|
96
26
|
const navigationId = id.current;
|
|
97
|
-
// fallback for abort/error paths
|
|
98
27
|
const currentPath = window.location.pathname + window.location.search;
|
|
99
28
|
let path = currentPath;
|
|
100
29
|
const replace = opts?.replace ?? DEFAULT_GO_CONFIG.replace;
|
|
101
30
|
controller.current?.abort();
|
|
102
31
|
controller.current = null;
|
|
103
|
-
// distinguish an actual prior prefetch from a cache entry we create
|
|
104
|
-
// opportunistically for this navigation
|
|
105
32
|
let existing = false;
|
|
106
33
|
try {
|
|
107
34
|
const target = BrowserRouter.toTarget(to, opts.params, opts.query);
|
|
@@ -112,10 +39,7 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
|
|
|
112
39
|
const key = Prefetcher.key(url.toString(), window.location.origin);
|
|
113
40
|
if (!key)
|
|
114
41
|
throw new Error('Invalid navigation url');
|
|
115
|
-
// switch to the normalised target once the url is valid
|
|
116
42
|
path = key;
|
|
117
|
-
// internal client navigation should update the route immediately, even
|
|
118
|
-
// if the subsequent fetch resolves to a 404 or other error state
|
|
119
43
|
if (path !== currentPath) {
|
|
120
44
|
if (replace) {
|
|
121
45
|
window.history.replaceState(null, '', path);
|
|
@@ -124,9 +48,6 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
|
|
|
124
48
|
window.history.pushState(null, '', path);
|
|
125
49
|
}
|
|
126
50
|
}
|
|
127
|
-
// if the target was already prefetched, use the cached response promise
|
|
128
|
-
// and set existing to true so we don't remove it from cache
|
|
129
|
-
// after navigation
|
|
130
51
|
let promise = prefetcher.get(path);
|
|
131
52
|
existing = promise !== undefined;
|
|
132
53
|
if (!promise) {
|
|
@@ -138,27 +59,18 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
|
|
|
138
59
|
});
|
|
139
60
|
prefetcher.set(path, promise);
|
|
140
61
|
}
|
|
141
|
-
// if another navigation has started since this one, ignore the result
|
|
142
|
-
// and return early
|
|
143
62
|
if (navigationId !== id.current)
|
|
144
63
|
return path;
|
|
145
|
-
// we need both the parsed payload and the final response url because
|
|
146
|
-
// redirects can change the canonical path we should store in history
|
|
147
64
|
const [res, payload] = await Promise.all([
|
|
148
65
|
promise,
|
|
149
66
|
createFromFetch(promise),
|
|
150
67
|
]);
|
|
151
|
-
// use the final response url so client history matches server redirects
|
|
152
68
|
const resolvedPath = Prefetcher.key(res.url, window.location.origin) ?? path;
|
|
153
|
-
// check again if another navigation has started while we were awaiting
|
|
154
|
-
// the response
|
|
155
69
|
if (navigationId !== id.current)
|
|
156
70
|
return resolvedPath;
|
|
157
71
|
if (resolvedPath !== path) {
|
|
158
72
|
window.history.replaceState(null, '', resolvedPath);
|
|
159
73
|
}
|
|
160
|
-
// this state update is already wrapped in a
|
|
161
|
-
// transition before being passed as props
|
|
162
74
|
setPayload?.(payload);
|
|
163
75
|
window.dispatchEvent(new CustomEvent(Solas.Events.names.NAVIGATION, {
|
|
164
76
|
detail: { path: resolvedPath },
|
|
@@ -180,20 +92,12 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
|
|
|
180
92
|
finally {
|
|
181
93
|
if (navigationId === id.current)
|
|
182
94
|
controller.current = null;
|
|
183
|
-
// keep entries that were already in the prefetch cache before go() ran. Only remove
|
|
184
|
-
// the temporary cache entry go() created for its own in-flight dedupe
|
|
185
95
|
if (!existing) {
|
|
186
|
-
// this fetch was not an intentional prefetch, so do not leave it behind
|
|
187
|
-
// as a reusable cache entry after navigation finishes
|
|
188
96
|
prefetcher.remove(path);
|
|
189
97
|
}
|
|
190
98
|
}
|
|
191
99
|
return path;
|
|
192
100
|
}, [setPayload]);
|
|
193
|
-
/**
|
|
194
|
-
* Prefetch a route's RSC payload
|
|
195
|
-
* @param path the route path to prefetch (absolute or relative to origin)
|
|
196
|
-
*/
|
|
197
101
|
const prefetch = useCallback((path) => {
|
|
198
102
|
const key = Prefetcher.key(path, window.location.origin);
|
|
199
103
|
if (!key)
|