@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
|
@@ -1,18 +1,16 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { compile } from 'path-to-regexp';
|
|
3
|
+
import { BasePath } from '../utils/base-path.js';
|
|
3
4
|
import { Logger } from '../utils/logger.js';
|
|
4
|
-
import { Time } from '../utils/time.js';
|
|
5
5
|
import { Solas } from '../solas.js';
|
|
6
6
|
import { toPathPattern } from './http-router/utils.js';
|
|
7
7
|
const logger = new Logger();
|
|
8
8
|
export { Prerender };
|
|
9
9
|
var Prerender;
|
|
10
10
|
(function (Prerender) {
|
|
11
|
-
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
12
11
|
const DEFAULT_CONCURRENCY = 4;
|
|
13
12
|
let Artifact;
|
|
14
13
|
(function (Artifact) {
|
|
15
|
-
const manifestCache = new Map();
|
|
16
14
|
/**
|
|
17
15
|
* Check whether a file name is safe to join under an artifact directory
|
|
18
16
|
*/
|
|
@@ -30,14 +28,6 @@ var Prerender;
|
|
|
30
28
|
return path.join(outDir, Solas.Config.GENERATED_DIR, 'ppr');
|
|
31
29
|
}
|
|
32
30
|
Artifact.getRootPath = getRootPath;
|
|
33
|
-
/**
|
|
34
|
-
* Get the file system path for the prerender artifact manifest, which
|
|
35
|
-
* contains metadata about all prerendered routes and their artifacts
|
|
36
|
-
*/
|
|
37
|
-
function getManifestPath(outDir) {
|
|
38
|
-
return path.join(getRootPath(outDir), 'manifest.json');
|
|
39
|
-
}
|
|
40
|
-
Artifact.getManifestPath = getManifestPath;
|
|
41
31
|
/**
|
|
42
32
|
* Get the file system path for storing prerender artifacts for a given route
|
|
43
33
|
*/
|
|
@@ -66,73 +56,6 @@ var Prerender;
|
|
|
66
56
|
* File name used for saved full-prerender html inside each route artifact directory
|
|
67
57
|
*/
|
|
68
58
|
Artifact.FULL_PRERENDER_FILENAME = 'prerendered.html';
|
|
69
|
-
/**
|
|
70
|
-
* Load the prerender artifact manifest for faster runtime route mode checks
|
|
71
|
-
*/
|
|
72
|
-
async function loadManifest(outDir) {
|
|
73
|
-
// if we already loaded this outDir, return cached result
|
|
74
|
-
// (either a valid manifest or null when it was missing/invalid)
|
|
75
|
-
if (manifestCache.has(outDir)) {
|
|
76
|
-
return manifestCache.get(outDir) ?? null;
|
|
77
|
-
}
|
|
78
|
-
const file = Bun.file(getManifestPath(outDir));
|
|
79
|
-
// no manifest means no prerender metadata to use
|
|
80
|
-
if (!(await file.exists())) {
|
|
81
|
-
manifestCache.set(outDir, null);
|
|
82
|
-
return null;
|
|
83
|
-
}
|
|
84
|
-
try {
|
|
85
|
-
// parse once, then validate the shape before trusting any fields
|
|
86
|
-
const value = JSON.parse(await file.text());
|
|
87
|
-
if (!value || typeof value !== 'object') {
|
|
88
|
-
manifestCache.set(outDir, null);
|
|
89
|
-
return null;
|
|
90
|
-
}
|
|
91
|
-
const routes = value.routes;
|
|
92
|
-
if (!routes || typeof routes !== 'object') {
|
|
93
|
-
manifestCache.set(outDir, null);
|
|
94
|
-
return null;
|
|
95
|
-
}
|
|
96
|
-
// verify each route entry so runtime can rely on mode/files safely
|
|
97
|
-
for (const entry of Object.values(routes)) {
|
|
98
|
-
if (!entry || typeof entry !== 'object') {
|
|
99
|
-
manifestCache.set(outDir, null);
|
|
100
|
-
return null;
|
|
101
|
-
}
|
|
102
|
-
const { mode, files } = entry;
|
|
103
|
-
// only allow known modes
|
|
104
|
-
if (mode !== 'full' && mode !== 'ppr') {
|
|
105
|
-
manifestCache.set(outDir, null);
|
|
106
|
-
return null;
|
|
107
|
-
}
|
|
108
|
-
if (files !== undefined) {
|
|
109
|
-
if (!Array.isArray(files)) {
|
|
110
|
-
manifestCache.set(outDir, null);
|
|
111
|
-
return null;
|
|
112
|
-
}
|
|
113
|
-
// only allow known artifact file labels
|
|
114
|
-
for (const f of files) {
|
|
115
|
-
if (f !== 'html' &&
|
|
116
|
-
f !== 'prelude' &&
|
|
117
|
-
f !== 'postponed' &&
|
|
118
|
-
f !== 'metadata') {
|
|
119
|
-
manifestCache.set(outDir, null);
|
|
120
|
-
return null;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
const manifest = routes;
|
|
126
|
-
// cache validated manifest to avoid reparsing on every request
|
|
127
|
-
manifestCache.set(outDir, manifest);
|
|
128
|
-
return manifest;
|
|
129
|
-
}
|
|
130
|
-
catch {
|
|
131
|
-
manifestCache.set(outDir, null);
|
|
132
|
-
return null;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
Artifact.loadManifest = loadManifest;
|
|
136
59
|
/**
|
|
137
60
|
* Load the postponed state for a given route from the file system, if it exists
|
|
138
61
|
*/
|
|
@@ -319,18 +242,6 @@ var Prerender;
|
|
|
319
242
|
})(Runtime = Prerender.Runtime || (Prerender.Runtime = {}));
|
|
320
243
|
let Build;
|
|
321
244
|
(function (Build) {
|
|
322
|
-
/**
|
|
323
|
-
* Get the prerender timeout value from the environment variable, or return the default
|
|
324
|
-
* if it's not set or invalid
|
|
325
|
-
*/
|
|
326
|
-
function getTimeout() {
|
|
327
|
-
const v = Number(process.env.SOLAS_PRERENDER_TIMEOUT_MS);
|
|
328
|
-
if (!Number.isFinite(v) || v <= 0) {
|
|
329
|
-
return DEFAULT_TIMEOUT_MS;
|
|
330
|
-
}
|
|
331
|
-
return v;
|
|
332
|
-
}
|
|
333
|
-
Build.getTimeout = getTimeout;
|
|
334
245
|
/**
|
|
335
246
|
* Get the prerender concurrency value from the environment variable, or return the default
|
|
336
247
|
* if it's not set or invalid
|
|
@@ -359,7 +270,7 @@ var Prerender;
|
|
|
359
270
|
const params = await buildContext.exportReader.value(filePath, 'params', (v) => typeof v === 'function');
|
|
360
271
|
if (!params)
|
|
361
272
|
return [];
|
|
362
|
-
const resolved = await
|
|
273
|
+
const resolved = await Promise.try(() => params());
|
|
363
274
|
if (!Array.isArray(resolved))
|
|
364
275
|
return [];
|
|
365
276
|
return resolved;
|
|
@@ -402,14 +313,14 @@ var Prerender;
|
|
|
402
313
|
* postponed to request-time
|
|
403
314
|
*/
|
|
404
315
|
async function get(app, route, opts) {
|
|
405
|
-
const url = `${opts.origin ?? `http://${Solas.Config.SLUG}.local`}${route}`;
|
|
406
|
-
const res = await
|
|
316
|
+
const url = `${opts.origin ?? `http://${Solas.Config.SLUG}.local`}${BasePath.apply(route, opts.base)}`;
|
|
317
|
+
const res = await app.fetch(new Request(url, {
|
|
407
318
|
headers: {
|
|
408
319
|
Accept: 'text/html',
|
|
409
320
|
[`x-${Solas.Config.SLUG}-prerender`]: '1',
|
|
410
321
|
[`x-${Solas.Config.SLUG}-prerender-artifact`]: '1',
|
|
411
322
|
},
|
|
412
|
-
}))
|
|
323
|
+
}));
|
|
413
324
|
if (!(res instanceof Response)) {
|
|
414
325
|
const error = new TypeError(`Invalid response for ${route}`);
|
|
415
326
|
logger.error(`[prerender:get] ${error.message}`, error);
|
|
@@ -421,9 +332,8 @@ var Prerender;
|
|
|
421
332
|
}
|
|
422
333
|
Build.get = get;
|
|
423
334
|
/**
|
|
424
|
-
* Run the prerendering process for a list of routes
|
|
425
|
-
*
|
|
426
|
-
* become available
|
|
335
|
+
* Run the prerendering process for a list of routes with a specified concurrency limit,
|
|
336
|
+
* by calling the 'get' function for each route and yielding the results as they become available
|
|
427
337
|
*/
|
|
428
338
|
async function* run(app, routes, opts) {
|
|
429
339
|
const limit = Math.max(1, Math.min(opts.concurrency ?? 4, routes.length || 1));
|
|
@@ -434,7 +344,7 @@ var Prerender;
|
|
|
434
344
|
const i = index++;
|
|
435
345
|
const value = routes[i];
|
|
436
346
|
pending.set(i, get(app, value, {
|
|
437
|
-
|
|
347
|
+
base: opts.base,
|
|
438
348
|
origin: opts.origin,
|
|
439
349
|
})
|
|
440
350
|
.then(result => ({ index: i, result }))
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collect the root request paths for files that originate in Vite's public dir.
|
|
3
|
+
* Vite copies these files into the built client output unchanged. Solas stores
|
|
4
|
+
* the request paths here so runtime serving can whitelist them
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```ts
|
|
8
|
+
* // public/robots.txt
|
|
9
|
+
* // becomes '/robots.txt'
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* // public/images/logo 1.png
|
|
15
|
+
* // becomes '/images/logo%201.png'
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export declare function collect(root: string | false | null | undefined): Promise<string[]>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { Solas } from '../solas.js';
|
|
4
|
+
/**
|
|
5
|
+
* Collect the root request paths for files that originate in Vite's public dir.
|
|
6
|
+
* Vite copies these files into the built client output unchanged. Solas stores
|
|
7
|
+
* the request paths here so runtime serving can whitelist them
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* // public/robots.txt
|
|
12
|
+
* // becomes '/robots.txt'
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```ts
|
|
17
|
+
* // public/images/logo 1.png
|
|
18
|
+
* // becomes '/images/logo%201.png'
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export async function collect(root) {
|
|
22
|
+
if (!root)
|
|
23
|
+
return [];
|
|
24
|
+
// normalise the configured public dir before we start walking it
|
|
25
|
+
const publicRoot = path.resolve(root);
|
|
26
|
+
try {
|
|
27
|
+
const stat = await fs.stat(publicRoot);
|
|
28
|
+
if (!stat.isDirectory())
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
const files = [];
|
|
35
|
+
async function walk(dir, parts = []) {
|
|
36
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
// top-level /public/_solas would collide with framework assets served
|
|
39
|
+
// from /_solas, so keep that namespace reserved
|
|
40
|
+
if (parts.length === 0 &&
|
|
41
|
+
entry.isDirectory() &&
|
|
42
|
+
entry.name === Solas.Config.ASSETS_DIR) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const nextParts = [...parts, entry.name];
|
|
46
|
+
const nextPath = path.join(dir, entry.name);
|
|
47
|
+
if (entry.isDirectory()) {
|
|
48
|
+
await walk(nextPath, nextParts);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (!entry.isFile())
|
|
52
|
+
continue;
|
|
53
|
+
// store the external request path, not the filesystem path
|
|
54
|
+
// eg public/favicon.ico -> /favicon.ico
|
|
55
|
+
// eg public/images/logo 1.png -> /images/logo%201.png
|
|
56
|
+
// encode each segment so manifest lookups match routed URLs
|
|
57
|
+
files.push(`/${nextParts.map(part => encodeURIComponent(part)).join('/')}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
await walk(publicRoot);
|
|
61
|
+
// keep manifest output stable regardless of directory iteration order
|
|
62
|
+
return files.sort();
|
|
63
|
+
}
|
|
@@ -68,8 +68,6 @@ export declare class Resolver {
|
|
|
68
68
|
* Reconcile a HttpRouter match against a manifest entry
|
|
69
69
|
*/
|
|
70
70
|
reconcile(path: string, match: HttpRouter.Match | null, error?: Error): {
|
|
71
|
-
params: HttpRouter.Params;
|
|
72
|
-
error: Error | undefined;
|
|
73
71
|
__id: string;
|
|
74
72
|
__path: string;
|
|
75
73
|
__params: string[];
|
|
@@ -89,9 +87,9 @@ export declare class Resolver {
|
|
|
89
87
|
prerender: "full" | "ppr" | false;
|
|
90
88
|
dynamic: boolean;
|
|
91
89
|
wildcard: boolean;
|
|
90
|
+
params: HttpRouter.Params;
|
|
91
|
+
error: Error | undefined;
|
|
92
92
|
} | {
|
|
93
|
-
params: {};
|
|
94
|
-
error: HttpException;
|
|
95
93
|
__id: string;
|
|
96
94
|
__path: string;
|
|
97
95
|
__params: string[];
|
|
@@ -111,11 +109,32 @@ export declare class Resolver {
|
|
|
111
109
|
prerender: "full" | "ppr" | false;
|
|
112
110
|
dynamic: boolean;
|
|
113
111
|
wildcard: boolean;
|
|
112
|
+
params: {};
|
|
113
|
+
error: HttpException;
|
|
114
114
|
} | null;
|
|
115
115
|
/**
|
|
116
116
|
* Enhance a matched route with its associated components
|
|
117
117
|
*/
|
|
118
118
|
enhance(match: Resolver.ReconciledMatch | null): {
|
|
119
|
+
__id: string;
|
|
120
|
+
__path: string;
|
|
121
|
+
__params: string[];
|
|
122
|
+
__kind: "$P";
|
|
123
|
+
__depth: number;
|
|
124
|
+
method: "get";
|
|
125
|
+
paths: {
|
|
126
|
+
layouts: (string | null)[];
|
|
127
|
+
'401s': (string | null)[];
|
|
128
|
+
'403s': (string | null)[];
|
|
129
|
+
'404s': (string | null)[];
|
|
130
|
+
'500s': (string | null)[];
|
|
131
|
+
loaders: (string | null)[];
|
|
132
|
+
middlewares: (string | null)[];
|
|
133
|
+
page?: string | null | undefined;
|
|
134
|
+
};
|
|
135
|
+
prerender: "full" | "ppr" | false;
|
|
136
|
+
dynamic: boolean;
|
|
137
|
+
wildcard: boolean;
|
|
119
138
|
ui: {
|
|
120
139
|
layouts: (View<{
|
|
121
140
|
children?: import("react").ReactNode;
|
|
@@ -155,25 +174,6 @@ export declare class Resolver {
|
|
|
155
174
|
metadata?: ((input: Metadata.Input<HttpRouter.Params, Error>) => Metadata.Task[]) | undefined;
|
|
156
175
|
params: HttpRouter.Params | {};
|
|
157
176
|
error: Error | HttpException | undefined;
|
|
158
|
-
__id: string;
|
|
159
|
-
__path: string;
|
|
160
|
-
__params: string[];
|
|
161
|
-
__kind: "$P";
|
|
162
|
-
__depth: number;
|
|
163
|
-
method: "get";
|
|
164
|
-
paths: {
|
|
165
|
-
layouts: (string | null)[];
|
|
166
|
-
'401s': (string | null)[];
|
|
167
|
-
'403s': (string | null)[];
|
|
168
|
-
'404s': (string | null)[];
|
|
169
|
-
'500s': (string | null)[];
|
|
170
|
-
loaders: (string | null)[];
|
|
171
|
-
middlewares: (string | null)[];
|
|
172
|
-
page?: string | null | undefined;
|
|
173
|
-
};
|
|
174
|
-
prerender: "full" | "ppr" | false;
|
|
175
|
-
dynamic: boolean;
|
|
176
|
-
wildcard: boolean;
|
|
177
177
|
} | null;
|
|
178
178
|
/**
|
|
179
179
|
* Find the closest ancestor entry for a given path and property
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ReactFormState } from 'react-dom/client';
|
|
2
2
|
import { SolasRequest } from '../../types.js';
|
|
3
|
+
import { CsrfConfig } from './csrf.js';
|
|
3
4
|
/**
|
|
4
5
|
* Check if a request is an action request and reuse parsed FormData
|
|
5
6
|
* when multipart action detection already had to inspect the body
|
|
@@ -16,7 +17,7 @@ export declare function maybeAction(req: Request): Promise<{
|
|
|
16
17
|
* @returns an object containing either the return value of the action or the form state, depending on the type
|
|
17
18
|
* of action request
|
|
18
19
|
*/
|
|
19
|
-
export declare function processActionRequest(req: SolasRequest): Promise<{
|
|
20
|
+
export declare function processActionRequest(req: SolasRequest, csrf?: CsrfConfig): Promise<{
|
|
20
21
|
returnValue: {
|
|
21
22
|
ok: boolean;
|
|
22
23
|
data: unknown;
|
|
@@ -24,7 +25,3 @@ export declare function processActionRequest(req: SolasRequest): Promise<{
|
|
|
24
25
|
formState: ReactFormState | undefined;
|
|
25
26
|
temporaryReferences: unknown;
|
|
26
27
|
}>;
|
|
27
|
-
/**
|
|
28
|
-
* Check whether an action request came from the same origin as the target app
|
|
29
|
-
*/
|
|
30
|
-
export declare function isTrustedActionRequest(req: Request): boolean;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createTemporaryReferenceSet, decodeAction, decodeFormState, decodeReply, loadServerAction, } from '@vitejs/plugin-rsc/rsc';
|
|
2
2
|
import { Solas } from '../../solas.js';
|
|
3
|
-
import {
|
|
3
|
+
import { enforce } from './csrf.js';
|
|
4
4
|
/**
|
|
5
5
|
* Check if a request is an action request and reuse parsed FormData
|
|
6
6
|
* when multipart action detection already had to inspect the body
|
|
@@ -34,14 +34,12 @@ export async function maybeAction(req) {
|
|
|
34
34
|
* @returns an object containing either the return value of the action or the form state, depending on the type
|
|
35
35
|
* of action request
|
|
36
36
|
*/
|
|
37
|
-
export async function processActionRequest(req) {
|
|
37
|
+
export async function processActionRequest(req, csrf = {}) {
|
|
38
38
|
let returnValue;
|
|
39
39
|
let formState;
|
|
40
40
|
let temporaryReferences;
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
throw new HttpException(403, 'Cross-site action requests are forbidden');
|
|
44
|
-
}
|
|
41
|
+
// enforce CSRF for all action requests
|
|
42
|
+
enforce(req, csrf);
|
|
45
43
|
const id = req.headers.get('x-rsc-action-id');
|
|
46
44
|
if (id) {
|
|
47
45
|
// x-rsc-action-id header exists when action is
|
|
@@ -76,32 +74,3 @@ export async function processActionRequest(req) {
|
|
|
76
74
|
}
|
|
77
75
|
return { returnValue, formState, temporaryReferences };
|
|
78
76
|
}
|
|
79
|
-
/**
|
|
80
|
-
* Reduce Origin and Referer headers to a comparable origin string
|
|
81
|
-
*/
|
|
82
|
-
function toOrigin(value) {
|
|
83
|
-
if (!value)
|
|
84
|
-
return null;
|
|
85
|
-
try {
|
|
86
|
-
return new URL(value).origin;
|
|
87
|
-
}
|
|
88
|
-
catch {
|
|
89
|
-
return null;
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
/**
|
|
93
|
-
* Check whether an action request came from the same origin as the target app
|
|
94
|
-
*/
|
|
95
|
-
export function isTrustedActionRequest(req) {
|
|
96
|
-
const requestOrigin = toOrigin(req.url);
|
|
97
|
-
if (!requestOrigin)
|
|
98
|
-
return false;
|
|
99
|
-
const origin = toOrigin(req.headers.get('origin'));
|
|
100
|
-
if (origin)
|
|
101
|
-
return origin === requestOrigin;
|
|
102
|
-
// some user agents omit Origin on same-origin form posts, so fall back to Referer
|
|
103
|
-
const referer = toOrigin(req.headers.get('referer'));
|
|
104
|
-
if (referer)
|
|
105
|
-
return referer === requestOrigin;
|
|
106
|
-
return false;
|
|
107
|
-
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { PluginConfig } from '../../types.js';
|
|
2
|
+
export type CsrfConfig = Pick<PluginConfig, 'trustedOrigins' | 'url'>;
|
|
3
|
+
/**
|
|
4
|
+
* Enforce the CSRF policy for one request
|
|
5
|
+
*/
|
|
6
|
+
export declare function enforce(req: Request, config?: CsrfConfig): void;
|
|
7
|
+
/**
|
|
8
|
+
* Get the first value from a forwarded-style header chain
|
|
9
|
+
*/
|
|
10
|
+
export declare function takeFirst(value: string | null | undefined): string | null;
|
|
11
|
+
/**
|
|
12
|
+
* Build an origin from host-style headers when there is no full origin value
|
|
13
|
+
*/
|
|
14
|
+
export declare function toHostOrigin(host: string | null | undefined, protocol: string | null | undefined): string | null;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { HttpException } from '../navigation/http-exception.js';
|
|
2
|
+
const UNSAFE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
3
|
+
const TRUSTED_FETCH_SITES = new Set(['same-origin', 'none']);
|
|
4
|
+
/**
|
|
5
|
+
* Reduce an origin-like value to just its origin for comparison
|
|
6
|
+
*/
|
|
7
|
+
function toOrigin(value) {
|
|
8
|
+
if (!value)
|
|
9
|
+
return null;
|
|
10
|
+
try {
|
|
11
|
+
// csrf only cares which origin sent the request, not its path or query
|
|
12
|
+
return new URL(value).origin;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Enforce the CSRF policy for one request
|
|
20
|
+
*/
|
|
21
|
+
export function enforce(req, config = {}) {
|
|
22
|
+
// only unsafe methods can mutate state, so safe methods bypass the guard
|
|
23
|
+
if (!UNSAFE_METHODS.has(req.method.toUpperCase()))
|
|
24
|
+
return;
|
|
25
|
+
// first trust the browser's own source headers when they are present
|
|
26
|
+
const sourceOrigin = toOrigin(req.headers.get('origin')) ?? toOrigin(req.headers.get('referer'));
|
|
27
|
+
if (sourceOrigin) {
|
|
28
|
+
const origins = new Set();
|
|
29
|
+
const forwardedProtocol = takeFirst(req.headers.get('x-forwarded-proto'));
|
|
30
|
+
let protocol;
|
|
31
|
+
if (forwardedProtocol === 'http' || forwardedProtocol === 'https') {
|
|
32
|
+
protocol = forwardedProtocol;
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
try {
|
|
36
|
+
// otherwise fall back to the protocol on the request url we received
|
|
37
|
+
protocol = new URL(req.url).protocol.replace(/:$/, '');
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
protocol = null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// allow the current request origin and any configured public origin
|
|
44
|
+
const requestOrigin = toOrigin(req.url);
|
|
45
|
+
if (requestOrigin)
|
|
46
|
+
origins.add(requestOrigin);
|
|
47
|
+
const configuredOrigin = toOrigin(config.url ?? null);
|
|
48
|
+
if (configuredOrigin)
|
|
49
|
+
origins.add(configuredOrigin);
|
|
50
|
+
// also allow host-based origins so proxied deployments still match the public site
|
|
51
|
+
const forwardedHostOrigin = toHostOrigin(takeFirst(req.headers.get('x-forwarded-host')), protocol);
|
|
52
|
+
if (forwardedHostOrigin)
|
|
53
|
+
origins.add(forwardedHostOrigin);
|
|
54
|
+
const hostOrigin = toHostOrigin(takeFirst(req.headers.get('host')), protocol);
|
|
55
|
+
if (hostOrigin)
|
|
56
|
+
origins.add(hostOrigin);
|
|
57
|
+
// add any cross-origin browser sites the config explicitly trusts
|
|
58
|
+
for (const value of config.trustedOrigins ?? []) {
|
|
59
|
+
const origin = toOrigin(value);
|
|
60
|
+
if (origin)
|
|
61
|
+
origins.add(origin);
|
|
62
|
+
}
|
|
63
|
+
if (origins.has(sourceOrigin))
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
// if origin and referer are missing, fall back to fetch metadata
|
|
67
|
+
const fetchSite = req.headers.get('sec-fetch-site')?.toLowerCase();
|
|
68
|
+
if (fetchSite && TRUSTED_FETCH_SITES.has(fetchSite))
|
|
69
|
+
return;
|
|
70
|
+
// if the browser sent no source hints at all, treat it like a non-browser client
|
|
71
|
+
if (!sourceOrigin && !fetchSite)
|
|
72
|
+
return;
|
|
73
|
+
throw new HttpException(403, 'Cross-site unsafe requests are forbidden');
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Get the first value from a forwarded-style header chain
|
|
77
|
+
*/
|
|
78
|
+
export function takeFirst(value) {
|
|
79
|
+
if (!value)
|
|
80
|
+
return null;
|
|
81
|
+
// use the client-facing value, not a later proxy hop
|
|
82
|
+
const first = value.split(',')[0]?.trim();
|
|
83
|
+
return first || null;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Build an origin from host-style headers when there is no full origin value
|
|
87
|
+
*/
|
|
88
|
+
export function toHostOrigin(host, protocol) {
|
|
89
|
+
if (!host || !protocol)
|
|
90
|
+
return null;
|
|
91
|
+
try {
|
|
92
|
+
// this lets host and forwarded-host compare against trusted origins too
|
|
93
|
+
return new URL(`${protocol}://${host}`).origin;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
package/dist/router.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { Link } from './internal/browser-router/link.js';
|
|
2
|
+
export { withBase } from './internal/browser-router/shared.js';
|
|
2
3
|
export { useRouter } from './internal/browser-router/use-router.js';
|
|
3
4
|
export { useSearchParams } from './internal/browser-router/use-search-params.js';
|
package/dist/router.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { Link } from './internal/browser-router/link.js';
|
|
2
|
+
export { withBase } from './internal/browser-router/shared.js';
|
|
2
3
|
export { useRouter } from './internal/browser-router/use-router.js';
|
|
3
4
|
export { useSearchParams } from './internal/browser-router/use-search-params.js';
|
package/dist/solas.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Prerender } from './internal/prerender.js';
|
|
1
2
|
import type { PluginConfig } from './types.js';
|
|
2
3
|
export declare namespace Solas {
|
|
3
4
|
interface Routes {
|
|
@@ -12,12 +13,14 @@ export declare namespace Solas {
|
|
|
12
13
|
const ENTRY_RSC = "entry.rsc.tsx";
|
|
13
14
|
const ENTRY_SSR = "entry.ssr.tsx";
|
|
14
15
|
const ENTRY_BROWSER = "entry.browser.tsx";
|
|
15
|
-
const ASSETS_DIR
|
|
16
|
+
const ASSETS_DIR: string;
|
|
17
|
+
const PUBLIC_DIR = "public";
|
|
16
18
|
const $: unique symbol;
|
|
17
19
|
const REQUEST_META_KEY: string;
|
|
18
20
|
const LOG_LEVELS: readonly ["debug", "info", "warn", "error", "fatal"];
|
|
19
21
|
const PRERENDER_MODES: readonly ["full", "ppr", false];
|
|
20
22
|
const TRAILING_SLASH_MODES: readonly ["always", "never", "ignore"];
|
|
23
|
+
const RUNTIME_MANIFEST = "runtime-manifest.json";
|
|
21
24
|
/**
|
|
22
25
|
* Validate the plugin configuration object, throwing an error if invalid
|
|
23
26
|
* @param input - the unvalidated configuration object
|
|
@@ -26,6 +29,14 @@ export declare namespace Solas {
|
|
|
26
29
|
function validate(input: unknown): PluginConfig;
|
|
27
30
|
}
|
|
28
31
|
function getVersion(): string;
|
|
32
|
+
namespace Runtime {
|
|
33
|
+
type Manifest = {
|
|
34
|
+
artifacts: Prerender.Artifact.Manifest;
|
|
35
|
+
publicFiles: ReadonlySet<string>;
|
|
36
|
+
};
|
|
37
|
+
function getManifestPath(outDir: string): string;
|
|
38
|
+
function loadManifest(outDir: string): Promise<Manifest | null>;
|
|
39
|
+
}
|
|
29
40
|
namespace Events {
|
|
30
41
|
const names: {
|
|
31
42
|
readonly NAVIGATION: `${string}navigation`;
|