@jk2908/solas 0.3.2 → 0.3.3

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,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.3 - 2026-04-23
4
+
5
+ - Fixed HTML missing-route rendering when Solas is installed from npm by serialising `HttpException` and `Error` values into transport-safe objects before they cross the RSC payload boundary, preserving the expected 404 flow instead of crashing during SSR.
6
+
3
7
  ## 0.3.2 - 2026-04-21
4
8
 
5
9
  - Fixed PPR flight transport and closed-connection handling by replacing `rsc-html-stream` with the local runtime transport.
@@ -91,17 +91,20 @@ export function injectPayload(payload, opts = {}) {
91
91
  let html = decoder.decode(chunk, { stream: true });
92
92
  // hold the final closing tags back so payload scripts land inside the document,
93
93
  // not after it
94
- if (html.endsWith(HTML_TRAIL)) {
94
+ if (html.endsWith(HTML_TRAIL))
95
95
  html = html.slice(0, -HTML_TRAIL.length);
96
- }
96
+ // write the buffered html before the payload scripts, so they are guaranteed to be
97
+ // parsed in the right place
97
98
  if (html)
98
99
  controller.enqueue(encoder.encode(html));
99
100
  }
100
101
  // flush any decoder state left over from split utf-8/html chunks
101
102
  let remaining = decoder.decode();
102
- if (remaining.endsWith(HTML_TRAIL)) {
103
+ // if the remaining buffered html ends with the closing tags, remove them so they
104
+ // can be re-appended after the payload
105
+ if (remaining.endsWith(HTML_TRAIL))
103
106
  remaining = remaining.slice(0, -HTML_TRAIL.length);
104
- }
107
+ // if there is any html left after removing the closing tags, write it before the payload
105
108
  if (remaining)
106
109
  controller.enqueue(encoder.encode(remaining));
107
110
  buffered = [];
@@ -159,7 +162,7 @@ async function writePayload(payload, controller, nonce) {
159
162
  catch {
160
163
  // most rows are text, but keep binary chunks intact when a payload
161
164
  // row cannot be decoded as utf-8
162
- const base64 = JSON.stringify(btoa(String.fromCodePoint(...chunk)));
165
+ const base64 = JSON.stringify(window.btoa(String.fromCodePoint(...chunk)));
163
166
  writePayloadScript(`Uint8Array.from(atob(${base64}), value => value.codePointAt(0))`, controller, nonce);
164
167
  }
165
168
  }
@@ -6,7 +6,7 @@ import { createHttpRouter } from '../http-router/create-http-router.js';
6
6
  import { HttpRouter } from '../http-router/router.js';
7
7
  import { normalisePathname } from '../http-router/utils.js';
8
8
  import { Metadata } from '../metadata.js';
9
- import { HttpException, isHttpException } from '../navigation/http-exception.js';
9
+ import { HttpException, isHttpException, toHttpExceptionLike, } from '../navigation/http-exception.js';
10
10
  import { Prerender } from '../prerender.js';
11
11
  import { Tree } from '../render/tree.js';
12
12
  import { Resolver } from '../resolver.js';
