diff --git a/packages/wrangler/src/__tests__/dev.test.tsx b/packages/wrangler/src/__tests__/dev.test.tsx index 2be088492f124..52aea4c32e93d 100644 --- a/packages/wrangler/src/__tests__/dev.test.tsx +++ b/packages/wrangler/src/__tests__/dev.test.tsx @@ -2,12 +2,16 @@ import { render } from "ink-testing-library"; import patchConsole from "patch-console"; import React from "react"; import Dev from "../dev"; +import { mockConsoleMethods } from "./helpers/mock-console"; +import { runWrangler } from "./helpers/run-wrangler"; +import writeWranglerToml from "./helpers/write-wrangler-toml"; import type { DevProps } from "../dev"; describe("Dev component", () => { let restoreConsole: ReturnType; beforeEach(() => (restoreConsole = patchConsole(() => {}))); afterEach(() => restoreConsole()); + const std = mockConsoleMethods(); // This test needs to be rewritten because the error now throws asynchronously // and the Ink framework does not yet have async testing support. @@ -23,6 +27,26 @@ describe("Dev component", () => { Error: You cannot use the service worker format with a \`public\` directory." `); }); + + describe("entry-points", () => { + it("should error if there is no entry-point specified", async () => { + writeWranglerToml(); + + await expect( + runWrangler("dev") + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Missing entry-point: The entry-point should be specified via the command line (e.g. \`wrangler dev path/to/script\`) or the \`build.upload.main\` config field."` + ); + + expect(std.out).toMatchInlineSnapshot(`""`); + expect(std.err).toMatchInlineSnapshot(` + "Missing entry-point: The entry-point should be specified via the command line (e.g. \`wrangler dev path/to/script\`) or the \`build.upload.main\` config field. + + %s + If you think this is a bug then please create an issue at https://github.com/cloudflare/wrangler2/issues/new." + `); + }); + }); }); /** @@ -32,7 +56,7 @@ describe("Dev component", () => { */ function renderDev({ name, - entry = "some/entry.ts", + entry = { file: "some/entry.ts", directory: process.cwd() }, port, format, accountId, diff --git a/packages/wrangler/src/__tests__/helpers/write-wrangler-toml.ts b/packages/wrangler/src/__tests__/helpers/write-wrangler-toml.ts new file mode 100644 index 0000000000000..d5950976ead27 --- /dev/null +++ b/packages/wrangler/src/__tests__/helpers/write-wrangler-toml.ts @@ -0,0 +1,20 @@ +import * as fs from "fs"; +import TOML from "@iarna/toml"; +import type { Config } from "../../config"; + +/** Write a mock wrangler.toml file to disk. */ +export default function writeWranglerToml(config: Omit = {}) { + // We Omit `env` from config because TOML.stringify() appears to + // have a weird type signature that appears to fail. We'll revisit this + // when we write tests for publishing environments + fs.writeFileSync( + "./wrangler.toml", + TOML.stringify({ + compatibility_date: "2022-01-12", + name: "test-name", + ...(config as TOML.JsonMap), + }), + + "utf-8" + ); +} diff --git a/packages/wrangler/src/__tests__/index.test.ts b/packages/wrangler/src/__tests__/index.test.ts index 0029978d27a45..836bf651fa727 100644 --- a/packages/wrangler/src/__tests__/index.test.ts +++ b/packages/wrangler/src/__tests__/index.test.ts @@ -33,7 +33,7 @@ describe("wrangler", () => { Commands: wrangler init [name] 📥 Create a wrangler.toml configuration file wrangler whoami 🕵️ Retrieve your user info and test your auth config - wrangler dev 👂 Start a local server for developing your worker + wrangler dev [script] 👂 Start a local server for developing your worker wrangler publish [script] 🆙 Publish your Worker to Cloudflare. wrangler tail [name] 🦚 Starts a log tailing session for a deployed Worker. wrangler secret 🤫 Generate a secret that can be referenced in the worker script @@ -71,7 +71,7 @@ describe("wrangler", () => { Commands: wrangler init [name] 📥 Create a wrangler.toml configuration file wrangler whoami 🕵️ Retrieve your user info and test your auth config - wrangler dev 👂 Start a local server for developing your worker + wrangler dev [script] 👂 Start a local server for developing your worker wrangler publish [script] 🆙 Publish your Worker to Cloudflare. wrangler tail [name] 🦚 Starts a log tailing session for a deployed Worker. wrangler secret 🤫 Generate a secret that can be referenced in the worker script diff --git a/packages/wrangler/src/__tests__/publish.test.ts b/packages/wrangler/src/__tests__/publish.test.ts index f7917bd1f07d5..8a20acbda1694 100644 --- a/packages/wrangler/src/__tests__/publish.test.ts +++ b/packages/wrangler/src/__tests__/publish.test.ts @@ -7,8 +7,8 @@ import { mockConsoleMethods } from "./helpers/mock-console"; import { mockKeyListRequest } from "./helpers/mock-kv"; import { runInTempDir } from "./helpers/run-in-tmp"; import { runWrangler } from "./helpers/run-wrangler"; +import writeWranglerToml from "./helpers/write-wrangler-toml"; import type { WorkerMetadata } from "../api/form_data"; -import type { Config } from "../config"; import type { KVNamespaceInfo } from "../kv"; import type { FormData, File } from "undici"; @@ -1373,23 +1373,6 @@ export default{ }); }); -/** Write a mock wrangler.toml file to disk. */ -function writeWranglerToml(config: Omit = {}) { - // We Omit `env` from config because TOML.stringify() appears to - // have a weird type signature that appears to fail. We'll revisit this - // when we write tests for publishing environments - fs.writeFileSync( - "./wrangler.toml", - TOML.stringify({ - compatibility_date: "2022-01-12", - name: "test-name", - ...(config as TOML.JsonMap), - }), - - "utf-8" - ); -} - /** Write a mock Worker script to disk. */ function writeWorkerSource({ basePath = ".", diff --git a/packages/wrangler/src/bundle.ts b/packages/wrangler/src/bundle.ts index 133dfb412bcc4..b0abb4bcbf8e7 100644 --- a/packages/wrangler/src/bundle.ts +++ b/packages/wrangler/src/bundle.ts @@ -5,6 +5,8 @@ import * as esbuild from "esbuild"; import makeModuleCollector from "./module-collection"; import type { CfModule, CfScriptFormat } from "./api/worker"; +export type Entry = { file: string; directory: string }; + type BundleResult = { modules: CfModule[]; resolvedEntryPointPath: string; @@ -16,9 +18,8 @@ type BundleResult = { * Generate a bundle for the worker identified by the arguments passed in. */ export async function bundleWorker( - entryFile: string, + entry: Entry, serveAssetsFromWorker: boolean, - workingDir: string, destination: string, jsxFactory: string | undefined, jsxFragment: string | undefined, @@ -27,9 +28,9 @@ export async function bundleWorker( ): Promise { const moduleCollector = makeModuleCollector({ format }); const result = await esbuild.build({ - ...getEntryPoint(entryFile, serveAssetsFromWorker), + ...getEntryPoint(entry.file, serveAssetsFromWorker), bundle: true, - absWorkingDir: workingDir, + absWorkingDir: entry.directory, outdir: destination, external: ["__STATIC_CONTENT_MANIFEST"], format: "esm", @@ -53,7 +54,7 @@ export async function bundleWorker( ); assert( entryPointOutputs.length > 0, - `Cannot find entry-point "${entryFile}" in generated bundle.` + + `Cannot find entry-point "${entry.file}" in generated bundle.` + listEntryPoints(entryPointOutputs) ); assert( @@ -67,7 +68,10 @@ export async function bundleWorker( return { modules: moduleCollector.modules, - resolvedEntryPointPath: path.resolve(workingDir, entryPointOutputs[0][0]), + resolvedEntryPointPath: path.resolve( + entry.directory, + entryPointOutputs[0][0] + ), bundleType, stop: result.stop, }; diff --git a/packages/wrangler/src/config.ts b/packages/wrangler/src/config.ts index 8bcc579654bad..8126cb5f59d34 100644 --- a/packages/wrangler/src/config.ts +++ b/packages/wrangler/src/config.ts @@ -410,75 +410,54 @@ export type Config = { * in wrangler2. We infer the format automatically, and we can pass * the path to the script either in the CLI (or, @todo, as the top level * `entry` property). - */ ( - | { - upload?: { - /** - * The format of the Worker script, must be "service-worker". - * - * @deprecated We infer the format automatically now. - */ - format?: "service-worker"; - - /** - * The path to the Worker script. This should be replaced - * by the top level `entry' property. - * - * @deprecated This will be replaced by the top level `entry' property. - */ - main: string; - }; - } - | { - /** - * When we use the module format, we only really - * need to specify the entry point. The format is deduced - * automatically in wrangler2. - */ - upload?: { - /** - * The format of the Worker script, must be "modules". - * - * @deprecated We infer the format automatically now. - */ - format?: "modules"; - - /** - * The directory you wish to upload your modules from, - * relative to the wrangler.toml file. - * - * Defaults to the directory containing the wrangler.toml file. - * - * @deprecated - * @breaking - */ - dir?: string; - - /** - * The path to the Worker script, relative to `upload.dir`. - * - * @deprecated This will be replaced by a command line argument. - */ - main?: string; - - /** - * An ordered list of rules that define which modules to import, - * and what type to import them as. You will need to specify rules - * to use Text, Data, and CompiledWasm modules, or when you wish to - * have a .js file be treated as an ESModule instead of CommonJS. - * - * @deprecated These are now inferred automatically for major file types, but you can still specify them manually. - * @todo this needs to be implemented! - * @breaking - */ - rules?: { - type: "ESModule" | "CommonJS" | "Text" | "Data" | "CompiledWasm"; - globs: string[]; - fallthrough?: boolean; - }; - }; - } - ); + */ { + /** + * We only really need to specify the entry point. + * The format is deduced automatically in wrangler2. + */ + upload?: { + /** + * The format of the Worker script. + * + * @deprecated We infer the format automatically now. + */ + format?: "modules" | "service-worker"; + + /** + * The directory you wish to upload your worker from, + * relative to the wrangler.toml file. + * + * Defaults to the directory containing the wrangler.toml file. + * + * @deprecated + * @breaking + */ + dir?: string; + + /** + * The path to the Worker script, relative to `upload.dir`. + * + * @deprecated This will be replaced by a command line argument. + */ + main?: string; + + /** + * An ordered list of rules that define which modules to import, + * and what type to import them as. You will need to specify rules + * to use Text, Data, and CompiledWasm modules, or when you wish to + * have a .js file be treated as an ESModule instead of CommonJS. + * + * @deprecated These are now inferred automatically for major file types, but you can still specify them manually. + * @todo this needs to be implemented! + * @breaking + */ + rules?: { + type: "ESModule" | "CommonJS" | "Text" | "Data" | "CompiledWasm"; + globs: string[]; + fallthrough?: boolean; + }; + }; + }; /** * The `env` section defines overrides for the configuration for diff --git a/packages/wrangler/src/dev.tsx b/packages/wrangler/src/dev.tsx index a8586343fec0e..cb641051c4f68 100644 --- a/packages/wrangler/src/dev.tsx +++ b/packages/wrangler/src/dev.tsx @@ -23,13 +23,14 @@ import { syncAssets } from "./sites"; import { getAPIToken } from "./user"; import type { CfPreviewToken } from "./api/preview"; import type { CfModule, CfWorkerInit, CfScriptFormat } from "./api/worker"; +import type { Entry } from "./bundle"; import type { AssetPaths } from "./sites"; import type { WatchMode } from "esbuild"; import type { DirectoryResult } from "tmp-promise"; export type DevProps = { name?: string; - entry: string; + entry: Entry; port?: number; format: CfScriptFormat | undefined; accountId: undefined | string; @@ -61,7 +62,7 @@ function Dev(props: DevProps): JSX.Element { // kinda forbid that, so we thread the entry through useCustomBuild const entry = useCustomBuild(props.entry, props.buildCommand); - const format = useWorkerFormat({ file: entry, format: props.format }); + const format = useWorkerFormat({ file: entry?.file, format: props.format }); if (format && props.public && format === "service-worker") { throw new Error( "You cannot use the service worker format with a `public` directory." @@ -439,16 +440,16 @@ function useTmpDir(): string | undefined { } function useCustomBuild( - expectedEntry: string, + expectedEntry: Entry, props: { command?: undefined | string; cwd?: undefined | string; watch_dir?: undefined | string; } -): undefined | string { - const [entry, setEntry] = useState( +): undefined | Entry { + const [entry, setEntry] = useState( // if there's no build command, just return the expected entry - props.command || expectedEntry + !props.command ? expectedEntry : undefined ); const { command, cwd, watch_dir } = props; useEffect(() => { @@ -481,14 +482,16 @@ function useCustomBuild( // if it does, we're done const startedAt = Date.now(); interval = setInterval(() => { - if (existsSync(expectedEntry)) { + if (existsSync(expectedEntry.file)) { clearInterval(interval); setEntry(expectedEntry); } else { const elapsed = Date.now() - startedAt; // timeout after 30 seconds of waiting - if (elapsed > 1000 * 60 * 30) { - console.error("⎔ Build timed out."); + if (elapsed > 1000 * 30) { + console.error( + `⎔ Build timed out, could not resolve ${expectedEntry}` + ); clearInterval(interval); cmd.kill(); } @@ -511,7 +514,7 @@ function useCustomBuild( type EsbuildBundle = { id: number; path: string; - entry: string; + entry: Entry; type: "esm" | "commonjs"; modules: CfModule[]; serveAssetsFromWorker: boolean; @@ -526,7 +529,7 @@ function useEsbuild({ format, serveAssetsFromWorker, }: { - entry: undefined | string; + entry: undefined | Entry; destination: string | undefined; format: CfScriptFormat | undefined; staticRoot: undefined | string; @@ -563,7 +566,6 @@ function useEsbuild({ entry, // In dev, we server assets from the local proxy before we send the request to the worker. /* serveAssetsFromWorker */ false, - process.cwd(), destination, jsxFactory, jsxFragment, diff --git a/packages/wrangler/src/index.tsx b/packages/wrangler/src/index.tsx index aef7fbcb87e1e..83edee2c2961a 100644 --- a/packages/wrangler/src/index.tsx +++ b/packages/wrangler/src/index.tsx @@ -77,8 +77,8 @@ async function readConfig(configPath?: string): Promise { // TODO: remove this error before GA. if ("experimental_services" in config) { throw new Error( - `The "experimental_services" field is no longer supported. Instead, use [[unsafe.bindings]] to enable experimental features. Add this to your wrangler.toml: - + `The "experimental_services" field is no longer supported. Instead, use [[unsafe.bindings]] to enable experimental features. Add this to your wrangler.toml: + ${TOML.stringify({ unsafe: { bindings: (config.experimental_services || []).map((serviceDefinition) => { @@ -131,6 +131,44 @@ ${TOML.stringify({ return config; } +function getEntry( + args: { _: (string | number)[]; script: string | undefined }, + config: Config +): { file: string; directory: string } { + // @ts-expect-error a hidden field + const wranglerTomlPath = config.__path__; + let file: string; + let directory = process.cwd(); + if (args.script) { + // If the script name comes from the command line it is relative to the current working directory. + file = path.resolve(args.script); + } else { + // If the script name comes from the config, then it is relative to the wrangler.toml file. + if (config.build?.upload?.main === undefined) { + throw new Error( + `Missing entry-point: The entry-point should be specified via the command line (e.g. \`wrangler ${args._[0]} path/to/script\`) or the \`build.upload.main\` config field.` + ); + } + directory = path.resolve( + path.dirname(wranglerTomlPath), + config.build.upload.dir || "" + ); + file = path.resolve(directory, config.build.upload.main); + } + + if ( + !config.build?.command && + // Use require.resolve to use node's resolution algorithm, + // this lets us use paths without explicit .js extension + // TODO: we should probably remove this, because it doesn't + // take into consideration other extensions like .tsx, .ts, .jsx, etc + !fs.existsSync(require.resolve(file)) + ) { + throw new Error(`Entry point ${file} does not exist.`); + } + return { file, directory }; +} + // a helper to demand one of a set of options // via https://github.com/yargs/yargs/issues/1093#issuecomment-491299261 function demandOneOfOption(...options: string[]) { @@ -603,14 +641,13 @@ export async function main(argv: string[]): Promise { // dev wrangler.command( - "dev ", + "dev [script]", "👂 Start a local server for developing your worker", (yargs) => { return yargs - .positional("filename", { + .positional("script", { describe: "entry point", type: "string", - demandOption: true, }) .option("name", { describe: "name of the script", @@ -699,8 +736,8 @@ export async function main(argv: string[]): Promise { }); }, async (args) => { - const { filename } = args; const config = args.config as Config; + const entry = getEntry(args, config); if (args["experimental-public"]) { console.warn( @@ -757,7 +794,7 @@ export async function main(argv: string[]): Promise { const { waitUntilExit } = render( { } const config = args.config as Config; + const entry = getEntry(args, config); if (args.latest) { console.warn( @@ -948,7 +986,7 @@ export async function main(argv: string[]): Promise { config, name: args.name, format: args.format || config.build?.upload?.format, - script: args.script, + entry, env: args.env, compatibilityDate: args.latest ? new Date().toISOString().substring(0, 10) diff --git a/packages/wrangler/src/publish.ts b/packages/wrangler/src/publish.ts index 75407c8e18c77..359eb1fa5f50e 100644 --- a/packages/wrangler/src/publish.ts +++ b/packages/wrangler/src/publish.ts @@ -1,5 +1,5 @@ import assert from "node:assert"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import path from "node:path"; import { URLSearchParams } from "node:url"; import { execaCommand } from "execa"; @@ -16,7 +16,7 @@ import type { AssetPaths } from "./sites"; type Props = { config: Config; format: CfScriptFormat | undefined; - script: string | undefined; + entry: { file: string; directory: string }; name: string | undefined; env: string | undefined; compatibilityDate: string | undefined; @@ -37,12 +37,7 @@ function sleep(ms: number) { export default async function publish(props: Props): Promise { // TODO: warn if git/hg has uncommitted changes const { config } = props; - const { - account_id: accountId, - build, - // @ts-expect-error hidden - __path__: wranglerTomlPath, - } = config; + const { account_id: accountId } = config; const envRootObj = props.env && config.env ? config.env[props.env] || {} : config; @@ -85,25 +80,6 @@ export default async function publish(props: Props): Promise { const destination = await tmp.dir({ unsafeCleanup: true }); try { - let file: string; - let dir = process.cwd(); - if (props.script) { - // If the script name comes from the command line it is relative to the current working directory. - file = path.resolve(props.script); - } else { - // If the script name comes from the config, then it is relative to the wrangler.toml file. - if (build?.upload?.main === undefined) { - throw new Error( - "Missing entry-point: The entry-point should be specified via the command line (e.g. `wrangler publish path/to/script`) or the `build.upload.main` config field." - ); - } - dir = path.resolve( - path.dirname(wranglerTomlPath), - (build.upload.format === "modules" && build.upload.dir) || "" - ); - file = path.resolve(dir, build.upload.main); - } - if (props.legacyEnv) { scriptName += props.env ? `-${props.env}` : ""; } @@ -116,11 +92,15 @@ export default async function publish(props: Props): Promise { shell: true, stdout: "inherit", stderr: "inherit", + timeout: 1000 * 30, ...(props.config.build?.cwd && { cwd: props.config.build.cwd }), }); + if (!existsSync(props.entry.file)) { + throw new Error(`${props.entry.file} does not exist`); + } } - const format = await guessWorkerFormat(file, props.format); + const format = await guessWorkerFormat(props.entry.file, props.format); if (props.experimentalPublic && format === "service-worker") { // TODO: check config too @@ -136,9 +116,8 @@ export default async function publish(props: Props): Promise { } const { modules, resolvedEntryPointPath, bundleType } = await bundleWorker( - file, + props.entry, props.experimentalPublic, - dir, destination.path, jsxFactory, jsxFragment,