@jk2908/solas 0.1.1 → 0.2.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/README.md +60 -3
- package/dist/cli.js +61 -13
- package/dist/index.js +20 -6
- package/dist/internal/build.d.ts +4 -2
- package/dist/internal/build.js +10 -4
- package/dist/internal/env/rsc.js +35 -23
- package/dist/internal/navigation/http-exception-boundary.js +2 -2
- package/dist/internal/navigation/link.d.ts +1 -1
- package/dist/internal/navigation/link.js +21 -6
- package/dist/internal/navigation/redirect-boundary.js +5 -3
- package/dist/internal/navigation/redirect.js +1 -1
- package/dist/internal/prerender.js +10 -8
- package/dist/internal/router/prefetcher.js +2 -1
- package/dist/internal/router/resolver.js +16 -1
- package/dist/internal/router/router-provider.js +17 -15
- package/dist/internal/router/router.d.ts +1 -1
- package/dist/internal/router/router.js +41 -24
- package/dist/internal/router/utils.d.ts +21 -0
- package/dist/internal/router/utils.js +62 -0
- package/dist/internal/server/url.js +4 -3
- package/dist/internal/ui/defaults/error.js +1 -1
- package/dist/solas.d.ts +1 -0
- package/dist/solas.js +18 -2
- package/dist/types.d.ts +17 -5
- package/dist/utils/logger.js +10 -1
- package/package.json +5 -1
- package/dist/internal/router/pattern.d.ts +0 -8
- package/dist/internal/router/pattern.js +0 -31
package/README.md
CHANGED
|
@@ -264,20 +264,77 @@ In that example, the final page title becomes `Routing - Solas`.
|
|
|
264
264
|
|
|
265
265
|
### `trailingSlash`
|
|
266
266
|
|
|
267
|
-
Use `trailingSlash`
|
|
267
|
+
Use `trailingSlash` to set the app-wide URL policy.
|
|
268
268
|
|
|
269
|
-
Default: `
|
|
269
|
+
Default: `never`
|
|
270
|
+
|
|
271
|
+
- `never`: `/about/` redirects to `/about`
|
|
272
|
+
- `always`: `/about` redirects to `/about/`
|
|
273
|
+
- `ignore`: both forms resolve without a canonical redirect
|
|
274
|
+
|
|
275
|
+
This is a global setting in `solas()`. Solas does not read `trailingSlash` from route files.
|
|
276
|
+
|
|
277
|
+
Prerendered output follows the same policy. `always` writes route HTML as `about/index.html`, while `never` and `ignore` write it as `about.html`.
|
|
270
278
|
|
|
271
279
|
```ts
|
|
272
280
|
export default defineConfig({
|
|
273
281
|
plugins: [
|
|
274
282
|
solas({
|
|
275
|
-
trailingSlash:
|
|
283
|
+
trailingSlash: 'always',
|
|
276
284
|
}),
|
|
277
285
|
],
|
|
278
286
|
})
|
|
279
287
|
```
|
|
280
288
|
|
|
289
|
+
### `sitemap`
|
|
290
|
+
|
|
291
|
+
Use `sitemap` to generate a `sitemap.xml` at build time.
|
|
292
|
+
|
|
293
|
+
Default: `false`
|
|
294
|
+
|
|
295
|
+
When enabled, Solas writes a sitemap containing all routes with deterministic URLs: static routes, prerendered routes, and dynamic routes resolved via `params`. The origin for each URL comes from `config.url`.
|
|
296
|
+
|
|
297
|
+
```ts
|
|
298
|
+
export default defineConfig(({ mode }) => ({
|
|
299
|
+
plugins: [
|
|
300
|
+
solas({
|
|
301
|
+
url: mode === 'production' ? 'https://example.com' : 'http://localhost:8787',
|
|
302
|
+
sitemap: true,
|
|
303
|
+
}),
|
|
304
|
+
],
|
|
305
|
+
}))
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
Routes with dynamic segments (`[id]`) or catch-all segments (`[...param]`) are only included if they export `params` and `prerender`.
|
|
309
|
+
|
|
310
|
+
To add routes that Solas cannot discover automatically (for example, catch-all routes backed by a CMS), pass an object with a `routes` function. The function receives the auto-discovered routes and returns the final list:
|
|
311
|
+
|
|
312
|
+
```ts
|
|
313
|
+
export default defineConfig(({ mode }) => ({
|
|
314
|
+
plugins: [
|
|
315
|
+
solas({
|
|
316
|
+
url: mode === 'production' ? 'https://example.com' : 'http://localhost:8787',
|
|
317
|
+
sitemap: {
|
|
318
|
+
async routes(discovered) {
|
|
319
|
+
const posts = await fetchPostSlugs()
|
|
320
|
+
return [...discovered, ...posts.map(s => `/blog/${s}`)]
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
}),
|
|
324
|
+
],
|
|
325
|
+
}))
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
The `routes` function can be async. The callback also lets you filter routes:
|
|
329
|
+
|
|
330
|
+
```ts
|
|
331
|
+
sitemap: {
|
|
332
|
+
routes: (r) => r.filter(route => !route.startsWith('/admin')),
|
|
333
|
+
}
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
The sitemap is written to the build output directory as `sitemap.xml` after prerendering and before precompression.
|
|
337
|
+
|
|
281
338
|
### `logger.level`
|
|
282
339
|
|
|
283
340
|
Use `logger.level` to control internal Solas logging.
|
package/dist/cli.js
CHANGED
|
@@ -7,7 +7,14 @@ import { Logger } from './utils/logger';
|
|
|
7
7
|
import { Prerender } from './internal/prerender';
|
|
8
8
|
const logger = new Logger();
|
|
9
9
|
const DEFAULT_PREVIEW_PORT = 4173;
|
|
10
|
+
/**
|
|
11
|
+
* The build command does more than just run vite build - it also handles prerendering and
|
|
12
|
+
* precompressing assets. This is because prerendering needs to run against the built
|
|
13
|
+
* server entry to ensure the same code paths as preview, and precompressing needs
|
|
14
|
+
* to include the prerendered html and json files
|
|
15
|
+
*/
|
|
10
16
|
async function build() {
|
|
17
|
+
// build and prerender should both run in production mode
|
|
11
18
|
process.env.NODE_ENV = 'production';
|
|
12
19
|
const cwd = process.cwd();
|
|
13
20
|
const manifestPath = path.join(cwd, Solas.Config.GENERATED_DIR, 'build.json');
|
|
@@ -40,24 +47,24 @@ async function build() {
|
|
|
40
47
|
// do not keep stale metadata from a previous build
|
|
41
48
|
await fs.rm(artifactRoot, { recursive: true, force: true });
|
|
42
49
|
// prerender routes
|
|
43
|
-
if (manifest.
|
|
50
|
+
if (manifest.prerenderRoutes.length > 0) {
|
|
44
51
|
const timeout = Prerender.Build.getTimeout();
|
|
45
52
|
const concurrency = Prerender.Build.getConcurrency();
|
|
53
|
+
// track the extra prerender files we write for preview
|
|
46
54
|
const artifactManifestRoutes = {};
|
|
47
|
-
logger.info('[prerender]', `prerendering ${manifest.
|
|
48
|
-
//
|
|
49
|
-
process.env.NODE_ENV = 'production';
|
|
55
|
+
logger.info('[prerender]', `prerendering ${manifest.prerenderRoutes.length} routes (timeout: ${timeout}ms, concurrency: ${concurrency})...`);
|
|
56
|
+
// load the built server entry and render each prerendered route through it
|
|
50
57
|
const rscEntry = path.join(rscDir, 'index.js');
|
|
51
58
|
const { default: app } = await import(/* @vite-ignore */ rscEntry);
|
|
52
|
-
|
|
59
|
+
// run prerender through the built app so build output uses the same path as preview
|
|
60
|
+
for await (const result of Prerender.Build.run(app, manifest.prerenderRoutes, {
|
|
53
61
|
timeout,
|
|
54
62
|
concurrency,
|
|
55
63
|
origin: manifest.url,
|
|
56
64
|
})) {
|
|
57
65
|
const route = result.route;
|
|
58
66
|
try {
|
|
59
|
-
|
|
60
|
-
// folder for this route's build notes/files
|
|
67
|
+
// store prerender metadata for this route under the framework folder
|
|
61
68
|
const artifactDir = Prerender.Artifact.getPath(outDir, route);
|
|
62
69
|
if ('error' in result)
|
|
63
70
|
throw result.error;
|
|
@@ -67,7 +74,7 @@ async function build() {
|
|
|
67
74
|
}
|
|
68
75
|
const artifact = result.artifact;
|
|
69
76
|
if (artifact.mode === 'ppr') {
|
|
70
|
-
// for ppr
|
|
77
|
+
// for ppr save the shell now and keep the postponed state for later
|
|
71
78
|
await fs.mkdir(artifactDir, { recursive: true });
|
|
72
79
|
const writes = [
|
|
73
80
|
Bun.write(path.join(artifactDir, 'prelude.html'), artifact.html),
|
|
@@ -104,7 +111,7 @@ async function build() {
|
|
|
104
111
|
continue;
|
|
105
112
|
}
|
|
106
113
|
// @todo: hash files
|
|
107
|
-
//
|
|
114
|
+
// full prerender still keeps metadata so preview knows to serve saved html
|
|
108
115
|
await fs.mkdir(artifactDir, { recursive: true });
|
|
109
116
|
await Bun.write(path.join(artifactDir, 'metadata.json'), JSON.stringify({
|
|
110
117
|
schema: artifact.schema,
|
|
@@ -112,9 +119,29 @@ async function build() {
|
|
|
112
119
|
createdAt: artifact.createdAt,
|
|
113
120
|
mode: artifact.mode,
|
|
114
121
|
}));
|
|
122
|
+
const routePath = route.replace(/^\//, '').replace(/\/$/, '');
|
|
115
123
|
const outPath = route === '/'
|
|
116
124
|
? path.join(outDir, 'index.html')
|
|
117
|
-
:
|
|
125
|
+
: manifest.trailingSlash === 'always'
|
|
126
|
+
? path.join(outDir, routePath, 'index.html')
|
|
127
|
+
: path.join(outDir, `${routePath}.html`);
|
|
128
|
+
// remove the old file shape for this route so switching trailingSlash mode does not leave
|
|
129
|
+
// both variants behind. we have to do this before writing the new file so that if the
|
|
130
|
+
// route shape changes, we still remove the old one instead of leaving in the output
|
|
131
|
+
const alternateOutPath = route === '/'
|
|
132
|
+
? null
|
|
133
|
+
: manifest.trailingSlash === 'always'
|
|
134
|
+
? path.join(outDir, `${routePath}.html`)
|
|
135
|
+
: path.join(outDir, routePath, 'index.html');
|
|
136
|
+
if (alternateOutPath) {
|
|
137
|
+
// remove the old file shape so switching trailingSlash mode
|
|
138
|
+
// does not leave both variants behind
|
|
139
|
+
await Promise.all([
|
|
140
|
+
fs.rm(alternateOutPath, { force: true }),
|
|
141
|
+
fs.rm(`${alternateOutPath}.br`, { force: true }),
|
|
142
|
+
]);
|
|
143
|
+
await fs.rmdir(path.dirname(alternateOutPath)).catch(() => { });
|
|
144
|
+
}
|
|
118
145
|
await fs.mkdir(path.dirname(outPath), { recursive: true });
|
|
119
146
|
await Bun.write(outPath, artifact.html);
|
|
120
147
|
artifactManifestRoutes[route] = {
|
|
@@ -128,15 +155,32 @@ async function build() {
|
|
|
128
155
|
logger.error('[prerender]', `failed ${route}: ${err}. This often means unresolved async work (for example external fetches or dynamic rendering in full mode).`);
|
|
129
156
|
}
|
|
130
157
|
}
|
|
158
|
+
// write one manifest for the saved prerender files after all routes finish
|
|
131
159
|
await fs.mkdir(artifactRoot, { recursive: true });
|
|
132
160
|
await Bun.write(Prerender.Artifact.getManifestPath(outDir), JSON.stringify({
|
|
133
161
|
generatedAt: Date.now(),
|
|
134
162
|
routes: artifactManifestRoutes,
|
|
135
163
|
}));
|
|
136
164
|
}
|
|
165
|
+
// sitemap
|
|
166
|
+
if (manifest.sitemapRoutes.length > 0 && manifest.url) {
|
|
167
|
+
const origin = manifest.url.replace(/\/$/, '');
|
|
168
|
+
const urls = manifest.sitemapRoutes
|
|
169
|
+
.map(route => ` <url><loc>${origin}${route}</loc></url>`)
|
|
170
|
+
.join('\n');
|
|
171
|
+
const sitemap = [
|
|
172
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
173
|
+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
|
174
|
+
urls,
|
|
175
|
+
'</urlset>',
|
|
176
|
+
].join('\n');
|
|
177
|
+
await Bun.write(path.join(outDir, 'sitemap.xml'), sitemap);
|
|
178
|
+
logger.info('[sitemap]', `generated ${manifest.sitemapRoutes.length} urls`);
|
|
179
|
+
}
|
|
137
180
|
// precompress
|
|
138
181
|
if (manifest.precompress) {
|
|
139
182
|
logger.info('[precompress]', 'compressing assets...');
|
|
183
|
+
// compress after prerender so generated html and json are included too
|
|
140
184
|
for await (const { input, compressed } of Compress.run(outDir, {
|
|
141
185
|
filter: f => /\.(js|css|html|svg|json|txt)$/.test(f),
|
|
142
186
|
})) {
|
|
@@ -145,6 +189,7 @@ async function build() {
|
|
|
145
189
|
}
|
|
146
190
|
}
|
|
147
191
|
// cleanup
|
|
192
|
+
// this file is only needed while the build command is running
|
|
148
193
|
await fs.unlink(manifestPath).catch(() => { });
|
|
149
194
|
logger.info('[build]', 'done');
|
|
150
195
|
}
|
|
@@ -159,6 +204,7 @@ async function dev() {
|
|
|
159
204
|
await proc.exited;
|
|
160
205
|
}
|
|
161
206
|
async function preview() {
|
|
207
|
+
// preview should behave like production, not like vite dev
|
|
162
208
|
process.env.NODE_ENV = 'production';
|
|
163
209
|
const cwd = process.cwd();
|
|
164
210
|
const outDir = path.resolve(cwd, Solas.Config.OUT_DIR);
|
|
@@ -168,11 +214,12 @@ async function preview() {
|
|
|
168
214
|
const parsedPort = portFlagIndex >= 0 && args[portFlagIndex + 1]
|
|
169
215
|
? Number(args[portFlagIndex + 1])
|
|
170
216
|
: DEFAULT_PREVIEW_PORT;
|
|
217
|
+
// fail fast if the port is invalid
|
|
171
218
|
if (!Number.isInteger(parsedPort) || parsedPort <= 0 || parsedPort > 65535) {
|
|
172
219
|
logger.error(`[preview] invalid port: ${args[portFlagIndex + 1] ?? 'undefined'}`);
|
|
173
220
|
process.exit(1);
|
|
174
221
|
}
|
|
175
|
-
//
|
|
222
|
+
// the built server entry handles routing, prerendered html, and ssr here
|
|
176
223
|
try {
|
|
177
224
|
await fs.access(rscEntry);
|
|
178
225
|
}
|
|
@@ -182,6 +229,7 @@ async function preview() {
|
|
|
182
229
|
}
|
|
183
230
|
const { default: app } = await import(/* @vite-ignore */ rscEntry);
|
|
184
231
|
try {
|
|
232
|
+
// keep the preview server thin and let the app handle requests
|
|
185
233
|
Bun.serve({
|
|
186
234
|
port: parsedPort,
|
|
187
235
|
fetch: app.fetch,
|
|
@@ -192,10 +240,10 @@ async function preview() {
|
|
|
192
240
|
process.exit(1);
|
|
193
241
|
}
|
|
194
242
|
logger.info('[preview]', `server running at http://localhost:${parsedPort}`);
|
|
195
|
-
// keep
|
|
243
|
+
// keep the process running after the server starts
|
|
196
244
|
await new Promise(() => { });
|
|
197
245
|
}
|
|
198
|
-
//
|
|
246
|
+
// read the subcommand once and dispatch below
|
|
199
247
|
const [, , command, ...args] = process.argv;
|
|
200
248
|
switch (command) {
|
|
201
249
|
case 'build':
|
package/dist/index.js
CHANGED
|
@@ -15,7 +15,7 @@ import { writeMaps } from './internal/codegen/maps';
|
|
|
15
15
|
const DEFAULT_CONFIG = {
|
|
16
16
|
precompress: true,
|
|
17
17
|
prerender: false,
|
|
18
|
-
trailingSlash:
|
|
18
|
+
trailingSlash: 'never',
|
|
19
19
|
};
|
|
20
20
|
function solas(c) {
|
|
21
21
|
const config = Solas.Config.validate({
|
|
@@ -28,7 +28,8 @@ function solas(c) {
|
|
|
28
28
|
const logger = new Logger();
|
|
29
29
|
const exportReader = new ExportReader();
|
|
30
30
|
const buildContext = {
|
|
31
|
-
|
|
31
|
+
prerenderRoutes: new Set(),
|
|
32
|
+
knownRoutes: new Set(),
|
|
32
33
|
exportReader,
|
|
33
34
|
};
|
|
34
35
|
// cache for file contents to avoid unnecessary readFile invocations
|
|
@@ -87,9 +88,9 @@ function solas(c) {
|
|
|
87
88
|
fs.mkdir(generatedDir, { recursive: true }),
|
|
88
89
|
]);
|
|
89
90
|
const processor = new Build.Finder(buildContext, config);
|
|
90
|
-
const { manifest,
|
|
91
|
-
|
|
92
|
-
buildContext.
|
|
91
|
+
const { manifest, prerenderRoutes, knownRoutes, imports, modules } = await processor.run();
|
|
92
|
+
buildContext.prerenderRoutes = prerenderRoutes;
|
|
93
|
+
buildContext.knownRoutes = knownRoutes;
|
|
93
94
|
const files = [
|
|
94
95
|
['config.ts', writeConfig(config)],
|
|
95
96
|
['manifest.ts', writeManifest(manifest)],
|
|
@@ -210,11 +211,24 @@ function solas(c) {
|
|
|
210
211
|
async closeBundle() {
|
|
211
212
|
if (process.env.NODE_ENV === 'development')
|
|
212
213
|
return;
|
|
214
|
+
// resolve sitemap routes
|
|
215
|
+
let sitemapRoutes = [];
|
|
216
|
+
if (config.sitemap && config.url) {
|
|
217
|
+
const auto = [...new Set([...buildContext.knownRoutes, ...buildContext.prerenderRoutes])];
|
|
218
|
+
if (typeof config.sitemap === 'object' && config.sitemap.routes) {
|
|
219
|
+
sitemapRoutes = await config.sitemap.routes(auto);
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
sitemapRoutes = auto;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
213
225
|
// write build manifest
|
|
214
226
|
const generatedDir = path.join(process.cwd(), Solas.Config.GENERATED_DIR);
|
|
215
227
|
await Bun.write(path.join(generatedDir, 'build.json'), JSON.stringify({
|
|
216
|
-
|
|
228
|
+
prerenderRoutes: Array.from(buildContext.prerenderRoutes),
|
|
229
|
+
sitemapRoutes,
|
|
217
230
|
precompress: config.precompress,
|
|
231
|
+
trailingSlash: config.trailingSlash,
|
|
218
232
|
url: config.url,
|
|
219
233
|
}));
|
|
220
234
|
logger.info('[closeBundle]', 'vite build complete');
|
package/dist/internal/build.d.ts
CHANGED
|
@@ -89,7 +89,8 @@ export declare namespace Build {
|
|
|
89
89
|
manifest: Record<string, (Endpoint | Segment)[] | Endpoint | Segment>;
|
|
90
90
|
imports: Imports;
|
|
91
91
|
modules: Modules;
|
|
92
|
-
|
|
92
|
+
prerenderRoutes: Set<string>;
|
|
93
|
+
knownRoutes: Set<string>;
|
|
93
94
|
}>;
|
|
94
95
|
/**
|
|
95
96
|
* Process the scanned route data
|
|
@@ -98,7 +99,8 @@ export declare namespace Build {
|
|
|
98
99
|
manifest: Record<string, (Endpoint | Segment)[] | Endpoint | Segment>;
|
|
99
100
|
imports: Imports;
|
|
100
101
|
modules: Modules;
|
|
101
|
-
|
|
102
|
+
prerenderRoutes: Set<string>;
|
|
103
|
+
knownRoutes: Set<string>;
|
|
102
104
|
}>;
|
|
103
105
|
}
|
|
104
106
|
}
|
package/dist/internal/build.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { Solas } from '../solas';
|
|
4
4
|
import { Logger } from '../utils/logger';
|
|
5
|
+
import { normalisePathname } from './router/utils';
|
|
5
6
|
import { Prerender } from './prerender';
|
|
6
7
|
export { Build };
|
|
7
8
|
var Build;
|
|
@@ -317,7 +318,9 @@ var Build;
|
|
|
317
318
|
*/
|
|
318
319
|
async process(res) {
|
|
319
320
|
const processed = new Set();
|
|
320
|
-
const
|
|
321
|
+
const prerenderRoutes = new Set();
|
|
322
|
+
const knownRoutes = new Set();
|
|
323
|
+
const trailingSlash = this.config?.trailingSlash ?? 'never';
|
|
321
324
|
const manifest = {};
|
|
322
325
|
// imports for endpoints and components
|
|
323
326
|
const imports = {
|
|
@@ -485,15 +488,18 @@ var Build;
|
|
|
485
488
|
: false;
|
|
486
489
|
if (shouldPrerender) {
|
|
487
490
|
if (!isDynamic && !isWildcard) {
|
|
488
|
-
|
|
491
|
+
prerenderRoutes.add(normalisePathname(route, trailingSlash));
|
|
489
492
|
}
|
|
490
493
|
else if (pagePath) {
|
|
491
494
|
const staticParams = await Prerender.Build.getStaticParams(pagePath, this.buildContext);
|
|
492
495
|
for (const r of Prerender.Build.getDynamicRouteList(route, params, staticParams)) {
|
|
493
|
-
|
|
496
|
+
prerenderRoutes.add(normalisePathname(r, trailingSlash));
|
|
494
497
|
}
|
|
495
498
|
}
|
|
496
499
|
}
|
|
500
|
+
if (!isDynamic && !isWildcard) {
|
|
501
|
+
knownRoutes.add(normalisePathname(route, trailingSlash));
|
|
502
|
+
}
|
|
497
503
|
const entry = {
|
|
498
504
|
__id: entryId,
|
|
499
505
|
__path: route,
|
|
@@ -626,7 +632,7 @@ var Build;
|
|
|
626
632
|
logger.error('[Build:Finder:process]: failed to process route', err);
|
|
627
633
|
}
|
|
628
634
|
}
|
|
629
|
-
return { manifest, imports, modules,
|
|
635
|
+
return { manifest, imports, modules, prerenderRoutes, knownRoutes };
|
|
630
636
|
}
|
|
631
637
|
}
|
|
632
638
|
Build.Finder = Finder;
|
package/dist/internal/env/rsc.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
import { createTemporaryReferenceSet, decodeAction, decodeFormState, decodeReply, loadServerAction, renderToReadableStream, } from '@vitejs/plugin-rsc/rsc';
|
|
3
4
|
import { Solas } from '../../solas';
|
|
4
5
|
import { Logger } from '../../utils/logger';
|
|
6
|
+
import { normalisePathname } from '../router/utils';
|
|
5
7
|
import { getKnownDigest, isKnownError } from './utils';
|
|
6
8
|
import { Metadata } from '../metadata';
|
|
7
9
|
import { HttpException, isHttpException } from '../navigation/http-exception';
|
|
@@ -214,10 +216,11 @@ export async function maybeActionWithParsedFormData(req) {
|
|
|
214
216
|
* with a fetch method that handles requests
|
|
215
217
|
*/
|
|
216
218
|
export function createHandler(config, manifest, importMap) {
|
|
219
|
+
const prerenderPathMode = config.trailingSlash === 'always' ? 'always' : 'never';
|
|
217
220
|
const fullyPrerenderedRoutes = new Set(Object.values(manifest)
|
|
218
221
|
.flat()
|
|
219
222
|
.filter(entry => 'prerender' in entry && String(entry.prerender) === 'full')
|
|
220
|
-
.map(entry => entry.__path));
|
|
223
|
+
.map(entry => normalisePathname(entry.__path, prerenderPathMode)));
|
|
221
224
|
/**
|
|
222
225
|
* Create the HTTP response for a single incoming request. Runs actions when needed,
|
|
223
226
|
* converts the payload into component, HTML, or prerender artifact responses, and
|
|
@@ -245,6 +248,7 @@ export function createHandler(config, manifest, importMap) {
|
|
|
245
248
|
}
|
|
246
249
|
const mod = await import.meta.viteRsc.loadModule('ssr', 'index');
|
|
247
250
|
const pathname = new URL(req.url).pathname;
|
|
251
|
+
const lookupPath = normalisePathname(pathname, prerenderPathMode);
|
|
248
252
|
const runtimePpr = !import.meta.env.DEV && ppr;
|
|
249
253
|
// prerender artifact requests bypass the normal document path so the cli
|
|
250
254
|
// gets structured JSON instead of a rendered html response
|
|
@@ -267,20 +271,20 @@ export function createHandler(config, manifest, importMap) {
|
|
|
267
271
|
const artifactManifest = runtimePpr
|
|
268
272
|
? await Prerender.Artifact.loadManifest(Solas.Config.OUT_DIR)
|
|
269
273
|
: null;
|
|
270
|
-
const artifactManifestEntry = artifactManifest?.routes[
|
|
274
|
+
const artifactManifestEntry = artifactManifest?.routes[lookupPath] ?? null;
|
|
271
275
|
let tryPrelude = false;
|
|
272
276
|
if (artifactManifestEntry) {
|
|
273
277
|
tryPrelude = artifactManifestEntry.mode === 'ppr';
|
|
274
278
|
}
|
|
275
279
|
else if (runtimePpr) {
|
|
276
|
-
const artifactMetadata = await Prerender.Artifact.loadMetadata(Solas.Config.OUT_DIR,
|
|
280
|
+
const artifactMetadata = await Prerender.Artifact.loadMetadata(Solas.Config.OUT_DIR, lookupPath);
|
|
277
281
|
tryPrelude =
|
|
278
282
|
!!artifactMetadata &&
|
|
279
|
-
Prerender.Artifact.isCompatible(artifactMetadata,
|
|
283
|
+
Prerender.Artifact.isCompatible(artifactMetadata, lookupPath, 'ppr');
|
|
280
284
|
}
|
|
281
285
|
if (tryPrelude) {
|
|
282
|
-
const postponedState = await Prerender.Artifact.loadPostponedState(Solas.Config.OUT_DIR,
|
|
283
|
-
const prelude = await Prerender.Artifact.loadPrelude(Solas.Config.OUT_DIR,
|
|
286
|
+
const postponedState = await Prerender.Artifact.loadPostponedState(Solas.Config.OUT_DIR, lookupPath);
|
|
287
|
+
const prelude = await Prerender.Artifact.loadPrelude(Solas.Config.OUT_DIR, lookupPath);
|
|
284
288
|
// resumable ppr responses splice fresh streamed content into the cached
|
|
285
289
|
// prelude when postponed state is available for this route
|
|
286
290
|
if (postponedState) {
|
|
@@ -319,37 +323,45 @@ export function createHandler(config, manifest, importMap) {
|
|
|
319
323
|
async fetch(req) {
|
|
320
324
|
const url = new URL(req.url);
|
|
321
325
|
const accept = req.headers.get('accept') ?? '';
|
|
326
|
+
const method = req.method.toUpperCase();
|
|
327
|
+
const canonicalPath = config.trailingSlash === 'ignore'
|
|
328
|
+
? url.pathname
|
|
329
|
+
: normalisePathname(url.pathname, config.trailingSlash);
|
|
330
|
+
if ((method === 'GET' || method === 'HEAD') &&
|
|
331
|
+
config.trailingSlash !== 'ignore' &&
|
|
332
|
+
canonicalPath !== url.pathname) {
|
|
333
|
+
url.pathname = canonicalPath;
|
|
334
|
+
return Response.redirect(url.toString(), 308);
|
|
335
|
+
}
|
|
322
336
|
// fully prerendered html can be served straight from disk for normal
|
|
323
337
|
// document requests, but artifact generation must still hit the runtime path
|
|
324
338
|
if (!import.meta.env.DEV &&
|
|
325
339
|
accept.includes('text/html') &&
|
|
326
340
|
req.headers.get(`x-${Solas.Config.SLUG}-prerender-artifact`) !== '1') {
|
|
327
|
-
const pathname =
|
|
341
|
+
const pathname = canonicalPath;
|
|
342
|
+
const lookupPath = normalisePathname(pathname, prerenderPathMode);
|
|
343
|
+
const routePath = lookupPath.replace(/^\//, '').replace(/\/$/, '');
|
|
328
344
|
let prerenderPath = null;
|
|
329
345
|
const artifactManifest = await Prerender.Artifact.loadManifest(Solas.Config.OUT_DIR);
|
|
330
|
-
const artifactManifestEntry = artifactManifest?.routes[
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
346
|
+
const artifactManifestEntry = artifactManifest?.routes[lookupPath] ?? null;
|
|
347
|
+
const fullHtmlPath = lookupPath === '/'
|
|
348
|
+
? path.join(Solas.Config.OUT_DIR, 'index.html')
|
|
349
|
+
: config.trailingSlash === 'always'
|
|
350
|
+
? path.join(Solas.Config.OUT_DIR, routePath, 'index.html')
|
|
351
|
+
: path.join(Solas.Config.OUT_DIR, `${routePath}.html`);
|
|
352
|
+
if (fullyPrerenderedRoutes.has(lookupPath)) {
|
|
353
|
+
prerenderPath = fullHtmlPath;
|
|
336
354
|
}
|
|
337
355
|
else if (artifactManifestEntry) {
|
|
338
356
|
if (artifactManifestEntry.mode === 'full') {
|
|
339
|
-
prerenderPath =
|
|
340
|
-
pathname === '/'
|
|
341
|
-
? Solas.Config.OUT_DIR + '/index.html'
|
|
342
|
-
: Solas.Config.OUT_DIR + pathname + '/index.html';
|
|
357
|
+
prerenderPath = fullHtmlPath;
|
|
343
358
|
}
|
|
344
359
|
}
|
|
345
360
|
else {
|
|
346
|
-
const artifactMetadata = await Prerender.Artifact.loadMetadata(Solas.Config.OUT_DIR,
|
|
361
|
+
const artifactMetadata = await Prerender.Artifact.loadMetadata(Solas.Config.OUT_DIR, lookupPath);
|
|
347
362
|
if (artifactMetadata &&
|
|
348
|
-
Prerender.Artifact.isCompatible(artifactMetadata,
|
|
349
|
-
prerenderPath =
|
|
350
|
-
pathname === '/'
|
|
351
|
-
? Solas.Config.OUT_DIR + '/index.html'
|
|
352
|
-
: Solas.Config.OUT_DIR + pathname + '/index.html';
|
|
363
|
+
Prerender.Artifact.isCompatible(artifactMetadata, lookupPath, 'full')) {
|
|
364
|
+
prerenderPath = fullHtmlPath;
|
|
353
365
|
}
|
|
354
366
|
}
|
|
355
367
|
if (prerenderPath) {
|
|
@@ -27,9 +27,9 @@ export function HttpExceptionBoundary({ components, children, }) {
|
|
|
27
27
|
if (!isHttpException(err))
|
|
28
28
|
throw err;
|
|
29
29
|
if ('digest' in err && typeof err.digest === 'string') {
|
|
30
|
-
|
|
30
|
+
// split with care, the message part may contain colons
|
|
31
|
+
const [type, code] = err.digest.split(':');
|
|
31
32
|
if (type === HTTP_EXCEPTION_DIGEST_PREFIX) {
|
|
32
|
-
const [code] = rest;
|
|
33
33
|
const status = Number(code);
|
|
34
34
|
if (!isSupportedStatusCode(status))
|
|
35
35
|
throw err;
|
|
@@ -5,7 +5,7 @@ type Props = {
|
|
|
5
5
|
/**
|
|
6
6
|
* A link component that navigates to a given href
|
|
7
7
|
* @param href - the href to navigate to
|
|
8
|
-
* @param prefetch - when to prefetch the linked page, defaults to '
|
|
8
|
+
* @param prefetch - when to prefetch the linked page, defaults to 'none'
|
|
9
9
|
* @param rest - other props to pass to the underlying anchor element
|
|
10
10
|
* @returns a link element that navigates to the given href
|
|
11
11
|
*/
|
|
@@ -1,17 +1,32 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
-
import { useRef } from 'react';
|
|
3
|
+
import { useEffect, useRef } from 'react';
|
|
4
4
|
import { useRouter } from '../router/use-router';
|
|
5
|
+
function guard(path, prefetcher) {
|
|
6
|
+
const connection = window.navigator.connection;
|
|
7
|
+
if (document.visibilityState === 'hidden')
|
|
8
|
+
return;
|
|
9
|
+
if (connection?.saveData)
|
|
10
|
+
return;
|
|
11
|
+
if (['2g', 'slow-2g'].includes(connection?.effectiveType ?? ''))
|
|
12
|
+
return;
|
|
13
|
+
prefetcher(path);
|
|
14
|
+
}
|
|
5
15
|
/**
|
|
6
16
|
* A link component that navigates to a given href
|
|
7
17
|
* @param href - the href to navigate to
|
|
8
|
-
* @param prefetch - when to prefetch the linked page, defaults to '
|
|
18
|
+
* @param prefetch - when to prefetch the linked page, defaults to 'none'
|
|
9
19
|
* @param rest - other props to pass to the underlying anchor element
|
|
10
20
|
* @returns a link element that navigates to the given href
|
|
11
21
|
*/
|
|
12
|
-
export function Link({ children, href, prefetch = '
|
|
22
|
+
export function Link({ children, href, prefetch = 'none', ...rest }) {
|
|
13
23
|
const { go, prefetch: prefetcher } = useRouter();
|
|
14
24
|
const timer = useRef(null);
|
|
25
|
+
// clear any pending hover-prefetch timer on unmount
|
|
26
|
+
useEffect(() => () => {
|
|
27
|
+
if (timer.current)
|
|
28
|
+
clearTimeout(timer.current);
|
|
29
|
+
}, []);
|
|
15
30
|
return (_jsx("a", { ...rest, href: href, onClick: e => {
|
|
16
31
|
rest.onClick?.(e);
|
|
17
32
|
if (e.defaultPrevented)
|
|
@@ -36,14 +51,14 @@ export function Link({ children, href, prefetch = 'intent', ...rest }) {
|
|
|
36
51
|
return;
|
|
37
52
|
if (prefetch !== 'intent')
|
|
38
53
|
return;
|
|
39
|
-
|
|
54
|
+
guard(href, prefetcher);
|
|
40
55
|
}, onTouchStart: e => {
|
|
41
56
|
rest.onTouchStart?.(e);
|
|
42
57
|
if (e.defaultPrevented)
|
|
43
58
|
return;
|
|
44
59
|
if (prefetch !== 'intent')
|
|
45
60
|
return;
|
|
46
|
-
|
|
61
|
+
guard(href, prefetcher);
|
|
47
62
|
}, onMouseEnter: e => {
|
|
48
63
|
rest.onMouseEnter?.(e);
|
|
49
64
|
if (e.defaultPrevented)
|
|
@@ -51,7 +66,7 @@ export function Link({ children, href, prefetch = 'intent', ...rest }) {
|
|
|
51
66
|
if (prefetch !== 'hover')
|
|
52
67
|
return;
|
|
53
68
|
timer.current = setTimeout(() => {
|
|
54
|
-
|
|
69
|
+
guard(href, prefetcher);
|
|
55
70
|
}, 100);
|
|
56
71
|
}, onMouseLeave: e => {
|
|
57
72
|
rest.onMouseLeave?.(e);
|
|
@@ -28,10 +28,12 @@ export function RedirectBoundary({ children }) {
|
|
|
28
28
|
if (!isRedirect(err))
|
|
29
29
|
throw err;
|
|
30
30
|
if ('digest' in err && typeof err.digest === 'string') {
|
|
31
|
-
|
|
31
|
+
// rejoin after status so urls with colons (https://...) stay intact
|
|
32
|
+
const [type, , ...parts] = err.digest.split(':');
|
|
32
33
|
if (type === REDIRECT_DIGEST_PREFIX) {
|
|
33
|
-
const
|
|
34
|
-
|
|
34
|
+
const url = parts.join(':');
|
|
35
|
+
if (url)
|
|
36
|
+
return _jsx("meta", { httpEquiv: "refresh", content: `0;url=${url}` });
|
|
35
37
|
}
|
|
36
38
|
}
|
|
37
39
|
return null;
|
|
@@ -7,6 +7,7 @@ export class Redirect extends Error {
|
|
|
7
7
|
status;
|
|
8
8
|
digest;
|
|
9
9
|
constructor(url, status = 307) {
|
|
10
|
+
validate(url);
|
|
10
11
|
super(`Redirecting to ${url} with status ${status}`);
|
|
11
12
|
this.url = url;
|
|
12
13
|
this.status = status;
|
|
@@ -58,6 +59,5 @@ export function isRedirect(err) {
|
|
|
58
59
|
* @param status - the HTTP status code for the redirect, defaults to 307
|
|
59
60
|
*/
|
|
60
61
|
export function redirect(url, status = 307) {
|
|
61
|
-
validate(url);
|
|
62
62
|
throw new Redirect(url, status);
|
|
63
63
|
}
|
|
@@ -3,7 +3,7 @@ import { compile } from 'path-to-regexp';
|
|
|
3
3
|
import { Solas } from '../solas';
|
|
4
4
|
import { Logger } from '../utils/logger';
|
|
5
5
|
import { Time } from '../utils/time';
|
|
6
|
-
import { toPathPattern } from './router/
|
|
6
|
+
import { toPathPattern } from './router/utils';
|
|
7
7
|
const logger = new Logger();
|
|
8
8
|
export { Prerender };
|
|
9
9
|
var Prerender;
|
|
@@ -211,20 +211,22 @@ var Prerender;
|
|
|
211
211
|
artifactMetadata.mode === mode);
|
|
212
212
|
}
|
|
213
213
|
Artifact.isCompatible = isCompatible;
|
|
214
|
+
// shared instances, both are stateless so one per module is fine
|
|
215
|
+
const encoder = new TextEncoder();
|
|
216
|
+
const decoder = new TextDecoder();
|
|
214
217
|
/**
|
|
215
218
|
* Compose the prelude HTML and the resume stream into a single HTML stream, by injecting the resume stream
|
|
216
219
|
* into the prelude at the appropriate location (before </body> or </html>)
|
|
217
220
|
*/
|
|
218
221
|
function composePreludeAndResume(prelude, resumeStream) {
|
|
219
|
-
|
|
220
|
-
const bodyClose =
|
|
221
|
-
const htmlClose =
|
|
222
|
-
const splitAt = bodyClose >= 0
|
|
222
|
+
// search both cases to avoid duplicating the full string with toLowerCase
|
|
223
|
+
const bodyClose = Math.max(prelude.lastIndexOf('</body>'), prelude.lastIndexOf('</BODY>'));
|
|
224
|
+
const htmlClose = Math.max(prelude.lastIndexOf('</html>'), prelude.lastIndexOf('</HTML>'));
|
|
225
|
+
const splitAt = bodyClose >= 0 ? bodyClose : htmlClose >= 0 ? htmlClose : prelude.length;
|
|
223
226
|
return new ReadableStream({
|
|
224
227
|
async start(controller) {
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
controller.enqueue(new TextEncoder().encode(prelude.slice(0, splitAt)));
|
|
228
|
+
// send everything before the closing tags so the resume stream can be injected
|
|
229
|
+
controller.enqueue(encoder.encode(prelude.slice(0, splitAt)));
|
|
228
230
|
const reader = resumeStream.getReader();
|
|
229
231
|
let strippedLeadingClose = false;
|
|
230
232
|
try {
|
|
@@ -12,7 +12,8 @@ export class Prefetcher {
|
|
|
12
12
|
*/
|
|
13
13
|
static key(path, base) {
|
|
14
14
|
const url = new URL(path, base);
|
|
15
|
-
|
|
15
|
+
// hash is client-only and never sent to the server, so exclude it
|
|
16
|
+
return url.pathname + url.search;
|
|
16
17
|
}
|
|
17
18
|
/**
|
|
18
19
|
* Evicts the oldest entry from the cache
|
|
@@ -47,6 +47,21 @@ export class Resolver {
|
|
|
47
47
|
}
|
|
48
48
|
return 200;
|
|
49
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Find a manifest entry by path, trying both with and without a trailing slash
|
|
52
|
+
*/
|
|
53
|
+
static #getEntryByPath(manifest, path) {
|
|
54
|
+
const direct = manifest[path];
|
|
55
|
+
if (direct)
|
|
56
|
+
return direct;
|
|
57
|
+
if (path !== '/' && path.endsWith('/')) {
|
|
58
|
+
return manifest[path.slice(0, -1)];
|
|
59
|
+
}
|
|
60
|
+
return manifest[`${path}/`];
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Merge the cached enhanced match with the params and error from this request's match
|
|
64
|
+
*/
|
|
50
65
|
static #withRequestState(cached, match) {
|
|
51
66
|
// the cached match only stores route structure, while params and errors
|
|
52
67
|
// still belong to this request so merge them back in here
|
|
@@ -111,7 +126,7 @@ export class Resolver {
|
|
|
111
126
|
*/
|
|
112
127
|
reconcile(path, match, error) {
|
|
113
128
|
if (match) {
|
|
114
|
-
const entry = Resolver.narrow(this.#manifest
|
|
129
|
+
const entry = Resolver.narrow(Resolver.#getEntryByPath(this.#manifest, match.route.path));
|
|
115
130
|
if (entry) {
|
|
116
131
|
// normal case, the router matched a page route so just attach request state
|
|
117
132
|
return {
|
|
@@ -54,21 +54,31 @@ export function RouterProvider({ children, setPayload, isNavigating = false, })
|
|
|
54
54
|
// and return early
|
|
55
55
|
if (navigationId !== id.current)
|
|
56
56
|
return path;
|
|
57
|
-
|
|
57
|
+
// we need both the parsed payload and the final response url because
|
|
58
|
+
// redirects can change the canonical path we should store in history
|
|
59
|
+
const [res, payload] = await Promise.all([
|
|
60
|
+
promise,
|
|
61
|
+
createFromFetch(promise),
|
|
62
|
+
]);
|
|
63
|
+
// use the final response url so client history matches server redirects
|
|
64
|
+
const resolvedPath = Prefetcher.key(res.url, window.location.origin);
|
|
58
65
|
// check again if another navigation has started while we were awaiting
|
|
59
66
|
// the response
|
|
60
67
|
if (navigationId !== id.current)
|
|
61
|
-
return
|
|
68
|
+
return resolvedPath;
|
|
62
69
|
// this state update is already wrapped in a
|
|
63
70
|
// transition before being passed as props
|
|
64
|
-
setPayload?.(
|
|
71
|
+
setPayload?.(payload);
|
|
65
72
|
if (replace) {
|
|
66
|
-
window.history.replaceState(null, '',
|
|
73
|
+
window.history.replaceState(null, '', resolvedPath);
|
|
67
74
|
}
|
|
68
75
|
else {
|
|
69
|
-
window.history.pushState(null, '',
|
|
76
|
+
window.history.pushState(null, '', resolvedPath);
|
|
70
77
|
}
|
|
71
|
-
window.dispatchEvent(new CustomEvent(Solas.Events.names.NAVIGATION, {
|
|
78
|
+
window.dispatchEvent(new CustomEvent(Solas.Events.names.NAVIGATION, {
|
|
79
|
+
detail: { path: resolvedPath },
|
|
80
|
+
}));
|
|
81
|
+
return resolvedPath;
|
|
72
82
|
}
|
|
73
83
|
catch (err) {
|
|
74
84
|
if (err instanceof Error && err.name === 'AbortError') {
|
|
@@ -96,18 +106,10 @@ export function RouterProvider({ children, setPayload, isNavigating = false, })
|
|
|
96
106
|
return path;
|
|
97
107
|
}, [setPayload]);
|
|
98
108
|
/**
|
|
99
|
-
* Prefetch a route's
|
|
109
|
+
* Prefetch a route's RSC payload
|
|
100
110
|
* @param path the route path to prefetch (absolute or relative to origin)
|
|
101
|
-
* @returns void
|
|
102
111
|
*/
|
|
103
112
|
const prefetch = useCallback((path) => {
|
|
104
|
-
const connection = window.navigator.connection;
|
|
105
|
-
if (document.visibilityState === 'hidden')
|
|
106
|
-
return;
|
|
107
|
-
if (connection?.saveData)
|
|
108
|
-
return;
|
|
109
|
-
if (['2g', 'slow-2g'].includes(connection?.effectiveType ?? ''))
|
|
110
|
-
return;
|
|
111
113
|
const key = Prefetcher.key(path, window.location.origin);
|
|
112
114
|
if (prefetcher.has(key))
|
|
113
115
|
return;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { match as createMatch } from 'path-to-regexp';
|
|
3
3
|
import { Solas } from '../../solas';
|
|
4
|
+
import { getAlternatePathname, normalisePathname, toPathPattern } from './utils';
|
|
4
5
|
import { maybeActionWithParsedFormData } from '../env/rsc';
|
|
5
6
|
import { HttpException } from '../navigation/http-exception';
|
|
6
|
-
import { toPathPattern } from './pattern';
|
|
7
7
|
/**
|
|
8
8
|
* Handle routing and matching for server requests
|
|
9
9
|
*/
|
|
@@ -49,7 +49,12 @@ export class Router {
|
|
|
49
49
|
* Register a route handler
|
|
50
50
|
*/
|
|
51
51
|
add(path, method, handler, params, middleware = []) {
|
|
52
|
-
|
|
52
|
+
// normalise static routes up front so trailingSlash matching
|
|
53
|
+
// uses the same pathname shape
|
|
54
|
+
const routePath = !path.includes(':') && !path.includes('*')
|
|
55
|
+
? normalisePathname(path, this.opts.trailingSlash ?? 'never')
|
|
56
|
+
: path;
|
|
57
|
+
const segments = Router.#split(routePath);
|
|
53
58
|
const tokens = [];
|
|
54
59
|
let score = 0;
|
|
55
60
|
let wildcard = false;
|
|
@@ -70,7 +75,7 @@ export class Router {
|
|
|
70
75
|
score += 2;
|
|
71
76
|
}
|
|
72
77
|
const route = {
|
|
73
|
-
path,
|
|
78
|
+
path: routePath,
|
|
74
79
|
method: method.toUpperCase(),
|
|
75
80
|
handler,
|
|
76
81
|
middleware: [...middleware],
|
|
@@ -81,7 +86,7 @@ export class Router {
|
|
|
81
86
|
};
|
|
82
87
|
// static route, easy map set
|
|
83
88
|
if (!path.includes(':') && !path.includes('*')) {
|
|
84
|
-
this.#routes.static.set(`${route.method}:${path}`, route);
|
|
89
|
+
this.#routes.static.set(`${route.method}:${route.path}`, route);
|
|
85
90
|
return this;
|
|
86
91
|
}
|
|
87
92
|
// wildcard route, push to end of list
|
|
@@ -117,15 +122,15 @@ export class Router {
|
|
|
117
122
|
* Match a path and method, returning params and route
|
|
118
123
|
*/
|
|
119
124
|
match(path, method) {
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
125
|
+
for (const candidate of Router.#candidates(path)) {
|
|
126
|
+
const direct = this.#routes.static.get(`${method}:${candidate}`);
|
|
127
|
+
if (direct)
|
|
128
|
+
return { route: direct, params: {} };
|
|
129
|
+
if (method === 'HEAD') {
|
|
130
|
+
const directGet = this.#routes.static.get(`GET:${candidate}`);
|
|
131
|
+
if (directGet)
|
|
132
|
+
return { route: directGet, params: {} };
|
|
133
|
+
}
|
|
129
134
|
}
|
|
130
135
|
// else dynamic/wildcard match
|
|
131
136
|
const segments = Router.#split(path);
|
|
@@ -157,10 +162,18 @@ export class Router {
|
|
|
157
162
|
*/
|
|
158
163
|
async fetch(req) {
|
|
159
164
|
const url = new URL(req.url);
|
|
160
|
-
const
|
|
165
|
+
const trailingSlash = this.opts.trailingSlash ?? 'never';
|
|
166
|
+
const path = trailingSlash === 'ignore'
|
|
167
|
+
? url.pathname
|
|
168
|
+
: normalisePathname(url.pathname, trailingSlash);
|
|
161
169
|
let match = null;
|
|
162
170
|
let action = false;
|
|
163
171
|
try {
|
|
172
|
+
const method = req.method.toUpperCase();
|
|
173
|
+
if ((method === 'GET' || method === 'HEAD') && path !== url.pathname) {
|
|
174
|
+
url.pathname = path;
|
|
175
|
+
return Response.redirect(url.toString(), 308);
|
|
176
|
+
}
|
|
164
177
|
if (path !== url.pathname) {
|
|
165
178
|
// rebuild the request with the canonical pathname so downstream code
|
|
166
179
|
// sees the same url the router matched against
|
|
@@ -169,7 +182,6 @@ export class Router {
|
|
|
169
182
|
}
|
|
170
183
|
const { action: isAction, formData: parsedFormData } = await maybeActionWithParsedFormData(req);
|
|
171
184
|
action = isAction;
|
|
172
|
-
const method = req.method.toUpperCase();
|
|
173
185
|
// action requests stay on the same pathname only the method is
|
|
174
186
|
// normalised to GET this lets page/layout routes match for
|
|
175
187
|
// rerender action execution still reads POST body and
|
|
@@ -218,7 +230,17 @@ export class Router {
|
|
|
218
230
|
for (let i = stack.length - 1; i >= 0; i -= 1) {
|
|
219
231
|
const handler = stack[i];
|
|
220
232
|
const prev = run;
|
|
221
|
-
run = () =>
|
|
233
|
+
run = () => {
|
|
234
|
+
let called = false;
|
|
235
|
+
return Promise.resolve(handler(req, () => {
|
|
236
|
+
// guard against double invocation so handlers/inner middleware
|
|
237
|
+
// only execute once per request
|
|
238
|
+
if (called)
|
|
239
|
+
throw new Error('next() called more than once');
|
|
240
|
+
called = true;
|
|
241
|
+
return prev();
|
|
242
|
+
}));
|
|
243
|
+
};
|
|
222
244
|
}
|
|
223
245
|
// run composed middleware stack
|
|
224
246
|
return run();
|
|
@@ -292,15 +314,10 @@ export class Router {
|
|
|
292
314
|
/**
|
|
293
315
|
* Normalise a path based on router options
|
|
294
316
|
*/
|
|
295
|
-
static #
|
|
296
|
-
if (!trailingSlash) {
|
|
297
|
-
// collapse non-root trailing slashes when the router runs in slashless mode
|
|
298
|
-
return path.endsWith('/') && path !== '/' ? path.slice(0, -1) : path;
|
|
299
|
-
}
|
|
317
|
+
static #candidates(path) {
|
|
300
318
|
if (path === '/')
|
|
301
|
-
return
|
|
302
|
-
|
|
303
|
-
return path.endsWith('/') ? path : `${path}/`;
|
|
319
|
+
return ['/'];
|
|
320
|
+
return [path, getAlternatePathname(path)];
|
|
304
321
|
}
|
|
305
322
|
/**
|
|
306
323
|
* Split a path into segments
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Route } from '../../types';
|
|
2
|
+
export type PathPattern = {
|
|
3
|
+
path: string;
|
|
4
|
+
wildcardNames: Set<string>;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Convert an internal route string into a path-to-regexp pattern and collect
|
|
8
|
+
* the wildcard param names used in that pattern
|
|
9
|
+
*/
|
|
10
|
+
export declare function toPathPattern(route: string, paramNames?: string[]): {
|
|
11
|
+
path: string;
|
|
12
|
+
wildcardNames: Set<string>;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Apply the configured trailing-slash policy to a pathname
|
|
16
|
+
*/
|
|
17
|
+
export declare function normalisePathname(pathname: string, trailingSlash?: Route.TrailingSlash): string;
|
|
18
|
+
/**
|
|
19
|
+
* Return the other pathname shape for a non-root route
|
|
20
|
+
*/
|
|
21
|
+
export declare function getAlternatePathname(pathname: string): string;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escape a literal path segment so it can be used safely in a
|
|
3
|
+
* path-to-regexp pattern
|
|
4
|
+
*/
|
|
5
|
+
function escapePathSegment(value) {
|
|
6
|
+
return value.replace(/[\\.+*?^${}()[\]|!:]/g, '\\$&');
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Convert an internal route string into a path-to-regexp pattern and collect
|
|
10
|
+
* the wildcard param names used in that pattern
|
|
11
|
+
*/
|
|
12
|
+
export function toPathPattern(route, paramNames = []) {
|
|
13
|
+
if (route === '/') {
|
|
14
|
+
return { path: route, wildcardNames: new Set() };
|
|
15
|
+
}
|
|
16
|
+
let paramIndex = 0;
|
|
17
|
+
let wildcardIndex = 0;
|
|
18
|
+
const wildcardNames = new Set();
|
|
19
|
+
const path = route
|
|
20
|
+
.split('/')
|
|
21
|
+
.filter(Boolean)
|
|
22
|
+
.map(segment => {
|
|
23
|
+
if (segment.startsWith(':')) {
|
|
24
|
+
paramIndex += 1;
|
|
25
|
+
return `/${segment}`;
|
|
26
|
+
}
|
|
27
|
+
if (segment === '*') {
|
|
28
|
+
// reuse the discovered param name when we have one so wildcard params
|
|
29
|
+
// line up with the generated route pattern
|
|
30
|
+
const value = paramNames[paramIndex];
|
|
31
|
+
const name = value && value !== '*' ? value : `wildcard${wildcardIndex}`;
|
|
32
|
+
paramIndex += 1;
|
|
33
|
+
wildcardIndex += 1;
|
|
34
|
+
wildcardNames.add(name);
|
|
35
|
+
return `/*${name}`;
|
|
36
|
+
}
|
|
37
|
+
return `/${escapePathSegment(segment)}`;
|
|
38
|
+
})
|
|
39
|
+
.join('');
|
|
40
|
+
return { path: path || '/', wildcardNames };
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Apply the configured trailing-slash policy to a pathname
|
|
44
|
+
*/
|
|
45
|
+
export function normalisePathname(pathname, trailingSlash = 'never') {
|
|
46
|
+
if (pathname === '/')
|
|
47
|
+
return pathname;
|
|
48
|
+
// ignore mode keeps the incoming pathname shape as-is
|
|
49
|
+
if (trailingSlash === 'ignore')
|
|
50
|
+
return pathname;
|
|
51
|
+
if (trailingSlash === 'always')
|
|
52
|
+
return pathname.endsWith('/') ? pathname : `${pathname}/`;
|
|
53
|
+
return pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Return the other pathname shape for a non-root route
|
|
57
|
+
*/
|
|
58
|
+
export function getAlternatePathname(pathname) {
|
|
59
|
+
if (pathname === '/')
|
|
60
|
+
return pathname;
|
|
61
|
+
return pathname.endsWith('/') ? pathname.slice(0, -1) : `${pathname}/`;
|
|
62
|
+
}
|
|
@@ -7,10 +7,11 @@ import { dynamic } from './dynamic';
|
|
|
7
7
|
export function url() {
|
|
8
8
|
dynamic();
|
|
9
9
|
const { req, cache } = RequestContext.use();
|
|
10
|
-
//
|
|
10
|
+
// always return a clone so consumers can mutate (e.g. searchParams.set)
|
|
11
|
+
// without corrupting the cached instance shared across the request
|
|
11
12
|
if (cache.url)
|
|
12
|
-
return cache.url;
|
|
13
|
+
return new URL(cache.url);
|
|
13
14
|
const parsed = new URL(req.url);
|
|
14
15
|
cache.url = parsed;
|
|
15
|
-
return parsed;
|
|
16
|
+
return new URL(parsed);
|
|
16
17
|
}
|
|
@@ -2,5 +2,5 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
|
|
|
2
2
|
export default function Err({ error }) {
|
|
3
3
|
const title = 'status' in error ? `${error.status} - ${error.message}` : error.message;
|
|
4
4
|
return (_jsxs(_Fragment, { children: [
|
|
5
|
-
_jsx("meta", { name: "robots", content: "noindex,nofollow" }), _jsx("title", { children: title }), _jsx("h1", { children: title }), _jsx("p", { children: error.message }), error?.stack && _jsx("pre", { children: error.stack })] }));
|
|
5
|
+
_jsx("meta", { name: "robots", content: "noindex,nofollow" }), _jsx("title", { children: title }), _jsx("h1", { children: title }), _jsx("p", { children: error.message }), process.env.NODE_ENV === 'development' && error?.stack && (_jsx("pre", { children: error.stack }))] }));
|
|
6
6
|
}
|
package/dist/solas.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export declare namespace Solas {
|
|
|
15
15
|
const REQUEST_META: string;
|
|
16
16
|
const LOG_LEVELS: readonly ["debug", "info", "warn", "error", "fatal"];
|
|
17
17
|
const PRERENDER_MODES: readonly ["full", "ppr", false];
|
|
18
|
+
const TRAILING_SLASH_MODES: readonly ["always", "never", "ignore"];
|
|
18
19
|
/**
|
|
19
20
|
* Validate the plugin configuration object, throwing an error if invalid
|
|
20
21
|
* @param input - the unvalidated configuration object
|
package/dist/solas.js
CHANGED
|
@@ -17,11 +17,13 @@ var Solas;
|
|
|
17
17
|
Config.REQUEST_META = `__${Config.SLUG.toUpperCase()}__`;
|
|
18
18
|
Config.LOG_LEVELS = ['debug', 'info', 'warn', 'error', 'fatal'];
|
|
19
19
|
Config.PRERENDER_MODES = ['full', 'ppr', false];
|
|
20
|
+
Config.TRAILING_SLASH_MODES = ['always', 'never', 'ignore'];
|
|
20
21
|
const CONFIG_KEYS = new Set([
|
|
21
22
|
'logger',
|
|
22
23
|
'metadata',
|
|
23
24
|
'precompress',
|
|
24
25
|
'prerender',
|
|
26
|
+
'sitemap',
|
|
25
27
|
'trailingSlash',
|
|
26
28
|
'url',
|
|
27
29
|
]);
|
|
@@ -64,14 +66,28 @@ var Solas;
|
|
|
64
66
|
errors.push('config.precompress must be a boolean');
|
|
65
67
|
}
|
|
66
68
|
}
|
|
69
|
+
if ('sitemap' in input && input.sitemap !== undefined && input.sitemap !== false) {
|
|
70
|
+
if (typeof input.sitemap !== 'boolean' && typeof input.sitemap !== 'object') {
|
|
71
|
+
errors.push('config.sitemap must be a boolean or an object with a routes function');
|
|
72
|
+
}
|
|
73
|
+
if (typeof input.sitemap === 'object' &&
|
|
74
|
+
input.sitemap !== null &&
|
|
75
|
+
typeof input.sitemap.routes !== 'function') {
|
|
76
|
+
errors.push('config.sitemap.routes must be a function');
|
|
77
|
+
}
|
|
78
|
+
if (!input.url) {
|
|
79
|
+
errors.push('config.url is required when sitemap is enabled');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
67
82
|
if ('prerender' in input && input.prerender !== undefined) {
|
|
68
83
|
if (!new Set(Config.PRERENDER_MODES).has(input.prerender)) {
|
|
69
84
|
errors.push("config.prerender must be 'full', 'ppr', or false");
|
|
70
85
|
}
|
|
71
86
|
}
|
|
72
87
|
if ('trailingSlash' in input && input.trailingSlash !== undefined) {
|
|
73
|
-
if (typeof input.trailingSlash !== '
|
|
74
|
-
|
|
88
|
+
if (typeof input.trailingSlash !== 'string' ||
|
|
89
|
+
!new Set(Config.TRAILING_SLASH_MODES).has(input.trailingSlash)) {
|
|
90
|
+
errors.push("config.trailingSlash must be 'always', 'never', or 'ignore'");
|
|
75
91
|
}
|
|
76
92
|
}
|
|
77
93
|
if ('metadata' in input &&
|
package/dist/types.d.ts
CHANGED
|
@@ -8,23 +8,32 @@ import type { Metadata } from './internal/metadata';
|
|
|
8
8
|
import type { HttpException } from './internal/navigation/http-exception';
|
|
9
9
|
import type { Router } from './internal/router/router';
|
|
10
10
|
export type LogLevel = (typeof Solas.Config.LOG_LEVELS)[number];
|
|
11
|
-
|
|
12
|
-
url?: `http://${string}` | `https://${string}`;
|
|
11
|
+
type PluginConfigBase = {
|
|
13
12
|
port?: number;
|
|
14
13
|
precompress?: boolean;
|
|
15
14
|
prerender?: Route.Prerender;
|
|
16
15
|
metadata?: Metadata.Item;
|
|
17
|
-
trailingSlash?:
|
|
16
|
+
trailingSlash?: Route.TrailingSlash;
|
|
18
17
|
readonly logger?: {
|
|
19
18
|
level?: LogLevel;
|
|
20
19
|
};
|
|
21
20
|
};
|
|
21
|
+
export type PluginConfig = PluginConfigBase & ({
|
|
22
|
+
url: `http://${string}` | `https://${string}`;
|
|
23
|
+
sitemap: true | {
|
|
24
|
+
routes: (existing: string[]) => string[] | Promise<string[]>;
|
|
25
|
+
};
|
|
26
|
+
} | {
|
|
27
|
+
url?: `http://${string}` | `https://${string}`;
|
|
28
|
+
sitemap?: false;
|
|
29
|
+
});
|
|
22
30
|
export type RuntimeConfig = PluginConfig & {
|
|
23
31
|
precompress: NonNullable<PluginConfig['precompress']>;
|
|
24
32
|
trailingSlash: NonNullable<PluginConfig['trailingSlash']>;
|
|
25
33
|
};
|
|
26
34
|
export type BuildContext = {
|
|
27
|
-
|
|
35
|
+
prerenderRoutes: Set<string>;
|
|
36
|
+
knownRoutes: Set<string>;
|
|
28
37
|
exportReader: ExportReader;
|
|
29
38
|
};
|
|
30
39
|
export type SolasRequest = Request & {};
|
|
@@ -80,13 +89,16 @@ export type HttpMethod = 'GET' | 'HEAD' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' |
|
|
|
80
89
|
export type Primitive = string | number | boolean | bigint | symbol | null | undefined;
|
|
81
90
|
export type LooseNumber<T extends number> = T | (number & {});
|
|
82
91
|
export type BuildManifest = {
|
|
83
|
-
|
|
92
|
+
prerenderRoutes: string[];
|
|
93
|
+
sitemapRoutes: string[];
|
|
84
94
|
precompress: boolean;
|
|
95
|
+
trailingSlash: Route.TrailingSlash;
|
|
85
96
|
url?: PluginConfig['url'];
|
|
86
97
|
};
|
|
87
98
|
export declare namespace Route {
|
|
88
99
|
type Metadata = Metadata.Item | ((input: Metadata.Input<Router.Params>) => Promise<Metadata.Item> | Metadata.Item);
|
|
89
100
|
type Prerender = (typeof Solas.Config.PRERENDER_MODES)[number];
|
|
101
|
+
type TrailingSlash = (typeof Solas.Config.TRAILING_SLASH_MODES)[number];
|
|
90
102
|
}
|
|
91
103
|
export type BoundaryError = Error & {
|
|
92
104
|
digest?: string;
|
package/dist/utils/logger.js
CHANGED
|
@@ -35,8 +35,17 @@ export class Logger {
|
|
|
35
35
|
if (err instanceof Error || err instanceof HttpException) {
|
|
36
36
|
return err.message + (err.stack ? `\n${err.stack}` : '');
|
|
37
37
|
}
|
|
38
|
+
// for plain objects, attempt to stringify with indentation
|
|
39
|
+
// for readability
|
|
38
40
|
if (typeof err === 'object' && err !== null) {
|
|
39
|
-
|
|
41
|
+
try {
|
|
42
|
+
return JSON.stringify(err, null, 2);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// if stringify fails (e.g. circular reference), fall back
|
|
46
|
+
// to basic string conversion
|
|
47
|
+
return String(err);
|
|
48
|
+
}
|
|
40
49
|
}
|
|
41
50
|
return String(err);
|
|
42
51
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jk2908/solas",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "A React Server Components meta-framework powered by Vite",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"framework",
|
|
@@ -61,6 +61,10 @@
|
|
|
61
61
|
"types": "./dist/server.d.ts",
|
|
62
62
|
"import": "./dist/server.js"
|
|
63
63
|
},
|
|
64
|
+
"./utils/logger": {
|
|
65
|
+
"types": "./dist/utils/logger.d.ts",
|
|
66
|
+
"import": "./dist/utils/logger.js"
|
|
67
|
+
},
|
|
64
68
|
"./error-boundary": {
|
|
65
69
|
"types": "./dist/error-boundary.d.ts",
|
|
66
70
|
"import": "./dist/error-boundary.js"
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
function escapePathSegment(value) {
|
|
2
|
-
return value.replace(/[\\.+*?^${}()[\]|!:]/g, '\\$&');
|
|
3
|
-
}
|
|
4
|
-
export function toPathPattern(route, paramNames = []) {
|
|
5
|
-
if (route === '/') {
|
|
6
|
-
return { path: route, wildcardNames: new Set() };
|
|
7
|
-
}
|
|
8
|
-
let paramIndex = 0;
|
|
9
|
-
let wildcardIndex = 0;
|
|
10
|
-
const wildcardNames = new Set();
|
|
11
|
-
const path = route
|
|
12
|
-
.split('/')
|
|
13
|
-
.filter(Boolean)
|
|
14
|
-
.map(segment => {
|
|
15
|
-
if (segment.startsWith(':')) {
|
|
16
|
-
paramIndex += 1;
|
|
17
|
-
return `/${segment}`;
|
|
18
|
-
}
|
|
19
|
-
if (segment === '*') {
|
|
20
|
-
const value = paramNames[paramIndex];
|
|
21
|
-
const name = value && value !== '*' ? value : `wildcard${wildcardIndex}`;
|
|
22
|
-
paramIndex += 1;
|
|
23
|
-
wildcardIndex += 1;
|
|
24
|
-
wildcardNames.add(name);
|
|
25
|
-
return `/*${name}`;
|
|
26
|
-
}
|
|
27
|
-
return `/${escapePathSegment(segment)}`;
|
|
28
|
-
})
|
|
29
|
-
.join('');
|
|
30
|
-
return { path: path || '/', wildcardNames };
|
|
31
|
-
}
|