@reckona/mreact-router 0.0.65 → 0.0.67
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/package.json +13 -12
- package/src/actions.ts +1130 -0
- package/src/adapters/aws-lambda.ts +993 -0
- package/src/adapters/cloudflare.ts +1286 -0
- package/src/adapters/devtools.ts +5 -0
- package/src/adapters/edge.ts +70 -0
- package/src/adapters/node.ts +126 -0
- package/src/adapters/static.ts +61 -0
- package/src/app-router-globals.ts +19 -0
- package/src/assets.ts +113 -0
- package/src/build.ts +2948 -0
- package/src/bundle-pipeline.ts +496 -0
- package/src/cache-config.ts +35 -0
- package/src/cache-stats.ts +54 -0
- package/src/cache.ts +418 -0
- package/src/cli-options.ts +296 -0
- package/src/cli.ts +94 -0
- package/src/client.ts +3398 -0
- package/src/config.ts +146 -0
- package/src/cookies.ts +113 -0
- package/src/csp.ts +103 -0
- package/src/csrf.ts +132 -0
- package/src/deferred.ts +52 -0
- package/src/dev-server.ts +262 -0
- package/src/file-conventions.ts +88 -0
- package/src/http.ts +128 -0
- package/src/i18n.ts +98 -0
- package/src/import-policy.ts +261 -0
- package/src/index.ts +221 -0
- package/src/link.ts +47 -0
- package/src/logger.ts +149 -0
- package/src/module-runner.ts +554 -0
- package/src/multipart.ts +577 -0
- package/src/native-escape.ts +53 -0
- package/src/native-route-matcher.ts +173 -0
- package/src/navigation-state.ts +98 -0
- package/src/navigation.ts +215 -0
- package/src/prerender-store.ts +233 -0
- package/src/render.ts +5187 -0
- package/src/route-path.ts +5 -0
- package/src/route-shells.ts +47 -0
- package/src/route-source.ts +224 -0
- package/src/route-styles.ts +91 -0
- package/src/routes.ts +362 -0
- package/src/runtime-cache.ts +8 -0
- package/src/runtime-state.ts +37 -0
- package/src/security-headers.ts +134 -0
- package/src/serve.ts +1265 -0
- package/src/session.ts +238 -0
- package/src/source-jsx.ts +30 -0
- package/src/source-modules.ts +69 -0
- package/src/stream-list.ts +45 -0
- package/src/trace.ts +114 -0
- package/src/types.ts +155 -0
- package/src/upgrade.ts +8 -0
- package/src/vite-config.ts +63 -0
- package/src/vite-plugin-cache-key.ts +53 -0
- package/src/vite.ts +690 -0
- package/src/workspace-packages.ts +67 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
2
|
+
|
|
3
|
+
export type AppRouterBuildTarget = "node" | "cloudflare" | "aws-lambda";
|
|
4
|
+
export type AppRouterClientSourceMapMode = "none" | "hidden" | "linked";
|
|
5
|
+
export type AppRouterClientSourceMapOption = boolean | AppRouterClientSourceMapMode;
|
|
6
|
+
|
|
7
|
+
export interface AppRouterProjectOptions {
|
|
8
|
+
assetBaseUrl?: string | undefined;
|
|
9
|
+
buildTargets?: readonly AppRouterBuildTarget[] | undefined;
|
|
10
|
+
clientSourceMaps?: AppRouterClientSourceMapOption | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* Legacy route root. When provided without routesDir/projectRoot, this keeps
|
|
13
|
+
* the historical "appDir is the whole app boundary" behavior.
|
|
14
|
+
*
|
|
15
|
+
* @deprecated Use projectRoot + routesDir instead. The legacy appDir shortcut
|
|
16
|
+
* is kept for direct tests and older programmatic callers and is planned for
|
|
17
|
+
* removal after 0.1.0.
|
|
18
|
+
*/
|
|
19
|
+
appDir?: string | undefined;
|
|
20
|
+
allowedSourceDirs?: readonly string[] | undefined;
|
|
21
|
+
projectRoot?: string | undefined;
|
|
22
|
+
publicDir?: string | undefined;
|
|
23
|
+
publicAssetBaseUrl?: string | undefined;
|
|
24
|
+
routesDir?: string | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ResolvedAppRouterProject {
|
|
28
|
+
allowedSourceDirs: readonly string[];
|
|
29
|
+
assetBaseUrl?: string | undefined;
|
|
30
|
+
buildTargets: readonly AppRouterBuildTarget[];
|
|
31
|
+
clientSourceMaps: AppRouterClientSourceMapMode;
|
|
32
|
+
projectRoot: string;
|
|
33
|
+
publicAssetBaseUrl?: string | undefined;
|
|
34
|
+
publicDir: string;
|
|
35
|
+
routesDir: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function resolveAppRouterProjectOptions(
|
|
39
|
+
options: AppRouterProjectOptions,
|
|
40
|
+
): ResolvedAppRouterProject {
|
|
41
|
+
if (
|
|
42
|
+
options.appDir !== undefined &&
|
|
43
|
+
options.projectRoot === undefined &&
|
|
44
|
+
options.routesDir === undefined
|
|
45
|
+
) {
|
|
46
|
+
const appDir = resolve(options.appDir);
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
allowedSourceDirs: (options.allowedSourceDirs ?? [appDir]).map((directory) =>
|
|
50
|
+
resolveProjectPath(appDir, directory, "allowedSourceDirs"),
|
|
51
|
+
),
|
|
52
|
+
...(options.assetBaseUrl === undefined ? {} : { assetBaseUrl: options.assetBaseUrl }),
|
|
53
|
+
buildTargets: resolveBuildTargets(options.buildTargets),
|
|
54
|
+
clientSourceMaps: resolveClientSourceMapMode(options.clientSourceMaps),
|
|
55
|
+
projectRoot: appDir,
|
|
56
|
+
...(options.publicAssetBaseUrl === undefined
|
|
57
|
+
? {}
|
|
58
|
+
: { publicAssetBaseUrl: options.publicAssetBaseUrl }),
|
|
59
|
+
publicDir: resolveProjectPath(appDir, options.publicDir ?? "public", "publicDir"),
|
|
60
|
+
routesDir: appDir,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const projectRoot = resolve(options.projectRoot ?? process.cwd());
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
allowedSourceDirs: (options.allowedSourceDirs ?? ["src"]).map((directory) =>
|
|
68
|
+
resolveProjectPath(projectRoot, directory, "allowedSourceDirs"),
|
|
69
|
+
),
|
|
70
|
+
...(options.assetBaseUrl === undefined ? {} : { assetBaseUrl: options.assetBaseUrl }),
|
|
71
|
+
buildTargets: resolveBuildTargets(options.buildTargets),
|
|
72
|
+
clientSourceMaps: resolveClientSourceMapMode(options.clientSourceMaps),
|
|
73
|
+
projectRoot,
|
|
74
|
+
...(options.publicAssetBaseUrl === undefined
|
|
75
|
+
? {}
|
|
76
|
+
: { publicAssetBaseUrl: options.publicAssetBaseUrl }),
|
|
77
|
+
publicDir: resolveProjectPath(projectRoot, options.publicDir ?? "public", "publicDir"),
|
|
78
|
+
routesDir: resolveProjectPath(projectRoot, options.routesDir ?? "src/app", "routesDir"),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function resolveClientSourceMapMode(
|
|
83
|
+
value: AppRouterClientSourceMapOption | undefined,
|
|
84
|
+
): AppRouterClientSourceMapMode {
|
|
85
|
+
if (value === undefined || value === false || value === "none") {
|
|
86
|
+
return "none";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (value === true || value === "linked") {
|
|
90
|
+
return "linked";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (value === "hidden") {
|
|
94
|
+
return "hidden";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
throw new Error(
|
|
98
|
+
`Unsupported mreactRouter clientSourceMaps value ${JSON.stringify(value)}. Expected false, true, "none", "hidden", or "linked".`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function resolveBuildTargets(
|
|
103
|
+
targets: readonly AppRouterBuildTarget[] | undefined,
|
|
104
|
+
): readonly AppRouterBuildTarget[] {
|
|
105
|
+
if (targets === undefined) {
|
|
106
|
+
return ["node", "cloudflare"];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const uniqueTargets = [...new Set(targets)];
|
|
110
|
+
|
|
111
|
+
if (uniqueTargets.length === 0) {
|
|
112
|
+
throw new Error("mreactRouter buildTargets must include at least one target.");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const target of uniqueTargets) {
|
|
116
|
+
if (target !== "node" && target !== "cloudflare" && target !== "aws-lambda") {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`Unsupported mreactRouter build target ${JSON.stringify(target)}. Expected "node", "cloudflare", or "aws-lambda".`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return uniqueTargets;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function resolveProjectPath(root: string, path: string, optionName: string): string {
|
|
127
|
+
const resolvedPath = resolvePath(root, path);
|
|
128
|
+
|
|
129
|
+
if (!isInsideDirectory(root, resolvedPath)) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`mreactRouter ${optionName} must resolve inside projectRoot. projectRoot: ${root}; ${optionName}: ${resolvedPath}`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return resolvedPath;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function resolvePath(root: string, path: string): string {
|
|
139
|
+
return isAbsolute(path) ? resolve(path) : resolve(root, path);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isInsideDirectory(root: string, path: string): boolean {
|
|
143
|
+
const relativePath = relative(root, path);
|
|
144
|
+
|
|
145
|
+
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
|
|
146
|
+
}
|
package/src/cookies.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
export interface CookieOptions {
|
|
2
|
+
domain?: string;
|
|
3
|
+
expires?: Date;
|
|
4
|
+
httpOnly?: boolean;
|
|
5
|
+
maxAge?: number;
|
|
6
|
+
path?: string;
|
|
7
|
+
sameSite?: "Strict" | "Lax" | "None";
|
|
8
|
+
secure?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const COOKIE_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
12
|
+
|
|
13
|
+
function assertCookieName(name: string): void {
|
|
14
|
+
if (!COOKIE_NAME.test(name)) {
|
|
15
|
+
throw new TypeError(`invalid cookie name: ${JSON.stringify(name)}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function assertAttributeValue(value: string): void {
|
|
20
|
+
if (/[\r\n;]/.test(value)) {
|
|
21
|
+
throw new TypeError(`invalid cookie attribute value: ${JSON.stringify(value)}`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function parseCookieHeader(
|
|
26
|
+
cookieHeader: string | null | undefined,
|
|
27
|
+
): Map<string, string> {
|
|
28
|
+
const values = new Map<string, string>();
|
|
29
|
+
|
|
30
|
+
for (const part of (cookieHeader ?? "").split(";")) {
|
|
31
|
+
const [rawName, ...rawValue] = part.trim().split("=");
|
|
32
|
+
|
|
33
|
+
if (rawName === undefined || rawName === "") {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const raw = rawValue.join("=");
|
|
38
|
+
if (raw.indexOf("%") === -1) {
|
|
39
|
+
values.set(rawName, raw);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
values.set(rawName, decodeURIComponent(raw));
|
|
45
|
+
} catch {
|
|
46
|
+
// Treat malformed cookie values as absent for this request.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return values;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function serializeCookie(
|
|
54
|
+
name: string,
|
|
55
|
+
value: string,
|
|
56
|
+
options: CookieOptions = {},
|
|
57
|
+
): string {
|
|
58
|
+
assertCookieName(name);
|
|
59
|
+
|
|
60
|
+
if (options.sameSite === "None" && options.secure !== true) {
|
|
61
|
+
throw new TypeError("SameSite=None requires Secure");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const parts = [`${name}=${encodeURIComponent(value)}`];
|
|
65
|
+
|
|
66
|
+
if (options.maxAge !== undefined) {
|
|
67
|
+
if (!Number.isSafeInteger(options.maxAge)) {
|
|
68
|
+
throw new TypeError("invalid cookie Max-Age");
|
|
69
|
+
}
|
|
70
|
+
parts.push(`Max-Age=${options.maxAge}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (options.domain !== undefined) {
|
|
74
|
+
assertAttributeValue(options.domain);
|
|
75
|
+
parts.push(`Domain=${options.domain}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (options.path !== undefined) {
|
|
79
|
+
assertAttributeValue(options.path);
|
|
80
|
+
parts.push(`Path=${options.path}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (options.expires !== undefined) {
|
|
84
|
+
parts.push(`Expires=${options.expires.toUTCString()}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (options.httpOnly === true) parts.push("HttpOnly");
|
|
88
|
+
if (options.secure === true) parts.push("Secure");
|
|
89
|
+
if (options.sameSite !== undefined) parts.push(`SameSite=${options.sameSite}`);
|
|
90
|
+
|
|
91
|
+
return parts.join("; ");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function setCookie(
|
|
95
|
+
response: Response,
|
|
96
|
+
name: string,
|
|
97
|
+
value: string,
|
|
98
|
+
options: CookieOptions = {},
|
|
99
|
+
): Response {
|
|
100
|
+
response.headers.append("set-cookie", serializeCookie(name, value, options));
|
|
101
|
+
return response;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function deleteCookie(
|
|
105
|
+
response: Response,
|
|
106
|
+
name: string,
|
|
107
|
+
options: Pick<CookieOptions, "domain" | "path" | "sameSite" | "secure"> = {},
|
|
108
|
+
): Response {
|
|
109
|
+
return setCookie(response, name, "", {
|
|
110
|
+
...options,
|
|
111
|
+
maxAge: 0,
|
|
112
|
+
});
|
|
113
|
+
}
|
package/src/csp.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// CSP header serializer used by the App Router metadata pipeline.
|
|
2
|
+
//
|
|
3
|
+
// Validates both nonce and directive values before string-concatenating
|
|
4
|
+
// them into the Content-Security-Policy header. Background: directive
|
|
5
|
+
// concatenation with `; ` and quoted-source concatenation with `' '` are
|
|
6
|
+
// trivially escapable if untrusted strings reach the metadata field.
|
|
7
|
+
//
|
|
8
|
+
// Allowed nonce shape: base64 / base64url alphabet only (`+/=` and `-_=`).
|
|
9
|
+
// Allowed directive value shape: no `;`, no quote, no whitespace, no ASCII
|
|
10
|
+
// control characters. Quoted-keyword forms (`'self'`, `'nonce-...'`,
|
|
11
|
+
// `'sha256-...'`, etc.) are accepted via the `'...'` allow-list pattern.
|
|
12
|
+
|
|
13
|
+
export interface ContentSecurityPolicyInput {
|
|
14
|
+
disable?: boolean;
|
|
15
|
+
directives?: Record<string, readonly string[] | string>;
|
|
16
|
+
nonce?: string;
|
|
17
|
+
remove?: readonly string[];
|
|
18
|
+
replace?: Record<string, readonly string[] | string>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const VALID_NONCE = /^[A-Za-z0-9+/=_-]+$/;
|
|
22
|
+
const VALID_DIRECTIVE_NAME = /^[a-z][a-z0-9-]*$/i;
|
|
23
|
+
// One CSP "source expression". Reject anything containing `;`, quote,
|
|
24
|
+
// whitespace, or ASCII control characters. Quoted keywords like
|
|
25
|
+
// `'self'`, `'unsafe-inline'`, `'nonce-XYZ='` are accepted via the
|
|
26
|
+
// alternate `'...'` shape.
|
|
27
|
+
const VALID_QUOTED_DIRECTIVE_VALUE = /^'[A-Za-z0-9+/=_:.-]+'$/;
|
|
28
|
+
|
|
29
|
+
function isValidNonce(nonce: string): boolean {
|
|
30
|
+
return VALID_NONCE.test(nonce);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isValidDirectiveName(name: string): boolean {
|
|
34
|
+
return VALID_DIRECTIVE_NAME.test(name);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isValidDirectiveValue(value: string): boolean {
|
|
38
|
+
if (VALID_QUOTED_DIRECTIVE_VALUE.test(value)) {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (value.length === 0) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
47
|
+
const code = value.charCodeAt(index);
|
|
48
|
+
|
|
49
|
+
if (
|
|
50
|
+
code <= 0x20 ||
|
|
51
|
+
code === 0x22 ||
|
|
52
|
+
code === 0x27 ||
|
|
53
|
+
code === 0x3b ||
|
|
54
|
+
code === 0x7f
|
|
55
|
+
) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function contentSecurityPolicy(
|
|
64
|
+
csp: ContentSecurityPolicyInput | undefined,
|
|
65
|
+
): string | undefined {
|
|
66
|
+
if (csp?.disable === true || csp?.directives === undefined) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (csp.nonce !== undefined && !isValidNonce(csp.nonce)) {
|
|
71
|
+
throw new TypeError(
|
|
72
|
+
`invalid CSP nonce: ${JSON.stringify(csp.nonce)} - must be base64 / base64url`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const serialized: string[] = [];
|
|
77
|
+
|
|
78
|
+
for (const [name, value] of Object.entries(csp.directives)) {
|
|
79
|
+
if (!isValidDirectiveName(name)) {
|
|
80
|
+
throw new TypeError(`invalid CSP directive name: ${JSON.stringify(name)}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const rawValues = Array.isArray(value) ? [...value] : [value];
|
|
84
|
+
|
|
85
|
+
for (const rawValue of rawValues) {
|
|
86
|
+
if (typeof rawValue !== "string" || !isValidDirectiveValue(rawValue)) {
|
|
87
|
+
throw new TypeError(
|
|
88
|
+
`invalid CSP directive value for ${name}: ${JSON.stringify(rawValue)}`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const values = [...rawValues];
|
|
94
|
+
|
|
95
|
+
if (csp.nonce !== undefined && (name === "script-src" || name === "style-src")) {
|
|
96
|
+
values.push(`'nonce-${csp.nonce}'`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
serialized.push(`${name} ${values.join(" ")}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return serialized.join("; ");
|
|
103
|
+
}
|
package/src/csrf.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
const csrfCookieNameProduction = "__Host-mreact.csrf";
|
|
2
|
+
const csrfCookieNameDevelopment = "mreact.csrf";
|
|
3
|
+
const csrfCookieNamesRead = [csrfCookieNameProduction, csrfCookieNameDevelopment];
|
|
4
|
+
const formFieldCsrf = "__mreact_csrf";
|
|
5
|
+
|
|
6
|
+
export const formCsrfFieldName = formFieldCsrf;
|
|
7
|
+
|
|
8
|
+
export function serverActionCookie(csrfToken: string): string {
|
|
9
|
+
const production = isProductionEnvironment();
|
|
10
|
+
const parts = [
|
|
11
|
+
`${currentCsrfCookieName()}=${encodeURIComponent(csrfToken)}`,
|
|
12
|
+
"Path=/",
|
|
13
|
+
"SameSite=Lax",
|
|
14
|
+
"HttpOnly",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
if (production) {
|
|
18
|
+
parts.push("Secure");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return parts.join("; ");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createFormCsrfToken(request?: Request | undefined): string {
|
|
25
|
+
return readExistingFormCsrfToken(request) ?? randomToken();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function formCsrfCookie(csrfToken: string): string {
|
|
29
|
+
return serverActionCookie(csrfToken);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function validateFormCsrf(request: Request, formData: FormData): Response | undefined {
|
|
33
|
+
const formToken = stringFormValue(formData.get(formFieldCsrf));
|
|
34
|
+
const cookieHeader = request.headers.get("cookie");
|
|
35
|
+
const cookieToken = csrfCookieNamesRead
|
|
36
|
+
.map((name) => readCookie(cookieHeader, name))
|
|
37
|
+
.find((token) => token !== undefined);
|
|
38
|
+
|
|
39
|
+
if (formToken === undefined || cookieToken === undefined) {
|
|
40
|
+
return jsonResponse({ ok: false, error: "Invalid CSRF token." }, 403);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return timingSafeStringEqual(formToken, cookieToken)
|
|
44
|
+
? undefined
|
|
45
|
+
: jsonResponse({ ok: false, error: "Invalid CSRF token." }, 403);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function readExistingFormCsrfToken(request: Request | undefined): string | undefined {
|
|
49
|
+
const cookieHeader = request?.headers.get("cookie") ?? null;
|
|
50
|
+
|
|
51
|
+
for (const name of csrfCookieNamesRead) {
|
|
52
|
+
const token = readCookie(cookieHeader, name);
|
|
53
|
+
|
|
54
|
+
if (token !== undefined && isCsrfTokenShape(token)) {
|
|
55
|
+
return token;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isCsrfTokenShape(value: string): boolean {
|
|
63
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function randomToken(): string {
|
|
67
|
+
const cryptoApi = globalThis.crypto;
|
|
68
|
+
|
|
69
|
+
if (typeof cryptoApi?.randomUUID === "function") {
|
|
70
|
+
return cryptoApi.randomUUID();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (typeof cryptoApi?.getRandomValues !== "function") {
|
|
74
|
+
throw new Error("Web Crypto is required to create a CSRF token.");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const bytes = new Uint8Array(16);
|
|
78
|
+
cryptoApi.getRandomValues(bytes);
|
|
79
|
+
bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40;
|
|
80
|
+
bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
|
|
81
|
+
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
|
|
82
|
+
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isProductionEnvironment(): boolean {
|
|
86
|
+
return typeof process !== "undefined" && process.env?.NODE_ENV === "production";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function currentCsrfCookieName(): string {
|
|
90
|
+
return isProductionEnvironment() ? csrfCookieNameProduction : csrfCookieNameDevelopment;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function timingSafeStringEqual(left: string, right: string): boolean {
|
|
94
|
+
let diff = left.length ^ right.length;
|
|
95
|
+
const length = Math.max(left.length, right.length);
|
|
96
|
+
|
|
97
|
+
for (let index = 0; index < length; index += 1) {
|
|
98
|
+
diff |= (left.charCodeAt(index) || 0) ^ (right.charCodeAt(index) || 0);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return diff === 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function stringFormValue(value: FormDataEntryValue | null): string | undefined {
|
|
105
|
+
return typeof value === "string" ? value : undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function readCookie(cookieHeader: string | null, name: string): string | undefined {
|
|
109
|
+
if (cookieHeader === null || cookieHeader.trim() === "") {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const part of cookieHeader.split(";")) {
|
|
114
|
+
const [rawName, ...rawValue] = part.trim().split("=");
|
|
115
|
+
|
|
116
|
+
if (rawName !== name) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
return decodeURIComponent(rawValue.join("="));
|
|
122
|
+
} catch {
|
|
123
|
+
return rawValue.join("=");
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function jsonResponse(payload: unknown, status: number): Response {
|
|
131
|
+
return Response.json(payload, { status });
|
|
132
|
+
}
|
package/src/deferred.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const deferredLoaderDataSymbol = Symbol.for("mreact.router.deferred-loader-data");
|
|
2
|
+
|
|
3
|
+
export type DeferredLoaderData<TData extends Record<string, unknown>> = TData & {
|
|
4
|
+
readonly [deferredLoaderDataSymbol]: true;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export function defer<TData extends Record<string, unknown>>(
|
|
8
|
+
data: TData,
|
|
9
|
+
): DeferredLoaderData<TData> {
|
|
10
|
+
markTopLevelPromisesHandled(data);
|
|
11
|
+
|
|
12
|
+
return Object.defineProperty(data, deferredLoaderDataSymbol, {
|
|
13
|
+
enumerable: false,
|
|
14
|
+
value: true,
|
|
15
|
+
}) as DeferredLoaderData<TData>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isDeferredLoaderData(
|
|
19
|
+
value: unknown,
|
|
20
|
+
): value is DeferredLoaderData<Record<string, unknown>> {
|
|
21
|
+
return (
|
|
22
|
+
typeof value === "object" &&
|
|
23
|
+
value !== null &&
|
|
24
|
+
(value as { [deferredLoaderDataSymbol]?: unknown })[deferredLoaderDataSymbol] === true
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function unwrapDeferredLoaderData<TData extends Record<string, unknown>>(
|
|
29
|
+
data: DeferredLoaderData<TData>,
|
|
30
|
+
): TData {
|
|
31
|
+
return data;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function markTopLevelPromisesHandled(data: Record<string, unknown>): void {
|
|
35
|
+
for (const value of Object.values(data)) {
|
|
36
|
+
if (isPromiseLike(value)) {
|
|
37
|
+
// Avoid process-level unhandled rejection noise before <Await> attaches
|
|
38
|
+
// its boundary handlers. Callers should still render every deferred
|
|
39
|
+
// promise through <Await catch>; unused rejected fields will not surface
|
|
40
|
+
// as unhandled rejections.
|
|
41
|
+
void Promise.resolve(value).catch(() => {});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
|
47
|
+
return (
|
|
48
|
+
(typeof value === "object" || typeof value === "function") &&
|
|
49
|
+
value !== null &&
|
|
50
|
+
typeof (value as { then?: unknown }).then === "function"
|
|
51
|
+
);
|
|
52
|
+
}
|