UNPKG

redirects-in-workers

Version:

Cloudflare Pages' _redirects file support in Cloudflare Workers

515 lines (506 loc) 15.2 kB
// ../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