diff --git a/.changeset/static-assets-detection.md b/.changeset/static-assets-detection.md new file mode 100644 index 000000000000..9ad5b0e801fe --- /dev/null +++ b/.changeset/static-assets-detection.md @@ -0,0 +1,9 @@ +--- +"wrangler": minor +--- + +Improve error messaging when no entry point is found by detecting potential static asset directories + +When `wrangler deploy` fails due to missing entry points, Wrangler now automatically detects common static asset directories (like `dist`, `build`, `public`, etc.) and framework-specific output directories (like Astro, Vite, Next.js, Eleventy). The error message now includes helpful suggestions about which directories might contain static assets and provides the exact command to deploy them with `--assets`. + +This feature helps new users better understand what they need to do when deploying static sites to Cloudflare without requiring them to understand Wrangler configuration files. \ No newline at end of file diff --git a/packages/wrangler/.changeset/README.md b/packages/wrangler/.changeset/README.md new file mode 100644 index 000000000000..22acb2b25508 --- /dev/null +++ b/packages/wrangler/.changeset/README.md @@ -0,0 +1,6 @@ +# Changesets + +Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works with multi-package repos, or single-package repos to help you version and publish your code. You can find the full documentation for it [in our repository](https://github.com/changesets/changesets) + +We have a quick list of common questions to get you started engaging with this project in +[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) diff --git a/packages/wrangler/.changeset/config.json b/packages/wrangler/.changeset/config.json new file mode 100644 index 000000000000..12248e5edefe --- /dev/null +++ b/packages/wrangler/.changeset/config.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@1.6.2/schema.json", + "changelog": [ + "@changesets/changelog-github", + { "repo": "cloudflare/workers-sdk" } + ], + "commit": false, + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "bumpVersionsWithWorkspaceProtocolOnly": true, + "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { + "onlyUpdatePeerDependentsWhenOutOfRange": true + }, + "ignore": [], + "privatePackages": { + "tag": true, + "version": true + } +} diff --git a/packages/wrangler/.changeset_readme.md b/packages/wrangler/.changeset_readme.md new file mode 100644 index 000000000000..22acb2b25508 --- /dev/null +++ b/packages/wrangler/.changeset_readme.md @@ -0,0 +1,6 @@ +# Changesets + +Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works with multi-package repos, or single-package repos to help you version and publish your code. You can find the full documentation for it [in our repository](https://github.com/changesets/changesets) + +We have a quick list of common questions to get you started engaging with this project in +[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) diff --git a/packages/wrangler/src/__tests__/get-entry.test.ts b/packages/wrangler/src/__tests__/get-entry.test.ts index 6801f867b11e..6109a4e6fe07 100644 --- a/packages/wrangler/src/__tests__/get-entry.test.ts +++ b/packages/wrangler/src/__tests__/get-entry.test.ts @@ -141,4 +141,104 @@ describe("getEntry()", () => { moduleRoot: "/tmp/dir/other-worker/src", }); }); + + describe("error messages with asset directory suggestions", () => { + it("should suggest single detected asset directory", async () => { + await seed({ + "dist/index.html": "", + "dist/style.css": "body { color: red; }", + }); + + await expect( + getEntry({}, defaultWranglerConfig, "deploy") + ).rejects.toThrow( + /We noticed that there is a directory called `\.\/dist` in your project \(common build output directory\)\. If you are trying to deploy the contents of that directory to Cloudflare, please run:/ + ); + }); + + it("should suggest multiple detected asset directories", async () => { + await seed({ + "dist/index.html": "", + "build/app.js": "console.log('hello');", + "public/favicon.ico": "fake-ico-content", + }); + + await expect( + getEntry({}, defaultWranglerConfig, "deploy") + ).rejects.toThrow( + /We noticed several directories that might contain static assets:/ + ); + + await expect( + getEntry({}, defaultWranglerConfig, "deploy") + ).rejects.toThrow( + /- `\.\/build` \(common build output directory\)/ + ); + + await expect( + getEntry({}, defaultWranglerConfig, "deploy") + ).rejects.toThrow( + /- `\.\/dist` \(common build output directory\)/ + ); + + await expect( + getEntry({}, defaultWranglerConfig, "deploy") + ).rejects.toThrow( + /- `\.\/public` \(common static assets directory\)/ + ); + }); + + it("should suggest framework-specific directories", async () => { + await seed({ + "package.json": JSON.stringify({ + name: "my-astro-project", + dependencies: { + astro: "^4.0.0", + }, + }), + "astro.config.mjs": "export default {};", + "dist/index.html": "", + }); + + await expect( + getEntry({}, defaultWranglerConfig, "deploy") + ).rejects.toThrow( + /We noticed that there is a directory called `\.\/dist` in your project \(detected astro\.config project\)/ + ); + }); + + it("should not suggest asset directories when none exist", async () => { + await seed({ + "src/some-file.ts": "export default {};", + }); + + await expect( + getEntry({}, defaultWranglerConfig, "deploy") + ).rejects.toThrow( + /Missing entry-point to Worker script or to assets directory/ + ); + + // Should not contain asset directory suggestions + await expect( + getEntry({}, defaultWranglerConfig, "deploy") + ).rejects.not.toThrow(/We noticed/); + }); + + it("should not suggest empty directories", async () => { + await seed({ + "dist/": "", // Creates empty directory + }); + + await expect( + getEntry({}, defaultWranglerConfig, "deploy") + ).rejects.toThrow( + /Missing entry-point to Worker script or to assets directory/ + ); + + // Should not contain asset directory suggestions + await expect( + getEntry({}, defaultWranglerConfig, "deploy") + ).rejects.not.toThrow(/We noticed/); + }); + }); }); diff --git a/packages/wrangler/src/__tests__/static-assets-detector.test.ts b/packages/wrangler/src/__tests__/static-assets-detector.test.ts new file mode 100644 index 000000000000..ce483394bfb2 --- /dev/null +++ b/packages/wrangler/src/__tests__/static-assets-detector.test.ts @@ -0,0 +1,185 @@ +import { detectStaticAssetDirectories } from "../deployment-bundle/static-assets-detector"; +import { mockConsoleMethods } from "./helpers/mock-console"; +import { runInTempDir } from "./helpers/run-in-tmp"; +import { seed } from "./helpers/seed"; + +describe("detectStaticAssetDirectories()", () => { + runInTempDir(); + mockConsoleMethods(); + + it("should detect dist directory", async () => { + await seed({ + "dist/index.html": "", + "dist/style.css": "body { color: red; }", + }); + + const suggestions = detectStaticAssetDirectories(); + expect(suggestions).toEqual([ + { + directory: "./dist", + reason: "common build output directory", + }, + ]); + }); + + it("should detect multiple asset directories", async () => { + await seed({ + "dist/index.html": "", + "build/app.js": "console.log('hello');", + "public/favicon.ico": "fake-ico-content", + }); + + const suggestions = detectStaticAssetDirectories(); + expect(suggestions).toEqual([ + { + directory: "./build", + reason: "common build output directory", + }, + { + directory: "./dist", + reason: "common build output directory", + }, + { + directory: "./public", + reason: "common static assets directory", + }, + ]); + }); + + it("should not detect empty directories", async () => { + await seed({ + "dist/": "", + "build/": "", + }); + + const suggestions = detectStaticAssetDirectories(); + expect(suggestions).toEqual([]); + }); + + it("should detect Astro project with dist directory", async () => { + await seed({ + "package.json": JSON.stringify({ + name: "my-astro-project", + dependencies: { + astro: "^4.0.0", + }, + }), + "astro.config.mjs": "export default {};", + "dist/index.html": "", + "dist/style.css": "body { color: red; }", + }); + + const suggestions = detectStaticAssetDirectories(); + expect(suggestions).toContainEqual({ + directory: "./dist", + reason: "detected astro.config project", + }); + }); + + it("should detect Next.js project with out directory", async () => { + await seed({ + "package.json": JSON.stringify({ + name: "my-next-project", + dependencies: { + next: "^14.0.0", + }, + }), + "next.config.js": "module.exports = {};", + "out/index.html": "", + "out/favicon.ico": "fake-ico-content", + }); + + const suggestions = detectStaticAssetDirectories(); + expect(suggestions).toContainEqual({ + directory: "./out", + reason: "detected next.config project", + }); + }); + + it("should detect Vite project from package.json", async () => { + await seed({ + "package.json": JSON.stringify({ + name: "my-vite-project", + devDependencies: { + vite: "^5.0.0", + }, + }), + "dist/index.html": "", + "dist/assets/main.js": "console.log('hello');", + }); + + const suggestions = detectStaticAssetDirectories(); + expect(suggestions).toContainEqual({ + directory: "./dist", + reason: "detected package.json project", + }); + }); + + it("should detect Eleventy project", async () => { + await seed({ + "package.json": JSON.stringify({ + name: "my-eleventy-project", + devDependencies: { + "@11ty/eleventy": "^2.0.0", + }, + }), + ".eleventy.js": "module.exports = {};", + "_site/index.html": "", + "_site/style.css": "body { color: red; }", + }); + + const suggestions = detectStaticAssetDirectories(); + expect(suggestions).toContainEqual({ + directory: "./_site", + reason: "detected .eleventy project", + }); + }); + + it("should not duplicate suggestions", async () => { + await seed({ + "package.json": JSON.stringify({ + name: "my-astro-project", + dependencies: { + astro: "^4.0.0", + }, + }), + "astro.config.mjs": "export default {};", + "dist/index.html": "", + }); + + const suggestions = detectStaticAssetDirectories(); + // Should only have one suggestion for dist, not duplicates + const distSuggestions = suggestions.filter(s => s.directory === "./dist"); + expect(distSuggestions).toHaveLength(1); + }); + + it("should handle non-existent directories gracefully", async () => { + await seed({ + "package.json": JSON.stringify({ + name: "my-project", + dependencies: { + astro: "^4.0.0", + }, + }), + // No dist directory created + }); + + const suggestions = detectStaticAssetDirectories(); + expect(suggestions).toEqual([]); + }); + + it("should handle invalid package.json gracefully", async () => { + await seed({ + "package.json": "invalid json content", + "dist/index.html": "", + }); + + const suggestions = detectStaticAssetDirectories(); + expect(suggestions).toEqual([ + { + directory: "./dist", + reason: "common build output directory", + }, + ]); + }); +}); \ No newline at end of file diff --git a/packages/wrangler/src/deployment-bundle/entry.ts b/packages/wrangler/src/deployment-bundle/entry.ts index 5622cda5c12e..74c6e1fc4498 100644 --- a/packages/wrangler/src/deployment-bundle/entry.ts +++ b/packages/wrangler/src/deployment-bundle/entry.ts @@ -12,6 +12,7 @@ import { resolveEntryWithScript, } from "./resolve-entry"; import { runCustomBuild } from "./run-custom-build"; +import { detectStaticAssetDirectories } from "./static-assets-detector"; import type { Config, RawConfig } from "../config"; import type { DurableObjectBindings } from "../config/environment"; import type { CfScriptFormat } from "./worker"; @@ -101,8 +102,11 @@ export async function getEntry( `; const fullCommand = `${getNpxEquivalent()} wrangler ${command}`; - throw new UserError( - dedent` + + // Detect potential static asset directories + const assetSuggestions = detectStaticAssetDirectories(config.configPath ? path.dirname(config.configPath) : process.cwd()); + + let errorMessage = dedent` Missing entry-point to Worker script or to assets directory If there is code to deploy, you can either: @@ -111,9 +115,26 @@ export async function getEntry( If are uploading a directory of assets, you can either: - Specify the path to the directory of assets via the command line: (ex: \`${fullCommand} --assets=./dist\`) - - Or ${updateConfigMessage({ assets: { directory: "./dist" } })}`, - { telemetryMessage: "missing worker entrypoint or assets directory" } - ); + - Or ${updateConfigMessage({ assets: { directory: "./dist" } })}`; + + // Add asset directory suggestions if any were found + if (assetSuggestions.length > 0) { + errorMessage += "\n\n"; + if (assetSuggestions.length === 1) { + const suggestion = assetSuggestions[0]; + errorMessage += dedent` + We noticed that there is a directory called \`${suggestion.directory}\` in your project (${suggestion.reason}). If you are trying to deploy the contents of that directory to Cloudflare, please run: + \`${fullCommand} --assets ${suggestion.directory}\``; + } else { + errorMessage += "We noticed several directories that might contain static assets:\n"; + for (const suggestion of assetSuggestions) { + errorMessage += `- \`${suggestion.directory}\` (${suggestion.reason})\n`; + } + errorMessage += `\nIf you want to deploy one of these directories, run: \`${fullCommand} --assets \``; + } + } + + throw new UserError(errorMessage, { telemetryMessage: "missing worker entrypoint or assets directory" }); } await runCustomBuild( paths.absolutePath, diff --git a/packages/wrangler/src/deployment-bundle/static-assets-detector.ts b/packages/wrangler/src/deployment-bundle/static-assets-detector.ts new file mode 100644 index 000000000000..725441da169c --- /dev/null +++ b/packages/wrangler/src/deployment-bundle/static-assets-detector.ts @@ -0,0 +1,150 @@ +import fs from "node:fs"; +import path from "node:path"; + +export interface AssetDirectorySuggestion { + directory: string; + reason: string; +} + +const COMMON_ASSET_DIRECTORIES = [ + { name: "dist", reason: "common build output directory" }, + { name: "build", reason: "common build output directory" }, + { name: "out", reason: "common build output directory" }, + { name: "public", reason: "common static assets directory" }, + { name: ".next/out", reason: "Next.js static export output directory" }, + { name: "_site", reason: "Jekyll/11ty output directory" }, +]; + +const FRAMEWORK_CONFIGS: Array<{ + configFile: string; + parseConfig?: (content: string) => string | null; + defaultOutDir?: string; +}> = [ + { + configFile: "package.json", + parseConfig: (content: string) => { + try { + const pkg = JSON.parse(content); + // Check for common frameworks and their typical output directories + if (pkg.dependencies?.astro || pkg.devDependencies?.astro) { + return "dist"; + } + if (pkg.dependencies?.vite || pkg.devDependencies?.vite) { + return "dist"; + } + if (pkg.dependencies?.next || pkg.devDependencies?.next) { + return "out"; // for static exports + } + if (pkg.dependencies?.["@11ty/eleventy"] || pkg.devDependencies?.["@11ty/eleventy"]) { + return "_site"; + } + } catch { + // Ignore JSON parse errors + } + return null; + }, + }, + { + configFile: "astro.config.mjs", + defaultOutDir: "dist", + }, + { + configFile: "astro.config.js", + defaultOutDir: "dist", + }, + { + configFile: "astro.config.ts", + defaultOutDir: "dist", + }, + { + configFile: "vite.config.js", + defaultOutDir: "dist", + }, + { + configFile: "vite.config.ts", + defaultOutDir: "dist", + }, + { + configFile: "next.config.js", + defaultOutDir: "out", + }, + { + configFile: "next.config.mjs", + defaultOutDir: "out", + }, + { + configFile: ".eleventy.js", + defaultOutDir: "_site", + }, + { + configFile: "eleventy.config.js", + defaultOutDir: "_site", + }, +]; + +export function detectStaticAssetDirectories(projectRoot: string = process.cwd()): AssetDirectorySuggestion[] { + const suggestionsMap = new Map(); + + // Check framework-specific configurations first (they have priority) + for (const { configFile, parseConfig, defaultOutDir } of FRAMEWORK_CONFIGS) { + const configPath = path.join(projectRoot, configFile); + if (fs.existsSync(configPath)) { + let suggestedDir: string | null = null; + + if (parseConfig) { + try { + const content = fs.readFileSync(configPath, "utf-8"); + suggestedDir = parseConfig(content); + } catch { + // Ignore read/parse errors + } + } else if (defaultOutDir) { + suggestedDir = defaultOutDir; + } + + if (suggestedDir) { + const dirPath = path.join(projectRoot, suggestedDir); + if (fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory()) { + try { + const files = fs.readdirSync(dirPath); + if (files.length > 0) { + const reason = `detected ${configFile.replace(/\.(js|mjs|ts)$/, "")} project`; + suggestionsMap.set(`./${suggestedDir}`, { + directory: `./${suggestedDir}`, + reason, + }); + } + } catch { + // Ignore read errors + } + } + } + } + } + + // Then check for common asset directories (lower priority) + for (const { name, reason } of COMMON_ASSET_DIRECTORIES) { + const directory = `./${name}`; + // Only add if not already detected by framework-specific logic + if (!suggestionsMap.has(directory)) { + const dirPath = path.join(projectRoot, name); + if (fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory()) { + // Check if directory has files (not empty) + try { + const files = fs.readdirSync(dirPath); + if (files.length > 0) { + suggestionsMap.set(directory, { + directory, + reason, + }); + } + } catch { + // Ignore read errors + } + } + } + } + + // Convert map to array and sort by directory name to ensure consistent ordering + return Array.from(suggestionsMap.values()).sort((a, b) => a.directory.localeCompare(b.directory)); +} \ No newline at end of file