@jk2908/solas 0.3.8 → 0.4.1

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 (50) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +66 -6
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +16 -2
  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/codegen/environments.js +5 -4
  13. package/dist/internal/env/rsc.d.ts +2 -2
  14. package/dist/internal/env/rsc.js +159 -62
  15. package/dist/internal/http-router/create-http-router.d.ts +1 -1
  16. package/dist/internal/http-router/create-http-router.js +4 -2
  17. package/dist/internal/http-router/router.d.ts +4 -14
  18. package/dist/internal/http-router/router.js +32 -59
  19. package/dist/internal/navigation/http-exception.d.ts +4 -4
  20. package/dist/internal/navigation/http-exception.js +4 -5
  21. package/dist/internal/navigation/redirect-boundary.js +2 -11
  22. package/dist/internal/navigation/redirect.d.ts +3 -0
  23. package/dist/internal/navigation/redirect.js +51 -0
  24. package/dist/internal/postbuild.d.ts +1 -0
  25. package/dist/{cli/build.js → internal/postbuild.js} +13 -48
  26. package/dist/internal/prerender.d.ts +4 -19
  27. package/dist/internal/prerender.js +8 -98
  28. package/dist/internal/public-files.d.ts +18 -0
  29. package/dist/internal/public-files.js +63 -0
  30. package/dist/internal/resolver.d.ts +23 -23
  31. package/dist/internal/server/actions.d.ts +2 -5
  32. package/dist/internal/server/actions.js +4 -35
  33. package/dist/internal/server/csrf.d.ts +14 -0
  34. package/dist/internal/server/csrf.js +98 -0
  35. package/dist/router.d.ts +1 -0
  36. package/dist/router.js +1 -0
  37. package/dist/solas.d.ts +12 -1
  38. package/dist/solas.js +116 -1
  39. package/dist/types.d.ts +8 -3
  40. package/dist/utils/base-path.d.ts +14 -0
  41. package/dist/utils/base-path.js +85 -0
  42. package/dist/utils/export-reader.js +10 -4
  43. package/package.json +4 -7
  44. package/dist/cli/build.d.ts +0 -7
  45. package/dist/cli/dev.d.ts +0 -4
  46. package/dist/cli/dev.js +0 -13
  47. package/dist/cli/preview.d.ts +0 -1
  48. package/dist/cli/preview.js +0 -47
  49. package/dist/cli.d.ts +0 -2
  50. package/dist/cli.js +0 -28
