Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/wrangler-public-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"wrangler": patch
"@cloudflare/workers-utils": patch
---

Fix unresolved imports in Wrangler's published TypeScript declarations

Projects that check dependency declarations no longer need Wrangler's private build dependencies to resolve its public types. Inherited bindings remain compatible between Wrangler and the shared utilities used by the Vite and Vitest plugins.
32 changes: 27 additions & 5 deletions packages/workers-utils/src/construct-wrangler-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import type {
} from "./config/environment";
import type { WorkerMetadata } from "./types";
import type { AssetConfig } from "@cloudflare/workers-shared";
import type { Cloudflare } from "cloudflare";

type RoutesRes = {
id: string;
Expand All @@ -18,6 +17,8 @@ type RoutesRes = {
script: string;
}[];

// Keep SDK response shapes structural so consumers do not need the SDK's
// declarations. The type assertions in the tests track changes to these shapes.
interface APIWorkerConfig {
/* sourced from https://developers.cloudflare.com/api/resources/workers/subresources/scripts/methods/list/ */
name: string; // property renamed from `id`...
Expand All @@ -31,16 +32,37 @@ interface APIWorkerConfig {
migration_tag?: string;

/* sourced from https://developers.cloudflare.com/api/resources/workers/subresources/domains/methods/list/ */
domains: Cloudflare.Workers.Domain[];
domains: {
id?: string;
environment?: string;
hostname?: string;
service?: string;
zone_id?: string;
zone_name?: string;
}[];
/* sourced from https://developers.cloudflare.com/api/resources/workers/subresources/scripts/subresources/schedules/methods/get/ */
schedules: Cloudflare.Workers.Scripts.Schedules.ScheduleGetResponse.Schedule[];
schedules: {
cron: string;
created_on?: string;
modified_on?: string;
}[];

/* 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;
observability:
| {
enabled?: boolean;
head_sampling_rate?: number;
logs?: {
enabled?: boolean;
head_sampling_rate?: number;
invocation_logs?: boolean;
};
}
| undefined;
limits: { cpu_ms?: number; subrequests?: number } | undefined;
placement: Cloudflare.Workers.Beta.Workers.Version.Placement | undefined;
placement: { mode?: "smart" } | undefined;
subdomain: {
enabled: boolean;
previews_enabled: boolean;
Expand Down
18 changes: 17 additions & 1 deletion packages/workers-utils/tests/construct-wrangler-config.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it } from "vitest";
import { describe, expectTypeOf, it } from "vitest";
import { constructWranglerConfig } from "../src/construct-wrangler-config";
import type { Cloudflare } from "cloudflare";

type APIWorkerConfig = Parameters<typeof constructWranglerConfig>[0];

Expand Down Expand Up @@ -34,6 +35,21 @@ function makeWorkerConfig(
}

describe("constructWranglerConfig", () => {
it("preserves the SDK response types without exposing the SDK", () => {
expectTypeOf<APIWorkerConfig["domains"]>().toEqualTypeOf<
Cloudflare.Workers.Domain[]
>();
expectTypeOf<APIWorkerConfig["schedules"]>().toEqualTypeOf<
Cloudflare.Workers.Scripts.Schedules.ScheduleGetResponse.Schedule[]
>();
expectTypeOf<APIWorkerConfig["observability"]>().toEqualTypeOf<
Cloudflare.Workers.Beta.Worker.Observability | undefined
>();
expectTypeOf<APIWorkerConfig["placement"]>().toEqualTypeOf<
Cloudflare.Workers.Beta.Workers.Version.Placement | undefined
>();
});

describe("basic field mapping", () => {
it("maps core fields correctly", ({ expect }) => {
const config = makeWorkerConfig({
Expand Down
17 changes: 15 additions & 2 deletions packages/workers-utils/tsup.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
import { defineConfig } from "tsup";
import { EXTERNAL_DEPENDENCIES } from "./scripts/deps";

const declarationOptions = {
resolve: ["@cloudflare/workers-shared", "jsonc-parser", "zod", /^\./],
compilerOptions: {
baseUrl: ".",
paths: {
"@cloudflare/workers-shared": ["../workers-shared/index.ts"],
// tsup 8.3 does not load Zod's default .d.cts entry point. Resolve its
// published ESM declarations without changing the runtime build.
zod: ["./node_modules/zod/index.d.ts"],
},
},
};

export default defineConfig(() => [
{
treeshake: true,
Expand All @@ -9,7 +22,7 @@ export default defineConfig(() => [
entry: ["src/browser.ts"],
platform: "node",
format: "esm",
dts: true,
dts: declarationOptions,
outDir: "dist",
tsconfig: "tsconfig.json",
metafile: true,
Expand Down Expand Up @@ -44,7 +57,7 @@ export default defineConfig(() => [
js: 'import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url || (typeof __filename === "string" ? __filename : "/"));',
},
format: "esm",
dts: true,
dts: declarationOptions,
outDir: "dist",
tsconfig: "tsconfig.json",
metafile: true,
Expand Down
6 changes: 3 additions & 3 deletions packages/wrangler/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,17 @@
"test:debug": "pnpm run test --silent=false --verbose=true",
"test:e2e": "dotenv -- vitest run -c ./e2e/vitest.config.mts",
"test:watch": "pnpm run test --testTimeout=50000 --watch",
"type:tests": "tsc -p ./src/__tests__/tsconfig.json && tsc -p ./e2e/tsconfig.json"
"type:tests": "tsc -p ./src/__tests__/tsconfig.json && tsc -p ./e2e/tsconfig.json && node -r esbuild-register scripts/check-published-declarations.ts"
},
"dependencies": {
"@cloudflare/kv-asset-handler": "workspace:*",
"@cloudflare/unenv-preset": "workspace:*",
"@cloudflare/workers-utils": "workspace:*",
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
"blake3-wasm": "2.1.5",
"esbuild": "catalog:default",
"miniflare": "workspace:*",
"path-to-regexp": "6.3.0",
"undici": "catalog:default",
"unenv": "2.0.0-rc.24",
"workerd": "catalog:default"
},
Expand All @@ -111,7 +113,6 @@
"@cloudflare/workers-shared": "workspace:*",
"@cloudflare/workers-tsconfig": "workspace:*",
"@cloudflare/workers-types": "catalog:default",
"@cloudflare/workers-utils": "workspace:*",
"@cloudflare/workflows-shared": "workspace:*",
"@cspotcode/source-map-support": "0.8.1",
"@netlify/build-info": "10.5.1",
Expand Down Expand Up @@ -184,7 +185,6 @@
"tsdown": "0.16.3",
"tsup": "8.3.0",
"typescript": "catalog:default",
"undici": "catalog:default",
"vitest": "catalog:default",
"vitest-websocket-mock": "^0.4.0",
"ws": "catalog:default",
Expand Down
116 changes: 116 additions & 0 deletions packages/wrangler/scripts/check-published-declarations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import fs from "node:fs";
import path from "node:path";
import ts from "typescript";
import {
getEntryPointPaths,
getPackageNameFromSpecifier,
isBareSpecifier,
validateDistImports,
type PackageJSON,
} from "../../../tools/deployments/validate-package-dependencies";

type PublishedPackageJSON = PackageJSON & { types?: string };

if (require.main === module) {
checkPublishedDeclarations(path.resolve(__dirname, ".."));
// Wrangler keeps workers-utils' binding types external to preserve their unique
// symbol identity. Its installed declarations must also resolve without private
// workspace packages or development dependencies.
checkPublishedDeclarations(path.resolve(__dirname, "../../workers-utils"));
}

/** Validate the declaration files shipped at a package's public type entry points. */
export function checkPublishedDeclarations(packageRoot: string): void {
const packageJson = JSON.parse(
fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8")
) as PublishedPackageJSON;

const typeEntryPoints = new Set(
[
packageJson.types,
...getEntryPointPaths(packageJson).filter(isDeclarationPath),
].filter((value): value is string => value !== undefined)
);

const declarationFiles = new Set<string>();
for (const entryPoint of typeEntryPoints) {
const absoluteEntryPoint = path.resolve(packageRoot, entryPoint);
if (!fs.existsSync(absoluteEntryPoint)) {
throw new Error("Missing built type entry point: " + entryPoint);
}
collectDeclarationFiles(path.dirname(absoluteEntryPoint), declarationFiles);
}

const importedPackages = new Set<string>();
const errors: string[] = [];
for (const declarationFile of declarationFiles) {
const preprocessed = ts.preProcessFile(
fs.readFileSync(declarationFile, "utf-8"),
true,
true
);
const references = [
...preprocessed.importedFiles,
...preprocessed.typeReferenceDirectives,
...preprocessed.referencedFiles,
];
for (const { fileName } of references) {
if (isBareSpecifier(fileName)) {
importedPackages.add(getPackageNameFromSpecifier(fileName));
} else if (fileName.startsWith(".")) {
const { resolvedModule } = ts.resolveModuleName(
fileName,
declarationFile,
{ moduleResolution: ts.ModuleResolutionKind.Bundler },
ts.sys
);
if (
resolvedModule === undefined ||
!declarationFiles.has(path.resolve(resolvedModule.resolvedFileName))
) {
errors.push(
path.relative(packageRoot, declarationFile) +
" references an unpublished declaration: " +
fileName
);
}
}
}
}

errors.push(
...validateDistImports(packageJson.name, packageJson, importedPackages)
);
if (errors.length > 0) {
throw new Error(
packageJson.name +
"'s published declarations have unresolved package boundaries:\n" +
errors.map((error) => "- " + error).join("\n")
);
}

process.stdout.write(
"Checked " +
declarationFiles.size +
" published declaration file(s) across " +
typeEntryPoints.size +
" public type entry point(s) for " +
packageJson.name +
".\n"
);
}

function isDeclarationPath(filePath: string): boolean {
return /\.d\.(?:ts|mts|cts)$/.test(filePath);
}

function collectDeclarationFiles(directory: string, files: Set<string>): void {
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const absolutePath = path.join(directory, entry.name);
if (entry.isDirectory()) {
collectDeclarationFiles(absolutePath, files);
} else if (isDeclarationPath(entry.name)) {
files.add(absolutePath);
}
}
}
4 changes: 4 additions & 0 deletions packages/wrangler/scripts/deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ export const EXTERNAL_DEPENDENCIES = [
// Wrangler depends on a pinned version of Miniflare.
"miniflare",

// Public fetch options expose Undici's MockAgent type. Keep its class identity
// shared with consumers; tsup's noExternal override still bundles its runtime.
"undici",

// @cloudflare/workers-types is an optional peer dependency of wrangler, so users can
// get the types by installing the package (to what version they prefer) themselves
"@cloudflare/workers-types",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,33 @@
import { assert, describe, test } from "vitest";
import { assert, describe, expectTypeOf, test } from "vitest";
import { experimental_getWranglerCommands } from "../experimental-commands-api";
import type { DefinitionTreeNode } from "../core/types";
import type { CommandDefinitionTreeNode } from "../command-types";
import type {
Command,
DefinitionTreeNode,
InternalDefinition,
Metadata,
} from "../core/types";
import type {
ExperimentalCommandDefinition,
ExperimentalCommandMetadata,
ExperimentalDefinitionTreeNode,
} from "../experimental-commands-api";

describe("experimental_getWranglerCommands", () => {
test("shares registry metadata and recursive structure with internal commands", () => {
expectTypeOf<Metadata>().toExtend<ExperimentalCommandMetadata>();
expectTypeOf<ExperimentalCommandMetadata>().not.toExtend<Metadata>();
expectTypeOf<
ExperimentalCommandDefinition["command"]
>().toEqualTypeOf<Command>();
expectTypeOf<DefinitionTreeNode>().toEqualTypeOf<
CommandDefinitionTreeNode<InternalDefinition>
>();
expectTypeOf<ExperimentalDefinitionTreeNode>().toEqualTypeOf<
CommandDefinitionTreeNode<ExperimentalCommandDefinition>
>();
});

test("returns global flags", ({ expect }) => {
const commandTree = experimental_getWranglerCommands().globalFlags;

Expand Down Expand Up @@ -109,6 +134,38 @@ describe("experimental_getWranglerCommands", () => {
expect(initCommand.definition.metadata.owner).toBeDefined();
});

test("preserves public command argument metadata", ({ expect }) => {
const commandTree = experimental_getWranglerCommands().registry;
const quickStart = commandTree.subtree
.get("tunnel")
?.subtree.get("quick-start");
assert(quickStart?.definition?.type === "command");

const urlArg = quickStart.definition.args?.url;
const allowedMailArg = quickStart.definition.args?.["allowed-mail"];
assert(urlArg);
assert(allowedMailArg);

expect(urlArg.description).toBe(
"The local URL to expose (e.g., http://localhost:3000)"
);
expect(allowedMailArg.nargs).toBe(1);

// These fields are part of the public structural type even when a
// particular command does not use all of them.
const publicArgMetadata = {
description: urlArg.description,
nargs: allowedMailArg.nargs,
group: "group",
conflicts: ["other"],
implies: "required",
} satisfies NonNullable<typeof urlArg>;

expect(publicArgMetadata.group).toBe("group");
expect(publicArgMetadata.conflicts).toEqual(["other"]);
expect(publicArgMetadata.implies).toBe("required");
});

test("includes namespace commands", ({ expect }) => {
const commandTree = experimental_getWranglerCommands().registry;

Expand Down Expand Up @@ -142,7 +199,7 @@ describe("experimental_getWranglerCommands", () => {
expect,
}) => {
const supporting: string[] = [];
const walk = (node: DefinitionTreeNode) => {
const walk = (node: ExperimentalDefinitionTreeNode) => {
if (
node.definition?.type === "command" &&
node.definition.behaviour?.supportTemporary
Expand Down
Loading
Loading