redirects-in-workers
Version:
Cloudflare Pages' _redirects file support in Cloudflare Workers
515 lines (506 loc) • 15.2 kB
JavaScript
// ../workers-sdk/packages/workers-shared/asset-worker/src/utils/rules-engine.ts
var ESCAPE_REGEX_CHARACTERS = /[-/\\^$*+?.()|[\]{}]/g;
var escapeRegex = (str) => {
return str.replace(ESCAPE_REGEX_CHARACTERS, "\\$&");
};
var HOST_PLACEHOLDER_REGEX = /(?<=^https:\\\/\\\/[^/]*?):([A-Za-z]\w*)(?=\\)/g;
var PLACEHOLDER_REGEX = /:([A-Za-z]\w*)/g;
var replacer = (str, replacements) => {
for (const [replacement, value] of Object.entries(replacements)) {
str = str.replaceAll(`:${replacement}`, value);
}
return str;
};
var generateRulesMatcher = (rules, replacerFn = (match) => match) => {
if (!rules) {
return () => [];
}
const compiledRules = Object.entries(rules).map(([rule, match]) => {
const crossHost = rule.startsWith("https://");
rule = rule.split("*").map(escapeRegex).join("(?<splat>.*)");
const host_matches = rule.matchAll(HOST_PLACEHOLDER_REGEX);
for (const host_match of host_matches) {
rule = rule.split(host_match[0]).join(`(?<${host_match[1]}>[^/.]+)`);
}
const path_matches = rule.matchAll(PLACEHOLDER_REGEX);
for (const path_match of path_matches) {
rule = rule.split(path_match[0]).join(`(?<${path_match[1]}>[^/]+)`);
}
rule = "^" + rule + "$";
try {
const regExp = new RegExp(rule);
return [{ crossHost, regExp }, match];
} catch {
}
}).filter((value) => value !== void 0);
return ({ request }) => {
const { pathname, hostname } = new URL(request.url);
return compiledRules.map(([{ crossHost, regExp }, match]) => {
const test = crossHost ? `https://${hostname}${pathname}` : pathname;
const result = regExp.exec(test);
if (result) {
return replacerFn(match, result.groups || {});
}
}).filter((value) => value !== void 0);
};
};
// node-path.js
var relative = () => {
throw new Error("Not implemented");
};
// ../workers-sdk/packages/workers-shared/utils/configuration/constants.ts
var REDIRECTS_VERSION = 1;
var PERMITTED_STATUS_CODES = /* @__PURE__ */ new Set([200, 301, 302, 303, 307, 308]);
var MAX_LINE_LENGTH = 2e3;
var MAX_DYNAMIC_REDIRECT_RULES = 100;
var MAX_STATIC_REDIRECT_RULES = 2e3;
var SPLAT_REGEX = /\*/g;
var PLACEHOLDER_REGEX2 = /:[A-Za-z]\w*/g;
// ../workers-sdk/packages/workers-shared/utils/configuration/constructConfiguration.ts
function constructRedirects({
redirects,
redirectsFile,
logger
}) {
if (!redirects) {
return {};
}
const num_valid = redirects.rules.length;
const num_invalid = redirects.invalid.length;
const redirectsRelativePath = redirectsFile ? relative(process.cwd(), redirectsFile) : "";
logger.log(
`\u2728 Parsed ${num_valid} valid redirect rule${num_valid === 1 ? "" : "s"}.`
);
if (num_invalid > 0) {
let invalidRedirectRulesList = ``;
for (const { line, lineNumber, message } of redirects.invalid) {
invalidRedirectRulesList += `\u25B6\uFE0E ${message}
`;
if (line) {
invalidRedirectRulesList += ` at ${redirectsRelativePath}${lineNumber ? `:${lineNumber}` : ""} | ${line}
`;
}
}
logger.warn(
`Found ${num_invalid} invalid redirect rule${num_invalid === 1 ? "" : "s"}:
${invalidRedirectRulesList}`
);
}
if (num_valid === 0) {
return {};
}
const staticRedirects = {};
const dynamicRedirects = {};
let canCreateStaticRule = true;
for (const rule of redirects.rules) {
if (!rule.from.match(SPLAT_REGEX) && !rule.from.match(PLACEHOLDER_REGEX2)) {
if (canCreateStaticRule) {
staticRedirects[rule.from] = {
status: rule.status,
to: rule.to,
lineNumber: rule.lineNumber
};
continue;
} else {
logger.info(
`The redirect rule ${rule.from} \u2192 ${rule.status} ${rule.to} could be made more performant by bringing it above any lines with splats or placeholders.`
);
}
}
dynamicRedirects[rule.from] = { status: rule.status, to: rule.to };
canCreateStaticRule = false;
}
return {
redirects: {
version: REDIRECTS_VERSION,
staticRules: staticRedirects,
rules: dynamicRedirects
}
};
}
// ../workers-sdk/packages/workers-shared/utils/configuration/validateURL.ts
var extractPathname = (path = "/", includeSearch, includeHash) => {
if (!path.startsWith("/")) {
path = `/${path}`;
}
const url = new URL(`//${path}`, "relative://");
return `${url.pathname}${includeSearch ? url.search : ""}${includeHash ? url.hash : ""}`;
};
var URL_REGEX = /^https:\/\/+(?<host>[^/]+)\/?(?<path>.*)/;
var HOST_WITH_PORT_REGEX = /.*:\d+$/;
var PATH_REGEX = /^\//;
var validateUrl = (token, onlyRelative = false, disallowPorts = false, includeSearch = false, includeHash = false) => {
const host = URL_REGEX.exec(token);
if (host && host.groups && host.groups.host) {
if (onlyRelative) {
return [
void 0,
`Only relative URLs are allowed. Skipping absolute URL ${token}.`
];
}
if (disallowPorts && host.groups.host.match(HOST_WITH_PORT_REGEX)) {
return [
void 0,
`Specifying ports is not supported. Skipping absolute URL ${token}.`
];
}
return [
`https://${host.groups.host}${extractPathname(
host.groups.path,
includeSearch,
includeHash
)}`,
void 0
];
} else {
if (!token.startsWith("/") && onlyRelative) {
token = `/${token}`;
}
const path = PATH_REGEX.exec(token);
if (path) {
try {
return [extractPathname(token, includeSearch, includeHash), void 0];
} catch {
return [void 0, `Error parsing URL segment ${token}. Skipping.`];
}
}
}
return [
void 0,
onlyRelative ? "URLs should begin with a forward-slash." : 'URLs should either be relative (e.g. begin with a forward-slash), or use HTTPS (e.g. begin with "https://").'
];
};
function urlHasHost(token) {
const host = URL_REGEX.exec(token);
return Boolean(host && host.groups && host.groups.host);
}
// ../workers-sdk/packages/workers-shared/utils/configuration/parseRedirects.ts
function parseRedirects(input, {
maxStaticRules = MAX_STATIC_REDIRECT_RULES,
maxDynamicRules = MAX_DYNAMIC_REDIRECT_RULES,
maxLineLength = MAX_LINE_LENGTH
} = {}) {
const lines = input.split("\n");
const rules = [];
const seen_paths = /* @__PURE__ */ new Set();
const invalid = [];
let staticRules = 0;
let dynamicRules = 0;
let canCreateStaticRule = true;
for (let i = 0; i < lines.length; i++) {
const line = lines[i]?.trim();
if (line?.length === 0 || line?.startsWith("#")) {
continue;
}
if ((line?.length ?? NaN) > maxLineLength) {
invalid.push({
message: `Ignoring line ${i + 1} as it exceeds the maximum allowed length of ${maxLineLength}.`
});
continue;
}
const tokens = line?.split(/\s+/);
if ((tokens?.length ?? NaN) < 2 || (tokens?.length ?? NaN) > 3) {
invalid.push({
line,
lineNumber: i + 1,
message: `Expected exactly 2 or 3 whitespace-separated tokens. Got ${tokens?.length}.`
});
continue;
}
const [str_from, str_to, str_status = "302"] = tokens;
const fromResult = validateUrl(str_from, true, true, false, false);
if (fromResult[0] === void 0) {
invalid.push({
line,
lineNumber: i + 1,
message: fromResult[1]
});
continue;
}
const from = fromResult[0];
if (canCreateStaticRule && !from.match(SPLAT_REGEX) && !from.match(PLACEHOLDER_REGEX2)) {
staticRules += 1;
if (staticRules > maxStaticRules) {
invalid.push({
message: `Maximum number of static rules supported is ${maxStaticRules}. Skipping line.`
});
continue;
}
} else {
dynamicRules += 1;
canCreateStaticRule = false;
if (dynamicRules > maxDynamicRules) {
invalid.push({
message: `Maximum number of dynamic rules supported is ${maxDynamicRules}. Skipping remaining ${lines.length - i} lines of file.`
});
break;
}
}
const toResult = validateUrl(str_to, false, false, true, true);
if (toResult[0] === void 0) {
invalid.push({
line,
lineNumber: i + 1,
message: toResult[1]
});
continue;
}
const to = toResult[0];
const status = Number(str_status);
if (isNaN(status) || !PERMITTED_STATUS_CODES.has(status)) {
invalid.push({
line,
lineNumber: i + 1,
message: `Valid status codes are 200, 301, 302 (default), 303, 307, or 308. Got ${str_status}.`
});
continue;
}
if (/\/\*?$/.test(from) && /\/index(.html)?$/.test(to) && !urlHasHost(to)) {
invalid.push({
line,
lineNumber: i + 1,
message: "Infinite loop detected in this rule and has been ignored. This will cause a redirect to strip `.html` or `/index` and end up triggering this rule again. Please fix or remove this rule to silence this warning."
});
continue;
}
if (seen_paths.has(from)) {
invalid.push({
line,
lineNumber: i + 1,
message: `Ignoring duplicate rule for path ${from}.`
});
continue;
}
seen_paths.add(from);
if (status === 200) {
if (urlHasHost(to)) {
invalid.push({
line,
lineNumber: i + 1,
message: `Proxy (200) redirects can only point to relative paths. Got ${to}`
});
continue;
}
}
rules.push({ from, to, status, lineNumber: i + 1 });
}
return {
rules,
invalid
};
}
// ../workers-sdk/packages/workers-shared/utils/responses.ts
var OkResponse = class _OkResponse extends Response {
static {
this.status = 200;
}
constructor(body, init) {
super(body, {
...init,
status: _OkResponse.status
});
}
};
var NotFoundResponse = class _NotFoundResponse extends Response {
static {
this.status = 404;
}
constructor(...[body, init]) {
super(body, {
...init,
status: _NotFoundResponse.status,
statusText: "Not Found"
});
}
};
var MethodNotAllowedResponse = class _MethodNotAllowedResponse extends Response {
static {
this.status = 405;
}
constructor(...[body, init]) {
super(body, {
...init,
status: _MethodNotAllowedResponse.status,
statusText: "Method Not Allowed"
});
}
};
var InternalServerErrorResponse = class _InternalServerErrorResponse extends Response {
static {
this.status = 500;
}
constructor(err, init) {
super(null, {
...init,
status: _InternalServerErrorResponse.status
});
}
};
var NotModifiedResponse = class _NotModifiedResponse extends Response {
static {
this.status = 304;
}
constructor(...[_body, init]) {
super(null, {
...init,
status: _NotModifiedResponse.status,
statusText: "Not Modified"
});
}
};
var MovedPermanentlyResponse = class _MovedPermanentlyResponse extends Response {
static {
this.status = 301;
}
constructor(location, init) {
super(null, {
...init,
status: _MovedPermanentlyResponse.status,
statusText: "Moved Permanently",
headers: {
...init?.headers,
Location: location
}
});
}
};
var FoundResponse = class _FoundResponse extends Response {
static {
this.status = 302;
}
constructor(location, init) {
super(null, {
...init,
status: _FoundResponse.status,
statusText: "Found",
headers: {
...init?.headers,
Location: location
}
});
}
};
var SeeOtherResponse = class _SeeOtherResponse extends Response {
static {
this.status = 303;
}
constructor(location, init) {
super(null, {
...init,
status: _SeeOtherResponse.status,
statusText: "See Other",
headers: {
...init?.headers,
Location: location
}
});
}
};
var TemporaryRedirectResponse = class _TemporaryRedirectResponse extends Response {
static {
this.status = 307;
}
constructor(location, init) {
super(null, {
...init,
status: _TemporaryRedirectResponse.status,
statusText: "Temporary Redirect",
headers: {
...init?.headers,
Location: location
}
});
}
};
var PermanentRedirectResponse = class _PermanentRedirectResponse extends Response {
static {
this.status = 308;
}
constructor(location, init) {
super(null, {
...init,
status: _PermanentRedirectResponse.status,
statusText: "Permanent Redirect",
headers: {
...init?.headers,
Location: location
}
});
}
};
// src/index.ts
var REDIRECTS_VERSION2 = 1;
var generateRedirectsEvaluator = (redirectsFileContents, {
maxLineLength,
maxStaticRules,
maxDynamicRules
} = {}) => {
const redirects = parseRedirects(redirectsFileContents, {
maxLineLength,
// Usually 2_000
maxStaticRules,
// Usually 2_000
maxDynamicRules
// Usually 100
});
const metadata = constructRedirects({
redirects,
logger: {
debug: console.debug,
log: console.log,
info: console.info,
warn: console.warn,
error: console.error
}
});
const staticRules = metadata.redirects?.version === REDIRECTS_VERSION2 ? metadata.redirects.staticRules || {} : {};
return async (request, assetsBinding) => {
const url = new URL(request.url);
const search = url.search;
let { pathname } = new URL(request.url);
const staticRedirectsMatcher = () => {
return staticRules[pathname];
};
const generateRedirectsMatcher = () => generateRulesMatcher(
metadata.redirects?.version === REDIRECTS_VERSION2 ? metadata.redirects.rules : {},
({ status, to }, replacements) => ({
status,
to: replacer(to, replacements)
})
);
const match = staticRedirectsMatcher() || generateRedirectsMatcher()({ request })[0];
if (match) {
if (match.status === 200) {
pathname = new URL(match.to, request.url).pathname;
return assetsBinding.fetch(
new Request(new URL(pathname + search, request.url), { ...request })
);
} else {
const { status, to } = match;
const destination = new URL(to, request.url);
const location = destination.origin === new URL(request.url).origin ? `${destination.pathname}${destination.search || search}${destination.hash}` : `${destination.href.slice(
0,
destination.href.length - (destination.search.length + destination.hash.length)
)}${destination.search ? destination.search : search}${destination.hash}`;
switch (status) {
case 301:
return new MovedPermanentlyResponse(location);
case 303:
return new SeeOtherResponse(location);
case 307:
return new TemporaryRedirectResponse(location);
case 308:
return new PermanentRedirectResponse(location);
case 302:
default:
return new FoundResponse(location);
}
}
}
return null;
};
};
export {
generateRedirectsEvaluator
};
//# sourceMappingURL=index.js.map