chore: import upstream snapshot with attribution
Deploy (to testing) and Test Playground Preview Worker / Deploy Playground Preview Worker (testing) (push) Has been skipped
Deploy Workers Shared Staging / Deploy Workers Shared Staging (push) Failing after 0s
Prerelease / build (push) Has been skipped
Handle Changesets / Handle Changesets (push) Has been cancelled
Semgrep OSS scan / semgrep-oss (push) Has been cancelled
Deploy (to testing) and Test Playground Preview Worker / Deploy Playground Preview Worker (testing) (push) Has been skipped
Deploy Workers Shared Staging / Deploy Workers Shared Staging (push) Failing after 0s
Prerelease / build (push) Has been skipped
Handle Changesets / Handle Changesets (push) Has been cancelled
Semgrep OSS scan / semgrep-oss (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export function assertNever(_value: never) {}
|
||||
@@ -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."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
});
|
||||
@@ -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)`;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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("");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user