diff --git a/.changeset/tall-gorillas-taste.md b/.changeset/tall-gorillas-taste.md new file mode 100644 index 0000000..191a4f1 --- /dev/null +++ b/.changeset/tall-gorillas-taste.md @@ -0,0 +1,5 @@ +--- +"cloudflared": minor +--- + +Tunnel class with custom output parser diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a849402..700b8a4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,10 +20,10 @@ jobs: - macos-latest version: - "latest" - - "2024.8.2" + - "2024.12.1" + - "2024.10.1" + - "2024.8.3" - "2024.6.1" - - "2024.4.1" - - "2024.2.1" name: "${{ matrix.os }} - ${{ matrix.version }}" runs-on: ${{ matrix.os }} diff --git a/README.md b/README.md index 8956933..1167558 100644 --- a/README.md +++ b/README.md @@ -77,29 +77,30 @@ spawn(bin, ["--version"], { stdio: "inherit" }); Checkout [`examples/tunnel.js`](examples/tunnel.js). +`Tunnel` is inherited from `EventEmitter`, so you can listen to the events it emits, checkout [`examples/events.mjs`](examples/events.mjs). + ```js -import { tunnel } from "cloudflared"; +import { Tunnel } from "cloudflared"; console.log("Cloudflared Tunnel Example."); main(); async function main() { // run: cloudflared tunnel --hello-world - const { url, connections, child, stop } = tunnel({ "--hello-world": null }); + const tunnel = Tunnel.quick(); // show the url + const url = new Promise((resolve) => tunnel.once("url", resolve)); console.log("LINK:", await url); - // wait for the all 4 connections to be established - const conns = await Promise.all(connections); - - // show the connections - console.log("Connections Ready!", conns); + // wait for connection to be established + const conn = new Promise((resolve) => tunnel.once("connected", resolve)); + console.log("CONN:", await conn); // stop the tunnel after 15 seconds - setTimeout(stop, 15_000); + setTimeout(tunnel.stop, 15_000); - child.on("exit", (code) => { + tunnel.on("exit", (code) => { console.log("tunnel process exited with code", code); }); } @@ -108,29 +109,12 @@ async function main() { ```sh ❯ node examples/tunnel.js Cloudflared Tunnel Example. -LINK: https://aimed-our-bite-brought.trycloudflare.com -Connections Ready! [ - { - id: 'd4681cd9-217d-40e2-9e15-427f9fb77856', - ip: '198.41.200.23', - location: 'MIA' - }, - { - id: 'b40d2cdd-0b99-4838-b1eb-9a58a6999123', - ip: '198.41.192.107', - location: 'LAX' - }, - { - id: '55545211-3f63-4722-99f1-d5fea688dabf', - ip: '198.41.200.53', - location: 'MIA' - }, - { - id: 'f3d5938a-d48c-463c-a4f7-a158782a0ddb', - ip: '198.41.192.77', - location: 'LAX' - } -] +LINK: https://mailto-davis-wilderness-facts.trycloudflare.com +CONN: { + id: 'df1b8330-44ea-4ecb-bb93-8a32400f6d1c', + ip: '198.41.200.193', + location: 'tpe01' +} tunnel process exited with code 0 ``` diff --git a/examples/events.mjs b/examples/events.mjs new file mode 100644 index 0000000..bd48c8f --- /dev/null +++ b/examples/events.mjs @@ -0,0 +1,45 @@ +import { Tunnel, ConfigHandler } from "cloudflared"; + +const token = process.env.CLOUDFLARED_TOKEN; +if (!token) { + throw new Error("CLOUDFLARED_TOKEN is not set"); +} + +const tunnel = Tunnel.withToken(token); +const handler = new ConfigHandler(tunnel); + +handler.on("config", ({ config }) => { + console.log("Config", config); +}); + +tunnel.on("url", (url) => { + console.log("Tunnel is ready at", url); +}); + +tunnel.on("connected", (connection) => { + console.log("Connected to", connection); +}); + +tunnel.on("disconnected", (connection) => { + console.log("Disconnected from", connection); +}); + +tunnel.on("stdout", (data) => { + console.log("Tunnel stdout", data); +}); + +tunnel.on("stderr", (data) => { + console.error("Tunnel stderr", data); +}); + +tunnel.on("exit", (code, signal) => { + console.log("Tunnel exited with code", code, "and signal", signal); +}); + +tunnel.on("error", (error) => { + console.error("Error", error); +}); + +process.on("SIGINT", () => { + console.log("Tunnel stopped", tunnel.stop()); +}); diff --git a/examples/tunnel.js b/examples/tunnel.js index edab5de..cf912be 100644 --- a/examples/tunnel.js +++ b/examples/tunnel.js @@ -1,25 +1,23 @@ -const { tunnel } = require("cloudflared"); +const { Tunnel } = require("cloudflared"); console.log("Cloudflared Tunnel Example."); main(); async function main() { // run: cloudflared tunnel --hello-world - const { url, connections, child, stop } = tunnel({ "--hello-world": null }); + const tunnel = Tunnel.quick(); // show the url + const url = new Promise((resolve) => tunnel.once("url", resolve)); console.log("LINK:", await url); - // wait for the all 4 connections to be established - const conns = await Promise.all(connections); - - // show the connections - console.log("Connections Ready!", conns); + const conn = new Promise((resolve) => tunnel.once("connected", resolve)); + console.log("CONN:", await conn); // stop the tunnel after 15 seconds - setTimeout(stop, 15_000); + setTimeout(tunnel.stop, 15_000); - child.on("exit", (code) => { + tunnel.on("exit", (code) => { console.log("tunnel process exited with code", code); }); } diff --git a/examples/tunnel.mjs b/examples/tunnel.mjs index 4e6c68a..c8d8cc9 100644 --- a/examples/tunnel.mjs +++ b/examples/tunnel.mjs @@ -1,25 +1,23 @@ -import { tunnel } from "cloudflared"; +import { Tunnel } from "cloudflared"; console.log("Cloudflared Tunnel Example."); main(); async function main() { // run: cloudflared tunnel --hello-world - const { url, connections, child, stop } = tunnel({ "--hello-world": null }); + const tunnel = Tunnel.quick(); // show the url + const url = new Promise((resolve) => tunnel.once("url", resolve)); console.log("LINK:", await url); - // wait for the all 4 connections to be established - const conns = await Promise.all(connections); - - // show the connections - console.log("Connections Ready!", conns); + const conn = new Promise((resolve) => tunnel.once("connected", resolve)); + console.log("CONN:", await conn); // stop the tunnel after 15 seconds - setTimeout(stop, 15_000); + setTimeout(tunnel.stop, 15_000); - child.on("exit", (code) => { + tunnel.on("exit", (code) => { console.log("tunnel process exited with code", code); }); } diff --git a/src/_tests/index.test.ts b/src/_tests/index.test.ts index af2d402..0b2119d 100644 --- a/src/_tests/index.test.ts +++ b/src/_tests/index.test.ts @@ -1,12 +1,13 @@ import { ChildProcess } from "node:child_process"; import fs from "node:fs"; -import { bin, install, tunnel, service } from "../lib.js"; +import { Tunnel, bin, install, service } from "../lib.js"; import { describe, it, expect, beforeAll } from "vitest"; process.env.VERBOSE = "1"; describe( "install", + { timeout: 60_000 }, () => { it("should install binary", async () => { if (fs.existsSync(bin)) { @@ -18,61 +19,55 @@ describe( expect(fs.existsSync(bin)).toBe(true); }); }, - { timeout: 60_000 }, ); describe( "tunnel", + { timeout: 60_000 }, () => { it("should create a tunnel", async () => { - const { url, connections, child, stop } = tunnel({ - "--url": "localhost:8080", - "--no-autoupdate": "true", - }); + const tunnel = new Tunnel(["tunnel", "--url", "localhost:8080", "--no-autoupdate"]); + const url = new Promise((resolve) => tunnel.once("url", resolve)); expect(await url).toMatch(/https?:\/\/[^\s]+/); - await connections[0]; // quick tunnel only has one connection - expect(child).toBeInstanceOf(ChildProcess); - stop(); + const conn = new Promise((resolve) => tunnel.once("connected", resolve)); + await conn; // quick tunnel only has one connection + expect(tunnel.process).toBeInstanceOf(ChildProcess); + tunnel.stop(); }); }, - { timeout: 60_000 }, ); -describe( - "service", - () => { - const TOKEN = process.env.TUNNEL_TOKEN; - const should_run = - TOKEN && - ["darwin", "linux"].includes(process.platform) && - !(process.platform === "linux" && process.getuid?.() !== 0); - if (should_run) { - beforeAll(() => { - if (service.exists()) { - service.uninstall(); - } - }); - } - - it("should work", async (ctx) => { - if (!should_run) { - ctx.skip(); +describe("service", { timeout: 60_000 }, () => { + const TOKEN = process.env.TUNNEL_TOKEN; + const should_run = + TOKEN && + ["darwin", "linux"].includes(process.platform) && + !(process.platform === "linux" && process.getuid?.() !== 0); + if (should_run) { + beforeAll(() => { + if (service.exists()) { + service.uninstall(); } - expect(service.exists()).toBe(false); - service.install(TOKEN); + }); + } - await new Promise((r) => setTimeout(r, 15_000)); + it("should work", async (ctx) => { + if (!should_run) { + ctx.skip(); + } + expect(service.exists()).toBe(false); + service.install(TOKEN); - expect(service.exists()).toBe(true); - const current = service.current(); - expect(current.tunnelID.length).toBeGreaterThan(0); - expect(current.connectorID.length).toBeGreaterThan(0); - expect(current.connections.length).toBeGreaterThan(0); - expect(current.metrics.length).toBeGreaterThan(0); - expect(current.config.ingress?.length).toBeGreaterThan(0); + await new Promise((r) => setTimeout(r, 15_000)); - service.uninstall(); - }); - }, - { timeout: 60_000 }, -); + expect(service.exists()).toBe(true); + const current = service.current(); + expect(current.tunnelID.length).toBeGreaterThan(0); + expect(current.connectorID.length).toBeGreaterThan(0); + expect(current.connections.length).toBeGreaterThan(0); + expect(current.metrics.length).toBeGreaterThan(0); + expect(current.config.ingress?.length).toBeGreaterThan(0); + + service.uninstall(); + }); +}); diff --git a/src/handler.ts b/src/handler.ts new file mode 100644 index 0000000..bcaca53 --- /dev/null +++ b/src/handler.ts @@ -0,0 +1,133 @@ +import { EventEmitter } from "node:stream"; +import { conn_regex, ip_regex, location_regex, index_regex } from "./regex"; +import type { OutputHandler, Tunnel } from "./tunnel.js"; +import type { Connection } from "./types.js"; + +export class ConnectionHandler { + private connections: (Connection | undefined)[] = []; + + constructor(tunnel: Tunnel) { + tunnel.addHandler(this.connected_handler.bind(this)); + tunnel.addHandler(this.disconnected_handler.bind(this)); + } + + private connected_handler: OutputHandler = (output, tunnel) => { + // Registered tunnel connection connIndex=0 connection=4db5ec6e-4076-45c5-8752-745071bc2567 event=0 ip=198.41.200.193 location=tpe01 protocol=quic + const conn_match = output.match(conn_regex); + const ip_match = output.match(ip_regex); + const location_match = output.match(location_regex); + const index_match = output.match(index_regex); + + if (conn_match && ip_match && location_match && index_match) { + const connection = { + id: conn_match[1], + ip: ip_match[1], + location: location_match[1], + }; + this.connections[Number(index_match[1])] = connection; + tunnel.emit("connected", connection); + } + }; + + private disconnected_handler: OutputHandler = (output, tunnel) => { + // Connection terminated error="connection with edge closed" connIndex=1 + const index_match = output.includes("terminated") ? output.match(index_regex) : null; + if (index_match) { + const index = Number(index_match[1]); + if (this.connections[index]) { + tunnel.emit("disconnected", this.connections[index]); + this.connections[index] = undefined; + } + } + }; +} + +export class TryCloudflareHandler { + constructor(tunnel: Tunnel) { + tunnel.addHandler(this.url_handler.bind(this)); + } + + private url_handler: OutputHandler = (output, tunnel) => { + // https://xxxxxxxxxx.trycloudflare.com + const url_match = output.match(/https:\/\/([a-z0-9-]+)\.trycloudflare\.com/); + if (url_match) { + tunnel.emit("url", url_match[0]); + } + }; +} + +export interface ConfigHandlerEvents { + config: (config: { config: T; version: number }) => void; + error: (error: Error) => void; +} + +export interface TunnelConfig { + ingress: Record[]; + warp_routing: { enabled: boolean }; +} + +export class ConfigHandler extends EventEmitter { + constructor(tunnel: Tunnel) { + super(); + tunnel.addHandler(this.config_handler.bind(this)); + } + + private config_handler: OutputHandler = (output, tunnel) => { + // Updated to new configuration config="{\"ingress\":[{\"hostname\":\"host.mydomain.com\", \"service\":\"http://localhost:1234\"}, {\"service\":\"http_status:404\"}], \"warp-routing\":{\"enabled\":false}}" version=1 + const config_match = output.match(/\bconfig="(.+?)" version=(\d+)/); + + if (config_match) { + try { + // Parse the escaped JSON string + const config_str = config_match[1].replace(/\\"/g, '"'); + const config: T = JSON.parse(config_str); + const version = parseInt(config_match[2], 10); + + this.emit("config", { + config, + version, + }); + + if ( + config && + typeof config === "object" && + "ingress" in config && + Array.isArray(config.ingress) + ) { + for (const ingress of config.ingress) { + if ("hostname" in ingress) { + tunnel.emit("url", ingress.hostname); + } + } + } + } catch (error) { + this.emit("error", new Error(`Failed to parse config: ${error}`)); + } + } + }; + + public on>( + event: E, + listener: ConfigHandlerEvents[E], + ): this { + return super.on(event, listener); + } + public once>( + event: E, + listener: ConfigHandlerEvents[E], + ): this { + return super.once(event, listener); + } + public off>( + event: E, + listener: ConfigHandlerEvents[E], + ): this { + return super.off(event, listener); + } + public emit>( + event: E, + ...args: Parameters[E]> + ): boolean { + return super.emit(event, ...args); + } +} diff --git a/src/lib.ts b/src/lib.ts index 3e52700..0551b83 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -1,6 +1,6 @@ -export { bin } from "./constants.js"; -export { install } from "./install.js"; -export { tunnel } from "./tunnel.js"; +export * from "./constants.js"; +export * from "./install.js"; +export * from "./tunnel.js"; export { service, identifier, @@ -8,4 +8,5 @@ export { AlreadyInstalledError, NotInstalledError, } from "./service.js"; -export type { Connection } from "./types.js"; +export type * from "./types.js"; +export * from "./handler.js"; diff --git a/src/tunnel.ts b/src/tunnel.ts index eb0800b..c74cb7d 100644 --- a/src/tunnel.ts +++ b/src/tunnel.ts @@ -1,82 +1,189 @@ import { spawn, ChildProcess } from "node:child_process"; +import { EventEmitter } from "node:events"; import { bin } from "./constants.js"; import { Connection } from "./types.js"; -import { conn_regex, ip_regex, location_regex, index_regex } from "./regex.js"; +import { ConnectionHandler, TryCloudflareHandler } from "./handler.js"; -/** - * Create a tunnel. - * @param options The options to pass to cloudflared. - * @returns - */ -export function tunnel(options: Record = {}): { - /** The URL of the tunnel */ - url: Promise; - /** The connections of the tunnel */ - connections: Promise[]; - /** Spwaned cloudflared process */ - child: ChildProcess; - /** Stop the cloudflared process */ - stop: ChildProcess["kill"]; -} { - const args: string[] = ["tunnel"]; - for (const [key, value] of Object.entries(options)) { - if (typeof value === "string") { - args.push(`${key}`, value); - } else if (typeof value === "number") { - args.push(`${key}`, value.toString()); - } else if (value === null) { - args.push(`${key}`); +export type TunnelOptions = Record; + +export interface TunnelEvents { + // Status events + url: (url: string) => void; + connected: (connection: Connection) => void; + disconnected: (connection: Connection) => void; + + // Process events + stdout: (data: string) => void; + stderr: (data: string) => void; + error: (error: Error) => void; + exit: (code: number | null, signal: NodeJS.Signals | null) => void; +} + +export type OutputHandler = (output: string, tunnel: Tunnel) => void; + +export class Tunnel extends EventEmitter { + private _process: ChildProcess; + private outputHandlers: OutputHandler[] = []; + + constructor(options: TunnelOptions | string[] = ["tunnel", "--hello-world"]) { + super(); + this.setupDefaultHandlers(); + const args = Array.isArray(options) ? options : build_args(options); + this._process = this.createProcess(args); + this.setupEventHandlers(); + } + + public get process(): ChildProcess { + return this._process; + } + + private setupDefaultHandlers() { + new ConnectionHandler(this); + new TryCloudflareHandler(this); + } + + /** + * Add a custom output handler + * @param handler Function to handle cloudflared output + */ + public addHandler(handler: OutputHandler): void { + this.outputHandlers.push(handler); + } + + /** + * Remove a previously added output handler + * @param handler The handler to remove + */ + public removeHandler(handler: OutputHandler): void { + const index = this.outputHandlers.indexOf(handler); + if (index !== -1) { + this.outputHandlers.splice(index, 1); } } - if (args.length === 1) { - args.push("--url", "localhost:8080"); + + private processOutput(output: string): void { + // Run all handlers on the output + for (const handler of this.outputHandlers) { + try { + handler(output, this); + } catch (error) { + this.emit("error", error instanceof Error ? error : new Error(String(error))); + } + } } - const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] }); + private setupEventHandlers() { + // cloudflared outputs to stderr, but I think its better to listen to stdout too + this.on("stdout", (output) => { + this.processOutput(output); + }).on("error", (err) => { + this.emit("error", err); + }); - if (process.env.VERBOSE) { - child.stdout.pipe(process.stdout); - child.stderr.pipe(process.stderr); + this.on("stderr", (output) => { + this.processOutput(output); + }).on("error", (err) => { + this.emit("error", err); + }); } - const url_regex = /\|\s+(https?:\/\/[^\s]+)/; - let url_resolver: (value: string | PromiseLike) => void = () => undefined; - let url_rejector: (reason: unknown) => void = () => undefined; - const url = new Promise((...pair) => ([url_resolver, url_rejector] = pair)); + private createProcess(args: string[]): ChildProcess { + const child = spawn(bin, args, { stdio: ["ignore", "pipe", "pipe"] }); + + child.on("error", (error) => this.emit("error", error)); + child.on("exit", (code, signal) => this.emit("exit", code, signal)); + + if (process.env.VERBOSE) { + child.stdout.pipe(process.stdout); + child.stderr.pipe(process.stderr); + } - const connection_resolvers: ((value: Connection | PromiseLike) => void)[] = []; - const connection_rejectors: ((reason: unknown) => void)[] = []; - const connections: Promise[] = []; - for (let i = 0; i < 1; i++) { - connections.push( - new Promise( - (...pair) => ([connection_resolvers[i], connection_rejectors[i]] = pair), - ), - ); + child.stdout?.on("data", (data) => this.emit("stdout", data.toString())); + child.stderr?.on("data", (data) => this.emit("stderr", data.toString())); + + return child; } - const parser = (data: Buffer) => { - const str = data.toString(); + public stop = this._stop.bind(this); + private _stop(): boolean { + return this.process.kill("SIGINT"); + } - const url_match = str.match(url_regex); - url_match && url_resolver(url_match[1]); + public on(event: E, listener: TunnelEvents[E]): this { + return super.on(event, listener); + } + public once(event: E, listener: TunnelEvents[E]): this { + return super.once(event, listener); + } + public off(event: E, listener: TunnelEvents[E]): this { + return super.off(event, listener); + } + public emit( + event: E, + ...args: Parameters + ): boolean { + return super.emit(event, ...args); + } - const conn_match = str.match(conn_regex); - const ip_match = str.match(ip_regex); - const location_match = str.match(location_regex); - const index_match = str.match(index_regex); - if (conn_match && ip_match && location_match && index_match) { - const [, id] = conn_match; - const [, ip] = ip_match; - const [, location] = location_match; - const [, idx] = index_match; - connection_resolvers[+idx]?.({ id, ip, location }); + /** + * Create a quick tunnel without a Cloudflare account. + * @param url The local service URL to connect to. If not provided, the hello world mode will be used. + * @param options The options to pass to cloudflared. + */ + public static quick(url?: string, options: TunnelOptions = {}): Tunnel { + const args = ["tunnel"]; + if (url) { + args.push("--url", url); + } else { + args.push("--hello-world"); } - }; - child.stdout.on("data", parser).on("error", url_rejector); - child.stderr.on("data", parser).on("error", url_rejector); + args.push(...build_options(options)); + return new Tunnel(args); + } + + /** + * Create a tunnel with a Cloudflare account. + * @param token The Cloudflare Tunnel token. + * @param options The options to pass to cloudflared. + */ + public static withToken(token: string, options: TunnelOptions = {}): Tunnel { + options["--token"] = token; + return new Tunnel(build_args(options)); + } +} + +/** + * Create a tunnel. + * @param options The options to pass to cloudflared. + * @returns A Tunnel instance + */ +export function tunnel(options: TunnelOptions = {}): Tunnel { + return new Tunnel(options); +} - const stop = () => child.kill("SIGINT"); +/** + * Build the arguments for the cloudflared command. + * @param options The options to pass to cloudflared. + * @returns The arguments for the cloudflared command. + */ +export function build_args(options: TunnelOptions): string[] { + const args: string[] = "--hello-world" in options ? ["tunnel"] : ["tunnel", "run"]; + args.push(...build_options(options)); + return args; +} - return { url, connections, child, stop }; +export function build_options(options: TunnelOptions): string[] { + const opts: string[] = []; + for (const [key, value] of Object.entries(options)) { + if (typeof value === "string") { + opts.push(`${key}`, value); + } else if (typeof value === "number") { + opts.push(`${key}`, value.toString()); + } else if (typeof value === "boolean") { + if (value === true) { + opts.push(`${key}`); + } + } + } + return opts; }