@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 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: true,
19
+ precompress: false,
20
20
  prerender: false,
21
21
  trustedOrigins: [],
22
22
  trailingSlash: 'never',
@@ -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
- const artifact = await mod.prerender(stream, {
206
- formState: opts.formState,
207
- ppr: runtimePpr,
208
- route: pathname,
209
- });
210
- return new Response(JSON.stringify(artifact), {
211
- headers: {
212
- 'Cache-Control': 'private, no-store',
213
- 'Content-Type': 'application/json; charset=utf-8',
214
- Vary: 'accept',
215
- },
216
- status,
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
- // resume/ssr can be the first place React surfaces an HttpException from abort(...),
264
- // after the initial RSC pass was streamed without request error state. Rerun once
265
- // with that error attached so createPayload rebuilds the same route through its
266
- // nearest matching HttpExceptionBoundary. If request meta already has an error
267
- // or the error is not an HttpException, then this is a real failure
268
- if (!req[Solas.Config.REQUEST_META_KEY].error && isHttpException(err)) {
269
- // normalise the surfaced digest error before attaching it, since tree/boundary lookup
270
- // relies on error.status - the guard above only tells us this came back with an
271
- // HttpException digest
272
- req[Solas.Config.REQUEST_META_KEY].error = toHttpException(err);
273
- try {
274
- const { stream: retriedRscStream, status: retriedStatus } = await createPayload(req, manifest, importMap, config.metadata, opts.returnValue, opts.formState, opts.temporaryReferences);
275
- const retriedStream = await retriedRscStream;
276
- const retriedHtmlStream = await mod.ssr(retriedStream, {
277
- formState: opts.formState,
278
- ppr: false,
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, REDIRECT_DIGEST_PREFIX } from './redirect.js';
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
- if ('digest' in err && typeof err.digest === 'string') {
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: f => /\.(js|css|html|svg|json|txt)$/.test(f),
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
- const code = await this.raw(filePath);
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
- '\\bexport\\s+(?:const|let|var)\\s+' +
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
- const text = code.match(new RegExp(source))?.groups?.value;
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.0",
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",