diff --git a/.changeset/quiet-pots-kiss.md b/.changeset/quiet-pots-kiss.md new file mode 100644 index 000000000000..4cef5243a2dc --- /dev/null +++ b/.changeset/quiet-pots-kiss.md @@ -0,0 +1,7 @@ +--- +"wrangler": patch +--- + +feat: reload server on configuration changes, the values passed into the server during restart will be `bindings` + +resolves #439 diff --git a/packages/wrangler/src/index.tsx b/packages/wrangler/src/index.tsx index 0e9b9141f5e2..ed1730eaef4f 100644 --- a/packages/wrangler/src/index.tsx +++ b/packages/wrangler/src/index.tsx @@ -4,6 +4,7 @@ import path from "node:path"; import { setTimeout } from "node:timers/promises"; import TOML from "@iarna/toml"; import chalk from "chalk"; +import { watch } from "chokidar"; import { execa } from "execa"; import { findUp } from "find-up"; import getPort from "get-port"; @@ -1035,197 +1036,229 @@ function createCLIParser(argv: string[]) { }); }, async (args) => { - await printWranglerBanner(); - const configPath = - (args.config as ConfigPath) || - (args.script && findWranglerToml(path.dirname(args.script))); - const config = readConfig(configPath, args); - const entry = await getEntry(args, config, "dev"); - - if (config.services && config.services.length > 0) { - logger.warn( - `This worker is bound to live services: ${config.services - .map( - (service) => - `${service.binding} (${service.service}${ - service.environment ? `@${service.environment}` : "" - })` - ) - .join(", ")}` - ); - } - - if (args.inspect) { - logger.warn( - "Passing --inspect is unnecessary, now you can always connect to devtools." - ); - } - - if (args["experimental-public"]) { - logger.warn( - "The --experimental-public field is experimental and will change in the future." - ); - } + let watcher: ReturnType | undefined; + try { + await printWranglerBanner(); + const configPath = + (args.config as ConfigPath) || + ((args.script && + findWranglerToml(path.dirname(args.script))) as ConfigPath); + let config = readConfig(configPath, args); + + if (config.configPath) { + watcher = watch(config.configPath, { + persistent: true, + }).on("change", async (_event) => { + // TODO: Do we need to handle different `_event` types differently? + // e.g. what if the file is deleted, or added? + config = readConfig(configPath, args); + if (config.configPath) { + logger.log(`${path.basename(config.configPath)} changed...`); + rerender(await getDevReactElement(config)); + } + }); + } - if (args.public) { - throw new Error( - "The --public field has been renamed to --experimental-public, and will change behaviour in the future." - ); - } + const entry = await getEntry(args, config, "dev"); - const upstreamProtocol = - args["upstream-protocol"] || config.dev.upstream_protocol; - if (upstreamProtocol === "http") { - logger.warn( - "Setting upstream-protocol to http is not currently implemented.\n" + - "If this is required in your project, please add your use case to the following issue:\n" + - "https://github.com/cloudflare/wrangler2/issues/583." - ); - } + if (config.services && config.services.length > 0) { + logger.warn( + `This worker is bound to live services: ${config.services + .map( + (service) => + `${service.binding} (${service.service}${ + service.environment ? `@${service.environment}` : "" + })` + ) + .join(", ")}` + ); + } - // TODO: if worker_dev = false and no routes, then error (only for dev) + if (args.inspect) { + logger.warn( + "Passing --inspect is unnecessary, now you can always connect to devtools." + ); + } - // Compute zone info from the `host` and `route` args and config; - let host = args.host || config.dev.host; - let zoneId: string | undefined; + if (args["experimental-public"]) { + logger.warn( + "The --experimental-public field is experimental and will change in the future." + ); + } - if (!args.local) { - if (host) { - zoneId = await getZoneIdFromHost(host); + if (args.public) { + throw new Error( + "The --public field has been renamed to --experimental-public, and will change behaviour in the future." + ); } - const routes = args.routes || config.route || config.routes; - if (!zoneId && routes) { - const firstRoute = Array.isArray(routes) ? routes[0] : routes; - const zone = await getZoneForRoute(firstRoute); - if (zone) { - zoneId = zone.id; - host = zone.host; - } + + const upstreamProtocol = + args["upstream-protocol"] || config.dev.upstream_protocol; + if (upstreamProtocol === "http") { + logger.warn( + "Setting upstream-protocol to http is not currently implemented.\n" + + "If this is required in your project, please add your use case to the following issue:\n" + + "https://github.com/cloudflare/wrangler2/issues/583." + ); } - } - const nodeCompat = args.nodeCompat ?? config.node_compat; - if (nodeCompat) { - logger.warn( - "Enabling node.js compatibility mode for built-ins and globals. This is experimental and has serious tradeoffs. Please see https://github.com/ionic-team/rollup-plugin-node-polyfills/ for more details." - ); - } + // TODO: if worker_dev = false and no routes, then error (only for dev) - const bindings = { - kv_namespaces: config.kv_namespaces?.map( - ({ binding, preview_id, id: _id }) => { - // In `dev`, we make folks use a separate kv namespace called - // `preview_id` instead of `id` so that they don't - // break production data. So here we check that a `preview_id` - // has actually been configured. - // This whole block of code will be obsoleted in the future - // when we have copy-on-write for previews on edge workers. - if (!preview_id) { - // TODO: This error has to be a _lot_ better, ideally just asking - // to create a preview namespace for the user automatically - throw new Error( - `In development, you should use a separate kv namespace than the one you'd use in production. Please create a new kv namespace with "wrangler kv:namespace create --preview" and add its id as preview_id to the kv_namespace "${binding}" in your wrangler.toml` - ); // Ugh, I really don't like this message very much - } - return { - binding, - id: preview_id, - }; + // Compute zone info from the `host` and `route` args and config; + let host = args.host || config.dev.host; + let zoneId: string | undefined; + + if (!args.local) { + if (host) { + zoneId = await getZoneIdFromHost(host); } - ), - // Use a copy of combinedVars since we're modifying it later - vars: getVarsForDev(config), - wasm_modules: config.wasm_modules, - text_blobs: config.text_blobs, - data_blobs: config.data_blobs, - durable_objects: config.durable_objects, - r2_buckets: config.r2_buckets?.map( - ({ binding, preview_bucket_name, bucket_name: _bucket_name }) => { - // same idea as kv namespace preview id, - // same copy-on-write TODO - if (!preview_bucket_name) { - throw new Error( - `In development, you should use a separate r2 bucket than the one you'd use in production. Please create a new r2 bucket with "wrangler r2 bucket create " and add its name as preview_bucket_name to the r2_buckets "${binding}" in your wrangler.toml` - ); + const routes = args.routes || config.route || config.routes; + if (!zoneId && routes) { + const firstRoute = Array.isArray(routes) ? routes[0] : routes; + const zone = await getZoneForRoute(firstRoute); + if (zone) { + zoneId = zone.id; + host = zone.host; } - return { - binding, - bucket_name: preview_bucket_name, - }; } - ), - services: config.services, - unsafe: config.unsafe?.bindings, - }; + } - // mask anything that was overridden in .dev.vars - // so that we don't log potential secrets into the terminal - const maskedVars = { ...bindings.vars }; - for (const key of Object.keys(maskedVars)) { - if (maskedVars[key] !== config.vars[key]) { - // This means it was overridden in .dev.vars - // so let's mask it - maskedVars[key] = "(hidden)"; + const nodeCompat = args.nodeCompat ?? config.node_compat; + if (nodeCompat) { + logger.warn( + "Enabling node.js compatibility mode for built-ins and globals. This is experimental and has serious tradeoffs. Please see https://github.com/ionic-team/rollup-plugin-node-polyfills/ for more details." + ); } - } - // now log all available bindings into the terminal - printBindings({ - ...bindings, - vars: maskedVars, - }); + // eslint-disable-next-line no-inner-declarations + async function getBindings(configParam: Config) { + return { + kv_namespaces: configParam.kv_namespaces?.map( + ({ binding, preview_id, id: _id }) => { + // In `dev`, we make folks use a separate kv namespace called + // `preview_id` instead of `id` so that they don't + // break production data. So here we check that a `preview_id` + // has actually been configured. + // This whole block of code will be obsoleted in the future + // when we have copy-on-write for previews on edge workers. + if (!preview_id) { + // TODO: This error has to be a _lot_ better, ideally just asking + // to create a preview namespace for the user automatically + throw new Error( + `In development, you should use a separate kv namespace than the one you'd use in production. Please create a new kv namespace with "wrangler kv:namespace create --preview" and add its id as preview_id to the kv_namespace "${binding}" in your wrangler.toml` + ); // Ugh, I really don't like this message very much + } + return { + binding, + id: preview_id, + }; + } + ), + // Use a copy of combinedVars since we're modifying it later + vars: getVarsForDev(configParam), + wasm_modules: configParam.wasm_modules, + text_blobs: configParam.text_blobs, + data_blobs: configParam.data_blobs, + durable_objects: configParam.durable_objects, + r2_buckets: configParam.r2_buckets?.map( + ({ binding, preview_bucket_name, bucket_name: _bucket_name }) => { + // same idea as kv namespace preview id, + // same copy-on-write TODO + if (!preview_bucket_name) { + throw new Error( + `In development, you should use a separate r2 bucket than the one you'd use in production. Please create a new r2 bucket with "wrangler r2 bucket create " and add its name as preview_bucket_name to the r2_buckets "${binding}" in your wrangler.toml` + ); + } + return { + binding, + bucket_name: preview_bucket_name, + }; + } + ), + services: configParam.services, + unsafe: configParam.unsafe?.bindings, + }; + } - const { waitUntilExit } = render( - - ); - await waitUntilExit(); + + printBindings({ + ...bindings, + vars: maskedVars, + }); + + return ( + + ); + } + const { waitUntilExit, rerender } = render( + await getDevReactElement(config) + ); + await waitUntilExit(); + } finally { + await watcher?.close(); + } } ); @@ -2801,3 +2834,13 @@ function getDevCompatibilityDate( } return compatibilityDate ?? currentDate; } + +/** + * Avoiding calling `getPort()` multiple times by memoizing the first result. + */ +function memoizeGetPort(defaultPort: number) { + let portValue: number; + return async () => { + return portValue || (portValue = await getPort({ port: defaultPort })); + }; +} diff --git a/packages/wrangler/src/proxy.ts b/packages/wrangler/src/proxy.ts index 214f913f2d26..a4362be72133 100644 --- a/packages/wrangler/src/proxy.ts +++ b/packages/wrangler/src/proxy.ts @@ -100,7 +100,10 @@ export function usePreviewServer({ .then((server) => { setProxy({ server, - terminator: createHttpTerminator({ server }), + terminator: createHttpTerminator({ + server, + gracefulTerminationTimeout: 0, + }), }); }) .catch(async (err) => { @@ -330,7 +333,9 @@ export function usePreviewServer({ abortController.abort(); // Running `proxy.server.close()` does not close open connections, preventing the process from exiting. // So we use this `terminator` to close all the connections and force the server to shutdown. - proxy.terminator.terminate(); + proxy.terminator + .terminate() + .catch(() => logger.error("Failed to terminate the proxy server.")); }; }, [port, ip, proxy, localProtocol]); } @@ -450,16 +455,24 @@ export async function waitForPortToBeAvailable( // trying to make a server listen on that port, and retrying // until it succeeds. const server = createHttpServer(); + const terminator = createHttpTerminator({ + server, + gracefulTerminationTimeout: 0, // default 1000 + }); + server.on("error", (err) => { // @ts-expect-error non standard property on Error if (err.code !== "EADDRINUSE") { doReject(err); } }); - server.listen(port, () => { - server.close(); - doResolve(); - }); + server.listen(port, () => + terminator + .terminate() + .then(doResolve, () => + logger.error("Failed to terminate the port checker.") + ) + ); } }); }