@jk2908/solas 0.8.3 → 0.9.1
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 +19 -0
- package/README.md +30 -3
- package/dist/adapters/cloudflare/vite.js +8 -5
- package/dist/config.d.ts +1 -6
- package/dist/config.js +26 -15
- package/dist/index.js +7 -17
- package/dist/internal/build.js +4 -4
- package/dist/internal/codegen/maps.js +3 -1
- package/dist/internal/env/browser.js +1 -1
- package/dist/internal/env/rsc.js +97 -74
- package/dist/internal/env/ssr.js +1 -1
- package/dist/internal/http-router/create-http-router.d.ts +1 -1
- package/dist/internal/http-router/create-http-router.js +3 -3
- package/dist/internal/http-router/router.js +3 -3
- package/dist/internal/http-router/utils.d.ts +1 -1
- package/dist/internal/http-router/utils.js +3 -2
- package/dist/internal/postbuild.js +26 -6
- package/dist/internal/render/head.d.ts +4 -1
- package/dist/internal/render/head.js +8 -2
- package/dist/manifest.d.ts +4 -1
- package/dist/types.d.ts +8 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.1
|
|
4
|
+
|
|
5
|
+
- Trimmed the install section of the README.
|
|
6
|
+
|
|
7
|
+
## 0.9.0
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
- Added a `headers` config option to add response headers to every response Solas serves, for security headers (CSP, HSTS, and so on). Pass an object, or a function for per-request values.
|
|
12
|
+
- Solas now writes a `<link rel="canonical">` into each document's head, built from the resolved `url` origin and the request path. A route can override it by declaring its own canonical link in `metadata`.
|
|
13
|
+
- When `sitemap` is enabled, Solas also generates a `robots.txt` pointing at the sitemap, unless the app ships its own.
|
|
14
|
+
|
|
15
|
+
### Fixes
|
|
16
|
+
|
|
17
|
+
- Middleware now runs for fully prerendered routes. The prerender shortcut used to run before routing, so `+middleware.ts` was skipped on static routes.
|
|
18
|
+
- `url` and `VITE_APP_URL` are validated with the rest of the config (collected with the other errors) instead of throwing on invalid values.
|
|
19
|
+
- Generated `maps.ts` now separates its static imports from its dynamic exports with a blank line.
|
|
20
|
+
- The generated `sitemap.xml` and `robots.txt` are now served by the Node server as well as Cloudflare (the Node server returned 404 for them before).
|
|
21
|
+
|
|
3
22
|
## 0.8.3
|
|
4
23
|
|
|
5
24
|
### Changed
|
package/README.md
CHANGED
|
@@ -13,8 +13,6 @@ npm install @jk2908/solas react react-dom react-server-dom-webpack vite@^8
|
|
|
13
13
|
npm install -D @vitejs/plugin-react@^6 typescript
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
Use Vite's built-in TypeScript path resolution (`resolve.tsconfigPaths: true`) rather than `vite-tsconfig-paths`, which is not compatible with Rolldown's handling of the `file://` runtime imports the RSC plugin emits.
|
|
17
|
-
|
|
18
16
|
## Use
|
|
19
17
|
|
|
20
18
|
Create a Vite config that registers Solas.
|
|
@@ -261,6 +259,8 @@ export default defineConfig({
|
|
|
261
259
|
})
|
|
262
260
|
```
|
|
263
261
|
|
|
262
|
+
Beyond sitemap URLs, the resolved origin is used to write a `<link rel="canonical">` into each document's head (unless a route declares its own canonical link in `metadata`).
|
|
263
|
+
|
|
264
264
|
### `precompress`
|
|
265
265
|
|
|
266
266
|
Use `precompress` to control whether Solas writes compressed browser-served build assets (like `.js`, `.css`, etc.).
|
|
@@ -449,6 +449,33 @@ export default defineConfig({
|
|
|
449
449
|
|
|
450
450
|
Only add origins you completely trust.
|
|
451
451
|
|
|
452
|
+
### `headers`
|
|
453
|
+
|
|
454
|
+
Use `headers` to add response headers to every response Solas serves: documents, RSC payloads, endpoints, and static assets. This is where security headers belong.
|
|
455
|
+
|
|
456
|
+
```ts
|
|
457
|
+
solas({
|
|
458
|
+
headers: {
|
|
459
|
+
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
|
|
460
|
+
'X-Content-Type-Options': 'nosniff',
|
|
461
|
+
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
|
462
|
+
'Content-Security-Policy': "default-src 'self'",
|
|
463
|
+
},
|
|
464
|
+
})
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
Pass a function to vary the headers per request, for example a per-request CSP nonce:
|
|
468
|
+
|
|
469
|
+
```ts
|
|
470
|
+
solas({
|
|
471
|
+
headers: req => ({
|
|
472
|
+
'Content-Security-Policy': `default-src 'self'; script-src 'nonce-${nonce(req)}'`,
|
|
473
|
+
}),
|
|
474
|
+
})
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
The function can be async. Configured headers win over any header Solas sets.
|
|
478
|
+
|
|
452
479
|
### `sitemap`
|
|
453
480
|
|
|
454
481
|
Use `sitemap` to generate a `sitemap.xml` at build time.
|
|
@@ -495,7 +522,7 @@ sitemap: {
|
|
|
495
522
|
}
|
|
496
523
|
```
|
|
497
524
|
|
|
498
|
-
The sitemap is written to the build output directory as `sitemap.xml` after prerendering and before precompression.
|
|
525
|
+
The sitemap is written to the build output directory as `sitemap.xml` after prerendering and before precompression, and served at `/sitemap.xml`. When enabled, Solas also writes a `robots.txt` (served at `/robots.txt`) that points at the sitemap, unless a `robots.txt` is provided in the public directory.
|
|
499
526
|
|
|
500
527
|
### `logger.level`
|
|
501
528
|
|
|
@@ -48,13 +48,12 @@ async function findAssetDirs(root, found = []) {
|
|
|
48
48
|
* renders RSC otherwise
|
|
49
49
|
* - `dist/.solas/**` (runtime manifest + ppr artifacts) at `<prefix>`, read back
|
|
50
50
|
* through the `ASSETS` binding
|
|
51
|
-
* - `dist/sitemap.xml` at the asset root
|
|
51
|
+
* - `dist/sitemap.xml` and `dist/robots.txt` at the asset root
|
|
52
52
|
*/
|
|
53
53
|
async function copyOutput(cwd) {
|
|
54
54
|
const outDir = path.join(cwd, Config.OUT_DIR);
|
|
55
55
|
const artifactSource = path.join(outDir, Config.GENERATED_DIR);
|
|
56
56
|
const staticSource = path.join(outDir, 'static');
|
|
57
|
-
const sitemapSource = path.join(outDir, 'sitemap.xml');
|
|
58
57
|
const assetDirs = await findAssetDirs(path.join(cwd, '.cloudflare', 'output'));
|
|
59
58
|
// Cloudflare compresses at the edge, so the Worker must serve uncompressed
|
|
60
59
|
// bytes. Keeping a `.br`/`.gz` sibling makes the assets binding hand the
|
|
@@ -76,9 +75,13 @@ async function copyOutput(cwd) {
|
|
|
76
75
|
filter: skipPrecompressed,
|
|
77
76
|
});
|
|
78
77
|
}
|
|
79
|
-
// the sitemap
|
|
80
|
-
|
|
81
|
-
|
|
78
|
+
// robots.txt and the sitemap are generated at the output root; serve them
|
|
79
|
+
// at the asset root
|
|
80
|
+
for (const file of ['sitemap.xml', 'robots.txt']) {
|
|
81
|
+
const source = path.join(outDir, file);
|
|
82
|
+
if (await pathExists(source)) {
|
|
83
|
+
await fs.copyFile(source, path.join(assetDir, file));
|
|
84
|
+
}
|
|
82
85
|
}
|
|
83
86
|
}
|
|
84
87
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -16,9 +16,4 @@ export declare const LOG_LEVELS: readonly ["debug", "info", "warn", "error", "fa
|
|
|
16
16
|
export declare const PRERENDER_MODES: readonly ["full", "ppr", false];
|
|
17
17
|
export declare const TRAILING_SLASH_MODES: readonly ["always", "never", "ignore"];
|
|
18
18
|
export declare const RUNTIME_MANIFEST = "runtime-manifest.json";
|
|
19
|
-
|
|
20
|
-
* Validate the plugin configuration object, throwing an error if invalid.
|
|
21
|
-
* @param input - The unvalidated configuration object.
|
|
22
|
-
* @returns The typed and validated configuration object.
|
|
23
|
-
*/
|
|
24
|
-
export declare function validate(input: unknown): PluginConfig;
|
|
19
|
+
export declare function validate(input: unknown, envUrl?: string): PluginConfig;
|
package/dist/config.js
CHANGED
|
@@ -17,6 +17,7 @@ export const TRAILING_SLASH_MODES = ['always', 'never', 'ignore'];
|
|
|
17
17
|
export const RUNTIME_MANIFEST = 'runtime-manifest.json';
|
|
18
18
|
const CONFIG_KEYS = new Set([
|
|
19
19
|
'adapter',
|
|
20
|
+
'headers',
|
|
20
21
|
'logger',
|
|
21
22
|
'metadata',
|
|
22
23
|
'precompress',
|
|
@@ -32,7 +33,22 @@ const LOGGER_KEYS = new Set(['level']);
|
|
|
32
33
|
* @param input - The unvalidated configuration object.
|
|
33
34
|
* @returns The typed and validated configuration object.
|
|
34
35
|
*/
|
|
35
|
-
|
|
36
|
+
function validateOrigin(value, label, errors) {
|
|
37
|
+
if (typeof value !== 'string') {
|
|
38
|
+
errors.push(`${label} must be a string`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const url = new URL(value);
|
|
43
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
44
|
+
errors.push(`${label} must use http:// or https://`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
errors.push(`${label} must be a valid URL`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export function validate(input, envUrl) {
|
|
36
52
|
if (input === undefined)
|
|
37
53
|
return {};
|
|
38
54
|
const errors = [];
|
|
@@ -45,20 +61,10 @@ export function validate(input) {
|
|
|
45
61
|
}
|
|
46
62
|
}
|
|
47
63
|
if ('url' in input && input.url !== undefined) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
try {
|
|
53
|
-
const url = new URL(input.url);
|
|
54
|
-
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
55
|
-
errors.push('url must use http:// or https://');
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
catch {
|
|
59
|
-
errors.push('url must be a valid URL');
|
|
60
|
-
}
|
|
61
|
-
}
|
|
64
|
+
validateOrigin(input.url, 'url', errors);
|
|
65
|
+
}
|
|
66
|
+
if (envUrl !== undefined && envUrl.length > 0) {
|
|
67
|
+
validateOrigin(envUrl, 'VITE_APP_URL', errors);
|
|
62
68
|
}
|
|
63
69
|
if ('adapter' in input && input.adapter !== undefined) {
|
|
64
70
|
const adapter = input.adapter;
|
|
@@ -73,6 +79,11 @@ export function validate(input) {
|
|
|
73
79
|
errors.push('precompress must be a boolean');
|
|
74
80
|
}
|
|
75
81
|
}
|
|
82
|
+
if ('headers' in input && input.headers !== undefined) {
|
|
83
|
+
if (typeof input.headers !== 'function' && !isRecord(input.headers)) {
|
|
84
|
+
errors.push('headers must be an object or a function that returns one');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
76
87
|
if ('trustedOrigins' in input && input.trustedOrigins !== undefined) {
|
|
77
88
|
if (!Array.isArray(input.trustedOrigins)) {
|
|
78
89
|
errors.push('trustedOrigins must be an array of origins');
|
package/dist/index.js
CHANGED
|
@@ -23,32 +23,22 @@ const DEFAULT_CONFIG = {
|
|
|
23
23
|
trailingSlash: 'never',
|
|
24
24
|
};
|
|
25
25
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
26
|
+
* Parse a validated url into its canonical origin (scheme and host, without a
|
|
27
|
+
* trailing slash or path).
|
|
28
28
|
*/
|
|
29
|
-
function
|
|
29
|
+
function normaliseOrigin(value) {
|
|
30
30
|
if (!value)
|
|
31
31
|
return undefined;
|
|
32
|
-
|
|
33
|
-
try {
|
|
34
|
-
url = new URL(value);
|
|
35
|
-
}
|
|
36
|
-
catch {
|
|
37
|
-
throw new Error(`[solas] Invalid url: "${value}"`);
|
|
38
|
-
}
|
|
39
|
-
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
40
|
-
throw new Error(`[solas] url must use http:// or https://: "${value}"`);
|
|
41
|
-
}
|
|
42
|
-
return url.origin;
|
|
32
|
+
return new URL(value).origin;
|
|
43
33
|
}
|
|
44
34
|
function solas(c) {
|
|
45
|
-
const
|
|
35
|
+
const envUrl = process.env.VITE_APP_URL?.toString();
|
|
36
|
+
const validatedConfig = Config.validate(c, envUrl);
|
|
46
37
|
const config = {
|
|
47
38
|
...DEFAULT_CONFIG,
|
|
48
39
|
...validatedConfig,
|
|
49
40
|
};
|
|
50
|
-
|
|
51
|
-
let resolvedUrl = resolveOrigin(c?.url) ?? resolveOrigin(envUrl);
|
|
41
|
+
let resolvedUrl = normaliseOrigin(c?.url) ?? normaliseOrigin(envUrl);
|
|
52
42
|
if (config.logger?.level)
|
|
53
43
|
Logger.defaultLevel = config.logger.level;
|
|
54
44
|
const logger = new Logger();
|
package/dist/internal/build.js
CHANGED
|
@@ -3,7 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { hash } from '../utils/hash.js';
|
|
4
4
|
import { Logger } from '../utils/logger.js';
|
|
5
5
|
import * as Config from '../config.js';
|
|
6
|
-
import {
|
|
6
|
+
import { normaliseTrailingSlash } from './http-router/utils.js';
|
|
7
7
|
import * as Prerender from './prerender.js';
|
|
8
8
|
const HTTP_VERBS = ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'];
|
|
9
9
|
// short prefixes used to namespace entry ids by kind.
|
|
@@ -476,19 +476,19 @@ export class Finder {
|
|
|
476
476
|
// expand using the page's exported static params
|
|
477
477
|
if (shouldPrerender) {
|
|
478
478
|
if (!isDynamic && !isWildcard) {
|
|
479
|
-
prerenderRoutes.add(
|
|
479
|
+
prerenderRoutes.add(normaliseTrailingSlash(route, trailingSlash));
|
|
480
480
|
}
|
|
481
481
|
else if (pagePath) {
|
|
482
482
|
const staticParams = await Prerender.getStaticParams(pagePath, this.buildContext);
|
|
483
483
|
for (const r of Prerender.getDynamicRouteList(route, params, staticParams)) {
|
|
484
|
-
prerenderRoutes.add(
|
|
484
|
+
prerenderRoutes.add(normaliseTrailingSlash(r, trailingSlash));
|
|
485
485
|
}
|
|
486
486
|
}
|
|
487
487
|
}
|
|
488
488
|
// track all concrete routes regardless of prerender mode
|
|
489
489
|
// so the sitemap can include ssr-only pages too
|
|
490
490
|
if (!isDynamic && !isWildcard) {
|
|
491
|
-
knownRoutes.add(
|
|
491
|
+
knownRoutes.add(normaliseTrailingSlash(route, trailingSlash));
|
|
492
492
|
}
|
|
493
493
|
const entry = {
|
|
494
494
|
__id: entryId,
|
|
@@ -64,7 +64,9 @@ export function writeMaps(imports, modules) {
|
|
|
64
64
|
return `${toStringLiteral(moduleId)}: {}`;
|
|
65
65
|
return `${toStringLiteral(moduleId)}: {\n${parts.map(part => indent(part, 1)).join(',\n')}\n}`;
|
|
66
66
|
});
|
|
67
|
-
const importLines = [
|
|
67
|
+
const importLines = [statics.join('\n'), dynamics.join('\n')]
|
|
68
|
+
.filter(Boolean)
|
|
69
|
+
.join('\n\n');
|
|
68
70
|
const entries = map.map(entry => indent(entry, 1)).join(',\n');
|
|
69
71
|
return source `
|
|
70
72
|
${AUTOGEN_MSG}
|
|
@@ -29,7 +29,7 @@ export async function browser() {
|
|
|
29
29
|
// immediately during render, without waiting for an effect to run
|
|
30
30
|
payloadSetter.current = setPayloadInTransition;
|
|
31
31
|
return (_jsx(RedirectBoundary, { children: _jsxs(BrowserRouterProvider, { setPayload: setPayloadInTransition, isNavigating: isPending, url: p.url, children: [
|
|
32
|
-
_jsx(ErrorBoundary, { fallback: null, children: _jsx(Suspense, { fallback: null, children: _jsx(Head, { metadata: p.metadata }) }) }), p.root] }) }));
|
|
32
|
+
_jsx(ErrorBoundary, { fallback: null, children: _jsx(Suspense, { fallback: null, children: _jsx(Head, { metadata: p.metadata, url: p.url }) }) }), p.root] }) }));
|
|
33
33
|
}
|
|
34
34
|
setServerCallback(async (id, args) => {
|
|
35
35
|
const url = new URL(window.location.href);
|
package/dist/internal/env/rsc.js
CHANGED
|
@@ -6,7 +6,7 @@ import * as Config from '../../config.js';
|
|
|
6
6
|
import * as RuntimeManifest from '../../manifest.js';
|
|
7
7
|
import { createHttpRouter } from '../http-router/create-http-router.js';
|
|
8
8
|
import { HttpRouter } from '../http-router/router.js';
|
|
9
|
-
import {
|
|
9
|
+
import { normaliseTrailingSlash } from '../http-router/utils.js';
|
|
10
10
|
import { Metadata } from '../metadata.js';
|
|
11
11
|
import { HttpException, isHttpException, toHttpException, toHttpExceptionLike, } from '../navigation/http-exception.js';
|
|
12
12
|
import { isRedirect, toRedirect } from '../navigation/redirect.js';
|
|
@@ -202,7 +202,7 @@ export function createHandler(config, manifest, importMap, options = {}) {
|
|
|
202
202
|
}
|
|
203
203
|
const mod = await import.meta.viteRsc.loadModule('ssr', 'index');
|
|
204
204
|
const pathname = new URL(req.url).pathname;
|
|
205
|
-
const lookupPath =
|
|
205
|
+
const lookupPath = normaliseTrailingSlash(pathname, prerenderPathMode);
|
|
206
206
|
const runtimePpr = !import.meta.env.DEV && ppr;
|
|
207
207
|
async function tryErrorRecovery(err) {
|
|
208
208
|
if (isRedirect(err)) {
|
|
@@ -335,81 +335,104 @@ export function createHandler(config, manifest, importMap, options = {}) {
|
|
|
335
335
|
throw err;
|
|
336
336
|
}
|
|
337
337
|
}
|
|
338
|
-
|
|
338
|
+
/**
|
|
339
|
+
* Serve the saved HTML for a fully prerendered route. Runs as the route
|
|
340
|
+
* handler, after middleware, so middleware applies to static routes too. It
|
|
341
|
+
* returns null for anything else (a route without a saved prerender, an RSC
|
|
342
|
+
* navigation, or a request without an HTML accept header), so the caller
|
|
343
|
+
* falls back to rendering.
|
|
344
|
+
*/
|
|
345
|
+
async function serveFullPrerender(req) {
|
|
346
|
+
if (import.meta.env.DEV)
|
|
347
|
+
return null;
|
|
348
|
+
if (!(req.headers.get('accept') ?? '').includes('text/html'))
|
|
349
|
+
return null;
|
|
350
|
+
if (req.headers.get(`x-${Config.SLUG}-prerender-artifact`) === '1')
|
|
351
|
+
return null;
|
|
352
|
+
if (!runtimeManifest)
|
|
353
|
+
return null;
|
|
354
|
+
const routedPath = stripBasePath(new URL(req.url).pathname, BASE_PATH);
|
|
355
|
+
if (routedPath == null)
|
|
356
|
+
return null;
|
|
357
|
+
const lookupPath = normaliseTrailingSlash(routedPath, prerenderPathMode);
|
|
358
|
+
if (runtimeManifest.artifacts[lookupPath]?.mode !== 'full')
|
|
359
|
+
return null;
|
|
360
|
+
const res = await HttpRouter.serveStatic(assets, {
|
|
361
|
+
namespace: 'static',
|
|
362
|
+
path: Prerender.staticRoutePath(lookupPath, prerenderPathMode),
|
|
363
|
+
}, req, config.precompress, {
|
|
364
|
+
// fully prerendered routes are immutable per build, so allow them
|
|
365
|
+
// to be cached and revalidated instead of forcing no-store
|
|
366
|
+
'Cache-Control': 'public, max-age=0, must-revalidate',
|
|
367
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
368
|
+
});
|
|
369
|
+
return res.status === 404 ? null : res;
|
|
370
|
+
}
|
|
371
|
+
const httpRouter = createHttpRouter(config, manifest, importMap, createResponse, serveFullPrerender);
|
|
372
|
+
/**
|
|
373
|
+
* Add the configured `headers` to a response. Clones the response so
|
|
374
|
+
* immutable ones (redirects, cached assets) can still be augmented.
|
|
375
|
+
*/
|
|
376
|
+
async function withHeaders(res, req) {
|
|
377
|
+
if (!config.headers)
|
|
378
|
+
return res;
|
|
379
|
+
const entries = typeof config.headers === 'function' ? await config.headers(req) : config.headers;
|
|
380
|
+
const headers = new Headers(res.headers);
|
|
381
|
+
for (const [key, value] of Object.entries(entries))
|
|
382
|
+
headers.set(key, value);
|
|
383
|
+
return new Response(res.body, {
|
|
384
|
+
status: res.status,
|
|
385
|
+
statusText: res.statusText,
|
|
386
|
+
headers,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
async function dispatch(req) {
|
|
390
|
+
const url = new URL(req.url);
|
|
391
|
+
const method = req.method.toUpperCase();
|
|
392
|
+
// fast path
|
|
393
|
+
if (method !== 'GET' && method !== 'HEAD')
|
|
394
|
+
return httpRouter.fetch(req);
|
|
395
|
+
const routedPath = stripBasePath(url.pathname, BASE_PATH);
|
|
396
|
+
const canonicalPath = routedPath == null
|
|
397
|
+
? null
|
|
398
|
+
: config.trailingSlash === 'ignore'
|
|
399
|
+
? routedPath
|
|
400
|
+
: normaliseTrailingSlash(routedPath, config.trailingSlash);
|
|
401
|
+
const canonicalPathname = canonicalPath == null ? null : applyBasePath(canonicalPath, BASE_PATH);
|
|
402
|
+
if (canonicalPathname != null &&
|
|
403
|
+
(method === 'GET' || method === 'HEAD') &&
|
|
404
|
+
config.trailingSlash !== 'ignore' &&
|
|
405
|
+
canonicalPathname !== url.pathname) {
|
|
406
|
+
url.pathname = canonicalPathname;
|
|
407
|
+
return Response.redirect(url.toString(), 308);
|
|
408
|
+
}
|
|
409
|
+
// block the bare /_solas namespace; only concrete solas asset files
|
|
410
|
+
// under /_solas/* are valid
|
|
411
|
+
if (routedPath === SOLAS_ASSETS_URL_ROOT) {
|
|
412
|
+
return new Response('Forbidden', { status: 403 });
|
|
413
|
+
}
|
|
414
|
+
if (routedPath?.startsWith(`${SOLAS_ASSETS_URL_ROOT}/`)) {
|
|
415
|
+
const asset = toClientAsset(`${Config.ASSETS_DIR}/${routedPath.slice(`${SOLAS_ASSETS_URL_ROOT}/`.length)}`);
|
|
416
|
+
// pass through bad-request or forbidden responses from path resolution
|
|
417
|
+
if (asset instanceof Response)
|
|
418
|
+
return asset;
|
|
419
|
+
return HttpRouter.serveStatic(assets, asset, req, config.precompress, {
|
|
420
|
+
'Cache-Control': 'public, immutable, max-age=31536000',
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
if (routedPath && runtimeManifest?.publicFiles.has(routedPath)) {
|
|
424
|
+
const asset = toClientAsset(routedPath.slice(1));
|
|
425
|
+
// pass through bad-request or forbidden responses from path resolution
|
|
426
|
+
if (asset instanceof Response)
|
|
427
|
+
return asset;
|
|
428
|
+
return HttpRouter.serveStatic(assets, asset, req, config.precompress);
|
|
429
|
+
}
|
|
430
|
+
return httpRouter.fetch(req);
|
|
431
|
+
}
|
|
339
432
|
// vite-plugin-rsc entrypoint
|
|
340
433
|
return {
|
|
341
434
|
async fetch(req) {
|
|
342
|
-
|
|
343
|
-
const method = req.method.toUpperCase();
|
|
344
|
-
// fast path
|
|
345
|
-
if (method !== 'GET' && method !== 'HEAD')
|
|
346
|
-
return httpRouter.fetch(req);
|
|
347
|
-
const accept = req.headers.get('accept') ?? '';
|
|
348
|
-
const routedPath = stripBasePath(url.pathname, BASE_PATH);
|
|
349
|
-
const canonicalPath = routedPath == null
|
|
350
|
-
? null
|
|
351
|
-
: config.trailingSlash === 'ignore'
|
|
352
|
-
? routedPath
|
|
353
|
-
: normalisePathname(routedPath, config.trailingSlash);
|
|
354
|
-
const canonicalPathname = canonicalPath == null ? null : applyBasePath(canonicalPath, BASE_PATH);
|
|
355
|
-
if (canonicalPathname != null &&
|
|
356
|
-
(method === 'GET' || method === 'HEAD') &&
|
|
357
|
-
config.trailingSlash !== 'ignore' &&
|
|
358
|
-
canonicalPathname !== url.pathname) {
|
|
359
|
-
url.pathname = canonicalPathname;
|
|
360
|
-
return Response.redirect(url.toString(), 308);
|
|
361
|
-
}
|
|
362
|
-
// block the bare /_solas namespace; only concrete solas asset files
|
|
363
|
-
// under /_solas/* are valid
|
|
364
|
-
if (routedPath === SOLAS_ASSETS_URL_ROOT) {
|
|
365
|
-
return new Response('Forbidden', { status: 403 });
|
|
366
|
-
}
|
|
367
|
-
if (routedPath?.startsWith(`${SOLAS_ASSETS_URL_ROOT}/`)) {
|
|
368
|
-
const asset = toClientAsset(`${Config.ASSETS_DIR}/${routedPath.slice(`${SOLAS_ASSETS_URL_ROOT}/`.length)}`);
|
|
369
|
-
// pass through bad-request or forbidden responses from path resolution
|
|
370
|
-
if (asset instanceof Response)
|
|
371
|
-
return asset;
|
|
372
|
-
return HttpRouter.serveStatic(assets, asset, req, config.precompress, {
|
|
373
|
-
'Cache-Control': 'public, immutable, max-age=31536000',
|
|
374
|
-
});
|
|
375
|
-
}
|
|
376
|
-
if (routedPath && runtimeManifest?.publicFiles.has(routedPath)) {
|
|
377
|
-
const asset = toClientAsset(routedPath.slice(1));
|
|
378
|
-
// pass through bad-request or forbidden responses from path resolution
|
|
379
|
-
if (asset instanceof Response)
|
|
380
|
-
return asset;
|
|
381
|
-
return HttpRouter.serveStatic(assets, asset, req, config.precompress);
|
|
382
|
-
}
|
|
383
|
-
// fully prerendered html can be served straight from disk for normal
|
|
384
|
-
// document requests, but build-time artifact requests must bypass
|
|
385
|
-
// this shortcut so they still render fresh output
|
|
386
|
-
if (canonicalPath != null &&
|
|
387
|
-
!import.meta.env.DEV &&
|
|
388
|
-
accept.includes('text/html') &&
|
|
389
|
-
req.headers.get(`x-${Config.SLUG}-prerender-artifact`) !== '1') {
|
|
390
|
-
// turn the request path into the normal route shape we use for artifact lookups
|
|
391
|
-
const lookupPath = normalisePathname(canonicalPath, prerenderPathMode);
|
|
392
|
-
// only full prerender routes have a saved html file we can serve directly
|
|
393
|
-
const staticAsset = runtimeManifest?.artifacts[lookupPath]?.mode === 'full'
|
|
394
|
-
? {
|
|
395
|
-
namespace: 'static',
|
|
396
|
-
path: Prerender.staticRoutePath(lookupPath, prerenderPathMode),
|
|
397
|
-
}
|
|
398
|
-
: null;
|
|
399
|
-
if (staticAsset) {
|
|
400
|
-
const res = await HttpRouter.serveStatic(assets, staticAsset, req, config.precompress, {
|
|
401
|
-
// fully prerendered routes are immutable per build, so allow them
|
|
402
|
-
// to be cached and revalidated instead of forcing no-store
|
|
403
|
-
'Cache-Control': 'public, max-age=0, must-revalidate',
|
|
404
|
-
'Content-Type': 'text/html; charset=utf-8',
|
|
405
|
-
});
|
|
406
|
-
// only a missing prerendered file should fall back to normal request handling
|
|
407
|
-
// any other static-file response should be returned as-is
|
|
408
|
-
if (res.status !== 404)
|
|
409
|
-
return res;
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
return httpRouter.fetch(req);
|
|
435
|
+
return withHeaders(await dispatch(req), req);
|
|
413
436
|
},
|
|
414
437
|
};
|
|
415
438
|
}
|
package/dist/internal/env/ssr.js
CHANGED
|
@@ -16,7 +16,7 @@ const logger = new Logger();
|
|
|
16
16
|
function A({ payloadPromise }) {
|
|
17
17
|
const payload = use(payloadPromise);
|
|
18
18
|
return (_jsx(RedirectBoundary, { children: _jsxs(BrowserRouterProvider, { url: payload.url, children: [
|
|
19
|
-
_jsx(ErrorBoundary, { fallback: null, children: _jsx(Suspense, { fallback: null, children: _jsx(Head, { metadata: payload.metadata }) }) }), payload.root] }) }));
|
|
19
|
+
_jsx(ErrorBoundary, { fallback: null, children: _jsx(Suspense, { fallback: null, children: _jsx(Head, { metadata: payload.metadata, url: payload.url }) }) }), payload.root] }) }));
|
|
20
20
|
}
|
|
21
21
|
/**
|
|
22
22
|
* SSR handler - returns a ReadableStream response for HTML requests.
|
|
@@ -3,4 +3,4 @@ import { HttpRouter } from './router.js';
|
|
|
3
3
|
/**
|
|
4
4
|
* Create the HTTP router from the generated manifest and import map.
|
|
5
5
|
*/
|
|
6
|
-
export declare function createHttpRouter(config: Pick<PluginConfig, 'trailingSlash' | 'trustedOrigins' | 'url'>, manifest: Manifest, importMap: ImportMap, rsc: (req: SolasRequest) => Response | Promise<Response>): HttpRouter;
|
|
6
|
+
export declare function createHttpRouter(config: Pick<PluginConfig, 'trailingSlash' | 'trustedOrigins' | 'url'>, manifest: Manifest, importMap: ImportMap, rsc: (req: SolasRequest) => Response | Promise<Response>, serveFullPrerender?: (req: Request) => Promise<Response | null>): HttpRouter;
|
|
@@ -46,7 +46,7 @@ function mergeMiddlewares(left, right) {
|
|
|
46
46
|
/**
|
|
47
47
|
* Create the HTTP router from the generated manifest and import map.
|
|
48
48
|
*/
|
|
49
|
-
export function createHttpRouter(config, manifest, importMap, rsc) {
|
|
49
|
+
export function createHttpRouter(config, manifest, importMap, rsc, serveFullPrerender) {
|
|
50
50
|
const router = new HttpRouter({
|
|
51
51
|
trailingSlash: config.trailingSlash,
|
|
52
52
|
csrf: {
|
|
@@ -58,7 +58,7 @@ export function createHttpRouter(config, manifest, importMap, rsc) {
|
|
|
58
58
|
if (!Array.isArray(group)) {
|
|
59
59
|
if ('paths' in group) {
|
|
60
60
|
// plain page routes always hand off to the RSC request pipeline
|
|
61
|
-
router.add(group.__path, group.method.toUpperCase(), req => rsc(req), group.__params, resolveMiddlewares(group, importMap));
|
|
61
|
+
router.add(group.__path, group.method.toUpperCase(), async req => (await serveFullPrerender?.(req)) ?? rsc(req), group.__params, resolveMiddlewares(group, importMap));
|
|
62
62
|
continue;
|
|
63
63
|
}
|
|
64
64
|
const endpoint = importMap[group.__id]?.endpoint;
|
|
@@ -83,7 +83,7 @@ export function createHttpRouter(config, manifest, importMap, rsc) {
|
|
|
83
83
|
router.add(page.__path, 'GET', async req => {
|
|
84
84
|
const accept = req.headers.get('accept') ?? '';
|
|
85
85
|
if (accept.includes('text/html') || accept.includes('text/x-component')) {
|
|
86
|
-
return rsc(req);
|
|
86
|
+
return (await serveFullPrerender?.(req)) ?? rsc(req);
|
|
87
87
|
}
|
|
88
88
|
if (!endpoint) {
|
|
89
89
|
throw new Error(`Missing unified endpoint handler for ${page.__path}`);
|
|
@@ -4,7 +4,7 @@ import * as Config from '../../config.js';
|
|
|
4
4
|
import { HttpException } from '../navigation/http-exception.js';
|
|
5
5
|
import { maybeAction } from '../server/actions.js';
|
|
6
6
|
import { enforce } from '../server/csrf.js';
|
|
7
|
-
import { getAlternatePathname,
|
|
7
|
+
import { getAlternatePathname, normaliseTrailingSlash, toPathPattern } from './utils.js';
|
|
8
8
|
const BASE_PATH = normaliseBasePath(import.meta.env.BASE_URL);
|
|
9
9
|
/**
|
|
10
10
|
* Handle routing and matching for server requests.
|
|
@@ -54,7 +54,7 @@ export class HttpRouter {
|
|
|
54
54
|
// normalise static routes up front so trailingSlash matching
|
|
55
55
|
// uses the same pathname shape
|
|
56
56
|
const routePath = !path.includes(':') && !path.includes('*')
|
|
57
|
-
?
|
|
57
|
+
? normaliseTrailingSlash(path, this.opts.trailingSlash ?? 'never')
|
|
58
58
|
: path;
|
|
59
59
|
const segments = HttpRouter.#split(routePath);
|
|
60
60
|
const tokens = [];
|
|
@@ -170,7 +170,7 @@ export class HttpRouter {
|
|
|
170
170
|
? null
|
|
171
171
|
: trailingSlash === 'ignore'
|
|
172
172
|
? routedPath
|
|
173
|
-
:
|
|
173
|
+
: normaliseTrailingSlash(routedPath, trailingSlash);
|
|
174
174
|
let match = null;
|
|
175
175
|
let action = false;
|
|
176
176
|
try {
|
|
@@ -14,7 +14,7 @@ export declare function toPathPattern(route: string, paramNames?: string[]): {
|
|
|
14
14
|
/**
|
|
15
15
|
* Apply the configured trailing-slash policy to a pathname.
|
|
16
16
|
*/
|
|
17
|
-
export declare function
|
|
17
|
+
export declare function normaliseTrailingSlash(pathname: string, trailingSlash?: PluginConfig['trailingSlash']): string;
|
|
18
18
|
/**
|
|
19
19
|
* Return the other pathname shape for a non-root route. For use within
|
|
20
20
|
* trailingSlash logic to easily switch between shapes.
|
|
@@ -42,14 +42,15 @@ export function toPathPattern(route, paramNames = []) {
|
|
|
42
42
|
/**
|
|
43
43
|
* Apply the configured trailing-slash policy to a pathname.
|
|
44
44
|
*/
|
|
45
|
-
export function
|
|
45
|
+
export function normaliseTrailingSlash(pathname, trailingSlash = 'never') {
|
|
46
46
|
if (pathname === '/')
|
|
47
47
|
return pathname;
|
|
48
48
|
// ignore mode keeps the incoming pathname shape as-is
|
|
49
49
|
if (trailingSlash === 'ignore')
|
|
50
50
|
return pathname;
|
|
51
|
-
if (trailingSlash === 'always')
|
|
51
|
+
if (trailingSlash === 'always') {
|
|
52
52
|
return pathname.endsWith('/') ? pathname : `${pathname}/`;
|
|
53
|
+
}
|
|
53
54
|
return pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
|
54
55
|
}
|
|
55
56
|
/**
|
|
@@ -115,11 +115,10 @@ export async function postbuild(cwd = process.cwd()) {
|
|
|
115
115
|
await Promise.all(pendingWrites);
|
|
116
116
|
}
|
|
117
117
|
await fs.mkdir(artifactRoot, { recursive: true });
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
await fs.writeFile(Manifest.getManifestPath(outDir), JSON.stringify(runtimeManifest));
|
|
118
|
+
// generated files served at the root: written to the output root (for the
|
|
119
|
+
// asset layer) and into the client output (for the Node server), then listed
|
|
120
|
+
// as public files so every runtime serves them
|
|
121
|
+
const generatedPublicFiles = [];
|
|
123
122
|
if (manifest.sitemapRoutes.length > 0 && manifest.url) {
|
|
124
123
|
const origin = manifest.url.replace(/\/$/, '');
|
|
125
124
|
const urls = manifest.sitemapRoutes
|
|
@@ -131,9 +130,30 @@ export async function postbuild(cwd = process.cwd()) {
|
|
|
131
130
|
urls,
|
|
132
131
|
'</urlset>',
|
|
133
132
|
].join('\n');
|
|
134
|
-
|
|
133
|
+
// point robots.txt at the sitemap, unless the app ships its own
|
|
134
|
+
const robots = manifest.publicFiles.some(file => file.replace(/^\//, '') === 'robots.txt')
|
|
135
|
+
? null
|
|
136
|
+
: ['User-agent: *', 'Allow: /', '', `Sitemap: ${origin}/sitemap.xml`, ''].join('\n');
|
|
137
|
+
const clientDir = path.join(outDir, 'client');
|
|
138
|
+
await fs.mkdir(clientDir, { recursive: true });
|
|
139
|
+
const writes = [
|
|
140
|
+
fs.writeFile(path.join(outDir, 'sitemap.xml'), sitemap),
|
|
141
|
+
fs.writeFile(path.join(clientDir, 'sitemap.xml'), sitemap),
|
|
142
|
+
];
|
|
143
|
+
generatedPublicFiles.push('/sitemap.xml');
|
|
135
144
|
logger.info('[sitemap]', `generated ${manifest.sitemapRoutes.length} urls`);
|
|
145
|
+
if (robots) {
|
|
146
|
+
writes.push(fs.writeFile(path.join(outDir, 'robots.txt'), robots), fs.writeFile(path.join(clientDir, 'robots.txt'), robots));
|
|
147
|
+
generatedPublicFiles.push('/robots.txt');
|
|
148
|
+
logger.info('[robots]', 'generated robots.txt');
|
|
149
|
+
}
|
|
150
|
+
await Promise.all(writes);
|
|
136
151
|
}
|
|
152
|
+
const runtimeManifest = {
|
|
153
|
+
artifacts: artfifactManifest,
|
|
154
|
+
publicFiles: [...manifest.publicFiles, ...generatedPublicFiles],
|
|
155
|
+
};
|
|
156
|
+
await fs.writeFile(Manifest.getManifestPath(outDir), JSON.stringify(runtimeManifest));
|
|
137
157
|
if (manifest.precompress) {
|
|
138
158
|
logger.info('[precompress]', 'compressing assets...');
|
|
139
159
|
for await (const { input, compressed } of Compress.run(outDir, {
|
|
@@ -2,6 +2,9 @@ import { type Metadata as Collection } from '../metadata.js';
|
|
|
2
2
|
/**
|
|
3
3
|
* Renders title, meta, and link tags based on the provided metadata payload.
|
|
4
4
|
*/
|
|
5
|
-
export declare function Head({ metadata: m }: {
|
|
5
|
+
export declare function Head({ metadata: m, url }: {
|
|
6
6
|
metadata?: Collection.Item | Promise<Collection.Item>;
|
|
7
|
+
url?: {
|
|
8
|
+
pathname: string;
|
|
9
|
+
};
|
|
7
10
|
}): import("react/jsx-runtime").JSX.Element | null;
|
|
@@ -32,12 +32,18 @@ function toSafeUsable(metadata) {
|
|
|
32
32
|
/**
|
|
33
33
|
* Renders title, meta, and link tags based on the provided metadata payload.
|
|
34
34
|
*/
|
|
35
|
-
export function Head({ metadata: m, }) {
|
|
35
|
+
export function Head({ metadata: m, url, }) {
|
|
36
36
|
if (!m)
|
|
37
37
|
return null;
|
|
38
38
|
const metadata = use(toSafeUsable(m));
|
|
39
|
+
// canonical url from the resolved origin and the request path, unless the
|
|
40
|
+
// route already declares one of its own
|
|
41
|
+
const origin = import.meta.env.VITE_APP_URL;
|
|
42
|
+
const canonical = origin && url && !metadata.link?.some(link => link.rel === 'canonical')
|
|
43
|
+
? new URL(url.pathname, origin).toString()
|
|
44
|
+
: null;
|
|
39
45
|
return (_jsxs(_Fragment, { children: [
|
|
40
|
-
_jsx("meta", { name: "generator", content: Config.NAME }), toContent(metadata.title) !== undefined && (_jsx("title", { children: toContent(metadata.title) })), metadata.meta?.map(meta => {
|
|
46
|
+
_jsx("meta", { name: "generator", content: Config.NAME }), canonical && _jsx("link", { rel: "canonical", href: canonical }), toContent(metadata.title) !== undefined && (_jsx("title", { children: toContent(metadata.title) })), metadata.meta?.map(meta => {
|
|
41
47
|
if ('charSet' in meta) {
|
|
42
48
|
return _jsx("meta", { charSet: meta.charSet }, meta.charSet);
|
|
43
49
|
}
|
package/dist/manifest.d.ts
CHANGED
|
@@ -5,4 +5,7 @@ export type Manifest = {
|
|
|
5
5
|
publicFiles: ReadonlySet<string>;
|
|
6
6
|
};
|
|
7
7
|
export declare function getManifestPath(outDir: string): string;
|
|
8
|
-
export declare function loadManifest(assets?: Assets): Promise<
|
|
8
|
+
export declare function loadManifest(assets?: Assets): Promise<{
|
|
9
|
+
artifacts: Prerender.ArtifactManifest;
|
|
10
|
+
publicFiles: Set<string>;
|
|
11
|
+
} | null>;
|
package/dist/types.d.ts
CHANGED
|
@@ -11,6 +11,13 @@ import * as Config from './config.js';
|
|
|
11
11
|
import * as Build from './internal/build.js';
|
|
12
12
|
export type LogLevel = (typeof Config.LOG_LEVELS)[number];
|
|
13
13
|
export type Origin = `http://${string}` | `https://${string}`;
|
|
14
|
+
/**
|
|
15
|
+
* Response headers to add to every response Solas serves. A function form
|
|
16
|
+
* receives the request, for headers that depend on it (for example a
|
|
17
|
+
* per-request CSP nonce).
|
|
18
|
+
*/
|
|
19
|
+
export type HeaderEntries = Record<string, string>;
|
|
20
|
+
export type HeadersInput = HeaderEntries | ((req: Request) => HeaderEntries | Promise<HeaderEntries>);
|
|
14
21
|
/**
|
|
15
22
|
* A platform deployment adapter. Solas registers `plugins` alongside its own,
|
|
16
23
|
* so adapters are configured here instead of being spread into the Vite plugins
|
|
@@ -26,6 +33,7 @@ type PluginConfigBase = {
|
|
|
26
33
|
metadata?: Metadata.Item;
|
|
27
34
|
trailingSlash?: (typeof Config.TRAILING_SLASH_MODES)[number];
|
|
28
35
|
trustedOrigins?: readonly string[];
|
|
36
|
+
headers?: HeadersInput;
|
|
29
37
|
adapter?: Adapter;
|
|
30
38
|
readonly logger?: {
|
|
31
39
|
level?: LogLevel;
|
package/package.json
CHANGED