@jk2908/solas 0.3.7 → 0.3.8

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,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.8 - 2026-04-30
4
+
5
+ - Improved route module type safety for params, metadata, and static params, and ensured HTTP error boundaries receive route params too.
6
+ - Moved initial route-graph generation to Vite's `buildStart()` hook for more reliable build setup.
7
+ - Exported `HttpExceptionLike` from the public navigation api for typing serialised HTTP-style errors.
8
+ - Improved tree-shaking by keeping HMR-only browser runtime code out of non-HMR builds.
9
+ - Switched build-time export loading to Vite's module loader, so route exports resolve through Vite transforms and aliasing during builds.
10
+ - Fixed `abort(...)` during rendering so surfaced HTTP exceptions again resolve through the nearest matching boundary instead of failing as generic production render errors. This fixes a regression introduced in `0.3.7` when the outer `Suspense` was removed, while keeping that `Suspense` removed.
11
+
3
12
  ## 0.3.7 - 2026-04-25
4
13
 
5
14
  - Fixed shell rendering so routes without a root `+loading` fallback no longer wrap the entire document in `Suspense`, which removes misplaced `<!--html-->`, `<!--head-->`, and `<!--body-->` markers from streamed HTML.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { PluginOption } from 'vite';
1
+ import { type PluginOption } from 'vite';
2
2
  import type { PluginConfig } from './types.js';
3
3
  declare function solas(c: PluginConfig): PluginOption[];
4
4
  export default solas;
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fsSync from 'node:fs';
2
2
  import fs from 'node:fs/promises';
3
3
  import path from 'node:path';
4
+ import { createServer, loadConfigFromFile, } from 'vite';
4
5
  import rsc from '@vitejs/plugin-rsc';
5
6
  import { ExportReader } from './utils/export-reader.js';
6
7
  import { Logger } from './utils/logger.js';
@@ -167,11 +168,48 @@ function solas(c) {
167
168
  rebuildReason = `${event}: ${path.relative(WATCH_CWD, file)}`;
168
169
  queue();
169
170
  }, 75);
