@jk2908/solas 0.3.7 → 0.4.0
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 +24 -0
- package/README.md +66 -6
- package/dist/index.d.ts +2 -2
- package/dist/index.js +75 -6
- package/dist/internal/browser-router/link.d.ts +1 -1
- package/dist/internal/browser-router/link.js +1 -1
- package/dist/internal/browser-router/router.d.ts +2 -165
- package/dist/internal/browser-router/router.js +3 -99
- package/dist/internal/browser-router/shared.d.ts +169 -0
- package/dist/internal/browser-router/shared.js +71 -0
- package/dist/internal/browser-router/use-router.d.ts +1 -1
- package/dist/internal/build.js +14 -14
- package/dist/internal/codegen/environments.js +5 -4
- package/dist/internal/env/browser.js +11 -9
- package/dist/internal/env/rsc.d.ts +2 -2
- package/dist/internal/env/rsc.js +170 -86
- package/dist/internal/http-router/create-http-router.d.ts +1 -1
- package/dist/internal/http-router/create-http-router.js +4 -2
- package/dist/internal/http-router/router.d.ts +4 -14
- package/dist/internal/http-router/router.js +32 -59
- package/dist/internal/navigation/http-exception.d.ts +8 -4
- package/dist/internal/navigation/http-exception.js +46 -6
- package/dist/internal/postbuild.d.ts +1 -0
- package/dist/{cli/build.js → internal/postbuild.js} +13 -48
- package/dist/internal/prerender.d.ts +4 -19
- package/dist/internal/prerender.js +8 -98
- package/dist/internal/public-files.d.ts +18 -0
- package/dist/internal/public-files.js +63 -0
- package/dist/internal/render/tree.d.ts +0 -3
- package/dist/internal/render/tree.js +1 -6
- package/dist/internal/resolver.d.ts +31 -23
- package/dist/internal/server/actions.d.ts +2 -5
- package/dist/internal/server/actions.js +4 -35
- package/dist/internal/server/csrf.d.ts +14 -0
- package/dist/internal/server/csrf.js +98 -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/router.d.ts +1 -0
- package/dist/router.js +1 -0
- package/dist/solas.d.ts +12 -1
- package/dist/solas.js +116 -1
- package/dist/types.d.ts +27 -5
- package/dist/utils/base-path.d.ts +14 -0
- package/dist/utils/base-path.js +85 -0
- package/dist/utils/export-reader.d.ts +6 -1
- package/dist/utils/export-reader.js +24 -15
- package/package.json +3 -6
- package/dist/cli/build.d.ts +0 -7
- package/dist/cli/dev.d.ts +0 -4
- package/dist/cli/dev.js +0 -13
- package/dist/cli/preview.d.ts +0 -1
- package/dist/cli/preview.js +0 -47
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +0 -28
package/dist/internal/env/rsc.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
import { renderToReadableStream } from '@vitejs/plugin-rsc/rsc';
|
|
4
|
+
import { BasePath } from '../../utils/base-path.js';
|
|
3
5
|
import { Logger } from '../../utils/logger.js';
|
|
4
6
|
import { Solas } from '../../solas.js';
|
|
5
7
|
import { createHttpRouter } from '../http-router/create-http-router.js';
|
|
6
8
|
import { HttpRouter } from '../http-router/router.js';
|
|
7
9
|
import { normalisePathname } from '../http-router/utils.js';
|
|
8
10
|
import { Metadata } from '../metadata.js';
|
|
9
|
-
import { HttpException, isHttpException, toHttpExceptionLike, } from '../navigation/http-exception.js';
|
|
11
|
+
import { HttpException, isHttpException, toHttpException, toHttpExceptionLike, } from '../navigation/http-exception.js';
|
|
10
12
|
import { Prerender } from '../prerender.js';
|
|
11
13
|
import { Tree } from '../render/tree.js';
|
|
12
14
|
import { Resolver } from '../resolver.js';
|
|
@@ -14,6 +16,23 @@ import { processActionRequest } from '../server/actions.js';
|
|
|
14
16
|
import DefaultErr from '../ui/defaults/error.js';
|
|
15
17
|
import { RequestContext } from './request-context.js';
|
|
16
18
|
import { getKnownDigest, isKnownError } from './utils.js';
|
|
19
|
+
const logger = new Logger();
|
|
20
|
+
const BASE_PATH = BasePath.normalise(import.meta.env.BASE_URL);
|
|
21
|
+
function resolveFilePath(root, relativePath) {
|
|
22
|
+
try {
|
|
23
|
+
const decodedPath = decodeURIComponent(relativePath);
|
|
24
|
+
if (!decodedPath)
|
|
25
|
+
return new Response('Forbidden', { status: 403 });
|
|
26
|
+
const filePath = path.resolve(root, decodedPath);
|
|
27
|
+
if (filePath !== root && !filePath.startsWith(`${root}${path.sep}`)) {
|
|
28
|
+
return new Response('Forbidden', { status: 403 });
|
|
29
|
+
}
|
|
30
|
+
return filePath;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return new Response('Bad Request', { status: 400 });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
17
36
|
/**
|
|
18
37
|
* Create the streamed RSC payload and response metadata for a single request.
|
|
19
38
|
* Resolves the route match, collects metadata, and returns the stream,
|
|
@@ -21,12 +40,10 @@ import { getKnownDigest, isKnownError } from './utils.js';
|
|
|
21
40
|
*/
|
|
22
41
|
async function createPayload(req, manifest, importMap, baseMetadata, returnValue, formState, temporaryReferences) {
|
|
23
42
|
const resolver = new Resolver(manifest, importMap);
|
|
24
|
-
const logger = new Logger();
|
|
25
43
|
const prerender = req.headers.get(`x-${Solas.Config.SLUG}-prerender`) === '1';
|
|
26
44
|
const url = new URL(req.url);
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
: url.pathname;
|
|
45
|
+
const routedPath = BasePath.strip(url.pathname, BASE_PATH) ?? url.pathname;
|
|
46
|
+
const pathname = routedPath.endsWith('/') && routedPath !== '/' ? routedPath.slice(0, -1) : routedPath;
|
|
30
47
|
const match = resolver.enhance(resolver.reconcile(pathname, req[Solas.Config.REQUEST_META_KEY].match, req[Solas.Config.REQUEST_META_KEY].error));
|
|
31
48
|
// if there's no match then no user supplied error boundary
|
|
32
49
|
// has been found, and we should server render a default
|
|
@@ -59,16 +76,7 @@ async function createPayload(req, manifest, importMap, baseMetadata, returnValue
|
|
|
59
76
|
cache: {},
|
|
60
77
|
}, () => renderToReadableStream(rscPayload, {
|
|
61
78
|
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
|
-
},
|
|
79
|
+
onError,
|
|
72
80
|
})),
|
|
73
81
|
status: 404,
|
|
74
82
|
ppr: false,
|
|
@@ -78,7 +86,10 @@ async function createPayload(req, manifest, importMap, baseMetadata, returnValue
|
|
|
78
86
|
const ppr = match.prerender === 'ppr';
|
|
79
87
|
const collection = new Metadata.Collection(baseMetadata);
|
|
80
88
|
const metadata = collection
|
|
81
|
-
.add(...(match.metadata?.({
|
|
89
|
+
.add(...(match.metadata?.({
|
|
90
|
+
params: match.params,
|
|
91
|
+
error: match.error,
|
|
92
|
+
}) ?? []))
|
|
82
93
|
.run();
|
|
83
94
|
const error = match.error ? toHttpExceptionLike(match.error) : undefined;
|
|
84
95
|
const rscPayload = {
|
|
@@ -91,35 +102,20 @@ async function createPayload(req, manifest, importMap, baseMetadata, returnValue
|
|
|
91
102
|
search: url.search,
|
|
92
103
|
},
|
|
93
104
|
};
|
|
94
|
-
// status code comes from route match error if any
|
|
95
105
|
const status = isHttpException(match.error) ? match.error.status : 200;
|
|
96
106
|
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
107
|
const stream = RequestContext.write({
|
|
102
108
|
req,
|
|
103
109
|
prerender: prerender ? (ppr ? 'ppr' : 'full') : null,
|
|
104
110
|
cache: {},
|
|
105
111
|
}, () => renderToReadableStream(rscPayload, {
|
|
106
112
|
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
|
-
},
|
|
113
|
+
onError,
|
|
117
114
|
}));
|
|
118
115
|
return { stream, status, ppr };
|
|
119
116
|
}
|
|
120
117
|
catch (err) {
|
|
121
|
-
|
|
122
|
-
logger.error('rsc shell', err);
|
|
118
|
+
logger.error('[rsc:render]', err);
|
|
123
119
|
const title = err instanceof Error
|
|
124
120
|
? 'status' in err
|
|
125
121
|
? `${err.status} - ${err.message}`
|
|
@@ -162,7 +158,12 @@ async function createPayload(req, manifest, importMap, baseMetadata, returnValue
|
|
|
162
158
|
* route manifest, and import map to build the router once, then returns an object
|
|
163
159
|
* with a fetch method that handles requests
|
|
164
160
|
*/
|
|
165
|
-
export function createHandler(config, manifest, importMap,
|
|
161
|
+
export function createHandler(config, manifest, importMap, runtimeManifest = null) {
|
|
162
|
+
const CLIENT_OUTPUT_DIR = path.resolve(Solas.Config.OUT_DIR, 'client');
|
|
163
|
+
// vite emits solas-controlled assets under dist/client/_solas
|
|
164
|
+
const SOLAS_ASSETS_DIR = path.resolve(CLIENT_OUTPUT_DIR, Solas.Config.ASSETS_DIR);
|
|
165
|
+
// requests for /_solas and /_solas/* are reserved
|
|
166
|
+
const SOLAS_ASSETS_URL_ROOT = `/${Solas.Config.ASSETS_DIR}`;
|
|
166
167
|
const prerenderPathMode = config.trailingSlash === 'always' ? 'always' : 'never';
|
|
167
168
|
/**
|
|
168
169
|
* Create the HTTP response for a single incoming request. Runs actions when needed,
|
|
@@ -175,8 +176,12 @@ export function createHandler(config, manifest, importMap, artifactManifest = nu
|
|
|
175
176
|
temporaryReferences: undefined,
|
|
176
177
|
returnValue: undefined,
|
|
177
178
|
};
|
|
178
|
-
if (req[Solas.Config.REQUEST_META_KEY].action)
|
|
179
|
-
opts = await processActionRequest(req
|
|
179
|
+
if (req[Solas.Config.REQUEST_META_KEY].action) {
|
|
180
|
+
opts = await processActionRequest(req, {
|
|
181
|
+
trustedOrigins: config.trustedOrigins,
|
|
182
|
+
url: config.url,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
180
185
|
const { stream: rscStream, status, ppr, } = await createPayload(req, manifest, importMap, config.metadata, opts.returnValue, opts.formState, opts.temporaryReferences);
|
|
181
186
|
const stream = await rscStream;
|
|
182
187
|
if (!req.headers.get('accept')?.includes('text/html')) {
|
|
@@ -211,82 +216,151 @@ export function createHandler(config, manifest, importMap, artifactManifest = nu
|
|
|
211
216
|
status,
|
|
212
217
|
});
|
|
213
218
|
}
|
|
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
|
-
|
|
219
|
+
try {
|
|
220
|
+
const artifactEntry = runtimePpr
|
|
221
|
+
? (runtimeManifest?.artifacts[lookupPath] ?? null)
|
|
222
|
+
: null;
|
|
223
|
+
const tryPrelude = artifactEntry?.mode === 'ppr';
|
|
224
|
+
if (tryPrelude) {
|
|
225
|
+
const postponedState = await Prerender.Artifact.loadPostponedState(Solas.Config.OUT_DIR, lookupPath);
|
|
226
|
+
const prelude = await Prerender.Artifact.loadPrelude(Solas.Config.OUT_DIR, lookupPath);
|
|
227
|
+
// resumable ppr responses splice fresh streamed content into the cached
|
|
228
|
+
// prelude when postponed state is available for this route
|
|
229
|
+
if (postponedState) {
|
|
230
|
+
// the cached prelude already carries the static payload, only needs to
|
|
231
|
+
// stream the html completions for postponed boundaries
|
|
232
|
+
const resumeStream = await mod.resume(stream, postponedState, {
|
|
233
|
+
nonce: undefined,
|
|
234
|
+
injectPayload: false,
|
|
235
|
+
});
|
|
236
|
+
const body = prelude
|
|
237
|
+
? Prerender.Artifact.composePreludeAndResume(prelude, resumeStream)
|
|
238
|
+
: resumeStream;
|
|
239
|
+
return new Response(body, {
|
|
240
|
+
headers: {
|
|
241
|
+
'Cache-Control': 'private, no-store',
|
|
242
|
+
'Content-Type': 'text/html',
|
|
243
|
+
Vary: 'accept',
|
|
244
|
+
},
|
|
245
|
+
status,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const htmlStream = await mod.ssr(stream, {
|
|
250
|
+
formState: opts.formState,
|
|
251
|
+
ppr: runtimePpr,
|
|
252
|
+
});
|
|
253
|
+
return new Response(htmlStream, {
|
|
254
|
+
headers: {
|
|
255
|
+
'Cache-Control': 'private, no-store',
|
|
256
|
+
'Content-Type': 'text/html',
|
|
257
|
+
Vary: 'accept',
|
|
258
|
+
},
|
|
259
|
+
status,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
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
|
+
}
|
|
241
292
|
}
|
|
293
|
+
throw err;
|
|
242
294
|
}
|
|
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
295
|
}
|
|
256
296
|
const httpRouter = createHttpRouter(config, manifest, importMap, createResponse);
|
|
257
297
|
// vite-plugin-rsc entrypoint
|
|
258
298
|
return {
|
|
259
299
|
async fetch(req) {
|
|
260
300
|
const url = new URL(req.url);
|
|
261
|
-
const accept = req.headers.get('accept') ?? '';
|
|
262
301
|
const method = req.method.toUpperCase();
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
302
|
+
// fast path
|
|
303
|
+
if (method !== 'GET' && method !== 'HEAD')
|
|
304
|
+
return httpRouter.fetch(req);
|
|
305
|
+
const accept = req.headers.get('accept') ?? '';
|
|
306
|
+
const routedPath = BasePath.strip(url.pathname, BASE_PATH);
|
|
307
|
+
const canonicalPath = routedPath == null
|
|
308
|
+
? null
|
|
309
|
+
: config.trailingSlash === 'ignore'
|
|
310
|
+
? routedPath
|
|
311
|
+
: normalisePathname(routedPath, config.trailingSlash);
|
|
312
|
+
const canonicalPathname = canonicalPath == null ? null : BasePath.apply(canonicalPath, BASE_PATH);
|
|
313
|
+
if (canonicalPathname != null &&
|
|
314
|
+
(method === 'GET' || method === 'HEAD') &&
|
|
267
315
|
config.trailingSlash !== 'ignore' &&
|
|
268
|
-
|
|
269
|
-
url.pathname =
|
|
316
|
+
canonicalPathname !== url.pathname) {
|
|
317
|
+
url.pathname = canonicalPathname;
|
|
270
318
|
return Response.redirect(url.toString(), 308);
|
|
271
319
|
}
|
|
320
|
+
// block the bare /_solas namespace; only concrete solas asset files
|
|
321
|
+
// under /_solas/* are valid
|
|
322
|
+
if (routedPath === SOLAS_ASSETS_URL_ROOT) {
|
|
323
|
+
return new Response('Forbidden', { status: 403 });
|
|
324
|
+
}
|
|
325
|
+
if (routedPath?.startsWith(`${SOLAS_ASSETS_URL_ROOT}/`)) {
|
|
326
|
+
const resolvedPath = resolveFilePath(SOLAS_ASSETS_DIR, routedPath.slice(`${SOLAS_ASSETS_URL_ROOT}/`.length));
|
|
327
|
+
// pass through bad-request or forbidden responses from path resolution
|
|
328
|
+
if (resolvedPath instanceof Response)
|
|
329
|
+
return resolvedPath;
|
|
330
|
+
return HttpRouter.serveStatic(resolvedPath, req, config.precompress, {
|
|
331
|
+
'Cache-Control': 'public, immutable, max-age=31536000',
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
if (routedPath && runtimeManifest?.publicFiles.has(routedPath)) {
|
|
335
|
+
const resolvedPath = resolveFilePath(CLIENT_OUTPUT_DIR, routedPath.slice(1));
|
|
336
|
+
// pass through bad-request or forbidden responses from path resolution
|
|
337
|
+
if (resolvedPath instanceof Response)
|
|
338
|
+
return resolvedPath;
|
|
339
|
+
return HttpRouter.serveStatic(resolvedPath, req, config.precompress);
|
|
340
|
+
}
|
|
272
341
|
// fully prerendered html can be served straight from disk for normal
|
|
273
342
|
// document requests, but build-time artifact requests must bypass
|
|
274
343
|
// this shortcut so they still render fresh output
|
|
275
|
-
if (
|
|
344
|
+
if (canonicalPath != null &&
|
|
345
|
+
!import.meta.env.DEV &&
|
|
276
346
|
accept.includes('text/html') &&
|
|
277
347
|
req.headers.get(`x-${Solas.Config.SLUG}-prerender-artifact`) !== '1') {
|
|
278
348
|
// turn the request path into the normal route shape we use for artifact lookups
|
|
279
349
|
const lookupPath = normalisePathname(canonicalPath, prerenderPathMode);
|
|
280
350
|
// only full prerender routes have a saved html file we can serve directly
|
|
281
|
-
const prerenderPath =
|
|
351
|
+
const prerenderPath = runtimeManifest?.artifacts[lookupPath]?.mode === 'full'
|
|
282
352
|
? Prerender.Artifact.getFilePath(Solas.Config.OUT_DIR, lookupPath, Prerender.Artifact.FULL_PRERENDER_FILENAME)
|
|
283
353
|
: null;
|
|
284
354
|
if (prerenderPath) {
|
|
285
|
-
const res = await HttpRouter.
|
|
286
|
-
//
|
|
355
|
+
const res = await HttpRouter.serveStatic(prerenderPath, req, config.precompress, {
|
|
356
|
+
// keep prerendered html out of shared caches unless users opt into explicit public caching
|
|
357
|
+
// default to private, no-store for now
|
|
358
|
+
// @todo: public caching?
|
|
287
359
|
'Cache-Control': 'private, no-store',
|
|
288
360
|
'Content-Type': 'text/html; charset=utf-8',
|
|
289
361
|
});
|
|
362
|
+
// only a missing prerendered file should fall back to normal request handling
|
|
363
|
+
// any other static-file response should be returned as-is
|
|
290
364
|
if (res.status !== 404)
|
|
291
365
|
return res;
|
|
292
366
|
}
|
|
@@ -295,3 +369,13 @@ export function createHandler(config, manifest, importMap, artifactManifest = nu
|
|
|
295
369
|
},
|
|
296
370
|
};
|
|
297
371
|
}
|
|
372
|
+
function onError(err) {
|
|
373
|
+
if (err == null)
|
|
374
|
+
return;
|
|
375
|
+
const digest = getKnownDigest(err);
|
|
376
|
+
if (digest)
|
|
377
|
+
return digest;
|
|
378
|
+
if (isKnownError(err))
|
|
379
|
+
return;
|
|
380
|
+
logger.error('[rsc]', err);
|
|
381
|
+
}
|
|
@@ -3,4 +3,4 @@ import { HttpRouter } from './router.js';
|
|
|
3
3
|
/**
|
|
4
4
|
* Create the HTTP router from the generated manifest and import map
|
|
5
5
|
*/
|
|
6
|
-
export declare function createHttpRouter(config: Pick<PluginConfig, '
|
|
6
|
+
export declare function createHttpRouter(config: Pick<PluginConfig, 'trailingSlash' | 'trustedOrigins' | 'url'>, manifest: Manifest, importMap: ImportMap, rsc: (req: SolasRequest) => Response | Promise<Response>): HttpRouter;
|
|
@@ -49,9 +49,11 @@ function mergeMiddlewares(left, right) {
|
|
|
49
49
|
export function createHttpRouter(config, manifest, importMap, rsc) {
|
|
50
50
|
const router = new HttpRouter({
|
|
51
51
|
trailingSlash: config.trailingSlash,
|
|
52
|
+
csrf: {
|
|
53
|
+
trustedOrigins: config.trustedOrigins,
|
|
54
|
+
url: config.url,
|
|
55
|
+
},
|
|
52
56
|
});
|
|
53
|
-
// static assets stay outside route middleware conventions and are registered once
|
|
54
|
-
router.add('/assets/*', 'GET', HttpRouter.static(config));
|
|
55
57
|
for (const [, group] of createHandlerGroups(manifest)) {
|
|
56
58
|
if (!Array.isArray(group)) {
|
|
57
59
|
if ('paths' in group) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { PluginConfig, SolasRequest } from '../../types.js';
|
|
2
|
+
import { CsrfConfig } from '../server/csrf.js';
|
|
2
3
|
export declare namespace HttpRouter {
|
|
3
4
|
type Params = Record<string, string | string[]>;
|
|
4
5
|
type Handler = (req: SolasRequest) => Response | Promise<Response>;
|
|
@@ -24,6 +25,7 @@ export declare namespace HttpRouter {
|
|
|
24
25
|
};
|
|
25
26
|
type Options = {
|
|
26
27
|
trailingSlash?: NonNullable<PluginConfig['trailingSlash']>;
|
|
28
|
+
csrf?: CsrfConfig;
|
|
27
29
|
};
|
|
28
30
|
type Registry = {
|
|
29
31
|
static: Map<string, Route>;
|
|
@@ -56,24 +58,12 @@ export declare class HttpRouter {
|
|
|
56
58
|
* Register a route handler
|
|
57
59
|
*/
|
|
58
60
|
add(path: string, method: string, handler: HttpRouter.Handler, params?: string[], middleware?: HttpRouter.Middleware[]): this;
|
|
59
|
-
/**
|
|
60
|
-
* Match a path and method, returning params and route
|
|
61
|
-
*/
|
|
62
|
-
match(path: string, method: HttpMethod): {
|
|
63
|
-
route: HttpRouter.Route;
|
|
64
|
-
params: HttpRouter.Params;
|
|
65
|
-
} | null;
|
|
66
61
|
/**
|
|
67
62
|
* Handle an incoming request
|
|
68
63
|
*/
|
|
69
64
|
fetch(req: Request): Promise<Response>;
|
|
70
|
-
/**
|
|
71
|
-
* Serve static assets from the output directory
|
|
72
|
-
* @note generated /assets/* handlers bypass +middleware conventions
|
|
73
|
-
*/
|
|
74
|
-
static static(config: PluginConfig): (req: Request) => Promise<Response>;
|
|
75
65
|
/**
|
|
76
66
|
* Serve a file with optional compression content negotiation
|
|
77
67
|
*/
|
|
78
|
-
static
|
|
68
|
+
static serveStatic(filePath: string, req: Request, precompress?: boolean, headers?: Record<string, string>): Promise<Response>;
|
|
79
69
|
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import path from 'node:path';
|
|
2
1
|
import { match as createMatch } from 'path-to-regexp';
|
|
2
|
+
import { BasePath } from '../../utils/base-path.js';
|
|
3
3
|
import { Solas } from '../../solas.js';
|
|
4
4
|
import { HttpException } from '../navigation/http-exception.js';
|
|
5
5
|
import { maybeAction } from '../server/actions.js';
|
|
6
|
+
import { enforce } from '../server/csrf.js';
|
|
6
7
|
import { getAlternatePathname, normalisePathname, toPathPattern } from './utils.js';
|
|
8
|
+
const BASE_PATH = BasePath.normalise(import.meta.env.BASE_URL);
|
|
7
9
|
/**
|
|
8
10
|
* Handle routing and matching for server requests
|
|
9
11
|
*/
|
|
@@ -121,7 +123,7 @@ export class HttpRouter {
|
|
|
121
123
|
/**
|
|
122
124
|
* Match a path and method, returning params and route
|
|
123
125
|
*/
|
|
124
|
-
match(path, method) {
|
|
126
|
+
#match(path, method) {
|
|
125
127
|
for (const candidate of HttpRouter.#candidates(path)) {
|
|
126
128
|
const direct = this.#routes.static.get(`${method}:${candidate}`);
|
|
127
129
|
if (direct)
|
|
@@ -163,51 +165,53 @@ export class HttpRouter {
|
|
|
163
165
|
async fetch(req) {
|
|
164
166
|
const url = new URL(req.url);
|
|
165
167
|
const trailingSlash = this.opts.trailingSlash ?? 'never';
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
168
|
+
const routedPath = BasePath.strip(url.pathname, BASE_PATH);
|
|
169
|
+
const path = routedPath == null
|
|
170
|
+
? null
|
|
171
|
+
: trailingSlash === 'ignore'
|
|
172
|
+
? routedPath
|
|
173
|
+
: normalisePathname(routedPath, trailingSlash);
|
|
169
174
|
let match = null;
|
|
170
175
|
let action = false;
|
|
171
176
|
try {
|
|
172
177
|
const method = req.method.toUpperCase();
|
|
173
|
-
|
|
174
|
-
|
|
178
|
+
const canonicalPathname = path == null ? null : BasePath.apply(path, BASE_PATH);
|
|
179
|
+
if (canonicalPathname != null &&
|
|
180
|
+
(method === 'GET' || method === 'HEAD') &&
|
|
181
|
+
canonicalPathname !== url.pathname) {
|
|
182
|
+
url.pathname = canonicalPathname;
|
|
175
183
|
return Response.redirect(url.toString(), 308);
|
|
176
184
|
}
|
|
177
|
-
if (
|
|
178
|
-
// rebuild the request
|
|
179
|
-
|
|
180
|
-
url.pathname = path;
|
|
185
|
+
if (canonicalPathname != null && canonicalPathname !== url.pathname) {
|
|
186
|
+
// rebuild the request so the rest of the app sees the same path the router used
|
|
187
|
+
url.pathname = canonicalPathname;
|
|
181
188
|
req = new Request(url.toString(), req);
|
|
182
189
|
}
|
|
183
190
|
const { action: isAction, formData: parsedFormData } = await maybeAction(req);
|
|
184
191
|
action = isAction;
|
|
185
|
-
// action requests stay on the same
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
// may redirect()
|
|
189
|
-
match = this.match(path, action ? 'GET' : method);
|
|
192
|
+
// action requests stay on the same path, but we match them like GET so
|
|
193
|
+
// the normal page tree can rerender around the action result
|
|
194
|
+
match = path == null ? null : this.#match(path, action ? 'GET' : method);
|
|
190
195
|
if (!match) {
|
|
191
196
|
const error = new HttpException(404, 'Not found');
|
|
192
|
-
// unmatched requests still
|
|
193
|
-
// same request metadata shape as matched requests
|
|
197
|
+
// unmatched requests still go through the same error hook shape as matched ones
|
|
194
198
|
return (this.#onError?.(error, Object.assign(req, {
|
|
195
199
|
[Solas.Config.REQUEST_META_KEY]: { match: null, error, action },
|
|
196
200
|
})) ?? new Response(error.message, { status: error.status }));
|
|
197
201
|
}
|
|
198
202
|
const matched = match;
|
|
199
|
-
// attach
|
|
200
|
-
// read the same per-request metadata
|
|
203
|
+
// attach route state once so middleware and handlers read the same request data
|
|
201
204
|
const request = Object.assign(req, {
|
|
202
205
|
[Solas.Config.REQUEST_META_KEY]: { match: matched, action, parsedFormData },
|
|
203
206
|
});
|
|
204
|
-
//
|
|
205
|
-
|
|
207
|
+
// check csrf before any middleware or handler runs
|
|
208
|
+
enforce(request, this.opts.csrf);
|
|
209
|
+
// keep global middleware outside route middleware and preserve registration order
|
|
206
210
|
const stack = [...this.#middleware.global, ...matched.route.middleware];
|
|
207
211
|
return this.#run(stack, request, () => matched.route.handler?.(request) ?? new Response('Not found', { status: 404 }));
|
|
208
212
|
}
|
|
209
213
|
catch (err) {
|
|
210
|
-
//
|
|
214
|
+
// turn unknown throws into Error objects so the error hook sees one shape
|
|
211
215
|
const error = err instanceof Error ? err : new Error(String(err), { cause: err });
|
|
212
216
|
const request = Object.assign(req, {
|
|
213
217
|
[Solas.Config.REQUEST_META_KEY]: { match, error, action },
|
|
@@ -224,17 +228,16 @@ export class HttpRouter {
|
|
|
224
228
|
* Run middleware stack
|
|
225
229
|
*/
|
|
226
230
|
#run(stack, req, next) {
|
|
227
|
-
//
|
|
231
|
+
// build the middleware chain from the inside out
|
|
228
232
|
let run = () => Promise.resolve(next());
|
|
229
|
-
//
|
|
233
|
+
// wrap each middleware around the current chain
|
|
230
234
|
for (let i = stack.length - 1; i >= 0; i -= 1) {
|
|
231
235
|
const handler = stack[i];
|
|
232
236
|
const prev = run;
|
|
233
237
|
run = () => {
|
|
234
238
|
let called = false;
|
|
235
239
|
return Promise.resolve(handler(req, () => {
|
|
236
|
-
//
|
|
237
|
-
// only execute once per request
|
|
240
|
+
// next() can only be used once per middleware call
|
|
238
241
|
if (called)
|
|
239
242
|
throw new Error('next() called more than once');
|
|
240
243
|
called = true;
|
|
@@ -242,43 +245,13 @@ export class HttpRouter {
|
|
|
242
245
|
}));
|
|
243
246
|
};
|
|
244
247
|
}
|
|
245
|
-
//
|
|
248
|
+
// start the middleware chain
|
|
246
249
|
return run();
|
|
247
250
|
}
|
|
248
|
-
/**
|
|
249
|
-
* Serve static assets from the output directory
|
|
250
|
-
* @note generated /assets/* handlers bypass +middleware conventions
|
|
251
|
-
*/
|
|
252
|
-
static static(config) {
|
|
253
|
-
return async (req) => {
|
|
254
|
-
const pathname = new URL(req.url).pathname;
|
|
255
|
-
const outDir = path.resolve(Solas.Config.OUT_DIR);
|
|
256
|
-
const staticRoot = path.resolve(outDir, 'client');
|
|
257
|
-
let decodedPathname = pathname;
|
|
258
|
-
try {
|
|
259
|
-
// validate any percent-encoding before resolving the asset path
|
|
260
|
-
decodedPathname = decodeURIComponent(pathname);
|
|
261
|
-
}
|
|
262
|
-
catch {
|
|
263
|
-
return new Response('Bad Request', { status: 400 });
|
|
264
|
-
}
|
|
265
|
-
const relativePath = decodedPathname.replace(/^\/+/, '');
|
|
266
|
-
const filePath = path.resolve(staticRoot, relativePath);
|
|
267
|
-
// keep asset requests pinned under the client output root even if the
|
|
268
|
-
// incoming path contains traversal segments
|
|
269
|
-
if (filePath !== staticRoot && !filePath.startsWith(`${staticRoot}${path.sep}`)) {
|
|
270
|
-
return new Response('Forbidden', { status: 403 });
|
|
271
|
-
}
|
|
272
|
-
// emitted assets are fingerprinted so they can be cached aggressively
|
|
273
|
-
return HttpRouter.serve(filePath, req, config.precompress, {
|
|
274
|
-
'Cache-Control': 'public, immutable, max-age=31536000',
|
|
275
|
-
});
|
|
276
|
-
};
|
|
277
|
-
}
|
|
278
251
|
/**
|
|
279
252
|
* Serve a file with optional compression content negotiation
|
|
280
253
|
*/
|
|
281
|
-
static async
|
|
254
|
+
static async serveStatic(filePath, req, precompress = false, headers = {}) {
|
|
282
255
|
const accept = req.headers.get('accept-encoding') ?? '';
|
|
283
256
|
let file = Bun.file(filePath);
|
|
284
257
|
let encoding = null;
|
|
@@ -8,8 +8,9 @@ export declare namespace HttpException {
|
|
|
8
8
|
}
|
|
9
9
|
export declare const HTTP_EXCEPTION_NAME_MAP: Record<HttpException.StatusCode, string>;
|
|
10
10
|
export type HttpExceptionLike = Pick<Error, 'name' | 'message' | 'stack'> & Partial<Pick<HttpException, 'digest' | 'payload' | 'status'>>;
|
|
11
|
+
export declare const HTTP_EXCEPTION_DIGEST_PREFIX = "HTTP_EXCEPTION";
|
|
11
12
|
/**
|
|
12
|
-
* An exception representing an
|
|
13
|
+
* An exception representing an HttpException error, with an optional payload
|
|
13
14
|
* and cause
|
|
14
15
|
*/
|
|
15
16
|
export declare class HttpException extends Error {
|
|
@@ -19,18 +20,21 @@ export declare class HttpException extends Error {
|
|
|
19
20
|
digest?: string;
|
|
20
21
|
constructor(status: HttpException.StatusCode, message: string, opts?: HttpException.Options);
|
|
21
22
|
}
|
|
22
|
-
export declare const HTTP_EXCEPTION_DIGEST_PREFIX = "HTTP_EXCEPTION";
|
|
23
23
|
/**
|
|
24
|
-
* Check if an error is an
|
|
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
|
|
30
34
|
*/
|
|
31
35
|
export declare function toHttpExceptionLike(error: HttpException | Error): HttpExceptionLike;
|
|
32
36
|
/**
|
|
33
|
-
* Throw an
|
|
37
|
+
* Throw an HttpException
|
|
34
38
|
*/
|
|
35
39
|
export declare function abort(status: HttpException.StatusCode, message: string, opts?: {
|
|
36
40
|
payload?: HttpException.Payload;
|