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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:30:11 +08:00
commit 70bf21e064
5213 changed files with 731895 additions and 0 deletions
@@ -0,0 +1 @@
export function assertNever(_value: never) {}
+1
View File
@@ -0,0 +1 @@
export { constructWranglerConfig } from "./construct-wrangler-config";
@@ -0,0 +1,35 @@
import { ParseError } from "../parse";
export interface FetchError {
code: number;
documentation_url?: string;
message: string;
error_chain?: FetchError[];
/**
* Optional structured error metadata returned alongside the message. The
* Cloudflare v4 envelope permits a `meta` object whose shape varies by
* endpoint; consumers are expected to validate the shape at use site
* before relying on any field.
*
* Known usage: declarative DO exports reconciliation returns `meta.details`
* as an array of per-class errors.
*/
meta?: { details?: unknown } & Record<string, unknown>;
}
function buildDetailedError(message: string, ...extra: string[]) {
return new ParseError({
text: message,
notes: extra.map((text) => ({ text })),
telemetryMessage: false,
});
}
export function maybeThrowFriendlyError(error: FetchError) {
if (error.message === "workers.api.error.email_verification_required") {
throw buildDetailedError(
"Please verify your account's email address and try again.",
"Check your email for a verification link, or login to https://dash.cloudflare.com and request a new one."
);
}
}
+544
View File
@@ -0,0 +1,544 @@
import assert from "node:assert";
import { URLSearchParams } from "node:url";
import { fetch, FormData, Headers, Response } from "undici";
import {
getCloudflareApiBaseUrl,
getTraceHeader,
} from "../environment-variables/misc-variables";
import { UserError } from "../errors";
import { APIError, parseJSON } from "../parse";
import { type FetchError, maybeThrowFriendlyError } from "./errors";
import type { ComplianceConfig } from "../environment-variables/misc-variables";
import type { Logger } from "../logger";
import type { HeadersInit, RequestInit } from "undici";
export type ApiCredentials =
| {
apiToken: string;
}
| {
authKey: string;
authEmail: string;
};
export interface FetchResult<ResponseType = unknown> {
success: boolean;
result: ResponseType;
errors: FetchError[];
messages?: (string | { code?: number; message: string })[];
result_info?: unknown;
}
export type FetchResultFetcher = <ResponseType>(
complianceConfig: ComplianceConfig,
resource: string,
init?: RequestInit,
queryParams?: URLSearchParams,
abortSignal?: AbortSignal
) => Promise<ResponseType>;
export type FetchListResultFetcher = <ResponseType>(
complianceConfig: ComplianceConfig,
resource: string,
init?: RequestInit,
queryParams?: URLSearchParams
) => Promise<ResponseType[]>;
export type FetchPagedListResultFetcher = <ResponseType>(
complianceConfig: ComplianceConfig,
resource: string,
init?: RequestInit,
queryParams?: URLSearchParams
) => Promise<ResponseType[]>;
function logHeaders(headers: Headers, logger: Logger): void {
const clone = cloneHeaders(headers);
clone.delete("Authorization");
logger.debugWithSanitization?.(
"HEADERS:",
JSON.stringify(Object.fromEntries(clone), null, 2)
);
}
/**
*
* Note this requires its caller to handle credentials
* (need to call requireLoggedIn and requireApiToken)
*/
export async function performApiFetchBase(
complianceConfig: ComplianceConfig,
resource: string,
init: RequestInit = {},
userAgent: string,
logger: Logger,
queryParams?: URLSearchParams,
abortSignal?: AbortSignal,
credentials?: ApiCredentials
): Promise<Response> {
assert(credentials, "credentials are required for performApiFetch");
const method = init.method ?? "GET";
assert(
resource.startsWith("/"),
`CF API fetch - resource path must start with a "/" but got "${resource}"`
);
const headers = cloneHeaders(new Headers(init.headers));
addAuthorizationHeader(headers, credentials);
headers.set("User-Agent", userAgent);
maybeAddTraceHeader(headers);
const queryString = queryParams ? `?${queryParams.toString()}` : "";
logger.debug(
`-- START CF API REQUEST: ${method} ${getCloudflareApiBaseUrl(complianceConfig)}${resource}`
);
logger.debugWithSanitization?.("QUERY STRING:", queryString);
logHeaders(headers, logger);
logger.debugWithSanitization?.("INIT:", JSON.stringify({ ...init }, null, 2));
if (init.body instanceof FormData) {
logger.debugWithSanitization?.(
"BODY:",
await new Response(init.body).text(),
null,
2
);
}
logger.debug("-- END CF API REQUEST");
return await fetch(
`${getCloudflareApiBaseUrl(complianceConfig)}${resource}${queryString}`,
{
method,
...init,
headers,
signal: abortSignal,
}
);
}
export async function fetchInternalBase<ResponseType>(
complianceConfig: ComplianceConfig,
resource: string,
init: RequestInit = {},
userAgent: string,
logger: Logger,
queryParams?: URLSearchParams,
abortSignal?: AbortSignal,
credentials?: ApiCredentials
): Promise<{ response: ResponseType; status: number }> {
const method = init.method ?? "GET";
const response = await performApiFetchBase(
complianceConfig,
resource,
init,
userAgent,
logger,
queryParams,
abortSignal,
credentials
);
const jsonText = await response.text();
logger.debug(
"-- START CF API RESPONSE:",
response.statusText,
response.status
);
logHeaders(response.headers, logger);
logger.debugWithSanitization?.("RESPONSE:", jsonText);
logger.debug("-- END CF API RESPONSE");
if (!jsonText && (response.status === 204 || response.status === 205)) {
return {
response: {
result: {},
success: true,
errors: [],
messages: [],
} as ResponseType,
status: response.status,
};
}
if (isWAFBlockResponse(response.headers)) {
throwWAFBlockError(
response.headers,
method,
resource,
response.status,
response.statusText
);
}
try {
const json = parseJSON(jsonText) as ResponseType;
return { response: json, status: response.status };
} catch {
const rayId = extractWAFBlockRayId(response.headers);
throw new APIError({
text: "Received a malformed response from the API",
notes: [
{
text: truncate(jsonText, 100),
},
{
text: `${method} ${resource} -> ${response.status} ${response.statusText}`,
},
...(rayId ? [{ text: `Cloudflare Ray ID: ${rayId}` }] : []),
],
status: response.status,
telemetryMessage: false,
});
}
}
export async function fetchResultBase<ResponseType>(
complianceConfig: ComplianceConfig,
resource: string,
init: RequestInit = {},
userAgent: string,
logger: Logger,
queryParams?: URLSearchParams,
abortSignal?: AbortSignal,
credentials?: ApiCredentials
): Promise<ResponseType> {
const { response: json, status } = await fetchInternalBase<
FetchResult<ResponseType>
>(
complianceConfig,
resource,
init,
userAgent,
logger,
queryParams,
abortSignal,
credentials
);
if (json.success) {
return json.result;
} else {
throwFetchError(resource, json, status);
}
}
export async function fetchListResultBase<ResponseType>(
complianceConfig: ComplianceConfig,
resource: string,
init: RequestInit = {},
userAgent: string,
logger: Logger,
queryParams?: URLSearchParams,
credentials?: ApiCredentials
): Promise<ResponseType[]> {
const results: ResponseType[] = [];
let getMoreResults = true;
let cursor: string | undefined;
while (getMoreResults) {
if (cursor) {
queryParams = new URLSearchParams(queryParams);
queryParams.set("cursor", cursor);
}
const { response: json, status } = await fetchInternalBase<
FetchResult<ResponseType[]>
>(
complianceConfig,
resource,
init,
userAgent,
logger,
queryParams,
undefined,
credentials
);
if (json.success) {
results.push(...json.result);
if (hasCursor(json.result_info)) {
cursor = json.result_info?.cursor;
} else {
getMoreResults = false;
}
} else {
throwFetchError(resource, json, status);
}
}
return results;
}
export function truncate(text: string, maxLength: number): string {
const { length } = text;
if (length <= maxLength) {
return text;
}
return `${text.substring(0, maxLength)}... (length = ${length})`;
}
export function isWAFBlockResponse(headers: Headers): boolean {
return headers.get("cf-mitigated") === "challenge";
}
export function extractWAFBlockRayId(headers: Headers): string | undefined {
return headers.get("cf-ray") ?? undefined;
}
export function extractAccountTag(resource: string): string | undefined {
const re = new RegExp("/accounts/([a-zA-Z0-9]+)/?");
const matches = re.exec(resource);
return matches?.[1];
}
interface PageResultInfo {
page: number;
per_page: number;
count: number;
total_count: number;
}
export function hasMorePages(
result_info: unknown
): result_info is PageResultInfo {
const page = (result_info as PageResultInfo | undefined)?.page;
const per_page = (result_info as PageResultInfo | undefined)?.per_page;
const total = (result_info as PageResultInfo | undefined)?.total_count;
return (
page !== undefined &&
per_page !== undefined &&
total !== undefined &&
page * per_page < total
);
}
export function renderError(
err:
| FetchError
| { code?: number; message?: string; documentation_url?: string },
level = 0
): string {
const indent = " ".repeat(level);
const message = err.message ?? "";
const chainedMessages =
"error_chain" in err
? ((err as FetchError).error_chain
?.map(
(chainedError) =>
`\n\n${indent}- ${renderError(chainedError, level + 1)}`
)
.join("\n") ?? "")
: "";
return (
(err.code ? `${message} [code: ${err.code}]` : message) +
(err.documentation_url
? `\n${indent}To learn more about this error, visit: ${err.documentation_url}`
: "") +
chainedMessages
);
}
export function addAuthorizationHeader(
headers: Headers,
auth: ApiCredentials,
overrideExisting = false
): void {
if (!headers.has("Authorization") || overrideExisting) {
if ("apiToken" in auth) {
const authorizationHeader = `Bearer ${auth.apiToken}`;
validateAuthorizationHeaderValue(authorizationHeader);
headers.set("Authorization", authorizationHeader);
} else {
headers.set("X-Auth-Key", auth.authKey);
headers.set("X-Auth-Email", auth.authEmail);
}
}
}
function validateAuthorizationHeaderValue(value: string): void {
for (const character of value) {
const codePoint = character.codePointAt(0);
if (codePoint === undefined || codePoint > 255) {
throw new UserError(
`The configured Cloudflare API token contains a character that cannot be used in an HTTP Authorization header: ${formatAuthorizationHeaderCharacter(character, codePoint)}. Recreate or copy the token again, making sure it does not include characters such as ellipses.`,
{
telemetryMessage: "cfetch auth invalid authorization header",
}
);
}
}
}
function formatAuthorizationHeaderCharacter(
character: string,
codePoint: number | undefined
): string {
if (codePoint === undefined) {
return '"\\u{unknown}"';
}
const codePointLabel = `U+${codePoint.toString(16).toUpperCase().padStart(4, "0")}`;
const characterLabel = isPrintableCharacter(character)
? `"${character}"`
: `"${escapeCharacter(character)}"`;
return `${characterLabel} (${codePointLabel})`;
}
function isPrintableCharacter(character: string): boolean {
return !/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(character);
}
function escapeCharacter(character: string): string {
return Array.from(character)
.map((c) => {
const codePoint = c.codePointAt(0);
if (codePoint === undefined) {
return "";
}
return codePoint <= 0xffff
? `\\u${codePoint.toString(16).toUpperCase().padStart(4, "0")}`
: `\\u{${codePoint.toString(16).toUpperCase()}}`;
})
.join("");
}
export function throwFetchError(
resource: string,
response: FetchResult<unknown>,
status: number
): never {
const errors = response.errors ?? [];
for (const error of errors) {
maybeThrowFriendlyError(error);
}
// Some API endpoints return non-standard error envelopes (e.g. {code, error}
// instead of {errors: [...]}). Surface those as notes when errors is empty.
const notes = [
...errors.map((err) => ({ text: renderError(err) })),
...(response.messages?.map((msg) => ({
text: typeof msg === "string" ? msg : (msg.message ?? String(msg)),
})) ?? []),
];
if (notes.length === 0) {
const raw = response as unknown as Record<string, unknown>;
const fallbackMessage =
typeof raw.error === "string"
? `${raw.error}${raw.code ? ` [code: ${raw.code}]` : ""}`
: undefined;
if (fallbackMessage) {
notes.push({ text: fallbackMessage });
}
}
const error = new APIError({
text: `A request to the Cloudflare API (${resource}) failed.`,
notes,
status,
telemetryMessage: false,
});
// add the first error code directly to this error
// so consumers can use it for specific behaviour
const code = errors[0]?.code;
if (code) {
error.code = code;
}
// hoist the first error's `meta` (if any) so consumers can inspect
// endpoint-specific structured error payloads without re-parsing the body
const meta = errors[0]?.meta;
if (meta) {
error.meta = meta;
}
// extract the account tag from the resource (if any)
error.accountTag = extractAccountTag(resource);
throw error;
}
function throwWAFBlockError(
headers: Headers,
method: string,
resource: string,
status: number,
statusText: string
): never {
const rayId = extractWAFBlockRayId(headers);
throw new APIError({
text: "The Cloudflare API responded with a WAF block page instead of the expected JSON response",
notes: [
{
text: "Cloudflare's firewall (WAF) blocked this API request. This is usually a false positive.",
},
...(rayId ? [{ text: `Cloudflare Ray ID: ${rayId}` }] : []),
{
text: rayId
? "If the issue persists, please open a Cloudflare Support ticket and include the Ray ID above."
: "If the issue persists, please open a Cloudflare Support ticket. You can find the Cloudflare Ray ID on the block page in your browser.",
},
{
text: `${method} ${resource} -> ${status} ${statusText}`,
},
],
status,
telemetryMessage: false,
});
}
/**
* Fetch a raw KV value from the Cloudflare API.
*
* This is special-cased because it's the only API endpoint that returns raw
* binary data instead of a JSON envelope.
*
* Note: callers must call encodeURIComponent on `key` before passing it.
*/
export async function fetchKVGetValueBase(
complianceConfig: ComplianceConfig,
accountId: string,
namespaceId: string,
key: string,
userAgent: string,
logger: Logger,
credentials: ApiCredentials
): Promise<ArrayBuffer> {
const headers = new Headers();
addAuthorizationHeader(headers, credentials);
headers.set("User-Agent", userAgent);
maybeAddTraceHeader(headers);
const resource = `${getCloudflareApiBaseUrl(complianceConfig)}/accounts/${accountId}/storage/kv/namespaces/${namespaceId}/values/${key}`;
logger.debug(`-- START CF API REQUEST: GET ${resource}`);
logger.debug("-- END CF API REQUEST");
const response = await fetch(resource, {
method: "GET",
headers,
});
if (response.ok) {
return await response.arrayBuffer();
} else {
throw new Error(
`Failed to fetch ${resource} - ${response.status}: ${response.statusText}`
);
}
}
export type FetchKVGetValueFetcher = (
complianceConfig: ComplianceConfig,
accountId: string,
namespaceId: string,
key: string
) => Promise<ArrayBuffer>;
export function hasCursor(
result_info: unknown
): result_info is { cursor: string } {
const cursor = (result_info as { cursor: string } | undefined)?.cursor;
return cursor !== undefined && cursor !== null && cursor !== "";
}
export function maybeAddTraceHeader(headers: Headers): void {
const traceHeader = getTraceHeader();
if (traceHeader) {
headers.set("Cf-Trace-Id", traceHeader);
}
}
function cloneHeaders(headers: HeadersInit | undefined): Headers {
return new Headers(headers);
}
+736
View File
@@ -0,0 +1,736 @@
/**
* cloudflared binary management for Wrangler tunnel commands.
*
* This module handles downloading, caching, and running the cloudflared binary.
* It uses the Cloudflare update worker (update.argotunnel.com) to resolve
* the latest version and download URL, matching cloudflared's own update mechanism.
*/
import { execFileSync, spawn } from "node:child_process";
import { createHash } from "node:crypto";
import {
accessSync,
chmodSync,
constants,
existsSync,
mkdirSync,
readFileSync,
renameSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { arch } from "node:os";
import { dirname, join } from "node:path";
import { sync as commandExistsSync } from "command-exists";
import { fetch } from "undici";
import { getCloudflaredPathFromEnv } from "./environment-variables/misc-variables";
import { UserError } from "./errors";
import { removeDirSync } from "./fs-helpers";
import { getGlobalConfigPath } from "./global-wrangler-config-path";
import type { Logger } from "./logger";
import type { ChildProcess } from "node:child_process";
/**
* Cloudflare update worker URL.
* This is the same endpoint cloudflared itself uses for self-update.
* It takes os, arch, and version query parameters and returns JSON with
* the download URL, version, checksum, and whether compression is used.
* The worker uses KV for caching.
*/
const UPDATE_SERVICE_URL = "https://update.argotunnel.com";
/**
* Response shape from the Cloudflare update worker.
*/
interface VersionResponse {
url: string;
version: string;
checksum: string;
compressed: boolean;
shouldUpdate: boolean;
userMessage: string;
error: string;
}
const CLOUDFLARED_VERSION_PATTERN = /^\d{4}\.\d+\.\d+$/;
function sha256Hex(buffer: Buffer): string {
return createHash("sha256").update(buffer).digest("hex");
}
/**
* Map Node.js arch() values to Go runtime.GOARCH values
* used by the update worker.
*/
function getGoArch(): string {
const nodeArch = arch();
switch (nodeArch) {
case "x64":
return "amd64";
case "arm64":
return "arm64";
case "arm":
return "arm";
default:
throw new UserError(
`Unsupported architecture for cloudflared: ${nodeArch}\n\n` +
`cloudflared supports: x64 (amd64), arm64, arm\n\n` +
`You can manually install cloudflared and set the CLOUDFLARED_PATH environment variable.\n` +
`Download instructions: https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/`,
{ telemetryMessage: "tunnel cloudflared unsupported architecture" }
);
}
}
/**
* Map Node.js process.platform to Go runtime.GOOS values
* used by the update worker.
*/
function getGoOS(): string {
switch (process.platform) {
case "darwin":
return "darwin";
case "linux":
return "linux";
case "win32":
return "windows";
default:
throw new UserError(
`Unsupported platform for cloudflared: ${process.platform}\n\n` +
`cloudflared supports: darwin (macOS), linux, win32 (Windows)\n\n` +
`You can manually install cloudflared and set the CLOUDFLARED_PATH environment variable.\n` +
`Download instructions: https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/`,
{ telemetryMessage: "tunnel cloudflared unsupported platform" }
);
}
}
/** GitHub release URL pattern for cloudflared binaries. */
const GITHUB_RELEASE_BASE =
"https://github.com/cloudflare/cloudflared/releases/download";
/**
* Build the expected GitHub release asset filename for this platform.
*/
export function getAssetFilename(goOS: string, goArch: string): string {
if (goOS === "windows") {
return `cloudflared-${goOS}-${goArch}.exe`;
}
if (goOS === "darwin") {
return `cloudflared-${goOS}-${goArch}.tgz`;
}
return `cloudflared-${goOS}-${goArch}`;
}
/**
* Query the update worker for a specific os/arch combination.
* Returns the parsed response, or null if the request failed or the
* worker returned an error (e.g. "no release found").
*/
async function queryUpdateService(
goOS: string,
goArch: string,
options?: { logger?: Pick<Logger, "debug" | "log" | "warn"> }
): Promise<VersionResponse | null> {
const { logger } = options ?? {};
const url = new URL(UPDATE_SERVICE_URL);
url.searchParams.set("os", goOS);
url.searchParams.set("arch", goArch);
logger?.debug(`Checking for latest cloudflared: ${url.toString()}`);
let response: Response;
try {
response = await fetch(url.toString(), {
headers: { "User-Agent": "wrangler" },
});
} catch (e) {
logger?.debug(
`Failed to reach update service: ${e instanceof Error ? e.message : String(e)}`
);
return null;
}
if (!response.ok) {
logger?.debug(
`Update service returned ${response.status} for ${goOS}/${goArch}`
);
return null;
}
let data: VersionResponse;
try {
data = (await response.json()) as VersionResponse;
} catch (e) {
logger?.debug(
`Update service returned non-JSON response: ${e instanceof Error ? e.message : String(e)}`
);
return null;
}
if (
typeof data.version === "string" &&
!CLOUDFLARED_VERSION_PATTERN.test(data.version)
) {
throw new Error(
`[cloudflared] Invalid cloudflared version returned by update service: ${data.version}`
);
}
// The update worker may return a response with only a version but no download URL
// (e.g. when querying a platform it doesn't have a binary for). In that case we
// still return the data so callers can use the version to construct a fallback URL.
if (data.error || !data.url || !data.version) {
return data.version ? data : null;
}
return data;
}
/**
* Query the Cloudflare update worker to get the latest cloudflared version info.
*
* The update worker doesn't have entries for every os/arch combination
* (e.g. darwin/arm64 is missing even though the GitHub release exists).
* When the primary query fails, we fall back to querying a known-working
* combination (linux/amd64) to discover the latest version, then construct
* the GitHub release URL directly.
*/
async function getLatestVersionInfo(options?: {
logger?: Pick<Logger, "debug" | "log" | "warn">;
}): Promise<VersionResponse> {
const { logger } = options ?? {};
const goOS = getGoOS();
const goArch = getGoArch();
// Try the update worker for our exact platform first
const primary = await queryUpdateService(goOS, goArch, { logger });
if (primary && primary.url && primary.version) {
return primary;
}
// Fallback: query a known-working combination to get the latest version,
// then construct the GitHub download URL for our actual platform.
logger?.debug(
`Update worker had no result for ${goOS}/${goArch}, falling back to GitHub release URL`
);
const fallback = await queryUpdateService("linux", "amd64", { logger });
if (!fallback?.version) {
throw new UserError(
`[cloudflared] Failed to determine the latest cloudflared version.\n\n` +
`The update service did not return results for ${goOS}/${goArch},\n` +
`and the fallback query also failed.\n\n` +
`You can manually install cloudflared from:\n` +
`https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/`,
{ telemetryMessage: "tunnel cloudflared version lookup failed" }
);
}
const version = fallback.version;
const filename = getAssetFilename(goOS, goArch);
const url = `${GITHUB_RELEASE_BASE}/${version}/${filename}`;
const compressed = filename.endsWith(".tgz");
return {
url,
version,
checksum: "", // no checksum available for fallback URLs
compressed,
shouldUpdate: true,
userMessage: "",
error: "",
};
}
/**
* Get the directory where cloudflared binary should be cached.
* Uses the resolved version so the cache is per-version.
*/
function getCacheDir(version: string): string {
return join(getGlobalConfigPath(), "cloudflared", version);
}
/**
* Get the expected path for the cloudflared binary within a version cache dir.
*/
export function getCloudflaredBinPath(version: string): string {
const binName =
process.platform === "win32" ? "cloudflared.exe" : "cloudflared";
return join(getCacheDir(version), binName);
}
/**
* Check if cloudflared binary exists and is executable at a given path.
*/
function isBinaryExecutable(binPath: string): boolean {
try {
accessSync(binPath, constants.X_OK);
return true;
} catch {
return false;
}
}
/**
* Validate that a binary works correctly by running --version.
*/
function validateBinary(
binPath: string,
options?: { logger?: Pick<Logger, "debug" | "log" | "warn"> }
): void {
const { logger } = options ?? {};
try {
const output = execFileSync(binPath, ["--version"], {
stdio: ["pipe", "pipe", "pipe"],
timeout: 10000,
encoding: "utf8",
}).trim();
logger?.debug(`cloudflared version: ${output}`);
} catch {
let errorMessage = `[cloudflared] Failed to validate cloudflared binary at ${binPath}\n\n`;
errorMessage += `This usually means:\n`;
errorMessage += ` - The binary is corrupted or incomplete\n`;
errorMessage += ` - You're missing required system libraries\n`;
if (process.platform === "linux") {
errorMessage += `\nOn Linux, make sure you have the required dependencies:\n`;
errorMessage += ` - glibc (GNU C Library)\n`;
errorMessage += ` - For Debian/Ubuntu: sudo apt-get install libc6\n`;
}
const cacheDir = join(getGlobalConfigPath(), "cloudflared");
errorMessage += `\nYou can try:\n`;
errorMessage += ` 1. Deleting the cache directory: rm -rf ${cacheDir}\n`;
errorMessage += ` 2. Running the command again to re-download\n`;
errorMessage += ` 3. Manually installing cloudflared: https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/\n`;
errorMessage += ` 4. Setting CLOUDFLARED_PATH to point to your cloudflared binary`;
throw new UserError(errorMessage, {
telemetryMessage: "tunnel cloudflared validation failed",
});
}
}
export function redactCloudflaredArgsForLogging(args: string[]): string[] {
const redacted = [...args];
for (let i = 0; i < redacted.length; i++) {
const arg = redacted[i];
if (arg === "--token" && i + 1 < redacted.length) {
redacted[i + 1] = "[REDACTED]";
}
if (arg.startsWith("--token=")) {
redacted[i] = "--token=[REDACTED]";
}
}
return redacted;
}
function tryGetCloudflaredFromPath(options?: {
logger?: Pick<Logger, "debug" | "log" | "warn">;
}): string | null {
const { logger } = options ?? {};
if (!commandExistsSync("cloudflared")) {
return null;
}
try {
validateBinary("cloudflared", { logger });
return "cloudflared";
} catch (e) {
logger?.debug("cloudflared found in PATH but failed validation", e);
return null;
}
}
/**
* Extract the version string from `cloudflared --version` output.
* Output format: "cloudflared version 2025.7.0 (built 2025-07-03-1703 UTC)"
* Returns null if the version cannot be parsed.
*/
function getInstalledVersion(binPath: string): string | null {
try {
const output = execFileSync(binPath, ["--version"], {
stdio: ["pipe", "pipe", "pipe"],
timeout: 10000,
encoding: "utf8",
});
const match = output.match(/(\d+\.\d+\.\d+)/);
return match ? match[1] : null;
} catch {
return null;
}
}
/**
* Compare two cloudflared version strings (e.g. "2025.7.0" vs "2026.2.0").
* Returns true if `installed` is older than `latest`.
*/
export function isVersionOutdated(installed: string, latest: string): boolean {
const parse = (v: string) => v.split(".").map(Number);
const [iYear, iMonth, iPatch] = parse(installed);
const [lYear, lMonth, lPatch] = parse(latest);
if (iYear !== lYear) {
return iYear < lYear;
}
if (iMonth !== lMonth) {
return iMonth < lMonth;
}
return iPatch < lPatch;
}
/**
* Check if a PATH-installed cloudflared is outdated compared to the latest
* version from the update worker. Logs a warning if outdated.
* This runs asynchronously and never throws — it's purely advisory.
*/
async function warnIfOutdated(
binPath: string,
options?: { logger?: Pick<Logger, "debug" | "log" | "warn"> }
): Promise<void> {
const { logger } = options ?? {};
try {
const installed = getInstalledVersion(binPath);
if (!installed) {
return;
}
// Try our platform first, fall back to linux/amd64 (always available)
const latest =
(await queryUpdateService(getGoOS(), getGoArch(), { logger })) ??
(await queryUpdateService("linux", "amd64", { logger }));
const latestVersion = latest?.version;
if (!latestVersion) {
return;
}
if (isVersionOutdated(installed, latestVersion)) {
logger?.warn(
`Your cloudflared (${installed}) is outdated. Latest version is ${latestVersion}.\n` +
`Update: https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/`
);
}
} catch (e) {
logger?.debug("Failed to check cloudflared version", e);
}
}
function writeFileAtomic(filePath: string, contents: Buffer): void {
const dir = dirname(filePath);
const tmpPath = join(
dir,
`.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
);
try {
writeFileSync(tmpPath, contents);
renameSync(tmpPath, filePath);
} finally {
try {
if (existsSync(tmpPath)) {
unlinkSync(tmpPath);
}
} catch {
// Ignore cleanup errors
}
}
}
/**
* Download cloudflared binary using the version info from the update worker.
*/
async function downloadCloudflared(
versionInfo: VersionResponse,
binPath: string,
options?: { logger?: Pick<Logger, "debug" | "log" | "warn"> }
): Promise<void> {
const { logger } = options ?? {};
const { url, version, checksum, compressed } = versionInfo;
logger?.log(`Downloading cloudflared ${version}...`);
logger?.debug(`Download URL: ${url}`);
const cacheDir = dirname(binPath);
mkdirSync(cacheDir, { recursive: true });
let response: Response;
try {
response = await fetch(url, {
headers: { "User-Agent": "wrangler" },
});
} catch (e) {
throw new UserError(
`[cloudflared] Failed to download cloudflared from ${url}\n\n` +
`Network error: ${e instanceof Error ? e.message : String(e)}\n\n` +
`Please check your internet connection and try again.\n` +
`If you're behind a proxy, make sure it's configured correctly.`,
{ telemetryMessage: "tunnel cloudflared download network failed" }
);
}
if (!response.ok) {
throw new UserError(
`[cloudflared] Failed to download cloudflared from ${url}\n\n` +
`HTTP ${response.status}: ${response.statusText}\n\n` +
`You can manually download cloudflared from:\n` +
`https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/`,
{ telemetryMessage: "tunnel cloudflared download response failed" }
);
}
try {
if (compressed) {
await downloadAndExtractTarball(response, checksum, binPath, cacheDir);
} else {
await downloadBinary(response, checksum, binPath);
}
} catch (e) {
// Clean up partial downloads
try {
if (existsSync(binPath)) {
unlinkSync(binPath);
}
} catch {
// Ignore cleanup errors
}
if (e instanceof UserError) {
throw e;
}
throw new UserError(
`[cloudflared] Failed to save cloudflared binary\n\n` +
`Error: ${e instanceof Error ? e.message : String(e)}\n\n` +
`Please ensure you have write permissions to: ${cacheDir}`,
{ telemetryMessage: "tunnel cloudflared save failed" }
);
}
// Make executable on Unix systems
if (process.platform !== "win32") {
chmodSync(binPath, 0o755);
}
logger?.log(`cloudflared ${version} installed`);
logger?.debug(`Binary location: ${binPath}`);
}
/**
* Download and extract a tarball (for macOS).
*
* The update service checksum is for the extracted binary, not the tarball
* itself. This matches cloudflared's own auto-update behavior — see
* cloudflared/cmd/cloudflared/updater/workers_update.go: the download()
* function decompresses .tgz into the raw binary, then Apply() checksums
* the resulting file. We do the same: extract first, then verify.
*/
async function downloadAndExtractTarball(
response: Response,
expectedChecksum: string,
binPath: string,
cacheDir: string
): Promise<void> {
const tempTarPath = join(cacheDir, "cloudflared.tgz");
const buffer = Buffer.from(await response.arrayBuffer());
writeFileSync(tempTarPath, buffer);
try {
execFileSync("tar", ["-xzf", tempTarPath, "-C", cacheDir], {
stdio: "ignore",
});
const extractedPath = join(cacheDir, "cloudflared");
if (extractedPath !== binPath && existsSync(extractedPath)) {
renameSync(extractedPath, binPath);
}
// Verify checksum against the extracted binary, not the tarball.
// The update service provides checksums for the uncompressed binary.
if (expectedChecksum) {
const extractedBinary = readFileSync(binPath);
const actualSha256 = sha256Hex(extractedBinary);
if (actualSha256 !== expectedChecksum) {
throw new UserError(
`[cloudflared] SHA256 mismatch for downloaded cloudflared binary.\n\n` +
`Expected: ${expectedChecksum}\n` +
`Actual: ${actualSha256}`,
{
telemetryMessage:
"tunnel cloudflared extracted binary checksum mismatch",
}
);
}
}
} finally {
try {
if (existsSync(tempTarPath)) {
unlinkSync(tempTarPath);
}
} catch {
// Ignore cleanup errors
}
}
}
/**
* Download binary directly (for Linux/Windows)
*/
async function downloadBinary(
response: Response,
expectedChecksum: string,
binPath: string
): Promise<void> {
const buffer = Buffer.from(await response.arrayBuffer());
if (expectedChecksum) {
const actualSha256 = sha256Hex(buffer);
if (actualSha256 !== expectedChecksum) {
throw new UserError(
`[cloudflared] SHA256 mismatch for downloaded cloudflared binary.\n\n` +
`Expected: ${expectedChecksum}\n` +
`Actual: ${actualSha256}`,
{ telemetryMessage: "tunnel cloudflared binary checksum mismatch" }
);
}
}
writeFileAtomic(binPath, buffer);
}
/**
* Get cloudflared binary path, installing if necessary.
*
* Resolution order:
* 1. CLOUDFLARED_PATH environment variable (user override)
* 2. cloudflared in system PATH
* 3. Cached binary in ~/.wrangler/cloudflared/{version}/
* 4. Download latest from Cloudflare update worker
*/
export async function getCloudflaredPath(options?: {
skipVersionCheck?: boolean;
confirmDownload?: (message: string) => Promise<boolean>;
logger?: Pick<Logger, "debug" | "log" | "warn">;
}): Promise<string> {
const logger = options?.logger;
// Check for environment variable override first
const envPath = getCloudflaredPathFromEnv();
if (envPath) {
if (!existsSync(envPath)) {
throw new UserError(
`CLOUDFLARED_PATH is set to "${envPath}" but the file does not exist.\n\n` +
`Please ensure the path points to a valid cloudflared binary.`,
{ telemetryMessage: "tunnel cloudflared env path missing" }
);
}
logger?.debug(`Using cloudflared from CLOUDFLARED_PATH: ${envPath}`);
// Skip validation — the user explicitly set the path, so trust it.
// This also avoids issues on platforms where the binary format
// differs (e.g. shell scripts won't pass --version on Windows).
return envPath;
}
// Next, prefer a user-installed cloudflared in PATH.
const pathBin = tryGetCloudflaredFromPath({ logger });
if (pathBin) {
logger?.debug("Using cloudflared from PATH");
if (!options?.skipVersionCheck) {
await warnIfOutdated(pathBin, { logger });
}
return pathBin;
}
// Query the update worker for the latest version
const versionInfo = await getLatestVersionInfo({ logger });
const binPath = getCloudflaredBinPath(versionInfo.version);
// Check if this version is already cached and valid
let needsDownload = !isBinaryExecutable(binPath);
if (!needsDownload) {
try {
validateBinary(binPath, { logger });
logger?.debug(
`Using cached cloudflared ${versionInfo.version}: ${binPath}`
);
return binPath;
} catch (e) {
logger?.debug("Cached cloudflared failed validation; re-downloading", e);
needsDownload = true;
}
}
// Prompt user before downloading
const shouldDownload =
(await options?.confirmDownload?.(
`cloudflared (${versionInfo.version}) is needed but not installed. Download to ${binPath}?`
)) ?? true;
if (!shouldDownload) {
throw new UserError(
`cloudflared is required to run this command.\n\n` +
`You can install it manually from:\n` +
`https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/\n\n` +
`Then either add it to your PATH or set CLOUDFLARED_PATH.`,
{ telemetryMessage: "tunnel cloudflared download declined" }
);
}
// Remove corrupted cache only after user has confirmed re-download
if (existsSync(binPath)) {
const cacheDir = removeCloudflaredCache(versionInfo.version);
if (cacheDir) {
logger?.log(`Removed cloudflared cache: ${cacheDir}`);
}
}
// Download cloudflared
await downloadCloudflared(versionInfo, binPath, { logger });
// Validate the downloaded binary
validateBinary(binPath, { logger });
return binPath;
}
/**
* Spawn cloudflared process with automatic binary management
*/
export async function spawnCloudflared(
args: string[],
options?: {
stdio?: "inherit" | "pipe";
env?: Record<string, string>;
skipVersionCheck?: boolean;
confirmDownload?: (message: string) => Promise<boolean>;
logger?: Pick<Logger, "debug" | "log" | "warn">;
}
): Promise<ChildProcess> {
const logger = options?.logger;
const binPath = await getCloudflaredPath({
skipVersionCheck: options?.skipVersionCheck,
confirmDownload: options?.confirmDownload,
logger,
});
logger?.debug(
`Spawning cloudflared: ${binPath} ${redactCloudflaredArgsForLogging(args).join(" ")}`
);
const cloudflared = spawn(binPath, args, {
stdio: options?.stdio ?? "inherit",
env: options?.env ? { ...process.env, ...options.env } : undefined,
});
return cloudflared;
}
/**
* Remove cached cloudflared binary for a specific version, or all versions.
*/
export function removeCloudflaredCache(version?: string): string | null {
const cacheDir = version
? getCacheDir(version)
: join(getGlobalConfigPath(), "cloudflared");
if (existsSync(cacheDir)) {
removeDirSync(cacheDir);
return cacheDir;
}
return null;
}
@@ -0,0 +1,39 @@
import assert from "node:assert";
type YYYY = `${number}${number}${number}${number}`;
type MM = `${number}${number}`;
type DD = `${number}${number}`;
/**
* Represents a valid compatibility date, a string such as `2025-09-27`
*/
export type CompatDate = `${YYYY}-${MM}-${DD}`;
/**
* Discern whether a string represents a compatibility date (`YYYY-MM-DD`)
*
* @param str The target string
* @returns true if the string represents a compatibility date, false otherwise
*/
export function isCompatDate(str: string): str is CompatDate {
return /^\d{4}-\d{2}-\d{2}$/.test(str);
}
/**
* Returns the date formatted as a compatibility date
*
* @param date The target date to convert
* @returns The date as a CompatDate string (a string following the format `YYYY-MM-DD`)
*/
function formatCompatibilityDate(date: Date): CompatDate {
const compatDate = date.toISOString().slice(0, 10);
assert(isCompatDate(compatDate));
return compatDate;
}
/**
* Returns today's date as a compatibility date string (`YYYY-MM-DD`).
*/
export function getTodaysCompatDate(): CompatDate {
return formatCompatibilityDate(new Date());
}
+107
View File
@@ -0,0 +1,107 @@
# Configuration validation
The files in this directory define and validate the configuration that is read from a Wrangler configuration file.
The configuration for a Worker is complicated since we can define different "environments", and each environment can have its own configuration.
There is a default ("top-level") environment and then named environments that provide environment specific configuration.
This is further complicated by the fact that there are three kinds of environment configuration:
- **non-overridable**: these values are defined once in the top-level configuration, apply to all environments and cannot be overridden by an environment.
- **inheritable**: these values can be defined at the top-level but can also be overridden by environment specific values.
Named environments do not need to provide their own values, in which case they inherit the value from the top-level.
- **non-inheritable**: these values must be explicitly defined in each environment if they are defined at the top-level.
Named environments do not inherit such configuration and must provide their own values.
All configuration values in Wrangler configuration are optional and will receive a default value if not defined.
## Types
### Environment
The fields that can be defined within the `env` containers are defined in the [`Environment`](./environment.ts) type.
This includes the `EnvironmentInheritable` and `EnvironmentNonInheritable` fields.
### Config
The "non-overridable" types are defined in the [`ConfigFields`](./config.ts) type.
The `Config` type is the overall configuration, which consists of the `ConfigFields` and also an `Environment`.
In this case the `Environment`, here, corresponds to the "currently active" environment. This is specified by the `--env` command line argument.
If there is no argument passed then the currently active environment is the "top-level" environment.
The fields in `Config` and `Environment` are not generally optional and so you can expect they have been filled with suitable inherited or default values.
These types should be used when you are working with fields that should be passed to commands.
### RawConfig
The `RawConfig` type is a version of `Config`, where all the fields are optional.
The `RawConfig` type includes `DeprecatedConfigFields` and `EnvironmentMap`.
It also extends the `RawEnvironment` type, which is a version of `Environment` where all the fields are optional.
These optional fields map to the actual fields found in the Wrangler configuration file.
These types should be used when you are working with raw configuration that is read or will be written to a Wrangler configuration file.
## Validation
Validation is triggered by passing a `RawConfig` object, and the active environment name, to the `normalizeAndValidateConfig()` function.
This function will return:
- a `Config` object, where all the fields have suitable valid values
- a `Diagnostics` object, which contains any errors or warnings from the validation process
The field values may have been parsed directly from the `RawConfig`, inherited into a named environment from the top-level environment, or given a default value.
Generally, if there are any warnings they should be presented to the user via `logger.warn()` messages,
and if there are any errors then an `Error` should be thrown describing these errors.
The `Diagnostics` object is hierarchical: each `Diagnostics` instance can contain a collection of child `Diagnostics` instance.
When checking for or rendering warnings and errors, the `Diagnostics` class will automatically traverse down to all its children.
## Usage
The [high level API](./index.ts) for configuration processing consists of the `findWranglerToml()` and `readConfig()` functions.
### readConfig()
The `readConfig()` function will find the nearest Wrangler configuration file, load and parse it, then validate and normalize the values into a `Config` object.
Note that you should pass the current active environment name in as a parameter. The resulting `Config` object will contain only the fields appropriate to that environment.
If there are validation errors then it will throw a suitable error.
## Changing configuration
### Add a new configuration field
When a new field needs to be added to the Wrangler configuration you will need to add to the types and validation code in this directory.
Here are some steps that you should consider when doing this:
- add the new field to one of the interface:
- if the field is not overridable in an environment then add it to the `ConfigFields` interface.
- if the field can be inherited and overridden in a named environment then add it to the `EnvironmentInheritable` interface.
- if the field cannot be inherited and must be specified in each named environment then add it to the `EnvironmentNonInheritable` interface.
- if the field is experimental then add a call to the `experimental()` function:
- if the field is now in the `ConfigDeprecated` interface then add the call to the `normalizeAndValidateConfig()` function.
- if the field is now in the `EnvironmentDeprecated` interface then add the call to the `normalizeAndValidateEnvironment()` function.
- add validation and normalization to the interface
- if the field is in `ConfigFields` then add validation calls to `normalizeAndValidateConfig()` and assign the normalized value to the appropriate property in the `config` object.
- if the field is in `EnvironmentInheritable` then call `inheritable()` in `normalizeAndValidateEnvironment()` and assign the normalized value to the appropriate property in the `environment` object.
- if the field is in `EnvironmentNonInheritable` then call `notInheritable()` in `normalizeAndValidateEnvironment()` and assign the normalized value to the appropriate property in the `environment` object.
- update the tests in `configuration.test.ts`
- add to the `"should use defaults for empty configuration"` test to prove the correct default value is assigned
- if the field is in `ConfigFields` add tests to the `"top-level non-environment configuration"` block to check the validation and normalization of values
- if the field is in `EnvironmentInheritable` or `EnvironmentNonInheritable`
- add tests to the `"top-level environment configuration"` block to check the validation and normalization of values
- add tests to the `"named environment configuration"` block to check the inheritance of values
### Remove a configuration field
We should not just remove a field from use, since users would not know that this field is no longer needed, nor how to migrate to any new usage.
Instead we should first deprecate it, and then then remove it in a future major release.
- move the field to a deprecation interface:
- if the field was originally in `ConfigFields` then move it to the `ConfigDeprecated` interface.
- if the field was originally in either `EnvironmentInheritable` or `EnvironmentNonInheritable` then move it to the `EnvironmentDeprecatedInterface`.
- remove the validation for the field
- the TypeScript compiler should indicate where there is now an unknown field, so that you can remove its validation and normalization from the code base.
- add a deprecation warning by calling `deprecated()`
- if the field is now in the `ConfigDeprecated` interface then add the call to the `normalizeAndValidateConfig()` function.
- if the field is now in the `EnvironmentDeprecated` interface then add the call to the `normalizeAndValidateEnvironment()` function.
- update the tests in `configuration.test.ts`
- Find tests where the field is mentioned and either remove them (e.g. validation) or update to show the deprecation message.
@@ -0,0 +1,83 @@
import type { Binding } from "../types";
/**
* Local-dev capability of each binding type. Source of truth for
* `pickRemoteBindings()` and `warnOrError()`.
*
* - `local-and-remote`: local simulator; `remote: true` opts into proxying.
* - `local-only`: local simulator only; `remote: true` is a config error.
* - `remote`: no local simulator *yet* — requires explicit `remote: true`.
* Move to `local-and-remote` once a simulator lands.
* - `DO-NOT-USE-this-resource-will-never-have-a-local-simulator`: no local
* simulator, *ever* — fundamentally remote-only. Always auto-routed; user
* is warned about usage charges. Adding here is permanent; prefer any
* other variant if a simulator is plausible.
*/
export type BindingLocalSupport =
| "local-and-remote"
| "local-only"
| "remote"
| "DO-NOT-USE-this-resource-will-never-have-a-local-simulator";
const BINDING_LOCAL_SUPPORT: Record<
Exclude<Binding["type"], `unsafe_${string}`> | "unsafe_hello_world",
BindingLocalSupport
> = {
plain_text: "local-only",
secret_text: "local-only",
json: "local-only",
wasm_module: "local-only",
text_blob: "local-only",
data_blob: "local-only",
version_metadata: "local-only",
inherit: "local-only",
logfwdr: "local-only",
assets: "local-only",
unsafe_hello_world: "local-only",
durable_object_namespace: "local-only",
hyperdrive: "local-only",
fetcher: "local-only",
analytics_engine: "local-only",
secrets_store_secret: "local-only",
ratelimit: "local-only",
worker_loader: "local-only",
kv_namespace: "local-and-remote",
r2_bucket: "local-and-remote",
d1: "local-and-remote",
workflow: "local-and-remote",
browser: "local-and-remote",
images: "local-and-remote",
stream: "local-and-remote",
send_email: "local-and-remote",
pipeline: "local-and-remote",
service: "local-and-remote",
// TODO: Miniflare currently ignores `remote: true` on queues, tracked in #13727.
queue: "local-and-remote",
vectorize: "remote",
mtls_certificate: "remote",
dispatch_namespace: "remote",
// Reach out to the @cloudflare/wrangler team before adding anything here
ai: "DO-NOT-USE-this-resource-will-never-have-a-local-simulator",
ai_search: "DO-NOT-USE-this-resource-will-never-have-a-local-simulator",
ai_search_namespace:
"DO-NOT-USE-this-resource-will-never-have-a-local-simulator",
media: "DO-NOT-USE-this-resource-will-never-have-a-local-simulator",
artifacts: "DO-NOT-USE-this-resource-will-never-have-a-local-simulator",
flagship: "DO-NOT-USE-this-resource-will-never-have-a-local-simulator",
vpc_service: "DO-NOT-USE-this-resource-will-never-have-a-local-simulator",
vpc_network: "DO-NOT-USE-this-resource-will-never-have-a-local-simulator",
websearch: "DO-NOT-USE-this-resource-will-never-have-a-local-simulator",
agent_memory: "DO-NOT-USE-this-resource-will-never-have-a-local-simulator",
};
export function getBindingLocalSupport(
type: Binding["type"]
): BindingLocalSupport {
if (type in BINDING_LOCAL_SUPPORT) {
return BINDING_LOCAL_SUPPORT[type as keyof typeof BINDING_LOCAL_SUPPORT];
}
return "local-only";
}
@@ -0,0 +1,183 @@
import { existsSync } from "node:fs";
import path from "node:path";
import * as find from "empathic/find";
import dedent from "ts-dedent";
import { PATH_TO_DEPLOY_CONFIG } from "../constants";
import { UserError } from "../errors";
import { parseJSONC, readFileSync } from "../parse";
import type { ComputedFields, RawConfig, RedirectedRawConfig } from "./config";
export type ResolveConfigPathOptions = {
useRedirectIfAvailable?: boolean;
};
export type ConfigPaths = {
/** Absolute path to the actual configuration being used (possibly redirected from the user's config). */
configPath: string | undefined;
/** Absolute path to the user's configuration, which may not be the same as `configPath` if it was redirected. */
userConfigPath: string | undefined;
/** Absolute path to the deploy config path used */
deployConfigPath: string | undefined;
/** Was a redirected config file read? */
redirected: boolean;
};
/**
* Resolve the path to the configuration file, given the `config` and `script` optional command line arguments.
* `config` takes precedence, then `script`, then we just use the cwd.
*
* Returns an object with two paths: `configPath` and `userConfigPath`. If defined these are absolute file paths.
*/
export function resolveWranglerConfigPath(
{
config,
script,
}: {
config?: string;
script?: string;
},
options: { useRedirectIfAvailable?: boolean }
): ConfigPaths {
if (config !== undefined) {
return {
userConfigPath: config,
configPath: config,
deployConfigPath: undefined,
redirected: false,
};
}
const leafPath = script !== undefined ? path.dirname(script) : process.cwd();
return findWranglerConfig(leafPath, options);
}
/**
* Find the wrangler configuration file by searching up the file-system
* from the current working directory.
*/
export function findWranglerConfig(
referencePath: string = process.cwd(),
{ useRedirectIfAvailable = false } = {}
): ConfigPaths {
const userConfigPath =
find.file(`wrangler.json`, { cwd: referencePath }) ??
find.file(`wrangler.jsonc`, { cwd: referencePath }) ??
find.file(`wrangler.toml`, { cwd: referencePath });
if (!useRedirectIfAvailable) {
return {
userConfigPath,
configPath: userConfigPath,
deployConfigPath: undefined,
redirected: false,
};
}
const { configPath, deployConfigPath, redirected } =
findRedirectedWranglerConfig(referencePath, userConfigPath);
return {
userConfigPath,
configPath,
deployConfigPath,
redirected,
};
}
/**
* Check whether there is a configuration file that indicates that we should redirect the user configuration.
* @param cwd
* @param userConfigPath
* @returns
*/
function findRedirectedWranglerConfig(
cwd: string,
userConfigPath: string | undefined
): {
configPath: string | undefined;
deployConfigPath: string | undefined;
redirected: boolean;
} {
const deployConfigPath = find.file(PATH_TO_DEPLOY_CONFIG, { cwd });
if (deployConfigPath === undefined) {
return { configPath: userConfigPath, deployConfigPath, redirected: false };
}
let redirectedConfigPath: string | undefined;
const deployConfigFile = readFileSync(deployConfigPath);
try {
const deployConfig = parseJSONC(deployConfigFile, deployConfigPath) as {
configPath?: string;
};
redirectedConfigPath =
deployConfig.configPath &&
path.resolve(path.dirname(deployConfigPath), deployConfig.configPath);
} catch (e) {
throw new UserError(
`Failed to parse the deploy configuration file at ${path.relative(".", deployConfigPath)}`,
{ cause: e, telemetryMessage: false }
);
}
if (!redirectedConfigPath) {
throw new UserError(
dedent`
A deploy configuration file was found at "${path.relative(".", deployConfigPath)}".
But this is not valid - the required "configPath" property was not found.
Instead this file contains:
\`\`\`
${deployConfigFile}
\`\`\`
`,
{ telemetryMessage: false }
);
}
if (!existsSync(redirectedConfigPath)) {
throw new UserError(
dedent`
There is a deploy configuration at "${path.relative(".", deployConfigPath)}".
But the redirected configuration path it points to, "${path.relative(".", redirectedConfigPath)}", does not exist.
`,
{ telemetryMessage: false }
);
}
if (userConfigPath) {
if (
path.join(path.dirname(userConfigPath), PATH_TO_DEPLOY_CONFIG) !==
deployConfigPath
) {
throw new UserError(
dedent`
Found both a user configuration file at "${path.relative(".", userConfigPath)}"
and a deploy configuration file at "${path.relative(".", deployConfigPath)}".
But these do not share the same base path so it is not clear which should be used.
`,
{ telemetryMessage: false }
);
}
}
return {
configPath: redirectedConfigPath,
deployConfigPath,
redirected: true,
};
}
export function isRedirectedConfig(
config: Pick<ComputedFields, "configPath" | "userConfigPath">
): boolean {
return (
config.configPath !== undefined &&
config.configPath !== config.userConfigPath
);
}
export function isRedirectedRawConfig(
rawConfig: RawConfig,
configPath: string | undefined,
userConfigPath: string | undefined
): rawConfig is RedirectedRawConfig {
return isRedirectedConfig({ configPath, userConfigPath });
}
+431
View File
@@ -0,0 +1,431 @@
import type {
ContainerEngine,
Environment,
RawEnvironment,
} from "./environment";
/**
* This is the static type definition for the configuration object.
*
* It reflects a normalized and validated version of the configuration that you can write in a Wrangler configuration file,
* and optionally augment with arguments passed directly to wrangler.
*
* For more information about the configuration object, see the
* documentation at https://developers.cloudflare.com/workers/cli-wrangler/configuration
*
* Notes:
*
* - Fields that are only specified in `ConfigFields` and not `Environment` can only appear
* in the top level config and should not appear in any environments.
* - Fields that are specified in `PagesConfigFields` are only relevant for Pages projects
* - All top level fields in config and environments are optional in the Wrangler configuration file.
*
* Legend for the annotations:
*
* - `@breaking`: the deprecation/optionality is a breaking change from Wrangler v1.
* - `@todo`: there's more work to be done (with details attached).
*/
export type Config = ComputedFields &
ConfigFields<DevConfig> &
PagesConfigFields &
Environment;
export type RawConfig = Partial<ConfigFields<RawDevConfig>> &
PagesConfigFields &
RawEnvironment &
EnvironmentMap & { $schema?: string };
export type RedirectedRawConfig = RawConfig & Partial<ComputedFields>;
export interface ComputedFields {
/** The path to the Wrangler configuration file (if any, and possibly redirected from the user Wrangler configuration) used to create this configuration. */
configPath: string | undefined;
/** The path to the user's Wrangler configuration file (if any), which may have been redirected to another file that used to create this configuration. */
userConfigPath: string | undefined;
/**
* The original top level name for the Worker in the raw configuration.
*
* When a raw configuration has been flattened to a single environment the worker name may have been replaced or transformed.
* It can be useful to know what the top-level name was before the flattening.
*/
topLevelName: string | undefined;
/** A list of environment names declared in the raw configuration. */
definedEnvironments: string[] | undefined;
/** The name of the environment being targeted. */
targetEnvironment: string | undefined;
}
export interface ConfigFields<Dev extends RawDevConfig> {
/**
* Whether Wrangler should send usage metrics to Cloudflare for this project.
*
* When defined this will override any user settings.
* Otherwise, Wrangler will use the user's preference.
*/
send_metrics: boolean | undefined;
/**
* Configuration for npm package dependency instrumentation.
*
* Controls whether Wrangler should collect and send npm package dependency
* metadata when deploying or uploading a Worker version.
*
* When `enabled` is set to `false`, Wrangler will not include
* `package_dependencies` in the upload payload. Defaults to enabled when
* not specified.
*
* Note: This is considered build metadata, so managed separately from the
* telemetry one and not disabled when
* `send_metrics`/`WRANGLER_SEND_METRICS` is set to `false`
*/
dependencies_instrumentation:
| {
/** Whether dependency instrumentation is enabled. Defaults to `true`. */
enabled: boolean;
}
| undefined;
/**
* Options to configure the development server that your worker will use.
*
* For reference, see https://developers.cloudflare.com/workers/wrangler/configuration/#local-development-settings
*/
dev: Dev;
/**
* The definition of a Worker Site, a feature that lets you upload
* static assets with your Worker.
*
* More details at https://developers.cloudflare.com/workers/platform/sites
*
* For reference, see https://developers.cloudflare.com/workers/wrangler/configuration/#workers-sites
*/
site:
| {
/**
* The directory containing your static assets.
*
* It must be a path relative to your Wrangler configuration file.
* Example: bucket = "./public"
*
* If there is a `site` field then it must contain this `bucket` field.
*/
bucket: string;
/**
* The location of your Worker script.
*
* @deprecated DO NOT use this (it's a holdover from Wrangler v1.x). Either use the top level `main` field, or pass the path to your entry file as a command line argument.
* @breaking
*/
"entry-point"?: string;
/**
* An exclusive list of .gitignore-style patterns that match file
* or directory names from your bucket location. Only matched
* items will be uploaded. Example: include = ["upload_dir"]
*
* @optional
* @default []
*/
include?: string[];
/**
* A list of .gitignore-style patterns that match files or
* directories in your bucket that should be excluded from
* uploads. Example: exclude = ["ignore_dir"]
*
* @optional
* @default []
*/
exclude?: string[];
}
| undefined;
/**
* A list of wasm modules that your worker should be bound to. This is
* the "legacy" way of binding to a wasm module. ES module workers should
* do proper module imports.
*/
wasm_modules:
| {
[key: string]: string;
}
| undefined;
/**
* A list of text files that your worker should be bound to. This is
* the "legacy" way of binding to a text file. ES module workers should
* do proper module imports.
*/
text_blobs:
| {
[key: string]: string;
}
| undefined;
/**
* A list of data files that your worker should be bound to. This is
* the "legacy" way of binding to a data file. ES module workers should
* do proper module imports.
*/
data_blobs:
| {
[key: string]: string;
}
| undefined;
/**
* A map of module aliases. Lets you swap out a module for any others.
* Corresponds with esbuild's `alias` config
*
* For reference, see https://developers.cloudflare.com/workers/wrangler/configuration/#module-aliasing
*/
alias: { [key: string]: string } | undefined;
/**
* By default, the Wrangler configuration file is the source of truth for your environment configuration, like a terraform file.
*
* If you change your vars in the dashboard, wrangler *will* override/delete them on its next deploy.
*
* If you want to keep your dashboard vars when wrangler deploys, set this field to true.
*
* @default false
* @nonInheritable
*/
keep_vars?: boolean;
}
// Pages-specific configuration fields
interface PagesConfigFields {
/**
* The directory of static assets to serve.
*
* The presence of this field in a Wrangler configuration file indicates a Pages project,
* and will prompt the handling of the configuration file according to the
* Pages-specific validation rules.
*/
pages_build_output_dir?: string;
}
export interface DevConfig {
/**
* IP address for the local dev server to listen on,
*
* @default localhost
*/
ip: string;
/**
* Port for the local dev server to listen on
*
* @default 8787
*/
port: number | undefined;
/**
* Port for the local dev server's inspector to listen on
*
* @default 9229
*/
inspector_port: number | undefined;
/**
* IP address for the local dev server's inspector to listen on
*
* @default 127.0.0.1
*/
inspector_ip: string | undefined;
/**
* Protocol that local wrangler dev server listens to requests on.
*
* @default http
*/
local_protocol: "http" | "https";
/**
* Protocol that wrangler dev forwards requests on
*
* Setting this to `http` is not currently implemented for remote mode.
* See https://github.com/cloudflare/workers-sdk/issues/583
*
* @default https
*/
upstream_protocol: "https" | "http";
/**
* Host to forward requests to, defaults to the host of the first route of project
*/
host: string | undefined;
/**
* When developing, whether to build and connect to containers. This requires a Docker daemon to be running.
* Defaults to `true`.
*
* @default true
*/
enable_containers: boolean;
/**
* Either the Docker unix socket i.e. `unix:///var/run/docker.sock` or a full configuration.
* Note that windows is only supported via WSL at the moment
*/
container_engine: ContainerEngine | undefined;
/**
* Re-generate your worker types when your Wrangler configuration file changes.
*
* @default false
*/
generate_types: boolean;
}
export type RawDevConfig = Partial<DevConfig>;
interface EnvironmentMap {
/**
* The `env` section defines overrides for the configuration for different environments.
*
* All environment fields can be specified at the top level of the config indicating the default environment settings.
*
* - Some fields are inherited and overridable in each environment.
* - But some are not inherited and must be explicitly specified in every environment, if they are specified at the top level.
*
* For more information, see the documentation at https://developers.cloudflare.com/workers/cli-wrangler/configuration#environments
*
* @default {}
*/
env?: {
[envName: string]: RawEnvironment;
};
}
export const defaultWranglerConfig: Config = {
/* COMPUTED_FIELDS */
configPath: undefined,
userConfigPath: undefined,
topLevelName: undefined,
definedEnvironments: undefined,
targetEnvironment: undefined,
/*====================================================*/
/* Fields supported by both Workers & Pages */
/*====================================================*/
/* TOP-LEVEL ONLY FIELDS */
pages_build_output_dir: undefined,
send_metrics: undefined,
dependencies_instrumentation: undefined,
dev: {
ip: process.platform === "win32" ? "127.0.0.1" : "localhost",
port: undefined, // the default of 8787 is set at runtime
inspector_port: undefined, // the default of 9229 is set at runtime
inspector_ip: undefined, // the default of 127.0.0.1 is set at runtime
local_protocol: "http",
upstream_protocol: "http",
host: undefined,
// Note this one is also workers only
enable_containers: true,
container_engine: undefined,
generate_types: false,
},
/** INHERITABLE ENVIRONMENT FIELDS **/
name: undefined,
compatibility_date: undefined,
compatibility_flags: [],
limits: undefined,
placement: undefined,
/** NON-INHERITABLE ENVIRONMENT FIELDS **/
vars: {},
durable_objects: { bindings: [] },
kv_namespaces: [],
queues: {
producers: [],
consumers: [], // WORKERS SUPPORT ONLY!!
},
r2_buckets: [],
d1_databases: [],
vectorize: [],
ai_search_namespaces: [],
ai_search: [],
websearch: undefined,
agent_memory: [],
hyperdrive: [],
workflows: [],
secrets_store_secrets: [],
artifacts: [],
services: [],
analytics_engine_datasets: [],
ai: undefined,
images: undefined,
stream: undefined,
media: undefined,
version_metadata: undefined,
unsafe_hello_world: [],
flagship: [],
ratelimits: [],
worker_loaders: [],
/*====================================================*/
/* Fields supported by Workers only */
/*====================================================*/
/* TOP-LEVEL ONLY FIELDS */
site: undefined,
wasm_modules: undefined,
text_blobs: undefined,
data_blobs: undefined,
keep_vars: undefined,
alias: undefined,
/** INHERITABLE ENVIRONMENT FIELDS **/
account_id: undefined,
main: undefined,
find_additional_modules: undefined,
preserve_file_names: undefined,
base_dir: undefined,
workers_dev: undefined,
preview_urls: undefined,
route: undefined,
routes: undefined,
tsconfig: undefined,
jsx_factory: "React.createElement",
jsx_fragment: "React.Fragment",
migrations: [],
exports: {},
triggers: {
crons: undefined,
},
rules: [],
build: { command: undefined, watch_dir: "./src", cwd: undefined },
no_bundle: undefined,
minify: undefined,
keep_names: undefined,
dispatch_namespaces: [],
first_party_worker: undefined,
logfwdr: { bindings: [] },
logpush: undefined,
upload_source_maps: undefined,
assets: undefined,
observability: { enabled: true },
cache: undefined,
/** The default here is undefined so that we can delegate to the CLOUDFLARE_COMPLIANCE_REGION environment variable. */
compliance_region: undefined,
python_modules: { exclude: ["**/*.pyc"] },
previews: undefined,
/** NON-INHERITABLE ENVIRONMENT FIELDS **/
define: {},
cloudchamber: {},
containers: undefined,
send_email: [],
browser: undefined,
unsafe: {},
mtls_certificates: [],
tail_consumers: undefined,
streaming_tail_consumers: undefined,
pipelines: [],
vpc_services: [],
vpc_networks: [],
};
@@ -0,0 +1,91 @@
/**
* Diagnostic errors and warnings.
*
* The structure is a tree, where each node can contain zero or more errors, warnings and child diagnostics objects.
* You can check whether the overall tree has errors or warnings, and you can render a string representation of the errors or warnings.
*/
export class Diagnostics {
errors: string[] = [];
warnings: string[] = [];
children: Diagnostics[] = [];
/** Set to true when an unexpected/unknown field is encountered during validation. */
hasUnexpectedFields: boolean = false;
/**
* Create a new Diagnostics object.
* @param description A general description of this collection of messages.
*/
constructor(public description: string) {}
/**
* Merge the given `diagnostics` into this as a child.
*/
addChild(diagnostics: Diagnostics): void {
if (diagnostics.hasErrors() || diagnostics.hasWarnings()) {
this.children.push(diagnostics);
}
}
/** Does this or any of its children have errors. */
hasErrors(): boolean {
if (this.errors.length > 0) {
return true;
} else {
return this.children.some((child) => child.hasErrors());
}
}
/** Does this or any of its children have unexpected fields. */
hasUnexpectedFieldsInTree(): boolean {
if (this.hasUnexpectedFields) {
return true;
} else {
return this.children.some((child) => child.hasUnexpectedFieldsInTree());
}
}
/** Render the errors of this and all its children. */
renderErrors(): string {
return this.render("errors");
}
/** Does this or any of its children have warnings. */
hasWarnings(): boolean {
if (this.warnings.length > 0) {
return true;
} else {
return this.children.some((child) => child.hasWarnings());
}
}
/** Render the warnings of this and all its children. */
renderWarnings(): string {
return this.render("warnings");
}
private render(field: "errors" | "warnings"): string {
const hasMethod = field === "errors" ? "hasErrors" : "hasWarnings";
return indentText(
`${this.description}\n` +
// Output all the fields (errors or warnings) at this level
this[field].map((message) => `- ${indentText(message)}`).join("\n") +
// Output all the child diagnostics at the next level
this.children
.map((child) =>
child[hasMethod]() ? "\n- " + child.render(field) : ""
)
.filter((output) => output !== "")
.join("\n")
);
}
}
/** Indent all the but the first line by two spaces. */
function indentText(str: string): string {
return str
.split("\n")
.map((line, index) =>
(index === 0 ? line : ` ${line}`).replace(/^\s*$/, "")
)
.join("\n");
}
@@ -0,0 +1,18 @@
import { partitionExports } from "./exports";
import type { Config } from "./config";
import type { DurableObjectExport } from "./environment";
/**
* Returns a map of exports that are only of type "durable-object".
*/
export function getDurableObjectExports(
exports: Config["exports"] | undefined
): Record<string, DurableObjectExport> {
return partitionExports(exports)["durable-object"];
}
export function hasDurableObjectExports(
exports: Config["exports"] | undefined
): boolean {
return Object.keys(getDurableObjectExports(exports)).length > 0;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,31 @@
import type {
DurableObjectExport,
Exports,
WorkerEntrypointExport,
} from "./environment";
export type ExportType = Exports[string]["type"];
export interface PartitionedExports {
"durable-object": Record<string, DurableObjectExport>;
worker: Record<string, WorkerEntrypointExport>;
}
export function partitionExports(
exports: Exports | undefined
): PartitionedExports {
const partitioned: PartitionedExports = {
"durable-object": {},
worker: {},
};
if (exports === undefined) {
return partitioned;
}
for (const [name, entry] of Object.entries(exports)) {
partitioned[entry.type][name] = entry;
}
return partitioned;
}
+152
View File
@@ -0,0 +1,152 @@
import TOML from "smol-toml";
import { parseJSONC, parseTOML, readFileSync } from "../parse";
import { resolveWranglerConfigPath } from "./config-helpers";
import type { Config, RawConfig } from "./config";
import type { ResolveConfigPathOptions } from "./config-helpers";
import type { NormalizeAndValidateConfigArgs } from "./validation";
export type {
Config,
ConfigFields,
DevConfig,
RawConfig,
RawDevConfig,
} from "./config";
export type ConfigBindingOptions = Pick<
Config,
| "ai"
| "browser"
| "d1_databases"
| "dispatch_namespaces"
| "durable_objects"
| "queues"
| "r2_buckets"
| "services"
| "kv_namespaces"
| "mtls_certificates"
| "vectorize"
| "workflows"
| "vpc_services"
>;
export type {
CacheOptions,
ConfiguredExport,
ConfigModuleRuleType,
Environment,
PreviewsConfig,
RawEnvironment,
WorkerEntrypointExport,
} from "./environment";
export { partitionExports } from "./exports";
export type { ExportType, PartitionedExports } from "./exports";
export function configFormat(
configPath: string | undefined
): "json" | "jsonc" | "toml" | "none" {
if (configPath?.endsWith("toml")) {
return "toml";
}
if (configPath?.endsWith("jsonc")) {
return "jsonc";
}
if (configPath?.endsWith("json")) {
return "json";
}
return "none";
}
export function configFileName(configPath: string | undefined) {
const format = configFormat(configPath);
switch (format) {
case "toml":
return "wrangler.toml";
case "json":
return "wrangler.json";
case "jsonc":
return "wrangler.jsonc";
default:
return "Wrangler configuration";
}
}
export function formatConfigSnippet(
snippet: RawConfig,
configPath: Config["configPath"],
formatted = true
) {
const format = configFormat(configPath);
if (format === "toml") {
return TOML.stringify(snippet);
} else {
return formatted
? JSON.stringify(snippet, null, 2)
: JSON.stringify(snippet);
}
}
export const sharedResourceCreationArgs = {
"use-remote": {
type: "boolean",
description:
"Use a remote binding when adding the newly created resource to your config",
},
"update-config": {
type: "boolean",
description:
"Automatically update your config file with the newly added resource",
},
binding: {
type: "string",
description: "The binding name of this resource in your Worker",
},
} as const;
export type ReadConfigCommandArgs = NormalizeAndValidateConfigArgs & {
config?: string;
script?: string;
};
export type ReadConfigOptions = ResolveConfigPathOptions & {
hideWarnings?: boolean;
// Used by the Vite plugin
// If set to `true`, the `main` field is not converted to an absolute path
preserveOriginalMain?: boolean;
};
const parseRawConfigFile = (configPath: string): RawConfig => {
if (configPath.endsWith(".toml")) {
return parseTOML(readFileSync(configPath), configPath) as RawConfig;
}
if (configPath.endsWith(".json") || configPath.endsWith(".jsonc")) {
return parseJSONC(readFileSync(configPath), configPath) as RawConfig;
}
return {};
};
export const experimental_readRawConfig = (
args: ReadConfigCommandArgs,
options: ReadConfigOptions = {}
): {
rawConfig: RawConfig;
configPath: string | undefined;
userConfigPath: string | undefined;
deployConfigPath: string | undefined;
redirected: boolean;
} => {
// Load the configuration from disk if available
const { configPath, userConfigPath, deployConfigPath, redirected } =
resolveWranglerConfigPath(args, options);
const rawConfig = parseRawConfigFile(configPath ?? "");
return {
rawConfig,
configPath,
userConfigPath,
deployConfigPath,
redirected,
};
};
@@ -0,0 +1,97 @@
import { writeFileSync } from "node:fs";
import { applyEdits, format, modify } from "jsonc-parser";
import TOML from "smol-toml";
import { parseJSONC, parseTOML, readFileSync } from "../parse";
import type { RawConfig } from "./config";
import type { JSONPath } from "jsonc-parser";
export const experimental_patchConfig = (
configPath: string,
/**
* if you want to add something new, e.g. a binding, you can just provide that {kv_namespace:[{binding:"KV"}]}
* and set isArrayInsertion = true
*
* if you want to edit or delete existing array elements, you have to provide the whole array
* e.g. {kv_namespace:[{binding:"KV", id:"new-id"}, {binding:"KV2", id:"untouched"}]}
* and set isArrayInsertion = false
*/
patch: RawConfig,
isArrayInsertion: boolean = true
) => {
let configString = readFileSync(configPath);
if (configPath.endsWith("toml")) {
// the TOML parser we use does not preserve comments
if (configString.includes("#")) {
throw new PatchConfigError(
"cannot patch .toml config if comments are present"
);
} else {
// for simplicity, use the JSONC editor to make all edits
// toml -> js object -> json string -> edits -> js object -> toml
configString = JSON.stringify(parseTOML(configString));
}
}
const patchPaths: JSONPath[] = [];
getJSONPath(patch, patchPaths, isArrayInsertion);
for (const patchPath of patchPaths) {
const value = patchPath.pop();
const edit = modify(configString, patchPath, value, {
isArrayInsertion,
});
configString = applyEdits(configString, edit);
}
const formatEdit = format(configString, undefined, {});
configString = applyEdits(configString, formatEdit);
if (configPath.endsWith(".toml")) {
configString = TOML.stringify(parseJSONC(configString));
}
writeFileSync(configPath, configString);
return configString;
};
/**
*
* Gets all the JSON paths for a given object by recursing through the object, recording the properties encountered.
* e.g. {a : { b: "c", d: ["e", "f"]}} -> [["a", "b", "c"], ["a", "d", 0], ["a", "d", 1]]
* The jsonc-parser library requires JSON paths for each edit.
* Note the final 'path' segment is the value we want to insert,
* so in the above example,["a", "b"] would be the path and we would insert "c"
*
* If isArrayInsertion = false, when we encounter an array, we use the item index as part of the path and continue
* If isArrayInsertion = false, we stop recursing down and treat the whole array item as the final path segment/value.
*
*/
const getJSONPath = (
obj: RawConfig,
allPaths: JSONPath[],
isArrayInsertion: boolean,
prevPath: JSONPath = []
) => {
for (const [k, v] of Object.entries(obj)) {
const currentPath = [...prevPath, k];
if (Array.isArray(v)) {
v.forEach((x, i) => {
if (isArrayInsertion) {
// makes sure we insert new array items at the end
allPaths.push([...currentPath, -1, x]);
} else if (typeof x === "object" && x !== null) {
getJSONPath(x, allPaths, isArrayInsertion, [...currentPath, i]);
} else {
allPaths.push([...currentPath, i, x]);
}
});
} else if (typeof v === "object" && v !== null) {
getJSONPath(v, allPaths, isArrayInsertion, currentPath);
} else {
allPaths.push([...currentPath, v]);
}
}
};
/**
* Custom error class for config patching errors
*/
export class PatchConfigError extends Error {}
@@ -0,0 +1,685 @@
import type { RawConfig } from "./config";
import type { Diagnostics } from "./diagnostics";
import type { Environment, RawEnvironment } from "./environment";
/**
* Mark a field as deprecated.
*
* This function will add a diagnostics warning if the deprecated field is found in the `rawEnv` (or an error if it's also a breaking deprecation)
* The `fieldPath` is a dot separated property path, e.g. `"build.upload.format"`.
*/
export function deprecated<T extends object>(
diagnostics: Diagnostics,
config: T,
fieldPath: DeepKeyOf<T>,
message: string,
remove: boolean,
title = "Deprecation",
type: "warning" | "error" = "warning"
): void {
const BOLD = "\x1b[1m";
const NORMAL = "\x1b[0m";
const diagnosticMessage = `${BOLD}${title}${NORMAL}: "${fieldPath}":\n${message}`;
const result = unwindPropertyPath(config, fieldPath);
if (result !== undefined && result.field in result.container) {
diagnostics[`${type}s`].push(diagnosticMessage);
if (remove) {
delete (result.container as Record<string, unknown>)[result.field];
}
}
}
/**
* Mark a field as experimental.
*
* This function will add a diagnostics warning if the experimental field is found in the `rawEnv`.
* The `fieldPath` is a dot separated property path, e.g. `"build.upload.format"`.
*/
export function experimental<T extends object>(
diagnostics: Diagnostics,
config: T,
fieldPath: DeepKeyOf<T>
): void {
const result = unwindPropertyPath(config, fieldPath);
if (
result !== undefined &&
result.field in result.container &&
!("WRANGLER_DISABLE_EXPERIMENTAL_WARNING" in process.env)
) {
diagnostics.warnings.push(
`"${fieldPath}" fields are experimental and may change or break at any time.`
);
}
}
/**
* Get an inheritable environment field, after computing and validating its value.
*
* If the field is not defined in the given environment, then fallback to the value from the top-level config,
* and then the `defaultValue`.
*/
export function inheritable<K extends keyof Environment>(
diagnostics: Diagnostics,
topLevelEnv: Environment | undefined,
rawEnv: RawEnvironment,
field: K,
validate: ValidatorFn,
defaultValue: Environment[K],
transformFn: TransformFn<Environment[K]> = (v) => v
): Environment[K] {
validate(diagnostics, field, rawEnv[field], topLevelEnv);
return (
// `rawEnv === topLevelEnv` is a special case where the user has provided an environment name
// but that named environment is not actually defined in the configuration.
// In that case we have reused the topLevelEnv as the rawEnv,
// and so we need to process the `transformFn()` anyway rather than just using the field in the `rawEnv`.
(rawEnv !== topLevelEnv ? (rawEnv[field] as Environment[K]) : undefined) ??
transformFn(topLevelEnv?.[field]) ??
defaultValue
);
}
/**
* Type of function that is used to transform an inheritable environment field.
*/
type TransformFn<T> = (fieldValue: T | undefined) => T | undefined;
/**
* Transform an environment field by appending current environment name to it.
*/
export const appendEnvName =
(envName: string): TransformFn<string | undefined> =>
(fieldValue) =>
fieldValue ? `${fieldValue}-${envName}` : undefined;
/**
* Get a not inheritable environment field, after computing and validating its value.
*
* If the field is not defined in the given environment but it is defined in the top-level config,
* then log a warning and return the `defaultValue`.
*/
export function notInheritable<K extends keyof Environment>(
diagnostics: Diagnostics,
topLevelEnv: Environment | undefined,
rawConfig: RawConfig | undefined,
rawEnv: RawEnvironment,
envName: string,
field: K,
validate: ValidatorFn,
defaultValue: Environment[K]
): Environment[K] {
if (rawEnv[field] !== undefined) {
validate(diagnostics, field, rawEnv[field], topLevelEnv);
} else {
if (rawConfig?.[field] !== undefined) {
diagnostics.warnings.push(
`"${field}" exists at the top level, but not on "env.${envName}".\n` +
`This is not what you probably want, since "${field}" is not inherited by environments.\n` +
`Please add "${field}" to "env.${envName}".`
);
}
}
return (rawEnv[field] as Environment[K]) ?? defaultValue;
}
// Idea taken from https://stackoverflow.com/a/66661477
type DeepKeyOf<T> = (
T extends object
? {
[K in Exclude<keyof T, symbol>]: `${K}${DotPrefix<DeepKeyOf<T[K]>>}`;
}[Exclude<keyof T, symbol>]
: ""
) extends infer D
? Extract<D, string>
: never;
type DotPrefix<T extends string> = T extends "" ? "" : `.${T}`;
/**
* Return a container object and field name for the last property in a given property path.
*
* For example, given a path of `"build.upload.format"`) and a starting `root` object
* this will return:
*
* ```
* { container: root.build.upload, field: "format" }
* ```
*/
function unwindPropertyPath<T extends object>(
root: T,
path: DeepKeyOf<T>
): { container: object; field: string } | undefined {
let container: object = root;
const parts = (path as string).split(".");
for (let i = 0; i < parts.length - 1; i++) {
if (!hasProperty(container, parts[i])) {
return;
}
container = container[parts[i]];
}
return { container, field: parts[parts.length - 1] };
}
/**
* The type of a function that can be used to validate a configuration field.
*/
export type ValidatorFn = (
diagnostics: Diagnostics,
field: string,
value: unknown,
topLevelEnv: Environment | undefined
) => boolean;
/**
* Validate that the field is a string.
*/
export const isString: ValidatorFn = (diagnostics, field, value) => {
if (value !== undefined && typeof value !== "string") {
diagnostics.errors.push(
`Expected "${field}" to be of type string but got ${JSON.stringify(
value
)}.`
);
return false;
}
return true;
};
/**
* Validate that the `name` field is compliant with EWC constraints.
*/
export const isValidName: ValidatorFn = (diagnostics, field, value) => {
if (
(typeof value === "string" && /^$|^[a-z0-9_][a-z0-9-_]*$/.test(value)) ||
value === undefined
) {
return true;
} else {
diagnostics.errors.push(
`Expected "${field}" to be of type string, alphanumeric and lowercase with dashes only but got ${JSON.stringify(
value
)}.`
);
return false;
}
};
/**
* Validate that the field is a valid ISO-8601 date time string
* see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format
* or https://tc39.es/ecma262/multipage/numbers-and-dates.html#sec-date-time-string-format
*/
export const isValidDateTimeStringFormat = (
diagnostics: Diagnostics,
field: string,
value: string
): boolean => {
let isValid = true;
// en/em dashes are not valid characters in the JS date time string format.
// While they would be caught by the `isNaN(data.getTime())` check below,
// we want to single these use cases out, and throw a more specific error
if (
value.includes("") || // en-dash
value.includes("—") // em-dash
) {
diagnostics.errors.push(
`"${field}" field should use ISO-8601 accepted hyphens (-) rather than en-dashes () or em-dashes (—).`
);
isValid = false;
}
// en/em dashes were already handled above. Let's replace them with hyphens,
// which is a valid date time string format character, and evaluate the
// resulting date time string further. This ensures we validate for hyphens
// only once!
const data = new Date(value.replaceAll(/|—/g, "-"));
if (isNaN(data.getTime())) {
diagnostics.errors.push(
`"${field}" field should be a valid ISO-8601 date (YYYY-MM-DD), but got ${JSON.stringify(value)}.`
);
isValid = false;
}
return isValid;
};
/**
* Validate that the field is an array of strings.
*/
export const isStringArray: ValidatorFn = (diagnostics, field, value) => {
if (
value !== undefined &&
(!Array.isArray(value) || value.some((item) => typeof item !== "string"))
) {
diagnostics.errors.push(
`Expected "${field}" to be of type string array but got ${JSON.stringify(
value
)}.`
);
return false;
}
return true;
};
/**
* Validate that the field is an object containing the given properties.
*/
export const isObjectWith =
(...properties: string[]): ValidatorFn =>
(diagnostics, field, value) => {
if (
value !== undefined &&
(typeof value !== "object" ||
value === null ||
!properties.every((prop) => prop in value))
) {
diagnostics.errors.push(
`Expected "${field}" to be of type object, containing only properties ${properties}, but got ${JSON.stringify(
value
)}.`
);
return false;
}
// it's an object with the field as desired,
// but let's also check for unexpected fields
if (value !== undefined) {
const restFields = Object.keys(value).filter(
(key) => !properties.includes(key)
);
validateAdditionalProperties(diagnostics, field, restFields, []);
}
return true;
};
/**
* Validate that the field value is one of the given choices.
*/
export const isOneOf =
(...choices: unknown[]): ValidatorFn =>
(diagnostics, field, value) => {
if (value !== undefined && !choices.some((choice) => value === choice)) {
diagnostics.errors.push(
`Expected "${field}" field to be one of ${JSON.stringify(
choices
)} but got ${JSON.stringify(value)}.`
);
return false;
}
return true;
};
/**
* Aggregate multiple validator functions
*/
export const all = (...validations: ValidatorFn[]): ValidatorFn => {
return (diagnostics, field, value, config) => {
let passedValidations = true;
for (const validate of validations) {
if (!validate(diagnostics, field, value, config)) {
passedValidations = false;
}
}
return passedValidations;
};
};
/**
* Check that the field is mutually exclusive with a list of other fields.
*
* @param container the container of the fields to check against.
* @param fields the names of the fields to check against.
*/
export const isMutuallyExclusiveWith = <T extends RawEnvironment | RawConfig>(
container: T,
...fields: (keyof T)[]
): ValidatorFn => {
return (diagnostics, field, value) => {
if (value === undefined) {
return true;
}
for (const exclusiveWith of fields) {
if (container[exclusiveWith] !== undefined) {
diagnostics.errors.push(
`Expected exactly one of the following fields ${JSON.stringify([
field,
...fields,
])}.`
);
return false;
}
}
return true;
};
};
/**
* Validate that the field is a boolean.
*/
export const isBoolean: ValidatorFn = (diagnostics, field, value) => {
if (value !== undefined && typeof value !== "boolean") {
diagnostics.errors.push(
`Expected "${field}" to be of type boolean but got ${JSON.stringify(
value
)}.`
);
return false;
}
return true;
};
/**
* Validate that the required field exists and has the expected type.
*/
export const validateRequiredProperty = (
diagnostics: Diagnostics,
container: string,
key: string,
value: unknown,
type: TypeofType,
choices?: unknown[]
): boolean => {
if (container) {
container += ".";
}
if (value === undefined) {
diagnostics.errors.push(`"${container}${key}" is a required field.`);
return false;
} else if (typeof value !== type) {
diagnostics.errors.push(
`Expected "${container}${key}" to be of type ${type} but got ${JSON.stringify(
value
)}.`
);
return false;
} else if (choices) {
if (
!isOneOf(...choices)(diagnostics, `${container}${key}`, value, undefined)
) {
return false;
}
}
return true;
};
/**
* Validate that at least one of the properties in the list is required.
*/
export const validateAtLeastOnePropertyRequired = (
diagnostics: Diagnostics,
container: string,
properties: {
key: string;
value: unknown;
type: TypeofType;
}[]
): boolean => {
const containerPath = container ? `${container}.` : "";
if (properties.every((property) => property.value === undefined)) {
diagnostics.errors.push(
`${properties.map(({ key }) => `"${containerPath}${key}"`).join(" or ")} is required.`
);
return false;
}
const errors = [];
for (const prop of properties) {
if (typeof prop.value === prop.type) {
return true;
}
errors.push(
`Expected "${containerPath}${prop.key}" to be of type ${prop.type} but got ${JSON.stringify(
prop.value
)}.`
);
}
diagnostics.errors.push(...errors);
return false;
};
/**
* Validate that, if the optional field exists, then it has the expected type.
*/
export const validateOptionalProperty = (
diagnostics: Diagnostics,
container: string,
key: string,
value: unknown,
type: TypeofType,
choices?: unknown[]
): boolean => {
if (value !== undefined) {
return validateRequiredProperty(
diagnostics,
container,
key,
value,
type,
choices
);
}
return true;
};
/**
* Validate that the field is an array of elements of the given type.
*/
export const validateTypedArray = (
diagnostics: Diagnostics,
container: string,
value: unknown,
type: TypeofType
): boolean => {
let isValid = true;
if (!Array.isArray(value)) {
diagnostics.errors.push(
`Expected "${container}" to be an array of ${type}s but got ${JSON.stringify(
value
)}`
);
isValid = false;
} else {
for (let i = 0; i < value.length; i++) {
isValid =
validateRequiredProperty(
diagnostics,
container,
`[${i}]`,
value[i],
type
) && isValid;
}
}
return isValid;
};
/**
* Validate that, if the optional field exists, it is an array of elements of the given type.
*/
export const validateOptionalTypedArray = (
diagnostics: Diagnostics,
container: string,
value: unknown,
type: TypeofType
) => {
if (value !== undefined) {
return validateTypedArray(diagnostics, container, value, type);
}
return true;
};
/**
* Returns whether `target` has the required property `property` of type `type`.
*
* @param target the object to test.
* @param property the property name to test.
* @param type the expected type of the property.
* @param choices optional list of allowed values for the property.
* @returns whether `target` has the required property `property` of type `type`, and optionally one of `choices`.
*/
export const isRequiredProperty = <T extends object>(
target: object,
property: keyof T,
type: TypeofType,
choices?: unknown[]
): target is T =>
hasProperty<T>(target, property) &&
typeof target[property] === type &&
(choices === undefined || choices.includes(target[property]));
/**
* Returns whether `target` has the optional property `property` of type `type`.
*
* @param target the object to test.
* @param property the property name to test.
* @param type the expected type of the property.
* @returns whether `target` has the optional property `property` of type `type`.
*/
export const isOptionalProperty = <T extends object>(
target: object,
property: keyof T,
type: TypeofType
): target is T =>
!hasProperty<T>(target, property) || typeof target[property] === type;
/**
* Returns whether `target` has the property `property`.
*
* @param target the object to test.
* @param property the property name to test.
* @returns whether `target` has the property `property`.
*/
export const hasProperty = <T extends object>(
target: object,
property: keyof T
): target is T => property in target;
/**
* Add warning messages about any properties in the given field that are not expected to be there.
*/
export const validateAdditionalProperties = (
diagnostics: Diagnostics,
fieldPath: string,
restProps: Iterable<string>,
knownProps: Iterable<string>
): boolean => {
const restPropSet = new Set(restProps);
for (const knownProp of knownProps) {
restPropSet.delete(knownProp);
}
if (restPropSet.size > 0) {
const fields = Array.from(restPropSet.keys()).map((field) => `"${field}"`);
diagnostics.warnings.push(
`Unexpected fields found in ${fieldPath} field: ${fields}`
);
diagnostics.hasUnexpectedFields = true;
return false;
}
return true;
};
/**
* Get the names of the bindings collection in `value`.
*
* Will return an empty array if it doesn't understand the value
* passed in, so another form of validation should be
* performed externally.
*/
export const getBindingNames = (value: unknown): string[] => {
if (typeof value !== "object" || value === null) {
return [];
}
if (isBindingList(value)) {
return value.bindings.map(({ name }) => name);
} else if (isNamespaceList(value)) {
return value.map(({ binding }) => binding);
} else if (isRecord(value)) {
// browser and AI bindings are single values with a similar shape
// { binding = "name" }
if (value["binding"] !== undefined) {
return [value["binding"] as string];
}
return Object.keys(value).filter((k) => value[k] !== undefined);
} else {
return [];
}
};
const isBindingList = (
value: unknown
): value is {
bindings: {
name: string;
}[];
} =>
isRecord(value) &&
"bindings" in value &&
Array.isArray(value.bindings) &&
value.bindings.every(
(binding) =>
isRecord(binding) && "name" in binding && typeof binding.name === "string"
);
const isNamespaceList = (value: unknown): value is { binding: string }[] =>
Array.isArray(value) &&
value.every(
(entry) =>
isRecord(entry) && "binding" in entry && typeof entry.binding === "string"
);
const isRecord = (
value: unknown
): value is Record<string | number | symbol, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
/**
* Ensure that all bindings in an array have unique `name` properties.
*/
export const validateUniqueNameProperty: ValidatorFn = (
diagnostics,
field,
value
) => {
if (Array.isArray(value)) {
const nameCount = new Map<string, number>();
Object.entries(value).forEach(([_, entry]) => {
nameCount.set(entry.name, (nameCount.get(entry.name) ?? 0) + 1);
});
const duplicates = Array.from(nameCount.entries())
.filter(([_, count]) => count > 1)
.map(([name]) => name);
if (duplicates.length > 0) {
const list = duplicates.join('", "');
diagnostics.errors.push(
`"${field}" bindings must have unique "name" values; duplicate(s) found: "${list}"`
);
return false;
}
}
return true;
};
/**
* JavaScript `typeof` operator return values.
*/
export type TypeofType =
| "string"
| "number"
| "bigint"
| "boolean"
| "symbol"
| "undefined"
| "object"
| "function";
@@ -0,0 +1,199 @@
/**
* Pages now supports configuration via a Wrangler configuration file. As opposed to
* Workers however, Pages only supports a limited subset of all available
* configuration keys.
*
* This file contains all Wrangler configuration file validation things, specific to
* Pages.
*/
import { FatalError } from "../errors";
import { defaultWranglerConfig } from "./config";
import { Diagnostics } from "./diagnostics";
import { isRequiredProperty } from "./validation-helpers";
import type { Config } from "./config";
const supportedPagesConfigFields = [
"pages_build_output_dir",
"name",
"compatibility_date",
"compatibility_flags",
"send_metrics",
"no_bundle",
"limits",
"placement",
"vars",
"durable_objects",
"kv_namespaces",
"queues", // `producers` ONLY
"r2_buckets",
"d1_databases",
"vectorize",
"hyperdrive",
"services",
"analytics_engine_datasets",
"ai",
"version_metadata",
"dev",
"mtls_certificates",
"browser",
"upload_source_maps",
// normalizeAndValidateConfig() sets these values
"configPath",
"userConfigPath",
"topLevelName",
"definedEnvironments",
"targetEnvironment",
] as const;
export function validatePagesConfig(
config: Config,
envNames: string[],
projectName?: string
): Diagnostics {
// exhaustive check
if (!config.pages_build_output_dir) {
throw new FatalError(
`Attempting to validate Pages configuration file, but "pages_build_output_dir" field was not found.
"pages_build_output_dir" is required for Pages projects.`,
{ telemetryMessage: false }
);
}
const diagnostics = new Diagnostics(
`Running configuration file validation for Pages:`
);
validateMainField(config, diagnostics);
validateProjectName(projectName, diagnostics);
validatePagesEnvironmentNames(envNames, diagnostics);
validateUnsupportedFields(config, diagnostics);
validateDurableObjectBinding(config, diagnostics);
return diagnostics;
}
/**
* Validate that configuration file doesn't specify "main", if
* "pages_build_output_dir" is present
*/
function validateMainField(config: Config, diagnostics: Diagnostics) {
if (config.main !== undefined) {
diagnostics.errors.push(
`Configuration file cannot contain both both "main" and "pages_build_output_dir" configuration keys.\n` +
`Please use "main" if you are deploying a Worker, or "pages_build_output_dir" if you are deploying a Pages project.`
);
}
}
/**
* Validate that "name" field is specified at the top-level
*/
function validateProjectName(
name: string | undefined,
diagnostics: Diagnostics
) {
if (name === undefined || name.trim() === "") {
diagnostics.errors.push(
`Missing top-level field "name" in configuration file.\n` +
`Pages requires the name of your project to be configured at the top-level of your Wrangler configuration file. This is because, in Pages, environments target the same project.`
);
}
}
/**
* Validate that no named-environments other than "preview" and "production"
* were specified in the configuration file for Pages
*/
function validatePagesEnvironmentNames(
envNames: string[],
diagnostics: Diagnostics
) {
if (!envNames?.length) {
return;
}
const unsupportedPagesEnvNames = envNames.filter(
(name) => name !== "preview" && name !== "production"
);
if (unsupportedPagesEnvNames.length > 0) {
diagnostics.errors.push(
`Configuration file contains the following environment names that are not supported by Pages projects:\n` +
`${unsupportedPagesEnvNames.map((name) => `"${name}"`).join()}.\n` +
`The supported named-environments for Pages are "preview" and "production".`
);
}
}
/**
* Check for configuration fields that are not supported by Pages via the
* configuration file
*/
function validateUnsupportedFields(config: Config, diagnostics: Diagnostics) {
const unsupportedFields = new Set(Object.keys(config) as Array<keyof Config>);
for (const field of supportedPagesConfigFields) {
// Pages config supports `queues.producers` only. However, let's skip
// that validation here and keep all diagnostics handling in one place.
// This way we'll avoid breaking the config key logical grouping in
// `supportedPagesConfigFields`, when writing the errors to stdout.
if (field === "queues" && config.queues?.consumers?.length) {
continue;
}
unsupportedFields.delete(field);
}
for (const field of unsupportedFields) {
// check for unsupported fields with default values and exclude if found.
// These were most likely set as part of `normalizeAndValidateConfig()`
// processing, and not via the config file.
if (
config[field] === undefined ||
JSON.stringify(config[field]) ===
JSON.stringify(defaultWranglerConfig[field])
) {
unsupportedFields.delete(field);
}
}
if (unsupportedFields.size > 0) {
const fields = Array.from(unsupportedFields.keys());
fields.forEach((field) => {
if (field === "queues" && config.queues?.consumers?.length) {
diagnostics.errors.push(
`Configuration file for Pages projects does not support "queues.consumers"`
);
} else {
diagnostics.errors.push(
`Configuration file for Pages projects does not support "${field}"`
);
}
});
}
}
/**
* Validate the "script_name" field is specified for [[durable_objects.bindings]]
*
* This is necessary because Pages cannot define/deploy a DO itself today,
* and so this needs to be done with a Worker.
*/
function validateDurableObjectBinding(
config: Config,
diagnostics: Diagnostics
) {
if (config.durable_objects.bindings.length > 0) {
const invalidBindings = config.durable_objects.bindings.filter(
(binding) => !isRequiredProperty(binding, "script_name", "string")
);
if (invalidBindings.length > 0) {
diagnostics.errors.push(
`Durable Objects bindings should specify a "script_name".\n` +
`Pages requires Durable Object bindings to specify the name of the Worker where the Durable Object is defined.`
);
}
}
}
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
/**
* A symbol to inherit a binding from the deployed worker.
*/
export const INHERIT_SYMBOL = Symbol.for("inherit_binding");
export const SERVICE_TAG_PREFIX = "cf:service=";
export const ENVIRONMENT_TAG_PREFIX = "cf:environment=";
export const PATH_TO_DEPLOY_CONFIG = ".wrangler/deploy/config.json";
/**
* Config formats that use JSON parsing
*/
export const JSON_CONFIG_FORMATS: readonly string[] = ["json", "jsonc"];
@@ -0,0 +1,117 @@
import { getTodaysCompatDate } from "./compatibility-date";
import { mapWorkerMetadataBindings } from "./map-worker-metadata-bindings";
import type { RawConfig } from "./config";
import type {
CustomDomainRoute,
Route,
TailConsumer,
ZoneNameRoute,
} from "./config/environment";
import type { WorkerMetadata } from "./types";
import type { AssetConfig } from "@cloudflare/workers-shared";
import type { Cloudflare } from "cloudflare";
type RoutesRes = {
id: string;
pattern: string;
zone_name: string;
script: string;
}[];
interface APIWorkerConfig {
/* sourced from https://developers.cloudflare.com/api/resources/workers/subresources/scripts/methods/list/ */
name: string; // property renamed from `id`...
entrypoint: string;
tags: string[] | null;
compatibility_date: string;
compatibility_flags: string[];
logpush: boolean | undefined;
routes: RoutesRes;
tail_consumers: TailConsumer[] | undefined | null;
migration_tag?: string;
/* sourced from https://developers.cloudflare.com/api/resources/workers/subresources/domains/methods/list/ */
domains: Cloudflare.Workers.Domain[];
/* sourced from https://developers.cloudflare.com/api/resources/workers/subresources/scripts/subresources/schedules/methods/get/ */
schedules: Cloudflare.Workers.Scripts.Schedules.ScheduleGetResponse.Schedule[];
/* sourced from https://developers.cloudflare.com/api/resources/workers/subresources/beta/subresources/workers/subresources/versions/methods/get/ using `{version_id}` of `latest` */
assets?: AssetConfig;
bindings: WorkerMetadata["bindings"];
observability: Cloudflare.Workers.Beta.Worker.Observability | undefined;
limits: { cpu_ms?: number; subrequests?: number } | undefined;
placement: Cloudflare.Workers.Beta.Workers.Version.Placement | undefined;
subdomain: {
enabled: boolean;
previews_enabled: boolean;
};
}
/**
* Given information about a Worker,
* construct a Wrangler config file for the application.
*
* @param config - The Worker configuration sourced from the Cloudflare API
* @returns A Wrangler-compatible raw config object
*/
export function constructWranglerConfig(config: APIWorkerConfig): RawConfig {
const mappedBindings = mapWorkerMetadataBindings(config.bindings);
const durableObjectClassNames = config.bindings
.filter(
(binding) =>
binding.type === "durable_object_namespace" &&
binding.script_name === config.name
)
.map(
(durableObject) => (durableObject as { class_name: string }).class_name
);
const allRoutes: Route[] = [
...config.routes.map<ZoneNameRoute>((r) => ({
pattern: r.pattern,
zone_name: r.zone_name,
})),
...config.domains.map<CustomDomainRoute>((c) => ({
pattern: c.hostname as string,
zone_name: c.zone_name,
custom_domain: true,
enabled: (c as typeof c & { enabled: boolean }).enabled,
previews_enabled: (c as typeof c & { previews_enabled: boolean })
.previews_enabled,
})),
];
return {
name: config.name,
main: config.entrypoint,
workers_dev: config.subdomain.enabled,
preview_urls: config.subdomain.previews_enabled,
compatibility_date: config.compatibility_date ?? getTodaysCompatDate(),
compatibility_flags: config.compatibility_flags,
...(allRoutes.length ? { routes: allRoutes } : {}),
placement:
config.placement?.mode === "smart" ? { mode: "smart" } : undefined,
limits: config.limits,
...(durableObjectClassNames.length && config.migration_tag
? {
migrations: [
{
tag: config.migration_tag,
new_classes: durableObjectClassNames,
},
],
}
: {}),
...(config.schedules.length
? {
triggers: {
crons: config.schedules.map((scheduled) => scheduled.cron),
},
}
: {}),
tail_consumers: config.tail_consumers ?? undefined,
observability: config.observability,
...mappedBindings,
};
}
@@ -0,0 +1,293 @@
import { UserError } from "../errors";
/**
* Environment variables supported by Wrangler for configuration and authentication.
* Each variable is documented with its individual JSDoc comment below.
*/
type VariableNames =
// ## Authentication & API Configuration
/** Overrides the account ID for API requests. Can also be set in Wrangler config via `account_id` field. */
| "CLOUDFLARE_ACCOUNT_ID"
/** API token for authentication. Preferred over API key + email. */
| "CLOUDFLARE_API_TOKEN"
/** Legacy API key for authentication. Requires CLOUDFLARE_EMAIL. It is preferred to use `CLOUDFLARE_API_TOKEN`. */
| "CLOUDFLARE_API_KEY"
/** Email address for API key authentication. Used with `CLOUDFLARE_API_KEY`. It is preferred to use `CLOUDFLARE_API_TOKEN`. */
| "CLOUDFLARE_EMAIL"
/** Custom API base URL. Defaults to https://api.cloudflare.com/client/v4 */
| "CLOUDFLARE_API_BASE_URL"
/** Set to "fedramp_high" for FedRAMP High compliance region. This will update the API/AUTH URLs used to make requests to Cloudflare. */
| "CLOUDFLARE_COMPLIANCE_REGION"
/** API token for R2 SQL service. */
| "WRANGLER_R2_SQL_AUTH_TOKEN"
// ## Development & Local Testing
/** Controls whether to fetch the cf.json file. Set to "false" or "0" to disable fetching and use fallback data. Defaults to "true". */
| "CLOUDFLARE_CF_FETCH_ENABLED"
/** Custom path for caching the cf.json file. Overrides the default node_modules/.mf/cf.json location. */
| "CLOUDFLARE_CF_FETCH_PATH"
/** Local database connection strings for Hyperdrive development. The * should be replaced with the Hyperdrive binding name in the Worker. */
| `CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_${string}`
/** Suppress Hyperdrive-related warnings during development. */
| "NO_HYPERDRIVE_WARNING"
/** Path to HTTPS private key file for running the local development server in HTTPS mode. Without this Wrangler will generate keys automatically. */
| "WRANGLER_HTTPS_KEY_PATH"
/** Path to HTTPS certificate file for running the local development server in HTTPS mode. Without this Wrangler will generate keys automatically. */
| "WRANGLER_HTTPS_CERT_PATH"
/** Load development variables from .env files (default: true). */
| "CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV"
/** Include process.env in development variables (default: false). */
| "CLOUDFLARE_INCLUDE_PROCESS_ENV"
/** Include a trace header in all API requests that Wrangler makes (for internal use only) */
| "WRANGLER_TRACE_ID"
/** Disable the check for mixed state of subdomain flags (`workers_dev`, `preview_urls`, etc.) (default: false). */
| "WRANGLER_DISABLE_SUBDOMAIN_MIXED_STATE_CHECK"
// ## Logging & Output
/** Set log level: "debug", "info", "log", "warn", "error". */
| "WRANGLER_LOG"
/** Directory for debug log files. */
| "WRANGLER_LOG_PATH"
/** Sanitize sensitive data in debug logs (default: true). */
| "WRANGLER_LOG_SANITIZE"
/** Directory for ND-JSON output files. */
| "WRANGLER_OUTPUT_FILE_DIRECTORY"
/** Hide the Wrangler version banner */
| "WRANGLER_HIDE_BANNER"
// ## Build & Deployment Configuration
/** Specific path for ND-JSON output file. */
| "WRANGLER_OUTPUT_FILE_PATH"
/** Comma-separated list of build conditions for esbuild. */
| "WRANGLER_BUILD_CONDITIONS"
/** Build platform for esbuild (e.g., "node", "browser"). */
| "WRANGLER_BUILD_PLATFORM"
/** Path to file-based dev registry folder. */
| "WRANGLER_REGISTRY_PATH"
/** Additional D1 location choices (internal use). */
| "WRANGLER_D1_EXTRA_LOCATION_CHOICES"
/** The Workers environment to target (equivalent to the `--env` CLI param) */
| "CLOUDFLARE_ENV"
// ## Directory Configuration
/** Custom directory for Wrangler's cache files (overrides `node_modules/.cache/wrangler`). */
| "WRANGLER_CACHE_DIR"
/** Custom path to cloudflared binary (overrides automatic binary management). */
| "CLOUDFLARED_PATH"
// ## Advanced Configuration
/** Set to "staging" to use staging APIs instead of production. */
| "WRANGLER_API_ENVIRONMENT"
/** Custom auth domain (usually auto-configured). */
| "WRANGLER_AUTH_DOMAIN"
/** Custom auth URL (usually auto-configured). */
| "WRANGLER_AUTH_URL"
/** Custom OAuth client ID (usually auto-configured). */
| "WRANGLER_CLIENT_ID"
/** Custom token URL (usually auto-configured). */
| "WRANGLER_TOKEN_URL"
/** Custom token revocation URL (usually auto-configured). */
| "WRANGLER_REVOKE_URL"
/** Direct authorization token for API requests. */
| "WRANGLER_CF_AUTHORIZATION_TOKEN"
// ## Cloudflare Access Service Token (for CI/non-interactive environments)
/** Cloudflare Access Service Token Client ID. Used to authenticate with Access-protected domains in non-interactive environments (e.g. CI). */
| "CLOUDFLARE_ACCESS_CLIENT_ID"
/** Cloudflare Access Service Token Client Secret. Used with CLOUDFLARE_ACCESS_CLIENT_ID. */
| "CLOUDFLARE_ACCESS_CLIENT_SECRET"
/**
* Store OAuth credentials in the OS keychain instead of a plaintext TOML
* file. Overrides the persistent `keyring_enabled` preference written by
* `wrangler login --use-keyring`.
*/
| "CLOUDFLARE_AUTH_USE_KEYRING"
// ## Experimental Feature Flags
/** Enable the local explorer UI at /cdn-cgi/explorer (experimental, default: false). */
| "X_LOCAL_EXPLORER"
/** Open the browser in headful (visible) mode when using the Browser Run API in local dev (default: false). */
| "X_BROWSER_HEADFUL"
// ## CI-specific Variables (Internal Use)
/** Override command used by `wrangler init` (default: "create cloudflare"). */
| "WRANGLER_C3_COMMAND"
/** Enable/disable telemetry data collection. */
| "WRANGLER_SEND_METRICS"
/** Enable/disable error reporting to Sentry. */
| "WRANGLER_SEND_ERROR_REPORTS"
/** CI branch name (internal use). */
| "WORKERS_CI_BRANCH"
/** CI tag matching configuration (internal use). */
| "WRANGLER_CI_MATCH_TAG"
/** CI override name configuration (internal use). */
| "WRANGLER_CI_OVERRIDE_NAME"
/** CI network mode host override (internal use). */
| "WRANGLER_CI_OVERRIDE_NETWORK_MODE_HOST"
/** CI preview alias generation (internal use). */
| "WRANGLER_CI_GENERATE_PREVIEW_ALIAS"
/** Disable config watching in ConfigController. */
| "WRANGLER_CI_DISABLE_CONFIG_WATCHING"
// ## Docker Configuration
/** Path to docker binary (default: "docker"). */
| "WRANGLER_DOCKER_BIN"
/** Docker host configuration (handled separately from environment variable factory). */
| "WRANGLER_DOCKER_HOST"
/** Docker host configuration (handled separately from environment variable factory). */
| "DOCKER_HOST"
/** Environment variable used to signal that the current process is being run by the open-next deploy command. */
| "OPEN_NEXT_DEPLOY";
type DeprecatedNames =
| "CF_ACCOUNT_ID"
| "CF_API_TOKEN"
| "CF_API_KEY"
| "CF_EMAIL"
| "CF_API_BASE_URL";
type ElementType<A> = A extends readonly (infer T)[] ? T : never;
/**
* Create a function used to access a boolean environment variable. It may return undefined if the variable is not set.
*
* This is not memoized to allow us to change the value at runtime, such as in testing.
*
* The environment variable must be either "true" or "false" (after lowercasing), otherwise it will throw an error.
*/
export function getBooleanEnvironmentVariableFactory(options: {
variableName: VariableNames;
}): () => boolean | undefined;
export function getBooleanEnvironmentVariableFactory(options: {
variableName: VariableNames;
defaultValue: boolean | (() => boolean);
}): () => boolean;
export function getBooleanEnvironmentVariableFactory(options: {
variableName: VariableNames;
defaultValue?: boolean | (() => boolean);
}): () => boolean | undefined {
return () => {
if (
!(options.variableName in process.env) ||
process.env[options.variableName] === undefined
) {
return typeof options.defaultValue === "function"
? options.defaultValue()
: options.defaultValue;
}
switch (process.env[options.variableName]?.toLowerCase()) {
case "true":
return true;
case "false":
return false;
default:
throw new UserError(
`Expected ${options.variableName} to be "true" or "false", but got ${JSON.stringify(
process.env[options.variableName]
)}`,
{ telemetryMessage: false }
);
}
};
}
/**
* Create a function used to access an environment variable. It may return undefined if the variable is not set.
*
* This is not memoized to allow us to change the value at runtime, such as in testing.
* A warning is shown if the client is using a deprecated version - but only once.
* If a list of choices is provided, then the environment variable must be one of those given.
*/
export function getEnvironmentVariableFactory<
Choices extends readonly string[],
>(options: {
variableName: VariableNames;
deprecatedName?: DeprecatedNames;
choices?: Choices;
}): () => ElementType<Choices> | undefined;
/**
* Create a function used to access an environment variable, with a default value if the variable is not set.
*
* This is not memoized to allow us to change the value at runtime, such as in testing.
* A warning is shown if the client is using a deprecated version - but only once.
* If a list of choices is provided, then the environment variable must be one of those given.
*/
export function getEnvironmentVariableFactory<
Choices extends readonly string[],
>(options: {
variableName: VariableNames;
deprecatedName?: DeprecatedNames;
defaultValue: () => ElementType<Choices>;
readonly choices?: Choices;
}): () => ElementType<Choices>;
export function getEnvironmentVariableFactory<
Choices extends readonly string[],
>({
variableName,
deprecatedName,
choices,
defaultValue,
}: {
variableName: VariableNames;
deprecatedName?: DeprecatedNames;
defaultValue?: () => ElementType<Choices>;
readonly choices?: Choices;
}): () => ElementType<Choices> | undefined {
let hasWarned = false;
return () => {
if (variableName in process.env) {
return getProcessEnv(variableName, choices);
}
if (deprecatedName && deprecatedName in process.env) {
if (!hasWarned) {
hasWarned = true;
// eslint-disable-next-line no-console -- ideally we'd use `logger.warn` here, but that creates a circular dependency that Vitest is unable to resolve
console.warn(
`Using "${deprecatedName}" environment variable. This is deprecated. Please use "${variableName}", instead.`
);
}
return getProcessEnv(deprecatedName, choices);
}
return defaultValue?.();
};
}
/**
* Get the value of an environment variable and check it is one of the choices.
*/
function getProcessEnv<Choices extends readonly string[]>(
variableName: string,
choices: Choices | undefined
): ElementType<Choices> | undefined {
assertOneOf(choices, process.env[variableName]);
return process.env[variableName];
}
/**
* Assert `value` is one of a list of `choices`.
*/
function assertOneOf<Choices extends readonly string[]>(
choices: Choices | undefined,
value: string | undefined
): asserts value is ElementType<Choices> {
if (Array.isArray(choices) && !choices.includes(value)) {
throw new UserError(
`Expected ${JSON.stringify(value)} to be one of ${JSON.stringify(choices)}`,
{ telemetryMessage: false }
);
}
}
@@ -0,0 +1,430 @@
import path from "node:path";
import { dedent } from "ts-dedent";
import { UserError } from "../errors";
import { getGlobalConfigPath } from "../global-wrangler-config-path";
import {
getBooleanEnvironmentVariableFactory,
getEnvironmentVariableFactory,
} from "./factory";
import type { Config } from "../config";
/**
* `WRANGLER_C3_COMMAND` can override the command used by `wrangler init` when delegating to C3.
*
* By default this will use `create cloudflare`.
*
* To run against the beta release of C3 use:
*
* ```sh
* # Tell Wrangler to use the beta version of create-cloudflare
* WRANGLER_C3_COMMAND="create cloudflare@beta" npx wrangler init
* ```
*
* To test the integration between wrangler and C3 locally, use:
*
* ```sh
* # Ensure both Wrangler and C3 are built
* npm run build
* # Tell Wrangler to use the local version of create-cloudflare
* WRANGLER_C3_COMMAND="exec ./packages/create-cloudflare" npx wrangler init temp
* ```
*
* Note that you cannot use `WRANGLER_C3_COMMAND="create cloudflare@2"` if you are
* running Wrangler from inside the monorepo as the bin paths get messed up.
*/
export const getC3CommandFromEnv = getEnvironmentVariableFactory({
variableName: "WRANGLER_C3_COMMAND",
defaultValue: () => "create cloudflare",
});
/**
* `WRANGLER_SEND_METRICS` can override whether we attempt to send metrics information to Sparrow.
*/
export const getWranglerSendMetricsFromEnv =
getBooleanEnvironmentVariableFactory({
variableName: "WRANGLER_SEND_METRICS",
});
/**
* `WRANGLER_SEND_ERROR_REPORTS` controls whether we attempt to send error reports to Sentry.
*
* Defaults to `false` to avoid noisy false-positive reports. Users can opt in
* by setting `WRANGLER_SEND_ERROR_REPORTS=true`.
*/
export const getWranglerSendErrorReportsFromEnv =
getBooleanEnvironmentVariableFactory({
variableName: "WRANGLER_SEND_ERROR_REPORTS",
defaultValue: false,
});
/**
* Set `WRANGLER_API_ENVIRONMENT` environment variable to "staging" to tell Wrangler to hit the staging APIs rather than production.
*/
export const getCloudflareApiEnvironmentFromEnv = getEnvironmentVariableFactory(
{
variableName: "WRANGLER_API_ENVIRONMENT",
defaultValue: () => "production" as const,
choices: ["production", "staging"] as const,
}
);
/**
* The compliance region to use for the API requests.
*/
export type ComplianceConfig = Partial<Pick<Config, "compliance_region">>;
/** Used for commands that explicitly do not support compliance regions other than "public" */
export const COMPLIANCE_REGION_CONFIG_PUBLIC: ComplianceConfig = {
compliance_region: "public",
};
/**
* Used for commands where there is no configuration available and
* we rely upon the CLOUDFLARE_COMPLIANCE_REGION environment variable
* to determine the compliance region.
*/
export const COMPLIANCE_REGION_CONFIG_UNKNOWN: ComplianceConfig = {
compliance_region: undefined,
};
const getCloudflareComplianceRegionFromEnv = getEnvironmentVariableFactory({
variableName: "CLOUDFLARE_COMPLIANCE_REGION",
choices: ["public", "fedramp_high"] as const,
});
/**
* Set `CLOUDFLARE_COMPLIANCE_REGION` environment variable to "fedramp_high"
* or set the `compliance_region` property in the Wrangler configuration
* to tell Wrangler to run in FedRAMP High compliance region mode, rather than "public" mode.
*/
export const getCloudflareComplianceRegion = (
complianceConfig: ComplianceConfig
) => {
const complianceRegionFromEnv = getCloudflareComplianceRegionFromEnv();
if (
complianceRegionFromEnv !== undefined &&
complianceConfig?.compliance_region !== undefined &&
complianceRegionFromEnv !== complianceConfig.compliance_region
) {
throw new UserError(
dedent`
The compliance region has been set to different values in two places:
- \`CLOUDFLARE_COMPLIANCE_REGION\` environment variable: \`${complianceRegionFromEnv}\`
- \`compliance_region\` configuration property: \`${complianceConfig.compliance_region}\`
`,
{ telemetryMessage: false }
);
}
return (
complianceRegionFromEnv || complianceConfig?.compliance_region || "public"
);
};
const getCloudflareApiBaseUrlFromEnv = getEnvironmentVariableFactory({
variableName: "CLOUDFLARE_API_BASE_URL",
deprecatedName: "CF_API_BASE_URL",
});
/**
* `CLOUDFLARE_API_BASE_URL` specifies the URL to the Cloudflare API.
*
* If this environment variable is not set, it will default to a URL computed from the
* Cloudflare compliance region and the API environment.
*/
export const getCloudflareApiBaseUrl = (complianceConfig: ComplianceConfig) =>
getCloudflareApiBaseUrlFromEnv() ??
`https://api${getComplianceRegionSubdomain(complianceConfig)}${getStagingSubdomain()}.cloudflare.com/client/v4`;
/**
* Compute the subdomain for the compliance region.
*/
export function getComplianceRegionSubdomain(
complianceConfig: ComplianceConfig
): string {
return getCloudflareComplianceRegion(complianceConfig) === "fedramp_high"
? ".fed"
: "";
}
/**
* Compute the subdomain for the staging environment.
*/
function getStagingSubdomain(): string {
return getCloudflareApiEnvironmentFromEnv() === "staging" ? ".staging" : "";
}
/**
* `WRANGLER_LOG_SANITIZE` specifies whether we sanitize debug logs.
*
* By default we do, since debug logs could be added to GitHub issues and shouldn't include sensitive information.
*/
export const getSanitizeLogs = getBooleanEnvironmentVariableFactory({
variableName: "WRANGLER_LOG_SANITIZE",
defaultValue: true,
});
/**
* `WRANGLER_OUTPUT_FILE_DIRECTORY` specifies a directory where we should write a file containing output data in ND-JSON format.
*
* If this is set a random file will be created in this directory, and certain Wrangler commands will write entries to this file.
* This is overridden by the `WRANGLER_OUTPUT_FILE_PATH` environment variable.
*/
export const getOutputFileDirectoryFromEnv = getEnvironmentVariableFactory({
variableName: "WRANGLER_OUTPUT_FILE_DIRECTORY",
});
/**
* `WRANGLER_OUTPUT_FILE_PATH` specifies a path to a file where we should write output data in ND-JSON format.
*
* If this is set certain Wrangler commands will write entries to this file.
* This overrides the `WRANGLER_OUTPUT_FILE_DIRECTORY` environment variable.
*/
export const getOutputFilePathFromEnv = getEnvironmentVariableFactory({
variableName: "WRANGLER_OUTPUT_FILE_PATH",
});
/**
* `WRANGLER_CI_MATCH_TAG` specifies a Worker tag
*
* If this is set, Wrangler will ensure the Worker being targeted has this tag
*/
export const getCIMatchTag = getEnvironmentVariableFactory({
variableName: "WRANGLER_CI_MATCH_TAG",
});
/**
* `WRANGLER_CI_OVERRIDE_NAME` specifies a Worker name
*
* If this is set, Wrangler will override the Worker name with this one
*/
export const getCIOverrideName = getEnvironmentVariableFactory({
variableName: "WRANGLER_CI_OVERRIDE_NAME",
});
/**
* `WRANGLER_CI_OVERRIDE_NETWORK_MODE_HOST` specifies whether --network=host should be set
*
* If this is set to true, Wrangler will use the --network=host flag when calling out to docker to build container images
*/
export const getCIOverrideNetworkModeHost = getEnvironmentVariableFactory({
variableName: "WRANGLER_CI_OVERRIDE_NETWORK_MODE_HOST",
});
/**
* `WRANGLER_CI_GENERATE_PREVIEW_ALIAS` specifies whether to generate a preview alias during version upload
*
* If this is set to true, Wrangler will attempt to autogenerate the preview alias by using the branch
* name. If the branch name is too long and an alias cannot be created, a warning will be printed to the console.
*/
export const getCIGeneratePreviewAlias = getEnvironmentVariableFactory({
variableName: "WRANGLER_CI_GENERATE_PREVIEW_ALIAS",
defaultValue: () => "false" as const,
choices: ["true", "false"] as const,
});
/**
* `WORKERS_CI_BRANCH` is the branch name exposed by Workers CI
*
*/
export const getWorkersCIBranchName = getEnvironmentVariableFactory({
variableName: "WORKERS_CI_BRANCH",
});
/**
* `WRANGLER_BUILD_CONDITIONS` specifies the "build conditions" to use when importing packages at build time.
*
* See https://nodejs.org/api/packages.html#conditional-exports
* and https://esbuild.github.io/api/#how-conditions-work.
*
* If this is set, Wrangler will configure esbuild to use this list of conditions.
* The format is a string of comma separated conditions.
*/
export const getBuildConditionsFromEnv = getEnvironmentVariableFactory({
variableName: "WRANGLER_BUILD_CONDITIONS",
});
/**
* `WRANGLER_BUILD_PLATFORM` specifies the "build platform" to use when importing packages at build time.
*
* See https://esbuild.github.io/api/#platform
* and https://esbuild.github.io/api/#how-conditions-work.
*
* If this is set, Wrangler will configure esbuild to use this platform.
*/
export const getBuildPlatformFromEnv = getEnvironmentVariableFactory({
variableName: "WRANGLER_BUILD_PLATFORM",
});
/**
* `WRANGLER_REGISTRY_PATH` specifies the file based dev registry folder
*/
export const getRegistryPath = getEnvironmentVariableFactory({
variableName: "WRANGLER_REGISTRY_PATH",
defaultValue() {
return path.join(getGlobalConfigPath(), "registry");
},
});
/**
* `WRANGLER_D1_EXTRA_LOCATION_CHOICES` is an internal variable to let D1 team target their testing environments.
*
* External accounts cannot access testing environments, so should not set this variable.
*/
export const getD1ExtraLocationChoices: () => string | undefined =
getEnvironmentVariableFactory({
variableName: "WRANGLER_D1_EXTRA_LOCATION_CHOICES",
});
/**
* `WRANGLER_DOCKER_BIN` specifies the path to a docker binary.
*
* By default it's `docker`.
*/
export const getDockerPath = getEnvironmentVariableFactory({
variableName: "WRANGLER_DOCKER_BIN",
defaultValue() {
return "docker";
},
});
export const getSubdomainMixedStateCheckDisabled =
getBooleanEnvironmentVariableFactory({
variableName: "WRANGLER_DISABLE_SUBDOMAIN_MIXED_STATE_CHECK",
defaultValue: false,
});
/**
/**
* `CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV` specifies whether to load vars for local dev from `.env` files.
*/
export const getCloudflareLoadDevVarsFromDotEnv =
getBooleanEnvironmentVariableFactory({
variableName: "CLOUDFLARE_LOAD_DEV_VARS_FROM_DOT_ENV",
defaultValue: true,
});
/**
* `CLOUDFLARE_INCLUDE_PROCESS_ENV` specifies whether to include the `process.env` in vars loaded from `.env` for local development.
*/
export const getCloudflareIncludeProcessEnvFromEnv =
getBooleanEnvironmentVariableFactory({
variableName: "CLOUDFLARE_INCLUDE_PROCESS_ENV",
defaultValue: false,
});
export const getTraceHeader = getEnvironmentVariableFactory({
variableName: "WRANGLER_TRACE_ID",
});
export const getDisableConfigWatching = getBooleanEnvironmentVariableFactory({
variableName: "WRANGLER_CI_DISABLE_CONFIG_WATCHING",
defaultValue: false,
});
/**
* Hide the Wrangler version banner and command status (deprecated/experimental) warnings
*/
export const getWranglerHideBanner = getBooleanEnvironmentVariableFactory({
variableName: "WRANGLER_HIDE_BANNER",
defaultValue: false,
});
/**
* `CLOUDFLARE_ENV` specifies the currently selected Wrangler/Cloudflare environment.
*/
export const getCloudflareEnv = getEnvironmentVariableFactory({
variableName: "CLOUDFLARE_ENV",
});
/**
* `OPEN_NEXT_DEPLOY` is an environment variables that indicates that the current process is being
* run by the open-next deploy command
*/
export const getOpenNextDeployFromEnv = getEnvironmentVariableFactory({
variableName: "OPEN_NEXT_DEPLOY",
});
/**
* `X_LOCAL_EXPLORER` enables the local explorer UI at /cdn-cgi/explorer.
*/
export const getLocalExplorerEnabledFromEnv =
getBooleanEnvironmentVariableFactory({
variableName: "X_LOCAL_EXPLORER",
defaultValue: true,
});
/**
* `X_BROWSER_HEADFUL` opens the browser in headful (visible) mode when using the
* Browser Run API in local development.
*
* Set to "true" to enable:
*
* ```sh
* X_BROWSER_HEADFUL=true vite dev
* ```
*
* Note: when using `@cloudflare/playwright`, two Chrome windows may appear — the initial blank
* page and the one created by `browser.newPage()`. This is expected due to how Playwright handles
* browser contexts via CDP.
*/
export const getBrowserRenderingHeadfulFromEnv =
getBooleanEnvironmentVariableFactory({
variableName: "X_BROWSER_HEADFUL",
defaultValue: false,
});
/**
* `CLOUDFLARE_CF_FETCH_ENABLED` controls whether Miniflare fetches the `cf.json` file
* containing request.cf properties from workers.cloudflare.com.
*
* - If set to "false", disables fetching and uses fallback data (no files created)
* - If set to "true" or not set, uses the default behavior (fetches and caches cf.json)
*
* This is particularly useful for non-JavaScript projects that don't want
* a node_modules directory created automatically.
*
* Example:
* ```sh
* # Disable cf fetching entirely
* CLOUDFLARE_CF_FETCH_ENABLED=false npx wrangler dev
* ```
*/
export const getCfFetchEnabledFromEnv = getBooleanEnvironmentVariableFactory({
variableName: "CLOUDFLARE_CF_FETCH_ENABLED",
defaultValue: true,
});
/**
* `CLOUDFLARE_CF_FETCH_PATH` specifies a custom path for caching the cf.json file.
*
* - If set, uses the specified path instead of the default node_modules/.mf/cf.json
* - If not set, uses the default location (node_modules/.mf/cf.json)
*
* Example:
* ```sh
* # Use a custom cache location
* CLOUDFLARE_CF_FETCH_PATH=/tmp/cf-cache.json npx wrangler dev
* ```
*/
export const getCfFetchPathFromEnv = getEnvironmentVariableFactory({
variableName: "CLOUDFLARE_CF_FETCH_PATH",
});
/**
* `WRANGLER_CACHE_DIR` specifies a custom directory for Wrangler's cache files.
* This overrides the default `node_modules/.cache/wrangler` location.
* Useful for Yarn PnP or projects without node_modules.
*/
export const getWranglerCacheDirFromEnv = getEnvironmentVariableFactory({
variableName: "WRANGLER_CACHE_DIR",
});
/**
* `CLOUDFLARED_PATH` specifies a custom path to a cloudflared binary.
*
* If set, Wrangler will use this cloudflared binary instead of downloading one.
* The path must point to an existing executable file.
*/
export const getCloudflaredPathFromEnv = getEnvironmentVariableFactory({
variableName: "CLOUDFLARED_PATH",
});
+89
View File
@@ -0,0 +1,89 @@
/**
* This is used to provide telemetry with a sanitised error
* message that could not have any user-identifying information.
* Set to `true` to duplicate `message`.
* */
export type TelemetryMessage = {
telemetryMessage: string | boolean;
};
type UserErrorOptions = ErrorOptions & TelemetryMessage;
type FatalErrorOptions = UserErrorOptions & {
code?: number;
};
/**
* Base class for errors where the user has done something wrong. These are not
* reported to Sentry. API errors are intentionally *not* `UserError`s, and are
* reported to Sentry. This will help us understand which API errors need better
* messaging.
*/
export class UserError extends Error {
telemetryMessage: string | undefined;
constructor(message: string, options: UserErrorOptions) {
super(message, options);
// Restore prototype chain:
// https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-2.html#support-for-newtarget
Object.setPrototypeOf(this, new.target.prototype);
this.telemetryMessage =
typeof options?.telemetryMessage === "string"
? options.telemetryMessage
: options?.telemetryMessage
? message
: undefined;
}
}
export class DeprecationError extends UserError {
constructor(message: string, options: TelemetryMessage) {
super(`Deprecation:\n${message}`, options);
}
}
export class FatalError extends UserError {
readonly code: number | undefined;
constructor(message: string, options: FatalErrorOptions) {
super(message, options);
this.code = options.code;
}
}
export class CommandLineArgsError extends UserError {}
/**
* JsonFriendlyFatalError is used to output JSON when wrangler crashes, useful for --json mode.
*
* To use, pass stringify'd json into the constructor like so:
* ```js
* throw new JsonFriendlyFatalError(JSON.stringify({ error: messageToDisplay }), {
* telemetryMessage: false,
* });
* ```
*/
export class JsonFriendlyFatalError extends FatalError {}
export class MissingConfigError extends Error {
telemetryMessage: string | undefined;
constructor(key: string) {
super(`Missing config value for ${key}`);
this.telemetryMessage = `Missing config value for ${key}`;
}
}
/**
* Create either a FatalError or JsonFriendlyFatalError depending upon `isJson` parameter.
*
* If `isJson` is true, then the `message` is JSON stringified.
*/
export function createFatalError(
message: unknown,
isJson: boolean,
options: FatalErrorOptions
): Error {
if (isJson) {
return new JsonFriendlyFatalError(JSON.stringify(message), options);
}
return new FatalError(`${message}`, options);
}
@@ -0,0 +1,3 @@
export function formatTime(duration: number): string {
return `(${(duration / 1000).toFixed(2)} sec)`;
}
+79
View File
@@ -0,0 +1,79 @@
import fs from "node:fs";
/**
* Returns whether the given path is a directory
*
* Note: this function never throws; if the path does not exist or is inaccessible, it returns false.
*
* @param path The file system path to check
* @returns `true` if the path is a directory, `false` otherwise
*/
export function isDirectory(path: string) {
return fs.statSync(path, { throwIfNoEntry: false })?.isDirectory() ?? false;
}
/**
* Recursively remove a directory without waiting for completion or throwing any errors.
*
* @param dirPath The directory path to remove
* @param options An object with a `fireAndForget` property set to `true`
* @return `void` - the removal is fire-and-forget with errors suppressed
*/
export function removeDir(
dirPath: string,
{ fireAndForget }: { fireAndForget: true }
): void;
/**
* Recursively remove a directory with retries for Windows compatibility.
*
* @param dirPath The directory path to remove
* @param options An optional object with a `fireAndForget` property set to `false`, if defined
* @return `Promise<void>` - resolves when the removal is complete, or rejects if the removal fails
*/
export function removeDir(
dirPath: string,
options?: { fireAndForget?: false }
): Promise<void>;
export function removeDir(
dirPath: string,
{ fireAndForget = false }: { fireAndForget?: boolean } = {}
): Promise<void> | void {
// On Windows, `workerd` (and other processes) may not release file handles
// immediately after disposal, causing `EBUSY` errors. Node.js's built-in
// `maxRetries` option handles `EBUSY`, `EMFILE`, `ENFILE`, `ENOTEMPTY`,
// and `EPERM` errors with automatic backoff retries.
// eslint-disable-next-line workers-sdk/no-direct-recursive-rm -- this is the helper itself
const result = fs.promises.rm(dirPath, {
recursive: true,
force: true,
maxRetries: 5,
retryDelay: 100,
});
if (fireAndForget) {
// Don't wait for the promise, and silently swallow errors by handling if rejected
void result.catch(() => {});
} else {
return result;
}
}
/**
* Synchronously and recursively remove a directory, with retries for Windows compatibility.
*
* @see {@link removeDir} for the async version and rationale.
*
* @param dirPath The directory path to remove
* @throws If the removal fails after retries, an error will be thrown
*/
export function removeDirSync(dirPath: string): void {
// eslint-disable-next-line workers-sdk/no-direct-recursive-rm -- this is the helper itself
fs.rmSync(dirPath, {
recursive: true,
force: true,
maxRetries: 5,
retryDelay: 100,
});
}
@@ -0,0 +1,61 @@
import os from "node:os";
import path from "node:path";
import { isDirectory } from "./fs-helpers";
import { xdgAppPaths } from "./xdg-app-paths";
export interface GetGlobalConfigPathOptions {
/**
* The application namespace. Defaults to `"wrangler"`.
*/
appName?: string;
/**
* Whether to prepend a `.` to `appName` when resolving the XDG path and the
* legacy `$HOME` directory. Defaults to `true` to match wrangler's
* historical behaviour (`.wrangler`).
*/
leadingDot?: boolean;
/**
* When `true` (the default, matching wrangler's historical behaviour), a
* pre-existing `~/.<appName>` directory takes precedence over the XDG path.
* Pass `false` to always use the XDG-compliant path.
*/
useLegacyHomeDir?: boolean;
}
/**
* Resolve the global config directory for a Cloudflare CLI.
*
* Defaults to wrangler's directory (`.wrangler`) so existing callers are
* unaffected, but accepts an `appName` so other first-party CLIs (e.g. `cf`)
* can reuse the same XDG-compliant resolution under their own namespace.
*/
export function getGlobalConfigPath({
appName = "wrangler",
leadingDot = true,
useLegacyHomeDir = true,
}: GetGlobalConfigPathOptions = {}) {
//TODO: We should implement a custom path --global-config and/or the WRANGLER_HOME type environment variable
const dirName = `${leadingDot ? "." : ""}${appName}`;
const configDir = xdgAppPaths(dirName).config(); // New XDG compliant config path
if (useLegacyHomeDir) {
const legacyConfigDir = path.join(os.homedir(), dirName); // Legacy config in user's home directory
// Check for the legacy directory in $HOME; if it is not there then use the
// XDG compliant path.
if (isDirectory(legacyConfigDir)) {
return legacyConfigDir;
}
}
return configDir;
}
/**
* Resolve the XDG-compliant global cache directory for wrangler (`.wrangler`).
*
* Unlike {@link getGlobalConfigPath}, this never falls back to a legacy
* `~/.wrangler` directory.
*/
export function getGlobalWranglerCachePath() {
return xdgAppPaths(".wrangler").cache();
}
+166
View File
@@ -0,0 +1,166 @@
export type {
RawConfig,
Config,
RawDevConfig,
ConfigFields,
RawEnvironment,
ConfigBindingOptions,
} from "./config";
export * from "./config/environment";
export { partitionExports } from "./config/exports";
export type { ExportType, PartitionedExports } from "./config/exports";
export {
getDurableObjectExports,
hasDurableObjectExports,
} from "./config/durable-object-exports";
export {
type RedirectedRawConfig,
defaultWranglerConfig,
} from "./config/config";
export {
formatConfigSnippet,
configFormat,
configFileName,
experimental_readRawConfig,
} from "./config";
export {
experimental_patchConfig,
PatchConfigError,
} from "./config/patch-config";
export * from "./worker";
export * from "./types";
export {
type Message,
type Location,
type ParseFile,
ParseError,
APIError,
parseTOML,
type PackageJSON,
parsePackageJSON,
parseJSON,
parseJSONC,
readFileSyncToBuffer,
readFileSync,
indexLocation,
searchLocation,
parseHumanDuration,
parseNonHyphenedUuid,
parseByteSize,
} from "./parse";
export {
getBindingTypeFriendlyName,
isPagesConfig,
normalizeAndValidateConfig,
type NormalizeAndValidateConfigArgs,
type ConfigBindingFieldName,
isValidR2BucketName,
bucketFormatMessage,
} from "./config/validation";
import * as validation from "./config/validation";
/**
* @deprecated new code should use getBindingTypeFriendlyName() instead
*/
export const friendlyBindingNames = validation.friendlyBindingNames;
export {
type BindingLocalSupport,
getBindingLocalSupport,
} from "./config/binding-local-support";
export { validatePagesConfig } from "./config/validation-pages";
export { Diagnostics } from "./config/diagnostics";
export {
hasProperty,
isRequiredProperty,
isOptionalProperty,
} from "./config/validation-helpers";
export {
resolveWranglerConfigPath,
findWranglerConfig,
isRedirectedConfig,
} from "./config/config-helpers";
export type { ResolveConfigPathOptions } from "./config/config-helpers";
export * from "./errors";
export { assertNever } from "./assert-never";
export {
getPackagePath,
isPackageInstalled,
getInstalledPackageVersion,
} from "./package-resolution";
export * from "./constants";
export { mapWorkerMetadataBindings } from "./map-worker-metadata-bindings";
export { constructWranglerConfig } from "./construct-wrangler-config";
export {
getBooleanEnvironmentVariableFactory,
getEnvironmentVariableFactory,
} from "./environment-variables/factory";
export * from "./environment-variables/misc-variables";
export {
getGlobalConfigPath,
getGlobalWranglerCachePath,
} from "./global-wrangler-config-path";
export type { GetGlobalConfigPathOptions } from "./global-wrangler-config-path";
export { isCompatDate, getTodaysCompatDate } from "./compatibility-date";
export type { CompatDate } from "./compatibility-date";
export { isDockerfile } from "./config/validation";
export { isDirectory, removeDir, removeDirSync } from "./fs-helpers";
export {
type EphemeralDirectory,
getWranglerHiddenDirPath,
getWranglerTmpDir,
sweepStaleWranglerTmpDirs,
} from "./wrangler-tmp-dir";
export { MetricsRegistry } from "./prometheus-metrics";
export type { Counter } from "./prometheus-metrics";
export type { Tunnel, TunnelOptions } from "./tunnel";
export { startTunnel } from "./tunnel";
export { spawnCloudflared } from "./cloudflared";
export * from "./cfetch";
export { fetchLatestNpmVersion } from "./update-check";
export type { NpmVersionCheckResult } from "./update-check";
export { LOGGER_LEVELS } from "./logger";
export type { Logger, LoggerLevel } from "./logger";
export { retryOnAPIFailure } from "./retry";
export { formatTime } from "./format-time";
export {
getHostFromRoute,
getHostFromUrl,
getZoneFromRoute,
} from "./route-utils";
export type { PackageManager } from "./package-manager";
export {
NpmPackageManager,
PnpmPackageManager,
YarnPackageManager,
BunPackageManager,
} from "./package-manager";
export {
checkWorkerNameValidity,
toValidWorkerName,
getWorkerName,
getWorkerNameFromProject,
} from "./worker-name";
+26
View File
@@ -0,0 +1,26 @@
export const LOGGER_LEVELS = {
none: -1,
error: 0,
warn: 1,
info: 2,
log: 3,
debug: 4,
} as const;
export type LoggerLevel = keyof typeof LOGGER_LEVELS;
export type Logger = {
loggerLevel?: LoggerLevel;
debug: typeof console.debug;
debugWithSanitization?: (label: string, ...args: unknown[]) => void;
log: typeof console.log;
info: typeof console.info;
warn: typeof console.warn;
error: typeof console.error;
once?: {
info: typeof console.info;
log: typeof console.log;
warn: typeof console.warn;
error: typeof console.error;
};
};
@@ -0,0 +1,438 @@
import { assertNever } from "./assert-never";
import type { RawConfig } from "./config";
import type { WorkerMetadataBinding } from "./types";
/**
* Maps a set of bindings defined as worker metadata bindings (straight from the Cloudflare API) to bindings defined in the local format.
*
* @param bindings The set of worker metadata bindings to convert
* @param accountId The ID of the account
* @param complianceConfig The compliance region configuration
* @returns A RawConfig object with its bindings populated based on the provided bindings
*/
export function mapWorkerMetadataBindings(
bindings: WorkerMetadataBinding[]
): RawConfig {
return (
bindings
.filter((binding) => (binding.type as string) !== "secret_text")
// Combine the same types into {[type]: [binding]}
.reduce((configObj, binding) => {
// Some types have different names in wrangler.toml
// I want the type safety of the binding being destructured after the case narrowing the union but type is unused
switch (binding.type) {
case "plain_text":
{
configObj.vars = {
...(configObj.vars ?? {}),
[binding.name]: binding.text,
};
}
break;
case "json":
{
configObj.vars = {
...(configObj.vars ?? {}),
[binding.name]: binding.json,
};
}
break;
case "kv_namespace":
{
configObj.kv_namespaces = [
...(configObj.kv_namespaces ?? []),
{ id: binding.namespace_id, binding: binding.name },
];
}
break;
case "durable_object_namespace":
{
configObj.durable_objects = {
bindings: [
...(configObj.durable_objects?.bindings ?? []),
{
name: binding.name,
class_name: binding.class_name,
script_name: binding.script_name,
environment: binding.environment,
},
],
};
}
break;
case "d1":
{
configObj.d1_databases = [
...(configObj.d1_databases ?? []),
{
binding: binding.name,
database_id: binding.id,
},
];
}
break;
case "browser":
{
configObj.browser = {
binding: binding.name,
};
}
break;
case "ai":
{
configObj.ai = {
binding: binding.name,
};
}
break;
case "images":
{
configObj.images = {
binding: binding.name,
};
}
break;
case "stream":
{
configObj.stream = {
binding: binding.name,
};
}
break;
case "media":
{
configObj.media = {
binding: binding.name,
};
}
break;
case "r2_bucket":
{
configObj.r2_buckets = [
...(configObj.r2_buckets ?? []),
{
binding: binding.name,
bucket_name: binding.bucket_name,
jurisdiction: binding.jurisdiction,
},
];
}
break;
case "secrets_store_secret":
{
configObj.secrets_store_secrets = [
...(configObj.secrets_store_secrets ?? []),
{
binding: binding.name,
store_id: binding.store_id,
secret_name: binding.secret_name,
},
];
}
break;
case "artifacts":
{
configObj.artifacts = [
...(configObj.artifacts ?? []),
{
binding: binding.name,
namespace: binding.namespace,
},
];
}
break;
case "unsafe_hello_world": {
configObj.unsafe_hello_world = [
...(configObj.unsafe_hello_world ?? []),
{
binding: binding.name,
enable_timer: binding.enable_timer,
},
];
break;
}
case "flagship": {
configObj.flagship = [
...(configObj.flagship ?? []),
{
binding: binding.name,
app_id: binding.app_id,
},
];
break;
}
case "service":
{
configObj.services = [
...(configObj.services ?? []),
{
binding: binding.name,
service: binding.service,
environment: binding.environment,
entrypoint: binding.entrypoint,
},
];
}
break;
case "analytics_engine":
{
configObj.analytics_engine_datasets = [
...(configObj.analytics_engine_datasets ?? []),
{ binding: binding.name, dataset: binding.dataset },
];
}
break;
case "dispatch_namespace":
{
configObj.dispatch_namespaces = [
...(configObj.dispatch_namespaces ?? []),
{
binding: binding.name,
namespace: binding.namespace,
...(binding.outbound && {
outbound: {
service: binding.outbound.worker.service,
environment: binding.outbound.worker.environment,
parameters:
binding.outbound.params?.map((p) => p.name) ?? [],
},
}),
},
];
}
break;
case "logfwdr":
{
configObj.logfwdr = {
bindings: [
...(configObj.logfwdr?.bindings ?? []),
{ name: binding.name, destination: binding.destination },
],
};
}
break;
case "wasm_module":
{
configObj.wasm_modules = {
...(configObj.wasm_modules ?? {}),
[binding.name]: binding.part,
};
}
break;
case "text_blob":
{
configObj.text_blobs = {
...(configObj.text_blobs ?? {}),
[binding.name]: binding.part,
};
}
break;
case "data_blob":
{
configObj.data_blobs = {
...(configObj.data_blobs ?? {}),
[binding.name]: binding.part,
};
}
break;
case "secret_text":
// Ignore secrets
break;
case "version_metadata": {
{
configObj.version_metadata = {
binding: binding.name,
};
}
break;
}
case "send_email": {
configObj.send_email = [
...(configObj.send_email ?? []),
{
name: binding.name,
destination_address: binding.destination_address,
allowed_destination_addresses:
binding.allowed_destination_addresses,
allowed_sender_addresses: binding.allowed_sender_addresses,
},
];
break;
}
case "queue":
configObj.queues ??= { producers: [] };
configObj.queues.producers = [
...(configObj.queues.producers ?? []),
{
binding: binding.name,
queue: binding.queue_name,
delivery_delay: binding.delivery_delay,
},
];
break;
case "vectorize":
configObj.vectorize = [
...(configObj.vectorize ?? []),
{
binding: binding.name,
index_name: binding.index_name,
},
];
break;
case "ai_search_namespace":
configObj.ai_search_namespaces = [
...(configObj.ai_search_namespaces ?? []),
{
binding: binding.name,
namespace: binding.namespace,
},
];
break;
case "ai_search":
configObj.ai_search = [
...(configObj.ai_search ?? []),
{
binding: binding.name,
instance_name: binding.instance_name,
},
];
break;
case "websearch":
{
configObj.websearch = {
binding: binding.name,
};
}
break;
case "agent_memory": {
configObj.agent_memory = [
...(configObj.agent_memory ?? []),
{
binding: binding.name,
namespace: binding.namespace,
},
];
break;
}
case "hyperdrive":
configObj.hyperdrive = [
...(configObj.hyperdrive ?? []),
{
binding: binding.name,
id: binding.id,
},
];
break;
case "mtls_certificate":
configObj.mtls_certificates = [
...(configObj.mtls_certificates ?? []),
{
binding: binding.name,
certificate_id: binding.certificate_id,
},
];
break;
case "pipelines":
configObj.pipelines = [
...(configObj.pipelines ?? []),
{
binding: binding.name,
// NOTE: stream is the primary field, but we also support pipeline for backward compatibility
...(binding.stream && { stream: binding.stream }),
...(binding.pipeline && { pipeline: binding.pipeline }),
},
];
break;
case "assets":
configObj.assets = {
binding: binding.name,
// Note: we currently don't get all the assets information from the
// API, so here we are only able to set the name of the binding
// hopefully in the future we can properly fully support the binding
};
break;
case "inherit":
configObj.unsafe = {
bindings: [...(configObj.unsafe?.bindings ?? []), binding],
metadata: configObj.unsafe?.metadata ?? undefined,
};
break;
case "workflow":
{
configObj.workflows = [
...(configObj.workflows ?? []),
{
binding: binding.name,
name: binding.workflow_name,
class_name: binding.class_name,
script_name: binding.script_name,
},
];
}
break;
case "worker_loader":
{
configObj.worker_loaders = [
...(configObj.worker_loaders ?? []),
{
binding: binding.name,
},
];
}
break;
case "ratelimit":
{
configObj.ratelimits = [
...(configObj.ratelimits ?? []),
{
name: binding.name,
namespace_id: binding.namespace_id,
simple: {
limit: binding.simple.limit,
period: binding.simple.period,
},
},
];
}
break;
case "vpc_service":
{
configObj.vpc_services = [
...(configObj.vpc_services ?? []),
{
binding: binding.name,
service_id: binding.service_id,
},
];
}
break;
case "vpc_network":
{
if (binding.tunnel_id !== undefined) {
configObj.vpc_networks = [
...(configObj.vpc_networks ?? []),
{ binding: binding.name, tunnel_id: binding.tunnel_id },
];
} else if (binding.network_id !== undefined) {
configObj.vpc_networks = [
...(configObj.vpc_networks ?? []),
{ binding: binding.name, network_id: binding.network_id },
];
}
}
break;
default: {
configObj.unsafe = {
bindings: [...(configObj.unsafe?.bindings ?? []), binding],
metadata: configObj.unsafe?.metadata ?? undefined,
};
assertNever(binding);
}
}
return configObj;
}, {} as RawConfig)
);
}
@@ -0,0 +1,54 @@
/**
* Describes a supported package manager and its associated CLI commands
* and lock file conventions.
*/
export interface PackageManager {
/** The package manager identifier. */
type: "npm" | "yarn" | "pnpm" | "bun";
/** The command used to execute packages (e.g. `npx`, `pnpm`, `bunx`). */
npx: string;
/** The command segments used to download and execute packages (e.g. `["npx"]`, `["pnpm", "dlx"]`). */
dlx: string[];
/** Lock file names produced by this package manager. */
lockFiles: string[];
}
/**
* Manage packages using npm.
*/
export const NpmPackageManager = {
type: "npm",
npx: "npx",
dlx: ["npx"],
lockFiles: ["package-lock.json"],
} as const satisfies PackageManager;
/**
* Manage packages using pnpm.
*/
export const PnpmPackageManager = {
type: "pnpm",
npx: "pnpm",
lockFiles: ["pnpm-lock.yaml"],
dlx: ["pnpm", "dlx"],
} as const satisfies PackageManager;
/**
* Manage packages using yarn.
*/
export const YarnPackageManager = {
type: "yarn",
npx: "yarn",
dlx: ["yarn", "dlx"],
lockFiles: ["yarn.lock"],
} as const satisfies PackageManager;
/**
* Manage packages using bun.
*/
export const BunPackageManager = {
type: "bun",
npx: "bunx",
dlx: ["bunx"],
lockFiles: ["bun.lockb", "bun.lock"],
} as const satisfies PackageManager;
@@ -0,0 +1,146 @@
import { statSync } from "node:fs";
import path from "node:path";
import { parsePackageJSON, readFileSync } from "./parse";
/**
* Resolves the filesystem path for an installed npm package.
*
* Tries two strategies:
* 1. `require.resolve("<pkg>/package.json")` -- works when the package exports its package.json
* 2. `require.resolve("<pkg>")` -- fallback for packages that don't export package.json
*
* @param packageName - The npm package name to resolve
* @param projectPath - The project directory to resolve from
* @returns The resolved directory path, or `undefined` if the package is not installed
*/
export function getPackagePath(
packageName: string,
projectPath: string
): string | undefined {
try {
// Try to resolve the package.json directly — works when the package exports it
return path.dirname(
require.resolve(`${packageName}/package.json`, {
paths: [projectPath],
})
);
} catch {}
try {
// Fallback: resolve the package entry point and return its directory
return path.dirname(
require.resolve(packageName, {
paths: [projectPath],
})
);
} catch {}
return undefined;
}
/**
* Checks whether an npm package is installed in a target project.
*
* @param packageName - The name of the target package
* @param projectPath - The path of the project to check
* @returns `true` if the package is installed, `false` otherwise
*/
export function isPackageInstalled(
packageName: string,
projectPath: string
): boolean {
return !!getPackagePath(packageName, projectPath);
}
/**
* Gets the exact version of an npm package installed in a project by resolving
* it from node_modules and reading its package.json.
*
* @param packageName - The name of the target package
* @param projectPath - The path of the project to check
* @param opts - Options
* @param opts.stopAtProjectPath - If `true`, stop walking up at the project's path
* @returns The installed version string, or `undefined` if the package is not installed
*/
export function getInstalledPackageVersion(
packageName: string,
projectPath: string,
opts: {
stopAtProjectPath?: boolean;
} = {}
): string | undefined {
try {
const packagePath = getPackagePath(packageName, projectPath);
if (!packagePath) {
return undefined;
}
const lastDir = opts.stopAtProjectPath === true ? projectPath : undefined;
const packageJsonPath = findFileUp("package.json", packagePath, lastDir);
if (!packageJsonPath) {
return undefined;
}
const packageJson = parsePackageJSON(
readFileSync(packageJsonPath),
packageJsonPath
);
// The requested package may be installed under an alias (e.g. vite+
// installs `@voidzero-dev/vite-plus-core` under the `vite` alias). In that
// case the resolved package.json belongs to the aliased package, so its
// `version` is not the version of the requested package.
//
// `bundledVersions` is NOT a standard package.json field (it is not the
// standard `bundledDependencies`) — it is a vite+ convention that maps the
// names of the tools it bundles to the versions it provides. When the
// resolved package name doesn't match the requested one, prefer the version
// declared there for the requested package.
if (packageJson.name !== packageName) {
const bundledVersion = packageJson.bundledVersions?.[packageName];
if (bundledVersion !== undefined) {
return bundledVersion;
}
}
return packageJson.version;
} catch {}
}
/**
* Walks up from `startDir` looking for a file named `name`.
* Stops at `lastDir` (inclusive) if provided, otherwise walks to the filesystem root.
*
* @param name - The filename to search for
* @param startDir - The directory to start searching from
* @param lastDir - If provided, stop searching after reaching this directory
* @returns The full path to the found file, or `undefined`
*/
function findFileUp(
name: string,
startDir: string,
lastDir?: string
): string | undefined {
let dir = startDir;
const root = path.parse(dir).root;
while (true) {
const candidate = path.join(dir, name);
try {
if (statSync(candidate).isFile()) {
return candidate;
}
} catch {}
if (lastDir !== undefined && dir === lastDir) {
break;
}
const parent = path.dirname(dir);
if (parent === dir || dir === root) {
break;
}
dir = parent;
}
return undefined;
}
+458
View File
@@ -0,0 +1,458 @@
import { readFileSync as fsReadFileSync } from "node:fs";
import { resolve } from "node:path";
import * as jsoncParser from "jsonc-parser";
import TOML, { TomlError } from "smol-toml";
import { UserError } from "./errors";
import type { TelemetryMessage } from "./errors";
import type { ParseError as JsoncParseError } from "jsonc-parser";
export type Message = {
text: string;
location?: Location;
notes?: Message[];
kind?: "warning" | "error";
};
type MessageInit = Message & TelemetryMessage;
export type Location = ParseFile & {
line: number;
column: number;
length?: number;
lineText?: string;
suggestion?: string;
};
export type ParseFile = {
file?: string;
fileText?: string;
};
/**
* An error that's thrown when something fails to parse.
*/
export class ParseError extends UserError implements Message {
readonly text: string;
readonly notes: Message[];
readonly location?: Location;
readonly kind: "warning" | "error";
constructor({ text, notes, location, kind, telemetryMessage }: MessageInit) {
super(text, { telemetryMessage });
this.name = this.constructor.name;
this.text = text;
this.notes = notes ?? [];
this.location = location;
this.kind = kind ?? "error";
}
}
// `ParseError`s shouldn't generally be reported to Sentry, but Wrangler has
// relied on `ParseError` for any sort of error with additional notes.
// In particular, API errors which we'd like to report are `ParseError`s.
// Therefore, allow particular `ParseError`s to be marked `reportable`.
export class APIError extends ParseError {
#status?: number;
code?: number;
accountTag?: string;
/**
* Optional structured metadata hoisted from the first `FetchError.meta`
* on the v4 response envelope. Consumers can inspect this to render
* endpoint-specific structured error payloads.
*/
meta?: { details?: unknown } & Record<string, unknown>;
constructor({ status, ...rest }: MessageInit & { status?: number }) {
super(rest);
this.name = this.constructor.name;
this.#status = status;
}
get status(): number | undefined {
return this.#status;
}
isGatewayError() {
if (this.#status !== undefined) {
return [524].includes(this.#status);
}
return false;
}
isRetryable() {
return String(this.#status).startsWith("5");
}
// Allow `APIError`s to be marked as handled.
#reportable = true;
get reportable() {
return this.#reportable;
}
preventReport() {
this.#reportable = false;
}
}
/**
* Parses a TOML string to an object.
*
* Note: throws a `ParseError` if parsing fails.
*
* @param tomlContent The TOML content to parse.
* @param filePath Optional file path for error reporting.
* @returns The parsed TOML object.
*/
export function parseTOML(tomlContent: string, filePath?: string): unknown {
try {
return TOML.parse(tomlContent);
} catch (err) {
if (!(err instanceof TomlError)) {
throw err;
}
const location = {
lineText: tomlContent.split("\n")[err.line - 1],
line: err.line,
column: err.column - 1,
file: filePath,
fileText: tomlContent,
};
throw new ParseError({
text: err.message.substring(0, err.message.indexOf("\n")),
location,
telemetryMessage: "TOML parse error",
});
}
}
/**
* A minimal type describing a package.json file.
*/
export type PackageJSON = {
name?: string;
version?: string;
private?: boolean;
devDependencies?: Record<string, unknown>;
dependencies?: Record<string, unknown>;
scripts?: Record<string, unknown>;
/**
* NOTE: This is **not** a standard `package.json` field — don't confuse it
* with the standard `bundledDependencies`. It is a convention introduced by
* vite+ (https://viteplus.dev): vite+ installs `@voidzero-dev/vite-plus-core`
* under the `vite` npm alias and records the versions of the tools it bundles
* here, keyed by package name
* (e.g. `{ "vite": "8.1.2", "rolldown": "...", "tsdown": "..." }`).
*
* We read it to recover the underlying Vite version when Vite is installed via
* such an alias. It is optional and absent for the vast majority of packages.
*/
bundledVersions?: Record<string, string | undefined>;
};
/**
* A typed version of `parseJSON()`.
*/
export function parsePackageJSON(input: string, file?: string): PackageJSON {
return parseJSON(input, file) as PackageJSON;
}
/**
* Parses JSON and throws a `ParseError`.
*/
export function parseJSON(input: string, file?: string): unknown {
return parseJSONC(input, file, {
allowEmptyContent: false,
allowTrailingComma: false,
disallowComments: true,
});
}
/**
* A wrapper around `JSONC.parse` that throws a `ParseError`.
*/
export function parseJSONC(
input: string,
file?: string,
options: jsoncParser.ParseOptions = { allowTrailingComma: true }
): unknown {
const errors: JsoncParseError[] = [];
const data = jsoncParser.parse(input, errors, options);
if (errors.length) {
throw new ParseError({
text: jsoncParser.printParseErrorCode(errors[0].error),
location: {
...indexLocation({ file, fileText: input }, errors[0].offset + 1),
length: errors[0].length,
},
telemetryMessage: "JSON(C) parse error",
});
}
return data;
}
/**
* Reads a file into a node Buffer.
*/
export function readFileSyncToBuffer(file: string): Buffer {
try {
return fsReadFileSync(file);
} catch (err) {
const { message } = err as Error;
throw new ParseError({
text: `Could not read file: ${file}`,
notes: [
{
text: message.replace(file, resolve(file)),
},
],
telemetryMessage: false,
});
}
}
/**
* Reads a file and parses it based on its type.
*/
export function readFileSync(file: string): string {
try {
const buffer = fsReadFileSync(file);
return removeBOMAndValidate(buffer, file);
} catch (err) {
if (err instanceof ParseError) {
throw err;
}
const { message } = err as Error;
throw new ParseError({
text: `Could not read file: ${file}`,
notes: [
{
text: message.replace(file, resolve(file)),
},
],
telemetryMessage: "Could not read file",
});
}
}
/**
* Calculates the line and column location from an index.
*/
export function indexLocation(file: ParseFile, index: number): Location {
let lineText,
line = 0,
column = 0,
cursor = 0;
const { fileText = "" } = file;
for (const row of fileText.split("\n")) {
line++;
cursor += row.length + 1;
if (cursor >= index) {
lineText = row;
column = row.length - (cursor - index);
break;
}
}
return { lineText, line, column, ...file };
}
/**
* Guesses the line and column location of a search query.
*/
export function searchLocation(file: ParseFile, query: unknown): Location {
let lineText,
length,
line = 0,
column = 0;
const queryText = String(query);
const { fileText = "" } = file;
for (const content of fileText.split("\n")) {
line++;
const index = content.indexOf(queryText);
if (index >= 0) {
lineText = content;
column = index;
length = queryText.length;
break;
}
}
return { lineText, line, column, length, ...file };
}
const units = {
nanoseconds: 0.000000001,
nanosecond: 0.000000001,
microseconds: 0.000001,
microsecond: 0.000001,
milliseconds: 0.001,
millisecond: 0.001,
seconds: 1,
second: 1,
minutes: 60,
minute: 60,
hours: 3600,
hour: 3600,
days: 86400,
day: 86400,
weeks: 604800,
week: 604800,
month: 18144000,
year: 220752000,
nsecs: 0.000000001,
nsec: 0.000000001,
usecs: 0.000001,
usec: 0.000001,
msecs: 0.001,
msec: 0.001,
secs: 1,
sec: 1,
mins: 60,
min: 60,
ns: 0.000000001,
us: 0.000001,
ms: 0.001,
mo: 18144000,
yr: 220752000,
s: 1,
m: 60,
h: 3600,
d: 86400,
w: 604800,
y: 220752000,
};
/**
* Parse a human-readable time duration in seconds (including fractional)
*
* Invalid values will return NaN
*/
export function parseHumanDuration(s: string): number {
const unitsMap = new Map(Object.entries(units));
s = s.trim().toLowerCase();
let base = 1;
for (const [name, _] of unitsMap) {
if (s.endsWith(name)) {
s = s.substring(0, s.length - name.length);
base = unitsMap.get(name) || 1;
break;
}
}
return Number(s) * base;
}
export function parseNonHyphenedUuid(uuid: string | null): string | null {
if (uuid == null || uuid.includes("-")) {
return uuid;
}
if (uuid.length != 32) {
return null;
}
const uuid_parts: string[] = [];
uuid_parts.push(uuid.slice(0, 8));
uuid_parts.push(uuid.slice(8, 12));
uuid_parts.push(uuid.slice(12, 16));
uuid_parts.push(uuid.slice(16, 20));
uuid_parts.push(uuid.slice(20));
let hyphenated = "";
uuid_parts.forEach((part) => (hyphenated += part + "-"));
return hyphenated.slice(0, 36);
}
export function parseByteSize(
s: string,
base: number | undefined = undefined
): number {
const match = s.match(
/^(\d*\.*\d*)\s*([kKmMgGtTpP]{0,1})([i]{0,1}[bB]{0,1})$/
);
if (!match) {
return NaN;
}
const size = match[1];
if (size.length === 0 || isNaN(Number(size))) {
return NaN;
}
const unit = match[2].toLowerCase();
const sizeUnits = {
k: 1,
m: 2,
g: 3,
t: 4,
p: 5,
} as const;
if (unit.length !== 0 && !(unit in sizeUnits)) {
return NaN;
}
const binary = match[3].toLowerCase() == "ib";
if (binary && unit.length === 0) {
// Plain "ib" without a size unit is invalid
return NaN;
}
const pow = sizeUnits[unit as keyof typeof sizeUnits] || 0;
return Math.floor(
Number(size) * Math.pow(base ?? (binary ? 1024 : 1000), pow)
);
}
const UNSUPPORTED_BOMS = [
{
buffer: Buffer.from([0x00, 0x00, 0xfe, 0xff]),
encoding: "UTF-32 BE",
},
{
buffer: Buffer.from([0xff, 0xfe, 0x00, 0x00]),
encoding: "UTF-32 LE",
},
{
buffer: Buffer.from([0xfe, 0xff]),
encoding: "UTF-16 BE",
},
{
buffer: Buffer.from([0xff, 0xfe]),
encoding: "UTF-16 LE",
},
];
/**
* Removes UTF-8 BOM if present and validates that no other BOMs are present.
* Throws ParseError for non-UTF-8 BOMs with descriptive error messages.
*/
function removeBOMAndValidate(buffer: Buffer, file: string): string {
for (const bom of UNSUPPORTED_BOMS) {
if (
buffer.length >= bom.buffer.length &&
buffer.subarray(0, bom.buffer.length).equals(bom.buffer)
) {
throw new ParseError({
text: `Configuration file contains ${bom.encoding} byte order marker`,
notes: [
{
text: `The file "${file}" appears to be encoded as ${bom.encoding}. Please save the file as UTF-8 without BOM.`,
},
],
location: { file, line: 1, column: 0 },
telemetryMessage: `${bom.encoding} BOM detected`,
});
}
}
const content = buffer.toString("utf-8");
if (content.charCodeAt(0) === 0xfeff) {
return content.slice(1);
}
return content;
}
@@ -0,0 +1,84 @@
/**
* Lightweight Prometheus metrics registry for push-based counter metrics.
*
* Designed for ephemeral per-request usage in Cloudflare Workers that push
* metrics to a Prometheus push gateway. Replaces the deprecated `promjs` library.
*
* @example
* ```ts
* const metrics = new MetricsRegistry();
* const reqCounter = metrics.createCounter(
* "service_request_total",
* "Total requests"
* );
* reqCounter.inc();
*
* // Push to Prometheus gateway
* ctx.waitUntil(
* fetch(prometheusUrl, {
* method: "POST",
* headers: { Authorization: `Bearer ${token}` },
* body: metrics.metrics(),
* })
* );
* ```
*/
export interface Counter {
/** Increment the counter by 1. */
inc(): void;
/** Increment the counter by the given non-negative amount. */
add(amount: number): void;
}
interface CounterEntry {
name: string;
help: string;
value: number;
}
export class MetricsRegistry {
private counters: CounterEntry[] = [];
/**
* Create and register a new counter metric.
*
* @param name - The metric name (e.g. "service_request_total")
* @param help - A human-readable description of the metric
* @returns A Counter that can be incremented
*/
createCounter(name: string, help: string): Counter {
const entry: CounterEntry = { name, help, value: 0 };
this.counters.push(entry);
return {
inc: () => {
entry.value++;
},
add: (amount: number) => {
if (amount < 0) {
throw new Error("Counter value cannot decrease");
}
entry.value += amount;
},
};
}
/**
* Serialize all registered metrics in Prometheus text exposition format.
*
* @see https://prometheus.io/docs/instrumenting/exposition_formats/#text-based-format
*/
metrics(): string {
return this.counters
.map((c) => {
let result = "";
if (c.help.length > 0) {
result += `# HELP ${c.name} ${c.help}\n`;
}
result += `# TYPE ${c.name} counter\n`;
result += `${c.name} ${c.value}\n`;
return result;
})
.join("");
}
}
+45
View File
@@ -0,0 +1,45 @@
import { setTimeout } from "node:timers/promises";
import { APIError } from "./parse";
import type { Logger } from "./logger";
const MAX_ATTEMPTS = 3;
export async function retryOnAPIFailure<T>(
action: () => T | Promise<T>,
logger: Logger,
backoff = 0,
attempts = MAX_ATTEMPTS,
abortSignal?: AbortSignal
): Promise<T> {
try {
return await action();
} catch (err) {
if (err instanceof APIError) {
if (!err.isRetryable()) {
throw err;
}
} else if (err instanceof DOMException && err.name === "TimeoutError") {
// Per-request timeouts (from AbortSignal.timeout()) are transient
// and should be retried, but user-initiated aborts (AbortError)
// should not.
} else if (!(err instanceof TypeError)) {
throw err;
}
logger.debug(`Retrying API call after error...`);
logger.debug(err);
if (attempts <= 1) {
throw err;
}
await setTimeout(backoff, undefined, { signal: abortSignal });
return retryOnAPIFailure(
action,
logger,
backoff + 1000,
attempts - 1,
abortSignal
);
}
}
+84
View File
@@ -0,0 +1,84 @@
import type { Route } from "./config/environment";
/**
* Get the hostname on which to run a Worker.
*
* The most accurate place is usually
* `route.pattern`, as that includes any subdomains. For example:
* ```js
* {
* pattern: foo.example.com
* zone_name: example.com
* }
* ```
* However, in the case of patterns that _can't_ be parsed as a hostname
* (primarily the pattern `*/ /*`), we fall back to the `zone_name`
* (and in the absence of that return undefined).
* @param route
*/
export function getHostFromRoute(route: Route): string | undefined {
let host: string | undefined;
if (typeof route === "string") {
host = getHostFromUrl(route);
} else if (typeof route === "object") {
host = getHostFromUrl(route.pattern);
if (host === undefined && "zone_name" in route) {
host = getHostFromUrl(route.zone_name);
}
}
return host;
}
/**
* Best-effort derivation of the Cloudflare zone name that owns a given route,
* for use as the `CF-Worker` header value on outbound subrequests in local
* development (see https://developers.cloudflare.com/fundamentals/reference/http-headers/#cf-worker).
*
* In production, `CF-Worker` is set to the zone name — for a route
* `foo.example.com/*` on zone `example.com`, the header is `example.com`.
* When the user has explicitly told us the zone name in their route config
* (`zone_name`), use it. Otherwise, fall back to {@link getHostFromRoute},
* which returns the route pattern's hostname — this is the closest local
* approximation without performing an API lookup, and matches the behaviour
* users see when their route's hostname is already the apex (e.g.
* `example.com/*`).
*/
export function getZoneFromRoute(route: Route): string | undefined {
if (typeof route === "object" && "zone_name" in route && route.zone_name) {
return route.zone_name;
}
return getHostFromRoute(route);
}
/**
* Given something that resembles a URL, try to extract a host from it.
*/
export function getHostFromUrl(urlLike: string): string | undefined {
// if the urlLike-pattern uses a splat for the entire host and is only concerned with the pathname, we cannot infer a host
if (
urlLike.startsWith("*/") ||
urlLike.startsWith("http://*/") ||
urlLike.startsWith("https://*/")
) {
return undefined;
}
// if the urlLike-pattern uses a splat for the sub-domain (*.example.com) or for the root-domain (*example.com), remove the wildcard parts
urlLike = urlLike.replace(/\*(\.)?/g, "");
// prepend a protocol if the pattern did not specify one
if (!(urlLike.startsWith("http://") || urlLike.startsWith("https://"))) {
urlLike = "http://" + urlLike;
}
// now we've done our best to make urlLike a valid url string which we can pass to `new URL()` to get the host
// if it still isn't, return undefined to indicate we couldn't infer a host
try {
return new URL(urlLike).host;
} catch {
return undefined;
}
}
@@ -0,0 +1,17 @@
export { mockConsoleMethods, createDeferred } from "./mock";
export {
normalizeString,
mockCreateDate,
mockEndDate,
mockModifiedDate,
mockQueuedDate,
mockStartDate,
} from "./normalize";
export { runInTempDir } from "./run-in-tmp";
export { seed } from "./seed";
export {
writeWranglerConfig,
writeDeployRedirectConfig,
writeRedirectedWranglerConfig,
readWranglerConfig,
} from "./wrangler-config";
@@ -0,0 +1,108 @@
import * as util from "node:util";
import { afterEach, beforeEach, vi } from "vitest";
import { normalizeString } from "./normalize";
import type { MockInstance } from "vitest";
/**
* We use this module to mock console methods, and optionally
* assert on the values they're called with in our tests.
*/
let debugSpy: MockInstance,
logSpy: MockInstance,
infoSpy: MockInstance,
errorSpy: MockInstance,
warnSpy: MockInstance;
/**
* An object containing the normalized output of each console method.
*
* We use `defineProperties` to add non enumerable methods to the object,
* so they don't show up in test assertions that iterate over the object's keys.
* i.e. `expect(std).toMatchInlineSnapshot('...')`;
*/
const std = Object.defineProperties(
{ debug: "", out: "", info: "", err: "", warn: "", getAndClearOut: () => "" },
{
debug: {
get: () => normalizeOutput(debugSpy),
enumerable: true,
},
out: {
get: () => normalizeOutput(logSpy),
enumerable: true,
},
info: {
get: () => normalizeOutput(infoSpy),
enumerable: true,
},
err: {
get: () => normalizeOutput(errorSpy),
enumerable: true,
},
warn: {
get: () => normalizeOutput(warnSpy),
enumerable: true,
},
/**
* Return the content of the mocked stdout and clear the mock's history.
*
* Helpful for tests that need to assert on multiple sequential console outputs.
*/
getAndClearOut: {
value: () => {
const output = normalizeOutput(logSpy);
logSpy.mockClear();
return output;
},
enumerable: false,
},
}
);
function normalizeOutput(spy: MockInstance, join = "\n"): string {
return normalizeString(captureCalls(spy, join));
}
function captureCalls(spy: MockInstance, join = "\n"): string {
return spy.mock.calls
.map((args: unknown[]) => util.format("%s", ...args))
.join(join);
}
export function mockConsoleMethods() {
beforeEach(() => {
debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {});
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
debugSpy.mockRestore();
logSpy.mockRestore();
infoSpy.mockRestore();
errorSpy.mockRestore();
warnSpy.mockRestore();
});
return std;
}
export function createDeferred<T>() {
let resolve: ((value: T) => void) | undefined;
let reject: ((reason?: unknown) => void) | undefined;
const promise = new Promise<T>((_resolve, _reject) => {
resolve = _resolve;
reject = _reject;
});
if (!resolve || !reject) {
throw new Error("Failed to create deferred promise");
}
return {
promise,
resolve,
reject,
};
}
@@ -0,0 +1,113 @@
export const mockCreateDate = new Date(2025, 4, 1);
export const mockModifiedDate = new Date(2025, 4, 2);
export const mockStartDate = new Date(2021, 1, 1);
export const mockQueuedDate = new Date(2025, 1, 2);
export const mockEndDate = new Date(2025, 1, 3);
/**
* Normalize the input string, to make it reliable to use in tests.
*/
export function normalizeString(input: string): string {
return normalizeTables(
normalizeDates(
normalizeErrorMarkers(
replaceByte(
stripTrailingWhitespace(
stripStartupProfileHash(
normalizeSlashes(
normalizeCwd(
normalizeTempDirs(stripTimings(replaceThinSpaces(input)))
)
)
)
)
)
)
)
);
}
function stripStartupProfileHash(str: string): string {
return str.replace(/startup-profile-[^/]+/g, "startup-profile-<HASH>");
}
function normalizeTables(str: string): string {
return str
.replaceAll(/┌─+/g, "┌─")
.replaceAll(/┬─+/g, "┬─")
.replaceAll(/ +│/g, " │")
.replaceAll(/├─+/g, "├─")
.replaceAll(/┼─+/g, "┼─")
.replaceAll(/└─+/g, "└─")
.replaceAll(/┴─+/g, "┴─");
}
function normalizeDates(str: string): string {
return str
.replaceAll(/\d+ (years?|days?|months?) ago/g, "[mock-time-ago]")
.replaceAll(mockCreateDate.toLocaleString(), "[mock-create-date]")
.replaceAll(mockModifiedDate.toLocaleString(), "[mock-modified-date]")
.replaceAll(mockStartDate.toLocaleString(), "[mock-start-date]")
.replaceAll(mockQueuedDate.toLocaleString(), "[mock-queued-date]")
.replaceAll(mockEndDate.toLocaleString(), "[mock-end-date]");
}
/**
* Normalize error `X` markers.
*
* Windows gets a different character.
*/
function normalizeErrorMarkers(str: string): string {
return str.replaceAll("✘", "X");
}
/**
* Ensure slashes in the `str` are OS file-system agnostic.
*
* Use this in snapshot tests to be resilient to file-system differences.
*/
function normalizeSlashes(str: string): string {
return str.replace(/\\/g, "/");
}
/**
* Replace any use of the current working directory with `<cwd>` to avoid cross OS issues.
*/
function normalizeCwd(str: string): string {
return str.replaceAll(process.cwd(), "<cwd>");
}
/**
* Strip "timing data" out of the `stdout` string, since this is not always deterministic.
*
* Use this in snapshot tests to be resilient to slight changes in timing of processing.
*/
function stripTimings(stdout: string): string {
return stdout.replace(/\(\d+\.\d+ sec\)/g, "(TIMINGS)");
}
function stripTrailingWhitespace(str: string): string {
return str.replace(/[^\S\n]+\n/g, "\n");
}
/**
* Removing leading kilobit (tenth of a byte) from test output due to
* variation causing every few tests the value to change by ± .01
*/
function replaceByte(stdout: string): string {
return stdout.replaceAll(/\d+\.\d+ KiB/g, "xx KiB");
}
/**
* Temp directories are created with random names, so we replace all comments temp dirs in them
*/
function normalizeTempDirs(stdout: string): string {
return stdout.replaceAll(/\/\/.+\/tmp.+/g, "//tmpdir");
}
/**
* Replace thin space characters (U+200A) with regular spaces to normalize output
*/
function replaceThinSpaces(str: string): string {
return str.replaceAll(/\u200a/g, " ");
}
@@ -0,0 +1,34 @@
import * as fs from "node:fs";
import os from "node:os";
import * as path from "node:path";
import { afterEach, beforeEach, vi } from "vitest";
import { removeDir } from "../fs-helpers";
const originalCwd = process.cwd();
export function runInTempDir({ homedir } = { homedir: "./home" }) {
let tmpDir: string;
beforeEach(() => {
// Use realpath because the temporary path can point to a symlink rather than the actual path.
tmpDir = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), "wrangler-tests"))
);
process.chdir(tmpDir);
vi.stubEnv("PWD", tmpDir);
// Override where the home directory is so that we can write our own user config,
// without destroying the real thing.
// The path that is returned from `homedir()` should be absolute.
const absHomedir = path.resolve(tmpDir, homedir);
fs.mkdirSync(absHomedir, { recursive: true });
vi.stubEnv("HOME", absHomedir);
vi.stubEnv("XDG_CONFIG_HOME", path.resolve(absHomedir, ".config"));
});
afterEach(() => {
process.chdir(originalCwd);
removeDir(tmpDir, { fireAndForget: true });
});
}
@@ -0,0 +1,13 @@
// Seeds the `root` directory on the file system with some data. Use in
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
// combination with `dedent` for petty formatting of seeded contents.
export async function seed(files: Record<string, string | Uint8Array>) {
for (const [name, contents] of Object.entries(files)) {
const filePath = path.resolve(name);
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, contents);
}
}
@@ -0,0 +1,75 @@
import * as fs from "node:fs";
import { dirname, relative } from "node:path";
import { formatConfigSnippet } from "../config";
import { PATH_TO_DEPLOY_CONFIG } from "../constants";
import { parseJSONC, parseTOML } from "../parse";
import type { RawConfig } from "../config";
import type { RedirectedRawConfig } from "../config/config";
/** Write a mock wrangler config file to disk. */
export function writeWranglerConfig(
config: RawConfig = {},
path = "./wrangler.toml"
) {
const json = /\.jsonc?$/.test(path);
fs.mkdirSync(dirname(path), { recursive: true });
fs.writeFileSync(
path,
formatConfigSnippet(
{
compatibility_date: "2022-01-12",
name: "test-name",
...config,
},
path,
!!json
),
"utf-8"
);
}
export function writeRedirectedWranglerConfig(
config: RedirectedRawConfig,
path = "./dist/wrangler.json"
) {
const json = /\.jsonc?$/.test(path);
fs.mkdirSync(dirname(path), { recursive: true });
fs.writeFileSync(
path,
formatConfigSnippet(
{
compatibility_date: "2022-01-12",
name: "test-name",
...config,
},
path,
!!json
),
"utf-8"
);
writeDeployRedirectConfig(path);
}
export function writeDeployRedirectConfig(configPath: string) {
const config: RedirectedRawConfig = {
configPath: relative(dirname(PATH_TO_DEPLOY_CONFIG), configPath),
};
fs.mkdirSync(dirname(PATH_TO_DEPLOY_CONFIG), { recursive: true });
fs.writeFileSync(
PATH_TO_DEPLOY_CONFIG,
formatConfigSnippet(config, ".json", true),
"utf-8"
);
}
export function readWranglerConfig(path = "./wrangler.toml"): RawConfig {
if (path.endsWith(".toml")) {
return parseTOML(fs.readFileSync(path, "utf8"), path) as RawConfig;
}
if (path.endsWith(".json") || path.endsWith(".jsonc")) {
return parseJSONC(fs.readFileSync(path, "utf8"), path) as RawConfig;
}
return {};
}
+339
View File
@@ -0,0 +1,339 @@
import { spawnCloudflared } from "./cloudflared";
import { UserError } from "./errors";
import type { Logger } from "./logger";
import type { ChildProcess } from "node:child_process";
/**
* Quick tunnels typically start in 5-15s, but we allow up to 30s for slow networks.
*/
const TUNNEL_STARTUP_TIMEOUT_MS = 30_000;
const TUNNEL_FORCE_KILL_TIMEOUT_MS = 5_000;
const DEFAULT_TUNNEL_EXPIRY_MS = 60 * 60 * 1_000;
const DEFAULT_TUNNEL_EXTENSION_MS = 60 * 60 * 1_000;
const DEFAULT_TUNNEL_MAX_REMAINING_MS = 3 * 60 * 60 * 1_000;
const DEFAULT_TUNNEL_REMINDER_INTERVAL_MS = 10 * 60 * 1_000;
/**
* cloudflared logs the quick tunnel URL to stderr.
*/
const QUICK_TUNNEL_URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
export interface QuickTunnelResult {
mode: "quick";
publicUrl: URL;
}
export interface NamedTunnelResult {
mode: "named";
}
export type TunnelResult = QuickTunnelResult | NamedTunnelResult;
export interface Tunnel {
ready: () => Promise<TunnelResult>;
isOpen: () => boolean;
dispose: () => void;
extendExpiry: (ms?: number) => void;
}
export interface TunnelOptions {
origin: URL;
token?: string;
timeoutMs?: number;
expiryMs?: number;
reminderIntervalMs?: number;
extendHint?: string;
logger?: Pick<Logger, "debug" | "log" | "warn">;
}
/**
* Start a Cloudflare Quick Tunnel for a local dev origin.
*
* Spawns `cloudflared tunnel --url <origin>` and waits for the public URL
* to appear in its stderr output. Returns a controller with a `ready()`
* promise that resolves once the tunnel URL is available, and a `dispose()`
* function to stop the tunnel.
*/
export function startTunnel(options: TunnelOptions): Tunnel {
let disposed = false;
let reminderInterval: ReturnType<typeof setInterval> | undefined;
let expiryTimeout: ReturnType<typeof setTimeout> | undefined;
let expiresAt = 0;
let cloudflaredProcess: ChildProcess | undefined;
const logger = options.logger;
const timeoutMs = options.timeoutMs ?? TUNNEL_STARTUP_TIMEOUT_MS;
const reminderIntervalMs =
options.reminderIntervalMs ?? DEFAULT_TUNNEL_REMINDER_INTERVAL_MS;
const defaultExpiryMs = options.expiryMs ?? DEFAULT_TUNNEL_EXPIRY_MS;
const isNamedTunnel = options.token !== undefined;
const timeFormatter = new Intl.DateTimeFormat(undefined, {
timeStyle: "short",
});
const cloudflaredArgs = isNamedTunnel
? ["tunnel", "--no-autoupdate", "run"]
: ["tunnel", "--no-autoupdate", "--url", options.origin.href];
const cloudflaredPromise = spawnCloudflared(cloudflaredArgs, {
stdio: "pipe",
env: options.token ? { TUNNEL_TOKEN: options.token } : undefined,
skipVersionCheck: true,
logger,
}).then((process) => {
cloudflaredProcess = process;
if (disposed) {
terminateCloudflared(process);
}
return process;
});
const readyPromise = cloudflaredPromise
.then((process) => {
if (isNamedTunnel) {
return { mode: "named" } as const;
}
return waitForQuickTunnelReady(process, timeoutMs, {
logger,
origin: options.origin,
});
})
.then((result) => {
expiresAt = Date.now() + defaultExpiryMs;
scheduleExpiryTimeout();
scheduleReminder(
result.mode === "quick" ? result.publicUrl.origin : undefined
);
return result;
});
function disposeTunnel() {
disposed = true;
clearTunnelTimers();
if (cloudflaredProcess) {
terminateCloudflared(cloudflaredProcess);
}
}
function clearTunnelTimers() {
if (expiryTimeout) {
clearTimeout(expiryTimeout);
expiryTimeout = undefined;
}
if (reminderInterval) {
clearInterval(reminderInterval);
reminderInterval = undefined;
}
}
function scheduleReminder(publicURL: string | undefined) {
if (reminderIntervalMs > 0) {
reminderInterval = setInterval(() => {
if (disposed) {
return;
}
const remainingMs = expiresAt - Date.now();
if (remainingMs <= 0) {
return;
}
logger?.log(
`${
publicURL
? `Tunnel still open, expires in ${formatTunnelDuration(remainingMs)}: ${publicURL}`
: `The tunnel is still open. It expires in ${formatTunnelDuration(remainingMs)}.`
}${options.extendHint ? ` ${options.extendHint}` : ""}`
);
}, reminderIntervalMs);
reminderInterval.unref?.();
}
}
function scheduleExpiryTimeout() {
if (disposed) {
return;
}
if (expiryTimeout) {
clearTimeout(expiryTimeout);
}
expiryTimeout = setTimeout(
() => {
if (disposed) {
return;
}
logger?.log("Tunnel expired. Closing tunnel.");
disposeTunnel();
},
Math.max(0, expiresAt - Date.now())
);
expiryTimeout.unref();
}
function extendExpiry(ms = DEFAULT_TUNNEL_EXTENSION_MS) {
if (disposed || !expiryTimeout || ms <= 0) {
return;
}
const now = Date.now();
const previousExpiresAt = expiresAt;
expiresAt = Math.min(
now + DEFAULT_TUNNEL_MAX_REMAINING_MS,
Math.max(expiresAt, now) + ms
);
const extendedByMs = expiresAt - previousExpiresAt;
if (extendedByMs < ms) {
logger?.log(
`Tunnel expiry extended to the ${formatTunnelDuration(DEFAULT_TUNNEL_MAX_REMAINING_MS)} limit. It now expires at ${timeFormatter.format(new Date(expiresAt))}.`
);
scheduleExpiryTimeout();
return;
}
logger?.log(
`Tunnel expiry extended by ${formatTunnelDuration(extendedByMs)}. It now expires at ${timeFormatter.format(new Date(expiresAt))}.`
);
scheduleExpiryTimeout();
}
return {
ready: () => readyPromise,
isOpen: () => !disposed,
dispose: disposeTunnel,
extendExpiry,
};
}
function formatTunnelDuration(durationMs: number) {
const totalMinutes = Math.max(1, Math.ceil(durationMs / 60_000));
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours === 0) {
return `${minutes}m`;
}
if (minutes === 0) {
return `${hours}h`;
}
return `${hours}h ${minutes}m`;
}
function terminateCloudflared(cloudflared: ChildProcess) {
if (cloudflared.killed) {
return;
}
cloudflared.unref();
cloudflared.kill("SIGTERM");
const forceKillTimer = setTimeout(() => {
if (!cloudflared.killed) {
cloudflared.kill("SIGKILL");
}
}, TUNNEL_FORCE_KILL_TIMEOUT_MS);
forceKillTimer.unref();
}
function waitForQuickTunnelReady(
cloudflared: ChildProcess,
timeoutMs: number,
options: { logger?: Pick<Logger, "debug" | "log" | "warn">; origin: URL }
): Promise<TunnelResult> {
return new Promise<TunnelResult>((resolve, reject) => {
let resolved = false;
let stderrOutput = "";
const logger = options?.logger;
const origin = options?.origin;
const timeoutId = setTimeout(() => {
if (!resolved) {
resolved = true;
terminateCloudflared(cloudflared);
reject(
createTunnelStartupError(
`Timed out waiting for cloudflared to start (${timeoutMs / 1_000}s).`,
stderrOutput,
origin
)
);
}
}, timeoutMs);
timeoutId.unref();
if (cloudflared.stderr) {
cloudflared.stderr.on("data", (data: Buffer) => {
const chunk = data.toString();
stderrOutput += chunk;
logger?.debug("[cloudflared]", chunk.trimEnd());
const match = QUICK_TUNNEL_URL_REGEX.exec(stderrOutput);
if (match && !resolved) {
resolved = true;
clearTimeout(timeoutId);
resolve({ mode: "quick", publicUrl: new URL(match[0]) });
}
});
}
cloudflared.on("error", (error) => {
if (!resolved) {
resolved = true;
clearTimeout(timeoutId);
reject(new Error(`Failed to start cloudflared: ${error.message}`));
}
});
cloudflared.on("exit", (code, signal) => {
if (!resolved) {
resolved = true;
clearTimeout(timeoutId);
const reason = signal
? `terminated by signal ${signal}`
: `exited with code ${code}`;
reject(
createTunnelStartupError(
`cloudflared ${reason} before the tunnel was ready.`,
stderrOutput,
origin
)
);
}
});
});
}
function createTunnelStartupError(
message: string,
stderrOutput: string,
origin: URL
): Error {
const isQuickTunnelRateLimited = stderrOutput.includes(
"429 Too Many Requests"
);
const errorMessage =
`${message}\n` +
`cloudflared output:\n${stderrOutput || "(no output)"}\n\n` +
`The local dev server started at ${origin.href}\n` +
(isQuickTunnelRateLimited
? "Cloudflare Quick Tunnel creation was rate limited. Try again in a few minutes, or use a named tunnel if you need more reliable access."
: `Check the cloudflared output above for more details, and verify that ${origin.href} is reachable from this machine if this keeps happening.`);
if (isQuickTunnelRateLimited) {
return new UserError(errorMessage, { telemetryMessage: false });
}
return new Error(errorMessage);
}
+713
View File
@@ -0,0 +1,713 @@
import type { ApiCredentials } from "./cfetch";
import type { Config } from "./config";
import type {
CustomDomainRoute,
ContainerApp,
ContainerEngine,
Exports,
DurableObjectMigration,
Observability,
Rule,
TailConsumer,
ZoneIdRoute,
ZoneNameRoute,
} from "./config/environment";
import type {
CfAIBinding,
CfAgentMemory,
CfAISearch,
CfAISearchNamespace,
CfAnalyticsEngineDataset,
CfBrowserBinding,
CfD1Database,
CfDispatchNamespace,
CfDurableObject,
CfExports,
CfDurableObjectMigrations,
CfFlagship,
CfHelloWorld,
CfHyperdrive,
CfImagesBinding,
CfKvNamespace,
CfLogfwdrBinding,
CfMediaBinding,
CfMTlsCertificate,
CfModule,
CfPipeline,
CfPlacement,
CfQueue,
CfR2Bucket,
CfRateLimit,
CfArtifacts,
CfSecretsStoreSecrets,
CfSendEmailBindings,
CfService,
CfStreamBinding,
CfTailConsumer,
CfUnsafeBinding,
CfUserLimits,
CfVectorize,
CfVpcNetwork,
CfVpcService,
CfWebSearch,
CfWorkerLoader,
CfWorkflow,
CfScriptFormat,
CfUnsafe,
} from "./worker";
import type { AssetConfig, RouterConfig } from "@cloudflare/workers-shared";
import type { MockAgent } from "undici";
export type Json =
| string
| number
| boolean
| null
| Json[]
| { [id: string]: Json };
export type WorkerMetadataBinding =
// If you add any new binding types here, also add it to safeBindings
// under validateUnsafeBinding in config/validation.ts
// Inherit is _not_ in safeBindings because it is here for API use only
// wrangler supports this per type today through keep_bindings
| { type: "inherit"; name: string }
| { type: "plain_text"; name: string; text: string }
| { type: "secret_text"; name: string; text: string }
| { type: "json"; name: string; json: Json }
| { type: "wasm_module"; name: string; part: string }
| { type: "text_blob"; name: string; part: string }
| { type: "browser"; name: string; raw?: boolean }
| { type: "ai"; name: string; staging?: boolean; raw?: boolean }
| { type: "images"; name: string; raw?: boolean }
| { type: "stream"; name: string }
| { type: "version_metadata"; name: string }
| { type: "data_blob"; name: string; part: string }
| { type: "ai_search_namespace"; name: string; namespace: string }
| { type: "ai_search"; name: string; instance_name: string }
| { type: "websearch"; name: string }
| { type: "agent_memory"; name: string; namespace: string }
| { type: "kv_namespace"; name: string; namespace_id: string; raw?: boolean }
| { type: "media"; name: string }
| {
type: "send_email";
name: string;
destination_address?: string;
allowed_destination_addresses?: string[];
allowed_sender_addresses?: string[];
}
| {
type: "durable_object_namespace";
name: string;
class_name: string;
script_name?: string;
environment?: string;
namespace_id?: string;
}
| {
type: "workflow";
name: string;
workflow_name: string;
class_name: string;
script_name?: string;
raw?: boolean;
}
| {
type: "queue";
name: string;
queue_name: string;
delivery_delay?: number;
raw?: boolean;
}
| {
type: "r2_bucket";
name: string;
bucket_name: string;
jurisdiction?: string;
raw?: boolean;
}
| {
type: "d1";
name: string;
id: string;
internalEnv?: string;
raw?: boolean;
}
| {
type: "vectorize";
name: string;
index_name: string;
internalEnv?: string;
raw?: boolean;
}
| { type: "hyperdrive"; name: string; id: string }
| {
type: "service";
name: string;
service: string;
environment?: string;
entrypoint?: string;
cross_account_grant?: string;
}
| { type: "analytics_engine"; name: string; dataset?: string }
| {
type: "dispatch_namespace";
name: string;
namespace: string;
outbound?: {
worker: {
service: string;
environment?: string;
};
params?: { name: string }[];
};
}
| { type: "mtls_certificate"; name: string; certificate_id: string }
| { type: "pipelines"; name: string; stream?: string; pipeline?: string }
| {
type: "secrets_store_secret";
name: string;
store_id: string;
secret_name: string;
}
| {
type: "artifacts";
name: string;
namespace: string;
}
| {
type: "unsafe_hello_world";
name: string;
enable_timer?: boolean;
}
| {
type: "flagship";
name: string;
app_id: string;
}
| {
type: "ratelimit";
name: string;
namespace_id: string;
simple: { limit: number; period: 10 | 60 };
}
| { type: "vpc_service"; name: string; service_id: string }
| {
type: "vpc_network";
name: string;
tunnel_id?: string;
network_id?: string;
}
| {
type: "worker_loader";
name: string;
}
| {
type: "logfwdr";
name: string;
destination: string;
}
| { type: "assets"; name: string };
export type AssetConfigMetadata = {
html_handling?: AssetConfig["html_handling"];
not_found_handling?: AssetConfig["not_found_handling"];
run_worker_first?: boolean | string[];
_redirects?: string;
_headers?: string;
};
export type AssetsOptions = {
directory: string;
binding?: string;
routerConfig: RouterConfig;
assetConfig: AssetConfig;
_redirects?: string;
_headers?: string;
run_worker_first?: boolean | string[];
};
/**
* The result of validating and resolving the assets directory, before the
* full {@link AssetsOptions} are resolved. Produced by the validation half of
* `getAssetsOptions` and consumed by `resolveAssetOptions`.
*/
export type ValidatedAssetsOptions = {
directory: string;
binding?: string;
directoryExists: boolean;
};
/**
* Information about the assets that should be uploaded
*/
export interface LegacyAssetPaths {
/**
* Absolute path to the root of the project.
*
* This is the directory containing wrangler.toml or cwd if no config.
*/
baseDirectory: string;
/**
* The path to the assets directory, relative to the `baseDirectory`.
*/
assetDirectory: string;
/**
* An array of patterns that match files that should be uploaded.
*/
includePatterns: string[];
/**
* An array of patterns that match files that should not be uploaded.
*/
excludePatterns: string[];
}
// for PUT /accounts/:accountId/workers/scripts/:scriptName
type WorkerMetadataPut = {
/** The name of the entry point module. Only exists when the worker is in the ES module format */
main_module?: string;
/** The name of the entry point module. Only exists when the worker is in the service-worker format */
body_part?: string;
compatibility_date?: string;
compatibility_flags?: string[];
usage_model?: "bundled" | "unbound";
migrations?: CfDurableObjectMigrations;
exports?: CfExports;
capnp_schema?: string;
bindings: WorkerMetadataBinding[];
keep_bindings?: (
| WorkerMetadataBinding["type"]
| "secret_text"
| "secret_key"
)[];
logpush?: boolean;
placement?: CfPlacement;
tail_consumers?: CfTailConsumer[];
streaming_tail_consumers?: CfTailConsumer[];
limits?: CfUserLimits;
assets?: {
jwt: string;
config?: AssetConfigMetadata;
};
observability?: Observability | undefined;
containers?: { class_name: string }[];
package_dependencies?: Array<{
name: string;
packageJsonVersion: string;
installedVersion: string;
}>;
// Allow unsafe.metadata to add arbitrary properties at runtime
[key: string]: unknown;
};
// for POST /accounts/:accountId/workers/:workerName/versions
type WorkerMetadataVersionsPost = WorkerMetadataPut & {
annotations?: Record<string, string>;
};
export type WorkerMetadata = WorkerMetadataPut | WorkerMetadataVersionsPost;
/**
* Structured per-class entry returned by the declarative exports
* reconciliation flow.
*
* The same shape is used for both successful info entries (under
* `exports_reconciliation.info[]`) and blocking errors (under the v4 error
* envelope's `meta.details[]`). The `scenario` tag is the stable, machine-
* readable identifier of which reconciliation case produced the entry.
*/
export type ExportsReconciliationEntryBase = {
class: string;
scenario: string;
message: string;
namespace_id?: string;
};
export type ExportsReconciliationInfo = ExportsReconciliationEntryBase & {
/**
* Workers in the account that still bind to the source class name and must
* be redeployed before the tombstone is safe to remove.
*/
referencing_scripts?: string[];
};
export type ExportsReconciliationWarning = ExportsReconciliationEntryBase;
export type ExportsReconciliationErrorDetail =
ExportsReconciliationEntryBase & {
suggestion?: string;
referencing_scripts?: string[];
};
export type ExportsReconciliationRename = {
from: string;
to: string;
};
export type ExportsReconciliationTransfer = {
class: string;
to: string;
phase: "committed";
};
export type ExportsReconciliationTransferPending = {
class: string;
from: string;
};
/**
* The customer-visible summary of a successful exports reconciliation,
* embedded in the upload response under `exports_reconciliation`. All arrays
* are present, and are empty when there are no entries to report.
*/
export type ExportsReconciliationResult = {
created: string[];
updated: string[];
deleted: string[];
renamed: ExportsReconciliationRename[];
transferred: ExportsReconciliationTransfer[];
transfer_pending: ExportsReconciliationTransferPending[];
warnings: ExportsReconciliationWarning[];
info: ExportsReconciliationInfo[];
removable_entries: string[];
};
export type ServiceMetadataRes = {
id: string;
default_environment: {
environment: string;
created_on: string;
modified_on: string;
script: {
id: string;
tag: string;
tags: string[];
etag: string;
handlers: string[];
modified_on: string;
created_on: string;
migration_tag: string;
usage_model: "bundled" | "unbound";
limits: {
cpu_ms: number;
subrequests: number;
};
compatibility_date: string;
compatibility_flags: string[];
last_deployed_from?: "wrangler" | "dash" | "api";
placement_mode?: "smart";
tail_consumers?: TailConsumer[];
observability?: Observability;
};
};
created_on: string;
modified_on: string;
usage_model: "bundled" | "unbound";
environments: [
{
environment: string;
created_on: string;
modified_on: string;
},
];
};
export type ServiceFetch = (request: Request) => Promise<Response> | Response;
export type File<Contents = string, Path = string> =
| { path: Path } // `path` resolved relative to cwd
| { contents: Contents; path?: Path }; // `contents` used instead, `path` can be specified if needed e.g. for module resolution
export type BinaryFile = File<Uint8Array>; // Note: Node's `Buffer`s are instances of `Uint8Array`
type QueueConsumer = NonNullable<Config["queues"]["consumers"]>[number];
export type Trigger =
| { type: "workers.dev" }
| { type: "route"; pattern: string } // SimpleRoute
| ({ type: "route" } & ZoneIdRoute)
| ({ type: "route" } & ZoneNameRoute)
| ({ type: "route" } & CustomDomainRoute)
| { type: "cron"; cron: string }
| ({ type: "queue-consumer" } & Omit<QueueConsumer, "type">);
type BindingOmit<T> = Omit<T, "binding">;
type NameOmit<T> = Omit<T, "name">;
export type Binding =
| {
type: "plain_text";
value: string;
/**
* Hide this environment variable in output as it may be sensitive
* This is a @deprecated feature to support current Wrangler behaviour, and sensitive
* variables should be marked as `type: secret_text` in future
*/
hidden?: boolean;
}
| { type: "secret_text"; value: string }
| { type: "json"; value: Json }
| ({ type: "kv_namespace" } & BindingOmit<CfKvNamespace>)
| ({ type: "send_email" } & NameOmit<CfSendEmailBindings>)
| { type: "wasm_module"; source: BinaryFile }
| { type: "text_blob"; source: File }
| ({ type: "browser" } & BindingOmit<CfBrowserBinding>)
| ({ type: "ai" } & BindingOmit<CfAIBinding>)
| ({ type: "images" } & BindingOmit<CfImagesBinding>)
| ({ type: "stream" } & BindingOmit<CfStreamBinding>)
| { type: "version_metadata" }
| { type: "data_blob"; source: BinaryFile }
| ({ type: "durable_object_namespace" } & NameOmit<CfDurableObject>)
| ({ type: "workflow" } & BindingOmit<CfWorkflow>)
| ({ type: "queue" } & BindingOmit<CfQueue>)
| ({ type: "r2_bucket" } & BindingOmit<CfR2Bucket>)
| ({ type: "d1" } & BindingOmit<CfD1Database>)
| ({ type: "vectorize" } & BindingOmit<CfVectorize>)
| ({ type: "ai_search_namespace" } & BindingOmit<CfAISearchNamespace>)
| ({ type: "ai_search" } & BindingOmit<CfAISearch>)
| ({ type: "websearch" } & BindingOmit<CfWebSearch>)
| ({ type: "agent_memory" } & BindingOmit<CfAgentMemory>)
| ({ type: "hyperdrive" } & BindingOmit<CfHyperdrive>)
| ({ type: "service" } & BindingOmit<CfService>)
| { type: "fetcher"; fetcher: ServiceFetch }
| ({ type: "analytics_engine" } & BindingOmit<CfAnalyticsEngineDataset>)
| ({ type: "dispatch_namespace" } & BindingOmit<CfDispatchNamespace>)
| ({ type: "mtls_certificate" } & BindingOmit<CfMTlsCertificate>)
| ({ type: "pipeline" } & BindingOmit<CfPipeline>)
| ({ type: "secrets_store_secret" } & BindingOmit<CfSecretsStoreSecrets>)
| ({ type: "artifacts" } & BindingOmit<CfArtifacts>)
| ({ type: "logfwdr" } & NameOmit<CfLogfwdrBinding>)
| ({ type: "unsafe_hello_world" } & BindingOmit<CfHelloWorld>)
| ({ type: "flagship" } & BindingOmit<CfFlagship>)
| ({ type: "ratelimit" } & NameOmit<CfRateLimit>)
| ({ type: "worker_loader" } & BindingOmit<CfWorkerLoader>)
| ({ type: "vpc_service" } & BindingOmit<CfVpcService>)
| ({ type: "vpc_network" } & BindingOmit<CfVpcNetwork>)
| ({ type: "media" } & BindingOmit<CfMediaBinding>)
| ({ type: `unsafe_${string}` } & Omit<CfUnsafeBinding, "name" | "type">)
| { type: "assets" }
| { type: "inherit" };
export interface CfAccount {
/**
* An API token.
*
* @link https://api.cloudflare.com/#user-api-tokens-properties
*/
apiToken: ApiCredentials;
/**
* An account ID.
*/
accountId: string;
}
export type HookValues = string | number | boolean | object | undefined | null;
export type Hook<T extends HookValues, Args extends unknown[] = []> =
| T
| ((...args: Args) => T);
export type AsyncHook<T extends HookValues, Args extends unknown[] = []> =
| Hook<T, Args>
| Hook<Promise<T>, Args>;
export type LogLevel = "debug" | "info" | "log" | "warn" | "error" | "none";
// Duplicate of Miniflare's NodeJSCompatMode to keep workers-utils from depending on Miniflare.
export type NodeJSCompatMode = "als" | "v1" | "v2" | null;
export interface StartDevWorkerInput {
/** The name of the worker. */
name?: string;
/**
* The javascript or typescript entry-point of the worker.
* This is the `main` property of a Wrangler configuration file.
*/
entrypoint?: string;
/** The configuration path of the worker, or a normalized configuration object. */
config?: string | Config;
/** The compatibility date for the workerd runtime. */
compatibilityDate?: string;
/** The compatibility flags for the workerd runtime. */
compatibilityFlags?: string[];
/** Specify the compliance region mode of the Worker. */
complianceRegion?: Config["compliance_region"];
/** Configuration for Python modules. */
pythonModules?: {
/** A list of glob patterns to exclude files from the python_modules directory when bundling. */
exclude?: string[];
};
env?: string;
/**
* An array of paths to the .env files to load for this worker, relative to the project directory.
*
* If not specified, defaults to the standard `.env` files as given from Wrangler.
* The project directory is where the Wrangler configuration file is located or the current working directory otherwise.
*/
envFiles?: string[];
/** The bindings available to the worker. The specified binding type will be exposed to the worker on the `env` object under the same key. */
bindings?: Record<string, Binding>;
/**
* Default bindings that can be overridden by config bindings.
* Useful for injecting environment-specific defaults like CF_PAGES variables.
*/
defaultBindings?: Record<string, Extract<Binding, { type: "plain_text" }>>;
migrations?: DurableObjectMigration[];
exports?: Exports;
containers?: ContainerApp[];
/** The triggers which will cause the worker's exported default handlers to be called. */
triggers?: Trigger[];
tailConsumers?: CfTailConsumer[];
streamingTailConsumers?: CfTailConsumer[];
/**
* Whether Wrangler should send usage metrics to Cloudflare for this project.
*
* When defined this will override any user settings.
* Otherwise, Wrangler will use the user's preference.
*/
sendMetrics?: boolean;
/** Options applying to the worker's build step. Applies to deploy and dev. */
build?: {
/** Whether the worker and its dependencies are bundled. Defaults to true. */
bundle?: boolean;
additionalModules?: CfModule[];
findAdditionalModules?: boolean;
processEntrypoint?: boolean;
/** Specifies types of modules matched by globs. */
moduleRules?: Rule[];
/** Replace global identifiers with constant expressions, e.g. { debug: 'true', version: '"1.0.0"' }. Only takes effect if bundle: true. */
define?: Record<string, string>;
/** Alias modules */
alias?: Record<string, string>;
/** Whether the bundled worker is minified. Only takes effect if bundle: true. */
minify?: boolean;
/** Whether to keep function names after JavaScript transpilations. */
keepNames?: boolean;
/** Options controlling a custom build step. */
custom?: {
/** Custom shell command to run before bundling. Runs even if bundle. */
command?: string;
/** The cwd to run the command in. */
workingDirectory?: string;
/** Filepath(s) to watch for changes. Upon changes, the command will be rerun. */
watch?: string | string[];
};
jsxFactory?: string;
jsxFragment?: string;
tsconfig?: string;
nodejsCompatMode?: Hook<NodeJSCompatMode, [Config]>;
moduleRoot?: string;
};
/** Options applying to the worker's development preview environment. */
dev?: {
/** Options applying to the worker's inspector server. False disables the inspector server. */
inspector?: { hostname?: string; port?: number; secure?: boolean } | false;
/** Whether the worker runs on the edge or locally. */
remote?: boolean | "minimal";
/** Cloudflare Account credentials. Can be provided upfront or as a function which will be called only when required. */
auth?: AsyncHook<CfAccount, [Pick<Config, "account_id">]>;
/** Whether local storage (KV, Durable Objects, R2, D1, etc) is persisted. You can also specify the directory to persist data to. Set to `false` to disable persistence. */
persist?: string | false;
/** Controls which logs are logged. */
logLevel?: LogLevel;
/** Whether the worker server restarts upon source/config file changes. */
watch?: boolean;
/** Whether a script tag is inserted on text/html responses which will reload the page upon file changes. Defaults to false. */
liveReload?: boolean;
/** The local address to reach your worker. Applies to remote: true (remote mode) and remote: false (local mode). */
server?: {
hostname?: string;
port?: number;
secure?: boolean;
httpsKeyPath?: string;
httpsCertPath?: string;
};
/** Controls what request.url looks like inside the worker. */
origin?: { hostname?: string; secure?: boolean };
/** A hook for outbound fetch calls from within the worker. */
outboundService?: ServiceFetch;
/** An undici MockAgent to declaratively mock fetch calls to particular resources. */
mockFetch?: MockAgent;
testScheduled?: boolean;
/** Treat this as the primary worker in a multiworker setup (i.e. the first Worker in Miniflare's options) */
multiworkerPrimary?: boolean;
/** Whether to infer the local request origin from configured routes. */
inferOriginFromRoutes?: boolean;
/** Whether local requests should be matched against configured routes. */
routeRequestsByRoutes?: boolean;
containerBuildId?: string;
/** Whether to build and connect to containers during local dev. Requires Docker daemon to be running. Defaults to true. */
enableContainers?: boolean;
/** Path to the dev registry directory */
registry?: string;
/** Path to the docker executable. Defaults to 'docker' */
dockerPath?: string;
/** Options for the container engine */
containerEngine?: ContainerEngine;
/** Re-generate your worker types when your Wrangler configuration file changes */
generateTypes?: boolean;
/**
* Experimental: Use `cloudflare.config.ts` + optional `wrangler.config.ts`
* instead of `wrangler.json[c]` / `wrangler.toml`.
*/
experimentalNewConfig?: boolean;
/** Tunnel configuration for this dev session. */
tunnel?: {
enabled: boolean;
name?: string;
};
};
legacy?: {
site?: Hook<Config["site"], [Config]>;
};
unsafe?: Omit<CfUnsafe, "bindings">;
assets?: string;
experimental?: Record<string, never>;
}
/**
* An entry point for the Worker.
*
* It consists not just of a `file`, but also of a `directory` that is used to resolve relative paths.
*/
export type Entry = {
/** A worker's entrypoint */
file: string;
/** A worker's directory. Usually where the Wrangler configuration file is located */
projectRoot: string;
/** The path to the config file, if it exists. */
configPath: string | undefined;
/** Is this a module worker or a service worker? */
format: CfScriptFormat;
/** The directory that contains all of a `--no-bundle` worker's modules. Usually `${directory}/src`. Defaults to path.dirname(file) */
moduleRoot: string;
/**
* A worker's name
*/
name?: string | undefined;
/** Export from a Worker's entrypoint */
exports: string[];
};
@@ -0,0 +1,64 @@
import * as timersPromises from "node:timers/promises";
import checkForUpdate from "update-check";
import type { Result } from "update-check";
// Safety-net timeout for the update check network request.
// The `update-check` library has its own 2s socket timeout, but if the first
// request gets a 4xx it retries with auth — potentially doubling the wait.
// This caps the total wall-clock time for the entire operation.
const UPDATE_CHECK_TIMEOUT_MS = 3_000;
// Sentinel value used to distinguish a timeout from the library returning null
// (which means "already up-to-date").
const TIMED_OUT: unique symbol = Symbol("timed_out");
export type NpmVersionCheckResult =
| { status: "up-to-date" }
| { status: "update-available"; latest: string }
| { status: "failed" };
/**
* Checks if a newer version of a package is available on npm.
*
* Uses the `update-check` library to query the npm registry for the latest
* version. The dist tag used for comparison depends on the current version —
* "beta" for pre-release versions (0.0.0-*) and "latest" for stable versions.
*
* @param name - The npm package name to check
* @param version - The current version to compare against
* @returns A discriminated result:
* - `{ status: "update-available", latest: string }` if a newer version exists
* - `{ status: "up-to-date" }` if the installed version is already the latest
* - `{ status: "failed" }` if the check could not be completed (network error, timeout, etc.)
*/
export async function fetchLatestNpmVersion(
name: string,
version: string
): Promise<NpmVersionCheckResult> {
let result: Result | null | typeof TIMED_OUT = null;
try {
// Race with a timeout as a safety net — the library's own 2s socket
// timeout handles most cases, but the auth-retry path can take up to 4s.
result = await Promise.race([
checkForUpdate(
{ name, version },
{
distTag: version.startsWith("0.0.0") ? "beta" : "latest",
}
),
timersPromises.setTimeout(UPDATE_CHECK_TIMEOUT_MS, TIMED_OUT, {
ref: false,
}),
]);
} catch {
return { status: "failed" };
}
if (result === TIMED_OUT) {
return { status: "failed" };
}
if (result === null) {
return { status: "up-to-date" };
}
return { status: "update-available", latest: result.latest };
}
+143
View File
@@ -0,0 +1,143 @@
import { basename, resolve } from "node:path";
import { getCIOverrideName } from "./environment-variables/misc-variables";
import { parsePackageJSON, readFileSync } from "./parse";
const invalidWorkerNameCharsRegex = /[^a-z0-9- ]/g;
const invalidWorkerNameStartEndRegex = /^(-+)|(-+)$/g;
const workerNameLengthLimit = 63;
/**
* Checks whether the provided worker name is valid, this means that:
* - the name is not empty
* - the name doesn't start nor ends with a dash
* - the name doesn't contain special characters besides dashes
* - the name is not longer than 63 characters
*
* See: https://developers.cloudflare.com/workers/configuration/routing/workers-dev/#limitations
*
* @param input The name to check
* @returns Object indicating whether the name is valid, and if not a cause indicating why it isn't
*/
export function checkWorkerNameValidity(
input: string
): { valid: false; cause: string } | { valid: true } {
if (!input) {
return {
valid: false,
cause: "Worker names cannot be empty.",
};
}
if (input.match(invalidWorkerNameStartEndRegex)) {
return {
valid: false,
cause: "Worker names cannot start or end with a dash.",
};
}
if (input.match(invalidWorkerNameCharsRegex)) {
return {
valid: false,
cause:
"Project names must only contain lowercase characters, numbers, and dashes.",
};
}
if (input.length > workerNameLengthLimit) {
return {
valid: false,
cause: "Project names must be less than 63 characters.",
};
}
return { valid: true };
}
/**
* Given an input string it converts it to a valid worker name.
*
* A worker name is valid if:
* - the name is not empty
* - the name doesn't start nor ends with a dash
* - the name doesn't contain special characters besides dashes
* - the name is not longer than 63 characters
*
* See: https://developers.cloudflare.com/workers/configuration/routing/workers-dev/#limitations
*
* @param input The input to convert
* @returns The input itself if it was already valid, the input converted to a valid worker name otherwise
*/
export function toValidWorkerName(input: string): string {
if (checkWorkerNameValidity(input).valid) {
return input;
}
input = input
// Replace all underscores with dashes
.replaceAll("_", "-")
// Replace all the special characters (besides dashes) with dashes
.replace(invalidWorkerNameCharsRegex, "-")
// Remove invalid start/end dashes
.replace(invalidWorkerNameStartEndRegex, "")
// If the name is longer than the limit let's truncate it to that
.slice(0, workerNameLengthLimit);
if (!input.length) {
// If we've emptied the whole name let's replace it with a fallback value
return "my-worker";
}
return input;
}
/**
* Derives a valid worker name from a project name (or worker name) and project path.
*
* The name is determined by (in order of precedence):
* 1. The WRANGLER_CI_OVERRIDE_NAME environment variable (for CI environments)
* 2. The provided project/worker name (if non-empty)
* 3. The directory basename of the project path
*
* The resulting name is sanitized to be a valid worker name.
*
* @param projectOrWorkerName An optional project or worker name to use
* @param projectPath The path to the project directory (used as fallback)
* @returns A valid worker name
*/
export function getWorkerName(
projectOrWorkerName = "",
projectPath: string
): string {
const rawName =
getCIOverrideName() ?? (projectOrWorkerName || basename(projectPath));
return toValidWorkerName(rawName);
}
/**
* Derives a valid worker name from a project directory.
*
* The name is determined by (in order of precedence):
* 1. The WRANGLER_CI_OVERRIDE_NAME environment variable (for CI environments)
* 2. The `name` field from package.json in the project directory
* 3. The directory basename
*
* The resulting name is sanitized to be a valid worker name.
*
* @param projectPath The path to the project directory
* @returns A valid worker name
*/
export function getWorkerNameFromProject(projectPath: string): string {
const packageJsonPath = resolve(projectPath, "package.json");
let packageJsonName: string | undefined;
try {
const packageJson = parsePackageJSON(
readFileSync(packageJsonPath),
packageJsonPath
);
packageJsonName = packageJson.name;
} catch {}
return getWorkerName(packageJsonName, projectPath);
}
+553
View File
@@ -0,0 +1,553 @@
import type {
CacheOptions,
Exports,
Observability,
Route,
} from "./config/environment";
import type { INHERIT_SYMBOL } from "./constants";
import type { Json, WorkerMetadata } from "./types";
import type { AssetConfig, RouterConfig } from "@cloudflare/workers-shared";
/**
* The type of Worker
*/
export type CfScriptFormat = "modules" | "service-worker";
/**
* A module type.
*/
export type CfModuleType =
| "esm"
| "commonjs"
| "compiled-wasm"
| "text"
| "buffer"
| "python"
| "python-requirement";
/**
* An imported module.
*/
export interface CfModule {
/**
* The module name.
*
* @example
* './src/index.js'
*/
name: string;
/**
* The absolute path of the module on disk, or `undefined` if this is a
* virtual module. Used as the source URL for this module, so source maps are
* correctly resolved.
*
* @example
* '/path/to/src/index.js'
*/
filePath: string | undefined;
/**
* The module content, usually JavaScript or WASM code.
*
* @example
* export default {
* async fetch(request) {
* return new Response('Ok')
* }
* }
*/
content: string | Buffer<ArrayBuffer>;
/**
* An optional sourcemap for this module if it's of a ESM or CJS type, this will only be present
* if we're deploying with sourcemaps enabled. Since we copy extra modules that aren't bundled
* we need to also copy the relevant sourcemaps into the final out directory.
*/
sourceMap?: CfWorkerSourceMap;
/**
* The module type.
*
* If absent, will default to the main module's type.
*/
type?: CfModuleType;
}
/**
* A map of variable names to values.
*/
export interface CfVars {
[key: string]: string | Json;
}
/**
* A KV namespace.
*/
export interface CfKvNamespace {
binding: string;
id?: string | typeof INHERIT_SYMBOL;
remote?: boolean;
raw?: boolean;
}
/**
* A binding to send email.
*/
export type CfSendEmailBindings = {
name: string;
remote?: boolean;
} & (
| { destination_address?: string }
| { allowed_destination_addresses?: string[] }
| { allowed_sender_addresses?: string[] }
);
/**
* A binding to a wasm module (in service-worker format)
*/
export interface CfWasmModuleBindings {
[key: string]: string | Uint8Array<ArrayBuffer>;
}
/**
* A binding to a text blob (in service-worker format)
*/
export interface CfTextBlobBindings {
[key: string]: string;
}
/**
* A binding to a browser
*/
export interface CfBrowserBinding {
binding: string;
raw?: boolean;
remote?: boolean;
}
/**
* A binding to the AI project
*/
export interface CfAIBinding {
binding: string;
staging?: boolean;
remote?: boolean;
raw?: boolean;
}
/**
* A binding to Cloudflare Images
*/
export interface CfImagesBinding {
binding: string;
raw?: boolean;
remote?: boolean;
}
/**
* A binding to Cloudflare Media Transformations
*/
export interface CfMediaBinding {
binding: string;
remote?: boolean;
}
/**
* A binding to Cloudflare Stream
*/
export interface CfStreamBinding {
binding: string;
remote?: boolean;
}
/**
* A binding to the Worker Version's metadata
*/
export interface CfVersionMetadataBinding {
binding: string;
}
/**
* A binding to a data blob (in service-worker format)
*/
export interface CfDataBlobBindings {
[key: string]: string | Uint8Array<ArrayBuffer>;
}
/**
* A Durable Object.
*/
export interface CfDurableObject {
name: string;
class_name: string;
script_name?: string;
environment?: string;
}
export interface CfWorkflow {
name: string;
class_name: string;
binding: string;
script_name?: string;
remote?: boolean;
raw?: boolean;
limits?: {
steps?: number;
};
schedules?: string | string[];
}
export interface CfQueue {
binding: string;
queue_name: string;
delivery_delay?: number;
remote?: boolean;
raw?: boolean;
}
export interface CfR2Bucket {
binding: string;
bucket_name?: string | typeof INHERIT_SYMBOL;
jurisdiction?: string;
remote?: boolean;
raw?: boolean;
}
// TODO: figure out if this is duplicated in packages/wrangler/src/config/environment.ts
export interface CfD1Database {
binding: string;
database_id?: string | typeof INHERIT_SYMBOL;
database_name?: string;
preview_database_id?: string;
database_internal_env?: string;
migrations_table?: string;
migrations_dir?: string;
migrations_pattern?: string;
remote?: boolean;
raw?: boolean;
}
export interface CfVectorize {
binding: string;
index_name: string;
raw?: boolean;
remote?: boolean;
}
export interface CfAISearchNamespace {
binding: string;
namespace: string | typeof INHERIT_SYMBOL;
remote?: boolean;
}
export interface CfAISearch {
binding: string;
instance_name: string;
remote?: boolean;
}
export interface CfWebSearch {
binding: string;
remote?: boolean;
}
export interface CfAgentMemory {
binding: string;
namespace: string | typeof INHERIT_SYMBOL;
remote?: boolean;
}
export interface CfSecretsStoreSecrets {
binding: string;
store_id: string;
secret_name: string;
}
export interface CfArtifacts {
binding: string;
namespace: string;
remote?: boolean;
}
export interface CfHelloWorld {
binding: string;
enable_timer?: boolean;
}
export interface CfFlagship {
binding: string;
app_id: string;
remote?: boolean;
}
export interface CfWorkerLoader {
binding: string;
}
export interface CfRateLimit {
name: string;
namespace_id: string;
simple: {
limit: number;
period: 10 | 60;
};
}
export interface CfHyperdrive {
binding: string;
id: string;
localConnectionString?: string;
}
export interface CfDevPluginCfg {
plugin: {
/**
* Package is the bare specifier of the package that exposes plugins to integrate into Miniflare via a named `plugins` export.
* @example "@cloudflare/my-external-miniflare-plugin"
*/
package: string;
/**
* Plugin is the name of the plugin exposed by the package.
* @example "my-unsafe-plugin"
*/
name: string;
};
/**
* dev-only options to pass to the plugin.
*/
options?: Record<string, unknown>;
}
export interface CfService {
binding: string;
service: string;
environment?: string;
entrypoint?: string;
props?: Record<string, unknown>;
remote?: boolean;
cross_account_grant?: string;
dev?: CfDevPluginCfg;
}
export interface CfVpcService {
binding: string;
service_id: string;
remote?: boolean;
}
export interface CfVpcNetwork {
binding: string;
tunnel_id?: string;
network_id?: string;
remote?: boolean;
}
export interface CfAnalyticsEngineDataset {
binding: string;
dataset?: string;
}
export interface CfDispatchNamespace {
binding: string;
namespace: string;
outbound?: {
service: string;
environment?: string;
parameters?: string[];
};
remote?: boolean;
}
export interface CfMTlsCertificate {
binding: string;
certificate_id: string;
remote?: boolean;
}
export interface CfLogfwdr {
bindings: CfLogfwdrBinding[];
}
export interface CfLogfwdrBinding {
name: string;
destination: string;
}
export interface CfAssetsBinding {
binding: string;
}
export interface CfPipeline {
binding: string;
stream?: string;
pipeline?: string;
remote?: boolean;
}
export interface CfUnsafeBinding {
name: string;
type: string;
dev?: CfDevPluginCfg;
}
type CfUnsafeMetadata = Record<string, unknown>;
export type CfCapnp =
| {
base_path?: never;
source_schemas?: never;
compiled_schema: string;
}
| {
base_path: string;
source_schemas: string[];
compiled_schema?: never;
};
export interface CfUnsafe {
bindings: CfUnsafeBinding[] | undefined;
metadata: CfUnsafeMetadata | undefined;
capnp: CfCapnp | undefined;
}
export interface CfDurableObjectMigrations {
old_tag?: string;
new_tag: string;
steps: {
new_classes?: string[];
new_sqlite_classes?: string[];
renamed_classes?: {
from: string;
to: string;
}[];
deleted_classes?: string[];
}[];
}
/**
* The declarative `exports` map keyed by class name.
*
* Durable Objects can only be configured by `exports` or `migrations`, not both.
*/
export type CfExports = Exports;
export type CfPlacement =
| { mode: "smart"; hint?: string }
| { mode?: "targeted"; region: string }
| { mode?: "targeted"; host: string }
| { mode?: "targeted"; hostname: string };
export interface CfTailConsumer {
service: string;
environment?: string;
}
export interface CfUserLimits {
cpu_ms?: number;
subrequests?: number;
}
/**
* Options for creating a `CfWorker`.
*/
export interface CfWorkerInit {
/**
* The name of the worker.
*/
name: string | undefined;
/**
* The entrypoint module.
*/
main: CfModule;
/**
* The list of additional modules.
*/
modules: CfModule[] | undefined;
/**
* The list of source maps to include on upload.
*/
sourceMaps: CfWorkerSourceMap[] | undefined;
containers: { class_name: string }[] | undefined;
migrations: CfDurableObjectMigrations | undefined;
/**
* Declarative exports configuration. Durable Object entries are sent instead
* of `migrations`.
*/
exports: CfExports | undefined;
compatibility_date: string | undefined;
compatibility_flags: string[] | undefined;
keepVars: boolean | undefined;
keepSecrets: boolean | undefined;
keepBindings?: WorkerMetadata["keep_bindings"];
logpush: boolean | undefined;
placement: CfPlacement | undefined;
tail_consumers: CfTailConsumer[] | undefined;
streaming_tail_consumers?: CfTailConsumer[] | undefined;
limits: CfUserLimits | undefined;
annotations?: Record<string, string | undefined>;
keep_assets?: boolean | undefined;
assets:
| {
jwt: string;
routerConfig: RouterConfig;
assetConfig: AssetConfig;
run_worker_first?: string[] | boolean;
_redirects?: string;
_headers?: string;
}
| undefined;
observability: Observability | undefined;
cache: CacheOptions | undefined;
/**
* The list of npm package dependencies collected from the project's package.json.
* Sent to the API for instrumentation and analytics purposes.
*/
package_dependencies?:
| Array<{
name: string;
packageJsonVersion: string;
installedVersion: string;
}>
| undefined;
}
export interface CfWorkerContext {
env: string | undefined;
zone: string | undefined;
host: string | undefined;
routes: Route[] | undefined;
sendMetrics: boolean | undefined;
}
export interface CfWorkerSourceMap {
/**
* The name of the source map.
*
* @example
* 'out.js.map'
*/
name: string;
/**
* The content of the source map, which is a JSON object described by the v3
* spec.
*
* @example
* {
* "version" : 3,
* "file": "out.js",
* "sourceRoot": "",
* "sources": ["foo.js", "bar.js"],
* "sourcesContent": [null, null],
* "names": ["src", "maps", "are", "fun"],
* "mappings": "A,AAAB;;ABCDE;"
* }
*/
content: string | Buffer;
}
@@ -0,0 +1,111 @@
import fs from "node:fs";
import path from "node:path";
import { onExit } from "signal-exit";
import { removeDirSync } from "./fs-helpers";
/**
* A short-lived directory. Automatically removed when the process exits, but
* can be removed earlier by calling `remove()`.
*/
export interface EphemeralDirectory {
path: string;
remove(): void;
}
/**
* Gets the path to the project's `.wrangler` folder.
*/
export function getWranglerHiddenDirPath(
projectRoot: string | undefined
): string {
projectRoot ??= process.cwd();
return path.join(projectRoot, ".wrangler");
}
/**
* Maximum age of a `.wrangler/tmp/*` entry before we treat it as orphaned and
* eligible for the startup sweep. Tuned to avoid touching any directory a
* concurrent wrangler session might still own.
*/
const STALE_WRANGLER_TMP_DIR_MS = 24 * 60 * 60 * 1000;
/**
* Tracks tmp roots already swept by this process so repeated
* `getWranglerTmpDir` calls within one wrangler invocation only scan once.
*/
const sweptTmpRoots = new Set<string>();
/**
* Removes stale `.wrangler/tmp/*` entries left behind by previous wrangler
* sessions that exited abnormally (SIGKILL, OOM, host crash) and so missed
* the `signal-exit` cleanup. Runs at most once per tmp root per process.
*
* Exported for tests.
*/
export function sweepStaleWranglerTmpDirs(tmpRoot: string): void {
if (sweptTmpRoots.has(tmpRoot)) {
return;
}
sweptTmpRoots.add(tmpRoot);
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(tmpRoot, { withFileTypes: true });
} catch {
return;
}
const cutoff = Date.now() - STALE_WRANGLER_TMP_DIR_MS;
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const entryPath = path.join(tmpRoot, entry.name);
try {
if (fs.statSync(entryPath).mtimeMs < cutoff) {
removeDirSync(entryPath);
}
} catch {
/* best effort - another process may have removed it first */
}
}
}
/**
* Gets a temporary directory in the project's `.wrangler` folder with the
* specified prefix. We create temporary directories in `.wrangler` as opposed
* to the OS's temporary directory to avoid issues with different drive letters
* on Windows. For example, when `esbuild` outputs a file to a different drive
* than the input sources, the generated source maps are incorrect.
*/
export function getWranglerTmpDir(
projectRoot: string | undefined,
prefix: string,
cleanup = true
): EphemeralDirectory {
const tmpRoot = path.join(getWranglerHiddenDirPath(projectRoot), "tmp");
fs.mkdirSync(tmpRoot, { recursive: true });
sweepStaleWranglerTmpDirs(tmpRoot);
const tmpPrefix = path.join(tmpRoot, `${prefix}-`);
const tmpDir = fs.realpathSync(fs.mkdtempSync(tmpPrefix));
const cleanupDir = () => {
if (cleanup) {
try {
removeDirSync(tmpDir);
} catch {
/* best effort */
}
}
};
const removeExitListener = onExit(cleanupDir);
return {
path: tmpDir,
remove() {
removeExitListener();
cleanupDir();
},
};
}
+209
View File
@@ -0,0 +1,209 @@
import os from "node:os";
import path from "node:path";
/**
* @module
*
* A small, pure-ESM, dependency-free reimplementation of the subset of
* [`xdg-app-paths`](https://www.npmjs.com/package/xdg-app-paths) that this
* repository relies on.
*
* `xdg-app-paths` (and its `xdg-portable` / `os-paths` dependencies) ship as
* CommonJS. When the consuming package is bundled to ESM (e.g. via tsup or when
* a downstream consumer runs through Vite), the CJS `require()` calls are
* shimmed and throw at runtime ("Dynamic require of 'path' is not supported").
* Vendoring the resolution logic here keeps the behaviour identical while
* remaining pure ESM.
*
* The path resolution intentionally mirrors `xdg-app-paths@8` →
* `xdg-portable@10` → `os-paths@7` exactly so that the resolved config/cache
* directories (which hold, amongst other things, Wrangler's auth credentials)
* do not change for existing users.
*
* ---
*
* This implementation is adapted from `xdg-app-paths`, `xdg-portable` and
* `os-paths` (https://github.com/rivy/js.xdg-app-paths), published under the
* MIT License:
*
* Copyright (c) Roy Ivy III <rivy.dev@gmail.com>
* Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (sindresorhus.com)
*
* Permission is hereby granted, free of charge, to any person obtaining a
* copy of this software and associated documentation files (the "Software"),
* to deal in the Software without restriction, including without limitation
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
* DEALINGS IN THE SOFTWARE.
*/
function isWindows(): boolean {
return /^win/i.test(process.platform);
}
function isMacOS(): boolean {
return /^darwin$/i.test(process.platform);
}
function isNonEmpty(value: string | undefined): value is string {
return !!value;
}
/**
* Read an OS/XDG environment variable. Accessed dynamically (mirroring
* `xdg-portable`'s `env.get()`) so these process-level variables don't need to
* be declared as build inputs (`turbo/no-undeclared-env-vars`) — they are not
* Wrangler configuration variables.
*/
function getEnv(name: string): string | undefined {
return process.env[name];
}
/** Mirrors `os-paths`' `normalizePath`. */
function normalizePath(p: string | undefined): string | undefined {
return p ? path.normalize(path.join(p, ".")) : undefined;
}
/** Mirrors `os-paths`' `home()`. */
function homeDir(): string | undefined {
if (isWindows()) {
const priorityList = [
os.homedir(),
getEnv("USERPROFILE"),
getEnv("HOME"),
// NOTE: `os-paths@7` uses `||` here (not `&&`), so a home directory is
// derived even when only one of HOMEDRIVE/HOMEPATH is set. This is
// preserved deliberately for byte-for-byte parity — see the parity
// tests, which compare this branch against the real package.
getEnv("HOMEDRIVE") || getEnv("HOMEPATH")
? path.join(getEnv("HOMEDRIVE") ?? "", getEnv("HOMEPATH") ?? "")
: undefined,
];
return normalizePath(priorityList.find(isNonEmpty));
}
return normalizePath(os.homedir() || getEnv("HOME"));
}
function joinToBase(
base: string | undefined,
segments: string[]
): string | undefined {
return base ? path.join(base, ...segments) : undefined;
}
/** Mirrors `os-paths`' `temp()`. */
function tempDir(): string {
if (isWindows()) {
const fallback = "C:\\Temp";
const priorityListLazy: Array<() => string | undefined> = [
() => os.tmpdir(),
() => getEnv("TEMP"),
() => getEnv("TMP"),
() => joinToBase(getEnv("LOCALAPPDATA"), ["Temp"]),
() => joinToBase(homeDir(), ["AppData", "Local", "Temp"]),
() => joinToBase(getEnv("ALLUSERSPROFILE"), ["Temp"]),
() => joinToBase(getEnv("SystemRoot"), ["Temp"]),
() => joinToBase(getEnv("windir"), ["Temp"]),
() => joinToBase(getEnv("SystemDrive"), ["\\", "Temp"]),
];
const found = priorityListLazy.find((fn) => isNonEmpty(fn()));
return (found && normalizePath(found())) || fallback;
}
const fallback = "/tmp";
const priorityList = [
os.tmpdir(),
getEnv("TMPDIR"),
getEnv("TEMP"),
getEnv("TMP"),
];
return normalizePath(priorityList.find(isNonEmpty)) || fallback;
}
/** Mirrors `xdg-portable`'s `baseDir()`. */
function baseDir(): string {
return homeDir() || tempDir();
}
/** Mirrors `xdg-portable`'s `valOrPath()`. */
function valOrPath(value: string | undefined, segments: string[]): string {
return value || path.join(...segments);
}
function windowsAppData(): string {
return valOrPath(getEnv("APPDATA"), [baseDir(), "AppData", "Roaming"]);
}
function windowsLocalAppData(): string {
return valOrPath(getEnv("LOCALAPPDATA"), [baseDir(), "AppData", "Local"]);
}
/** XDG config base directory (`xdg-portable`'s `config()`). */
function xdgConfig(): string {
if (isMacOS()) {
return valOrPath(getEnv("XDG_CONFIG_HOME"), [
baseDir(),
"Library",
"Preferences",
]);
}
if (isWindows()) {
return valOrPath(getEnv("XDG_CONFIG_HOME"), [
windowsAppData(),
"xdg.config",
]);
}
return valOrPath(getEnv("XDG_CONFIG_HOME"), [baseDir(), ".config"]);
}
/** XDG cache base directory (`xdg-portable`'s `cache()`). */
function xdgCache(): string {
if (isMacOS()) {
return valOrPath(getEnv("XDG_CACHE_HOME"), [
baseDir(),
"Library",
"Caches",
]);
}
if (isWindows()) {
return valOrPath(getEnv("XDG_CACHE_HOME"), [
windowsLocalAppData(),
"xdg.cache",
]);
}
return valOrPath(getEnv("XDG_CACHE_HOME"), [baseDir(), ".cache"]);
}
export interface XDGAppPaths {
/** The XDG-compliant config directory for the application. */
config(): string;
/** The XDG-compliant cache directory for the application. */
cache(): string;
}
/**
* Resolve XDG-compliant application paths for the given application name.
*
* Equivalent to `xdgAppPaths(name)` with the default `isolated: true`, i.e. the
* application name is appended as the final path segment.
*/
export function xdgAppPaths(name: string): XDGAppPaths {
// `xdg-app-paths` runs the name through `path.parse(name).name`, which (for
// example) leaves `.wrangler` untouched since a leading dot is not treated
// as a file extension.
const segment = path.parse(name).name;
return {
config: () => path.join(xdgConfig(), segment),
cache: () => path.join(xdgCache(), segment),
};
}