From b7968ce5fe9136fee94b5004bf0544029d8ae1ed Mon Sep 17 00:00:00 2001 From: Sunil Pai Date: Sun, 19 Jun 2022 09:12:54 +0100 Subject: [PATCH] feat: `publish --no-build` This adds a `--no-build` flag to `wrangler publish`. We've had a bunch of people asking ot be able to upload a worker directly, without any modifications. While there are tradeoffs to this approach (any linked modules etc won't work), we understand that people who need this functionality are aware of it (and the usecases that have presented themselves all seem to match this). --- .changeset/spotty-onions-exist.md | 7 + .../wrangler/src/__tests__/publish.test.ts | 13 + packages/wrangler/src/dev.tsx | 320 ++++++++++-------- packages/wrangler/src/dev/dev.tsx | 4 +- packages/wrangler/src/dev/use-esbuild.ts | 75 ++-- packages/wrangler/src/index.tsx | 260 +++++++------- packages/wrangler/src/publish.ts | 54 ++- 7 files changed, 425 insertions(+), 308 deletions(-) create mode 100644 .changeset/spotty-onions-exist.md diff --git a/.changeset/spotty-onions-exist.md b/.changeset/spotty-onions-exist.md new file mode 100644 index 000000000000..2d2e9f53c9f9 --- /dev/null +++ b/.changeset/spotty-onions-exist.md @@ -0,0 +1,7 @@ +--- +"wrangler": patch +--- + +feat: `publish --no-build` + +This adds a `--no-build` flag to `wrangler publish`. We've had a bunch of people asking ot be able to upload a worker directly, without any modifications. While there are tradeoffs to this approach (any linked modules etc won't work), we understand that people who need this functionality are aware of it (and the usecases that have presented themselves all seem to match this). diff --git a/packages/wrangler/src/__tests__/publish.test.ts b/packages/wrangler/src/__tests__/publish.test.ts index 7d57a2dc0188..d3e56eb2c175 100644 --- a/packages/wrangler/src/__tests__/publish.test.ts +++ b/packages/wrangler/src/__tests__/publish.test.ts @@ -6020,6 +6020,19 @@ addEventListener('fetch', event => {});` `); }); }); + + describe("--no-build", () => { + it("should not transform the source code before publishing it", async () => { + writeWranglerToml(); + const scriptContent = ` + import X from '@cloudflare/no-such-package'; // let's add an import that doesn't exist + const xyz = 123; // a statement that would otherwise be compiled out + `; + fs.writeFileSync("index.js", scriptContent); + await runWrangler("publish index.js --no-build --dry-run --outdir dist"); + expect(fs.readFileSync("dist/index.js", "utf-8")).toMatch(scriptContent); + }); + }); }); /** Write mock assets to the file system so they can be uploaded. */ diff --git a/packages/wrangler/src/dev.tsx b/packages/wrangler/src/dev.tsx index d3109440aff5..f4a1ffbae9b1 100644 --- a/packages/wrangler/src/dev.tsx +++ b/packages/wrangler/src/dev.tsx @@ -28,6 +28,7 @@ interface DevArgs { config?: string; script?: string; name?: string; + build?: boolean; format?: string; env?: string; "compatibility-date"?: string; @@ -58,156 +59,174 @@ interface DevArgs { } export function devOptions(yargs: Argv): Argv { - return yargs - .positional("script", { - describe: "The path to an entry point for your worker", - type: "string", - }) - .option("name", { - describe: "Name of the worker", - type: "string", - requiresArg: true, - }) - .option("format", { - choices: ["modules", "service-worker"] as const, - describe: "Choose an entry type", - deprecated: true, - }) - .option("env", { - describe: "Perform on a specific environment", - type: "string", - requiresArg: true, - alias: "e", - }) - .option("compatibility-date", { - describe: "Date to use for compatibility checks", - type: "string", - requiresArg: true, - }) - .option("compatibility-flags", { - describe: "Flags to use for compatibility checks", - alias: "compatibility-flag", - type: "string", - requiresArg: true, - array: true, - }) - .option("latest", { - describe: "Use the latest version of the worker runtime", - type: "boolean", - default: true, - }) - .option("ip", { - describe: "IP address to listen on, defaults to `localhost`", - type: "string", - requiresArg: true, - }) - .option("port", { - describe: "Port to listen on", - type: "number", - }) - .option("inspector-port", { - describe: "Port for devtools to connect to", - type: "number", - }) - .option("routes", { - describe: "Routes to upload", - alias: "route", - type: "string", - requiresArg: true, - array: true, - }) - .option("host", { - type: "string", - requiresArg: true, - describe: "Host to forward requests to, defaults to the zone of project", - }) - .option("local-protocol", { - describe: "Protocol to listen to requests on, defaults to http.", - choices: ["http", "https"] as const, - }) - .options("local-upstream", { - type: "string", - describe: - "Host to act as origin in local mode, defaults to dev.host or route", - }) - .option("experimental-public", { - describe: "Static assets to be served", - type: "string", - requiresArg: true, - deprecated: true, - hidden: true, - }) - .option("assets", { - describe: "Static assets to be served", - type: "string", - requiresArg: true, - }) - .option("site", { - describe: "Root folder of static assets for Workers Sites", - type: "string", - requiresArg: true, - }) - .option("site-include", { - describe: - "Array of .gitignore-style patterns that match file or directory names from the sites directory. Only matched items will be uploaded.", - type: "string", - requiresArg: true, - array: true, - }) - .option("site-exclude", { - describe: - "Array of .gitignore-style patterns that match file or directory names from the sites directory. Matched items will not be uploaded.", - type: "string", - requiresArg: true, - array: true, - }) - .option("upstream-protocol", { - describe: "Protocol to forward requests to host on, defaults to https.", - choices: ["http", "https"] as const, - }) - .option("jsx-factory", { - describe: "The function that is called for each JSX element", - type: "string", - requiresArg: true, - }) - .option("jsx-fragment", { - describe: "The function that is called for each JSX fragment", - type: "string", - requiresArg: true, - }) - .option("tsconfig", { - describe: "Path to a custom tsconfig.json file", - type: "string", - requiresArg: true, - }) - .option("local", { - alias: "l", - describe: "Run on my machine", - type: "boolean", - default: false, // I bet this will a point of contention. We'll revisit it. - }) - .option("minify", { - describe: "Minify the script", - type: "boolean", - }) - .option("node-compat", { - describe: "Enable node.js compatibility", - type: "boolean", - }) - .option("experimental-enable-local-persistence", { - describe: "Enable persistence for this session (only for local mode)", - type: "boolean", - }) - .option("inspect", { - describe: "Enable dev tools", - type: "boolean", - deprecated: true, - }) - .option("legacy-env", { - type: "boolean", - describe: "Use legacy environments", - hidden: true, - }); + return ( + yargs + .positional("script", { + describe: "The path to an entry point for your worker", + type: "string", + }) + .option("name", { + describe: "Name of the worker", + type: "string", + requiresArg: true, + }) + // We want to have a --no-build flag, but yargs requires that + // we also have a --build flag (that it adds the --no to by itself) + // So we make a --build flag, but hide it, and then add a --no-build flag + // that's visible to the user but doesn't "do" anything. + .option("build", { + describe: "Run wrangler's compilation step before publishing", + type: "boolean", + default: true, + hidden: true, + }) + .option("no-build", { + describe: "Skip internal build steps and directly publish script", + type: "boolean", + default: false, + }) + .option("format", { + choices: ["modules", "service-worker"] as const, + describe: "Choose an entry type", + deprecated: true, + }) + .option("env", { + describe: "Perform on a specific environment", + type: "string", + requiresArg: true, + alias: "e", + }) + .option("compatibility-date", { + describe: "Date to use for compatibility checks", + type: "string", + requiresArg: true, + }) + .option("compatibility-flags", { + describe: "Flags to use for compatibility checks", + alias: "compatibility-flag", + type: "string", + requiresArg: true, + array: true, + }) + .option("latest", { + describe: "Use the latest version of the worker runtime", + type: "boolean", + default: true, + }) + .option("ip", { + describe: "IP address to listen on, defaults to `localhost`", + type: "string", + requiresArg: true, + }) + .option("port", { + describe: "Port to listen on", + type: "number", + }) + .option("inspector-port", { + describe: "Port for devtools to connect to", + type: "number", + }) + .option("routes", { + describe: "Routes to upload", + alias: "route", + type: "string", + requiresArg: true, + array: true, + }) + .option("host", { + type: "string", + requiresArg: true, + describe: + "Host to forward requests to, defaults to the zone of project", + }) + .option("local-protocol", { + describe: "Protocol to listen to requests on, defaults to http.", + choices: ["http", "https"] as const, + }) + .options("local-upstream", { + type: "string", + describe: + "Host to act as origin in local mode, defaults to dev.host or route", + }) + .option("experimental-public", { + describe: "Static assets to be served", + type: "string", + requiresArg: true, + deprecated: true, + hidden: true, + }) + .option("assets", { + describe: "Static assets to be served", + type: "string", + requiresArg: true, + }) + .option("site", { + describe: "Root folder of static assets for Workers Sites", + type: "string", + requiresArg: true, + }) + .option("site-include", { + describe: + "Array of .gitignore-style patterns that match file or directory names from the sites directory. Only matched items will be uploaded.", + type: "string", + requiresArg: true, + array: true, + }) + .option("site-exclude", { + describe: + "Array of .gitignore-style patterns that match file or directory names from the sites directory. Matched items will not be uploaded.", + type: "string", + requiresArg: true, + array: true, + }) + .option("upstream-protocol", { + describe: "Protocol to forward requests to host on, defaults to https.", + choices: ["http", "https"] as const, + }) + .option("jsx-factory", { + describe: "The function that is called for each JSX element", + type: "string", + requiresArg: true, + }) + .option("jsx-fragment", { + describe: "The function that is called for each JSX fragment", + type: "string", + requiresArg: true, + }) + .option("tsconfig", { + describe: "Path to a custom tsconfig.json file", + type: "string", + requiresArg: true, + }) + .option("local", { + alias: "l", + describe: "Run on my machine", + type: "boolean", + default: false, // I bet this will a point of contention. We'll revisit it. + }) + .option("minify", { + describe: "Minify the script", + type: "boolean", + }) + .option("node-compat", { + describe: "Enable node.js compatibility", + type: "boolean", + }) + .option("experimental-enable-local-persistence", { + describe: "Enable persistence for this session (only for local mode)", + type: "boolean", + }) + .option("inspect", { + describe: "Enable dev tools", + type: "boolean", + deprecated: true, + }) + .option("legacy-env", { + type: "boolean", + describe: "Use legacy environments", + hidden: true, + }) + ); } export async function devHandler(args: ArgumentsCamelCase) { @@ -411,6 +430,7 @@ export async function devHandler(args: ArgumentsCamelCase) { return ( (); const { exit } = useApp(); useEffect(() => { let stopWatching: (() => void) | undefined = undefined; + function updateBundle() { + // nothing really changes here, so let's increment the id + // to change the return object's identity + setBundle((previousBundle) => { + assert( + previousBundle, + "Rebuild triggered with no previous build available" + ); + return { ...previousBundle, id: previousBundle.id + 1 }; + }); + } + const watchMode: WatchMode = { async onRebuild(error) { if (error) logger.error("Watch build failed:", error); else { - // nothing really changes here, so let's increment the id - // to change the return object's identity - setBundle((previousBundle) => { - assert( - previousBundle, - "Rebuild triggered with no previous build available" - ); - return { ...previousBundle, id: previousBundle.id + 1 }; - }); + updateBundle(); } }, }; @@ -64,22 +71,47 @@ export function useEsbuild({ async function build() { if (!destination) return; - const { resolvedEntryPointPath, bundleType, modules, stop } = - await bundleWorker(entry, destination, { - serveAssetsFromWorker, - jsxFactory, - jsxFragment, - rules, - watch: watchMode, - tsconfig, - minify, - nodeCompat, - define, - }); + const { + resolvedEntryPointPath, + bundleType, + modules, + stop, + }: Awaited> = noBuild + ? { + modules: [], + resolvedEntryPointPath: entry.file, + bundleType: entry.format === "modules" ? "esm" : "commonjs", + stop: undefined, + } + : await bundleWorker(entry, destination, { + serveAssetsFromWorker, + jsxFactory, + jsxFragment, + rules, + watch: watchMode, + tsconfig, + minify, + nodeCompat, + define, + }); // Capture the `stop()` method to use as the `useEffect()` destructor. stopWatching = stop; + // if "noBuild" is true, then we need to manually watch the entry point and + // trigger "builds" when it changes + if (noBuild) { + const watcher = watch(entry.file, { + persistent: true, + }).on("change", async (_event) => { + updateBundle(); + }); + + stopWatching = () => { + watcher.close(); + }; + } + setBundle({ id: 0, entry, @@ -108,6 +140,7 @@ export function useEsbuild({ rules, tsconfig, exit, + noBuild, minify, nodeCompat, define, diff --git a/packages/wrangler/src/index.tsx b/packages/wrangler/src/index.tsx index dabe3ad0777d..a387241f7c80 100644 --- a/packages/wrangler/src/index.tsx +++ b/packages/wrangler/src/index.tsx @@ -353,127 +353,144 @@ function createCLIParser(argv: string[]) { "publish [script]", "🆙 Publish your Worker to Cloudflare.", (yargs) => { - return yargs - .option("env", { - type: "string", - requiresArg: true, - describe: "Perform on a specific environment", - alias: "e", - }) - .positional("script", { - describe: "The path to an entry point for your worker", - type: "string", - requiresArg: true, - }) - .option("name", { - describe: "Name of the worker", - type: "string", - requiresArg: true, - }) - .option("outdir", { - describe: "Output directory for the bundled worker", - type: "string", - requiresArg: true, - }) - .option("format", { - choices: ["modules", "service-worker"] as const, - describe: "Choose an entry type", - deprecated: true, - }) - .option("compatibility-date", { - describe: "Date to use for compatibility checks", - type: "string", - requiresArg: true, - }) - .option("compatibility-flags", { - describe: "Flags to use for compatibility checks", - alias: "compatibility-flag", - type: "string", - requiresArg: true, - array: true, - }) - .option("latest", { - describe: "Use the latest version of the worker runtime", - type: "boolean", - default: false, - }) - .option("experimental-public", { - describe: "Static assets to be served", - type: "string", - requiresArg: true, - deprecated: true, - hidden: true, - }) - .option("assets", { - describe: "Static assets to be served", - type: "string", - requiresArg: true, - }) - .option("site", { - describe: "Root folder of static assets for Workers Sites", - type: "string", - requiresArg: true, - }) - .option("site-include", { - describe: - "Array of .gitignore-style patterns that match file or directory names from the sites directory. Only matched items will be uploaded.", - type: "string", - requiresArg: true, - array: true, - }) - .option("site-exclude", { - describe: - "Array of .gitignore-style patterns that match file or directory names from the sites directory. Matched items will not be uploaded.", - type: "string", - requiresArg: true, - array: true, - }) - .option("triggers", { - describe: "cron schedules to attach", - alias: ["schedule", "schedules"], - type: "string", - requiresArg: true, - array: true, - }) - .option("routes", { - describe: "Routes to upload", - alias: "route", - type: "string", - requiresArg: true, - array: true, - }) - .option("jsx-factory", { - describe: "The function that is called for each JSX element", - type: "string", - requiresArg: true, - }) - .option("jsx-fragment", { - describe: "The function that is called for each JSX fragment", - type: "string", - requiresArg: true, - }) - .option("tsconfig", { - describe: "Path to a custom tsconfig.json file", - type: "string", - requiresArg: true, - }) - .option("minify", { - describe: "Minify the script", - type: "boolean", - }) - .option("node-compat", { - describe: "Enable node.js compatibility", - type: "boolean", - }) - .option("dry-run", { - describe: "Don't actually publish", - type: "boolean", - }) - .option("legacy-env", { - type: "boolean", - describe: "Use legacy environments", - hidden: true, - }); + return ( + yargs + .option("env", { + type: "string", + requiresArg: true, + describe: "Perform on a specific environment", + alias: "e", + }) + .positional("script", { + describe: "The path to an entry point for your worker", + type: "string", + requiresArg: true, + }) + .option("name", { + describe: "Name of the worker", + type: "string", + requiresArg: true, + }) + // We want to have a --no-build flag, but yargs requires that + // we also have a --build flag (that it adds the --no to by itself) + // So we make a --build flag, but hide it, and then add a --no-build flag + // that's visible to the user but doesn't "do" anything. + .option("build", { + describe: "Run wrangler's compilation step before publishing", + type: "boolean", + default: true, + hidden: true, + }) + .option("no-build", { + describe: "Skip internal build steps and directly publish script", + type: "boolean", + default: false, + }) + .option("outdir", { + describe: "Output directory for the bundled worker", + type: "string", + requiresArg: true, + }) + .option("format", { + choices: ["modules", "service-worker"] as const, + describe: "Choose an entry type", + deprecated: true, + }) + .option("compatibility-date", { + describe: "Date to use for compatibility checks", + type: "string", + requiresArg: true, + }) + .option("compatibility-flags", { + describe: "Flags to use for compatibility checks", + alias: "compatibility-flag", + type: "string", + requiresArg: true, + array: true, + }) + .option("latest", { + describe: "Use the latest version of the worker runtime", + type: "boolean", + default: false, + }) + .option("experimental-public", { + describe: "Static assets to be served", + type: "string", + requiresArg: true, + deprecated: true, + hidden: true, + }) + .option("assets", { + describe: "Static assets to be served", + type: "string", + requiresArg: true, + }) + .option("site", { + describe: "Root folder of static assets for Workers Sites", + type: "string", + requiresArg: true, + }) + .option("site-include", { + describe: + "Array of .gitignore-style patterns that match file or directory names from the sites directory. Only matched items will be uploaded.", + type: "string", + requiresArg: true, + array: true, + }) + .option("site-exclude", { + describe: + "Array of .gitignore-style patterns that match file or directory names from the sites directory. Matched items will not be uploaded.", + type: "string", + requiresArg: true, + array: true, + }) + .option("triggers", { + describe: "cron schedules to attach", + alias: ["schedule", "schedules"], + type: "string", + requiresArg: true, + array: true, + }) + .option("routes", { + describe: "Routes to upload", + alias: "route", + type: "string", + requiresArg: true, + array: true, + }) + .option("jsx-factory", { + describe: "The function that is called for each JSX element", + type: "string", + requiresArg: true, + }) + .option("jsx-fragment", { + describe: "The function that is called for each JSX fragment", + type: "string", + requiresArg: true, + }) + .option("tsconfig", { + describe: "Path to a custom tsconfig.json file", + type: "string", + requiresArg: true, + }) + .option("minify", { + describe: "Minify the script", + type: "boolean", + }) + .option("node-compat", { + describe: "Enable node.js compatibility", + type: "boolean", + }) + .option("dry-run", { + describe: "Don't actually publish", + type: "boolean", + }) + .option("legacy-env", { + type: "boolean", + describe: "Use legacy environments", + hidden: true, + }) + ); }, async (args) => { await printWranglerBanner(); @@ -546,6 +563,7 @@ function createCLIParser(argv: string[]) { isWorkersSite: Boolean(args.site || config.site), outDir: args.outdir, dryRun: args.dryRun, + noBuild: !args.build, }); } ); diff --git a/packages/wrangler/src/publish.ts b/packages/wrangler/src/publish.ts index 5066d3f2413e..82579e299b33 100644 --- a/packages/wrangler/src/publish.ts +++ b/packages/wrangler/src/publish.ts @@ -46,6 +46,7 @@ type Props = { nodeCompat: boolean | undefined; outDir: string | undefined; dryRun: boolean | undefined; + noBuild: boolean | undefined; }; type RouteObject = ZoneIdRoute | ZoneNameRoute | CustomDomainRoute; @@ -340,21 +341,44 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m ); } try { - const { modules, resolvedEntryPointPath, bundleType } = await bundleWorker( - props.entry, - typeof destination === "string" ? destination : destination.path, - { - serveAssetsFromWorker: - !props.isWorkersSite && Boolean(props.assetPaths), - jsxFactory, - jsxFragment, - rules: props.rules, - tsconfig: props.tsconfig ?? config.tsconfig, - minify, - nodeCompat, - define: config.define, - } - ); + if (props.noBuild) { + // if we're not building, let's just copy the entry to the destination directory + const destinationDir = + typeof destination === "string" ? destination : destination.path; + mkdirSync(destinationDir, { recursive: true }); + writeFileSync( + path.join(destinationDir, path.basename(props.entry.file)), + readFileSync(props.entry.file, "utf-8") + ); + } + + const { + modules, + resolvedEntryPointPath, + bundleType, + }: Awaited> = props.noBuild + ? // we can skip the whole bundling step and mock a bundle here + { + modules: [], + resolvedEntryPointPath: props.entry.file, + bundleType: props.entry.format === "modules" ? "esm" : "commonjs", + stop: undefined, + } + : await bundleWorker( + props.entry, + typeof destination === "string" ? destination : destination.path, + { + serveAssetsFromWorker: + !props.isWorkersSite && Boolean(props.assetPaths), + jsxFactory, + jsxFragment, + rules: props.rules, + tsconfig: props.tsconfig ?? config.tsconfig, + minify, + nodeCompat, + define: config.define, + } + ); const content = readFileSync(resolvedEntryPointPath, { encoding: "utf-8",