@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.
- package/CHANGELOG.md +20 -0
- package/README.md +66 -6
- package/dist/index.d.ts +1 -1
- package/dist/index.js +16 -2
- 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/codegen/environments.js +5 -4
- package/dist/internal/env/rsc.d.ts +2 -2
- package/dist/internal/env/rsc.js +159 -62
- 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 +4 -4
- package/dist/internal/navigation/http-exception.js +4 -5
- package/dist/internal/navigation/redirect-boundary.js +2 -11
- package/dist/internal/navigation/redirect.d.ts +3 -0
- package/dist/internal/navigation/redirect.js +51 -0
- 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/resolver.d.ts +23 -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/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 +8 -3
- package/dist/utils/base-path.d.ts +14 -0
- package/dist/utils/base-path.js +85 -0
- package/dist/utils/export-reader.js +10 -4
- package/package.json +4 -7
- 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
|
@@ -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
|
+
}
|
|
@@ -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
|
|
17
|
+
const runtimeManifest = await Solas.Runtime.loadManifest(Solas.Config.OUT_DIR)
|
|
19
18
|
|
|
20
|
-
export default createHandler(config, manifest, importMap,
|
|
19
|
+
export default createHandler(config, manifest, importMap, runtimeManifest)
|
|
21
20
|
|
|
22
|
-
import.meta.hot
|
|
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,
|
|
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
|
};
|
package/dist/internal/env/rsc.js
CHANGED
|
@@ -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
|
|
28
|
-
|
|
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,
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
195
|
-
? (
|
|
270
|
+
const artifactEntry = runtimePpr
|
|
271
|
+
? (runtimeManifest?.artifacts[lookupPath] ?? null)
|
|
196
272
|
: null;
|
|
197
|
-
const tryPrelude =
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
283
|
-
url.pathname =
|
|
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 (
|
|
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 =
|
|
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.
|
|
300
|
-
//
|
|
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, '
|
|
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
|
}
|