171
+ let resolvedViteConfig = null;
172
+ let utilityServer = null;
173
+ async function getUtilityServer() {
174
+ if (utilityServer)
175
+ return utilityServer;
176
+ if (!resolvedViteConfig)
177
+ throw new Error('Vite config not resolved yet');
178
+ const loaded = await loadConfigFromFile({
179
+ command: resolvedViteConfig.command,
180
+ mode: resolvedViteConfig.mode,
181
+ }, resolvedViteConfig.configFile, resolvedViteConfig.root);
182
+ const config = loaded?.config ?? {};
183
+ // recursively flatten and remove any instances of this plugin
184
+ const plugins = (config.plugins ?? []).flatMap(function flatten(plugin) {
185
+ if (!plugin)
186
+ return [];
187
+ if (Array.isArray(plugin))
188
+ return plugin.flatMap(flatten);
189
+ if (typeof plugin === 'object' &&
190
+ 'name' in plugin &&
191
+ plugin.name === Solas.Config.NAME) {
192
+ return [];
193
+ }
194
+ return [plugin];
195
+ });
196
+ utilityServer = await createServer({
197
+ ...config,
198
+ configFile: false,
199
+ root: resolvedViteConfig.root,
200
+ mode: resolvedViteConfig.mode,
201
+ server: {
202
+ ...config.server,
203
+ middlewareMode: true,
204
+ },
205
+ plugins,
206
+ appType: 'custom',
207
+ });
208
+ return utilityServer;
209
+ }
170
210
  const plugin = {
171
211
  name: Solas.Config.NAME,
172
- enforce: 'pre',
173
212
  async config(viteConfig) {
174
- await build();
175
213
  const pkg = JSON.parse(fsSync.readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
176
214
  if (typeof pkg.name !== 'string' || pkg.name.length === 0) {
177
215
  throw new Error(`Missing ${Solas.Config.NAME} package name`);
@@ -210,6 +248,10 @@ function solas(c) {
210
248
  '.solas': path.resolve(process.cwd(), Solas.Config.GENERATED_DIR),
211
249
  };
212
250
  },
251
+ configResolved(resolvedConfig) {
252
+ resolvedViteConfig = resolvedConfig;
253
+ buildContext.command = resolvedConfig.command;
254
+ },
213
255
  configureServer(server) {
214
256
  logger.info('[configureServer]', `Watching for changes in ./${Solas.Config.APP_DIR}...`);
215
257
  server.watcher
@@ -219,9 +261,22 @@ function solas(c) {
219
261
  .on('addDir', (p) => rebuild('addDir', p))
220
262
  .on('unlinkDir', (p) => rebuild('unlinkDir', p));
221
263
  },
264
+ async buildStart() {
265
+ logger.info('[buildStart]', 'building route graph...');
266
+ // create and attach server instance for ExportReader.value to use when
267
+ // loading modules
268
+ if (buildContext.command === 'build') {
269
+ const server = await getUtilityServer();
270
+ buildContext.exportReader.loadModule = server.ssrLoadModule.bind(server);
271
+ }
272
+ await build();
273
+ },
222
274
  async closeBundle() {
223
- if (process.env.NODE_ENV === 'development')
224
- return;
275
+ if (utilityServer) {
276
+ const server = utilityServer;
277
+ utilityServer = null;
278
+ await server.close();
279
+ }
225
280
  // resolve sitemap routes
226
281
  let sitemapRoutes = [];
227
282
  if (config.sitemap && config.url) {
@@ -324,7 +324,7 @@ var Build;
324
324
  try {
325
325
  if (!this.buildContext || !this.config)
326
326
  continue;
327
- const { shell: shellPath, layouts: layoutPaths, '401s': unauthorizedPaths, '403s': forbiddenPaths, page: pagePath, '404s': notFoundPaths, '500s': serverErrorPaths, loaders: loaderPaths, middlewares: middlewarePaths, dir, } = segment;
327
+ const { shell: shellPath, layouts: layoutPaths, '401s': unauthorisedPaths, '403s': forbiddenPaths, page: pagePath, '404s': notFoundPaths, '500s': serverErrorPaths, loaders: loaderPaths, middlewares: middlewarePaths, dir, } = segment;
328
328
  // derive the route pattern from the directory path, falling
329
329
  // back to a synthetic +page.tsx when this is
330
330
  // a layout-only segment
@@ -347,7 +347,7 @@ var Build;
347
347
  const shellImport = Finder.getImportPath(shellPath);
348
348
  const shellId = `${Build.EntryKind.SHELL}${Bun.hash(shellImport)}`;
349
349
  const layoutIds = [];
350
- const unauthorizedIds = [];
350
+ const unauthorisedIds = [];
351
351
  const forbiddenIds = [];
352
352
  const notFoundIds = [];
353
353
  const serverErrorIds = [];
@@ -375,17 +375,17 @@ var Build;
375
375
  applyPrerenderMode(prerenderCache.get(layoutPath));
376
376
  layoutIds.push(layoutId);
377
377
  }
378
- for (const unauthorizedPath of unauthorizedPaths) {
379
- if (!unauthorizedPath) {
380
- unauthorizedIds.push(null);
378
+ for (const unauthorisedPath of unauthorisedPaths) {
379
+ if (!unauthorisedPath) {
380
+ unauthorisedIds.push(null);
381
381
  continue;
382
382
  }
383
- const unauthorizedImport = Finder.getImportPath(unauthorizedPath);
384
- const unauthorizedId = `${Build.EntryKind['401']}${Bun.hash(unauthorizedImport)}`;
385
- unauthorizedIds.push(unauthorizedId);
386
- if (!processed.has(unauthorizedPath)) {
387
- imports.components.dynamic.set(unauthorizedId, unauthorizedImport);
388
- processed.add(unauthorizedPath);
383
+ const unauthorisedImport = Finder.getImportPath(unauthorisedPath);
384
+ const unauthorisedId = `${Build.EntryKind['401']}${Bun.hash(unauthorisedImport)}`;
385
+ unauthorisedIds.push(unauthorisedId);
386
+ if (!processed.has(unauthorisedPath)) {
387
+ imports.components.dynamic.set(unauthorisedId, unauthorisedImport);
388
+ processed.add(unauthorisedPath);
389
389
  }
390
390
  }
391
391
  for (const forbiddenPath of forbiddenPaths) {
@@ -475,7 +475,7 @@ var Build;
475
475
  processed.add(pagePath);
476
476
  }
477
477
  // resolve final prerender mode after shell → layout → page overrides
478
- const shouldPrerender = currentPrerenderMode !== false;
478
+ const shouldPrerender = currentPrerenderMode !== false && this.buildContext.command === 'build';
479
479
  const prerenderMode = shouldPrerender
480
480
  ? currentPrerenderMode
481
481
  : false;
@@ -506,7 +506,7 @@ var Build;
506
506
  method: 'get',
507
507
  paths: {
508
508
  layouts: [shellPath, ...layoutPaths].map(layout => layout ? Finder.getImportPath(layout) : null),
509
- '401s': unauthorizedPaths.map(unauthorized => unauthorized ? Finder.getImportPath(unauthorized) : null),
509
+ '401s': unauthorisedPaths.map(unauthorised => unauthorised ? Finder.getImportPath(unauthorised) : null),
510
510
  '403s': forbiddenPaths.map(forbidden => forbidden ? Finder.getImportPath(forbidden) : null),
511
511
  '404s': notFoundPaths.map(notFound => notFound ? Finder.getImportPath(notFound) : null),
512
512
  '500s': serverErrorPaths.map(serverError => serverError ? Finder.getImportPath(serverError) : null),
@@ -533,7 +533,7 @@ var Build;
533
533
  shellId,
534
534
  layoutIds,
535
535
  pageId: pagePath ? entryId : undefined,
536
- '401Ids': unauthorizedIds,
536
+ '401Ids': unauthorisedIds,
537
537
  '403Ids': forbiddenIds,
538
538
  '404Ids': notFoundIds,
539
539
  '500Ids': serverErrorIds,
@@ -50,13 +50,15 @@ export async function browser() {
50
50
  hydrateRoot(document, _jsx(StrictMode, { children: _jsx(A, {}) }), {
51
51
  formState: payload.formState,
52
52
  });
53
- import.meta.hot?.on?.('rsc:update', async () => {
54
- try {
55
- const p = await createFromFetch(fetch(window.location.href, { headers: { Accept: 'text/x-component' } }));
56
- payloadSetter.current(p);
57
- }
58
- catch (err) {
59
- console.error('[hmr] failed to refresh rsc payload', err);
60
- }
61
- });
53
+ if (import.meta.hot) {
54
+ import.meta.hot.on?.('rsc:update', async () => {
55
+ try {
56
+ const p = await createFromFetch(fetch(window.location.href, { headers: { Accept: 'text/x-component' } }));
57
+ payloadSetter.current(p);
58
+ }
59
+ catch (err) {
60
+ console.error('[hmr] failed to refresh rsc payload', err);
61
+ }
62
+ });
63
+ }
62
64
  }
@@ -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, toHttpExceptionLike, } from '../navigation/http-exception.js';
9
+ import { HttpException, isHttpException, toHttpException, 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';
@@ -14,6 +14,7 @@ import { processActionRequest } from '../server/actions.js';
14
14
  import DefaultErr from '../ui/defaults/error.js';
15
15
  import { RequestContext } from './request-context.js';
16
16
  import { getKnownDigest, isKnownError } from './utils.js';
17
+ const logger = new Logger();
17
18
  /**
18
19
  * Create the streamed RSC payload and response metadata for a single request.
19
20
  * Resolves the route match, collects metadata, and returns the stream,
@@ -21,7 +22,6 @@ import { getKnownDigest, isKnownError } from './utils.js';
21
22
  */
22
23
  async function createPayload(req, manifest, importMap, baseMetadata, returnValue, formState, temporaryReferences) {
23
24
  const resolver = new Resolver(manifest, importMap);
24
- const logger = new Logger();
25
25
  const prerender = req.headers.get(`x-${Solas.Config.SLUG}-prerender`) === '1';
26
26
  const url = new URL(req.url);
27
27
  const pathname = url.pathname.endsWith('/') && url.pathname !== '/'
@@ -59,16 +59,7 @@ async function createPayload(req, manifest, importMap, baseMetadata, returnValue
59
59
  cache: {},
60
60
  }, () => renderToReadableStream(rscPayload, {
61
61
  temporaryReferences,
62
- onError(err) {
63
- if (err == null)
64
- return;
65
- const digest = getKnownDigest(err);
66
- if (digest)
67
- return digest;
68
- if (isKnownError(err))
69
- return;
70
- logger.error('[rsc]', err);
71
- },
62
+ onError,
72
63
  })),
73
64
  status: 404,
74
65
  ppr: false,
@@ -78,7 +69,10 @@ async function createPayload(req, manifest, importMap, baseMetadata, returnValue
78
69
  const ppr = match.prerender === 'ppr';
79
70
  const collection = new Metadata.Collection(baseMetadata);
80
71
  const metadata = collection
81
- .add(...(match.metadata?.({ params: match.params, error: match.error }) ?? []))
72
+ .add(...(match.metadata?.({
73
+ params: match.params,
74
+ error: match.error,
75
+ }) ?? []))
82
76
  .run();
83
77
  const error = match.error ? toHttpExceptionLike(match.error) : undefined;
84
78
  const rscPayload = {
@@ -91,35 +85,20 @@ async function createPayload(req, manifest, importMap, baseMetadata, returnValue
91
85
  search: url.search,
92
86
  },
93
87
  };
94
- // status code comes from route match error if any
95
88
  const status = isHttpException(match.error) ? match.error.status : 200;
96
89
  try {
97
- // this is the main matched route render pass for page/layout
98
- // tree output. Mode is null for normal ssr, 'full' for full
99
- // prerender, and 'ppr' for ppr prerender. dynamic() only
100
- // suspends when mode is 'ppr'
101
90
  const stream = RequestContext.write({
102
91
  req,
103
92
  prerender: prerender ? (ppr ? 'ppr' : 'full') : null,
104
93
  cache: {},
105
94
  }, () => renderToReadableStream(rscPayload, {
106
95
  temporaryReferences,
107
- onError(err) {
108
- if (err == null)
109
- return;
110
- const digest = getKnownDigest(err);
111
- if (digest)
112
- return digest;
113
- if (isKnownError(err))
114
- return;
115
- logger.error('[rsc]', err);
116
- },
96
+ onError,
117
97
  }));
118
98
  return { stream, status, ppr };
119
99
  }
120
100
  catch (err) {
121
- // shell failed to render - return minimal fallback
122
- logger.error('rsc shell', err);
101
+ logger.error('[rsc:render]', err);
123
102
  const title = err instanceof Error
124
103
  ? 'status' in err
125
104
  ? `${err.status} - ${err.message}`
@@ -211,47 +190,82 @@ export function createHandler(config, manifest, importMap, artifactManifest = nu
211
190
  status,
212
191
  });
213
192
  }
214
- const artifactManifestEntry = runtimePpr
215
- ? (artifactManifest?.[lookupPath] ?? null)
216
- : null;
217
- const tryPrelude = artifactManifestEntry?.mode === 'ppr';
218
- if (tryPrelude) {
219
- const postponedState = await Prerender.Artifact.loadPostponedState(Solas.Config.OUT_DIR, lookupPath);
220
- const prelude = await Prerender.Artifact.loadPrelude(Solas.Config.OUT_DIR, lookupPath);
221
- // resumable ppr responses splice fresh streamed content into the cached
222
- // prelude when postponed state is available for this route
223
- if (postponedState) {
224
- // the cached prelude already carries the static payload, only needs to
225
- // stream the html completions for postponed boundaries
226
- const resumeStream = await mod.resume(stream, postponedState, {
227
- nonce: undefined,
228
- injectPayload: false,
229
- });
230
- const body = prelude
231
- ? Prerender.Artifact.composePreludeAndResume(prelude, resumeStream)
232
- : resumeStream;
233
- return new Response(body, {
234
- headers: {
235
- 'Cache-Control': 'private, no-store',
236
- 'Content-Type': 'text/html',
237
- Vary: 'accept',
238
- },
239
- status,
240
- });
193
+ try {
194
+ const artifactManifestEntry = runtimePpr
195
+ ? (artifactManifest?.[lookupPath] ?? null)
196
+ : null;
197
+ const tryPrelude = artifactManifestEntry?.mode === 'ppr';
198
+ if (tryPrelude) {
199
+ const postponedState = await Prerender.Artifact.loadPostponedState(Solas.Config.OUT_DIR, lookupPath);
200
+ const prelude = await Prerender.Artifact.loadPrelude(Solas.Config.OUT_DIR, lookupPath);
201
+ // resumable ppr responses splice fresh streamed content into the cached
202
+ // prelude when postponed state is available for this route
203
+ if (postponedState) {
204
+ // the cached prelude already carries the static payload, only needs to
205
+ // stream the html completions for postponed boundaries
206
+ const resumeStream = await mod.resume(stream, postponedState, {
207
+ nonce: undefined,
208
+ injectPayload: false,
209
+ });
210
+ const body = prelude
211
+ ? Prerender.Artifact.composePreludeAndResume(prelude, resumeStream)
212
+ : resumeStream;
213
+ return new Response(body, {
214
+ headers: {
215
+ 'Cache-Control': 'private, no-store',
216
+ 'Content-Type': 'text/html',
217
+ Vary: 'accept',
218
+ },
219
+ status,
220
+ });
221
+ }
241
222
  }
223
+ const htmlStream = await mod.ssr(stream, {
224
+ formState: opts.formState,
225
+ ppr: runtimePpr,
226
+ });
227
+ return new Response(htmlStream, {
228
+ headers: {
229
+ 'Cache-Control': 'private, no-store',
230
+ 'Content-Type': 'text/html',
231
+ Vary: 'accept',
232
+ },
233
+ status,
234
+ });
235
+ }
236
+ catch (err) {
237
+ // resume/ssr can be the first place react surfaces an http exception from abort(...),
238
+ // after the initial rsc pass was streamed without request error state. Rerun once
239
+ // with that error attached so createPayload rebuilds the same route through its
240
+ // nearest matching HttpExceptionBoundary. If request meta already has an error
241
+ // or the error is not an HttpException, then this is a real failure
242
+ if (!req[Solas.Config.REQUEST_META_KEY].error && isHttpException(err)) {
243
+ // normalise the surfaced digest error before attaching it, since tree/boundary lookup
244
+ // relies on error.status - the guard above only tells us this came back with an
245
+ // HttpException digest
246
+ req[Solas.Config.REQUEST_META_KEY].error = toHttpException(err);
247
+ try {
248
+ const { stream: retriedRscStream, status: retriedStatus } = await createPayload(req, manifest, importMap, config.metadata, opts.returnValue, opts.formState, opts.temporaryReferences);
249
+ const retriedStream = await retriedRscStream;
250
+ const retriedHtmlStream = await mod.ssr(retriedStream, {
251
+ formState: opts.formState,
252
+ ppr: false,
253
+ });
254
+ return new Response(retriedHtmlStream, {
255
+ headers: {
256
+ 'Cache-Control': 'private, no-store',
257
+ 'Content-Type': 'text/html',
258
+ Vary: 'accept',
259
+ },
260
+ status: retriedStatus,
261
+ });
262
+ }
263
+ finally {
264
+ req[Solas.Config.REQUEST_META_KEY].error = undefined;
265
+ }
266
+ }
267
+ throw err;
242
268
  }
243
- const htmlStream = await mod.ssr(stream, {
244
- formState: opts.formState,
245
- ppr: runtimePpr,
246
- });
247
- return new Response(htmlStream, {
248
- headers: {
249
- 'Cache-Control': 'private, no-store',
250
- 'Content-Type': 'text/html',
251
- Vary: 'accept',
252
- },
253
- status,
254
- });
255
269
  }
256
270
  const httpRouter = createHttpRouter(config, manifest, importMap, createResponse);
257
271
  // vite-plugin-rsc entrypoint
@@ -295,3 +309,13 @@ export function createHandler(config, manifest, importMap, artifactManifest = nu
295
309
  },
296
310
  };
297
311
  }
312
+ function onError(err) {
313
+ if (err == null)
314
+ return;
315
+ const digest = getKnownDigest(err);
316
+ if (digest)
317
+ return digest;
318
+ if (isKnownError(err))
319
+ return;
320
+ logger.error('[rsc]', err);
321
+ }
@@ -24,6 +24,10 @@ export declare const HTTP_EXCEPTION_DIGEST_PREFIX = "HTTP_EXCEPTION";
24
24
  * Check if an error is an HTTPException
25
25
  */
26
26
  export declare function isHttpException(err: unknown): err is HttpException;
27
+ /**
28
+ * Convert any error into an HttpException
29
+ */
30
+ export declare function toHttpException(err: unknown): HttpException;
27
31
  /**
28
32
  * Convert an HttpException or any Error into a plain object that can be
29
33
  * safely serialised
@@ -23,6 +23,12 @@ export class HttpException extends Error {
23
23
  }
24
24
  }
25
25
  export const HTTP_EXCEPTION_DIGEST_PREFIX = 'HTTP_EXCEPTION';
26
+ /**
27
+ * Status type predicate
28
+ */
29
+ function isStatusCode(value) {
30
+ return value === 401 || value === 403 || value === 404 || value === 500;
31
+ }
26
32
  /**
27
33
  * Check if an error is an HTTPException
28
34
  */
@@ -33,6 +39,41 @@ export function isHttpException(err) {
33
39
  typeof err.digest === 'string' &&
34
40
  err.digest.startsWith(HTTP_EXCEPTION_DIGEST_PREFIX));
35
41
  }
42
+ /**
43
+ * Convert any error into an HttpException
44
+ */
45
+ export function toHttpException(err) {
46
+ if (err instanceof HttpException)
47
+ return err;
48
+ let digestStatus;
49
+ let digestMessage;
50
+ if (typeof err === 'object' &&
51
+ err !== null &&
52
+ 'digest' in err &&
53
+ typeof err.digest === 'string') {
54
+ const [type, rawStatus, ...rawMessageParts] = err.digest.split(':');
55
+ const status = Number(rawStatus);
56
+ if (type === HTTP_EXCEPTION_DIGEST_PREFIX && isStatusCode(status)) {
57
+ digestStatus = status;
58
+ digestMessage = rawMessageParts.join(':');
59
+ }
60
+ }
61
+ const status = digestStatus ??
62
+ (typeof err === 'object' &&
63
+ err !== null &&
64
+ 'status' in err &&
65
+ isStatusCode(err.status)
66
+ ? err.status
67
+ : 500);
68
+ const message = digestMessage ||
69
+ (typeof err === 'object' &&
70
+ err !== null &&
71
+ 'message' in err &&
72
+ typeof err.message === 'string'
73
+ ? err.message
74
+ : 'Internal Server Error');
75
+ return new HttpException(status, message, { cause: err });
76
+ }
36
77
  /**
37
78
  * Convert an HttpException or any Error into a plain object that can be
38
79
  * safely serialised
@@ -48,7 +89,7 @@ export function toHttpExceptionLike(error) {
48
89
  ...('payload' in error && error.payload !== undefined
49
90
  ? { payload: error.payload }
50
91
  : {}),
51
- ...('status' in error ? { status: error.status } : {}),
92
+ ...('status' in error && isStatusCode(error.status) ? { status: error.status } : {}),
52
93
  };
53
94
  }
54
95
  /**
@@ -11,9 +11,6 @@ type Match = NonNullable<Resolver.EnhancedMatch>;
11
11
  * 2. `Suspense` with that segment's loading fallback
12
12
  * 3. `HttpExceptionBoundary` with that segment's status boundaries
13
13
  *
14
- * The shell level is applied last using the same outer wrapper order:
15
- * `HttpExceptionBoundary` -> `Suspense` -> `Shell`
16
- *
17
14
  * @example
18
15
  * ```tsx
19
16
  * <HttpExceptionBoundary shell>
@@ -17,9 +17,6 @@ const SERVER_ERROR = new HttpException(500, 'Internal Server Error');
17
17
  * 2. `Suspense` with that segment's loading fallback
18
18
  * 3. `HttpExceptionBoundary` with that segment's status boundaries
19
19
  *
20
- * The shell level is applied last using the same outer wrapper order:
21
- * `HttpExceptionBoundary` -> `Suspense` -> `Shell`
22
- *
23
20
  * @example
24
21
  * ```tsx
25
22
  * <HttpExceptionBoundary shell>
@@ -63,7 +60,7 @@ export function Tree({ depth, params, error, ui, }) {
63
60
  const Exception = httpExceptionMap[error.status].slice(0, depth + 1).findLast(e => e !== null) ??
64
61
  DefaultErr;
65
62
  inner = (_jsxs(_Fragment, { children: [
66
- _jsx("meta", { name: "robots", content: "noindex,nofollow" }), _jsx(Exception, { error: error })
63
+ _jsx("meta", { name: "robots", content: "noindex,nofollow" }), _jsx(Exception, { error: error, params: params })
67
64
  ] }));
68
65
  }
69
66
  else if (Page) {
@@ -96,8 +93,6 @@ export function Tree({ depth, params, error, ui, }) {
96
93
  inner = (_jsx(HttpExceptionBoundary, { components: errorBoundaries, children: inner }));
97
94
  }
98
95
  }
99
- // now wrap with shell structure: shell renders immediately,
100
- // inner streams inside Suspense
101
96
  const ShellLoading = loaders[0];
102
97
  const ShellUnauthorised = unauthorised[0];
103
98
  const ShellForbidden = forbidden[0];
@@ -17,18 +17,22 @@ export declare namespace Resolver {
17
17
  }> | null;
18
18
  '401s': (View<{
19
19
  children?: React.ReactNode;
20
+ params?: HttpRouter.Params;
20
21
  error?: HttpException;
21
22
  }> | null)[];
22
23
  '403s': (View<{
23
24
  children?: React.ReactNode;
25
+ params?: HttpRouter.Params;
24
26
  error?: HttpException;
25
27
  }> | null)[];
26
28
  '404s': (View<{
27
29
  children?: React.ReactNode;
30
+ params?: HttpRouter.Params;
28
31
  error?: HttpException;
29
32
  }> | null)[];
30
33
  '500s': (View<{
31
34
  children?: React.ReactNode;
35
+ params?: HttpRouter.Params;
32
36
  error?: HttpException;
33
37
  }> | null)[];
34
38
  loaders: (View<{
@@ -123,18 +127,22 @@ export declare class Resolver {
123
127
  }> | null;
124
128
  '401s': (View<{
125
129
  children?: import("react").ReactNode;
130
+ params?: HttpRouter.Params | undefined;
126
131
  error?: HttpException | undefined;
127
132
  }> | null)[];
128
133
  '403s': (View<{
129
134
  children?: import("react").ReactNode;
135
+ params?: HttpRouter.Params | undefined;
130
136
  error?: HttpException | undefined;
131
137
  }> | null)[];
132
138
  '404s': (View<{
133
139
  children?: import("react").ReactNode;
140
+ params?: HttpRouter.Params | undefined;
134
141
  error?: HttpException | undefined;
135
142
  }> | null)[];
136
143
  '500s': (View<{
137
144
  children?: import("react").ReactNode;
145
+ params?: HttpRouter.Params | undefined;
138
146
  error?: HttpException | undefined;
139
147
  }> | null)[];
140
148
  loaders: (View<{
@@ -1,4 +1,6 @@
1
+ import type { HttpRouter } from '../../http-router/router.js';
1
2
  import type { HttpExceptionLike } from '../../navigation/http-exception.js';
2
3
  export default function Err({ error }: {
3
4
  error: HttpExceptionLike;
5
+ params?: HttpRouter.Params;
4
6
  }): import("react/jsx-runtime").JSX.Element;
@@ -1,5 +1,5 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- export default function Err({ error }) {
2
+ export default function Err({ error, }) {
3
3
  const title = 'status' in error ? `${error.status} - ${error.message}` : error.message;
4
4
  return (_jsxs(_Fragment, { children: [
5
5
  _jsx("meta", { name: "robots", content: "noindex,nofollow" }), _jsx("title", { children: title }), _jsx("h1", { children: title }), _jsx("p", { children: error.message }), process.env.NODE_ENV === 'development' && error?.stack && _jsx("pre", { children: error.stack })] }));
@@ -1,4 +1,4 @@
1
1
  export { HttpExceptionBoundary } from './internal/navigation/http-exception-boundary.js';
2
- export { HttpException, abort, isHttpException, } from './internal/navigation/http-exception.js';
2
+ export { HttpException, HttpExceptionLike, abort, isHttpException, } from './internal/navigation/http-exception.js';
3
3
  export { RedirectBoundary } from './internal/navigation/redirect-boundary.js';
4
4
  export { Redirect, isRedirect, redirect } from './internal/navigation/redirect.js';
package/dist/types.d.ts CHANGED
@@ -36,6 +36,7 @@ export type BuildContext = {
36
36
  prerenderRoutes: Set<string>;
37
37
  knownRoutes: Set<string>;
38
38
  exportReader: ExportReader;
39
+ command?: 'build' | 'serve';
39
40
  };
40
41
  export type RequestMeta = {
41
42
  error?: HttpException | Error;
@@ -105,8 +106,24 @@ export type BuildManifest = {
105
106
  url?: PluginConfig['url'];
106
107
  };
107
108
  export declare namespace Route {
108
- type Metadata = Metadata.Item | ((input: Metadata.Input<BrowserRouter.Params>) => Promise<Metadata.Item> | Metadata.Item);
109
- type Prerender = (typeof Solas.Config.PRERENDER_MODES)[number];
109
+ type ParamsOf<TRoute> = TRoute extends {
110
+ params?: infer TParams extends BrowserRouter.Params;
111
+ } ? TParams : never;
112
+ type ErrorPropsOf<TError> = [TError] extends [never] ? {} : {
113
+ error?: TError;
114
+ };
115
+ export type Params<TRoute> = ParamsOf<TRoute>;
116
+ export type Metadata<TRoute = {
117
+ params?: BrowserRouter.Params;
118
+ }, TError = never> = Metadata.Item | ((input: Metadata.Input<ParamsOf<TRoute>, TError>) => Promise<Metadata.Item> | Metadata.Item);
119
+ export type StaticParams<TRoute> = [ParamsOf<TRoute>] extends [never] ? never : () => readonly ParamsOf<TRoute>[] | Promise<readonly ParamsOf<TRoute>[]>;
120
+ export type Props<TRoute, TError = never> = ([ParamsOf<TRoute>] extends [never] ? {
121
+ params?: never;
122
+ } : {
123
+ params: ParamsOf<TRoute>;
124
+ }) & ErrorPropsOf<TError>;
125
+ export type Prerender = (typeof Solas.Config.PRERENDER_MODES)[number];
126
+ export {};
110
127
  }
111
128
  export type BoundaryError = Error & {
112
129
  digest?: string;
@@ -1,5 +1,10 @@
1
+ import type { ViteDevServer } from 'vite';
1
2
  export declare class ExportReader {
2
3
  #private;
4
+ /**
5
+ * Set the Vite server's SSR module loader so we can execute modules
6
+ */
7
+ set loadModule(l: ViteDevServer['ssrLoadModule']);
3
8
  /**
4
9
  * Read the raw text content of a file
5
10
  */
@@ -24,6 +29,6 @@ export declare class ExportReader {
24
29
  value<T>(filePath: string, name: string, validate?: ExportReader.Validator<T>): Promise<T | undefined>;
25
30
  }
26
31
  export declare namespace ExportReader {
27
- type Loader = 'js' | 'jsx' | 'ts' | 'tsx';
32
+ type LoaderType = 'js' | 'jsx' | 'ts' | 'tsx';
28
33
  type Validator<T> = (value: unknown) => value is T;
29
34
  }
@@ -1,10 +1,11 @@
1
1
  import path from 'node:path';
2
2
  export class ExportReader {
3
3
  #transpilers = new Map();
4
+ #loadModule = null;
4
5
  /**
5
- * Pick the Bun loader that matches the source file extension
6
+ * Pick the Bun loader type that matches the source file extension
6
7
  */
7
- static #getLoader(filePath) {
8
+ static #getLoaderType(filePath) {
8
9
  const ext = path.extname(filePath).toLowerCase();
9
10
  if (ext === '.js' || ext === '.mjs' || ext === '.cjs')
10
11
  return 'js';
@@ -16,18 +17,6 @@ export class ExportReader {
16
17
  return 'tsx';
17
18
  throw new Error(`Unsupported module extension: ${ext || '(none)'} in ${filePath}`);
18
19
  }
19
- /**
20
- * Reuse one transpiler per supported loader so scans match the module syntax
21
- */
22
- #getTranspiler(filePath) {
23
- const loader = ExportReader.#getLoader(filePath);
24
- const cached = this.#transpilers.get(loader);
25
- if (cached)
26
- return cached;
27
- const transpiler = new Bun.Transpiler({ loader });
28
- this.#transpilers.set(loader, transpiler);
29
- return transpiler;
30
- }
31
20
  /**
32
21
  * Parse a literal value from a string
33
22
  */
@@ -50,6 +39,24 @@ export class ExportReader {
50
39
  if (Number.isFinite(n))
51
40
  return n;
52
41
  }
42
+ /**
43
+ * Set the Vite server's SSR module loader so we can execute modules
44
+ */
45
+ set loadModule(l) {
46
+ this.#loadModule = l;
47
+ }
48
+ /**
49
+ * Reuse one transpiler per supported loader so scans match the module syntax
50
+ */
51
+ #getTranspiler(filePath) {
52
+ const type = ExportReader.#getLoaderType(filePath);
53
+ const cached = this.#transpilers.get(type);
54
+ if (cached)
55
+ return cached;
56
+ const transpiler = new Bun.Transpiler({ loader: type });
57
+ this.#transpilers.set(type, transpiler);
58
+ return transpiler;
59
+ }
53
60
  /**
54
61
  * Read the raw text content of a file
55
62
  */
@@ -107,7 +114,9 @@ export class ExportReader {
107
114
  // resolve from the project root so generated/build-time callers can pass the
108
115
  // same workspace-relative paths used elsewhere in the route graph
109
116
  const abs = path.resolve(process.cwd(), filePath);
110
- const mod = (await import(/* @vite-ignore */ abs));
117
+ const mod = this.#loadModule
118
+ ? await this.#loadModule(abs)
119
+ : await import(/* @vite-ignore */ abs);
111
120
  const value = mod[name];
112
121
  if (value === undefined)
113
122
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jk2908/solas",
3
- "version": "0.3.7",
3
+ "version": "0.3.8",
4
4
  "description": "A React Server Components meta-framework powered by Vite",
5
5
  "keywords": [
6
6
  "framework",