@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.
Files changed (55) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +66 -6
  3. package/dist/index.d.ts +2 -2
  4. package/dist/index.js +75 -6
  5. package/dist/internal/browser-router/link.d.ts +1 -1
  6. package/dist/internal/browser-router/link.js +1 -1
  7. package/dist/internal/browser-router/router.d.ts +2 -165
  8. package/dist/internal/browser-router/router.js +3 -99
  9. package/dist/internal/browser-router/shared.d.ts +169 -0
  10. package/dist/internal/browser-router/shared.js +71 -0
  11. package/dist/internal/browser-router/use-router.d.ts +1 -1
  12. package/dist/internal/build.js +14 -14
  13. package/dist/internal/codegen/environments.js +5 -4
  14. package/dist/internal/env/browser.js +11 -9
  15. package/dist/internal/env/rsc.d.ts +2 -2
  16. package/dist/internal/env/rsc.js +170 -86
  17. package/dist/internal/http-router/create-http-router.d.ts +1 -1
  18. package/dist/internal/http-router/create-http-router.js +4 -2
  19. package/dist/internal/http-router/router.d.ts +4 -14
  20. package/dist/internal/http-router/router.js +32 -59
  21. package/dist/internal/navigation/http-exception.d.ts +8 -4
  22. package/dist/internal/navigation/http-exception.js +46 -6
  23. package/dist/internal/postbuild.d.ts +1 -0
  24. package/dist/{cli/build.js → internal/postbuild.js} +13 -48
  25. package/dist/internal/prerender.d.ts +4 -19
  26. package/dist/internal/prerender.js +8 -98
  27. package/dist/internal/public-files.d.ts +18 -0
  28. package/dist/internal/public-files.js +63 -0
  29. package/dist/internal/render/tree.d.ts +0 -3
  30. package/dist/internal/render/tree.js +1 -6
  31. package/dist/internal/resolver.d.ts +31 -23
  32. package/dist/internal/server/actions.d.ts +2 -5
  33. package/dist/internal/server/actions.js +4 -35
  34. package/dist/internal/server/csrf.d.ts +14 -0
  35. package/dist/internal/server/csrf.js +98 -0
  36. package/dist/internal/ui/defaults/error.d.ts +2 -0
  37. package/dist/internal/ui/defaults/error.js +1 -1
  38. package/dist/navigation.d.ts +1 -1
  39. package/dist/router.d.ts +1 -0
  40. package/dist/router.js +1 -0
  41. package/dist/solas.d.ts +12 -1
  42. package/dist/solas.js +116 -1
  43. package/dist/types.d.ts +27 -5
  44. package/dist/utils/base-path.d.ts +14 -0
  45. package/dist/utils/base-path.js +85 -0
  46. package/dist/utils/export-reader.d.ts +6 -1
  47. package/dist/utils/export-reader.js +24 -15
  48. package/package.json +3 -6
  49. package/dist/cli/build.d.ts +0 -7
  50. package/dist/cli/dev.d.ts +0 -4
  51. package/dist/cli/dev.js +0 -13
  52. package/dist/cli/preview.d.ts +0 -1
  53. package/dist/cli/preview.js +0 -47
  54. package/dist/cli.d.ts +0 -2
  55. package/dist/cli.js +0 -28
@@ -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 pathname = url.pathname.endsWith('/') && url.pathname !== '/'
28
- ? url.pathname.slice(0, -1)
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(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
- },
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?.({ params: match.params, error: match.error }) ?? []))
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(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
- },
113
+ onError,
117
114
  }));
118
115
  return { stream, status, ppr };
119
116
  }
120
117
  catch (err) {
121
- // shell failed to render - return minimal fallback
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, artifactManifest = null) {
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
- 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
- });
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
- const canonicalPath = config.trailingSlash === 'ignore'
264
- ? url.pathname
265
- : normalisePathname(url.pathname, config.trailingSlash);
266
- if ((method === 'GET' || method === 'HEAD') &&
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
- canonicalPath !== url.pathname) {
269
- url.pathname = canonicalPath;
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 (!import.meta.env.DEV &&
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 = artifactManifest?.[lookupPath]?.mode === 'full'
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.serve(prerenderPath, req, config.precompress, {
286
- // avoid shared or proxy caching unless users opt into public caching later
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, 'precompress' | 'trailingSlash'>, manifest: Manifest, importMap: ImportMap, rsc: (req: SolasRequest) => Response | Promise<Response>): HttpRouter;
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 { HttpMethod, PluginConfig, SolasRequest } from '../../types.js';
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 serve(filePath: string, req: Request, precompress?: boolean, headers?: Record<string, string>): Promise<Response>;
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 path = trailingSlash === 'ignore'
167
- ? url.pathname
168
- : normalisePathname(url.pathname, trailingSlash);
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
- if ((method === 'GET' || method === 'HEAD') && path !== url.pathname) {
174
- url.pathname = path;
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 (path !== url.pathname) {
178
- // rebuild the request with the canonical pathname so downstream code
179
- // sees the same url the router matched against
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 pathname only the method is
186
- // normalised to GET this lets page/layout routes match for
187
- // rerender action execution still reads POST body and
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 pass through the shared error hook with the
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 routing state to the request once so middleware and handlers can
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
- // global middleware stays outside route middleware by preserving
205
- // registration order here before composition in #run
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
- // normalise unknown throwables so the error hook always receives an Error
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
- // compose middleware stack
231
+ // build the middleware chain from the inside out
228
232
  let run = () => Promise.resolve(next());
229
- // unwind stack
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
- // guard against double invocation so handlers/inner middleware
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
- // run composed middleware stack
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 serve(filePath, req, precompress = false, headers = {}) {
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 HTTP error, with an optional payload
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 HTTPException
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 HTTPException
37
+ * Throw an HttpException
34
38
  */
35
39
  export declare function abort(status: HttpException.StatusCode, message: string, opts?: {
36
40
  payload?: HttpException.Payload;