Skip to content

Commit

Permalink
refactor: share worker bundling between both publish and dev comm…
Browse files Browse the repository at this point in the history
…ands

This changes moves the code that does the esbuild bundling into a shared file
and updates the `publish` and `dev` to use it, rather than duplicating the
behaviour.

See #396
Resolves #401
  • Loading branch information
petebacondarwin committed Feb 11, 2022
1 parent 78acd24 commit 2bb8d21
Show file tree
Hide file tree
Showing 4 changed files with 221 additions and 150 deletions.
12 changes: 12 additions & 0 deletions .changeset/small-rules-invite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"wrangler": patch
---

refactor: share worker bundling between both `publish` and `dev` commands

This changes moves the code that does the esbuild bundling into a shared file
and updates the `publish` and `dev` to use it, rather than duplicating the
behaviour.

See #396
Resolves #401
119 changes: 119 additions & 0 deletions packages/wrangler/src/bundle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import assert from "node:assert";
import * as fs from "node:fs";
import * as path from "node:path";
import * as esbuild from "esbuild";
import makeModuleCollector from "./module-collection";
import type { CfModule, CfScriptFormat } from "./api/worker";

type BundleResult = {
modules: CfModule[];
resolvedEntryPointPath: string;
bundleType: "esm" | "commonjs";
stop: (() => void) | undefined;
};

/**
* Generate a bundle for the worker identified by the arguments passed in.
*/
export async function bundleWorker(
entryFile: string,
serveAssetsFromWorker: boolean,
workingDir: string,
destination: string,
jsxFactory: string | undefined,
jsxFragment: string | undefined,
format: CfScriptFormat,
watch?: esbuild.WatchMode
): Promise<BundleResult> {
const moduleCollector = makeModuleCollector({ format });
const result = await esbuild.build({
...getEntryPoint(entryFile, serveAssetsFromWorker),
bundle: true,
absWorkingDir: workingDir,
outdir: destination,
external: ["__STATIC_CONTENT_MANIFEST"],
format: "esm",
sourcemap: true,
metafile: true,
conditions: ["worker", "browser"],
loader: {
".js": "jsx",
".html": "text",
".pem": "text",
".txt": "text",
},
plugins: [moduleCollector.plugin],
...(jsxFactory && { jsxFactory }),
...(jsxFragment && { jsxFragment }),
watch,
});

const entryPointOutputs = Object.entries(result.metafile.outputs).filter(
([_path, output]) => output.entryPoint !== undefined
);
assert(
entryPointOutputs.length > 0,
`Cannot find entry-point "${entryFile}" in generated bundle.` +
listEntryPoints(entryPointOutputs)
);
assert(
entryPointOutputs.length < 2,
"More than one entry-point found for generated bundle." +
listEntryPoints(entryPointOutputs)
);

const entryPointExports = entryPointOutputs[0][1].exports;
const bundleType = entryPointExports.length > 0 ? "esm" : "commonjs";

return {
modules: moduleCollector.modules,
resolvedEntryPointPath: path.resolve(workingDir, entryPointOutputs[0][0]),
bundleType,
stop: result.stop,
};
}

type EntryPoint =
| { stdin: esbuild.StdinOptions; nodePaths: string[] }
| { entryPoints: string[] };

/**
* Create an object that describes the entry point for esbuild.
*
* If we are using the experimental asset handling, then the entry point is
* actually a shim worker that will either return an asset from a KV store,
* or delegate to the actual worker.
*/
function getEntryPoint(
entryFile: string,
serveAssetsFromWorker: boolean
): EntryPoint {
if (serveAssetsFromWorker) {
return {
stdin: {
contents: fs
.readFileSync(
path.join(__dirname, "../templates/static-asset-facade.js"),
"utf8"
)
.replace("__ENTRY_POINT__", entryFile),
sourcefile: "static-asset-facade.js",
resolveDir: path.dirname(entryFile),
},
nodePaths: [path.join(__dirname, "../vendor")],
};
} else {
return { entryPoints: [entryFile] };
}
}

/**
* Generate a string that describes the entry-points that were identified by esbuild.
*/
function listEntryPoints(
outputs: [string, ValueOf<esbuild.Metafile["outputs"]>][]
): string {
return outputs.map(([_input, output]) => output.entryPoint).join("\n");
}

