Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fresh-tunnel-binaries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"cloudflared": minor
---

Extend `install` with options for a minimum binary version and a configurable refresh interval. Refresh CLI-managed binaries every 30 days by default, preserve explicit version pins, and keep working binaries when an optional refresh fails.
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ jobs:
run: pnpm build

- name: Test (w/ service)
run: sudo -E ${{ steps.pnpm.outputs.bin_dest }}/pnpm test
run: sudo --preserve-env=CI,VERBOSE,CLOUDFLARED_VERSION,TUNNEL_TOKEN,PNPM_HOME ${{ steps.pnpm.outputs.bin_dest }}/pnpm test
if: matrix.os != 'windows-latest'

- name: Test (w/o service)
Expand Down
35 changes: 33 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

A Node.js package that allows you to easily create HTTPS tunnels using Cloudflare's `cloudflared` command-line tool. It provides a typed API for creating tunnels and managing the `cloudflared` binary installation.

> This tool will automatically install the [latest version of `cloudflared`](https://github.com/cloudflare/cloudflared/releases/latest) (or `CLOUDFLARED_VERSION` env var if exists) at the first time.
> Then, it just passes down the command to `cloudflared`.
> This tool automatically installs the [latest version of `cloudflared`](https://github.com/cloudflare/cloudflared/releases/latest) on first use and updates to the latest version at most once every 30 days.
> Set `CLOUDFLARED_VERSION` to install an exact version and disable automatic updates.

## Installation

Expand All @@ -29,6 +29,9 @@ yarn global add cloudflared

> If `CLOUDFLARED_VERSION` env var is set, it will install the specified version of `cloudflared`, otherwise it will install the latest version.

Set `CLOUDFLARED_UPDATE_INTERVAL` to the interval in milliseconds to change the CLI's
update frequency, or `0` to disable periodic updates.

## CLI Usage

You can use the `cloudflared` command-line tool to create HTTPS tunnels. You can find the usage of `cloudflared` [here](https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/tunnel-useful-commands/).
Expand Down Expand Up @@ -73,6 +76,34 @@ spawn(bin, ["--version"], { stdio: "inherit" });
- `bin`: The path of the binary.
- `install`: A function that installs the binary to the given path.

### Automatic updates

Library consumers can pass options to `install` before spawning the binary or
creating a `Tunnel`. Synchronous `Tunnel` constructors do not check for updates.

```js
import { bin, install } from "cloudflared";
import { spawn } from "node:child_process";

await install(bin, {
minimumVersion: "2026.10.2",
});
spawn(bin, ["--version"], { stdio: "inherit" });
```

- `minimumVersion`: Upgrade an older binary immediately, even if a periodic check
is not due. Missing or older binaries install the latest release, unless a
version is pinned.
- `checkInterval`: Milliseconds between optional update attempts. Omitting it or
passing `undefined` uses the default of 30 days. Set it to `0` to disable periodic
updates while still installing missing binaries and enforcing the minimum.

`CLOUDFLARED_VERSION` pins take precedence over periodic updates. A pin below
`minimumVersion` is rejected.

For compatibility, an exact version can be installed with `install(path, version)`.
Passing a version string or omitting the second argument always downloads the binary.

### Tunnel

Checkout [`examples/tunnel.mjs`](examples/tunnel.mjs) (or [`examples/tunnel.js`](examples/tunnel.js) for CommonJS).
Expand Down
60 changes: 60 additions & 0 deletions src/_tests/download.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { EventEmitter } from "node:events";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { PassThrough } from "node:stream";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { install_linux } from "../install.js";

const mocks = vi.hoisted(() => ({ get: vi.fn() }));
vi.mock("node:https", () => ({ default: { get: mocks.get } }));

let directory: string;
let binary: string;
let response: PassThrough & { statusCode: number };
let request: EventEmitter & {
destroy: ReturnType<typeof vi.fn>;
setTimeout: ReturnType<typeof vi.fn>;
end: ReturnType<typeof vi.fn>;
};

beforeEach(() => {
directory = fs.mkdtempSync(path.join(os.tmpdir(), "cloudflared-download-test-"));
binary = path.join(directory, "cloudflared");
response = Object.assign(new PassThrough(), { statusCode: 200 });
request = Object.assign(new EventEmitter(), {
destroy: vi.fn((error?: Error) => {
if (error) request.emit("error", error);
}),
setTimeout: vi.fn(),
end: vi.fn(),
});
mocks.get.mockImplementation((_url, callback) => {
queueMicrotask(() => callback(response));
return request;
});
});

afterEach(() => {
fs.rmSync(directory, { recursive: true, force: true });
});

describe("download", () => {
it("rejects an interrupted response and removes the partial file", async () => {
const installed = install_linux(binary, "2026.10.2");
queueMicrotask(() => {
response.write("partial binary");
response.destroy(new Error("connection reset"));
});
await expect(installed).rejects.toThrow("connection reset");
expect(fs.existsSync(binary)).toBe(false);
});

it("bounds stalled download requests", async () => {
mocks.get.mockReturnValue(request);
const installed = install_linux(binary, "2026.10.2");
expect(request.setTimeout).toHaveBeenCalledWith(30_000, expect.any(Function));
request.setTimeout.mock.calls[0][1]();
await expect(installed).rejects.toThrow("download timed out");
});
});
5 changes: 4 additions & 1 deletion src/_tests/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ChildProcess } from "node:child_process";
import { ChildProcess, execFileSync } from "node:child_process";
import fs from "node:fs";
import { Tunnel, bin, install, service } from "../lib.js";
import { describe, it, expect, beforeAll } from "vitest";
Expand All @@ -14,6 +14,9 @@ describe("install", { timeout: 60_000 }, () => {

await install(bin);
expect(fs.existsSync(bin)).toBe(true);
expect(execFileSync(bin, ["--version"], { encoding: "utf8" })).toMatch(
/^cloudflared version \d+\.\d+\.\d+/,
);
});
});

