@cloudflare/pages-shared 0.0.2 → 0.0.3
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/asset-server/metadata.js +0 -0
- package/dist/asset-server/patchUrl.js +15 -0
- package/dist/asset-server/responses.js +123 -0
- package/dist/asset-server/rulesEngine.js +44 -0
- package/package.json +5 -2
- package/src/asset-server/metadata.ts +66 -0
- package/src/asset-server/patchUrl.ts +22 -0
- package/src/asset-server/responses.ts +152 -0
- package/src/asset-server/rulesEngine.ts +84 -0
- package/src/index.ts +1 -0
- package/src/metadata-generator/constants.ts +15 -0
- package/src/metadata-generator/createMetadataObject.ts +156 -0
- package/src/metadata-generator/parseHeaders.ts +168 -0
- package/src/metadata-generator/parseRedirects.ts +129 -0
- package/src/metadata-generator/types.ts +89 -0
- package/src/metadata-generator/validateURL.ts +57 -0
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
globalThis.URL = function(globalURL) {
|
|
2
|
+
PatchedURL.prototype = globalURL.prototype;
|
|
3
|
+
PatchedURL.createObjectURL = globalURL.createObjectURL;
|
|
4
|
+
PatchedURL.revokeObjectURL = globalURL.revokeObjectURL;
|
|
5
|
+
return PatchedURL;
|
|
6
|
+
function PatchedURL(input, base) {
|
|
7
|
+
const url = new URL(encodeURI(input), base);
|
|
8
|
+
return new Proxy(url, {
|
|
9
|
+
get(target, prop) {
|
|
10
|
+
return globalThis.decodeURIComponent(target[prop]);
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
}(URL);
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
function mergeHeaders(base, extra) {
|
|
2
|
+
base = new Headers(base ?? {});
|
|
3
|
+
extra = new Headers(extra ?? {});
|
|
4
|
+
return new Headers({
|
|
5
|
+
...Object.fromEntries(base.entries()),
|
|
6
|
+
...Object.fromEntries(extra.entries())
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
export class OkResponse extends Response {
|
|
10
|
+
constructor(...[body, init]) {
|
|
11
|
+
super(body, {
|
|
12
|
+
...init,
|
|
13
|
+
status: 200,
|
|
14
|
+
statusText: "OK"
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export class MovedPermanentlyResponse extends Response {
|
|
19
|
+
constructor(location, init) {
|
|
20
|
+
super(`Redirecting to ${location}`, {
|
|
21
|
+
...init,
|
|
22
|
+
status: 301,
|
|
23
|
+
statusText: "Moved Permanently",
|
|
24
|
+
headers: mergeHeaders(init?.headers, {
|
|
25
|
+
location
|
|
26
|
+
})
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export class FoundResponse extends Response {
|
|
31
|
+
constructor(location, init) {
|
|
32
|
+
super(`Redirecting to ${location}`, {
|
|
33
|
+
...init,
|
|
34
|
+
status: 302,
|
|
35
|
+
statusText: "Found",
|
|
36
|
+
headers: mergeHeaders(init?.headers, {
|
|
37
|
+
location
|
|
38
|
+
})
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export class NotModifiedResponse extends Response {
|
|
43
|
+
constructor(...[_body, _init]) {
|
|
44
|
+
super(void 0, {
|
|
45
|
+
status: 304,
|
|
46
|
+
statusText: "Not Modified"
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export class PermanentRedirectResponse extends Response {
|
|
51
|
+
constructor(location, init) {
|
|
52
|
+
super(void 0, {
|
|
53
|
+
...init,
|
|
54
|
+
status: 308,
|
|
55
|
+
statusText: "Permanent Redirect",
|
|
56
|
+
headers: mergeHeaders(init?.headers, {
|
|
57
|
+
location
|
|
58
|
+
})
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export class NotFoundResponse extends Response {
|
|
63
|
+
constructor(...[body, init]) {
|
|
64
|
+
super(body, {
|
|
65
|
+
...init,
|
|
66
|
+
status: 404,
|
|
67
|
+
statusText: "Not Found"
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export class MethodNotAllowedResponse extends Response {
|
|
72
|
+
constructor(...[body, init]) {
|
|
73
|
+
super(body, {
|
|
74
|
+
...init,
|
|
75
|
+
status: 405,
|
|
76
|
+
statusText: "Method Not Allowed"
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export class NotAcceptableResponse extends Response {
|
|
81
|
+
constructor(...[body, init]) {
|
|
82
|
+
super(body, {
|
|
83
|
+
...init,
|
|
84
|
+
status: 406,
|
|
85
|
+
statusText: "Not Acceptable"
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export class InternalServerErrorResponse extends Response {
|
|
90
|
+
constructor(err, init) {
|
|
91
|
+
let body = void 0;
|
|
92
|
+
if (globalThis.DEBUG) {
|
|
93
|
+
body = `${err.message}
|
|
94
|
+
|
|
95
|
+
${err.stack}`;
|
|
96
|
+
}
|
|
97
|
+
super(body, {
|
|
98
|
+
...init,
|
|
99
|
+
status: 500,
|
|
100
|
+
statusText: "Internal Server Error"
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export class SeeOtherResponse extends Response {
|
|
105
|
+
constructor(location, init) {
|
|
106
|
+
super(`Redirecting to ${location}`, {
|
|
107
|
+
...init,
|
|
108
|
+
status: 303,
|
|
109
|
+
statusText: "See Other",
|
|
110
|
+
headers: mergeHeaders(init?.headers, { location })
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
export class TemporaryRedirectResponse extends Response {
|
|
115
|
+
constructor(location, init) {
|
|
116
|
+
super(`Redirecting to ${location}`, {
|
|
117
|
+
...init,
|
|
118
|
+
status: 307,
|
|
119
|
+
statusText: "Temporary Redirect",
|
|
120
|
+
headers: mergeHeaders(init?.headers, { location })
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const ESCAPE_REGEX_CHARACTERS = /[-/\\^$*+?.()|[\]{}]/g;
|
|
2
|
+
const escapeRegex = (str) => {
|
|
3
|
+
return str.replace(ESCAPE_REGEX_CHARACTERS, "\\$&");
|
|
4
|
+
};
|
|
5
|
+
const HOST_PLACEHOLDER_REGEX = /(?<=^https:\\\/\\\/[^/]*?):([^\\]+)(?=\\)/g;
|
|
6
|
+
const PLACEHOLDER_REGEX = /:(\w+)/g;
|
|
7
|
+
export const replacer = (str, replacements) => {
|
|
8
|
+
for (const [replacement, value] of Object.entries(replacements)) {
|
|
9
|
+
str = str.replaceAll(`:${replacement}`, value);
|
|
10
|
+
}
|
|
11
|
+
return str;
|
|
12
|
+
};
|
|
13
|
+
export const generateRulesMatcher = (rules, replacerFn = (match) => match) => {
|
|
14
|
+
if (!rules)
|
|
15
|
+
return () => [];
|
|
16
|
+
const compiledRules = Object.entries(rules).map(([rule, match]) => {
|
|
17
|
+
const crossHost = rule.startsWith("https://");
|
|
18
|
+
rule = rule.split("*").map(escapeRegex).join("(?<splat>.*)");
|
|
19
|
+
const host_matches = rule.matchAll(HOST_PLACEHOLDER_REGEX);
|
|
20
|
+
for (const host_match of host_matches) {
|
|
21
|
+
rule = rule.split(host_match[0]).join(`(?<${host_match[1]}>[^/.]+)`);
|
|
22
|
+
}
|
|
23
|
+
const path_matches = rule.matchAll(PLACEHOLDER_REGEX);
|
|
24
|
+
for (const path_match of path_matches) {
|
|
25
|
+
rule = rule.split(path_match[0]).join(`(?<${path_match[1]}>[^/]+)`);
|
|
26
|
+
}
|
|
27
|
+
rule = "^" + rule + "$";
|
|
28
|
+
try {
|
|
29
|
+
const regExp = new RegExp(rule);
|
|
30
|
+
return [{ crossHost, regExp }, match];
|
|
31
|
+
} catch {
|
|
32
|
+
}
|
|
33
|
+
}).filter((value) => value !== void 0);
|
|
34
|
+
return ({ request }) => {
|
|
35
|
+
const { pathname, host } = new URL(request.url);
|
|
36
|
+
return compiledRules.map(([{ crossHost, regExp }, match]) => {
|
|
37
|
+
const test = crossHost ? `https://${host}${pathname}` : pathname;
|
|
38
|
+
const result = regExp.exec(test);
|
|
39
|
+
if (result) {
|
|
40
|
+
return replacerFn(match, result.groups || {});
|
|
41
|
+
}
|
|
42
|
+
}).filter((value) => value !== void 0);
|
|
43
|
+
};
|
|
44
|
+
};
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloudflare/pages-shared",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"files": [
|
|
5
5
|
"tsconfig.json",
|
|
6
|
+
"src/**/*",
|
|
6
7
|
"dist/**/*"
|
|
7
8
|
],
|
|
8
9
|
"scripts": {
|
|
@@ -36,7 +37,9 @@
|
|
|
36
37
|
},
|
|
37
38
|
"devDependencies": {
|
|
38
39
|
"@cloudflare/workers-types": "^3.16.0",
|
|
40
|
+
"@types/service-worker-mock": "^2.0.1",
|
|
39
41
|
"concurrently": "^7.3.0",
|
|
40
|
-
"glob": "^8.0.3"
|
|
42
|
+
"glob": "^8.0.3",
|
|
43
|
+
"service-worker-mock": "^2.0.5"
|
|
41
44
|
}
|
|
42
45
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// TODO: Use types from metadata-generator instead
|
|
2
|
+
|
|
3
|
+
export type MetadataStaticRedirectEntry = {
|
|
4
|
+
status: number;
|
|
5
|
+
to: string;
|
|
6
|
+
lineNumber: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type MetadataRedirectEntry = {
|
|
10
|
+
status: number;
|
|
11
|
+
to: string;
|
|
12
|
+
lineNumber?: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type MetadataStaticRedirects = {
|
|
16
|
+
[path: string]: MetadataStaticRedirectEntry;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type MetadataRedirects = {
|
|
20
|
+
[path: string]: MetadataRedirectEntry;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// v1 Types
|
|
24
|
+
export type MetadataHeadersEntries = Record<string, string>;
|
|
25
|
+
|
|
26
|
+
export type MetadataHeadersRulesV1 = {
|
|
27
|
+
[path: string]: MetadataHeadersEntries;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type MetadataHeadersV1 = {
|
|
31
|
+
version: number; // TODO: This could be 1
|
|
32
|
+
rules: MetadataHeadersRulesV1;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// v2 Types
|
|
36
|
+
export type SetHeaders = MetadataHeadersEntries;
|
|
37
|
+
|
|
38
|
+
export type UnsetHeaders = Array<string>;
|
|
39
|
+
|
|
40
|
+
export type MetadataHeadersRulesV2 = {
|
|
41
|
+
[path: string]: MetadataHeaderEntry;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type MetadataHeaderEntry = {
|
|
45
|
+
set?: SetHeaders;
|
|
46
|
+
unset?: UnsetHeaders;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export type MetadataHeadersV2 = {
|
|
50
|
+
version: number; // TODO: This could be 2
|
|
51
|
+
rules: MetadataHeadersRulesV2;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type Metadata = {
|
|
55
|
+
redirects?: {
|
|
56
|
+
version: number;
|
|
57
|
+
staticRules?: MetadataStaticRedirects;
|
|
58
|
+
rules: MetadataRedirects;
|
|
59
|
+
};
|
|
60
|
+
headers?: MetadataHeadersV1 | MetadataHeadersV2;
|
|
61
|
+
analytics?: {
|
|
62
|
+
version: number;
|
|
63
|
+
token: string;
|
|
64
|
+
};
|
|
65
|
+
deploymentId?: string;
|
|
66
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
2
|
+
// @ts-nocheck
|
|
3
|
+
globalThis.URL = (function (globalURL) {
|
|
4
|
+
PatchedURL.prototype = globalURL.prototype;
|
|
5
|
+
PatchedURL.createObjectURL = globalURL.createObjectURL;
|
|
6
|
+
PatchedURL.revokeObjectURL = globalURL.revokeObjectURL;
|
|
7
|
+
|
|
8
|
+
return PatchedURL as unknown as typeof globalURL;
|
|
9
|
+
|
|
10
|
+
function PatchedURL(input: string, base?: string | URL) {
|
|
11
|
+
const url = new URL(encodeURI(input), base);
|
|
12
|
+
|
|
13
|
+
return new Proxy(url, {
|
|
14
|
+
get(target, prop) {
|
|
15
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
16
|
+
return globalThis.decodeURIComponent((target as any)[prop]);
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
})(URL);
|
|
21
|
+
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
type HeadersInit = ConstructorParameters<typeof Headers>[0];
|
|
2
|
+
|
|
3
|
+
function mergeHeaders(base: HeadersInit, extra: HeadersInit) {
|
|
4
|
+
base = new Headers(base ?? {});
|
|
5
|
+
extra = new Headers(extra ?? {});
|
|
6
|
+
|
|
7
|
+
return new Headers({
|
|
8
|
+
...Object.fromEntries(base.entries()),
|
|
9
|
+
...Object.fromEntries(extra.entries()),
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class OkResponse extends Response {
|
|
14
|
+
constructor(...[body, init]: ConstructorParameters<typeof Response>) {
|
|
15
|
+
super(body, {
|
|
16
|
+
...init,
|
|
17
|
+
status: 200,
|
|
18
|
+
statusText: "OK",
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class MovedPermanentlyResponse extends Response {
|
|
24
|
+
constructor(
|
|
25
|
+
location: string,
|
|
26
|
+
init?: ConstructorParameters<typeof Response>[1]
|
|
27
|
+
) {
|
|
28
|
+
super(`Redirecting to ${location}`, {
|
|
29
|
+
...init,
|
|
30
|
+
status: 301,
|
|
31
|
+
statusText: "Moved Permanently",
|
|
32
|
+
headers: mergeHeaders(init?.headers, {
|
|
33
|
+
location,
|
|
34
|
+
}),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class FoundResponse extends Response {
|
|
40
|
+
constructor(
|
|
41
|
+
location: string,
|
|
42
|
+
init?: ConstructorParameters<typeof Response>[1]
|
|
43
|
+
) {
|
|
44
|
+
super(`Redirecting to ${location}`, {
|
|
45
|
+
...init,
|
|
46
|
+
status: 302,
|
|
47
|
+
statusText: "Found",
|
|
48
|
+
headers: mergeHeaders(init?.headers, {
|
|
49
|
+
location,
|
|
50
|
+
}),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class NotModifiedResponse extends Response {
|
|
56
|
+
constructor(...[_body, _init]: ConstructorParameters<typeof Response>) {
|
|
57
|
+
super(undefined, {
|
|
58
|
+
status: 304,
|
|
59
|
+
statusText: "Not Modified",
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class PermanentRedirectResponse extends Response {
|
|
65
|
+
constructor(
|
|
66
|
+
location: string,
|
|
67
|
+
init?: ConstructorParameters<typeof Response>[1]
|
|
68
|
+
) {
|
|
69
|
+
super(undefined, {
|
|
70
|
+
...init,
|
|
71
|
+
status: 308,
|
|
72
|
+
statusText: "Permanent Redirect",
|
|
73
|
+
headers: mergeHeaders(init?.headers, {
|
|
74
|
+
location,
|
|
75
|
+
}),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class NotFoundResponse extends Response {
|
|
81
|
+
constructor(...[body, init]: ConstructorParameters<typeof Response>) {
|
|
82
|
+
super(body, {
|
|
83
|
+
...init,
|
|
84
|
+
status: 404,
|
|
85
|
+
statusText: "Not Found",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class MethodNotAllowedResponse extends Response {
|
|
91
|
+
constructor(...[body, init]: ConstructorParameters<typeof Response>) {
|
|
92
|
+
super(body, {
|
|
93
|
+
...init,
|
|
94
|
+
status: 405,
|
|
95
|
+
statusText: "Method Not Allowed",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export class NotAcceptableResponse extends Response {
|
|
101
|
+
constructor(...[body, init]: ConstructorParameters<typeof Response>) {
|
|
102
|
+
super(body, {
|
|
103
|
+
...init,
|
|
104
|
+
status: 406,
|
|
105
|
+
statusText: "Not Acceptable",
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export class InternalServerErrorResponse extends Response {
|
|
111
|
+
constructor(err: Error, init?: ConstructorParameters<typeof Response>[1]) {
|
|
112
|
+
let body: string | undefined = undefined;
|
|
113
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
114
|
+
if ((globalThis as any).DEBUG) {
|
|
115
|
+
body = `${err.message}\n\n${err.stack}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
super(body, {
|
|
119
|
+
...init,
|
|
120
|
+
status: 500,
|
|
121
|
+
statusText: "Internal Server Error",
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export class SeeOtherResponse extends Response {
|
|
127
|
+
constructor(
|
|
128
|
+
location: string,
|
|
129
|
+
init?: ConstructorParameters<typeof Response>[1]
|
|
130
|
+
) {
|
|
131
|
+
super(`Redirecting to ${location}`, {
|
|
132
|
+
...init,
|
|
133
|
+
status: 303,
|
|
134
|
+
statusText: "See Other",
|
|
135
|
+
headers: mergeHeaders(init?.headers, { location }),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export class TemporaryRedirectResponse extends Response {
|
|
141
|
+
constructor(
|
|
142
|
+
location: string,
|
|
143
|
+
init?: ConstructorParameters<typeof Response>[1]
|
|
144
|
+
) {
|
|
145
|
+
super(`Redirecting to ${location}`, {
|
|
146
|
+
...init,
|
|
147
|
+
status: 307,
|
|
148
|
+
statusText: "Temporary Redirect",
|
|
149
|
+
headers: mergeHeaders(init?.headers, { location }),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
declare type Request = { url: string };
|
|
2
|
+
|
|
3
|
+
// Taken from https://stackoverflow.com/a/3561711
|
|
4
|
+
// which is everything from the tc39 proposal, plus the following two characters: ^/
|
|
5
|
+
// It's also everything included in the URLPattern escape (https://wicg.github.io/urlpattern/#escape-a-regexp-string), plus the following: -
|
|
6
|
+
// As the answer says, there's no downside to escaping these extra characters, so better safe than sorry
|
|
7
|
+
const ESCAPE_REGEX_CHARACTERS = /[-/\\^$*+?.()|[\]{}]/g;
|
|
8
|
+
const escapeRegex = (str: string) => {
|
|
9
|
+
return str.replace(ESCAPE_REGEX_CHARACTERS, "\\$&");
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// Placeholder names must begin with a colon, be alphanumeric and optionally contain underscores.
|
|
13
|
+
// e.g. :place_123_holder
|
|
14
|
+
const HOST_PLACEHOLDER_REGEX = /(?<=^https:\\\/\\\/[^/]*?):([^\\]+)(?=\\)/g;
|
|
15
|
+
const PLACEHOLDER_REGEX = /:(\w+)/g;
|
|
16
|
+
|
|
17
|
+
export type Replacements = Record<string, string>;
|
|
18
|
+
|
|
19
|
+
export type Removals = string[];
|
|
20
|
+
|
|
21
|
+
export const replacer = (str: string, replacements: Replacements) => {
|
|
22
|
+
for (const [replacement, value] of Object.entries(replacements)) {
|
|
23
|
+
str = str.replaceAll(`:${replacement}`, value);
|
|
24
|
+
}
|
|
25
|
+
return str;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const generateRulesMatcher = <T>(
|
|
29
|
+
rules?: Record<string, T>,
|
|
30
|
+
replacerFn: (match: T, replacements: Replacements) => T = (match) => match
|
|
31
|
+
) => {
|
|
32
|
+
if (!rules) return () => [];
|
|
33
|
+
|
|
34
|
+
const compiledRules = Object.entries(rules)
|
|
35
|
+
.map(([rule, match]) => {
|
|
36
|
+
const crossHost = rule.startsWith("https://");
|
|
37
|
+
|
|
38
|
+
// Create :splat capturer then escape.
|
|
39
|
+
rule = rule.split("*").map(escapeRegex).join("(?<splat>.*)");
|
|
40
|
+
|
|
41
|
+
// Create :placeholder capturers (already escaped).
|
|
42
|
+
// For placeholders in the host, we separate at forward slashes and periods.
|
|
43
|
+
// For placeholders in the path, we separate at forward slashes.
|
|
44
|
+
// This matches the behavior of URLPattern.
|
|
45
|
+
// e.g. https://:subdomain.domain/ -> https://(here).domain/
|
|
46
|
+
// e.g. /static/:file -> /static/(image.jpg)
|
|
47
|
+
// e.g. /blog/:post -> /blog/(an-exciting-post)
|
|
48
|
+
const host_matches = rule.matchAll(HOST_PLACEHOLDER_REGEX);
|
|
49
|
+
for (const host_match of host_matches) {
|
|
50
|
+
rule = rule.split(host_match[0]).join(`(?<${host_match[1]}>[^/.]+)`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const path_matches = rule.matchAll(PLACEHOLDER_REGEX);
|
|
54
|
+
for (const path_match of path_matches) {
|
|
55
|
+
rule = rule.split(path_match[0]).join(`(?<${path_match[1]}>[^/]+)`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Wrap in line terminators to be safe.
|
|
59
|
+
rule = "^" + rule + "$";
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const regExp = new RegExp(rule);
|
|
63
|
+
return [{ crossHost, regExp }, match];
|
|
64
|
+
} catch {}
|
|
65
|
+
})
|
|
66
|
+
.filter((value) => value !== undefined) as [
|
|
67
|
+
{ crossHost: boolean; regExp: RegExp },
|
|
68
|
+
T
|
|
69
|
+
][];
|
|
70
|
+
|
|
71
|
+
return ({ request }: { request: Request }) => {
|
|
72
|
+
const { pathname, host } = new URL(request.url);
|
|
73
|
+
|
|
74
|
+
return compiledRules
|
|
75
|
+
.map(([{ crossHost, regExp }, match]) => {
|
|
76
|
+
const test = crossHost ? `https://${host}${pathname}` : pathname;
|
|
77
|
+
const result = regExp.exec(test);
|
|
78
|
+
if (result) {
|
|
79
|
+
return replacerFn(match, result.groups || {});
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
.filter((value) => value !== undefined) as T[];
|
|
83
|
+
};
|
|
84
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const REDIRECTS_VERSION = 1;
|
|
2
|
+
export const HEADERS_VERSION = 2;
|
|
3
|
+
export const ANALYTICS_VERSION = 1;
|
|
4
|
+
export const ROUTES_JSON_VERSION = 1;
|
|
5
|
+
|
|
6
|
+
export const PERMITTED_STATUS_CODES = new Set([301, 302, 303, 307, 308]);
|
|
7
|
+
export const HEADER_SEPARATOR = ":";
|
|
8
|
+
export const MAX_LINE_LENGTH = 2000;
|
|
9
|
+
export const MAX_HEADER_RULES = 100;
|
|
10
|
+
export const MAX_DYNAMIC_REDIRECT_RULES = 100;
|
|
11
|
+
export const MAX_STATIC_REDIRECT_RULES = 2000;
|
|
12
|
+
export const UNSET_OPERATOR = "! ";
|
|
13
|
+
|
|
14
|
+
export const SPLAT_REGEX = /\*/g;
|
|
15
|
+
export const PLACEHOLDER_REGEX = /:\w+/g;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ANALYTICS_VERSION,
|
|
3
|
+
REDIRECTS_VERSION,
|
|
4
|
+
HEADERS_VERSION,
|
|
5
|
+
SPLAT_REGEX,
|
|
6
|
+
PLACEHOLDER_REGEX,
|
|
7
|
+
} from "./constants";
|
|
8
|
+
import type {
|
|
9
|
+
Metadata,
|
|
10
|
+
MetadataRedirects,
|
|
11
|
+
MetadataHeaders,
|
|
12
|
+
ParsedRedirects,
|
|
13
|
+
ParsedHeaders,
|
|
14
|
+
Logger,
|
|
15
|
+
} from "./types";
|
|
16
|
+
|
|
17
|
+
export function createMetadataObject({
|
|
18
|
+
redirects,
|
|
19
|
+
headers,
|
|
20
|
+
webAnalyticsToken,
|
|
21
|
+
deploymentId,
|
|
22
|
+
logger = (_message: string) => {},
|
|
23
|
+
}: {
|
|
24
|
+
redirects?: ParsedRedirects;
|
|
25
|
+
headers?: ParsedHeaders;
|
|
26
|
+
webAnalyticsToken?: string;
|
|
27
|
+
deploymentId?: string;
|
|
28
|
+
logger?: Logger;
|
|
29
|
+
}): Metadata {
|
|
30
|
+
return {
|
|
31
|
+
...constructRedirects({ redirects, logger }),
|
|
32
|
+
...constructHeaders({ headers, logger }),
|
|
33
|
+
...constructWebAnalytics({ webAnalyticsToken, logger }),
|
|
34
|
+
deploymentId,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function constructRedirects({
|
|
39
|
+
redirects,
|
|
40
|
+
logger,
|
|
41
|
+
}: {
|
|
42
|
+
redirects?: ParsedRedirects;
|
|
43
|
+
logger: Logger;
|
|
44
|
+
}): Metadata {
|
|
45
|
+
if (!redirects) return {};
|
|
46
|
+
|
|
47
|
+
const num_valid = redirects.rules.length;
|
|
48
|
+
const num_invalid = redirects.invalid.length;
|
|
49
|
+
|
|
50
|
+
logger(
|
|
51
|
+
`Parsed ${num_valid} valid redirect rule${num_valid === 1 ? "" : "s"}.`
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
if (num_invalid > 0) {
|
|
55
|
+
logger(`Found invalid redirect lines:`);
|
|
56
|
+
for (const { line, lineNumber, message } of redirects.invalid) {
|
|
57
|
+
if (line) logger(` - ${lineNumber ? `#${lineNumber}: ` : ""}${line}`);
|
|
58
|
+
logger(` ${message}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* Better to return no Redirects object at all than one with empty rules */
|
|
63
|
+
if (num_valid === 0) {
|
|
64
|
+
return {};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const staticRedirects: MetadataRedirects = {};
|
|
68
|
+
const dynamicRedirects: MetadataRedirects = {};
|
|
69
|
+
let canCreateStaticRule = true;
|
|
70
|
+
for (const rule of redirects.rules) {
|
|
71
|
+
if (!rule.from.match(SPLAT_REGEX) && !rule.from.match(PLACEHOLDER_REGEX)) {
|
|
72
|
+
if (canCreateStaticRule) {
|
|
73
|
+
staticRedirects[rule.from] = { status: rule.status, to: rule.to };
|
|
74
|
+
continue;
|
|
75
|
+
} else {
|
|
76
|
+
logger(
|
|
77
|
+
`Info: the redirect rule ${rule.from} → ${rule.status} ${rule.to} could be made more performant by bringing it above any lines with splats or placeholders.`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
dynamicRedirects[rule.from] = { status: rule.status, to: rule.to };
|
|
83
|
+
canCreateStaticRule = false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
redirects: {
|
|
88
|
+
version: REDIRECTS_VERSION,
|
|
89
|
+
staticRules: staticRedirects,
|
|
90
|
+
rules: dynamicRedirects,
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function constructHeaders({
|
|
96
|
+
headers,
|
|
97
|
+
logger,
|
|
98
|
+
}: {
|
|
99
|
+
headers?: ParsedHeaders;
|
|
100
|
+
logger: Logger;
|
|
101
|
+
}): Metadata {
|
|
102
|
+
if (!headers) return {};
|
|
103
|
+
|
|
104
|
+
const num_valid = headers.rules.length;
|
|
105
|
+
const num_invalid = headers.invalid.length;
|
|
106
|
+
|
|
107
|
+
logger(`Parsed ${num_valid} valid header rule${num_valid === 1 ? "" : "s"}.`);
|
|
108
|
+
|
|
109
|
+
if (num_invalid > 0) {
|
|
110
|
+
logger(`Found invalid header lines:`);
|
|
111
|
+
for (const { line, lineNumber, message } of headers.invalid) {
|
|
112
|
+
if (line) logger(` - ${lineNumber ? `#${lineNumber}: ` : ""} ${line}`);
|
|
113
|
+
logger(` ${message}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* Better to return no Headers object at all than one with empty rules */
|
|
118
|
+
if (num_valid === 0) {
|
|
119
|
+
return {};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const rules: MetadataHeaders = {};
|
|
123
|
+
for (const rule of headers.rules) {
|
|
124
|
+
rules[rule.path] = {};
|
|
125
|
+
|
|
126
|
+
if (Object.keys(rule.headers).length) {
|
|
127
|
+
rules[rule.path].set = rule.headers;
|
|
128
|
+
}
|
|
129
|
+
if (rule.unsetHeaders.length) {
|
|
130
|
+
rules[rule.path].unset = rule.unsetHeaders;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
headers: {
|
|
136
|
+
version: HEADERS_VERSION,
|
|
137
|
+
rules,
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function constructWebAnalytics({
|
|
143
|
+
webAnalyticsToken,
|
|
144
|
+
}: {
|
|
145
|
+
webAnalyticsToken?: string;
|
|
146
|
+
logger: Logger;
|
|
147
|
+
}) {
|
|
148
|
+
if (!webAnalyticsToken) return {};
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
analytics: {
|
|
152
|
+
version: ANALYTICS_VERSION,
|
|
153
|
+
token: webAnalyticsToken,
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MAX_LINE_LENGTH,
|
|
3
|
+
MAX_HEADER_RULES,
|
|
4
|
+
HEADER_SEPARATOR,
|
|
5
|
+
UNSET_OPERATOR,
|
|
6
|
+
} from "./constants";
|
|
7
|
+
import { validateUrl } from "./validateURL";
|
|
8
|
+
import type { InvalidHeadersRule, ParsedHeaders, HeadersRule } from "./types";
|
|
9
|
+
|
|
10
|
+
// Not strictly necessary to check for all protocols-like beginnings, since _technically_ that could be a legit header (e.g. name=http, value=://I'm a value).
|
|
11
|
+
// But we're checking here since some people might be caught out and it'll help 99.9% of people who get it wrong.
|
|
12
|
+
// We do the proper validation in `validateUrl` anyway :)
|
|
13
|
+
const LINE_IS_PROBABLY_A_PATH = new RegExp(/^([^\s]+:\/\/|^\/)/);
|
|
14
|
+
|
|
15
|
+
export function parseHeaders(input: string): ParsedHeaders {
|
|
16
|
+
const lines = input.split("\n");
|
|
17
|
+
const rules: HeadersRule[] = [];
|
|
18
|
+
const invalid: InvalidHeadersRule[] = [];
|
|
19
|
+
|
|
20
|
+
let rule: (HeadersRule & { line: string }) | undefined = undefined;
|
|
21
|
+
|
|
22
|
+
for (let i = 0; i < lines.length; i++) {
|
|
23
|
+
const line = lines[i].trim();
|
|
24
|
+
if (line.length === 0 || line.startsWith("#")) continue;
|
|
25
|
+
|
|
26
|
+
if (line.length > MAX_LINE_LENGTH) {
|
|
27
|
+
invalid.push({
|
|
28
|
+
message: `Ignoring line ${
|
|
29
|
+
i + 1
|
|
30
|
+
} as it exceeds the maximum allowed length of ${MAX_LINE_LENGTH}.`,
|
|
31
|
+
});
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (LINE_IS_PROBABLY_A_PATH.test(line)) {
|
|
36
|
+
if (rules.length >= MAX_HEADER_RULES) {
|
|
37
|
+
invalid.push({
|
|
38
|
+
message: `Maximum number of rules supported is ${MAX_HEADER_RULES}. Skipping remaining ${
|
|
39
|
+
lines.length - i
|
|
40
|
+
} lines of file.`,
|
|
41
|
+
});
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (rule) {
|
|
46
|
+
if (isValidRule(rule)) {
|
|
47
|
+
rules.push({
|
|
48
|
+
path: rule.path,
|
|
49
|
+
headers: rule.headers,
|
|
50
|
+
unsetHeaders: rule.unsetHeaders,
|
|
51
|
+
});
|
|
52
|
+
} else {
|
|
53
|
+
invalid.push({
|
|
54
|
+
line: rule.line,
|
|
55
|
+
lineNumber: i + 1,
|
|
56
|
+
message: "No headers specified",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const [path, pathError] = validateUrl(line);
|
|
62
|
+
if (pathError) {
|
|
63
|
+
invalid.push({
|
|
64
|
+
line,
|
|
65
|
+
lineNumber: i + 1,
|
|
66
|
+
message: pathError,
|
|
67
|
+
});
|
|
68
|
+
rule = undefined;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
rule = {
|
|
73
|
+
path: path as string,
|
|
74
|
+
line,
|
|
75
|
+
headers: {},
|
|
76
|
+
unsetHeaders: [],
|
|
77
|
+
};
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!line.includes(HEADER_SEPARATOR)) {
|
|
82
|
+
if (!rule) {
|
|
83
|
+
invalid.push({
|
|
84
|
+
line,
|
|
85
|
+
lineNumber: i + 1,
|
|
86
|
+
message: "Expected a path beginning with at least one forward-slash",
|
|
87
|
+
});
|
|
88
|
+
} else {
|
|
89
|
+
if (line.trim().startsWith(UNSET_OPERATOR)) {
|
|
90
|
+
rule.unsetHeaders.push(line.trim().replace(UNSET_OPERATOR, ""));
|
|
91
|
+
} else {
|
|
92
|
+
invalid.push({
|
|
93
|
+
line,
|
|
94
|
+
lineNumber: i + 1,
|
|
95
|
+
message:
|
|
96
|
+
"Expected a colon-separated header pair (e.g. name: value)",
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const [rawName, ...rawValue] = line.split(HEADER_SEPARATOR);
|
|
104
|
+
const name = rawName.trim().toLowerCase();
|
|
105
|
+
|
|
106
|
+
if (name.includes(" ")) {
|
|
107
|
+
invalid.push({
|
|
108
|
+
line,
|
|
109
|
+
lineNumber: i + 1,
|
|
110
|
+
message: "Header name cannot include spaces",
|
|
111
|
+
});
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const value = rawValue.join(HEADER_SEPARATOR).trim();
|
|
116
|
+
|
|
117
|
+
if (name === "") {
|
|
118
|
+
invalid.push({
|
|
119
|
+
line,
|
|
120
|
+
lineNumber: i + 1,
|
|
121
|
+
message: "No header name specified",
|
|
122
|
+
});
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (value === "") {
|
|
127
|
+
invalid.push({
|
|
128
|
+
line,
|
|
129
|
+
lineNumber: i + 1,
|
|
130
|
+
message: "No header value specified",
|
|
131
|
+
});
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!rule) {
|
|
136
|
+
invalid.push({
|
|
137
|
+
line,
|
|
138
|
+
lineNumber: i + 1,
|
|
139
|
+
message: `Path should come before header (${name}: ${value})`,
|
|
140
|
+
});
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const existingValues = rule.headers[name];
|
|
145
|
+
rule.headers[name] = existingValues ? `${existingValues}, ${value}` : value;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (rule) {
|
|
149
|
+
if (isValidRule(rule)) {
|
|
150
|
+
rules.push({
|
|
151
|
+
path: rule.path,
|
|
152
|
+
headers: rule.headers,
|
|
153
|
+
unsetHeaders: rule.unsetHeaders,
|
|
154
|
+
});
|
|
155
|
+
} else {
|
|
156
|
+
invalid.push({ line: rule.line, message: "No headers specified" });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
rules,
|
|
162
|
+
invalid,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function isValidRule(rule: HeadersRule) {
|
|
167
|
+
return Object.keys(rule.headers).length > 0 || rule.unsetHeaders.length > 0;
|
|
168
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MAX_LINE_LENGTH,
|
|
3
|
+
MAX_DYNAMIC_REDIRECT_RULES,
|
|
4
|
+
MAX_STATIC_REDIRECT_RULES,
|
|
5
|
+
PERMITTED_STATUS_CODES,
|
|
6
|
+
SPLAT_REGEX,
|
|
7
|
+
PLACEHOLDER_REGEX,
|
|
8
|
+
} from "./constants";
|
|
9
|
+
import { validateUrl } from "./validateURL";
|
|
10
|
+
import type {
|
|
11
|
+
InvalidRedirectRule,
|
|
12
|
+
ParsedRedirects,
|
|
13
|
+
RedirectLine,
|
|
14
|
+
RedirectRule,
|
|
15
|
+
} from "./types";
|
|
16
|
+
|
|
17
|
+
export function parseRedirects(input: string): ParsedRedirects {
|
|
18
|
+
const lines = input.split("\n");
|
|
19
|
+
const rules: RedirectRule[] = [];
|
|
20
|
+
const seen_paths = new Set<string>();
|
|
21
|
+
const invalid: InvalidRedirectRule[] = [];
|
|
22
|
+
|
|
23
|
+
let staticRules = 0;
|
|
24
|
+
let dynamicRules = 0;
|
|
25
|
+
let canCreateStaticRule = true;
|
|
26
|
+
|
|
27
|
+
for (let i = 0; i < lines.length; i++) {
|
|
28
|
+
const line = lines[i].trim();
|
|
29
|
+
if (line.length === 0 || line.startsWith("#")) continue;
|
|
30
|
+
|
|
31
|
+
if (line.length > MAX_LINE_LENGTH) {
|
|
32
|
+
invalid.push({
|
|
33
|
+
message: `Ignoring line ${
|
|
34
|
+
i + 1
|
|
35
|
+
} as it exceeds the maximum allowed length of ${MAX_LINE_LENGTH}.`,
|
|
36
|
+
});
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const tokens = line.split(/\s+/);
|
|
41
|
+
|
|
42
|
+
if (tokens.length < 2 || tokens.length > 3) {
|
|
43
|
+
invalid.push({
|
|
44
|
+
line,
|
|
45
|
+
lineNumber: i + 1,
|
|
46
|
+
message: `Expected exactly 2 or 3 whitespace-separated tokens. Got ${tokens.length}.`,
|
|
47
|
+
});
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const [str_from, str_to, str_status = "302"] = tokens as RedirectLine;
|
|
52
|
+
|
|
53
|
+
const fromResult = validateUrl(str_from, true, false, false);
|
|
54
|
+
if (fromResult[0] === undefined) {
|
|
55
|
+
invalid.push({
|
|
56
|
+
line,
|
|
57
|
+
lineNumber: i + 1,
|
|
58
|
+
message: fromResult[1],
|
|
59
|
+
});
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const from = fromResult[0];
|
|
63
|
+
|
|
64
|
+
if (
|
|
65
|
+
canCreateStaticRule &&
|
|
66
|
+
!from.match(SPLAT_REGEX) &&
|
|
67
|
+
!from.match(PLACEHOLDER_REGEX)
|
|
68
|
+
) {
|
|
69
|
+
staticRules += 1;
|
|
70
|
+
|
|
71
|
+
if (staticRules > MAX_STATIC_REDIRECT_RULES) {
|
|
72
|
+
invalid.push({
|
|
73
|
+
message: `Maximum number of static rules supported is ${MAX_STATIC_REDIRECT_RULES}. Skipping line.`,
|
|
74
|
+
});
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
dynamicRules += 1;
|
|
79
|
+
canCreateStaticRule = false;
|
|
80
|
+
|
|
81
|
+
if (dynamicRules > MAX_DYNAMIC_REDIRECT_RULES) {
|
|
82
|
+
invalid.push({
|
|
83
|
+
message: `Maximum number of dynamic rules supported is ${MAX_DYNAMIC_REDIRECT_RULES}. Skipping remaining ${
|
|
84
|
+
lines.length - i
|
|
85
|
+
} lines of file.`,
|
|
86
|
+
});
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const toResult = validateUrl(str_to, false, true, true);
|
|
92
|
+
if (toResult[0] === undefined) {
|
|
93
|
+
invalid.push({
|
|
94
|
+
line,
|
|
95
|
+
lineNumber: i + 1,
|
|
96
|
+
message: toResult[1],
|
|
97
|
+
});
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const to = toResult[0];
|
|
101
|
+
|
|
102
|
+
const status = Number(str_status);
|
|
103
|
+
if (isNaN(status) || !PERMITTED_STATUS_CODES.has(status)) {
|
|
104
|
+
invalid.push({
|
|
105
|
+
line,
|
|
106
|
+
lineNumber: i + 1,
|
|
107
|
+
message: `Valid status codes are 301, 302 (default), 303, 307, or 308. Got ${str_status}.`,
|
|
108
|
+
});
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (seen_paths.has(from)) {
|
|
113
|
+
invalid.push({
|
|
114
|
+
line,
|
|
115
|
+
lineNumber: i + 1,
|
|
116
|
+
message: `Ignoring duplicate rule for path ${from}.`,
|
|
117
|
+
});
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
seen_paths.add(from);
|
|
121
|
+
|
|
122
|
+
rules.push({ from, to, status, lineNumber: i + 1 });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
rules,
|
|
127
|
+
invalid,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/* REDIRECT PARSING TYPES */
|
|
2
|
+
|
|
3
|
+
export type RedirectLine = [from: string, to: string, status?: number];
|
|
4
|
+
export type RedirectRule = {
|
|
5
|
+
from: string;
|
|
6
|
+
to: string;
|
|
7
|
+
status: number;
|
|
8
|
+
lineNumber: number;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type Headers = Record<string, string>;
|
|
12
|
+
export type HeadersRule = {
|
|
13
|
+
path: string;
|
|
14
|
+
headers: Headers;
|
|
15
|
+
unsetHeaders: string[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type InvalidRedirectRule = {
|
|
19
|
+
line?: string;
|
|
20
|
+
lineNumber?: number;
|
|
21
|
+
message: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type InvalidHeadersRule = {
|
|
25
|
+
line?: string;
|
|
26
|
+
lineNumber?: number;
|
|
27
|
+
message: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type ParsedRedirects = {
|
|
31
|
+
invalid: InvalidRedirectRule[];
|
|
32
|
+
rules: RedirectRule[];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Parsed redirects and input file */
|
|
36
|
+
export type ParsedRedirectsWithFile = {
|
|
37
|
+
parsedRedirects?: ParsedRedirects;
|
|
38
|
+
file?: string;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type ParsedHeaders = {
|
|
42
|
+
invalid: InvalidHeadersRule[];
|
|
43
|
+
rules: HeadersRule[];
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Parsed headers and input file */
|
|
47
|
+
export type ParsedHeadersWithFile = {
|
|
48
|
+
parsedHeaders?: ParsedHeaders;
|
|
49
|
+
file?: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/* METADATA TYPES*/
|
|
53
|
+
|
|
54
|
+
export type MetadataRedirectEntry = {
|
|
55
|
+
status: number;
|
|
56
|
+
to: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type MetadataRedirects = {
|
|
60
|
+
[path: string]: MetadataRedirectEntry;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type MetadataHeaders = {
|
|
64
|
+
[path: string]: MetadataHeaderEntry;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type MetadataHeaderEntry = {
|
|
68
|
+
set?: Record<string, string>;
|
|
69
|
+
unset?: Array<string>;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type Metadata = {
|
|
73
|
+
redirects?: {
|
|
74
|
+
version: number;
|
|
75
|
+
staticRules: MetadataRedirects;
|
|
76
|
+
rules: MetadataRedirects;
|
|
77
|
+
};
|
|
78
|
+
headers?: {
|
|
79
|
+
version: number;
|
|
80
|
+
rules: MetadataHeaders;
|
|
81
|
+
};
|
|
82
|
+
analytics?: {
|
|
83
|
+
version: number;
|
|
84
|
+
token: string;
|
|
85
|
+
};
|
|
86
|
+
deploymentId?: string;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export type Logger = (message: string) => void;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export const extractPathname = (
|
|
2
|
+
path = "/",
|
|
3
|
+
includeSearch: boolean,
|
|
4
|
+
includeHash: boolean
|
|
5
|
+
): string => {
|
|
6
|
+
if (!path.startsWith("/")) path = `/${path}`;
|
|
7
|
+
const url = new URL(`//${path}`, "relative://");
|
|
8
|
+
return `${url.pathname}${includeSearch ? url.search : ""}${
|
|
9
|
+
includeHash ? url.hash : ""
|
|
10
|
+
}`;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const URL_REGEX = /^https:\/\/+(?<host>[^/]+)\/?(?<path>.*)/;
|
|
14
|
+
const PATH_REGEX = /^\//;
|
|
15
|
+
|
|
16
|
+
export const validateUrl = (
|
|
17
|
+
token: string,
|
|
18
|
+
onlyRelative = false,
|
|
19
|
+
includeSearch = false,
|
|
20
|
+
includeHash = false
|
|
21
|
+
): [undefined, string] | [string, undefined] => {
|
|
22
|
+
const host = URL_REGEX.exec(token);
|
|
23
|
+
if (host && host.groups && host.groups.host) {
|
|
24
|
+
if (onlyRelative)
|
|
25
|
+
return [
|
|
26
|
+
undefined,
|
|
27
|
+
`Only relative URLs are allowed. Skipping absolute URL ${token}.`,
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
return [
|
|
31
|
+
`https://${host.groups.host}${extractPathname(
|
|
32
|
+
host.groups.path,
|
|
33
|
+
includeSearch,
|
|
34
|
+
includeHash
|
|
35
|
+
)}`,
|
|
36
|
+
undefined,
|
|
37
|
+
];
|
|
38
|
+
} else {
|
|
39
|
+
if (!token.startsWith("/") && onlyRelative) token = `/${token}`;
|
|
40
|
+
|
|
41
|
+
const path = PATH_REGEX.exec(token);
|
|
42
|
+
if (path) {
|
|
43
|
+
try {
|
|
44
|
+
return [extractPathname(token, includeSearch, includeHash), undefined];
|
|
45
|
+
} catch {
|
|
46
|
+
return [undefined, `Error parsing URL segment ${token}. Skipping.`];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return [
|
|
52
|
+
undefined,
|
|
53
|
+
onlyRelative
|
|
54
|
+
? "URLs should begin with a forward-slash."
|
|
55
|
+
: 'URLs should either be relative (e.g. begin with a forward-slash), or use HTTPS (e.g. begin with "https://").',
|
|
56
|
+
];
|
|
57
|
+
};
|