@@ -0,0 +1,169 @@
1
+ import { Solas } from '../../solas.js';
2
+ export declare namespace BrowserRouter {
3
+ export type Params = Record<string, string>;
4
+ export type Query = Record<string, string | number | boolean>;
5
+ export type Path = keyof Solas.Routes & string;
6
+ type Replace = {
7
+ replace?: boolean;
8
+ };
9
+ export type GoOptions = {
10
+ replace?: boolean;
11
+ query?: Query;
12
+ params?: Params;
13
+ };
14
+ /**
15
+ * These targets are used as-is. They are not matched against the route table,
16
+ * so this covers normal external URLs and hash-only links
17
+ */
18
+ export type ExternalTarget = `${string}:${string}` | `//${string}` | `#${string}`;
19
+ export function isHashOnlyTarget(target: string): boolean;
20
+ export function isExternalTarget(target: string, origin: string): boolean;
21
+ /**
22
+ * Turn a route pattern into the real path shape a caller can use. In practice,
23
+ * every ':param' or '*' part becomes a plain string slot
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * // '/p/:id' becomes '/p/${string}'
28
+ * // '/test/*' becomes '/test/${string}'
29
+ * // '/posts' stays '/posts'
30
+ * ```
31
+ *
32
+ * @example
33
+ * ```ts
34
+ * type A = ResolvedPath<'/posts/:id'>
35
+ * // '/posts/${string}'
36
+ * ```
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * type B = ResolvedPath<'/docs/*'>
41
+ * // '/docs/${string}'
42
+ * ```
43
+ */
44
+ export type ResolvedPath<TPath extends string> = TPath extends `${infer Start}:${string}/${infer Rest}` ? `${Start}${string}/${ResolvedPath<Rest>}` : TPath extends `${infer Start}:${string}` ? `${Start}${string}` : TPath extends `${infer Start}*${infer Rest}` ? `${Start}${string}${ResolvedPath<Rest>}` : TPath;
45
+ /**
46
+ * Once we have a real path, also allow the usual query-string and hash forms
47
+ *
48
+ * @example
49
+ * ```ts
50
+ * type A = TargetSuffix<'/posts/123'>
51
+ * // '/posts/123' | '/posts/123?${string}' | '/posts/123#${string}' | '/posts/123?${string}#${string}'
52
+ * ```
53
+ */
54
+ export type TargetSuffix<TPath extends string> = TPath | `${TPath}?${string}` | `${TPath}#${string}` | `${TPath}?${string}#${string}`;
55
+ /**
56
+ * This is the final string form a caller can navigate to. It can be an external
57
+ * URL, or a concrete URL that matches one of the known routes
58
+ *
59
+ * @example
60
+ * ```ts
61
+ * type A = Target
62
+ * // 'https://example.com'
63
+ * // '#intro'
64
+ * // '/posts/123'
65
+ * // '/posts/123?draft=true'
66
+ * ```
67
+ */
68
+ export type Target = ExternalTarget | TargetSuffix<ResolvedPath<Path>>;
69
+ /**
70
+ * Extra options for callers who already have a finished target string
71
+ *
72
+ * @example
73
+ * ```ts
74
+ * const a: TargetConfig = { query: { page: 2 } }
75
+ * // params is rejected here because the path is already complete
76
+ * ```
77
+ */
78
+ type TargetConfig = {
79
+ params?: never;
80
+ query?: Query;
81
+ };
82
+ /**
83
+ * Extra options for callers who pass a route pattern and params separately.
84
+ * If the route definition says that route needs params, this type makes
85
+ * those params required. If the route has no params, it rejects them
86
+ *
87
+ * @example
88
+ * ```ts
89
+ * // if Solas.Routes['/posts/:id'] is { params: { id: string } }
90
+ * type A = PatternConfig<'/posts/:id'>
91
+ * // { query?: Query } & { params: { id: string } }
92
+ * ```
93
+ *
94
+ * @example
95
+ * ```ts
96
+ * // if Solas.Routes['/about'] has no params field
97
+ * type B = PatternConfig<'/about'>
98
+ * // { query?: Query } & { params?: never }
99
+ * ```
100
+ */
101
+ type PatternConfig<TPath extends Path> = {
102
+ query?: Query;
103
+ } & (Solas.Routes[TPath] extends {
104
+ params: infer TParams extends Params;
105
+ } ? {
106
+ params: TParams;
107
+ } : {
108
+ params?: never;
109
+ });
110
+ /**
111
+ * Typed <Link /> props, using `href` instead of the internal `to` name
112
+ *
113
+ * `query` is always allowed
114
+ * `params` are only allowed when `href` is a known route pattern
115
+ *
116
+ * @example
117
+ * ```ts
118
+ * const a: LinkProps = { href: '/posts/:id', params: { id: '123' } }
119
+ * const b: LinkProps = { href: '/posts/123?draft=true' }
120
+ * ```
121
+ */
122
+ export type LinkProps = ({
123
+ href: Target;
124
+ } & TargetConfig) | (keyof Solas.Routes extends never ? never : {
125
+ [TPath in Path]: {
126
+ href: TPath;
127
+ } & PatternConfig<TPath>;
128
+ }[Path]);
129
+ /**
130
+ * Typed input for router.go(), using the same route rules as <Link />
131
+ *
132
+ * @example
133
+ * ```ts
134
+ * go('/p/post-2')
135
+ * go('/?foo=bar', { replace: true })
136
+ * ```
137
+ *
138
+ * @example
139
+ * ```ts
140
+ * go('/p/:id', { params: { id: 'post-2' }, replace: true })
141
+ * ```
142
+ *
143
+ * The last overload is the fallback for plain `string` values. The
144
+ * `string extends TTo` check stops that fallback from taking over
145
+ * when TypeScript already knows the caller passed a more specific
146
+ * string literal
147
+ *
148
+ * @example
149
+ * ```ts
150
+ * declare const dynamicPath: string
151
+ * go(dynamicPath, { replace: true })
152
+ * ```
153
+ */
154
+ export type Go = {
155
+ <TTo extends Path>(to: TTo, opts?: PatternConfig<TTo> & Replace): Promise<string>;
156
+ <TTo extends Target>(to: TTo, opts?: TargetConfig & Replace): Promise<string>;
157
+ <TTo extends string>(to: string extends TTo ? TTo : never, opts?: GoOptions): Promise<string>;
158
+ };
159
+ /**
160
+ * Convert a route pattern and params into a real path string. This is used internally
161
+ * to implement <Link /> and router.go
162
+ */
163
+ export function toTarget(path: string, params?: Record<string, string>, query?: Query): string;
164
+ export {};
165
+ }
166
+ /**
167
+ * Apply the base path to a target string when needed
168
+ */
169
+ export declare function withBase(target: string): string;
@@ -0,0 +1,71 @@
1
+ import { BasePath } from '../../utils/base-path.js';
2
+ const BASE_PATH = BasePath.normalise(import.meta.env.BASE_URL);
3
+ export { BrowserRouter };
4
+ var BrowserRouter;
5
+ (function (BrowserRouter) {
6
+ function isHashOnlyTarget(target) {
7
+ return target.startsWith('#');
8
+ }
9
+ BrowserRouter.isHashOnlyTarget = isHashOnlyTarget;
10
+ function isExternalTarget(target, origin) {
11
+ if (isHashOnlyTarget(target))
12
+ return false;
13
+ try {
14
+ return new URL(target, origin).origin !== origin;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
20
+ BrowserRouter.isExternalTarget = isExternalTarget;
21
+ /**
22
+ * Convert a route pattern and params into a real path string. This is used internally
23
+ * to implement <Link /> and router.go
24
+ */
25
+ function toTarget(path, params, query) {
26
+ const used = new Set();
27
+ let to = path.replaceAll(/:([A-Za-z0-9_]+)/g, (_, key) => {
28
+ const value = params?.[key];
29
+ if (value == null) {
30
+ throw new Error(`[Link]: missing route param: ${key}`);
31
+ }
32
+ used.add(key);
33
+ return encodeURIComponent(value);
34
+ });
35
+ if (to.includes('*')) {
36
+ const remaining = Object.entries(params ?? {}).filter(([key]) => !used.has(key));
37
+ if (remaining.length !== 1) {
38
+ throw new Error('[Link]: wildcard routes require exactly one unmatched param');
39
+ }
40
+ to = to.replace('*', remaining[0][1].split('/').map(encodeURIComponent).join('/'));
41
+ }
42
+ if (!query)
43
+ return withBase(to);
44
+ const hashIndex = to.indexOf('#');
45
+ const hash = hashIndex >= 0 ? to.slice(hashIndex) : '';
46
+ const pathWithSearch = hashIndex >= 0 ? to.slice(0, hashIndex) : to;
47
+ const searchIndex = pathWithSearch.indexOf('?');
48
+ const pathname = searchIndex >= 0 ? pathWithSearch.slice(0, searchIndex) : pathWithSearch;
49
+ const currentSearch = searchIndex >= 0 ? pathWithSearch.slice(searchIndex + 1) : '';
50
+ const search = new URLSearchParams(currentSearch);
51
+ for (const [key, value] of Object.entries(query)) {
52
+ search.set(key, String(value));
53
+ }
54
+ const value = search.toString();
55
+ return withBase(`${pathname}${value.length > 0 ? `?${value}` : ''}${hash}`);
56
+ }
57
+ BrowserRouter.toTarget = toTarget;
58
+ })(BrowserRouter || (BrowserRouter = {}));
59
+ /**
60
+ * Apply the base path to a target string when needed
61
+ */
62
+ export function withBase(target) {
63
+ if (BrowserRouter.isHashOnlyTarget(target))
64
+ return target;
65
+ if (target.startsWith('//') || /^[A-Za-z][A-Za-z\d+.-]*:/.test(target))
66
+ return target;
67
+ const suffixIndex = target.search(/[?#]/);
68
+ const pathname = suffixIndex === -1 ? target : target.slice(0, suffixIndex);
69
+ const suffix = suffixIndex === -1 ? '' : target.slice(suffixIndex);
70
+ return `${BasePath.apply(pathname || '/', BASE_PATH)}${suffix}`;
71
+ }
@@ -1,5 +1,5 @@
1
1
  export declare function useRouter(): {
2
- go: import("./router.js").BrowserRouter.Go;
2
+ go: import("./shared.js").BrowserRouter.Go;
3
3
  prefetch: (path: string) => void;
4
4
  isNavigating: boolean;
5
5
  url: {
@@ -8,18 +8,19 @@ export function writeRSCEntry() {
8
8
  ${AUTOGEN_MSG}
9
9
 
10
10
  import { createHandler } from '${Solas.Config.PKG_NAME}/env/rsc'
11
- import { Prerender } from '${Solas.Config.PKG_NAME}/prerender'
12
11
  import { Solas } from '${Solas.Config.PKG_NAME}'
13
12
 
14
13
  import { manifest } from './manifest.js'
15
14
  import { importMap } from './maps.js'
16
15
  import { config } from './config.js'
17
16
 
18
- const artifactManifest = await Prerender.Artifact.loadManifest(Solas.Config.OUT_DIR)
17
+ const runtimeManifest = await Solas.Runtime.loadManifest(Solas.Config.OUT_DIR)
19
18
 
20
- export default createHandler(config, manifest, importMap, artifactManifest)
19
+ export default createHandler(config, manifest, importMap, runtimeManifest)
21
20
 
22
- import.meta.hot?.accept()
21
+ if (import.meta.hot) {
22
+ import.meta.hot.accept()
23
+ }
23
24
  `;
24
25
  }
25
26
  /**
@@ -1,7 +1,7 @@
1
1
  import type { ReactFormState } from 'react-dom/client';
2
2
  import type { ImportMap, Manifest, RuntimeConfig } from '../../types.js';
3
+ import { Solas } from '../../solas.js';
3
4
  import { Metadata } from '../metadata.js';
4
- import { Prerender } from '../prerender.js';
5
5
  export type RscPayload = {
6
6
  returnValue?: {
7
7
  ok: boolean;
@@ -20,6 +20,6 @@ export type RscPayload = {
20
20
  * route manifest, and import map to build the router once, then returns an object
21
21
  * with a fetch method that handles requests
22
22
  */
23
- export declare function createHandler(config: RuntimeConfig, manifest: Manifest, importMap: ImportMap, artifactManifest?: Prerender.Artifact.Manifest | null): {
23
+ export declare function createHandler(config: RuntimeConfig, manifest: Manifest, importMap: ImportMap, runtimeManifest?: Solas.Runtime.Manifest | null): {
24
24
  fetch(req: Request): Promise<Response>;
25
25
  };
@@ -1,5 +1,7 @@
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';
@@ -7,6 +9,7 @@ import { HttpRouter } from '../http-router/router.js';
7
9
  import { normalisePathname } from '../http-router/utils.js';
8
10
  import { Metadata } from '../metadata.js';
9
11
  import { HttpException, isHttpException, toHttpException, toHttpExceptionLike, } from '../navigation/http-exception.js';
12
+ import { isRedirect, toRedirect } from '../navigation/redirect.js';
10
13
  import { Prerender } from '../prerender.js';
11
14
  import { Tree } from '../render/tree.js';
12
15
  import { Resolver } from '../resolver.js';
@@ -15,6 +18,22 @@ import DefaultErr from '../ui/defaults/error.js';
15
18
  import { RequestContext } from './request-context.js';
16
19
  import { getKnownDigest, isKnownError } from './utils.js';
17
20
  const logger = new Logger();
21
+ const BASE_PATH = BasePath.normalise(import.meta.env.BASE_URL);
22
+ function resolveFilePath(root, relativePath) {
23
+ try {
24
+ const decodedPath = decodeURIComponent(relativePath);
25
+ if (!decodedPath)
26
+ return new Response('Forbidden', { status: 403 });
27
+ const filePath = path.resolve(root, decodedPath);
28
+ if (filePath !== root && !filePath.startsWith(`${root}${path.sep}`)) {
29
+ return new Response('Forbidden', { status: 403 });
30
+ }
31
+ return filePath;
32
+ }
33
+ catch {
34
+ return new Response('Bad Request', { status: 400 });
35
+ }
36
+ }
18
37
  /**
19
38
  * Create the streamed RSC payload and response metadata for a single request.
20
39
  * Resolves the route match, collects metadata, and returns the stream,
@@ -24,9 +43,8 @@ async function createPayload(req, manifest, importMap, baseMetadata, returnValue
24
43
  const resolver = new Resolver(manifest, importMap);
25
44
  const prerender = req.headers.get(`x-${Solas.Config.SLUG}-prerender`) === '1';
26
45
  const url = new URL(req.url);
27
- const pathname = url.pathname.endsWith('/') && url.pathname !== '/'
28
- ? url.pathname.slice(0, -1)
29
- : url.pathname;
46
+ const routedPath = BasePath.strip(url.pathname, BASE_PATH) ?? url.pathname;
47
+ const pathname = routedPath.endsWith('/') && routedPath !== '/' ? routedPath.slice(0, -1) : routedPath;
30
48
  const match = resolver.enhance(resolver.reconcile(pathname, req[Solas.Config.REQUEST_META_KEY].match, req[Solas.Config.REQUEST_META_KEY].error));
31
49
  // if there's no match then no user supplied error boundary
32
50
  // has been found, and we should server render a default
@@ -141,7 +159,12 @@ async function createPayload(req, manifest, importMap, baseMetadata, returnValue
141
159
  * route manifest, and import map to build the router once, then returns an object
142
160
  * with a fetch method that handles requests
143
161
  */
144
- export function createHandler(config, manifest, importMap, artifactManifest = null) {
162
+ export function createHandler(config, manifest, importMap, runtimeManifest = null) {
163
+ const CLIENT_OUTPUT_DIR = path.resolve(Solas.Config.OUT_DIR, 'client');
164
+ // vite emits solas-controlled assets under dist/client/_solas
165
+ const SOLAS_ASSETS_DIR = path.resolve(CLIENT_OUTPUT_DIR, Solas.Config.ASSETS_DIR);
166
+ // requests for /_solas and /_solas/* are reserved
167
+ const SOLAS_ASSETS_URL_ROOT = `/${Solas.Config.ASSETS_DIR}`;
145
168
  const prerenderPathMode = config.trailingSlash === 'always' ? 'always' : 'never';
146
169
  /**
147
170
  * Create the HTTP response for a single incoming request. Runs actions when needed,
@@ -154,8 +177,12 @@ export function createHandler(config, manifest, importMap, artifactManifest = nu
154
177
  temporaryReferences: undefined,
155
178
  returnValue: undefined,
156
179
  };
157
- if (req[Solas.Config.REQUEST_META_KEY].action)
158
- opts = await processActionRequest(req);
180
+ if (req[Solas.Config.REQUEST_META_KEY].action) {
181
+ opts = await processActionRequest(req, {
182
+ trustedOrigins: config.trustedOrigins,
183
+ url: config.url,
184
+ });
185
+ }
159
186
  const { stream: rscStream, status, ppr, } = await createPayload(req, manifest, importMap, config.metadata, opts.returnValue, opts.formState, opts.temporaryReferences);
160
187
  const stream = await rscStream;
161
188
  if (!req.headers.get('accept')?.includes('text/html')) {
@@ -172,29 +199,78 @@ export function createHandler(config, manifest, importMap, artifactManifest = nu
172
199
  const pathname = new URL(req.url).pathname;
173
200
  const lookupPath = normalisePathname(pathname, prerenderPathMode);
174
201
  const runtimePpr = !import.meta.env.DEV && ppr;
202
+ async function tryErrorRecovery(err) {
203
+ if (isRedirect(err)) {
204
+ const redirect = toRedirect(err);
205
+ const location = redirect.url.startsWith('/')
206
+ ? new URL(BasePath.apply(redirect.url, BASE_PATH), req.url).toString()
207
+ : redirect.url;
208
+ return Response.redirect(location, redirect.status);
209
+ }
210
+ if (req[Solas.Config.REQUEST_META_KEY].error || !isHttpException(err)) {
211
+ return null;
212
+ }
213
+ // retry once with the surfaced HttpException attached so createPayload can
214
+ // rebuild the route through the nearest matching status boundary
215
+ req[Solas.Config.REQUEST_META_KEY].error = toHttpException(err);
216
+ try {
217
+ const { stream: retriedRscStream, status: retriedStatus } = await createPayload(req, manifest, importMap, config.metadata, opts.returnValue, opts.formState, opts.temporaryReferences);
218
+ const retriedStream = await retriedRscStream;
219
+ return {
220
+ retriedStatus,
221
+ retriedStream,
222
+ };
223
+ }
224
+ finally {
225
+ req[Solas.Config.REQUEST_META_KEY].error = undefined;
226
+ }
227
+ }
175
228
  // prerender artifact requests bypass the normal document path so the cli
176
229
  // gets structured JSON instead of a rendered html response
177
230
  if (req.headers.get(`x-${Solas.Config.SLUG}-prerender`) === '1' &&
178
231
  req.headers.get(`x-${Solas.Config.SLUG}-prerender-artifact`) === '1') {
179
- const artifact = await mod.prerender(stream, {
180
- formState: opts.formState,
181
- ppr: runtimePpr,
182
- route: pathname,
183
- });
184
- return new Response(JSON.stringify(artifact), {
185
- headers: {
186
- 'Cache-Control': 'private, no-store',
187
- 'Content-Type': 'application/json; charset=utf-8',
188
- Vary: 'accept',
189
- },
190
- status,
191
- });
232
+ try {
233
+ const artifact = await mod.prerender(stream, {
234
+ formState: opts.formState,
235
+ ppr: runtimePpr,
236
+ route: pathname,
237
+ });
238
+ return new Response(JSON.stringify(artifact), {
239
+ headers: {
240
+ 'Cache-Control': 'private, no-store',
241
+ 'Content-Type': 'application/json; charset=utf-8',
242
+ Vary: 'accept',
243
+ },
244
+ status,
245
+ });
246
+ }
247
+ catch (err) {
248
+ const recovered = await tryErrorRecovery(err);
249
+ if (recovered instanceof Response)
250
+ return recovered;
251
+ if (recovered) {
252
+ const artifact = await mod.prerender(recovered.retriedStream, {
253
+ formState: opts.formState,
254
+ ppr: false,
255
+ route: pathname,
256
+ });
257
+ return new Response(JSON.stringify(artifact), {
258
+ headers: {
259
+ 'Cache-Control': 'private, no-store',
260
+ 'Content-Type': 'application/json; charset=utf-8',
261
+ Vary: 'accept',
262
+ },
263
+ status: recovered.retriedStatus,
264
+ });
265
+ }
266
+ throw err;
267
+ }
192
268
  }
193
269
  try {
194
- const artifactManifestEntry = runtimePpr
195
- ? (artifactManifest?.[lookupPath] ?? null)
270
+ const artifactEntry = runtimePpr
271
+ ? (runtimeManifest?.artifacts[lookupPath] ?? null)
196
272
  : null;
197
- const tryPrelude = artifactManifestEntry?.mode === 'ppr';
273
+ const tryPrelude = artifactEntry?.mode === 'ppr';
198
274
  if (tryPrelude) {
199
275
  const postponedState = await Prerender.Artifact.loadPostponedState(Solas.Config.OUT_DIR, lookupPath);
200
276
  const prelude = await Prerender.Artifact.loadPrelude(Solas.Config.OUT_DIR, lookupPath);
@@ -234,35 +310,22 @@ export function createHandler(config, manifest, importMap, artifactManifest = nu
234
310
  });
235
311
  }
236
312
  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
- }
313
+ const recovered = await tryErrorRecovery(err);
314
+ if (recovered instanceof Response)
315
+ return recovered;
316
+ if (recovered) {
317
+ const retriedHtmlStream = await mod.ssr(recovered.retriedStream, {
318
+ formState: opts.formState,
319
+ ppr: false,
320
+ });
321
+ return new Response(retriedHtmlStream, {
322
+ headers: {
323
+ 'Cache-Control': 'private, no-store',
324
+ 'Content-Type': 'text/html',
325
+ Vary: 'accept',
326
+ },
327
+ status: recovered.retriedStatus,
328
+ });
266
329
  }
267
330
  throw err;
268
331
  }
@@ -272,35 +335,69 @@ export function createHandler(config, manifest, importMap, artifactManifest = nu
272
335
  return {
273
336
  async fetch(req) {
274
337
  const url = new URL(req.url);
275
- const accept = req.headers.get('accept') ?? '';
276
338
  const method = req.method.toUpperCase();
277
- const canonicalPath = config.trailingSlash === 'ignore'
278
- ? url.pathname
279
- : normalisePathname(url.pathname, config.trailingSlash);
280
- if ((method === 'GET' || method === 'HEAD') &&
339
+ // fast path
340
+ if (method !== 'GET' && method !== 'HEAD')
341
+ return httpRouter.fetch(req);
342
+ const accept = req.headers.get('accept') ?? '';
343
+ const routedPath = BasePath.strip(url.pathname, BASE_PATH);
344
+ const canonicalPath = routedPath == null
345
+ ? null
346
+ : config.trailingSlash === 'ignore'
347
+ ? routedPath
348
+ : normalisePathname(routedPath, config.trailingSlash);
349
+ const canonicalPathname = canonicalPath == null ? null : BasePath.apply(canonicalPath, BASE_PATH);
350
+ if (canonicalPathname != null &&
351
+ (method === 'GET' || method === 'HEAD') &&
281
352
  config.trailingSlash !== 'ignore' &&
282
- canonicalPath !== url.pathname) {
283
- url.pathname = canonicalPath;
353
+ canonicalPathname !== url.pathname) {
354
+ url.pathname = canonicalPathname;
284
355
  return Response.redirect(url.toString(), 308);
285
356
  }
357
+ // block the bare /_solas namespace; only concrete solas asset files
358
+ // under /_solas/* are valid
359
+ if (routedPath === SOLAS_ASSETS_URL_ROOT) {
360
+ return new Response('Forbidden', { status: 403 });
361
+ }
362
+ if (routedPath?.startsWith(`${SOLAS_ASSETS_URL_ROOT}/`)) {
363
+ const resolvedPath = resolveFilePath(SOLAS_ASSETS_DIR, routedPath.slice(`${SOLAS_ASSETS_URL_ROOT}/`.length));
364
+ // pass through bad-request or forbidden responses from path resolution
365
+ if (resolvedPath instanceof Response)
366
+ return resolvedPath;
367
+ return HttpRouter.serveStatic(resolvedPath, req, config.precompress, {
368
+ 'Cache-Control': 'public, immutable, max-age=31536000',
369
+ });
370
+ }
371
+ if (routedPath && runtimeManifest?.publicFiles.has(routedPath)) {
372
+ const resolvedPath = resolveFilePath(CLIENT_OUTPUT_DIR, routedPath.slice(1));
373
+ // pass through bad-request or forbidden responses from path resolution
374
+ if (resolvedPath instanceof Response)
375
+ return resolvedPath;
376
+ return HttpRouter.serveStatic(resolvedPath, req, config.precompress);
377
+ }
286
378
  // fully prerendered html can be served straight from disk for normal
287
379
  // document requests, but build-time artifact requests must bypass
288
380
  // this shortcut so they still render fresh output
289
- if (!import.meta.env.DEV &&
381
+ if (canonicalPath != null &&
382
+ !import.meta.env.DEV &&
290
383
  accept.includes('text/html') &&
291
384
  req.headers.get(`x-${Solas.Config.SLUG}-prerender-artifact`) !== '1') {
292
385
  // turn the request path into the normal route shape we use for artifact lookups
293
386
  const lookupPath = normalisePathname(canonicalPath, prerenderPathMode);
294
387
  // only full prerender routes have a saved html file we can serve directly
295
- const prerenderPath = artifactManifest?.[lookupPath]?.mode === 'full'
388
+ const prerenderPath = runtimeManifest?.artifacts[lookupPath]?.mode === 'full'
296
389
  ? Prerender.Artifact.getFilePath(Solas.Config.OUT_DIR, lookupPath, Prerender.Artifact.FULL_PRERENDER_FILENAME)
297
390
  : null;
298
391
  if (prerenderPath) {
299
- const res = await HttpRouter.serve(prerenderPath, req, config.precompress, {
300
- // avoid shared or proxy caching unless users opt into public caching later
392
+ const res = await HttpRouter.serveStatic(prerenderPath, req, config.precompress, {
393
+ // keep prerendered html out of shared caches unless users opt into explicit public caching
394
+ // default to private, no-store for now
395
+ // @todo: public caching?
301
396
  'Cache-Control': 'private, no-store',
302
397
  'Content-Type': 'text/html; charset=utf-8',
303
398
  });
399
+ // only a missing prerendered file should fall back to normal request handling
400
+ // any other static-file response should be returned as-is
304
401
  if (res.status !== 404)
305
402
  return res;
306
403
  }
@@ -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
  }