type ValueOf<T> = T[keyof T];
136 changes: 67 additions & 69 deletions packages/wrangler/src/dev.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import path from "node:path";
import { watch } from "chokidar";
import clipboardy from "clipboardy";
import commandExists from "command-exists";
import * as esbuild from "esbuild";
import { execaCommand } from "execa";
import { Box, Text, useApp, useInput } from "ink";
import React, { useState, useEffect, useRef } from "react";
Expand All @@ -15,16 +14,17 @@ import onExit from "signal-exit";
import tmp from "tmp-promise";
import { fetch } from "undici";
import { createWorker } from "./api/worker";
import { bundleWorker } from "./bundle";
import guessWorkerFormat from "./guess-worker-format";
import useInspector from "./inspect";
import makeModuleCollector from "./module-collection";
import openInBrowser from "./open-in-browser";
import { usePreviewServer, waitForPortToBeAvailable } from "./proxy";
import { syncAssets } from "./sites";
import { getAPIToken } from "./user";
import type { CfPreviewToken } from "./api/preview";
import type { CfModule, CfWorkerInit, CfScriptFormat } from "./api/worker";
import type { AssetPaths } from "./sites";
import type { WatchMode } from "esbuild";
import type { DirectoryResult } from "tmp-promise";

export type DevProps = {
Expand Down Expand Up @@ -81,6 +81,7 @@ function Dev(props: DevProps): JSX.Element {
staticRoot: props.public,
jsxFactory: props.jsxFactory,
jsxFragment: props.jsxFragment,
serveAssetsFromWorker: !!props.public,
});

const toggles = useHotkeys(
Expand Down Expand Up @@ -512,95 +513,92 @@ type EsbuildBundle = {
path: string;
entry: string;
type: "esm" | "commonjs";
exports: string[];
modules: CfModule[];
serveAssetsFromWorker: boolean;
};

function useEsbuild(props: {
function useEsbuild({
entry,
destination,
staticRoot,
jsxFactory,
jsxFragment,
format,
serveAssetsFromWorker,
}: {
entry: undefined | string;
destination: string | undefined;
format: CfScriptFormat | undefined;
staticRoot: undefined | string;
jsxFactory: string | undefined;
jsxFragment: string | undefined;
serveAssetsFromWorker: boolean;
}): EsbuildBundle | undefined {
const { entry, destination, staticRoot, jsxFactory, jsxFragment, format } =
props;
const [bundle, setBundle] = useState<EsbuildBundle>();
useEffect(() => {
let result: esbuild.BuildResult | undefined;
let stopWatching: (() => void) | undefined = undefined;

const watchMode: WatchMode = {
async onRebuild(error) {
if (error) console.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 };
});
}
},
};

async function build() {
if (!destination || !entry || !format) return;
const moduleCollector = makeModuleCollector({ format });
result = await esbuild.build({
entryPoints: [entry],
bundle: true,
outdir: destination,
metafile: true,
format: "esm",
sourcemap: true,
loader: {
".js": "jsx",
".html": "text",
".pem": "text",
".txt": "text",
},
...(jsxFactory && { jsxFactory }),
...(jsxFragment && { jsxFragment }),
external: ["__STATIC_CONTENT_MANIFEST"],
conditions: ["worker", "browser"],
plugins: [moduleCollector.plugin],
// TODO: import.meta.url
watch: {
async onRebuild(error) {
if (error) console.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) => {
if (previousBundle === undefined) {
assert.fail(
"Rebuild triggered with no previous build available"
);
}
return { ...previousBundle, id: previousBundle.id + 1 };
});
}
},
},
});

// result.metafile is defined because of the `metafile: true` option above.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const metafile = result.metafile!;
const outputEntry = Object.entries(metafile.outputs).find(
([_path, { entryPoint }]) => entryPoint === entry
); // assumedly only one entry point

if (outputEntry === undefined) {
throw new Error(
`Cannot find entry-point "${entry}" in generated bundle.`
const { resolvedEntryPointPath, bundleType, modules, stop } =
await bundleWorker(
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,
format,
watchMode
);
}

// Capture the `stop()` method to use as the `useEffect()` destructor.
stopWatching = stop;

setBundle({
id: 0,
entry,
path: outputEntry[0],
type: outputEntry[1].exports.length > 0 ? "esm" : "commonjs",
exports: outputEntry[1].exports,
modules: moduleCollector.modules,
path: resolvedEntryPointPath,
type: bundleType,
modules,
serveAssetsFromWorker,
});
}
build().catch((_err) => {
// esbuild already logs errors to stderr
// and we don't want to end the process
// on build errors anyway
// so this is a no-op error handler

build().catch(() => {
// esbuild already logs errors to stderr and we don't want to end the process
// on build errors anyway so this is a no-op error handler
});
return () => {
result?.stop?.();
};
}, [entry, destination, staticRoot, jsxFactory, jsxFragment, format]);

return stopWatching;
}, [
entry,
destination,
staticRoot,
jsxFactory,
jsxFragment,
format,
serveAssetsFromWorker,
]);
return bundle;
}

Expand Down
Loading

0 comments on commit 2bb8d21

Please sign in to comment.