@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 +13 -0
- package/README.md +5 -1
- package/dist/index.js +2 -0
- package/dist/internal/browser-router/router.js +26 -6
- package/dist/internal/env/rsc.js +5 -6
- package/dist/internal/http-router/create-http-router.js +5 -4
- package/dist/internal/http-router/utils.d.ts +8 -0
- package/dist/internal/http-router/utils.js +11 -0
- package/dist/internal/postbuild.js +8 -0
- package/dist/internal/prerender.d.ts +11 -0
- package/dist/internal/prerender.js +13 -0
- package/dist/types.d.ts +1 -0
- package/package.json +1 -1
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 =
|
|
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
|
-
|
|
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,
|
|
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
|
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 { 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 (
|
|
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
|
|
342
|
-
* navigation,
|
|
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 (
|
|
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
|
-
|
|
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
package/package.json
CHANGED