@@ -32,8 +32,8 @@ async function createPayload(req, manifest, importMap, baseMetadata, returnValue
32
32
  // has been found, and we should server render a default
33
33
  // error screen
34
34
  if (!match) {
35
- const error = req[Solas.Config.REQUEST_META_KEY].error ?? new HttpException(404, 'Not found');
36
- const title = `${'status' in error ? `${error.status} -` : ''}${error.message}`;
35
+ const error = toHttpExceptionLike(req[Solas.Config.REQUEST_META_KEY].error ?? new HttpException(404, 'Not found'));
36
+ const title = `${error.status ? `${error.status} -` : ''}${error.message}`;
37
37
  const rscPayload = {
38
38
  root: (_jsxs("html", { lang: "en", children: [
39
39
  _jsxs("head", { children: [
@@ -80,8 +80,9 @@ async function createPayload(req, manifest, importMap, baseMetadata, returnValue
80
80
  const metadata = collection
81
81
  .add(...(match.metadata?.({ params: match.params, error: match.error }) ?? []))
82
82
  .run();
83
+ const error = match.error ? toHttpExceptionLike(match.error) : undefined;
83
84
  const rscPayload = {
84
- root: (_jsx(_Fragment, { children: _jsx(Tree, { depth: match.__depth, params: match.params, error: match.error, ui: match.ui }) })),
85
+ root: (_jsx(_Fragment, { children: _jsx(Tree, { depth: match.__depth, params: match.params, error: error, ui: match.ui }) })),
85
86
  returnValue,
86
87
  formState,
87
88
  metadata,
@@ -124,9 +125,9 @@ async function createPayload(req, manifest, importMap, baseMetadata, returnValue
124
125
  ? `${err.status} - ${err.message}`
125
126
  : `500 - ${err.message}`
126
127
  : '500 - Unknown server error';
127
- const error = new Error(err instanceof Error ? err.message : 'Unknown server error', {
128
+ const error = toHttpExceptionLike(new Error(err instanceof Error ? err.message : 'Unknown server error', {
128
129
  cause: err,
129
- });
130
+ }));
130
131
  return {
131
132
  // this branch renders the minimal error shell after the
132
133
  // main tree throws. We keep the same mode as the
@@ -7,6 +7,7 @@ export declare namespace HttpException {
7
7
  };
8
8
  }
9
9
  export declare const HTTP_EXCEPTION_NAME_MAP: Record<HttpException.StatusCode, string>;
10
+ export type HttpExceptionLike = Pick<Error, 'name' | 'message' | 'stack'> & Partial<Pick<HttpException, 'digest' | 'payload' | 'status'>>;
10
11
  /**
11
12
  * An exception representing an HTTP error, with an optional payload
12
13
  * and cause
@@ -21,9 +22,13 @@ export declare class HttpException extends Error {
21
22
  export declare const HTTP_EXCEPTION_DIGEST_PREFIX = "HTTP_EXCEPTION";
22
23
  /**
23
24
  * Check if an error is an HTTPException
24
- * @description uses the digest property to work across server/client boundaries
25
25
  */
26
26
  export declare function isHttpException(err: unknown): err is HttpException;
27
+ /**
28
+ * Convert an HttpException or any Error into a plain object that can be
29
+ * safely serialised
30
+ */
31
+ export declare function toHttpExceptionLike(error: HttpException | Error): HttpExceptionLike;
27
32
  /**
28
33
  * Throw an HTTPException
29
34
  */
@@ -25,7 +25,6 @@ export class HttpException extends Error {
25
25
  export const HTTP_EXCEPTION_DIGEST_PREFIX = 'HTTP_EXCEPTION';
26
26
  /**
27
27
  * Check if an error is an HTTPException
28
- * @description uses the digest property to work across server/client boundaries
29
28
  */
30
29
  export function isHttpException(err) {
31
30
  return (typeof err === 'object' &&
@@ -34,6 +33,24 @@ export function isHttpException(err) {
34
33
  typeof err.digest === 'string' &&
35
34
  err.digest.startsWith(HTTP_EXCEPTION_DIGEST_PREFIX));
36
35
  }
36
+ /**
37
+ * Convert an HttpException or any Error into a plain object that can be
38
+ * safely serialised
39
+ */
40
+ export function toHttpExceptionLike(error) {
41
+ return {
42
+ name: error.name,
43
+ message: error.message,
44
+ stack: error.stack,
45
+ ...('digest' in error && typeof error.digest === 'string'
46
+ ? { digest: error.digest }
47
+ : {}),
48
+ ...('payload' in error && error.payload !== undefined
49
+ ? { payload: error.payload }
50
+ : {}),
51
+ ...('status' in error ? { status: error.status } : {}),
52
+ };
53
+ }
37
54
  /**
38
55
  * Throw an HTTPException
39
56
  */
@@ -1,4 +1,5 @@
1
1
  import type { Resolver } from '../resolver.js';
2
+ import { type HttpExceptionLike } from '../navigation/http-exception.js';
2
3
  type Match = NonNullable<Resolver.EnhancedMatch>;
3
4
  /**
4
5
  * Render the resolved route tree for a matched page
@@ -41,7 +42,7 @@ type Match = NonNullable<Resolver.EnhancedMatch>;
41
42
  export declare function Tree({ depth, params, error, ui }: {
42
43
  depth: Match['__depth'];
43
44
  params: Match['params'];
44
- error: Match['error'];
45
+ error?: HttpExceptionLike;
45
46
  ui: Match['ui'];
46
47
  }): import("react/jsx-runtime").JSX.Element;
47
48
  export {};
@@ -1,7 +1,7 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Suspense } from 'react';
3
3
  import { HttpExceptionBoundary } from '../navigation/http-exception-boundary.js';
4
- import { HttpException, isHttpException } from '../navigation/http-exception.js';
4
+ import { HttpException, isHttpException, } from '../navigation/http-exception.js';
5
5
  import DefaultErr from '../ui/defaults/error.js';
6
6
  const UNAUTHORISED_ERROR = new HttpException(401, 'Unauthorised');
7
7
  const FORBIDDEN_ERROR = new HttpException(403, 'Forbidden');
@@ -1,4 +1,4 @@
1
- import type { HttpException } from '../../navigation/http-exception.js';
1
+ import type { HttpExceptionLike } from '../../navigation/http-exception.js';
2
2
  export default function Err({ error }: {
3
- error: HttpException | Error;
3
+ error: HttpExceptionLike;
4
4
  }): import("react/jsx-runtime").JSX.Element;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jk2908/solas",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "A React Server Components meta-framework powered by Vite",
5
5
  "keywords": [
6
6
  "framework",