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
7 changes: 7 additions & 0 deletions .changeset/viteplus-worker-starters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"cf": minor
---

Initialize Workers with Vite+ checks, formatting, and Workers-runtime tests. Align Vite and Vitest across supported package managers while keeping cf responsible for development, builds, and deployment.

Use the project-local `vp` executable for Vite+ development and builds. This avoids downloading a separate Vite with npm or failing to find a `vite` executable with pnpm.
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,7 @@ merely because a spec happens to be thin.

Generalising the runtime-schema pattern into a forge annotation is deliberately deferred until there are four or more such examples. That threshold does not combine unrelated Build Output or migration workflows.

`cf init [directory]` is the single explicit setup entry point; there is no separate `cf setup` or `cf workers setup`. It currently behaves exactly like `cf init workers [directory]`, the first product initializer, which `cf init --help` lists as `[default]`; other products can later add `cf init <product>` subcommands, and bare `cf init` is expected to become a selection menu of what to create. When the directory is omitted, cf asks for it (defaulting to the current directory); non-interactive runs must pass one, such as `.`. cf shows the absolute target directory (hidden by `--quiet`) before the package-manager choice or autoconfig, and every successful run ends with a `Next steps` list led by `cf dev` (`npx cf dev` when cf itself ran through `npx` or `pnpm dlx`). A missing directory, an empty directory, or one containing only `.git` receives the hello-world Worker scaffold (`cloudflare.config.ts` with a `WORLD` text binding, `vite.config.ts`, `src/index.ts`, `tsconfig.json`, `package.json`, `.gitignore`) built on the `@cloudflare/vite-plugin` v2 beta without Wrangler; dependencies are installed by default with a prompted or `--package-manager` choice. After a successful install, cf generates `.cloudflare/types/index.d.ts` through `commands/workers/types/generate.ts`, the generator behind `cf workers types`; a failure there is a warning, not an error. The template's `typecheck` script runs `cf workers types && tsc`, and its `tsconfig.json` includes `.cloudflare/types`. Any other non-empty directory is handed to autoconfig after cf changes into it, because autoconfig's package installs use the process working directory. `cf dev` and `cf build` still run autoconfig implicitly. Tunnel log streaming is the process-backed `cf tunnels tail` command; the generated tail-create API command only creates a tail descriptor.
`cf init [directory]` is the single explicit setup entry point; there is no separate `cf setup` or `cf workers setup`. It currently behaves exactly like `cf init workers [directory]`, the first product initializer, which `cf init --help` lists as `[default]`; other products can later add `cf init <product>` subcommands, and bare `cf init` is expected to become a selection menu of what to create. When the directory is omitted, cf asks for it (defaulting to the current directory); non-interactive runs must pass one, such as `.`. cf shows the absolute target directory (hidden by `--quiet`) before the package-manager choice or autoconfig, and every successful run ends with a `Next steps` list led by `cf dev` (`npx cf dev` when cf itself ran through `npx` or `pnpm dlx`). A missing directory, an empty directory, or one containing only `.git` receives the hello-world Worker scaffold (`cloudflare.config.ts` with a `WORLD` text binding, `vite.config.ts`, `vitest.config.ts`, `src/index.ts`, `test/index.test.ts`, `tsconfig.json`, `package.json`, `.gitignore`, plus `pnpm-workspace.yaml` for pnpm or `.yarnrc.yml` for Yarn) built on the `@cloudflare/vite-plugin` v2 beta without Wrangler; dependencies are installed by default with a prompted or `--package-manager` choice. After a successful install, cf generates `.cloudflare/types/index.d.ts` through `commands/workers/types/generate.ts`, the generator behind `cf workers types`; a failure there is a warning, not an error. The template's `typecheck` script runs `cf workers types && vp check --no-fmt --no-lint`, and its `tsconfig.json` includes `.cloudflare/types`. Any other non-empty directory is handed to autoconfig after cf changes into it, because autoconfig's package installs use the process working directory. `cf dev` and `cf build` still run autoconfig implicitly. Tunnel log streaming is the process-backed `cf tunnels tail` command; the generated tail-create API command only creates a tail descriptor.

