@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 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, so `/sitemap.xml` is served
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 is generated at the output root; serve it at the asset root
80
- if (await pathExists(sitemapSource)) {
81
- await fs.copyFile(sitemapSource, path.join(assetDir, 'sitemap.xml'));
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
- export function validate(input) {
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
- if (typeof input.url !== 'string') {
49
- errors.push('url must be a string');
50
- }
51
- else {
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
- * Validate a configured url and return its canonical origin (scheme and host,
27
- * without a trailing slash or path). Throws when it is not a http(s) origin.
26
+ * Parse a validated url into its canonical origin (scheme and host, without a
27
+ * trailing slash or path).
28
28
  */
29
- function resolveOrigin(value) {
29
+ function normaliseOrigin(value) {
30
30
  if (!value)
31
31
  return undefined;
32
- let url;
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 validatedConfig = Config.validate(c);
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
- const envUrl = process.env.VITE_APP_URL?.toString();
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();
@@ -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 { normalisePathname } from './http-router/utils.js';
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(normalisePathname(route, trailingSlash));
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(normalisePathname(r, trailingSlash));
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(normalisePathname(route, trailingSlash));
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 = [...statics, ...dynamics].join('\n');
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);
@@ -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 { normalisePathname } from '../http-router/utils.js';
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 = normalisePathname(pathname, prerenderPathMode);
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
- const httpRouter = createHttpRouter(config, manifest, importMap, createResponse);
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
- const url = new URL(req.url);
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
  }
@@ -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, normalisePathname, toPathPattern } from './utils.js';
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
- ? normalisePathname(path, this.opts.trailingSlash ?? 'never')
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
- : normalisePathname(routedPath, trailingSlash);
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 normalisePathname(pathname: string, trailingSlash?: PluginConfig['trailingSlash']): string;
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 normalisePathname(pathname, trailingSlash = 'never') {
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
- const runtimeManifest = {
119
- artifacts: artfifactManifest,
120
- publicFiles: manifest.publicFiles,
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
- await fs.writeFile(path.join(outDir, 'sitemap.xml'), sitemap);
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
  }
@@ -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<Manifest | null>;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jk2908/solas",
3
- "version": "0.8.3",
3
+ "version": "0.9.1",
4
4
  "description": "A Vite + React meta-framework exploring streaming, Server Components, and partial prerendering. Designed for simplicity and lightness",
5
5
  "keywords": [
6
6
  "framework",