@reckona/mreact-router 0.0.66 → 0.0.68
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/dist/actions.js.map +1 -1
- package/dist/adapters/aws-lambda.js.map +1 -1
- package/dist/adapters/cloudflare.js.map +1 -1
- package/dist/adapters/devtools.js.map +1 -1
- package/dist/adapters/edge.js.map +1 -1
- package/dist/adapters/node.js.map +1 -1
- package/dist/adapters/static.js.map +1 -1
- package/dist/app-router-globals.js.map +1 -1
- package/dist/assets.js.map +1 -1
- package/dist/build.js.map +1 -1
- package/dist/bundle-pipeline.js.map +1 -1
- package/dist/cache-config.js.map +1 -1
- package/dist/cache-stats.js.map +1 -1
- package/dist/cache.js.map +1 -1
- package/dist/cli-options.js.map +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/client.js.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/cookies.js.map +1 -1
- package/dist/csp.js.map +1 -1
- package/dist/csrf.js.map +1 -1
- package/dist/deferred.js.map +1 -1
- package/dist/dev-server.js.map +1 -1
- package/dist/file-conventions.js.map +1 -1
- package/dist/http.js.map +1 -1
- package/dist/i18n.js.map +1 -1
- package/dist/import-policy.js.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/link.js.map +1 -1
- package/dist/logger.js.map +1 -1
- package/dist/module-runner.js.map +1 -1
- package/dist/multipart.js.map +1 -1
- package/dist/native-escape.js.map +1 -1
- package/dist/native-route-matcher.js.map +1 -1
- package/dist/navigation-state.js.map +1 -1
- package/dist/navigation.js.map +1 -1
- package/dist/prerender-store.js.map +1 -1
- package/dist/render.js.map +1 -1
- package/dist/route-path.js.map +1 -1
- package/dist/route-shells.js.map +1 -1
- package/dist/route-source.js.map +1 -1
- package/dist/route-styles.js.map +1 -1
- package/dist/routes.js.map +1 -1
- package/dist/runtime-cache.js.map +1 -1
- package/dist/runtime-state.js.map +1 -1
- package/dist/security-headers.js.map +1 -1
- package/dist/serve.js.map +1 -1
- package/dist/session.js.map +1 -1
- package/dist/source-jsx.js.map +1 -1
- package/dist/source-modules.js.map +1 -1
- package/dist/stream-list.js.map +1 -1
- package/dist/trace.js.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/upgrade.js.map +1 -1
- package/dist/vite-config.js.map +1 -1
- package/dist/vite-plugin-cache-key.js.map +1 -1
- package/dist/vite.js.map +1 -1
- package/dist/workspace-packages.js.map +1 -1
- 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/routes.ts
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { join, relative, sep } from "node:path";
|
|
3
|
+
import { createNativeRouteMatcher } from "./native-route-matcher.js";
|
|
4
|
+
import {
|
|
5
|
+
appFileConventionForRootFilename,
|
|
6
|
+
type AppFileConvention,
|
|
7
|
+
} from "./file-conventions.js";
|
|
8
|
+
|
|
9
|
+
export interface ScanAppRoutesOptions {
|
|
10
|
+
appDir: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type AppRoute = AppAssetRoute | AppMetadataRoute | PageRoute | ServerRoute;
|
|
14
|
+
|
|
15
|
+
export interface PageRoute {
|
|
16
|
+
kind: "page";
|
|
17
|
+
path: string;
|
|
18
|
+
file: string;
|
|
19
|
+
segments: RouteSegment[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ServerRoute {
|
|
23
|
+
kind: "server";
|
|
24
|
+
path: string;
|
|
25
|
+
file: string;
|
|
26
|
+
segments: RouteSegment[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface AppMetadataRoute {
|
|
30
|
+
convention: AppFileConvention;
|
|
31
|
+
kind: "metadata";
|
|
32
|
+
path: string;
|
|
33
|
+
file: string;
|
|
34
|
+
segments: RouteSegment[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AppAssetRoute {
|
|
38
|
+
convention: AppFileConvention;
|
|
39
|
+
kind: "asset";
|
|
40
|
+
path: string;
|
|
41
|
+
file: string;
|
|
42
|
+
segments: RouteSegment[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type RouteSegment =
|
|
46
|
+
| { kind: "static"; value: string }
|
|
47
|
+
| { kind: "dynamic"; name: string }
|
|
48
|
+
| { kind: "catch-all"; name: string };
|
|
49
|
+
|
|
50
|
+
export interface MatchedRoute {
|
|
51
|
+
route: AppRoute;
|
|
52
|
+
params: Record<string, readonly string[] | string>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface RouteMatcher {
|
|
56
|
+
match(pathname: string): MatchedRoute | undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function scanAppRoutes(
|
|
60
|
+
options: ScanAppRoutesOptions,
|
|
61
|
+
): Promise<AppRoute[]> {
|
|
62
|
+
const files = await collectRouteFiles(options.appDir);
|
|
63
|
+
|
|
64
|
+
return files
|
|
65
|
+
.map((file): AppRoute => {
|
|
66
|
+
const relativeFile = relative(options.appDir, file);
|
|
67
|
+
const convention = appFileConventionForRelativeFile(relativeFile);
|
|
68
|
+
if (convention !== undefined) {
|
|
69
|
+
return {
|
|
70
|
+
convention: convention.convention,
|
|
71
|
+
file,
|
|
72
|
+
kind: convention.kind,
|
|
73
|
+
path: convention.path,
|
|
74
|
+
segments: segmentsFromPath(convention.path),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const kind = relativeFile.endsWith("route.ts") ? "server" : "page";
|
|
79
|
+
const routePath = routePathFromRelativeFile(relativeFile);
|
|
80
|
+
const segments = segmentsFromPath(routePath);
|
|
81
|
+
|
|
82
|
+
return { kind, path: routePath, file, segments };
|
|
83
|
+
})
|
|
84
|
+
.sort(compareRouteListEntries);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function matchRoute(
|
|
88
|
+
routes: readonly AppRoute[],
|
|
89
|
+
pathname: string,
|
|
90
|
+
): MatchedRoute | undefined {
|
|
91
|
+
return createRouteMatcher(routes).match(pathname);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function createRouteMatcher(routes: readonly AppRoute[]): RouteMatcher {
|
|
95
|
+
const sortedRoutes = [...routes].sort(compareRoutes);
|
|
96
|
+
const nativeMatcher = createNativeRouteMatcher(sortedRoutes);
|
|
97
|
+
|
|
98
|
+
if (nativeMatcher !== undefined) {
|
|
99
|
+
return nativeMatcher;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
match(pathname) {
|
|
104
|
+
return matchSortedRoutes(sortedRoutes, pathname);
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function matchSortedRoutes(
|
|
110
|
+
routes: readonly AppRoute[],
|
|
111
|
+
pathname: string,
|
|
112
|
+
): MatchedRoute | undefined {
|
|
113
|
+
const normalized = normalizePath(pathname);
|
|
114
|
+
const pathnameSegments = normalized === "/" ? [] : normalized.slice(1).split("/");
|
|
115
|
+
|
|
116
|
+
for (const route of routes) {
|
|
117
|
+
const catchAllIndex = route.segments.findIndex(
|
|
118
|
+
(segment) => segment.kind === "catch-all",
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
if (catchAllIndex === -1 && route.segments.length !== pathnameSegments.length) {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (catchAllIndex !== -1 && pathnameSegments.length < catchAllIndex + 1) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const params: Record<string, readonly string[] | string> = {};
|
|
130
|
+
let matched = true;
|
|
131
|
+
|
|
132
|
+
for (const [index, segment] of route.segments.entries()) {
|
|
133
|
+
const value = pathnameSegments[index];
|
|
134
|
+
|
|
135
|
+
if (value === undefined) {
|
|
136
|
+
matched = false;
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (segment.kind === "static" && segment.value !== value) {
|
|
141
|
+
matched = false;
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (segment.kind === "dynamic") {
|
|
146
|
+
const decoded = safeDecodeURIComponent(value);
|
|
147
|
+
if (decoded === undefined) {
|
|
148
|
+
// Malformed percent-encoding -- treat as non-match rather than
|
|
149
|
+
// letting URIError escape and produce a 500 (Issue 072).
|
|
150
|
+
matched = false;
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
params[segment.name] = decoded;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (segment.kind === "catch-all") {
|
|
157
|
+
const suffixSegments = route.segments.slice(index + 1);
|
|
158
|
+
const catchAllEnd = pathnameSegments.length - suffixSegments.length;
|
|
159
|
+
|
|
160
|
+
if (catchAllEnd <= index) {
|
|
161
|
+
matched = false;
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const decodedParts: string[] = [];
|
|
166
|
+
let decodeOk = true;
|
|
167
|
+
for (const part of pathnameSegments.slice(index, catchAllEnd)) {
|
|
168
|
+
const decoded = safeDecodeURIComponent(part);
|
|
169
|
+
if (decoded === undefined) {
|
|
170
|
+
decodeOk = false;
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
decodedParts.push(decoded);
|
|
174
|
+
}
|
|
175
|
+
if (!decodeOk) {
|
|
176
|
+
matched = false;
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
params[segment.name] = decodedParts;
|
|
180
|
+
|
|
181
|
+
for (let suffixIndex = 0; suffixIndex < suffixSegments.length; suffixIndex += 1) {
|
|
182
|
+
const suffixSegment = suffixSegments[suffixIndex];
|
|
183
|
+
const suffixValue = pathnameSegments[catchAllEnd + suffixIndex];
|
|
184
|
+
|
|
185
|
+
if (suffixSegment === undefined || suffixValue === undefined) {
|
|
186
|
+
matched = false;
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (suffixSegment.kind === "static" && suffixSegment.value !== suffixValue) {
|
|
191
|
+
matched = false;
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (suffixSegment.kind === "dynamic") {
|
|
196
|
+
const decoded = safeDecodeURIComponent(suffixValue);
|
|
197
|
+
if (decoded === undefined) {
|
|
198
|
+
matched = false;
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
params[suffixSegment.name] = decoded;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (suffixSegment.kind === "catch-all") {
|
|
205
|
+
matched = false;
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (matched) {
|
|
215
|
+
return { route, params };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Issue 072: attacker-supplied path segments / cookie values can include
|
|
223
|
+
// malformed percent escapes (e.g. `%ZZ`, `%E0`). Catch the URIError so a
|
|
224
|
+
// single bad byte cannot turn every request into a 500 + stack-leaking
|
|
225
|
+
// response.
|
|
226
|
+
export function safeDecodeURIComponent(value: string): string | undefined {
|
|
227
|
+
try {
|
|
228
|
+
return decodeURIComponent(value);
|
|
229
|
+
} catch {
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function collectRouteFiles(directory: string, rootDirectory = directory): Promise<string[]> {
|
|
235
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
236
|
+
const files: string[] = [];
|
|
237
|
+
|
|
238
|
+
for (const entry of entries) {
|
|
239
|
+
const path = join(directory, entry.name);
|
|
240
|
+
|
|
241
|
+
if (entry.isDirectory()) {
|
|
242
|
+
files.push(...(await collectRouteFiles(path, rootDirectory)));
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (
|
|
247
|
+
entry.isFile() &&
|
|
248
|
+
(entry.name === "page.tsx" ||
|
|
249
|
+
entry.name === "page.mreact.tsx" ||
|
|
250
|
+
entry.name === "route.ts" ||
|
|
251
|
+
appFileConventionForRelativeFile(relative(rootDirectory, path)) !== undefined)
|
|
252
|
+
) {
|
|
253
|
+
files.push(path);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return files;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function appFileConventionForRelativeFile(
|
|
261
|
+
relativeFile: string,
|
|
262
|
+
): ReturnType<typeof appFileConventionForRootFilename> {
|
|
263
|
+
const parts = relativeFile.split(sep);
|
|
264
|
+
const filename = parts.at(-1);
|
|
265
|
+
|
|
266
|
+
if (filename === undefined) {
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const convention = appFileConventionForRootFilename(filename);
|
|
271
|
+
|
|
272
|
+
if (convention === undefined) {
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (parts.length === 1) {
|
|
277
|
+
return convention;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (convention.kind !== "metadata" || convention.convention !== "opengraph-image") {
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const routeParts = parts
|
|
285
|
+
.slice(0, -1)
|
|
286
|
+
.filter((part) => !isRouteGroup(part))
|
|
287
|
+
.map((part) => (part.startsWith("$") ? `:${part.slice(1)}` : part));
|
|
288
|
+
const conventionPath = convention.path.startsWith("/")
|
|
289
|
+
? convention.path.slice(1)
|
|
290
|
+
: convention.path;
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
...convention,
|
|
294
|
+
path: normalizePath(`/${[...routeParts, conventionPath].join("/")}`),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function routePathFromRelativeFile(relativeFile: string): string {
|
|
299
|
+
const parts = relativeFile.split(sep);
|
|
300
|
+
const routeParts = parts
|
|
301
|
+
.slice(0, -1)
|
|
302
|
+
.filter((part) => !isRouteGroup(part))
|
|
303
|
+
.map((part) => (part.startsWith("$") ? `:${part.slice(1)}` : part));
|
|
304
|
+
|
|
305
|
+
return normalizePath(`/${routeParts.join("/")}`);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function segmentsFromPath(path: string): RouteSegment[] {
|
|
309
|
+
return path === "/"
|
|
310
|
+
? []
|
|
311
|
+
: path
|
|
312
|
+
.slice(1)
|
|
313
|
+
.split("/")
|
|
314
|
+
.map((part) =>
|
|
315
|
+
part.startsWith(":...")
|
|
316
|
+
? { kind: "catch-all", name: part.slice(4) }
|
|
317
|
+
: part.startsWith(":")
|
|
318
|
+
? { kind: "dynamic", name: part.slice(1) }
|
|
319
|
+
: { kind: "static", value: part },
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function normalizePath(path: string): string {
|
|
324
|
+
const withoutTrailing = path.length > 1 ? path.replace(/\/+$/, "") : path;
|
|
325
|
+
|
|
326
|
+
return withoutTrailing === "" ? "/" : withoutTrailing;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function isRouteGroup(part: string): boolean {
|
|
330
|
+
return part.startsWith("(") && part.endsWith(")");
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function compareRoutes(a: AppRoute, b: AppRoute): number {
|
|
334
|
+
const scoreDelta = routeScore(b) - routeScore(a);
|
|
335
|
+
|
|
336
|
+
return scoreDelta === 0 ? a.path.localeCompare(b.path) || a.kind.localeCompare(b.kind) : scoreDelta;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function compareRouteListEntries(a: AppRoute, b: AppRoute): number {
|
|
340
|
+
return routeListKey(a.path).localeCompare(routeListKey(b.path)) ||
|
|
341
|
+
a.kind.localeCompare(b.kind);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function routeListKey(path: string): string {
|
|
345
|
+
return path === "/"
|
|
346
|
+
? ""
|
|
347
|
+
: path.replaceAll("/:...", "/zzzz-catch-all-").replaceAll("/:", "/zzzz-dynamic-");
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function routeScore(route: AppRoute): number {
|
|
351
|
+
return route.segments.reduce((score, segment) => {
|
|
352
|
+
if (segment.kind === "static") {
|
|
353
|
+
return score + 100;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (segment.kind === "dynamic") {
|
|
357
|
+
return score + 10;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return score;
|
|
361
|
+
}, route.segments.length);
|
|
362
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { routerModuleRunnerRuntimeCacheStats } from "./module-runner.js";
|
|
2
|
+
import { routerRenderRuntimeCacheStats } from "./render.js";
|
|
3
|
+
import type { RouterRuntimeCacheStat } from "./cache-stats.js";
|
|
4
|
+
export type { RouterRuntimeCacheStat } from "./cache-stats.js";
|
|
5
|
+
|
|
6
|
+
export function getRouterRuntimeCacheStats(): RouterRuntimeCacheStat[] {
|
|
7
|
+
return [...routerRenderRuntimeCacheStats(), ...routerModuleRunnerRuntimeCacheStats()];
|
|
8
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
type RuntimeGlobal = typeof globalThis & Record<string, unknown>;
|
|
2
|
+
type RuntimeStateRoot = Record<string, unknown>;
|
|
3
|
+
|
|
4
|
+
const namespace = "__mreactRouterRuntimeState";
|
|
5
|
+
|
|
6
|
+
export function getServerRuntimeState<TState extends object>(
|
|
7
|
+
key: string,
|
|
8
|
+
create: () => TState,
|
|
9
|
+
): TState {
|
|
10
|
+
const global = globalThis as RuntimeGlobal;
|
|
11
|
+
const root = getOrCreateRoot(global);
|
|
12
|
+
const existing = root[key];
|
|
13
|
+
|
|
14
|
+
if (existing !== undefined) {
|
|
15
|
+
return existing as TState;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const state = create();
|
|
19
|
+
root[key] = state;
|
|
20
|
+
return state;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getOrCreateRoot(global: RuntimeGlobal): RuntimeStateRoot {
|
|
24
|
+
const existing = global[namespace];
|
|
25
|
+
|
|
26
|
+
if (isRuntimeGlobal(existing)) {
|
|
27
|
+
return existing;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const root: RuntimeStateRoot = {};
|
|
31
|
+
global[namespace] = root;
|
|
32
|
+
return root;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isRuntimeGlobal(value: unknown): value is RuntimeStateRoot {
|
|
36
|
+
return typeof value === "object" && value !== null;
|
|
37
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { RouteSecurityHeaders } from "./types.js";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_SECURITY_HEADERS = Object.freeze({
|
|
4
|
+
"permissions-policy": "camera=(), microphone=(), geolocation=()",
|
|
5
|
+
"referrer-policy": "strict-origin-when-cross-origin",
|
|
6
|
+
"x-content-type-options": "nosniff",
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export function routeSecurityHeaders(options: {
|
|
10
|
+
request: Request;
|
|
11
|
+
security: RouteSecurityHeaders | undefined;
|
|
12
|
+
}): Record<string, string> {
|
|
13
|
+
const headers: Record<string, string> = { ...DEFAULT_SECURITY_HEADERS };
|
|
14
|
+
const security = options.security;
|
|
15
|
+
|
|
16
|
+
if (security?.contentTypeOptions === null) {
|
|
17
|
+
delete headers["x-content-type-options"];
|
|
18
|
+
} else {
|
|
19
|
+
headers["x-content-type-options"] = validateHeaderValue(
|
|
20
|
+
security?.contentTypeOptions ?? "nosniff",
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (security?.referrerPolicy === null) {
|
|
25
|
+
delete headers["referrer-policy"];
|
|
26
|
+
} else {
|
|
27
|
+
headers["referrer-policy"] = validateHeaderValue(
|
|
28
|
+
security?.referrerPolicy ?? "strict-origin-when-cross-origin",
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (security?.permissionsPolicy === null) {
|
|
33
|
+
delete headers["permissions-policy"];
|
|
34
|
+
} else {
|
|
35
|
+
const permissionsPolicy = serializePermissionsPolicy(security?.permissionsPolicy);
|
|
36
|
+
if (permissionsPolicy === undefined) {
|
|
37
|
+
delete headers["permissions-policy"];
|
|
38
|
+
} else {
|
|
39
|
+
headers["permissions-policy"] = permissionsPolicy;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (security?.frameOptions === null) {
|
|
44
|
+
delete headers["x-frame-options"];
|
|
45
|
+
} else if (security?.frameOptions !== undefined) {
|
|
46
|
+
headers["x-frame-options"] = validateHeaderValue(security.frameOptions);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (
|
|
50
|
+
options.request.url.startsWith("https://") &&
|
|
51
|
+
security?.hsts !== undefined &&
|
|
52
|
+
security.hsts !== false &&
|
|
53
|
+
security.hsts !== null
|
|
54
|
+
) {
|
|
55
|
+
headers["strict-transport-security"] = serializeHsts(security.hsts);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return headers;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function serializeHsts(hsts: NonNullable<RouteSecurityHeaders["hsts"]>): string {
|
|
62
|
+
if (hsts === false) {
|
|
63
|
+
throw new TypeError("Invalid security header value for hsts.");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const maxAge = Math.trunc(hsts.maxAge);
|
|
67
|
+
if (!Number.isFinite(maxAge) || maxAge < 0) {
|
|
68
|
+
throw new TypeError("Invalid security header value for hsts.maxAge.");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const parts = [`max-age=${maxAge}`];
|
|
72
|
+
if (hsts.includeSubDomains === true) {
|
|
73
|
+
parts.push("includeSubDomains");
|
|
74
|
+
}
|
|
75
|
+
if (hsts.preload === true) {
|
|
76
|
+
parts.push("preload");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return parts.join("; ");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function serializePermissionsPolicy(
|
|
83
|
+
policy: NonNullable<RouteSecurityHeaders["permissionsPolicy"]> | undefined,
|
|
84
|
+
): string | undefined {
|
|
85
|
+
if (policy === undefined) {
|
|
86
|
+
return DEFAULT_SECURITY_HEADERS["permissions-policy"];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const directives: string[] = [];
|
|
90
|
+
for (const [directive, allowlist] of Object.entries(policy)) {
|
|
91
|
+
if (allowlist === null || allowlist === undefined) {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
validateToken(directive, "permissionsPolicy directive");
|
|
96
|
+
for (const value of allowlist) {
|
|
97
|
+
validatePermissionAllowlistValue(value);
|
|
98
|
+
}
|
|
99
|
+
directives.push(`${directive}=(${allowlist.join(" ")})`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return directives.length === 0 ? undefined : directives.join(", ");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function validateHeaderValue(value: string): string {
|
|
106
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
107
|
+
const code = value.charCodeAt(index);
|
|
108
|
+
if (code <= 0x1f || code === 0x7f) {
|
|
109
|
+
throw new TypeError(`Invalid security header value: ${JSON.stringify(value)}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return value;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function validateToken(value: string, label: string): void {
|
|
117
|
+
if (!/^[A-Za-z][A-Za-z0-9-]*$/.test(value)) {
|
|
118
|
+
throw new TypeError(`Invalid security header value for ${label}: ${JSON.stringify(value)}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function validatePermissionAllowlistValue(value: string): void {
|
|
123
|
+
if (value === "self" || value === "*" || /^[A-Za-z][A-Za-z0-9+.-]*:$/.test(value)) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (/^https:\/\/[A-Za-z0-9.-]+(?::[0-9]+)?$/.test(value)) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
throw new TypeError(
|
|
132
|
+
`Invalid security header value for permissionsPolicy allowlist: ${JSON.stringify(value)}`,
|
|
133
|
+
);
|
|
134
|
+
}
|