@jk2908/solas 0.4.0 → 0.4.2
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 +10 -0
- package/dist/index.js +1 -1
- package/dist/internal/env/rsc.js +79 -42
- package/dist/internal/navigation/redirect-boundary.js +2 -11
- package/dist/internal/navigation/redirect.d.ts +3 -0
- package/dist/internal/navigation/redirect.js +51 -0
- package/dist/internal/postbuild.js +17 -12
- package/dist/utils/export-reader.js +10 -4
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.2 - 2026-05-22
|
|
4
|
+
|
|
5
|
+
- Changed `precompress` to default to `false`, so Solas no longer emits precompressed build output unless you opt in.
|
|
6
|
+
- Narrowed precompression to browser-served client assets and full prerendered HTML, so enabling `precompress` no longer writes `.br` files for internal `.solas` support artifacts.
|
|
7
|
+
|
|
8
|
+
## 0.4.1 - 2026-05-22
|
|
9
|
+
|
|
10
|
+
- Fixed redirect recovery during prerender and production HTML rendering, so redirecting routes now resolve as redirects instead of failing as generic Server Components render errors.
|
|
11
|
+
- Fixed build-time route export detection to ignore commented-out exports and transpile-only syntax before reading literal values, so stale commented `prerender` exports no longer change prerender mode.
|
|
12
|
+
|
|
3
13
|
## 0.4.0 - 2026-05-11
|
|
4
14
|
|
|
5
15
|
- Added CSRF protection for server actions and `+endpoint` handlers, plus a new `trustedOrigins` config option for tightly scoped cross-origin browser submissions. The checks are proxy-aware and use browser request headers when available.
|
package/dist/index.js
CHANGED
|
@@ -16,7 +16,7 @@ import { postbuild } from './internal/postbuild.js';
|
|
|
16
16
|
import { collect as collectPublicFiles } from './internal/public-files.js';
|
|
17
17
|
import { Solas } from './solas.js';
|
|
18
18
|
const DEFAULT_CONFIG = {
|
|
19
|
-
precompress:
|
|
19
|
+
precompress: false,
|
|
20
20
|
prerender: false,
|
|
21
21
|
trustedOrigins: [],
|
|
22
22
|
trailingSlash: 'never',
|
package/dist/internal/env/rsc.js
CHANGED
|
@@ -9,6 +9,7 @@ import { HttpRouter } from '../http-router/router.js';
|
|
|
9
9
|
import { normalisePathname } 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
|
+
import { isRedirect, toRedirect } from '../navigation/redirect.js';
|
|
12
13
|
import { Prerender } from '../prerender.js';
|
|
13
14
|
import { Tree } from '../render/tree.js';
|
|
14
15
|
import { Resolver } from '../resolver.js';
|
|
@@ -198,23 +199,72 @@ export function createHandler(config, manifest, importMap, runtimeManifest = nul
|
|
|
198
199
|
const pathname = new URL(req.url).pathname;
|
|
199
200
|
const lookupPath = normalisePathname(pathname, prerenderPathMode);
|
|
200
201
|
const runtimePpr = !import.meta.env.DEV && ppr;
|
|
202
|
+
async function tryErrorRecovery(err) {
|
|
203
|
+
if (isRedirect(err)) {
|
|
204
|
+
const redirect = toRedirect(err);
|
|
205
|
+
const location = redirect.url.startsWith('/')
|
|
206
|
+
? new URL(BasePath.apply(redirect.url, BASE_PATH), req.url).toString()
|
|
207
|
+
: redirect.url;
|
|
208
|
+
return Response.redirect(location, redirect.status);
|
|
209
|
+
}
|
|
210
|
+
if (req[Solas.Config.REQUEST_META_KEY].error || !isHttpException(err)) {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
// retry once with the surfaced HttpException attached so createPayload can
|
|
214
|
+
// rebuild the route through the nearest matching status boundary
|
|
215
|
+
req[Solas.Config.REQUEST_META_KEY].error = toHttpException(err);
|
|
216
|
+
try {
|
|
217
|
+
const { stream: retriedRscStream, status: retriedStatus } = await createPayload(req, manifest, importMap, config.metadata, opts.returnValue, opts.formState, opts.temporaryReferences);
|
|
218
|
+
const retriedStream = await retriedRscStream;
|
|
219
|
+
return {
|
|
220
|
+
retriedStatus,
|
|
221
|
+
retriedStream,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
req[Solas.Config.REQUEST_META_KEY].error = undefined;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
201
228
|
// prerender artifact requests bypass the normal document path so the cli
|
|
202
229
|
// gets structured JSON instead of a rendered html response
|
|
203
230
|
if (req.headers.get(`x-${Solas.Config.SLUG}-prerender`) === '1' &&
|
|
204
231
|
req.headers.get(`x-${Solas.Config.SLUG}-prerender-artifact`) === '1') {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
232
|
+
try {
|
|
233
|
+
const artifact = await mod.prerender(stream, {
|
|
234
|
+
formState: opts.formState,
|
|
235
|
+
ppr: runtimePpr,
|
|
236
|
+
route: pathname,
|
|
237
|
+
});
|
|
238
|
+
return new Response(JSON.stringify(artifact), {
|
|
239
|
+
headers: {
|
|
240
|
+
'Cache-Control': 'private, no-store',
|
|
241
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
242
|
+
Vary: 'accept',
|
|
243
|
+
},
|
|
244
|
+
status,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
const recovered = await tryErrorRecovery(err);
|
|
249
|
+
if (recovered instanceof Response)
|
|
250
|
+
return recovered;
|
|
251
|
+
if (recovered) {
|
|
252
|
+
const artifact = await mod.prerender(recovered.retriedStream, {
|
|
253
|
+
formState: opts.formState,
|
|
254
|
+
ppr: false,
|
|
255
|
+
route: pathname,
|
|
256
|
+
});
|
|
257
|
+
return new Response(JSON.stringify(artifact), {
|
|
258
|
+
headers: {
|
|
259
|
+
'Cache-Control': 'private, no-store',
|
|
260
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
261
|
+
Vary: 'accept',
|
|
262
|
+
},
|
|
263
|
+
status: recovered.retriedStatus,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
throw err;
|
|
267
|
+
}
|
|
218
268
|
}
|
|
219
269
|
try {
|
|
220
270
|
const artifactEntry = runtimePpr
|
|
@@ -260,35 +310,22 @@ export function createHandler(config, manifest, importMap, runtimeManifest = nul
|
|
|
260
310
|
});
|
|
261
311
|
}
|
|
262
312
|
catch (err) {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
});
|
|
280
|
-
return new Response(retriedHtmlStream, {
|
|
281
|
-
headers: {
|
|
282
|
-
'Cache-Control': 'private, no-store',
|
|
283
|
-
'Content-Type': 'text/html',
|
|
284
|
-
Vary: 'accept',
|
|
285
|
-
},
|
|
286
|
-
status: retriedStatus,
|
|
287
|
-
});
|
|
288
|
-
}
|
|
289
|
-
finally {
|
|
290
|
-
req[Solas.Config.REQUEST_META_KEY].error = undefined;
|
|
291
|
-
}
|
|
313
|
+
const recovered = await tryErrorRecovery(err);
|
|
314
|
+
if (recovered instanceof Response)
|
|
315
|
+
return recovered;
|
|
316
|
+
if (recovered) {
|
|
317
|
+
const retriedHtmlStream = await mod.ssr(recovered.retriedStream, {
|
|
318
|
+
formState: opts.formState,
|
|
319
|
+
ppr: false,
|
|
320
|
+
});
|
|
321
|
+
return new Response(retriedHtmlStream, {
|
|
322
|
+
headers: {
|
|
323
|
+
'Cache-Control': 'private, no-store',
|
|
324
|
+
'Content-Type': 'text/html',
|
|
325
|
+
Vary: 'accept',
|
|
326
|
+
},
|
|
327
|
+
status: recovered.retriedStatus,
|
|
328
|
+
});
|
|
292
329
|
}
|
|
293
330
|
throw err;
|
|
294
331
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import { Component } from 'react';
|
|
4
|
-
import { isRedirect,
|
|
4
|
+
import { isRedirect, toRedirect } from './redirect.js';
|
|
5
5
|
class Boundary extends Component {
|
|
6
6
|
constructor(props) {
|
|
7
7
|
super(props);
|
|
@@ -27,15 +27,6 @@ export function RedirectBoundary({ children }) {
|
|
|
27
27
|
return (_jsx(Boundary, { fallback: err => {
|
|
28
28
|
if (!isRedirect(err))
|
|
29
29
|
throw err;
|
|
30
|
-
|
|
31
|
-
// rejoin after status so urls with colons (https://...) stay intact
|
|
32
|
-
const [type, , ...parts] = err.digest.split(':');
|
|
33
|
-
if (type === REDIRECT_DIGEST_PREFIX) {
|
|
34
|
-
const url = parts.join(':');
|
|
35
|
-
if (url)
|
|
36
|
-
return _jsx("meta", { httpEquiv: "refresh", content: `0;url=${url}` });
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
return null;
|
|
30
|
+
return _jsx("meta", { httpEquiv: "refresh", content: `0;url=${toRedirect(err).url}` });
|
|
40
31
|
}, children: children }));
|
|
41
32
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export type RedirectStatusCode = 301 | 302 | 307 | 308;
|
|
2
|
+
export type RedirectLike = Pick<Error, 'name' | 'message' | 'stack'> & Partial<Pick<Redirect, 'digest' | 'status' | 'url'>>;
|
|
2
3
|
/**
|
|
3
4
|
* Redirect exception class to signal a redirect
|
|
4
5
|
*/
|
|
@@ -13,6 +14,8 @@ export declare const REDIRECT_DIGEST_PREFIX = "REDIRECT";
|
|
|
13
14
|
* Check if an error is a Redirect error
|
|
14
15
|
*/
|
|
15
16
|
export declare function isRedirect(err: unknown): err is Redirect;
|
|
17
|
+
export declare function toRedirect(err: unknown): Redirect;
|
|
18
|
+
export declare function toRedirectLike(error: Redirect | Error): RedirectLike;
|
|
16
19
|
/**
|
|
17
20
|
* Throws a Redirect exc`eption to signal a redirect
|
|
18
21
|
* @param url - the application-relative URL or absolute http/https URL to redirect to
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { Solas } from '../../solas.js';
|
|
2
|
+
function isRedirectStatusCode(value) {
|
|
3
|
+
return value === 301 || value === 302 || value === 307 || value === 308;
|
|
4
|
+
}
|
|
2
5
|
/**
|
|
3
6
|
* Redirect exception class to signal a redirect
|
|
4
7
|
*/
|
|
@@ -53,6 +56,54 @@ export function isRedirect(err) {
|
|
|
53
56
|
typeof err.digest === 'string' &&
|
|
54
57
|
err.digest.startsWith(REDIRECT_DIGEST_PREFIX));
|
|
55
58
|
}
|
|
59
|
+
export function toRedirect(err) {
|
|
60
|
+
if (err instanceof Redirect)
|
|
61
|
+
return err;
|
|
62
|
+
let digestStatus;
|
|
63
|
+
let digestUrl;
|
|
64
|
+
if (typeof err === 'object' &&
|
|
65
|
+
err !== null &&
|
|
66
|
+
'digest' in err &&
|
|
67
|
+
typeof err.digest === 'string') {
|
|
68
|
+
const [type, rawStatus, ...rawUrlParts] = err.digest.split(':');
|
|
69
|
+
const status = Number(rawStatus);
|
|
70
|
+
if (type === REDIRECT_DIGEST_PREFIX && isRedirectStatusCode(status)) {
|
|
71
|
+
digestStatus = status;
|
|
72
|
+
digestUrl = rawUrlParts.join(':');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const status = digestStatus ??
|
|
76
|
+
(typeof err === 'object' &&
|
|
77
|
+
err !== null &&
|
|
78
|
+
'status' in err &&
|
|
79
|
+
isRedirectStatusCode(err.status)
|
|
80
|
+
? err.status
|
|
81
|
+
: 307);
|
|
82
|
+
const url = digestUrl ||
|
|
83
|
+
(typeof err === 'object' &&
|
|
84
|
+
err !== null &&
|
|
85
|
+
'url' in err &&
|
|
86
|
+
typeof err.url === 'string'
|
|
87
|
+
? err.url
|
|
88
|
+
: undefined);
|
|
89
|
+
if (!url) {
|
|
90
|
+
throw new TypeError(`[${Solas.Config.NAME}] failed to reconstruct redirect`);
|
|
91
|
+
}
|
|
92
|
+
return new Redirect(url, status);
|
|
93
|
+
}
|
|
94
|
+
export function toRedirectLike(error) {
|
|
95
|
+
return {
|
|
96
|
+
name: error.name,
|
|
97
|
+
message: error.message,
|
|
98
|
+
...('digest' in error && typeof error.digest === 'string'
|
|
99
|
+
? { digest: error.digest }
|
|
100
|
+
: {}),
|
|
101
|
+
...('url' in error && typeof error.url === 'string' ? { url: error.url } : {}),
|
|
102
|
+
...('status' in error && isRedirectStatusCode(error.status)
|
|
103
|
+
? { status: error.status }
|
|
104
|
+
: {}),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
56
107
|
/**
|
|
57
108
|
* Throws a Redirect exc`eption to signal a redirect
|
|
58
109
|
* @param url - the application-relative URL or absolute http/https URL to redirect to
|
|
@@ -77,17 +77,6 @@ export async function postbuild(cwd = process.cwd()) {
|
|
|
77
77
|
? ['metadata', 'prelude', 'postponed']
|
|
78
78
|
: ['metadata', 'prelude'],
|
|
79
79
|
};
|
|
80
|
-
logger.info('[prerender:artifacts]', JSON.stringify({
|
|
81
|
-
route,
|
|
82
|
-
prelude: artifact.html,
|
|
83
|
-
postponed: artifact.postponed ?? null,
|
|
84
|
-
metadata: {
|
|
85
|
-
schema: artifact.schema,
|
|
86
|
-
route: artifact.route,
|
|
87
|
-
createdAt: artifact.createdAt,
|
|
88
|
-
mode: artifact.mode,
|
|
89
|
-
},
|
|
90
|
-
}));
|
|
91
80
|
logger.info('[prerender]', `${route} (ppr)`);
|
|
92
81
|
return;
|
|
93
82
|
}
|
|
@@ -137,7 +126,23 @@ export async function postbuild(cwd = process.cwd()) {
|
|
|
137
126
|
if (manifest.precompress) {
|
|
138
127
|
logger.info('[precompress]', 'compressing assets...');
|
|
139
128
|
for await (const { input, compressed } of Compress.run(outDir, {
|
|
140
|
-
filter:
|
|
129
|
+
filter: filePath => {
|
|
130
|
+
const relativePath = path.relative(outDir, filePath);
|
|
131
|
+
if (relativePath.length === 0 ||
|
|
132
|
+
relativePath.startsWith('..') ||
|
|
133
|
+
path.isAbsolute(relativePath)) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
const normalisedPath = relativePath.split(path.sep).join('/');
|
|
137
|
+
// only browser-served client/public files benefit from generic precompression
|
|
138
|
+
if (normalisedPath.startsWith('client/')) {
|
|
139
|
+
return /\.(js|css|html|svg|json|txt)$/.test(normalisedPath);
|
|
140
|
+
}
|
|
141
|
+
// full prerendered html is served straight from disk, but internal ppr
|
|
142
|
+
// support files like prelude/metadata/postponed are read by the server
|
|
143
|
+
return (normalisedPath.startsWith(`${Solas.Config.GENERATED_DIR}/ppr/`) &&
|
|
144
|
+
normalisedPath.endsWith(`/${Prerender.Artifact.FULL_PRERENDER_FILENAME}`));
|
|
145
|
+
},
|
|
141
146
|
})) {
|
|
142
147
|
await Bun.write(`${input}.br`, compressed);
|
|
143
148
|
logger.info('[precompress]', `${path.basename(input)}.br`);
|
|
@@ -84,17 +84,23 @@ export class ExportReader {
|
|
|
84
84
|
* The export must be in the form of `export const|let|var name = <literal>`
|
|
85
85
|
*/
|
|
86
86
|
async literal(filePath, name, validate) {
|
|
87
|
-
|
|
87
|
+
if (!(await this.has(filePath, name)))
|
|
88
|
+
return;
|
|
89
|
+
// transpile first so comments and type-only syntax do not confuse the
|
|
90
|
+
// literal matcher with exports that do not actually exist at runtime
|
|
91
|
+
const code = this.#getTranspiler(filePath).transformSync(await this.raw(filePath));
|
|
88
92
|
// build the matcher from escaped plain-text pieces so arbitrary export names
|
|
89
93
|
// cannot change the regex shape
|
|
90
94
|
const source =
|
|
91
|
-
// match: `export const|let|var `
|
|
92
|
-
'\\
|
|
95
|
+
// match: `export const|let|var ` at statement boundaries
|
|
96
|
+
'(?:^|[;\\n])\\s*export\\s+(?:const|let|var)\\s+' +
|
|
93
97
|
// treat export name as plain text in regex
|
|
94
98
|
name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
|
|
95
99
|
// capture one supported literal value (string, number, boolean, null)
|
|
96
100
|
'\\s*=\\s*(?<value>(?:"(?:[^"\\\\]|\\\\.)*"|\'(?:[^\'\\\\]|\\\\.)*\'|\\x60(?:[^\\x60\\\\]|\\\\.)*\\x60|true|false|null|-?\\d+(?:\\.\\d+)?))(?=\\s|;|$)';
|
|
97
|
-
|
|
101
|
+
// multiline mode lets ^ match the start of each transpiled line, so the
|
|
102
|
+
// export regex stays anchored to a real statement boundary instead of the file start
|
|
103
|
+
const text = code.match(new RegExp(source, 'm'))?.groups?.value;
|
|
98
104
|
if (!text)
|
|
99
105
|
return;
|
|
100
106
|
// only support cheap literal parsing here. Anything richer should go through
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jk2908/solas",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
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",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"license": "MIT",
|
|
18
18
|
"repository": {
|
|
19
19
|
"type": "git",
|
|
20
|
-
"url": "https://github.com/jk2908/solas.git"
|
|
20
|
+
"url": "git+https://github.com/jk2908/solas.git"
|
|
21
21
|
},
|
|
22
22
|
"files": [
|
|
23
23
|
"dist",
|