@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 +9 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +59 -4
- package/dist/internal/build.js +14 -14
- package/dist/internal/env/browser.js +11 -9
- package/dist/internal/env/rsc.js +93 -69
- package/dist/internal/navigation/http-exception.d.ts +4 -0
- package/dist/internal/navigation/http-exception.js +42 -1
- package/dist/internal/render/tree.d.ts +0 -3
- package/dist/internal/render/tree.js +1 -6
- package/dist/internal/resolver.d.ts +8 -0
- package/dist/internal/ui/defaults/error.d.ts +2 -0
- package/dist/internal/ui/defaults/error.js +1 -1
- package/dist/navigation.d.ts +1 -1
- package/dist/types.d.ts +19 -2
- package/dist/utils/export-reader.d.ts +6 -1
- package/dist/utils/export-reader.js +24 -15
- package/package.json +1 -1
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
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 (
|
|
224
|
-
|
|
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) {
|
package/dist/internal/build.js
CHANGED
|
@@ -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':
|
|
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
|
|
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
|
|
379
|
-
if (!
|
|
380
|
-
|
|
378
|
+
for (const unauthorisedPath of unauthorisedPaths) {
|
|
379
|
+
if (!unauthorisedPath) {
|
|
380
|
+
unauthorisedIds.push(null);
|
|
381
381
|
continue;
|
|
382
382
|
}
|
|
383
|
-
const
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
if (!processed.has(
|
|
387
|
-
imports.components.dynamic.set(
|
|
388
|
-
processed.add(
|
|
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':
|
|
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':
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
}
|
package/dist/internal/env/rsc.js
CHANGED
|
@@ -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
|
|
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?.({
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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 })] }));
|
package/dist/navigation.d.ts
CHANGED
|
@@ -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
|
|
109
|
-
|
|
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
|
|
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 #
|
|
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 =
|
|
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;
|