Expand Down
189 changes: 189 additions & 0 deletions src/_tests/install-update.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { EventEmitter } from "node:events";
import { Readable } from "node:stream";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({ exec: vi.fn(), download: vi.fn() }));
vi.mock("node:child_process", async (original) => {
const actual = await original<typeof import("node:child_process")>();
const { promisify } = await import("node:util");
return {
...actual,
execFile: Object.assign(vi.fn(), { [promisify.custom]: mocks.exec }),
execSync: vi.fn((_command: string, { cwd }: { cwd: string }) => {
const archive = fs.readdirSync(cwd).find((file) => file.endsWith(".tgz"))!;
fs.writeFileSync(
path.join(cwd, "cloudflared"),
fs.readFileSync(path.join(cwd, archive)),
);
}),
};
});
vi.mock("node:https", () => ({
default: {
get: (url: string, callback: (response: Readable) => void) => {
const request = Object.assign(new EventEmitter(), {
setTimeout: vi.fn(),
end: vi.fn(),
destroy: vi.fn(),
});
queueMicrotask(() => {
try {
callback(
Object.assign(Readable.from([mocks.download(url)]), {
statusCode: 200,
headers: {},
}),
);
} catch (error) {
request.emit("error", error);
}
});
return request;
},
},
}));

let directory: string;
let binary: string;
let downloaded: string;

beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
vi.stubEnv("CLOUDFLARED_VERSION", "latest");
directory = fs.mkdtempSync(path.join(os.tmpdir(), "cloudflared-update-test-"));
binary = path.join(directory, "cloudflared");
downloaded = "2026.10.2";
mocks.exec.mockImplementation(async (file: string) => ({
stdout: `cloudflared version ${fs.readFileSync(file, "utf8")} (built today)\n`,
}));
mocks.download.mockImplementation(() => downloaded);
});

afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
fs.rmSync(directory, { recursive: true, force: true });
});

async function ensure(options: import("../install.js").InstallOptions = {}) {
const { install } = await import("../install.js");
return install(binary, options);
}

function writeBinary(version: string, daysOld = 0) {
fs.writeFileSync(binary, version);
const time = new Date(Date.now() - daysOld * 24 * 60 * 60 * 1000);
fs.utimesSync(binary, time, time);
}