Hand-written commands with no generated parent (`auth/`, `build/`, `cli/`,
`completions/`, `dev/`, `deploy/`, `init/`, `previews/`, plus top-level
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ encrypted credentials, so affected profiles must authenticate again.

## Projects

`cf init [directory]` starts a project. If you leave out the directory, cf asks for one (use `cf init .` for the current directory in scripts). In a new or empty directory (only `.git` is allowed), it creates a hello-world Worker with `cloudflare.config.ts`, `vite.config.ts`, `src/index.ts`, `tsconfig.json`, `package.json`, and `.gitignore`, then installs dependencies with the package manager you choose (`--package-manager`, or `--no-install` to skip) and, once they are installed, generates Worker types in `.cloudflare/types/index.d.ts`. With `--no-install`, the types are generated later by `cf dev` or the project's `typecheck` script, which runs `cf workers types` and then `tsc`. In a directory that already contains files, it configures the existing project with autoconfig instead. Either way, it finishes with the next steps to run. `cf init workers [directory]` is the default initializer and currently does the same thing.
`cf init [directory]` starts a project. If you leave out the directory, cf asks for one (use `cf init .` for the current directory in scripts). In a new or empty directory (only `.git` is allowed), it creates a hello-world Worker with `cloudflare.config.ts`, `vite.config.ts`, `vitest.config.ts`, `src/index.ts`, `test/index.test.ts`, `tsconfig.json`, `package.json`, and `.gitignore` (plus `pnpm-workspace.yaml` for pnpm or `.yarnrc.yml` for Yarn), then installs dependencies with the package manager you choose (`--package-manager`, or `--no-install` to skip) and, once they are installed, generates Worker types in `.cloudflare/types/index.d.ts`. With `--no-install`, the types are generated later by `cf dev` or the project's `typecheck` script, which runs `cf workers types && vp check --no-fmt --no-lint`. In a directory that already contains files, it configures the existing project with autoconfig instead. Either way, it finishes with the next steps to run. `cf init workers [directory]` is the default initializer and currently does the same thing.

```sh
cf init my-worker
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@
"@bytecodealliance/jco": "1.39.0",
"@clack/core": "1.5.1",
"@clack/prompts": "^1.8.1",
"@cloudflare/autoconfig": "0.8.2",
"@cloudflare/autoconfig": "https://pkg.pr.new/cloudflare/workers-sdk/@cloudflare/autoconfig@147a75ae1bc58b81206dee52d4c795ff2a5686cd",
"@cloudflare/cli-shared-helpers": "0.2.10",
"@cloudflare/codemods": "0.7.0",
"@cloudflare/containers-shared": "0.23.4",
Expand Down
65 changes: 58 additions & 7 deletions packages/cli/src/__tests__/commands/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@ const TEMPLATE_FILES = [
"cloudflare.config.ts",
"package.json",
"src",
"test",
"tsconfig.json",
"vite.config.ts",
"vitest.config.ts",
];

const PNPM_USER_AGENT = "pnpm/10.27.0 npm/? node/v24.15.0 darwin arm64";
Expand Down Expand Up @@ -94,18 +96,23 @@ describe("cf init", () => {
name: "my-app",
private: true,
type: "module",
engines: { node: "^22.23.0 || ^24.22.0 || ^26.12.0" },
scripts: {
dev: "cf dev",
build: "cf build",
deploy: "cf deploy",
typecheck: "cf workers types && tsc",
check: "cf workers types && vp check",
format: "vp fmt",
test: "vp test",
typecheck: "cf workers types && vp check --no-fmt --no-lint",
},
devDependencies: {
"@cloudflare/vite-plugin":
WORKER_TEMPLATE_DEV_DEPENDENCIES["@cloudflare/vite-plugin"],
...WORKER_TEMPLATE_DEV_DEPENDENCIES,
cf: `^${VERSION}`,
typescript: WORKER_TEMPLATE_DEV_DEPENDENCIES.typescript,
},
overrides: {
vite: WORKER_TEMPLATE_DEV_DEPENDENCIES.vite,
vitest: WORKER_TEMPLATE_DEV_DEPENDENCIES.vitest,
},
});
const config = readFileSync("my-app/cloudflare.config.ts", "utf8");
Expand All @@ -126,6 +133,7 @@ describe("cf init", () => {
);
expect(readJson("my-app/tsconfig.json").include).toEqual([
"src",
"test",
"cloudflare.config.ts",
".cloudflare/types",
]);
Expand All @@ -144,6 +152,50 @@ describe("cf init", () => {
expect(stderr()).not.toContain("npm run dev");
});

it.each(["npm", "pnpm", "yarn", "bun"])(
"aligns plugin dependencies with Vite+ for %s",
async (packageManager) => {
await runCf([
"init",
"my-app",
"--no-install",
"--package-manager",
packageManager,
]);
const pkg = readJson("my-app/package.json");
const versions = {
vite: WORKER_TEMPLATE_DEV_DEPENDENCIES.vite,
vitest: WORKER_TEMPLATE_DEV_DEPENDENCIES.vitest,
};
if (packageManager === "pnpm") {
const workspace = readFileSync("my-app/pnpm-workspace.yaml", "utf8");
for (const [name, version] of Object.entries(versions)) {
expect(workspace).toContain(`${name}: ${JSON.stringify(version)}`);
}
} else {
expect(
pkg[packageManager === "yarn" ? "resolutions" : "overrides"]
).toEqual(versions);
}
if (packageManager === "yarn") {
expect(readFileSync("my-app/.yarnrc.yml", "utf8")).toBe(
"nodeLinker: node-modules\n"
);
} else {
expect(existsSync("my-app/.yarnrc.yml")).toBe(false);
}
expect(readFileSync("my-app/vitest.config.ts", "utf8")).toContain(
"experimental: { newConfig: true }"
);
expect(readFileSync("my-app/vite.config.ts", "utf8")).toContain(
"typeAware: true, typeCheck: true"
);
expect(readFileSync("my-app/test/index.test.ts", "utf8")).toContain(
"SELF.fetch"
);
}
);

it("uses the Vite plugin beta dist-tag without Wrangler", async () => {
await runCf(["init", "my-app", "--no-install"]);

Expand Down Expand Up @@ -296,9 +348,8 @@ describe("cf init", () => {
describe("dependency installation", () => {
it("installs with the package manager that launched cf", async () => {
vi.mocked(runProjectCommand).mockImplementationOnce(async () => {
expect(readFileSync("my-app/pnpm-workspace.yaml", "utf8")).toBe(
"# Approve build scripts needed by the generated Worker's dependencies.\n" +
"allowBuilds:\n esbuild: true\n workerd: true\n"
expect(readFileSync("my-app/pnpm-workspace.yaml", "utf8")).toContain(
"allowBuilds:\n esbuild: true\n workerd: true\n"
);
return { exitCode: 0 };
});
Expand Down
52 changes: 52 additions & 0 deletions packages/cli/src/__tests__/lib/autoconfig-vite-plus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { runInTempDir, seed } from "@cloudflare/workers-utils/test-helpers";
import { expect, it } from "vite-plus/test";
import { renderWorkerTemplate } from "../../commands/init/template.js";
import { analyzeProject } from "../../lib/autoconfig.js";

runInTempDir();

it("detects Vite+ commands using the installed autoconfig dependency", async () => {
await seed({
...renderWorkerTemplate({
name: "my-worker",
compatibilityDate: "2026-10-29",
cfVersion: "0.1.0",
packageManager: "npm",
}),
"package-lock.json": "{}",
});
const details = await analyzeProject(process.cwd(), { output: "silent" });
expect(details).toMatchObject({
configured: true,
devCommand: "npx vp dev",
buildCommand: "npx vp build",
});
});

it.each([false, true])(
"detects vinext commands with Vite+ enabled: %s",
async (vitePlus) => {
await seed({
"package.json": JSON.stringify({
dependencies: { next: "^16.0.0", vinext: "^0.0.1" },
...(vitePlus && { devDependencies: { "vite-plus": "1.4.0" } }),
}),
"package-lock.json": "{}",
"node_modules/next/package.json": JSON.stringify({
name: "next",
version: "16.0.0",
}),
"node_modules/vinext/package.json": JSON.stringify({
name: "vinext",
version: "0.0.1",
}),
});
const details = await analyzeProject(process.cwd(), { output: "silent" });
const command = vitePlus ? "vp" : "vite";
expect(details).toMatchObject({
configured: true,
devCommand: `npx ${command} dev`,
buildCommand: `npx ${command} build`,
});
}
);
100 changes: 79 additions & 21 deletions packages/cli/src/commands/init/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ import type { PackageManager } from "./workers.js";

export const WORKER_TEMPLATE_DEV_DEPENDENCIES = {
"@cloudflare/vite-plugin": "beta",
"@cloudflare/vitest-plugin": "^1.4.10",
typescript: "^7.0.2",
vite: "^8.3.0",
vite: "npm:@voidzero-dev/vite-plus-core@1.4.0",
"vite-plus": "1.4.0",
vitest: "5.0.5",
} as const;

export interface WorkerTemplateOptions {
Expand All @@ -23,14 +26,27 @@ export function renderWorkerTemplate(
...(options.packageManager === "pnpm"
? { "pnpm-workspace.yaml": renderPnpmWorkspace() }
: {}),
Comment thread
irvinebroque marked this conversation as resolved.
...(options.packageManager === "yarn"
? { ".yarnrc.yml": "nodeLinker: node-modules\n" }
: {}),
"src/index.ts": renderEntrypoint(),
"test/index.test.ts": renderTest(),
"tsconfig.json": renderTsconfig(),
"vite.config.ts": renderViteConfig(),
"vitest.config.ts": renderVitestConfig(),
};
}

function renderPnpmWorkspace(): string {
return `# Approve build scripts needed by the generated Worker's dependencies.
return `packages:
- "."

# Keep the toolchain used by plugins aligned with Vite+.
overrides:
vite: ${JSON.stringify(WORKER_TEMPLATE_DEV_DEPENDENCIES.vite)}
vitest: ${JSON.stringify(WORKER_TEMPLATE_DEV_DEPENDENCIES.vitest)}

# Approve build scripts needed by the generated Worker's dependencies.
allowBuilds:
esbuild: true
workerd: true
Expand Down Expand Up @@ -62,10 +78,36 @@ export default defineConfig({

function renderViteConfig(): string {
return `import { cloudflare } from "@cloudflare/vite-plugin";
import { defineConfig } from "vite";
import { defineConfig } from "vite-plus";

export default defineConfig({
plugins: [cloudflare()],
fmt: { useTabs: true },
lint: {
options: { typeAware: true, typeCheck: true },
},
});
`;
}

function renderVitestConfig(): string {
return `import { cloudflareTest } from "@cloudflare/vitest-plugin";
import { defineConfig } from "vite-plus";

export default defineConfig({
plugins: [cloudflareTest({ experimental: { newConfig: true } })],
});
`;
}

function renderTest(): string {
return `import { SELF } from "cloudflare:test";
import { expect, it } from "vite-plus/test";

it("responds using the configured Worker binding", async () => {
const response = await SELF.fetch("https://example.com/");
expect(response.status).toBe(200);
expect(await response.text()).toBe("Hello World!");
});
`;
}
Expand All @@ -90,37 +132,53 @@ function renderPackageJson(options: WorkerTemplateOptions): string {
dev: "cf dev",
build: "cf build",
deploy: "cf deploy",
typecheck: "cf workers types && tsc",
check: "cf workers types && vp check",
format: "vp fmt",
test: "vp test",
typecheck: "cf workers types && vp check --no-fmt --no-lint",
},
devDependencies: {
"@cloudflare/vite-plugin":
WORKER_TEMPLATE_DEV_DEPENDENCIES["@cloudflare/vite-plugin"],
"@cloudflare/vitest-plugin":
WORKER_TEMPLATE_DEV_DEPENDENCIES["@cloudflare/vitest-plugin"],
cf: cfDependencyRange(options.cfVersion),
typescript: WORKER_TEMPLATE_DEV_DEPENDENCIES.typescript,
vite: WORKER_TEMPLATE_DEV_DEPENDENCIES.vite,
"vite-plus": WORKER_TEMPLATE_DEV_DEPENDENCIES["vite-plus"],
vitest: WORKER_TEMPLATE_DEV_DEPENDENCIES.vitest,
},
...(options.packageManager === "pnpm"
? {}
: {
[options.packageManager === "yarn" ? "resolutions" : "overrides"]: {
vite: WORKER_TEMPLATE_DEV_DEPENDENCIES.vite,
vitest: WORKER_TEMPLATE_DEV_DEPENDENCIES.vitest,
},
}),
engines: { node: "^22.23.0 || ^24.22.0 || ^26.12.0" },
};
return `${JSON.stringify(packageJson, null, "\t")}\n`;
}

function renderTsconfig(): string {
const tsconfig = {
compilerOptions: {
target: "es2024",
lib: ["es2024"],
module: "preserve",
moduleResolution: "bundler",
types: [],
strict: true,
noEmit: true,
allowImportingTsExtensions: true,
isolatedModules: true,
verbatimModuleSyntax: true,
skipLibCheck: true,
},
include: ["src", "cloudflare.config.ts", ".cloudflare/types"],
};
return `${JSON.stringify(tsconfig, null, "\t")}\n`;
return `{
"compilerOptions": {
"target": "es2024",
"lib": ["es2024"],
"module": "preserve",
"moduleResolution": "bundler",
"types": ["@cloudflare/vitest-plugin/types"],
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true
},
"include": ["src", "test", "cloudflare.config.ts", ".cloudflare/types"]
}
`;
}

function renderGitignore(): string {
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/lib/vinext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,10 @@ function getVinextCommand(
details: AutoConfigDetails,
command: "dev" | "build"
): string {
return `${details.packageManager.npx} vite ${command}`;
const vite =
details.packageJson?.dependencies?.["vite-plus"] ||
details.packageJson?.devDependencies?.["vite-plus"]
? "vp"
: "vite";
return `${details.packageManager.npx} ${vite} ${command}`;
}
Loading
Loading