@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
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import type { AppRoute, MatchedRoute, RouteMatcher } from "./routes.js";
|
|
5
|
+
|
|
6
|
+
interface NativeRouteMatcherInstance {
|
|
7
|
+
matchRoute(pathname: string): NativeMatchOutput | null | undefined;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface NativeRouteMatcherConstructor {
|
|
11
|
+
new (routesJson: string): NativeRouteMatcherInstance;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface NativeRouteMatcherModule {
|
|
15
|
+
NativeRouteMatcher?: NativeRouteMatcherConstructor | undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface NativeMatchOutput {
|
|
19
|
+
index: number;
|
|
20
|
+
params: Record<string, string>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let loadedNativeModule: NativeRouteMatcherModule | false | undefined;
|
|
24
|
+
const nativeRouteMatcherAutoThreshold = 100;
|
|
25
|
+
|
|
26
|
+
export function createNativeRouteMatcher(
|
|
27
|
+
sortedRoutes: readonly AppRoute[],
|
|
28
|
+
): RouteMatcher | undefined {
|
|
29
|
+
if (
|
|
30
|
+
!shouldUseNativeRouteMatcher(
|
|
31
|
+
sortedRoutes.length,
|
|
32
|
+
process.env.MREACT_APP_ROUTER_NATIVE_ROUTE_MATCHER,
|
|
33
|
+
)
|
|
34
|
+
) {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const nativeModule = loadNativeRouteMatcherModule();
|
|
39
|
+
const NativeRouteMatcher = nativeModule === false
|
|
40
|
+
? undefined
|
|
41
|
+
: nativeModule.NativeRouteMatcher;
|
|
42
|
+
|
|
43
|
+
if (NativeRouteMatcher === undefined) {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const nativeRoutes = sortedRoutes.map((route, index) => ({
|
|
48
|
+
index,
|
|
49
|
+
segments: route.segments,
|
|
50
|
+
}));
|
|
51
|
+
const matcher = new NativeRouteMatcher(JSON.stringify(nativeRoutes));
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
match(pathname): MatchedRoute | undefined {
|
|
55
|
+
const output = matcher.matchRoute(pathname);
|
|
56
|
+
|
|
57
|
+
if (output == null) {
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const route = sortedRoutes[output.index];
|
|
62
|
+
|
|
63
|
+
return route === undefined
|
|
64
|
+
? undefined
|
|
65
|
+
: {
|
|
66
|
+
route,
|
|
67
|
+
params: normalizeNativeParams(route, output.params),
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function normalizeNativeParams(
|
|
74
|
+
route: AppRoute,
|
|
75
|
+
params: Record<string, string>,
|
|
76
|
+
): MatchedRoute["params"] {
|
|
77
|
+
const normalized: MatchedRoute["params"] = { ...params };
|
|
78
|
+
|
|
79
|
+
for (const segment of route.segments) {
|
|
80
|
+
if (segment.kind !== "catch-all") {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const value = normalized[segment.name];
|
|
84
|
+
if (typeof value === "string") {
|
|
85
|
+
normalized[segment.name] = value.split("/").filter((part: string) => part !== "");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return normalized;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function shouldUseNativeRouteMatcher(
|
|
93
|
+
routeCount: number,
|
|
94
|
+
mode: string | undefined,
|
|
95
|
+
): boolean {
|
|
96
|
+
if (mode === "1" || mode === "true") {
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (mode === "0" || mode === "false") {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return routeCount >= nativeRouteMatcherAutoThreshold;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function loadNativeRouteMatcherModule(): NativeRouteMatcherModule | false {
|
|
108
|
+
if (loadedNativeModule !== undefined) {
|
|
109
|
+
return loadedNativeModule;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const require = nativePackageRequire();
|
|
113
|
+
|
|
114
|
+
if (require === undefined) {
|
|
115
|
+
loadedNativeModule = false;
|
|
116
|
+
return loadedNativeModule;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const candidate of nativeModuleCandidates()) {
|
|
120
|
+
try {
|
|
121
|
+
loadedNativeModule = require(candidate) as NativeRouteMatcherModule;
|
|
122
|
+
return loadedNativeModule;
|
|
123
|
+
} catch {
|
|
124
|
+
// Native package is optional. The JS matcher remains the portable fallback.
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
loadedNativeModule = false;
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function nativePackageRequire(): ReturnType<typeof createRequire> | undefined {
|
|
133
|
+
try {
|
|
134
|
+
return new URL(import.meta.url).protocol === "file:" ? createRequire(import.meta.url) : undefined;
|
|
135
|
+
} catch {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function nativeModuleCandidates(): string[] {
|
|
141
|
+
const currentDir = dirname(fileURLToPath(import.meta.url));
|
|
142
|
+
const workspaceNativePackage = join(currentDir, "..", "..", "router-native");
|
|
143
|
+
|
|
144
|
+
return [
|
|
145
|
+
...nativeModulePackageCandidates(process.platform, process.arch),
|
|
146
|
+
workspaceNativePackage,
|
|
147
|
+
];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function nativeModulePackageCandidates(platform: NodeJS.Platform, arch: string): string[] {
|
|
151
|
+
const platformPackage = nativePlatformPackageName(platform, arch);
|
|
152
|
+
|
|
153
|
+
return [
|
|
154
|
+
...(platformPackage === undefined ? [] : [platformPackage]),
|
|
155
|
+
"@reckona/mreact-router-native",
|
|
156
|
+
];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function nativePlatformPackageName(platform: NodeJS.Platform, arch: string): string | undefined {
|
|
160
|
+
if (platform === "linux" && arch === "x64") {
|
|
161
|
+
return "@reckona/mreact-router-native-linux-x64-gnu";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (platform === "darwin" && arch === "arm64") {
|
|
165
|
+
return "@reckona/mreact-router-native-darwin-arm64";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (platform === "win32" && arch === "x64") {
|
|
169
|
+
return "@reckona/mreact-router-native-win32-x64-msvc";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
export type AppRouterNavigationType = "push" | "replace" | "pop" | "refresh";
|
|
2
|
+
|
|
3
|
+
export interface AppRouterNavigationState {
|
|
4
|
+
pending: boolean;
|
|
5
|
+
from: string | null;
|
|
6
|
+
to: string | null;
|
|
7
|
+
type: AppRouterNavigationType | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type AppRouterNavigationStateListener = (
|
|
11
|
+
state: AppRouterNavigationState,
|
|
12
|
+
) => void;
|
|
13
|
+
|
|
14
|
+
const idleNavigationState: AppRouterNavigationState = {
|
|
15
|
+
from: null,
|
|
16
|
+
pending: false,
|
|
17
|
+
to: null,
|
|
18
|
+
type: null,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function getNavigationState(): AppRouterNavigationState {
|
|
22
|
+
const runtimeState = (globalThis as {
|
|
23
|
+
__mreactNavigationState?: { current?: unknown };
|
|
24
|
+
}).__mreactNavigationState?.current;
|
|
25
|
+
const normalized = normalizeNavigationState(runtimeState);
|
|
26
|
+
|
|
27
|
+
if (normalized !== undefined) {
|
|
28
|
+
return normalized;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return navigationStateFromDocument();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function subscribeNavigationState(
|
|
35
|
+
listener: AppRouterNavigationStateListener,
|
|
36
|
+
): () => void {
|
|
37
|
+
if (typeof window === "undefined") {
|
|
38
|
+
return () => undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const handleNavigationStateChange = (event: Event) => {
|
|
42
|
+
const state = normalizeNavigationState((event as CustomEvent<unknown>).detail);
|
|
43
|
+
|
|
44
|
+
if (state !== undefined) {
|
|
45
|
+
listener(state);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
window.addEventListener("mreact:navigation-state-change", handleNavigationStateChange);
|
|
50
|
+
|
|
51
|
+
return () => {
|
|
52
|
+
window.removeEventListener("mreact:navigation-state-change", handleNavigationStateChange);
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function navigationStateFromDocument(): AppRouterNavigationState {
|
|
57
|
+
if (typeof document === "undefined") {
|
|
58
|
+
return { ...idleNavigationState };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const root = document.documentElement;
|
|
62
|
+
|
|
63
|
+
if (root.getAttribute("data-mreact-navigation-pending") !== "true") {
|
|
64
|
+
return { ...idleNavigationState };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
from: root.getAttribute("data-mreact-navigation-from"),
|
|
69
|
+
pending: true,
|
|
70
|
+
to: root.getAttribute("data-mreact-navigation-to"),
|
|
71
|
+
type: navigationType(root.getAttribute("data-mreact-navigation-type")),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalizeNavigationState(value: unknown): AppRouterNavigationState | undefined {
|
|
76
|
+
if (value === null || typeof value !== "object") {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const state = value as Partial<AppRouterNavigationState>;
|
|
81
|
+
|
|
82
|
+
if (typeof state.pending !== "boolean") {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
from: typeof state.from === "string" ? state.from : null,
|
|
88
|
+
pending: state.pending,
|
|
89
|
+
to: typeof state.to === "string" ? state.to : null,
|
|
90
|
+
type: navigationType(state.type),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function navigationType(value: unknown): AppRouterNavigationType | null {
|
|
95
|
+
return value === "push" || value === "replace" || value === "pop" || value === "refresh"
|
|
96
|
+
? value
|
|
97
|
+
: null;
|
|
98
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { parseCookieHeader } from "./cookies.js";
|
|
2
|
+
|
|
3
|
+
const redirectErrorName = "MReactRedirect";
|
|
4
|
+
const notFoundErrorName = "MReactNotFound";
|
|
5
|
+
const rewriteHeaderName = "x-mreact-rewrite";
|
|
6
|
+
const rewriteLocationSymbol = Symbol.for("mreact.router.rewriteLocation");
|
|
7
|
+
|
|
8
|
+
export interface RedirectOptions {
|
|
9
|
+
status?: 301 | 302 | 303 | 307 | 308;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type MiddlewareNext = undefined;
|
|
13
|
+
|
|
14
|
+
export interface ParseSchema<T> {
|
|
15
|
+
parse(value: FormData): T;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Strip leading C0 controls + ASCII whitespace per WHATWG URL parsing.
|
|
19
|
+
// Browsers ignore these characters when resolving the Location header,
|
|
20
|
+
// so attacker payloads must be rejected after the same normalization.
|
|
21
|
+
function stripLeadingControlOrWhitespace(value: string): string {
|
|
22
|
+
let start = 0;
|
|
23
|
+
|
|
24
|
+
while (start < value.length && value.charCodeAt(start) <= 0x20) {
|
|
25
|
+
start += 1;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return value.slice(start);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Returns true if `location` is safe to use as a same-origin Location header.
|
|
32
|
+
// Allowed: path-absolute (`/foo`), query-only (`?x=1`), hash-only (`#x`),
|
|
33
|
+
// relative (`foo`). Rejected: protocol-relative (`//evil`), backslash variants
|
|
34
|
+
// (`/\evil`, `\\evil`), and anything with a scheme like `javascript:`.
|
|
35
|
+
function isSafeInternalRedirect(location: string): boolean {
|
|
36
|
+
const trimmed = stripLeadingControlOrWhitespace(location);
|
|
37
|
+
if (trimmed === "") return false;
|
|
38
|
+
if (trimmed.startsWith("//")) return false;
|
|
39
|
+
if (trimmed.startsWith("/\\")) return false;
|
|
40
|
+
if (trimmed.startsWith("\\")) return false;
|
|
41
|
+
// Reject anything that parses as a URL with a scheme.
|
|
42
|
+
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) return false;
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isSafeExternalRedirect(location: string): boolean {
|
|
47
|
+
const trimmed = stripLeadingControlOrWhitespace(location);
|
|
48
|
+
const match = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(trimmed);
|
|
49
|
+
if (match === null || match[1] === undefined) return false;
|
|
50
|
+
const scheme = match[1].toLowerCase();
|
|
51
|
+
return scheme === "http" || scheme === "https";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function throwUnsafeRedirect(location: string): never {
|
|
55
|
+
throw new TypeError(
|
|
56
|
+
`unsafe redirect target: ${JSON.stringify(location)} - use redirectExternal() for off-site destinations and ensure the URL is http(s)`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function redirect(location: string, options: RedirectOptions = {}): never {
|
|
61
|
+
if (!isSafeInternalRedirect(location)) {
|
|
62
|
+
throwUnsafeRedirect(location);
|
|
63
|
+
}
|
|
64
|
+
throw Object.assign(new Error(`Redirect to ${location}`), {
|
|
65
|
+
location,
|
|
66
|
+
name: redirectErrorName,
|
|
67
|
+
status: options.status ?? 303,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function redirectExternal(
|
|
72
|
+
location: string,
|
|
73
|
+
options: RedirectOptions = {},
|
|
74
|
+
): never {
|
|
75
|
+
if (!isSafeExternalRedirect(location)) {
|
|
76
|
+
throwUnsafeRedirect(location);
|
|
77
|
+
}
|
|
78
|
+
throw Object.assign(new Error(`Redirect to ${location}`), {
|
|
79
|
+
location,
|
|
80
|
+
name: redirectErrorName,
|
|
81
|
+
status: options.status ?? 307,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function redirect303(location: string, init: ResponseInit = {}): Response {
|
|
86
|
+
if (!isSafeInternalRedirect(location)) {
|
|
87
|
+
throwUnsafeRedirect(location);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const headers = new Headers(init.headers);
|
|
91
|
+
headers.set("location", location);
|
|
92
|
+
|
|
93
|
+
return new Response(null, {
|
|
94
|
+
...init,
|
|
95
|
+
headers,
|
|
96
|
+
status: 303,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function textError(message: string, status = 400, init: ResponseInit = {}): Response {
|
|
101
|
+
const headers = new Headers(init.headers);
|
|
102
|
+
|
|
103
|
+
if (!headers.has("content-type")) {
|
|
104
|
+
headers.set("content-type", "text/plain; charset=utf-8");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return new Response(message, {
|
|
108
|
+
...init,
|
|
109
|
+
headers,
|
|
110
|
+
status,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function parseForm(request: Request): Promise<FormData>;
|
|
115
|
+
export async function parseForm<T>(request: Request, schema: ParseSchema<T>): Promise<T>;
|
|
116
|
+
export async function parseForm<T>(
|
|
117
|
+
request: Request,
|
|
118
|
+
schema?: ParseSchema<T>,
|
|
119
|
+
): Promise<FormData | T> {
|
|
120
|
+
const form = await request.formData();
|
|
121
|
+
|
|
122
|
+
return schema === undefined ? form : schema.parse(form);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function notFound(): never {
|
|
126
|
+
throw Object.assign(new Error("Not Found"), {
|
|
127
|
+
name: notFoundErrorName,
|
|
128
|
+
status: 404,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function next(): MiddlewareNext {
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function rewrite(location: string, init: ResponseInit = {}): Response {
|
|
137
|
+
const response = new Response(null, {
|
|
138
|
+
...init,
|
|
139
|
+
status: init.status ?? 200,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
Object.defineProperty(response, rewriteLocationSymbol, {
|
|
143
|
+
configurable: false,
|
|
144
|
+
enumerable: false,
|
|
145
|
+
value: location,
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
return response;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function json(value: unknown, init?: ResponseInit): Response {
|
|
152
|
+
return Response.json(value, init);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function html(value: string, init: ResponseInit = {}): Response {
|
|
156
|
+
if (init.headers === undefined) {
|
|
157
|
+
return new Response(value, {
|
|
158
|
+
...init,
|
|
159
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const headers = new Headers(init.headers);
|
|
164
|
+
|
|
165
|
+
if (!headers.has("content-type")) {
|
|
166
|
+
headers.set("content-type", "text/html; charset=utf-8");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return new Response(value, {
|
|
170
|
+
...init,
|
|
171
|
+
headers,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function headers(request: Request): Headers {
|
|
176
|
+
return request.headers;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface RequestCookies {
|
|
180
|
+
entries(): IterableIterator<[string, string]>;
|
|
181
|
+
get(name: string): string | undefined;
|
|
182
|
+
has(name: string): boolean;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function cookies(request: Request): RequestCookies {
|
|
186
|
+
let values: ReadonlyMap<string, string> | undefined;
|
|
187
|
+
const cookieValues = () => {
|
|
188
|
+
values ??= parseCookieHeader(request.headers.get("cookie"));
|
|
189
|
+
return values;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
entries: () => cookieValues().entries(),
|
|
194
|
+
get: (name) => cookieValues().get(name),
|
|
195
|
+
has: (name) => cookieValues().has(name),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function rewriteLocation(response: Response): string | undefined {
|
|
200
|
+
const marked = (response as { [rewriteLocationSymbol]?: unknown })[rewriteLocationSymbol];
|
|
201
|
+
|
|
202
|
+
return typeof marked === "string"
|
|
203
|
+
? marked
|
|
204
|
+
: response.headers.get(rewriteHeaderName) ?? undefined;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function isRedirectError(error: unknown): error is Error & { location: string; status: number } {
|
|
208
|
+
return error instanceof Error &&
|
|
209
|
+
error.name === redirectErrorName &&
|
|
210
|
+
typeof (error as { location?: unknown }).location === "string";
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function isNotFoundError(error: unknown): boolean {
|
|
214
|
+
return error instanceof Error && error.name === notFoundErrorName;
|
|
215
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import type { BuiltPrerenderedRoute } from "./build.js";
|
|
5
|
+
import type { AppRouterPrerenderStore } from "./serve.js";
|
|
6
|
+
|
|
7
|
+
export interface MemoryPrerenderStoreOptions {
|
|
8
|
+
backing?: Map<string, MemoryPrerenderStoreEntry>;
|
|
9
|
+
maxEntries?: number;
|
|
10
|
+
namespace?: string;
|
|
11
|
+
now?: () => number;
|
|
12
|
+
ttlMs?: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface MemoryPrerenderStoreEntry {
|
|
16
|
+
entry: BuiltPrerenderedRoute;
|
|
17
|
+
expiresAt: number;
|
|
18
|
+
lastAccessedAt: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface FileSystemPrerenderStoreOptions {
|
|
22
|
+
directory: string;
|
|
23
|
+
lockPollMs?: number;
|
|
24
|
+
lockTimeoutMs?: number;
|
|
25
|
+
namespace?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface KeyValuePrerenderStoreAdapter {
|
|
29
|
+
delete(key: string): void | Promise<void>;
|
|
30
|
+
get(key: string): string | undefined | Promise<string | undefined>;
|
|
31
|
+
set(key: string, value: string, options?: { ttlMs?: number | undefined }): void | Promise<void>;
|
|
32
|
+
withLock?<T>(key: string, task: (token: string) => Promise<T>): Promise<T>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface KeyValuePrerenderStoreOptions {
|
|
36
|
+
adapter: KeyValuePrerenderStoreAdapter;
|
|
37
|
+
namespace?: string;
|
|
38
|
+
ttlMs?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createMemoryPrerenderStore(
|
|
42
|
+
options: MemoryPrerenderStoreOptions = {},
|
|
43
|
+
): AppRouterPrerenderStore {
|
|
44
|
+
const backing = options.backing ?? new Map<string, MemoryPrerenderStoreEntry>();
|
|
45
|
+
const locks = new Map<string, Promise<void>>();
|
|
46
|
+
const namespace = options.namespace ?? "default";
|
|
47
|
+
const now = options.now ?? Date.now;
|
|
48
|
+
const maxEntries = options.maxEntries ?? Number.POSITIVE_INFINITY;
|
|
49
|
+
const ttlMs = options.ttlMs ?? Number.POSITIVE_INFINITY;
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
delete(path) {
|
|
53
|
+
backing.delete(storeKey(namespace, path));
|
|
54
|
+
},
|
|
55
|
+
get(path) {
|
|
56
|
+
const key = storeKey(namespace, path);
|
|
57
|
+
const current = backing.get(key);
|
|
58
|
+
|
|
59
|
+
if (current === undefined) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (current.expiresAt <= now()) {
|
|
64
|
+
backing.delete(key);
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
current.lastAccessedAt = now();
|
|
69
|
+
return current.entry;
|
|
70
|
+
},
|
|
71
|
+
set(path, entry) {
|
|
72
|
+
backing.set(storeKey(namespace, path), {
|
|
73
|
+
entry,
|
|
74
|
+
expiresAt: now() + ttlMs,
|
|
75
|
+
lastAccessedAt: now(),
|
|
76
|
+
});
|
|
77
|
+
evictLeastRecentlyUsed(backing, namespace, maxEntries);
|
|
78
|
+
},
|
|
79
|
+
async withLock(path, task) {
|
|
80
|
+
const key = storeKey(namespace, path);
|
|
81
|
+
const previous = locks.get(key) ?? Promise.resolve();
|
|
82
|
+
let release: () => void = () => undefined;
|
|
83
|
+
const current = new Promise<void>((resolve) => {
|
|
84
|
+
release = resolve;
|
|
85
|
+
});
|
|
86
|
+
const queued = previous.then(() => current, () => current);
|
|
87
|
+
locks.set(key, queued);
|
|
88
|
+
|
|
89
|
+
await previous.catch(() => undefined);
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
return await task();
|
|
93
|
+
} finally {
|
|
94
|
+
release();
|
|
95
|
+
if (locks.get(key) === queued) {
|
|
96
|
+
locks.delete(key);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function createFileSystemPrerenderStore(
|
|
104
|
+
options: FileSystemPrerenderStoreOptions,
|
|
105
|
+
): AppRouterPrerenderStore {
|
|
106
|
+
const namespace = options.namespace ?? "default";
|
|
107
|
+
const lockPollMs = options.lockPollMs ?? 5;
|
|
108
|
+
const lockTimeoutMs = options.lockTimeoutMs ?? 5_000;
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
async delete(path) {
|
|
112
|
+
await rm(filePath(options.directory, namespace, path), { force: true });
|
|
113
|
+
},
|
|
114
|
+
async get(path) {
|
|
115
|
+
try {
|
|
116
|
+
return JSON.parse(
|
|
117
|
+
await readFile(filePath(options.directory, namespace, path), "utf8"),
|
|
118
|
+
) as BuiltPrerenderedRoute;
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (isNodeError(error, "ENOENT")) {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
async set(path, entry) {
|
|
128
|
+
const target = filePath(options.directory, namespace, path);
|
|
129
|
+
const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
130
|
+
|
|
131
|
+
await mkdir(dirname(target), { recursive: true });
|
|
132
|
+
await writeFile(temporary, JSON.stringify(entry), "utf8");
|
|
133
|
+
await rename(temporary, target);
|
|
134
|
+
},
|
|
135
|
+
async withLock(path, task) {
|
|
136
|
+
const lock = `${filePath(options.directory, namespace, path)}.lock`;
|
|
137
|
+
const startedAt = Date.now();
|
|
138
|
+
|
|
139
|
+
while (true) {
|
|
140
|
+
try {
|
|
141
|
+
await mkdir(lock, { recursive: false });
|
|
142
|
+
break;
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (!isNodeError(error, "EEXIST")) {
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (Date.now() - startedAt > lockTimeoutMs) {
|
|
149
|
+
throw new Error(`Timed out acquiring prerender lock for ${path}.`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
await new Promise((resolve) => setTimeout(resolve, lockPollMs));
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
return await task();
|
|
158
|
+
} finally {
|
|
159
|
+
await rm(lock, { force: true, recursive: true });
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function createKeyValuePrerenderStore(
|
|
166
|
+
options: KeyValuePrerenderStoreOptions,
|
|
167
|
+
): AppRouterPrerenderStore {
|
|
168
|
+
const namespace = options.namespace ?? "default";
|
|
169
|
+
const store: AppRouterPrerenderStore = {
|
|
170
|
+
delete(path) {
|
|
171
|
+
return options.adapter.delete(keyValueStoreKey(namespace, path));
|
|
172
|
+
},
|
|
173
|
+
async get(path) {
|
|
174
|
+
const value = await options.adapter.get(keyValueStoreKey(namespace, path));
|
|
175
|
+
|
|
176
|
+
return value === undefined ? undefined : JSON.parse(value) as BuiltPrerenderedRoute;
|
|
177
|
+
},
|
|
178
|
+
set(path, entry) {
|
|
179
|
+
return options.adapter.set(
|
|
180
|
+
keyValueStoreKey(namespace, path),
|
|
181
|
+
JSON.stringify(entry),
|
|
182
|
+
{ ttlMs: options.ttlMs },
|
|
183
|
+
);
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
if (options.adapter.withLock !== undefined) {
|
|
188
|
+
store.withLock = (path, task) =>
|
|
189
|
+
options.adapter.withLock?.(keyValueStoreKey(namespace, path), async () => await task()) ??
|
|
190
|
+
task();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return store;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function storeKey(namespace: string, path: string): string {
|
|
197
|
+
return `${namespace}\0${path}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function keyValueStoreKey(namespace: string, path: string): string {
|
|
201
|
+
return `${namespace}:${path}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function filePath(directory: string, namespace: string, path: string): string {
|
|
205
|
+
const digest = createHash("sha256")
|
|
206
|
+
.update(storeKey(namespace, path))
|
|
207
|
+
.digest("hex");
|
|
208
|
+
|
|
209
|
+
return join(directory, namespace, `${digest}.json`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function isNodeError(error: unknown, code: string): boolean {
|
|
213
|
+
return error instanceof Error && (error as NodeJS.ErrnoException).code === code;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function evictLeastRecentlyUsed(
|
|
217
|
+
backing: Map<string, MemoryPrerenderStoreEntry>,
|
|
218
|
+
namespace: string,
|
|
219
|
+
maxEntries: number,
|
|
220
|
+
): void {
|
|
221
|
+
if (!Number.isFinite(maxEntries) || maxEntries < 0) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const prefix = `${namespace}\0`;
|
|
226
|
+
const entries = Array.from(backing.entries())
|
|
227
|
+
.filter(([key]) => key.startsWith(prefix))
|
|
228
|
+
.sort(([, left], [, right]) => left.lastAccessedAt - right.lastAccessedAt);
|
|
229
|
+
|
|
230
|
+
for (const [key] of entries.slice(0, Math.max(0, entries.length - maxEntries))) {
|
|
231
|
+
backing.delete(key);
|
|
232
|
+
}
|
|
233
|
+
}
|