describe("conditional install", () => {
it("installs a missing binary through a separate staging directory", async () => {
await expect(ensure({ minimumVersion: "2026.10.1" })).resolves.toBe(binary);
expect(mocks.download).toHaveBeenCalledWith(expect.stringContaining("/latest/download/"));
expect(path.dirname(mocks.exec.mock.calls[1][0])).not.toBe(directory);
expect(fs.readFileSync(binary, "utf8")).toBe(downloaded);
expect(fs.readdirSync(directory).sort()).toEqual(["cloudflared", "cloudflared.last-check"]);
});

it.each([undefined, 7 * 24 * 60 * 60 * 1000])(
"refreshes only when the default or custom interval has elapsed: %s",
async (checkInterval) => {
const days = checkInterval === undefined ? 30 : 7;
writeBinary("2026.9.0", days - 1);
await ensure({ checkInterval });
expect(mocks.download).not.toHaveBeenCalled();

writeBinary("2026.9.0", days + 1);
await ensure({ checkInterval });
await ensure({ checkInterval });
expect(mocks.download).toHaveBeenCalledTimes(1);
expect(fs.readFileSync(binary, "utf8")).toBe(downloaded);
},
);

it("disables periodic refreshes while still enforcing a numeric minimum", async () => {
writeBinary("2026.10.2", 60);
await ensure({ minimumVersion: "2026.9.0", checkInterval: 0 });
expect(mocks.download).not.toHaveBeenCalled();

downloaded = "2026.11.0";
await ensure({ minimumVersion: "2026.10.3", checkInterval: 0 });
expect(mocks.download).toHaveBeenCalledWith(expect.stringContaining("/latest/download/"));
expect(fs.readFileSync(binary, "utf8")).toBe(downloaded);
});

it("respects an exact version pin and rejects a conflicting minimum", async () => {
vi.stubEnv("CLOUDFLARED_VERSION", downloaded);
writeBinary(downloaded, 60);
await ensure();
expect(mocks.download).not.toHaveBeenCalled();

writeBinary("2026.9.0");
await ensure();
expect(mocks.download).toHaveBeenCalledWith(
expect.stringContaining(`/download/${downloaded}/`),
);
expect(fs.readFileSync(binary, "utf8")).toBe(downloaded);
await expect(ensure({ minimumVersion: "2026.11.0" })).rejects.toThrow(
"CLOUDFLARED_VERSION is older than minimumVersion",
);
});

it.each(["download", "replacement"])(
"keeps the working binary after a failed %s and waits before retrying",
async (failure) => {
writeBinary("2026.9.0", 31);
if (failure === "download") {
mocks.download.mockImplementation(() => {
throw new Error("offline");
});
} else {
vi.spyOn(fs, "renameSync").mockImplementation(() => {
throw new Error("EBUSY");
});
}
await ensure();
await ensure();
expect(mocks.download).toHaveBeenCalledTimes(1);
expect(fs.readFileSync(binary, "utf8")).toBe("2026.9.0");
expect(fs.readdirSync(directory).sort()).toEqual([
"cloudflared",
"cloudflared.last-check",
]);
},
);

it("propagates required installation failures without damaging the installed binary", async () => {
mocks.download.mockImplementation(() => {
throw new Error("offline");
});
writeBinary("2026.9.0");
await expect(ensure({ minimumVersion: downloaded })).rejects.toThrow("offline");
expect(fs.readFileSync(binary, "utf8")).toBe("2026.9.0");

fs.unlinkSync(binary);
await expect(ensure()).rejects.toThrow("offline");
expect(fs.existsSync(binary)).toBe(false);
});
});

describe("latest or specified version installation", () => {
it("always downloads with omitted or explicit versions", async () => {
writeBinary(downloaded);
const { install } = await import("../install.js");
await install(binary);
expect(mocks.download).toHaveBeenLastCalledWith(
expect.stringContaining("/latest/download/"),
);
await install(binary, downloaded);
expect(mocks.download).toHaveBeenLastCalledWith(
expect.stringContaining(`/download/${downloaded}/`),
);
expect(mocks.download).toHaveBeenCalledTimes(2);
expect(mocks.exec).not.toHaveBeenCalled();
});
});
10 changes: 7 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,13 @@ export async function main(): Promise<void> {
}
}

if (!fs.existsSync(bin)) {
console.log("Installed cloudflared to " + (await install(bin)));
}
await install(bin, {
checkInterval: process.env.CLOUDFLARED_BIN
? 0
: process.env.CLOUDFLARED_UPDATE_INTERVAL === undefined
? undefined
: Number(process.env.CLOUDFLARED_UPDATE_INTERVAL),
});

const sub = spawn(bin, args, { stdio: "inherit" });

Expand Down
Loading
Loading