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 minimum-version enforcement and opt-in periodic updates. Enable periodic CLI updates with `CLOUDFLARED_UPDATE_INTERVAL`. Explicit version pins are preserved, and optional update failures keep the working binary.
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
36 changes: 34 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.
> Set `CLOUDFLARED_VERSION` to install an exact version.

## 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 a positive interval in milliseconds to enable
periodic CLI updates. For example, `2592000000` updates at most once every 30 days.

## 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,35 @@ 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",
checkInterval: 30 * 24 * 60 * 60 * 1000,
});
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`: Enable periodic updates to the newest version by specifying
the period in milliseconds.

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

An exact version can be installed with `install(path, version)`.
This form and `install(path)` reinstall the binary even if it is already present.
Pass an options object to reuse a suitable installed 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("updates only when the requested interval has elapsed", async () => {
const checkInterval = 7 * 24 * 60 * 60 * 1000;
await expect(ensure({ checkInterval: 0 })).rejects.toThrow("positive number");
writeBinary("2026.9.0", 6);
await ensure({ checkInterval });
expect(mocks.download).not.toHaveBeenCalled();

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

it("enforces the minimum without a periodic update interval", async () => {
writeBinary("2026.10.2", 60);
await ensure();
await ensure({ checkInterval: undefined });
await ensure({ minimumVersion: "2026.9.0" });
expect(mocks.download).not.toHaveBeenCalled();

downloaded = "2026.11.0";
await ensure({ minimumVersion: "2026.10.3" });
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({ checkInterval: 7 * 24 * 60 * 60 * 1000 });
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({ checkInterval: 30 * 24 * 60 * 60 * 1000 });
await ensure({ checkInterval: 30 * 24 * 60 * 60 * 1000 });
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();
});
});
9 changes: 6 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,12 @@ 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 || process.env.CLOUDFLARED_UPDATE_INTERVAL === undefined
? undefined
: Number(process.env.CLOUDFLARED_UPDATE_INTERVAL),
});

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

Expand Down
Loading
Loading