@jk2908/solas 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.0
4
+
5
+ ### Features
6
+
7
+ - The static adapter supports client-side navigation. The build emits each fully prerendered route's RSC payload as `<route>.rsc.txt` next to its HTML, and the browser router fetches it, so a static host needs no server to answer navigations.
8
+ - The active adapter's name is exposed as `import.meta.env.SOLAS_ADAPTER`, so apps can branch on the platform.
9
+
10
+ ## 0.11.1
11
+
12
+ ### Fixes
13
+
14
+ - A document request is now served as HTML by default. Solas only returns the RSC payload when the client asks for `text/x-component` (and not `text/html`); a request to a page without that, such as `Accept: */*` or no `Accept` header, used to receive the RSC payload — and, for a page with no endpoint, an error.
15
+
3
16
  ## 0.11.0
4
17
 
5
18
  ### Breaking
package/README.md CHANGED
@@ -229,6 +229,8 @@ Solas resolves the origin in this order:
229
229
 
230
230
  The resolved origin is exposed to your app as `import.meta.env.VITE_APP_URL` and is used for build-time prerender requests and `sitemap` URLs. When `url` is set, it is also used as a trusted origin for CSRF.
231
231
 
232
+ The active adapter's name is exposed as `import.meta.env.SOLAS_ADAPTER`, so your app can branch on the platform it is deployed to.
233
+
232
234
  You usually only need `url` in production, where the Vite dev server origin is meaningless. For example, to generate a `sitemap.xml` with real URLs:
233
235
 
234
236
  ```ts
@@ -707,7 +709,9 @@ export default defineConfig({
707
709
  })
708
710
  ```
709
711
 
710
- `vite build` writes the site to `build/` (override with `outDir`): the client assets, each route as `<route>.html` (or `<route>/index.html` under `trailingSlash: 'always'`), plus `sitemap.xml` and `robots.txt`. Serve that directory with anything — `npx serve build`, GitHub Pages, Netlify, an S3 bucket, nginx.
712
+ `vite build` writes the site to `build/` (override with `outDir`): the client assets, each route as `<route>.html` (or `<route>/index.html` under `trailingSlash: 'always'`), its RSC payload as `<route>.rsc.txt`, plus `sitemap.xml` and `robots.txt`. Serve that directory with anything — `npx serve build`, GitHub Pages, Netlify, an S3 bucket, nginx.
713
+
714
+ Client-side navigation works with no server: the browser router fetches each route's `<route>.rsc.txt` payload instead of asking a server for `text/x-component`.
711
715
 
712
716
  Because it forces full prerendering, routes that need a server are not a fit: per-route PPR or SSR opt-ins and dynamic routes without `params()`.
713
717
 
