@jk2908/solas 0.3.8 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -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 +82 -22
- 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/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/package.json +3 -6
- package/dist/cli/build.d.ts +0 -7
- package/dist/cli/dev.d.ts +0 -4
- package/dist/cli/dev.js +0 -13
- package/dist/cli/preview.d.ts +0 -1
- package/dist/cli/preview.js +0 -47
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +0 -28
|
@@ -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';
|
|
@@ -15,6 +17,22 @@ import DefaultErr from '../ui/defaults/error.js';
|
|
|
15
17
|
import { RequestContext } from './request-context.js';
|
|
16
18
|
import { getKnownDigest, isKnownError } from './utils.js';
|
|
17
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
|
+
}
|
|
18
36
|
/**
|
|
19
37
|
* Create the streamed RSC payload and response metadata for a single request.
|
|
20
38
|
* Resolves the route match, collects metadata, and returns the stream,
|
|
@@ -24,9 +42,8 @@ async function createPayload(req, manifest, importMap, baseMetadata, returnValue
|
|
|
24
42
|
const resolver = new Resolver(manifest, importMap);
|
|
25
43
|
const prerender = req.headers.get(`x-${Solas.Config.SLUG}-prerender`) === '1';
|
|
26
44
|
const url = new URL(req.url);
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
: url.pathname;
|
|
45
|
+
const routedPath = BasePath.strip(url.pathname, BASE_PATH) ?? url.pathname;
|
|
46
|
+
const pathname = routedPath.endsWith('/') && routedPath !== '/' ? routedPath.slice(0, -1) : routedPath;
|
|
30
47
|
const match = resolver.enhance(resolver.reconcile(pathname, req[Solas.Config.REQUEST_META_KEY].match, req[Solas.Config.REQUEST_META_KEY].error));
|
|
31
48
|
// if there's no match then no user supplied error boundary
|
|
32
49
|
// has been found, and we should server render a default
|
|
@@ -141,7 +158,12 @@ async function createPayload(req, manifest, importMap, baseMetadata, returnValue
|
|
|
141
158
|
* route manifest, and import map to build the router once, then returns an object
|
|
142
159
|
* with a fetch method that handles requests
|
|
143
160
|
*/
|
|
144
|
-
export function createHandler(config, manifest, importMap,
|
|
161
|
+
export function createHandler(config, manifest, importMap, runtimeManifest = null) {
|
|
162
|
+
const CLIENT_OUTPUT_DIR = path.resolve(Solas.Config.OUT_DIR, 'client');
|
|
163
|
+
// vite emits solas-controlled assets under dist/client/_solas
|
|
164
|
+
const SOLAS_ASSETS_DIR = path.resolve(CLIENT_OUTPUT_DIR, Solas.Config.ASSETS_DIR);
|
|
165
|
+
// requests for /_solas and /_solas/* are reserved
|
|
166
|
+
const SOLAS_ASSETS_URL_ROOT = `/${Solas.Config.ASSETS_DIR}`;
|
|
145
167
|
const prerenderPathMode = config.trailingSlash === 'always' ? 'always' : 'never';
|
|
146
168
|
/**
|
|
147
169
|
* Create the HTTP response for a single incoming request. Runs actions when needed,
|
|
@@ -154,8 +176,12 @@ export function createHandler(config, manifest, importMap, artifactManifest = nu
|
|
|
154
176
|
temporaryReferences: undefined,
|
|
155
177
|
returnValue: undefined,
|
|
156
178
|
};
|
|
157
|
-
if (req[Solas.Config.REQUEST_META_KEY].action)
|
|
158
|
-
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
|
+
}
|
|
159
185
|
const { stream: rscStream, status, ppr, } = await createPayload(req, manifest, importMap, config.metadata, opts.returnValue, opts.formState, opts.temporaryReferences);
|
|
160
186
|
const stream = await rscStream;
|
|
161
187
|
if (!req.headers.get('accept')?.includes('text/html')) {
|
|
@@ -191,10 +217,10 @@ export function createHandler(config, manifest, importMap, artifactManifest = nu
|
|
|
191
217
|
});
|
|
192
218
|
}
|
|
193
219
|
try {
|
|
194
|
-
const
|
|
195
|
-
? (
|
|
220
|
+
const artifactEntry = runtimePpr
|
|
221
|
+
? (runtimeManifest?.artifacts[lookupPath] ?? null)
|
|
196
222
|
: null;
|
|
197
|
-
const tryPrelude =
|
|
223
|
+
const tryPrelude = artifactEntry?.mode === 'ppr';
|
|
198
224
|
if (tryPrelude) {
|
|
199
225
|
const postponedState = await Prerender.Artifact.loadPostponedState(Solas.Config.OUT_DIR, lookupPath);
|
|
200
226
|
const prelude = await Prerender.Artifact.loadPrelude(Solas.Config.OUT_DIR, lookupPath);
|
|
@@ -234,8 +260,8 @@ export function createHandler(config, manifest, importMap, artifactManifest = nu
|
|
|
234
260
|
});
|
|
235
261
|
}
|
|
236
262
|
catch (err) {
|
|
237
|
-
// resume/ssr can be the first place
|
|
238
|
-
// after the initial
|
|
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
|
|
239
265
|
// with that error attached so createPayload rebuilds the same route through its
|
|
240
266
|
// nearest matching HttpExceptionBoundary. If request meta already has an error
|
|
241
267
|
// or the error is not an HttpException, then this is a real failure
|
|
@@ -272,35 +298,69 @@ export function createHandler(config, manifest, importMap, artifactManifest = nu
|
|
|
272
298
|
return {
|
|
273
299
|
async fetch(req) {
|
|
274
300
|
const url = new URL(req.url);
|
|
275
|
-
const accept = req.headers.get('accept') ?? '';
|
|
276
301
|
const method = req.method.toUpperCase();
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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') &&
|
|
281
315
|
config.trailingSlash !== 'ignore' &&
|
|
282
|
-
|
|
283
|
-
url.pathname =
|
|
316
|
+
canonicalPathname !== url.pathname) {
|
|
317
|
+
url.pathname = canonicalPathname;
|
|
284
318
|
return Response.redirect(url.toString(), 308);
|
|
285
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
|
+
}
|
|
286
341
|
// fully prerendered html can be served straight from disk for normal
|
|
287
342
|
// document requests, but build-time artifact requests must bypass
|
|
288
343
|
// this shortcut so they still render fresh output
|
|
289
|
-
if (
|
|
344
|
+
if (canonicalPath != null &&
|
|
345
|
+
!import.meta.env.DEV &&
|
|
290
346
|
accept.includes('text/html') &&
|
|
291
347
|
req.headers.get(`x-${Solas.Config.SLUG}-prerender-artifact`) !== '1') {
|
|
292
348
|
// turn the request path into the normal route shape we use for artifact lookups
|
|
293
349
|
const lookupPath = normalisePathname(canonicalPath, prerenderPathMode);
|
|
294
350
|
// only full prerender routes have a saved html file we can serve directly
|
|
295
|
-
const prerenderPath =
|
|
351
|
+
const prerenderPath = runtimeManifest?.artifacts[lookupPath]?.mode === 'full'
|
|
296
352
|
? Prerender.Artifact.getFilePath(Solas.Config.OUT_DIR, lookupPath, Prerender.Artifact.FULL_PRERENDER_FILENAME)
|
|
297
353
|
: null;
|
|
298
354
|
if (prerenderPath) {
|
|
299
|
-
const res = await HttpRouter.
|
|
300
|
-
//
|
|
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?
|
|
301
359
|
'Cache-Control': 'private, no-store',
|
|
302
360
|
'Content-Type': 'text/html; charset=utf-8',
|
|
303
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
|
|
304
364
|
if (res.status !== 404)
|
|
305
365
|
return res;
|
|
306
366
|
}
|
|
@@ -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
|
}
|