chore: import upstream snapshot with attribution
Deploy (to testing) and Test Playground Preview Worker / Deploy Playground Preview Worker (testing) (push) Has been skipped
Deploy Workers Shared Staging / Deploy Workers Shared Staging (push) Failing after 0s
Prerelease / build (push) Has been skipped
Handle Changesets / Handle Changesets (push) Has been cancelled
Semgrep OSS scan / semgrep-oss (push) Has been cancelled
Deploy (to testing) and Test Playground Preview Worker / Deploy Playground Preview Worker (testing) (push) Has been skipped
Deploy Workers Shared Staging / Deploy Workers Shared Staging (push) Failing after 0s
Prerelease / build (push) Has been skipped
Handle Changesets / Handle Changesets (push) Has been cancelled
Semgrep OSS scan / semgrep-oss (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
export const REDIRECTS_VERSION = 1;
|
||||
export const HEADERS_VERSION = 2;
|
||||
|
||||
export const PERMITTED_STATUS_CODES = new Set([200, 301, 302, 303, 307, 308]);
|
||||
export const HEADER_SEPARATOR = ":";
|
||||
export const MAX_LINE_LENGTH = 2000;
|
||||
export const MAX_HEADER_RULES = 100;
|
||||
export const MAX_DYNAMIC_REDIRECT_RULES = 100;
|
||||
export const MAX_STATIC_REDIRECT_RULES = 2000;
|
||||
export const UNSET_OPERATOR = "! ";
|
||||
|
||||
export const SPLAT_REGEX = /\*/g;
|
||||
export const PLACEHOLDER_REGEX = /:[A-Za-z]\w*/g;
|
||||
|
||||
/** Max number of rules in `run_worker_first` */
|
||||
export const MAX_ROUTES_RULES = 100;
|
||||
/** Max char length of each rule in `run_worker_first` */
|
||||
export const MAX_ROUTES_RULE_LENGTH = 100;
|
||||
@@ -0,0 +1,164 @@
|
||||
import { relative } from "node:path";
|
||||
import {
|
||||
HEADERS_VERSION,
|
||||
PLACEHOLDER_REGEX,
|
||||
REDIRECTS_VERSION,
|
||||
SPLAT_REGEX,
|
||||
} from "./constants";
|
||||
import type {
|
||||
AssetConfig,
|
||||
MetadataHeaders,
|
||||
MetadataRedirects,
|
||||
MetadataStaticRedirects,
|
||||
} from "../types";
|
||||
import type { Logger, ParsedHeaders, ParsedRedirects } from "./types";
|
||||
|
||||
export function constructRedirects({
|
||||
redirects,
|
||||
redirectsFile,
|
||||
logger,
|
||||
}: {
|
||||
redirects?: ParsedRedirects;
|
||||
redirectsFile?: string;
|
||||
logger: Logger;
|
||||
}): Pick<AssetConfig, "redirects"> {
|
||||
if (!redirects) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const num_valid = redirects.rules.length;
|
||||
const num_invalid = redirects.invalid.length;
|
||||
|
||||
// exhaustive check, since we could not have parsed `redirects` out of
|
||||
// a non-existing redirects file
|
||||
const redirectsRelativePath = redirectsFile
|
||||
? relative(process.cwd(), redirectsFile)
|
||||
: "";
|
||||
|
||||
logger.log(
|
||||
`✨ 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 += `▶︎ ${message}\n`;
|
||||
|
||||
if (line) {
|
||||
invalidRedirectRulesList += ` at ${redirectsRelativePath}${lineNumber ? `:${lineNumber}` : ""} | ${line}\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`Found ${num_invalid} invalid redirect rule${num_invalid === 1 ? "" : "s"}:\n` +
|
||||
`${invalidRedirectRulesList}`
|
||||
);
|
||||
}
|
||||
|
||||
/* Better to return no Redirects object at all than one with empty rules */
|
||||
if (num_valid === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const staticRedirects: MetadataStaticRedirects = {};
|
||||
const dynamicRedirects: MetadataRedirects = {};
|
||||
let canCreateStaticRule = true;
|
||||
for (const rule of redirects.rules) {
|
||||
if (!rule.from.match(SPLAT_REGEX) && !rule.from.match(PLACEHOLDER_REGEX)) {
|
||||
if (canCreateStaticRule) {
|
||||
staticRedirects[rule.from] = {
|
||||
status: rule.status,
|
||||
to: rule.to,
|
||||
lineNumber: rule.lineNumber,
|
||||
};
|
||||
continue;
|
||||
} else {
|
||||
logger.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.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
dynamicRedirects[rule.from] = { status: rule.status, to: rule.to };
|
||||
canCreateStaticRule = false;
|
||||
}
|
||||
|
||||
return {
|
||||
redirects: {
|
||||
version: REDIRECTS_VERSION,
|
||||
staticRules: staticRedirects,
|
||||
rules: dynamicRedirects,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function constructHeaders({
|
||||
headers,
|
||||
headersFile,
|
||||
logger,
|
||||
}: {
|
||||
headers?: ParsedHeaders;
|
||||
headersFile?: string;
|
||||
logger: Logger;
|
||||
}): Pick<AssetConfig, "headers"> {
|
||||
if (!headers) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const num_valid = headers.rules.length;
|
||||
const num_invalid = headers.invalid.length;
|
||||
|
||||
// exhaustive check, since we could not have parsed `headers` out of
|
||||
// a non-existing headers file
|
||||
const headersRelativePath = headersFile
|
||||
? relative(process.cwd(), headersFile)
|
||||
: "";
|
||||
|
||||
logger.log(
|
||||
`✨ Parsed ${num_valid} valid header rule${num_valid === 1 ? "" : "s"}.`
|
||||
);
|
||||
|
||||
if (num_invalid > 0) {
|
||||
let invalidHeaderRulesList = ``;
|
||||
|
||||
for (const { line, lineNumber, message } of headers.invalid) {
|
||||
invalidHeaderRulesList += `▶︎ ${message}\n`;
|
||||
|
||||
if (line) {
|
||||
invalidHeaderRulesList += ` at ${headersRelativePath}${lineNumber ? `:${lineNumber}` : ""} | ${line}\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`Found ${num_invalid} invalid header rule${num_invalid === 1 ? "" : "s"}:\n` +
|
||||
`${invalidHeaderRulesList}`
|
||||
);
|
||||
}
|
||||
|
||||
/* Better to return no Headers object at all than one with empty rules */
|
||||
if (num_valid === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const rules: MetadataHeaders = {};
|
||||
for (const rule of headers.rules) {
|
||||
const configuredRule: MetadataHeaders[string] = {};
|
||||
|
||||
if (Object.keys(rule.headers).length) {
|
||||
configuredRule.set = rule.headers;
|
||||
}
|
||||
if (rule.unsetHeaders.length) {
|
||||
configuredRule.unset = rule.unsetHeaders;
|
||||
}
|
||||
|
||||
rules[rule.path] = configuredRule;
|
||||
}
|
||||
|
||||
return {
|
||||
headers: {
|
||||
version: HEADERS_VERSION,
|
||||
rules,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import {
|
||||
HEADER_SEPARATOR,
|
||||
MAX_HEADER_RULES,
|
||||
MAX_LINE_LENGTH,
|
||||
SPLAT_REGEX,
|
||||
UNSET_OPERATOR,
|
||||
} from "./constants";
|
||||
import { validateUrl } from "./validateURL";
|
||||
import type { HeadersRule, InvalidHeadersRule, ParsedHeaders } from "./types";
|
||||
|
||||
// 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).
|
||||
// But we're checking here since some people might be caught out and it'll help 99.9% of people who get it wrong.
|
||||
// We do the proper validation in `validateUrl` anyway :)
|
||||
const LINE_IS_PROBABLY_A_PATH = new RegExp(/^([^\s]+:\/\/|^\/)/);
|
||||
|
||||
export function parseHeaders(
|
||||
input: string,
|
||||
{
|
||||
maxRules = MAX_HEADER_RULES,
|
||||
maxLineLength = MAX_LINE_LENGTH,
|
||||
}: { maxRules?: number; maxLineLength?: number } = {}
|
||||
): ParsedHeaders {
|
||||
const lines = input.split("\n");
|
||||
const rules: HeadersRule[] = [];
|
||||
const invalid: InvalidHeadersRule[] = [];
|
||||
|
||||
let rule: (HeadersRule & { line: string }) | undefined = undefined;
|
||||
// When a path line is rejected (invalid URL, multiple wildcards, etc.),
|
||||
// we silently skip subsequent header lines until the next path line
|
||||
// rather than emitting confusing "Path should come before header" errors.
|
||||
let skipUntilNextPath = false;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = (lines[i] || "").trim();
|
||||
if (line.length === 0 || line.startsWith("#")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.length > maxLineLength) {
|
||||
invalid.push({
|
||||
message: `Ignoring line ${
|
||||
i + 1
|
||||
} as it exceeds the maximum allowed length of ${maxLineLength}.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (LINE_IS_PROBABLY_A_PATH.test(line)) {
|
||||
skipUntilNextPath = false;
|
||||
|
||||
if (rules.length >= maxRules) {
|
||||
invalid.push({
|
||||
message: `Maximum number of rules supported is ${maxRules}. Skipping remaining ${
|
||||
lines.length - i
|
||||
} lines of file.`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
if (rule) {
|
||||
if (isValidRule(rule)) {
|
||||
rules.push({
|
||||
path: rule.path,
|
||||
headers: rule.headers,
|
||||
unsetHeaders: rule.unsetHeaders,
|
||||
});
|
||||
} else {
|
||||
invalid.push({
|
||||
line: rule.line,
|
||||
lineNumber: i + 1,
|
||||
message: "No headers specified",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const [path, pathError] = validateUrl(line, false, true);
|
||||
if (pathError) {
|
||||
invalid.push({
|
||||
line,
|
||||
lineNumber: i + 1,
|
||||
message: pathError,
|
||||
});
|
||||
rule = undefined;
|
||||
skipUntilNextPath = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const wildcardError = validateNoMultipleWildcards(path as string);
|
||||
if (wildcardError) {
|
||||
invalid.push({
|
||||
line,
|
||||
lineNumber: i + 1,
|
||||
message: wildcardError,
|
||||
});
|
||||
rule = undefined;
|
||||
skipUntilNextPath = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
rule = {
|
||||
path: path as string,
|
||||
line,
|
||||
headers: {},
|
||||
unsetHeaders: [],
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (skipUntilNextPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!line.includes(HEADER_SEPARATOR)) {
|
||||
if (!rule) {
|
||||
invalid.push({
|
||||
line,
|
||||
lineNumber: i + 1,
|
||||
message: "Expected a path beginning with at least one forward-slash",
|
||||
});
|
||||
} else {
|
||||
if (line.trim().startsWith(UNSET_OPERATOR)) {
|
||||
rule.unsetHeaders.push(line.trim().replace(UNSET_OPERATOR, ""));
|
||||
} else {
|
||||
invalid.push({
|
||||
line,
|
||||
lineNumber: i + 1,
|
||||
message:
|
||||
"Expected a colon-separated header pair (e.g. name: value)",
|
||||
});
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const [rawName, ...rawValue] = line.split(HEADER_SEPARATOR);
|
||||
const name = (rawName || "").trim().toLowerCase();
|
||||
|
||||
if (name.includes(" ")) {
|
||||
invalid.push({
|
||||
line,
|
||||
lineNumber: i + 1,
|
||||
message: "Header name cannot include spaces",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = rawValue.join(HEADER_SEPARATOR).trim();
|
||||
|
||||
if (name === "") {
|
||||
invalid.push({
|
||||
line,
|
||||
lineNumber: i + 1,
|
||||
message: "No header name specified",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (value === "") {
|
||||
invalid.push({
|
||||
line,
|
||||
lineNumber: i + 1,
|
||||
message: "No header value specified",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!rule) {
|
||||
invalid.push({
|
||||
line,
|
||||
lineNumber: i + 1,
|
||||
message: `Path should come before header (${name}: ${value})`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingValues = rule.headers[name];
|
||||
rule.headers[name] = existingValues ? `${existingValues}, ${value}` : value;
|
||||
}
|
||||
|
||||
if (rule) {
|
||||
if (isValidRule(rule)) {
|
||||
rules.push({
|
||||
path: rule.path,
|
||||
headers: rule.headers,
|
||||
unsetHeaders: rule.unsetHeaders,
|
||||
});
|
||||
} else {
|
||||
invalid.push({ line: rule.line, message: "No headers specified" });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
rules,
|
||||
invalid,
|
||||
};
|
||||
}
|
||||
|
||||
function isValidRule(rule: HeadersRule) {
|
||||
return Object.keys(rule.headers).length > 0 || rule.unsetHeaders.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* At runtime, `*` wildcards are converted to `:splat` placeholders. This means
|
||||
* a path with multiple wildcards, or a wildcard combined with an explicit
|
||||
* `:splat` placeholder, would result in duplicate `:splat` parameters which is
|
||||
* unsupported.
|
||||
*/
|
||||
function validateNoMultipleWildcards(path: string): string | undefined {
|
||||
const wildcardCount = (path.match(SPLAT_REGEX) ?? []).length;
|
||||
const hasSplatPlaceholder = /:splat(?!\w)/.test(path);
|
||||
|
||||
if (wildcardCount > 1) {
|
||||
return `Only one wildcard is allowed per rule. Use a named placeholder (e.g. :project) instead. Skipping ${path}.`;
|
||||
}
|
||||
|
||||
if (wildcardCount > 0 && hasSplatPlaceholder) {
|
||||
return `Cannot combine a wildcard * with a :splat placeholder because wildcards are converted to :splat at runtime. Skipping ${path}.`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import {
|
||||
MAX_DYNAMIC_REDIRECT_RULES,
|
||||
MAX_LINE_LENGTH,
|
||||
MAX_STATIC_REDIRECT_RULES,
|
||||
PERMITTED_STATUS_CODES,
|
||||
PLACEHOLDER_REGEX,
|
||||
SPLAT_REGEX,
|
||||
} from "./constants";
|
||||
import { urlHasHost, validateUrl } from "./validateURL";
|
||||
import type { AssetConfig } from "../types";
|
||||
import type {
|
||||
InvalidRedirectRule,
|
||||
ParsedRedirects,
|
||||
RedirectLine,
|
||||
RedirectRule,
|
||||
} from "./types";
|
||||
|
||||
export function parseRedirects(
|
||||
input: string,
|
||||
{
|
||||
htmlHandling = undefined,
|
||||
maxStaticRules = MAX_STATIC_REDIRECT_RULES,
|
||||
maxDynamicRules = MAX_DYNAMIC_REDIRECT_RULES,
|
||||
maxLineLength = MAX_LINE_LENGTH,
|
||||
}: {
|
||||
htmlHandling?: AssetConfig["html_handling"];
|
||||
maxStaticRules?: number;
|
||||
maxDynamicRules?: number;
|
||||
maxLineLength?: number;
|
||||
} = {}
|
||||
): ParsedRedirects {
|
||||
const lines = input.split("\n");
|
||||
const rules: RedirectRule[] = [];
|
||||
const seen_paths = new Set<string>();
|
||||
const invalid: InvalidRedirectRule[] = [];
|
||||
|
||||
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 > maxLineLength) {
|
||||
invalid.push({
|
||||
message: `Ignoring line ${
|
||||
i + 1
|
||||
} as it exceeds the maximum allowed length of ${maxLineLength}.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle inline comments: strip off from the first `#` token that starts a comment, but not URL fragments.
|
||||
// This allows `/a /b#fragment` but strips `/a /b # comment`
|
||||
const tokens = line.replace(/\s+#.*$/, "").split(/\s+/);
|
||||
|
||||
if (tokens.length < 2 || tokens.length > 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 as RedirectLine;
|
||||
|
||||
const fromResult = validateUrl(str_from, true, true, false, false);
|
||||
if (fromResult[0] === undefined) {
|
||||
invalid.push({
|
||||
line,
|
||||
lineNumber: i + 1,
|
||||
message: fromResult[1],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const from = fromResult[0];
|
||||
|
||||
if (
|
||||
canCreateStaticRule &&
|
||||
!from.match(SPLAT_REGEX) &&
|
||||
!from.match(PLACEHOLDER_REGEX)
|
||||
) {
|
||||
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] === undefined) {
|
||||
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;
|
||||
}
|
||||
|
||||
// Here we reject two patterns:
|
||||
// 1. `/* /index.html` Is always rejected.
|
||||
// 2. `/ /index` Is rejected when HTML handling is enabled.
|
||||
// Allowing the redirect in other cases will cause TOO_MANY_REDIRECTS errors as the asset Worker will
|
||||
// redirect it back to `/` by removing the `/index.html`.
|
||||
// We only want to run this on relative URLs.
|
||||
const hasRelativePath = !urlHasHost(to);
|
||||
const hasWildcardToIndex =
|
||||
from.endsWith("/*") && /\/index(.html)?$/.test(to);
|
||||
const hasRootToIndex = from.endsWith("/") && /\/index(.html)?$/.test(to);
|
||||
const hasHTMLHandling = htmlHandling !== "none"; // HTML handling is enabled by default.
|
||||
if (
|
||||
hasRelativePath &&
|
||||
(hasWildcardToIndex || (hasRootToIndex && hasHTMLHandling))
|
||||
) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { MAX_ROUTES_RULE_LENGTH, MAX_ROUTES_RULES } from "./constants";
|
||||
import type { StaticRouting } from "../types";
|
||||
|
||||
// copy of what EWC does. Wrangler uploads the rules in one array (so the API is consistent with Wrangler config),
|
||||
// but router Worker expects the rules to be split into two arrays, which we do here.
|
||||
// This logic is translated from assets/staticrouting.go.
|
||||
|
||||
export function parseStaticRouting(input: string[]): StaticRouting {
|
||||
if (input.length === 0) {
|
||||
throw new Error(
|
||||
"No `run_worker_first` rules were provided; must provide at least 1 rule."
|
||||
);
|
||||
}
|
||||
if (input.length > MAX_ROUTES_RULES) {
|
||||
throw new Error(
|
||||
`Too many \`run_worker_first\` rules were provided; ${input.length} rules provided exceeds max of ${MAX_ROUTES_RULES}.`
|
||||
);
|
||||
}
|
||||
|
||||
const rawAssetWorkerRules = [];
|
||||
const assetWorkerRules = [];
|
||||
const userWorkerRules = [];
|
||||
const invalidRules = [];
|
||||
|
||||
for (const rule of input) {
|
||||
if (rule.startsWith("!/")) {
|
||||
assetWorkerRules.push(rule.slice(1)); // Remove leading !
|
||||
rawAssetWorkerRules.push(rule);
|
||||
} else if (rule.startsWith("/")) {
|
||||
userWorkerRules.push(rule);
|
||||
} else if (rule.startsWith("!")) {
|
||||
invalidRules.push(`'${rule}': negative rules must start with '!/'`);
|
||||
} else {
|
||||
invalidRules.push(`'${rule}': rules must start with '/' or '!/'`);
|
||||
}
|
||||
}
|
||||
|
||||
if (assetWorkerRules.length > 0 && userWorkerRules.length === 0) {
|
||||
throw new Error(
|
||||
"Only negative `run_worker_first` rules were provided; must provide at least 1 non-negative rule"
|
||||
);
|
||||
}
|
||||
|
||||
const invalidAssetWorkerRules =
|
||||
validateStaticRoutingRules(rawAssetWorkerRules);
|
||||
const invalidUserWorkerRules = validateStaticRoutingRules(userWorkerRules);
|
||||
const errorMessage = formatInvalidRoutes([
|
||||
...invalidRules,
|
||||
...invalidUserWorkerRules,
|
||||
...invalidAssetWorkerRules,
|
||||
]);
|
||||
|
||||
if (errorMessage) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return { asset_worker: assetWorkerRules, user_worker: userWorkerRules };
|
||||
}
|
||||
|
||||
function validateStaticRoutingRules(rules: string[]): string[] {
|
||||
const invalid: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const rule of rules) {
|
||||
if (rule.length > MAX_ROUTES_RULE_LENGTH) {
|
||||
invalid.push(
|
||||
`'${rule}': all rules must be less than ${MAX_ROUTES_RULE_LENGTH} characters in length`
|
||||
);
|
||||
}
|
||||
if (seen.has(rule)) {
|
||||
invalid.push(`'${rule}': rule is a duplicate; rules must be unique`);
|
||||
}
|
||||
if (rule.endsWith("*")) {
|
||||
// Check for redundant rules due to a glob
|
||||
for (const otherRule of rules) {
|
||||
if (otherRule !== rule && otherRule.startsWith(rule.slice(0, -1))) {
|
||||
invalid.push(`'${otherRule}': rule '${rule}' makes it redundant`);
|
||||
}
|
||||
}
|
||||
}
|
||||
seen.add(rule);
|
||||
}
|
||||
return invalid;
|
||||
}
|
||||
|
||||
const formatInvalidRoutes = (invalidRules: string[]) => {
|
||||
if (invalidRules.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return `Invalid routes in \`run_worker_first\`:\n` + invalidRules.join("\n");
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
export type RedirectLine = [from: string, to: string, status?: number];
|
||||
export type RedirectRule = {
|
||||
from: string;
|
||||
to: string;
|
||||
status: number;
|
||||
lineNumber: number;
|
||||
};
|
||||
|
||||
export type Headers = Record<string, string>;
|
||||
export type HeadersRule = {
|
||||
path: string;
|
||||
headers: Headers;
|
||||
unsetHeaders: string[];
|
||||
};
|
||||
|
||||
export type InvalidRedirectRule = {
|
||||
line?: string;
|
||||
lineNumber?: number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type InvalidHeadersRule = {
|
||||
line?: string;
|
||||
lineNumber?: number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ParsedRedirects = {
|
||||
invalid: InvalidRedirectRule[];
|
||||
rules: RedirectRule[];
|
||||
};
|
||||
|
||||
export type ParsedHeaders = {
|
||||
invalid: InvalidHeadersRule[];
|
||||
rules: HeadersRule[];
|
||||
};
|
||||
|
||||
export interface Logger {
|
||||
debug: (message: string) => void;
|
||||
log: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
error: (error: Error) => void;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
export const extractPathname = (
|
||||
path = "/",
|
||||
includeSearch: boolean,
|
||||
includeHash: boolean
|
||||
): string => {
|
||||
if (!path.startsWith("/")) {
|
||||
path = `/${path}`;
|
||||
}
|
||||
const url = new URL(`//${path}`, "relative://");
|
||||
return `${url.pathname}${includeSearch ? url.search : ""}${
|
||||
includeHash ? url.hash : ""
|
||||
}`;
|
||||
};
|
||||
|
||||
const URL_REGEX = /^https:\/\/+(?<host>[^/]+)\/?(?<path>.*)/;
|
||||
const HOST_WITH_PORT_REGEX = /.*:\d+$/;
|
||||
const PATH_REGEX = /^\//;
|
||||
|
||||
export const validateUrl = (
|
||||
token: string,
|
||||
onlyRelative = false,
|
||||
disallowPorts = false,
|
||||
includeSearch = false,
|
||||
includeHash = false
|
||||
): [undefined, string] | [string, undefined] => {
|
||||
const host = URL_REGEX.exec(token);
|
||||
if (host && host.groups && host.groups.host) {
|
||||
if (onlyRelative) {
|
||||
return [
|
||||
undefined,
|
||||
`Only relative URLs are allowed. Skipping absolute URL ${token}.`,
|
||||
];
|
||||
}
|
||||
|
||||
if (disallowPorts && host.groups.host.match(HOST_WITH_PORT_REGEX)) {
|
||||
return [
|
||||
undefined,
|
||||
`Specifying ports is not supported. Skipping absolute URL ${token}.`,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
`https://${host.groups.host}${extractPathname(
|
||||
host.groups.path,
|
||||
includeSearch,
|
||||
includeHash
|
||||
)}`,
|
||||
undefined,
|
||||
];
|
||||
} else {
|
||||
if (!token.startsWith("/") && onlyRelative) {
|
||||
token = `/${token}`;
|
||||
}
|
||||
|
||||
const path = PATH_REGEX.exec(token);
|
||||
if (path) {
|
||||
try {
|
||||
return [extractPathname(token, includeSearch, includeHash), undefined];
|
||||
} catch {
|
||||
return [undefined, `Error parsing URL segment ${token}. Skipping.`];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
undefined,
|
||||
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://").',
|
||||
];
|
||||
};
|
||||
|
||||
export function urlHasHost(token: string): boolean {
|
||||
const host = URL_REGEX.exec(token);
|
||||
return Boolean(host && host.groups && host.groups.host);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// -- Constants for manifest encoding/decoding --
|
||||
// (header and tails are not currently being used for anything afaik)
|
||||
// NB these all refer to bytes
|
||||
|
||||
/** Reserved header at the start of the whole manifest, NOT in each entry (currently unused)
|
||||
* manifest = [HEADER, [ entry = PATH_HASH, CONTENT_HASH, TAIL], [entry], ... , [entry] ]
|
||||
*/
|
||||
export const HEADER_SIZE = 20;
|
||||
/** manifest = [HEADER, [ entry = PATH_HASH, CONTENT_HASH, TAIL], [entry], ... , [entry] ] */
|
||||
export const PATH_HASH_SIZE = 16;
|
||||
/** manifest = [HEADER, [ entry = PATH_HASH, CONTENT_HASH, TAIL], [entry], ... , [entry] ] */
|
||||
export const CONTENT_HASH_SIZE = 16;
|
||||
/** manifest = [HEADER, [ entry = PATH_HASH, CONTENT_HASH, TAIL], [entry], ... , [entry] ] */
|
||||
export const TAIL_SIZE = 8;
|
||||
/** offset of PATH_HASH from start of each entry
|
||||
* manifest = [HEADER, [ entry = PATH_HASH, CONTENT_HASH, TAIL], [entry], ... , [entry] ] */
|
||||
export const PATH_HASH_OFFSET = 0;
|
||||
/** offset of CONTENT_HASH from start of each entry
|
||||
* manifest = [HEADER, [ entry = PATH_HASH, CONTENT_HASH, TAIL], [entry], ... , [entry] ] */
|
||||
export const CONTENT_HASH_OFFSET = PATH_HASH_SIZE;
|
||||
/** manifest = [HEADER, [ entry = PATH_HASH, CONTENT_HASH, TAIL], [entry], ... , [entry] ] */
|
||||
export const ENTRY_SIZE = PATH_HASH_SIZE + CONTENT_HASH_SIZE + TAIL_SIZE;
|
||||
|
||||
// -- Manifest creation constants --
|
||||
// used in wrangler dev and deploy
|
||||
/**
|
||||
* Maximum number of assets that can be deployed with a Worker; this is a global
|
||||
* ceiling, and may vary by the user's subscription.
|
||||
*/
|
||||
export const MAX_ASSET_COUNT = 100_000;
|
||||
/** Maximum size per asset that can be deployed with a Worker */
|
||||
export const MAX_ASSET_SIZE = 25 * 1024 * 1024;
|
||||
|
||||
export const CF_ASSETS_IGNORE_FILENAME = ".assetsignore";
|
||||
export const REDIRECTS_FILENAME = "_redirects";
|
||||
export const HEADERS_FILENAME = "_headers";
|
||||
@@ -0,0 +1,93 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { isAbsolute, resolve, sep } from "node:path";
|
||||
import ignore from "ignore";
|
||||
import mime from "mime";
|
||||
import {
|
||||
CF_ASSETS_IGNORE_FILENAME,
|
||||
HEADERS_FILENAME,
|
||||
REDIRECTS_FILENAME,
|
||||
} from "./constants";
|
||||
import type { PathOrFileDescriptor } from "node:fs";
|
||||
|
||||
/** normalises sep for windows and prefix with `/` */
|
||||
export const normalizeFilePath = (relativeFilepath: string) => {
|
||||
if (isAbsolute(relativeFilepath)) {
|
||||
throw new Error(`Expected relative path`);
|
||||
}
|
||||
return "/" + relativeFilepath.split(sep).join("/");
|
||||
};
|
||||
|
||||
export const getContentType = (absFilePath: string) => {
|
||||
let contentType = mime.getType(absFilePath);
|
||||
if (
|
||||
contentType &&
|
||||
contentType.startsWith("text/") &&
|
||||
!contentType.includes("charset")
|
||||
) {
|
||||
contentType = `${contentType}; charset=utf-8`;
|
||||
}
|
||||
return contentType;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a function that can match relative filepaths against a list of gitignore formatted patterns.
|
||||
*/
|
||||
export function createPatternMatcher(
|
||||
patterns: string[],
|
||||
exclude: boolean
|
||||
): (filePath: string) => boolean {
|
||||
if (patterns.length === 0) {
|
||||
return (_filePath) => !exclude;
|
||||
} else {
|
||||
const ignorer = ignore().add(patterns);
|
||||
return (filePath) => ignorer.test(filePath).ignored;
|
||||
}
|
||||
}
|
||||
|
||||
export function thrownIsDoesNotExistError(
|
||||
thrown: unknown
|
||||
): thrown is Error & { code: "ENOENT" } {
|
||||
return (
|
||||
thrown instanceof Error && "code" in thrown && thrown.code === "ENOENT"
|
||||
);
|
||||
}
|
||||
|
||||
export function maybeGetFile(filePath: PathOrFileDescriptor) {
|
||||
try {
|
||||
return readFileSync(filePath, "utf8");
|
||||
} catch (e: unknown) {
|
||||
if (!thrownIsDoesNotExistError(e)) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a function for filtering out ignored assets.
|
||||
*
|
||||
* The generated function takes an asset path, relative to the asset directory,
|
||||
* and returns true if the asset should not be ignored.
|
||||
*/
|
||||
export async function createAssetsIgnoreFunction(dir: string) {
|
||||
const cfAssetIgnorePath = resolve(dir, CF_ASSETS_IGNORE_FILENAME);
|
||||
|
||||
const ignorePatterns = [
|
||||
// Ignore the `.assetsignore` file and other metafiles by default.
|
||||
// The ignore lib expects unix-style paths for its patterns
|
||||
`/${CF_ASSETS_IGNORE_FILENAME}`,
|
||||
`/${REDIRECTS_FILENAME}`,
|
||||
`/${HEADERS_FILENAME}`,
|
||||
];
|
||||
|
||||
let assetsIgnoreFilePresent = false;
|
||||
const assetsIgnore = maybeGetFile(cfAssetIgnorePath);
|
||||
if (assetsIgnore !== undefined) {
|
||||
assetsIgnoreFilePresent = true;
|
||||
ignorePatterns.push(...assetsIgnore.split("\n"));
|
||||
}
|
||||
|
||||
return {
|
||||
assetsIgnoreFunction: createPatternMatcher(ignorePatterns, true),
|
||||
assetsIgnoreFilePresent,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { UnsafePerformanceTimer } from "./types";
|
||||
|
||||
export class PerformanceTimer {
|
||||
private performanceTimer;
|
||||
|
||||
constructor(performanceTimer?: UnsafePerformanceTimer) {
|
||||
this.performanceTimer = performanceTimer;
|
||||
}
|
||||
|
||||
now() {
|
||||
if (this.performanceTimer) {
|
||||
return this.performanceTimer.timeOrigin + this.performanceTimer.now();
|
||||
}
|
||||
return Date.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
export class OkResponse extends Response {
|
||||
static readonly status = 200;
|
||||
|
||||
constructor(body: BodyInit | null, init?: ResponseInit) {
|
||||
super(body, {
|
||||
...init,
|
||||
status: OkResponse.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundResponse extends Response {
|
||||
static readonly status = 404;
|
||||
|
||||
constructor(...[body, init]: ConstructorParameters<typeof Response>) {
|
||||
super(body, {
|
||||
...init,
|
||||
status: NotFoundResponse.status,
|
||||
statusText: "Not Found",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// A magical response type which is used to signal that a user worker should be invoked if one is present.
|
||||
export class NoIntentResponse extends NotFoundResponse {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class MethodNotAllowedResponse extends Response {
|
||||
static readonly status = 405;
|
||||
|
||||
constructor(...[body, init]: ConstructorParameters<typeof Response>) {
|
||||
super(body, {
|
||||
...init,
|
||||
status: MethodNotAllowedResponse.status,
|
||||
statusText: "Method Not Allowed",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class InternalServerErrorResponse extends Response {
|
||||
static readonly status = 500;
|
||||
|
||||
constructor(_: Error, init?: ResponseInit) {
|
||||
super(null, {
|
||||
...init,
|
||||
status: InternalServerErrorResponse.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class NotModifiedResponse extends Response {
|
||||
static readonly status = 304;
|
||||
|
||||
constructor(...[_body, init]: ConstructorParameters<typeof Response>) {
|
||||
super(null, {
|
||||
...init,
|
||||
status: NotModifiedResponse.status,
|
||||
statusText: "Not Modified",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class MovedPermanentlyResponse extends Response {
|
||||
static readonly status = 301;
|
||||
|
||||
constructor(location: string, init?: ResponseInit) {
|
||||
super(null, {
|
||||
...init,
|
||||
status: MovedPermanentlyResponse.status,
|
||||
statusText: "Moved Permanently",
|
||||
headers: {
|
||||
...init?.headers,
|
||||
Location: location,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class FoundResponse extends Response {
|
||||
static readonly status = 302;
|
||||
|
||||
constructor(location: string, init?: ResponseInit) {
|
||||
super(null, {
|
||||
...init,
|
||||
status: FoundResponse.status,
|
||||
statusText: "Found",
|
||||
headers: {
|
||||
...init?.headers,
|
||||
Location: location,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class SeeOtherResponse extends Response {
|
||||
static readonly status = 303;
|
||||
|
||||
constructor(location: string, init?: ResponseInit) {
|
||||
super(null, {
|
||||
...init,
|
||||
status: SeeOtherResponse.status,
|
||||
statusText: "See Other",
|
||||
headers: {
|
||||
...init?.headers,
|
||||
Location: location,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class TemporaryRedirectResponse extends Response {
|
||||
static readonly status = 307;
|
||||
|
||||
constructor(location: string, init?: ResponseInit) {
|
||||
super(null, {
|
||||
...init,
|
||||
status: TemporaryRedirectResponse.status,
|
||||
statusText: "Temporary Redirect",
|
||||
headers: {
|
||||
...init?.headers,
|
||||
Location: location,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class PermanentRedirectResponse extends Response {
|
||||
static readonly status = 308;
|
||||
|
||||
constructor(location: string, init?: ResponseInit) {
|
||||
super(null, {
|
||||
...init,
|
||||
status: PermanentRedirectResponse.status,
|
||||
statusText: "Permanent Redirect",
|
||||
headers: {
|
||||
...init?.headers,
|
||||
Location: location,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { rewriteFramesIntegration, Toucan } from "toucan-js";
|
||||
import type { ColoMetadata } from "./types";
|
||||
|
||||
export function setupSentry(
|
||||
request: Request,
|
||||
context: ExecutionContext | undefined,
|
||||
dsn: string,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
coloMetadata?: ColoMetadata,
|
||||
versionMetadata?: WorkerVersionMetadata,
|
||||
accountId?: number,
|
||||
scriptId?: number
|
||||
): Toucan | undefined {
|
||||
// Are we running locally without access to Sentry secrets? If so, don't initialise Sentry
|
||||
if (!(dsn && clientId && clientSecret)) {
|
||||
return undefined;
|
||||
}
|
||||
const sentry = new Toucan({
|
||||
dsn,
|
||||
request,
|
||||
context,
|
||||
sampleRate: 1.0,
|
||||
release: versionMetadata?.tag,
|
||||
integrations: [
|
||||
rewriteFramesIntegration({
|
||||
iteratee(frame) {
|
||||
frame.filename = "/index.js";
|
||||
return frame;
|
||||
},
|
||||
}),
|
||||
],
|
||||
requestDataOptions: {
|
||||
allowedHeaders: [
|
||||
"user-agent",
|
||||
"cf-challenge",
|
||||
"accept-encoding",
|
||||
"accept-language",
|
||||
"cf-ray",
|
||||
"content-length",
|
||||
"content-type",
|
||||
"host",
|
||||
],
|
||||
allowedSearchParams: /(.*)/,
|
||||
},
|
||||
|
||||
transportOptions: {
|
||||
headers: {
|
||||
"CF-Access-Client-ID": clientId,
|
||||
"CF-Access-Client-Secret": clientSecret,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (coloMetadata) {
|
||||
sentry.setTag("colo", coloMetadata.coloId);
|
||||
sentry.setTag("metal", coloMetadata.metalId);
|
||||
}
|
||||
|
||||
if (accountId && scriptId) {
|
||||
sentry.setTag("accountId", accountId);
|
||||
sentry.setTag("scriptId", scriptId);
|
||||
}
|
||||
|
||||
sentry.setUser({ id: accountId?.toString() });
|
||||
|
||||
return sentry;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, it } from "vitest";
|
||||
import { createAssetsIgnoreFunction, getContentType } from "../helpers";
|
||||
|
||||
describe("assets", () => {
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), "wrangler-tests"));
|
||||
|
||||
describe(".assetsignore", () => {
|
||||
it("should ignore metafiles by default", async ({ expect }) => {
|
||||
const { assetsIgnoreFunction } = await createAssetsIgnoreFunction(tmpDir);
|
||||
|
||||
expect(assetsIgnoreFunction(".assetsignore")).toBeTruthy();
|
||||
expect(assetsIgnoreFunction("_redirects")).toBeTruthy();
|
||||
expect(assetsIgnoreFunction("_headers")).toBeTruthy();
|
||||
|
||||
// don't ignore metafiles in child directories
|
||||
expect(assetsIgnoreFunction(join("child", ".assetsignore"))).toBeFalsy();
|
||||
expect(assetsIgnoreFunction(join("child", "_redirects"))).toBeFalsy();
|
||||
expect(assetsIgnoreFunction(join("child", "_headers"))).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should allow users to force opt-in metafiles", async ({ expect }) => {
|
||||
await writeFile(
|
||||
join(tmpDir, "./.assetsignore"),
|
||||
"!.assetsignore\n!_redirects\n!_headers"
|
||||
);
|
||||
const { assetsIgnoreFunction } = await createAssetsIgnoreFunction(tmpDir);
|
||||
|
||||
expect(assetsIgnoreFunction(".assetsignore")).toBeFalsy();
|
||||
expect(assetsIgnoreFunction("_redirects")).toBeFalsy();
|
||||
expect(assetsIgnoreFunction("_headers")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should allow users to ignore files", async ({ expect }) => {
|
||||
await writeFile(
|
||||
join(tmpDir, "./.assetsignore"),
|
||||
"logo.png\nchild/**/*.svg\n!child/nope.svg\n/*.js"
|
||||
);
|
||||
const { assetsIgnoreFunction } = await createAssetsIgnoreFunction(tmpDir);
|
||||
|
||||
expect(assetsIgnoreFunction("abc")).toBeFalsy();
|
||||
expect(assetsIgnoreFunction("logo.png")).toBeTruthy();
|
||||
expect(assetsIgnoreFunction(join("child", "logo.png"))).toBeTruthy();
|
||||
expect(assetsIgnoreFunction("foo.js")).toBeTruthy();
|
||||
expect(assetsIgnoreFunction(join("child", "foo.js"))).toBeFalsy();
|
||||
expect(assetsIgnoreFunction(join("child", "yup.svg"))).toBeTruthy();
|
||||
expect(
|
||||
assetsIgnoreFunction(join("child", "a", "b", "c", "yup.svg"))
|
||||
).toBeTruthy();
|
||||
expect(assetsIgnoreFunction(join("child", "nope.svg"))).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getContentType", () => {
|
||||
it("should return 'text/javascript", ({ expect }) => {
|
||||
const contentType = getContentType("/_astro/sponsors.CIiPz7eJ.js");
|
||||
expect(contentType).toBe("text/javascript; charset=utf-8");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
import { test } from "vitest";
|
||||
import { parseHeaders } from "../configuration/parseHeaders";
|
||||
|
||||
test("parseHeaders should reject malformed initial lines", ({ expect }) => {
|
||||
const input = `
|
||||
# A single token before a path
|
||||
c
|
||||
# A header before a path
|
||||
Access-Control-Allow-Origin: *
|
||||
`;
|
||||
const result = parseHeaders(input);
|
||||
expect(result).toEqual({
|
||||
rules: [],
|
||||
invalid: [
|
||||
{
|
||||
line: "c",
|
||||
lineNumber: 3,
|
||||
message: "Expected a path beginning with at least one forward-slash",
|
||||
},
|
||||
{
|
||||
line: "Access-Control-Allow-Origin: *",
|
||||
lineNumber: 5,
|
||||
message:
|
||||
"Path should come before header (access-control-allow-origin: *)",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseHeaders should reject invalid headers", ({ expect }) => {
|
||||
const input = `
|
||||
# Valid header sails through
|
||||
/a
|
||||
Name: Value
|
||||
#
|
||||
/b
|
||||
I'm invalid!
|
||||
!x-content-type
|
||||
But: I'm okay!
|
||||
! Content-Type: application/json
|
||||
`;
|
||||
const result = parseHeaders(input);
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{ path: "/a", headers: { name: "Value" }, unsetHeaders: [] },
|
||||
{ path: "/b", headers: { but: "I'm okay!" }, unsetHeaders: [] },
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
line: `I'm invalid!`,
|
||||
lineNumber: 7,
|
||||
message: "Expected a colon-separated header pair (e.g. name: value)",
|
||||
},
|
||||
{
|
||||
line: "!x-content-type",
|
||||
lineNumber: 8,
|
||||
message: "Expected a colon-separated header pair (e.g. name: value)",
|
||||
},
|
||||
{
|
||||
line: `! Content-Type: application/json`,
|
||||
lineNumber: 10,
|
||||
message: "Header name cannot include spaces",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseHeaders should reject lines longer than 2000 chars", ({
|
||||
expect,
|
||||
}) => {
|
||||
const huge_line = `${Array(1001).fill("a").join("")}: ${Array(1001)
|
||||
.fill("b")
|
||||
.join("")}`;
|
||||
const input = `
|
||||
# Valid entry
|
||||
/a
|
||||
Name: Value
|
||||
# Jumbo comment line OK, ignored as normal
|
||||
${Array(1001).fill("#").join("")}
|
||||
# Huge path names rejected
|
||||
/b
|
||||
Name: Value
|
||||
${huge_line}
|
||||
`;
|
||||
const result = parseHeaders(input);
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{ path: "/a", headers: { name: "Value" }, unsetHeaders: [] },
|
||||
{ path: "/b", headers: { name: "Value" }, unsetHeaders: [] },
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
message: `Ignoring line 10 as it exceeds the maximum allowed length of 2000.`,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseHeaders should reject any rules after the first 100", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `
|
||||
# COMMENTS DON'T COUNT TOWARDS TOTAL VALID RULES
|
||||
${Array(150)
|
||||
.fill(undefined)
|
||||
.map((_, i) => `/a/${i}\nx-index: ${i}`)
|
||||
.join("\n")}
|
||||
# BUT DO GET COUNTED AS TOTAL LINES SKIPPED
|
||||
`;
|
||||
|
||||
expect(parseHeaders(input)).toEqual({
|
||||
rules: Array(101)
|
||||
.fill(undefined)
|
||||
.map((_, i) => ({
|
||||
path: "/a/" + i,
|
||||
headers: { "x-index": `${i}` },
|
||||
unsetHeaders: [],
|
||||
})),
|
||||
invalid: [
|
||||
{
|
||||
message: `Maximum number of rules supported is 100. Skipping remaining 100 lines of file.`,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseHeaders should reject paths with multiple wildcards", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `
|
||||
# Multiple wildcards in an absolute URL
|
||||
https://*.pages.dev/*
|
||||
x-custom: value
|
||||
# Multiple wildcards in a relative path
|
||||
/blog/*/posts/*
|
||||
x-custom: value
|
||||
# Single wildcard is fine
|
||||
https://*.pages.dev/
|
||||
x-custom: value
|
||||
/blog/*
|
||||
x-custom: value
|
||||
`;
|
||||
const result = parseHeaders(input);
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{
|
||||
path: "https://*.pages.dev/",
|
||||
headers: { "x-custom": "value" },
|
||||
unsetHeaders: [],
|
||||
},
|
||||
{
|
||||
path: "/blog/*",
|
||||
headers: { "x-custom": "value" },
|
||||
unsetHeaders: [],
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
line: "https://*.pages.dev/*",
|
||||
lineNumber: 3,
|
||||
message:
|
||||
"Only one wildcard is allowed per rule. Use a named placeholder (e.g. :project) instead. Skipping https://*.pages.dev/*.",
|
||||
},
|
||||
{
|
||||
line: "/blog/*/posts/*",
|
||||
lineNumber: 6,
|
||||
message:
|
||||
"Only one wildcard is allowed per rule. Use a named placeholder (e.g. :project) instead. Skipping /blog/*/posts/*.",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseHeaders should reject paths combining wildcard with :splat placeholder", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `
|
||||
# Wildcard + :splat in an absolute URL
|
||||
https://*.pages.dev/:splat
|
||||
x-custom: value
|
||||
# Wildcard + :splat in a relative path
|
||||
/blog/*/:splat
|
||||
x-custom: value
|
||||
# Just :splat without wildcard is fine
|
||||
/blog/:splat
|
||||
x-custom: value
|
||||
`;
|
||||
const result = parseHeaders(input);
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{
|
||||
path: "/blog/:splat",
|
||||
headers: { "x-custom": "value" },
|
||||
unsetHeaders: [],
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
line: "https://*.pages.dev/:splat",
|
||||
lineNumber: 3,
|
||||
message:
|
||||
"Cannot combine a wildcard * with a :splat placeholder because wildcards are converted to :splat at runtime. Skipping https://*.pages.dev/:splat.",
|
||||
},
|
||||
{
|
||||
line: "/blog/*/:splat",
|
||||
lineNumber: 6,
|
||||
message:
|
||||
"Cannot combine a wildcard * with a :splat placeholder because wildcards are converted to :splat at runtime. Skipping /blog/*/:splat.",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseHeaders should reject malformed URLs", ({ expect }) => {
|
||||
const input = `
|
||||
# Spaces should be URI encoded
|
||||
/some page with spaces
|
||||
valid: yup
|
||||
# OK with URL escaped encoding
|
||||
/some%20page
|
||||
valid: yup
|
||||
# Unescaped URLs are handled OK by Deno, so escape & pass them through
|
||||
/://so;\`me
|
||||
valid: yup
|
||||
/nons:/&@%+~{}ense
|
||||
valid: yup
|
||||
# Absolute URLs with a non-https protocol should be rejected
|
||||
https://yeah.com
|
||||
valid: yup
|
||||
http://nah.com/blog
|
||||
invalid: things
|
||||
# Absolute URLs with a port should be rejected
|
||||
https://nah.com:8080
|
||||
invalid: also
|
||||
https://nah.com:8080/blog
|
||||
invalid: 2
|
||||
//yeah.com/blog
|
||||
valid: things
|
||||
/yeah
|
||||
valid: yup
|
||||
/yeah.com
|
||||
valid: yup
|
||||
# Anything standalone is interpreted as a invalid header pair
|
||||
nah.com
|
||||
:
|
||||
test:
|
||||
`;
|
||||
const result = parseHeaders(input);
|
||||
expect(result).toEqual({
|
||||
invalid: [
|
||||
{
|
||||
line: "http://nah.com/blog",
|
||||
lineNumber: 16,
|
||||
message:
|
||||
'URLs should either be relative (e.g. begin with a forward-slash), or use HTTPS (e.g. begin with "https://").',
|
||||
},
|
||||
{
|
||||
line: "https://nah.com:8080",
|
||||
lineNumber: 19,
|
||||
message:
|
||||
"Specifying ports is not supported. Skipping absolute URL https://nah.com:8080.",
|
||||
},
|
||||
{
|
||||
line: "https://nah.com:8080/blog",
|
||||
lineNumber: 21,
|
||||
message:
|
||||
"Specifying ports is not supported. Skipping absolute URL https://nah.com:8080/blog.",
|
||||
},
|
||||
{
|
||||
line: "nah.com",
|
||||
lineNumber: 30,
|
||||
message: "Expected a colon-separated header pair (e.g. name: value)",
|
||||
},
|
||||
{ line: ":", lineNumber: 31, message: "No header name specified" },
|
||||
{ line: "test:", lineNumber: 32, message: "No header value specified" },
|
||||
],
|
||||
rules: [
|
||||
{
|
||||
path: "/some%20page%20with%20spaces",
|
||||
headers: { valid: "yup" },
|
||||
unsetHeaders: [],
|
||||
},
|
||||
{ path: "/some%20page", headers: { valid: "yup" }, unsetHeaders: [] },
|
||||
{ path: "/://so;%60me", headers: { valid: "yup" }, unsetHeaders: [] },
|
||||
{
|
||||
path: "/nons:/&@%+~%7B%7Dense",
|
||||
headers: { valid: "yup" },
|
||||
unsetHeaders: [],
|
||||
},
|
||||
{
|
||||
path: "https://yeah.com/",
|
||||
headers: { valid: "yup" },
|
||||
unsetHeaders: [],
|
||||
},
|
||||
{
|
||||
path: "//yeah.com/blog",
|
||||
headers: { valid: "things" },
|
||||
unsetHeaders: [],
|
||||
},
|
||||
{ path: "/yeah", headers: { valid: "yup" }, unsetHeaders: [] },
|
||||
{ path: "/yeah.com", headers: { valid: "yup" }, unsetHeaders: [] },
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { test } from "vitest";
|
||||
import { parseHeaders } from "../configuration/parseHeaders";
|
||||
|
||||
test("parseHeaders should handle a single rule", ({ expect }) => {
|
||||
const input = `/a
|
||||
Name: Value`;
|
||||
const result = parseHeaders(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ path: "/a", headers: { name: "Value" }, unsetHeaders: [] }],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseHeaders should handle headers with exclamation marks", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `/a
|
||||
!Name: Value`;
|
||||
const result = parseHeaders(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ path: "/a", headers: { "!name": "Value" }, unsetHeaders: [] }],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseHeaders should ignore blank lines", ({ expect }) => {
|
||||
const input = `
|
||||
/a
|
||||
|
||||
Name: Value
|
||||
|
||||
`;
|
||||
const result = parseHeaders(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ path: "/a", headers: { name: "Value" }, unsetHeaders: [] }],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseHeaders should trim whitespace", ({ expect }) => {
|
||||
const input = `
|
||||
/a
|
||||
Name : Value
|
||||
`;
|
||||
const result = parseHeaders(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ path: "/a", headers: { name: "Value" }, unsetHeaders: [] }],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseHeaders should ignore comments", ({ expect }) => {
|
||||
const input = `
|
||||
# This is a comment
|
||||
/a
|
||||
# And one here too.
|
||||
Name: Value
|
||||
`;
|
||||
const result = parseHeaders(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ path: "/a", headers: { name: "Value" }, unsetHeaders: [] }],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseHeaders should combine headers together", ({ expect }) => {
|
||||
const input = `
|
||||
/a
|
||||
Set-Cookie: test=cookie; expires=never
|
||||
Set-Cookie: another=cookie; magic!
|
||||
|
||||
/b
|
||||
A: ABBA
|
||||
B: BABA
|
||||
`;
|
||||
const result = parseHeaders(input);
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{
|
||||
path: "/a",
|
||||
headers: {
|
||||
"set-cookie": "test=cookie; expires=never, another=cookie; magic!",
|
||||
},
|
||||
unsetHeaders: [],
|
||||
},
|
||||
{
|
||||
path: "/b",
|
||||
headers: { a: "ABBA", b: "BABA" },
|
||||
unsetHeaders: [],
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseHeaders should support setting hosts", ({ expect }) => {
|
||||
const input = `
|
||||
https://example.com
|
||||
a: a
|
||||
https://example.com/
|
||||
b: b
|
||||
https://example.com/blog
|
||||
c: c
|
||||
/blog
|
||||
d:d
|
||||
https://:subdomain.example.*/path
|
||||
e:e
|
||||
`;
|
||||
const result = parseHeaders(input);
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{ path: "https://example.com/", headers: { a: "a" }, unsetHeaders: [] },
|
||||
{ path: "https://example.com/", headers: { b: "b" }, unsetHeaders: [] },
|
||||
{
|
||||
path: "https://example.com/blog",
|
||||
headers: { c: "c" },
|
||||
unsetHeaders: [],
|
||||
},
|
||||
{ path: "/blog", headers: { d: "d" }, unsetHeaders: [] },
|
||||
{
|
||||
path: "https://:subdomain.example.*/path",
|
||||
headers: { e: "e" },
|
||||
unsetHeaders: [],
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseHeaders should add unset headers", ({ expect }) => {
|
||||
const input = `/a
|
||||
Name: Value
|
||||
! Place`;
|
||||
const result = parseHeaders(input);
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{ path: "/a", headers: { name: "Value" }, unsetHeaders: ["Place"] },
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseHeaders should support custom limits", ({ expect }) => {
|
||||
const aaa = Array(1001).fill("a").join("");
|
||||
const bbb = Array(1001).fill("b").join("");
|
||||
const huge_line = `${aaa}: ${bbb}`;
|
||||
let input = `
|
||||
# Valid entry
|
||||
/a
|
||||
Name: Value
|
||||
# Jumbo comment line OK, ignored as normal
|
||||
${Array(1001).fill("#").join("")}
|
||||
# Huge path names rejected
|
||||
/b
|
||||
Name: Value
|
||||
${huge_line}
|
||||
`;
|
||||
let result = parseHeaders(input, { maxLineLength: 3000 });
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{ path: "/a", headers: { name: "Value" }, unsetHeaders: [] },
|
||||
{ path: "/b", headers: { name: "Value", [aaa]: bbb }, unsetHeaders: [] },
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
|
||||
input = `
|
||||
# COMMENTS DON'T COUNT TOWARDS TOTAL VALID RULES
|
||||
${Array(150)
|
||||
.fill(undefined)
|
||||
.map((_, i) => `/a/${i}\nx-index: ${i}`)
|
||||
.join("\n")}
|
||||
# BUT DO GET COUNTED AS TOTAL LINES SKIPPED
|
||||
`;
|
||||
result = parseHeaders(input, { maxRules: 200 });
|
||||
expect(result.rules.length).toBe(150);
|
||||
expect(result.invalid.length).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,370 @@
|
||||
import { test } from "vitest";
|
||||
import { parseRedirects } from "../configuration/parseRedirects";
|
||||
|
||||
// Snapshot values
|
||||
const maxDynamicRedirectRules = 100;
|
||||
const maxLineLength = 2000;
|
||||
const maxStaticRedirectRules = 2000;
|
||||
|
||||
test("parseRedirects should reject malformed lines", ({ expect }) => {
|
||||
const input = `
|
||||
# Single token
|
||||
/c
|
||||
# Four tokens
|
||||
/d /e 302 !important
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [],
|
||||
invalid: [
|
||||
{
|
||||
line: `/c`,
|
||||
lineNumber: 3,
|
||||
message: "Expected exactly 2 or 3 whitespace-separated tokens. Got 1.",
|
||||
},
|
||||
{
|
||||
line: `/d /e 302 !important`,
|
||||
lineNumber: 5,
|
||||
message: "Expected exactly 2 or 3 whitespace-separated tokens. Got 4.",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should reject invalid status codes", ({ expect }) => {
|
||||
const input = `
|
||||
# Valid token sails through
|
||||
/a /b 301
|
||||
# 418 NOT OK
|
||||
/c /d 418
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ from: "/a", status: 301, to: "/b", lineNumber: 3 }],
|
||||
invalid: [
|
||||
{
|
||||
line: `/c /d 418`,
|
||||
lineNumber: 5,
|
||||
message:
|
||||
"Valid status codes are 200, 301, 302 (default), 303, 307, or 308. Got 418.",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test(`parseRedirects should reject duplicate 'from' paths`, ({ expect }) => {
|
||||
const input = `
|
||||
# Valid entry
|
||||
/a /b
|
||||
# Nonsensical but permitted (for now)
|
||||
/b /a
|
||||
# Duplicate 'from'
|
||||
/a /c
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{ from: "/a", status: 302, to: "/b", lineNumber: 3 },
|
||||
{ from: "/b", status: 302, to: "/a", lineNumber: 5 },
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
line: `/a /c`,
|
||||
lineNumber: 7,
|
||||
message: `Ignoring duplicate rule for path /a.`,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test(`parseRedirects should reject lines longer than ${maxLineLength} chars`, ({
|
||||
expect,
|
||||
}) => {
|
||||
const huge_line = `/${Array(maxLineLength).fill("a").join("")} /${Array(
|
||||
maxLineLength
|
||||
)
|
||||
.fill("b")
|
||||
.join("")} 301`;
|
||||
const input = `
|
||||
# Valid entry
|
||||
/a /b
|
||||
# Jumbo comment line OK, ignored as normal
|
||||
${Array(maxLineLength + 1)
|
||||
.fill("#")
|
||||
.join("")}
|
||||
# Huge path names rejected
|
||||
${huge_line}
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ from: "/a", status: 302, to: "/b", lineNumber: 3 }],
|
||||
invalid: [
|
||||
{
|
||||
message: `Ignoring line 7 as it exceeds the maximum allowed length of ${maxLineLength}.`,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should reject any dynamic rules after the first 100", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `
|
||||
# COMMENTS DON'T COUNT TOWARDS TOTAL VALID RULES
|
||||
${Array(150)
|
||||
.fill(undefined)
|
||||
.map((_, i) => `/a/${i}/* /b/${i}/:splat`)
|
||||
.join("\n")}
|
||||
# BUT DO GET COUNTED AS TOTAL LINES SKIPPED
|
||||
`;
|
||||
|
||||
expect(parseRedirects(input)).toEqual({
|
||||
rules: Array(100)
|
||||
.fill(undefined)
|
||||
.map((_, i) => ({
|
||||
from: `/a/${i}/*`,
|
||||
to: `/b/${i}/:splat`,
|
||||
status: 302,
|
||||
lineNumber: i + 3,
|
||||
})),
|
||||
invalid: [
|
||||
{
|
||||
message: `Maximum number of dynamic rules supported is 100. Skipping remaining 52 lines of file.`,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test(`parseRedirects should reject any static rules after the first ${maxStaticRedirectRules}`, ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `
|
||||
# COMMENTS DON'T COUNT TOWARDS TOTAL VALID RULES
|
||||
${Array(maxStaticRedirectRules + 50)
|
||||
.fill(undefined)
|
||||
.map((_, i) => `/a/${i} /b/${i}`)
|
||||
.join("\n")}
|
||||
# BUT DO GET COUNTED AS TOTAL LINES SKIPPED
|
||||
`;
|
||||
|
||||
expect(parseRedirects(input)).toEqual({
|
||||
rules: Array(maxStaticRedirectRules)
|
||||
.fill(undefined)
|
||||
.map((_, i) => ({
|
||||
from: `/a/${i}`,
|
||||
to: `/b/${i}`,
|
||||
status: 302,
|
||||
lineNumber: i + 3,
|
||||
})),
|
||||
invalid: Array(50)
|
||||
.fill(undefined)
|
||||
.map(() => ({
|
||||
message: `Maximum number of static rules supported is ${maxStaticRedirectRules}. Skipping line.`,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should reject a combination of lots of static and dynamic rules", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `
|
||||
# COMMENTS DON'T COUNT TOWARDS TOTAL VALID RULES
|
||||
${Array(maxStaticRedirectRules + 50)
|
||||
.fill(undefined)
|
||||
.map((_, i) => `/a/${i} /b/${i}`)
|
||||
.join("\n")}
|
||||
${Array(maxDynamicRedirectRules + 50)
|
||||
.fill(undefined)
|
||||
.map((_, i) => `/a/${i}/* /b/${i}/:splat`)
|
||||
.join("\n")}
|
||||
# BUT DO GET COUNTED AS TOTAL LINES SKIPPED
|
||||
`;
|
||||
|
||||
expect(parseRedirects(input)).toEqual({
|
||||
rules: [
|
||||
...Array(maxStaticRedirectRules)
|
||||
.fill(undefined)
|
||||
.map((_, i) => ({
|
||||
from: `/a/${i}`,
|
||||
to: `/b/${i}`,
|
||||
status: 302,
|
||||
lineNumber: i + 3,
|
||||
})),
|
||||
...Array(maxDynamicRedirectRules)
|
||||
.fill(undefined)
|
||||
.map((_, i) => ({
|
||||
from: `/a/${i}/*`,
|
||||
to: `/b/${i}/:splat`,
|
||||
status: 302,
|
||||
lineNumber: i + maxStaticRedirectRules + 53,
|
||||
})),
|
||||
],
|
||||
invalid: [
|
||||
...Array(50)
|
||||
.fill(undefined)
|
||||
.map(() => ({
|
||||
message: `Maximum number of static rules supported is ${maxStaticRedirectRules}. Skipping line.`,
|
||||
})),
|
||||
{
|
||||
message: `Maximum number of dynamic rules supported is ${maxDynamicRedirectRules}. Skipping remaining 52 lines of file.`,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should reject malformed URLs", ({ expect }) => {
|
||||
const input = `
|
||||
# Spaces rejected on token length
|
||||
/some page /somewhere else
|
||||
# OK with URL escaped encoding
|
||||
/some%20page /somewhere%20else
|
||||
# Unescaped URLs are handled OK by Deno, so escape & pass them through
|
||||
/://so;\`me /nons:/&@%+~{}ense
|
||||
# Absolute URLs aren't OK for 'from', but are fine for 'to'
|
||||
https://yeah.com https://nah.com
|
||||
/nah https://yeah.com
|
||||
# This is actually parsed as /yeah.com, which we might want to detect but is ok for now
|
||||
yeah.com https://nah.com
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
invalid: [
|
||||
{
|
||||
line: `/some page /somewhere else`,
|
||||
lineNumber: 3,
|
||||
message: "Expected exactly 2 or 3 whitespace-separated tokens. Got 4.",
|
||||
},
|
||||
{
|
||||
line: `https://yeah.com https://nah.com`,
|
||||
lineNumber: 9,
|
||||
message:
|
||||
"Only relative URLs are allowed. Skipping absolute URL https://yeah.com.",
|
||||
},
|
||||
],
|
||||
rules: [
|
||||
{
|
||||
from: "/some%20page",
|
||||
status: 302,
|
||||
to: "/somewhere%20else",
|
||||
lineNumber: 5,
|
||||
},
|
||||
{
|
||||
from: "/://so;%60me",
|
||||
status: 302,
|
||||
to: "/nons:/&@%+~%7B%7Dense",
|
||||
lineNumber: 7,
|
||||
},
|
||||
{ from: "/nah", status: 302, to: "https://yeah.com/", lineNumber: 10 },
|
||||
{
|
||||
from: "/yeah.com",
|
||||
status: 302,
|
||||
to: "https://nah.com/",
|
||||
lineNumber: 12,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should reject non-relative URLs for proxying (200) redirects", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `
|
||||
/a https://example.com/b 200
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [],
|
||||
invalid: [
|
||||
{
|
||||
line: `/a https://example.com/b 200`,
|
||||
lineNumber: 2,
|
||||
message:
|
||||
"Proxy (200) redirects can only point to relative paths. Got https://example.com/b",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should reject wildcard patterns to index", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `
|
||||
/* /index.html 200
|
||||
/* /index 200
|
||||
/ /index.html
|
||||
/ /index
|
||||
`;
|
||||
const invalidRedirectError =
|
||||
"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.";
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [],
|
||||
invalid: [
|
||||
{
|
||||
line: "/* /index.html 200",
|
||||
lineNumber: 2,
|
||||
message: invalidRedirectError,
|
||||
},
|
||||
{
|
||||
line: "/* /index 200",
|
||||
lineNumber: 3,
|
||||
message: invalidRedirectError,
|
||||
},
|
||||
{
|
||||
line: "/ /index.html",
|
||||
lineNumber: 4,
|
||||
message: invalidRedirectError,
|
||||
},
|
||||
{
|
||||
line: "/ /index",
|
||||
lineNumber: 5,
|
||||
message: invalidRedirectError,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should allow root patterns to index when HTML handling disabled", ({
|
||||
expect,
|
||||
}) => {
|
||||
// This test documents the fix for https://github.com/cloudflare/workers-sdk/issues/11824
|
||||
// Exact path matches like "/ /index.html" are valid when html_handling is "none"
|
||||
// because there's no automatic index.html serving, so no infinite loop occurs.
|
||||
// However, wildcard patterns like "/* /index.html" should still be blocked.
|
||||
const input = `
|
||||
/* /index.html 200
|
||||
/* /index 200
|
||||
/ /index.html
|
||||
/ /index
|
||||
`;
|
||||
const invalidRedirectError =
|
||||
"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.";
|
||||
const result = parseRedirects(input, { htmlHandling: "none" });
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{
|
||||
from: "/",
|
||||
status: 302,
|
||||
to: "/index.html",
|
||||
lineNumber: 4,
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
line: "/* /index.html 200",
|
||||
lineNumber: 2,
|
||||
message: invalidRedirectError,
|
||||
},
|
||||
{
|
||||
line: "/* /index 200",
|
||||
lineNumber: 3,
|
||||
message: invalidRedirectError,
|
||||
},
|
||||
{
|
||||
line: "/ /index",
|
||||
lineNumber: 5,
|
||||
message: "Ignoring duplicate rule for path /.",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,440 @@
|
||||
import { test } from "vitest";
|
||||
import { parseRedirects } from "../configuration/parseRedirects";
|
||||
|
||||
test("parseRedirects should handle a single rule", ({ expect }) => {
|
||||
const input = `/a /b 301`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ from: "/a", status: 301, to: "/b", lineNumber: 1 }],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should ignore blank lines", ({ expect }) => {
|
||||
const input = `
|
||||
/a /b 301
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ from: "/a", status: 301, to: "/b", lineNumber: 2 }],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should trim whitespace", ({ expect }) => {
|
||||
const input = `
|
||||
/a /b 301
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ from: "/a", status: 301, to: "/b", lineNumber: 2 }],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should ignore comments", ({ expect }) => {
|
||||
const input = `
|
||||
# This is a comment
|
||||
/a /b 301
|
||||
# And one here too.
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ from: "/a", status: 301, to: "/b", lineNumber: 3 }],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should handle a single comment-only line", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `# This is just a comment`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should handle an indented comment-only line", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = ` # indented comment`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should handle multiple consecutive comment lines", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `
|
||||
# First comment
|
||||
# Second comment
|
||||
# Indented comment
|
||||
/a /b 301
|
||||
# Comment after rule
|
||||
# Another comment
|
||||
/c /d
|
||||
# Final comment
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{ from: "/a", status: 301, to: "/b", lineNumber: 5 },
|
||||
{ from: "/c", status: 302, to: "/d", lineNumber: 8 },
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should handle a file with only comments", ({ expect }) => {
|
||||
const input = `
|
||||
# This file has no redirects
|
||||
# Just comments
|
||||
# Some indented
|
||||
# And more comments
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should default to 302", ({ expect }) => {
|
||||
const input = `
|
||||
/a /b 302
|
||||
/c /d
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{ from: "/a", status: 302, to: "/b", lineNumber: 2 },
|
||||
{ from: "/c", status: 302, to: "/d", lineNumber: 3 },
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should preserve querystrings on to", ({ expect }) => {
|
||||
const input = `
|
||||
/a /b?query=string 302
|
||||
/c?this=rejected /d 301
|
||||
/ext https://some.domain:1234/route?q=string#anchor
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{ from: "/a", status: 302, to: "/b?query=string", lineNumber: 2 },
|
||||
{ from: "/c", status: 301, to: "/d", lineNumber: 3 },
|
||||
{
|
||||
from: "/ext",
|
||||
status: 302,
|
||||
to: "https://some.domain:1234/route?q=string#anchor",
|
||||
lineNumber: 4,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should preserve fragments", ({ expect }) => {
|
||||
const input = `
|
||||
/a /b#blah 302
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ from: "/a", status: 302, to: "/b#blah", lineNumber: 2 }],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should preserve fragments which contain a hash sign", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `
|
||||
/a /b##blah-1 302
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ from: "/a", status: 302, to: "/b##blah-1", lineNumber: 2 }],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should preserve fragments which contain a hash sign and are full URLs", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `
|
||||
/a https://example.com/b##blah-1 302
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{
|
||||
from: "/a",
|
||||
status: 302,
|
||||
to: "https://example.com/b##blah-1",
|
||||
lineNumber: 2,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should accept 200 (proxying) redirects", ({ expect }) => {
|
||||
const input = `
|
||||
/a /b 200
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{
|
||||
from: "/a",
|
||||
status: 200,
|
||||
to: "/b",
|
||||
lineNumber: 2,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should accept absolute URLs that end with index.html", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `
|
||||
/foo https://bar.com/index.html 302
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{
|
||||
from: "/foo",
|
||||
status: 302,
|
||||
to: "https://bar.com/index.html",
|
||||
lineNumber: 2,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should accept going to absolute URLs with ports", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `
|
||||
/foo https://bar.com:123/index.html 302
|
||||
/cat https://cat.com:12345 302
|
||||
/dog https://dog.com:12345
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{
|
||||
from: "/foo",
|
||||
status: 302,
|
||||
to: "https://bar.com:123/index.html",
|
||||
lineNumber: 2,
|
||||
},
|
||||
{
|
||||
from: "/cat",
|
||||
status: 302,
|
||||
to: "https://cat.com:12345/",
|
||||
lineNumber: 3,
|
||||
},
|
||||
{
|
||||
from: "/dog",
|
||||
status: 302,
|
||||
to: "https://dog.com:12345/",
|
||||
lineNumber: 4,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should accept relative URLs that don't point to .html files", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `
|
||||
/* /foo 200
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{
|
||||
from: "/*",
|
||||
status: 200,
|
||||
to: "/foo",
|
||||
lineNumber: 2,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should support inline comments", ({ expect }) => {
|
||||
const input = `/a /b 301 # redirect a to b`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ from: "/a", status: 301, to: "/b", lineNumber: 1 }],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should support inline comments without status code", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `/a /b # redirect with default status`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ from: "/a", status: 302, to: "/b", lineNumber: 1 }],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should support inline comments after URL fragments", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `/a /b#section 301 # redirect to section`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ from: "/a", status: 301, to: "/b#section", lineNumber: 1 }],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should support inline comments without space after hash", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `/a /b 301 #no space comment`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ from: "/a", status: 301, to: "/b", lineNumber: 1 }],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should support multiple rules with inline comments", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `
|
||||
/a /b 301 # first rule
|
||||
/c /d # second rule with default status
|
||||
# full line comment
|
||||
/e /f 307 # third rule
|
||||
`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{ from: "/a", status: 301, to: "/b", lineNumber: 2 },
|
||||
{ from: "/c", status: 302, to: "/d", lineNumber: 3 },
|
||||
{ from: "/e", status: 307, to: "/f", lineNumber: 5 },
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should support inline comments with absolute URLs containing fragments", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `/a https://x.com/b#c # comment`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{ from: "/a", status: 302, to: "https://x.com/b#c", lineNumber: 1 },
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should support empty inline comments (just hash)", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `/a /b #`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ from: "/a", status: 302, to: "/b", lineNumber: 1 }],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should support inline comments with multiple hashes in URL fragment", ({
|
||||
expect,
|
||||
}) => {
|
||||
const input = `/a /b##anchor # comment`;
|
||||
const result = parseRedirects(input);
|
||||
expect(result).toEqual({
|
||||
rules: [{ from: "/a", status: 302, to: "/b##anchor", lineNumber: 1 }],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("parseRedirects should support custom limits", ({ expect }) => {
|
||||
const aaa = Array(1001).fill("a").join("");
|
||||
const bbb = Array(1001).fill("b").join("");
|
||||
const huge_line = `/${aaa} /${bbb} 301`;
|
||||
let input = `
|
||||
# Valid entry
|
||||
/a /b
|
||||
# Jumbo comment line OK, ignored as normal
|
||||
${Array(1001).fill("#").join("")}
|
||||
# Huge path names rejected
|
||||
${huge_line}
|
||||
`;
|
||||
let result = parseRedirects(input, { maxLineLength: 3000 });
|
||||
expect(result).toEqual({
|
||||
rules: [
|
||||
{ from: "/a", status: 302, to: "/b", lineNumber: 3 },
|
||||
{ from: `/${aaa}`, status: 301, to: `/${bbb}`, lineNumber: 7 },
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
|
||||
input = `
|
||||
# COMMENTS DON'T COUNT TOWARDS TOTAL VALID RULES
|
||||
${Array(150)
|
||||
.fill(undefined)
|
||||
.map((_, i) => `/a/${i}/* /b/${i}/:splat`)
|
||||
.join("\n")}
|
||||
# BUT DO GET COUNTED AS TOTAL LINES SKIPPED
|
||||
`;
|
||||
result = parseRedirects(input, { maxDynamicRules: 200 });
|
||||
expect(result.rules.length).toBe(150);
|
||||
expect(result.invalid.length).toBe(0);
|
||||
|
||||
input = `
|
||||
# COMMENTS DON'T COUNT TOWARDS TOTAL VALID RULES
|
||||
${Array(2050)
|
||||
.fill(undefined)
|
||||
.map((_, i) => `/a/${i} /b/${i}`)
|
||||
.join("\n")}
|
||||
# BUT DO GET COUNTED AS TOTAL LINES SKIPPED
|
||||
`;
|
||||
result = parseRedirects(input, { maxStaticRules: 3000 });
|
||||
expect(result.rules.length).toBe(2050);
|
||||
expect(result.invalid.length).toBe(0);
|
||||
|
||||
input = `
|
||||
# COMMENTS DON'T COUNT TOWARDS TOTAL VALID RULES
|
||||
${Array(2050)
|
||||
.fill(undefined)
|
||||
.map((_, i) => `/a/${i} /b/${i}`)
|
||||
.join("\n")}
|
||||
${Array(150)
|
||||
.fill(undefined)
|
||||
.map((_, i) => `/a/${i}/* /b/${i}/:splat`)
|
||||
.join("\n")}
|
||||
# BUT DO GET COUNTED AS TOTAL LINES SKIPPED
|
||||
`;
|
||||
result = parseRedirects(input, {
|
||||
maxDynamicRules: 200,
|
||||
maxStaticRules: 3000,
|
||||
});
|
||||
expect(result.rules.length).toBe(2200);
|
||||
expect(result.invalid.length).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, it } from "vitest";
|
||||
import { parseStaticRouting } from "../configuration/parseStaticRouting";
|
||||
|
||||
describe("parseStaticRouting", () => {
|
||||
it("throws when given empty rules", ({ expect }) => {
|
||||
expect(() => parseStaticRouting([])).toThrowErrorMatchingInlineSnapshot(
|
||||
`[Error: No \`run_worker_first\` rules were provided; must provide at least 1 rule.]`
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when given only negative rules", ({ expect }) => {
|
||||
expect(() =>
|
||||
parseStaticRouting(["!/assets"])
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`[Error: Only negative \`run_worker_first\` rules were provided; must provide at least 1 non-negative rule]`
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when too many rules are provided", ({ expect }) => {
|
||||
const rules = Array.from({ length: 120 }, (_, i) => `/rule/${i}`);
|
||||
expect(() => parseStaticRouting(rules)).toThrowErrorMatchingInlineSnapshot(
|
||||
`[Error: Too many \`run_worker_first\` rules were provided; 120 rules provided exceeds max of 100.]`
|
||||
);
|
||||
|
||||
const userWorkerRules = Array.from({ length: 60 }, (_, i) => `/rule/${i}`);
|
||||
const assetRules = Array.from({ length: 60 }, (_, i) => `!/rule/${60 + i}`);
|
||||
expect(() =>
|
||||
parseStaticRouting([...userWorkerRules, ...assetRules])
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`[Error: Too many \`run_worker_first\` rules were provided; 120 rules provided exceeds max of 100.]`
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when a rule is too long", ({ expect }) => {
|
||||
const rule = `/api/${"a".repeat(130)}`;
|
||||
expect(() => parseStaticRouting([rule])).toThrowErrorMatchingInlineSnapshot(
|
||||
`
|
||||
[Error: Invalid routes in \`run_worker_first\`:
|
||||
'/api/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa': all rules must be less than 100 characters in length]
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when rule doesn't begin with /", ({ expect }) => {
|
||||
expect(() => parseStaticRouting(["api/*", "!asset"]))
|
||||
.toThrowErrorMatchingInlineSnapshot(`
|
||||
[Error: Invalid routes in \`run_worker_first\`:
|
||||
'api/*': rules must start with '/' or '!/'
|
||||
'!asset': negative rules must start with '!/']
|
||||
`);
|
||||
});
|
||||
|
||||
it("throws when given redundant rules", ({ expect }) => {
|
||||
expect(() =>
|
||||
parseStaticRouting([
|
||||
"/api/*",
|
||||
"/oauth/callback",
|
||||
"/api/some/route",
|
||||
"!/api/assets/*",
|
||||
])
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`
|
||||
[Error: Invalid routes in \`run_worker_first\`:
|
||||
'/api/some/route': rule '/api/*' makes it redundant]
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when given duplicate routes", ({ expect }) => {
|
||||
expect(() =>
|
||||
parseStaticRouting([
|
||||
"/api/some/route",
|
||||
"/oauth/callback",
|
||||
"/api/some/route",
|
||||
"!/api/assets/*",
|
||||
])
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`
|
||||
[Error: Invalid routes in \`run_worker_first\`:
|
||||
'/api/some/route': rule is a duplicate; rules must be unique]
|
||||
`
|
||||
);
|
||||
});
|
||||
|
||||
it("correctly parses valid rules", ({ expect }) => {
|
||||
const parsed = parseStaticRouting([
|
||||
"/api/*",
|
||||
"/oauth/callback",
|
||||
"!/api/assets/*",
|
||||
]);
|
||||
const expected = {
|
||||
user_worker: ["/api/*", "/oauth/callback"],
|
||||
asset_worker: ["/api/assets/*"],
|
||||
};
|
||||
expect(parsed).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { JaegerTracing, Span } from "./types";
|
||||
|
||||
export function mockJaegerBindingSpan(): Span {
|
||||
return {
|
||||
addLogs: () => {},
|
||||
setTags: () => {},
|
||||
end: () => {},
|
||||
isRecording: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function mockJaegerBinding(): JaegerTracing {
|
||||
return {
|
||||
enterSpan: (_, span, ...args) => {
|
||||
return span(mockJaegerBindingSpan(), ...args);
|
||||
},
|
||||
getSpanContext: () => ({
|
||||
traceId: "test-trace",
|
||||
spanId: "test-span",
|
||||
parentSpanId: "test-parent-span",
|
||||
traceFlags: 0,
|
||||
}),
|
||||
runWithSpanContext: (_, callback, ...args) => {
|
||||
return callback(...args);
|
||||
},
|
||||
|
||||
traceId: "test-trace",
|
||||
spanId: "test-span",
|
||||
parentSpanId: "test-parent-span",
|
||||
cfTraceIdHeader: "test-trace:test-span:0",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2021",
|
||||
"lib": ["es2021"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "nodenext",
|
||||
"types": ["@cloudflare/workers-types/experimental", "@types/node"],
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const InternalConfigSchema = z.object({
|
||||
account_id: z.number().optional(),
|
||||
script_id: z.number().optional(),
|
||||
debug: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const StaticRoutingSchema = z.object({
|
||||
user_worker: z.array(z.string()),
|
||||
asset_worker: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export type StaticRouting = z.infer<typeof StaticRoutingSchema>;
|
||||
|
||||
export const RouterConfigSchema = z.object({
|
||||
invoke_user_worker_ahead_of_assets: z.boolean().optional(),
|
||||
static_routing: StaticRoutingSchema.optional(),
|
||||
has_user_worker: z.boolean().optional(),
|
||||
...InternalConfigSchema.shape,
|
||||
});
|
||||
|
||||
export const EyeballRouterConfigSchema = z.union([
|
||||
z.object({
|
||||
limitedAssetsOnly: z.boolean().optional(),
|
||||
}),
|
||||
z.null(),
|
||||
]);
|
||||
|
||||
const MetadataStaticRedirectEntry = z.object({
|
||||
status: z.number(),
|
||||
to: z.string(),
|
||||
lineNumber: z.number(),
|
||||
});
|
||||
|
||||
const MetadataRedirectEntry = z.object({
|
||||
status: z.number(),
|
||||
to: z.string(),
|
||||
});
|
||||
|
||||
const MetadataStaticRedirects = z.record(MetadataStaticRedirectEntry);
|
||||
export type MetadataStaticRedirects = z.infer<typeof MetadataStaticRedirects>;
|
||||
const MetadataRedirects = z.record(MetadataRedirectEntry);
|
||||
export type MetadataRedirects = z.infer<typeof MetadataRedirects>;
|
||||
|
||||
const MetadataHeaderEntry = z.object({
|
||||
set: z.record(z.string()).optional(),
|
||||
unset: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
const MetadataHeaders = z.record(MetadataHeaderEntry);
|
||||
export type MetadataHeaders = z.infer<typeof MetadataHeaders>;
|
||||
|
||||
export const RedirectsSchema = z
|
||||
.object({
|
||||
version: z.literal(1),
|
||||
staticRules: MetadataStaticRedirects,
|
||||
rules: MetadataRedirects,
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const HeadersSchema = z
|
||||
.object({
|
||||
version: z.literal(2),
|
||||
rules: MetadataHeaders,
|
||||
})
|
||||
.optional();
|
||||
|
||||
export const AssetConfigSchema = z.object({
|
||||
compatibility_date: z.string().optional(),
|
||||
compatibility_flags: z.array(z.string()).optional(),
|
||||
html_handling: z
|
||||
.enum([
|
||||
"auto-trailing-slash",
|
||||
"force-trailing-slash",
|
||||
"drop-trailing-slash",
|
||||
"none",
|
||||
])
|
||||
.optional(),
|
||||
not_found_handling: z
|
||||
.enum(["single-page-application", "404-page", "none"])
|
||||
.optional(),
|
||||
redirects: RedirectsSchema,
|
||||
headers: HeadersSchema,
|
||||
has_static_routing: z.boolean().optional(),
|
||||
...InternalConfigSchema.shape,
|
||||
});
|
||||
|
||||
export type EyeballRouterConfig = z.infer<typeof EyeballRouterConfigSchema>;
|
||||
export type RouterConfig = z.infer<typeof RouterConfigSchema>;
|
||||
export type AssetConfig = z.infer<typeof AssetConfigSchema>;
|
||||
|
||||
export interface UnsafePerformanceTimer {
|
||||
readonly timeOrigin: number;
|
||||
now: () => number;
|
||||
}
|
||||
|
||||
export interface JaegerTracing {
|
||||
enterSpan<T extends unknown[], R = void>(
|
||||
name: string,
|
||||
span: (s: Span, ...args: T) => R,
|
||||
...args: T
|
||||
): R;
|
||||
getSpanContext(): SpanContext | null;
|
||||
runWithSpanContext<T extends unknown[]>(
|
||||
spanContext: SpanContext | null,
|
||||
callback: (...args: T) => unknown,
|
||||
...args: T
|
||||
): unknown;
|
||||
|
||||
readonly traceId: string | null;
|
||||
readonly spanId: string | null;
|
||||
readonly parentSpanId: string | null;
|
||||
readonly cfTraceIdHeader: string | null;
|
||||
}
|
||||
|
||||
export interface Span {
|
||||
addLogs(logs: JaegerRecord): void;
|
||||
setTags(tags: JaegerRecord): void;
|
||||
end(): void;
|
||||
|
||||
isRecording: boolean;
|
||||
}
|
||||
|
||||
export interface SpanContext {
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
parentSpanId: string;
|
||||
traceFlags: number;
|
||||
}
|
||||
|
||||
export type JaegerValue = string | number | boolean;
|
||||
export type JaegerRecord = Record<string, JaegerValue>;
|
||||
|
||||
export interface ColoMetadata {
|
||||
metalId: number;
|
||||
coloId: number;
|
||||
coloRegion: string;
|
||||
coloTier: number;
|
||||
}
|
||||
Reference in New Issue
Block a user