package/dist/index.js CHANGED
@@ -261,6 +261,7 @@ function solas(c) {
261
261
  viteConfig.define ??= {};
262
262
  viteConfig.define['import.meta.env.VITE_APP_URL'] = JSON.stringify(resolvedUrl);
263
263
  viteConfig.define['import.meta.env.SOLAS_VERSION'] = JSON.stringify(pkg.version);
264
+ viteConfig.define['import.meta.env.SOLAS_ADAPTER'] = JSON.stringify(config.adapter?.name ?? '');
264
265
  viteConfig.optimizeDeps ??= {};
265
266
  viteConfig.optimizeDeps.exclude = [
266
267
  ...new Set([
@@ -335,6 +336,7 @@ function solas(c) {
335
336
  precompress: config.precompress,
336
337
  trailingSlash: config.trailingSlash,
337
338
  url: resolvedUrl,
339
+ adapter: config.adapter?.name ?? '',
338
340
  }));
339
341
  },
340
342
  buildApp: {
@@ -28,6 +28,25 @@ const DEFAULT_GO_CONFIG = {
28
28
  };
29
29
  const logger = new Logger();
30
30
  const responseCache = new ResponseCache();
31
+ const STATIC = import.meta.env.SOLAS_ADAPTER === 'static';
32
+ /**
33
+ * The URL to fetch the response for a navigation. On a static host that is the
34
+ * pre-generated RSC payload; otherwise the path itself carries the RSC response
35
+ * for an `Accept: text/x-component` request.
36
+ */
37
+ function getPayloadUrl(path) {
38
+ const url = new URL(path, window.location.origin);
39
+ url.pathname = url.pathname.endsWith('/')
40
+ ? `${url.pathname}index.rsc.txt`
41
+ : `${url.pathname}.rsc.txt`;
42
+ url.search = '';
43
+ return url;
44
+ }
45
+ function performNavigationFetch(path, init) {
46
+ return STATIC
47
+ ? fetch(getPayloadUrl(path), init)
48
+ : fetch(path, { ...init, headers: { ...init?.headers, accept: 'text/x-component' } });
49
+ }
31
50
  export function BrowserRouterProvider({ children, setPayload, isNavigating = false, url, }) {
32
51
  const id = useRef(0);
33
52
  const controller = useRef(null);
@@ -79,10 +98,7 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
79
98
  if (!promise) {
80
99
  const ctrl = new AbortController();
81
100
  controller.current = ctrl;
82
- promise = fetch(path, {
83
- headers: { accept: 'text/x-component' },
84
- signal: ctrl.signal,
85
- });
101
+ promise = performNavigationFetch(path, { signal: ctrl.signal });
86
102
  responseCache.set(path, promise);
87
103
  }
88
104
  if (navigationId !== id.current)
@@ -91,7 +107,11 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
91
107
  promise,
92
108
  createFromFetch(promise),
93
109
  ]);
94
- const resolvedPath = ResponseCache.toCacheKey(res.url, window.location.origin) ?? path;
110
+ // on a static host the payload comes from `<path>.rsc.txt`, so the
111
+ // response URL is not the page path
112
+ const resolvedPath = STATIC
113
+ ? path
114
+ : (ResponseCache.toCacheKey(res.url, window.location.origin) ?? path);
95
115
  if (navigationId !== id.current)
96
116
  return resolvedPath;
97
117
  if (resolvedPath !== path) {
@@ -136,7 +156,7 @@ export function BrowserRouterProvider({ children, setPayload, isNavigating = fal
136
156
  return;
137
157
  if (responseCache.has(key))
138
158
  return;
139
- responseCache.set(key, fetch(key, { headers: { Accept: 'text/x-component' } }));
159
+ responseCache.set(key, performNavigationFetch(key));
140
160
  }, []);
141
161
  /**
142
162
  * Refreshes the current page by re-fetching the RSC response for the current path and updating the
@@ -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 { normaliseTrailingSlash } from '../http-router/utils.js';
9
+ import { isRscRequest, 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';
@@ -190,7 +190,7 @@ export function createHandler(config, manifest, importMap, options = {}) {
190
190
  }
191
191
  const { stream: rscStream, status, ppr, } = await createPayload(req, manifest, importMap, config.metadata, opts.returnValue, opts.formState, opts.temporaryReferences);
192
192
  const stream = await rscStream;
193
- if (!req.headers.get('accept')?.includes('text/html')) {
193
+ if (isRscRequest(req)) {
194
194
  return new Response(stream, {
195
195
  headers: {
196
196
  'Cache-Control': 'private, no-store',
@@ -338,14 +338,13 @@ export function createHandler(config, manifest, importMap, options = {}) {
338
338
  /**
339
339
  * Serve the saved HTML for a fully prerendered route. Runs as the route
340
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.
341
+ * returns null for anything else (a route without a saved prerender or an RSC
342
+ * navigation), so the caller falls back to rendering.
344
343
  */
345
344
  async function serveFullPrerender(req) {
346
345
  if (import.meta.env.DEV)
347
346
  return null;
348
- if (!(req.headers.get('accept') ?? '').includes('text/html'))
347
+ if (isRscRequest(req))
349
348
  return null;
350
349
  if (req.headers.get(`x-${Config.SLUG}-prerender-artifact`) === '1')
351
350
  return null;
@@ -1,4 +1,5 @@
1
1
  import { HttpRouter } from './router.js';
2
+ import { isRscRequest } from './utils.js';
2
3
  function callEndpoint(fn, req) {
3
4
  // endpoint modules may export either a zero-arg handler or one that expects the request
4
5
  if (fn.length === 0)
@@ -82,12 +83,12 @@ export function createHttpRouter(config, manifest, importMap, rsc, serveFullPrer
82
83
  const middlewares = mergeMiddlewares(importMap[page.__id]?.middlewares, endpointEntry ? importMap[endpointEntry.__id]?.middlewares : undefined);
83
84
  router.add(page.__path, 'GET', async req => {
84
85
  const accept = req.headers.get('accept') ?? '';
85
- if (accept.includes('text/html') || accept.includes('text/x-component')) {
86
+ // a request that asks for a document or the RSC payload is rendered,
87
+ // as is any request to a page with no endpoint; anything else is the
88
+ // endpoint's own body
89
+ if (isRscRequest(req) || accept.includes('text/html') || !endpoint) {
86
90
  return (await serveFullPrerender?.(req)) ?? rsc(req);
87
91
  }
88
- if (!endpoint) {
89
- throw new Error(`Missing unified endpoint handler for ${page.__path}`);
90
- }
91
92
  return callEndpoint(endpoint, req);
92
93
  }, page.__params, middlewares);
93
94
  }
@@ -1,4 +1,12 @@
1
1
  import type { PluginConfig } from '../../types.js';
2
+ /**
3
+ * Whether a request wants the RSC payload rather than a document.
4
+ *
5
+ * The client asks for `text/x-component` on navigations. Anything else (a
6
+ * browser, or a fetch with no explicit preference) should get HTML, so a plain
7
+ * request to a page never receives the RSC payload.
8
+ */
9
+ export declare function isRscRequest(req: Request): boolean;
2
10
  export type PathPattern = {
3
11
  path: string;
4
12
  wildcardNames: Set<string>;
@@ -1,3 +1,14 @@
1
+ /**
2
+ * Whether a request wants the RSC payload rather than a document.
3
+ *
4
+ * The client asks for `text/x-component` on navigations. Anything else (a
5
+ * browser, or a fetch with no explicit preference) should get HTML, so a plain
6
+ * request to a page never receives the RSC payload.
7
+ */
8
+ export function isRscRequest(req) {
9
+ const accept = req.headers.get('accept') ?? '';
10
+ return accept.includes('text/x-component') && !accept.includes('text/html');
11
+ }
1
12
  /**
2
13
  * Escape a literal path segment so it can be used safely in a
3
14
  * path-to-regexp pattern.
@@ -114,6 +114,14 @@ export async function postbuild(cwd = process.cwd()) {
114
114
  const staticPath = path.join(staticRoot, Prerender.staticRoutePath(route, staticMode));
115
115
  await fs.mkdir(path.dirname(staticPath), { recursive: true });
116
116
  await fs.writeFile(staticPath, artifact.html);
117
+ // a static host has no server to answer an RSC navigation, so emit
118
+ // the payload next to the HTML for the client router to fetch
119
+ if (manifest.adapter === 'static') {
120
+ await fs.writeFile(staticPath.replace(/\.html$/, '.rsc.txt'), await Prerender.getRscPayload(app, route, {
121
+ origin: manifest.url,
122
+ base: manifest.base,
123
+ }));
124
+ }
117
125
  artfifactManifest[route] = {
118
126
  mode: artifact.mode,
119
127
  files: ['html'],
@@ -122,6 +122,17 @@ export declare function get(app: {
122
122
  route: string;
123
123
  artifact: any;
124
124
  }>;
125
+ /**
126
+ * Fetch the RSC payload for a route, the response a client would get for an RSC
127
+ * navigation. Written alongside a fully prerendered route's HTML so a static
128
+ * host can serve client-side navigation without a server.
129
+ */
130
+ export declare function getRscPayload(app: {
131
+ fetch: (req: Request) => Promise<Response>;
132
+ }, route: string, opts: {
133
+ origin?: string;
134
+ base?: string;
135
+ }): Promise<string>;
125
136
  /**
126
137
  * Run the prerendering process for a list of routes with a specified concurrency limit,
127
138
  * by calling the 'get' function for each route and yielding the results as they become available.
@@ -331,6 +331,19 @@ export async function get(app, route, opts) {
331
331
  return { route, status: res.status };
332
332
  return { route, artifact: await res.json() };
333
333
  }
334
+ /**
335
+ * Fetch the RSC payload for a route, the response a client would get for an RSC
336
+ * navigation. Written alongside a fully prerendered route's HTML so a static
337
+ * host can serve client-side navigation without a server.
338
+ */
339
+ export async function getRscPayload(app, route, opts) {
340
+ const url = `${opts.origin ?? `http://${Config.SLUG}.local`}${applyBasePath(route, opts.base)}`;
341
+ const res = await app.fetch(new Request(url, { headers: { Accept: 'text/x-component' } }));
342
+ if (!(res instanceof Response) || !res.ok) {
343
+ throw new Error(`[prerender] RSC payload for ${route} was not served`);
344
+ }
345
+ return res.text();
346
+ }
334
347
  /**
335
348
  * Run the prerendering process for a list of routes with a specified concurrency limit,
336
349
  * by calling the 'get' function for each route and yielding the results as they become available.
package/dist/types.d.ts CHANGED
@@ -130,6 +130,7 @@ export type BuildManifest = {
130
130
  precompress: boolean;
131
131
  trailingSlash: (typeof Config.TRAILING_SLASH_MODES)[number];
132
132
  url?: string;
133
+ adapter?: string;
133
134
  };
134
135
  export declare namespace Route {
135
136
  type ParamsOf<TRoute> = TRoute extends {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jk2908/solas",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
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",