Skip to content
Closed
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/tall-gorillas-taste.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"cloudflared": minor
---

Tunnel class with custom output parser
6 changes: 3 additions & 3 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
48 changes: 16 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}
Expand All @@ -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
```

Expand Down
45 changes: 45 additions & 0 deletions examples/events.mjs
Original file line number Diff line number Diff line change
@@ -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());
});
16 changes: 7 additions & 9 deletions examples/tunnel.js
Original file line number Diff line number Diff line change
@@ -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);
});
}
16 changes: 7 additions & 9 deletions examples/tunnel.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
}
133 changes: 133 additions & 0 deletions src/handler.ts
Original file line number Diff line number Diff line change
@@ -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<T> {
config: (config: { config: T; version: number }) => void;
error: (error: Error) => void;
}

export interface TunnelConfig {
ingress: Record<string, string>[];
warp_routing: { enabled: boolean };
}

export class ConfigHandler<T = TunnelConfig> 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<E extends keyof ConfigHandlerEvents<T>>(
event: E,
listener: ConfigHandlerEvents<T>[E],
): this {
return super.on(event, listener);
}
public once<E extends keyof ConfigHandlerEvents<T>>(
event: E,
listener: ConfigHandlerEvents<T>[E],
): this {
return super.once(event, listener);
}
public off<E extends keyof ConfigHandlerEvents<T>>(
event: E,
listener: ConfigHandlerEvents<T>[E],
): this {
return super.off(event, listener);
}
public emit<E extends keyof ConfigHandlerEvents<T>>(
event: E,
...args: Parameters<ConfigHandlerEvents<T>[E]>
): boolean {
return super.emit(event, ...args);
}
}
9 changes: 5 additions & 4 deletions src/lib.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
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,
MACOS_SERVICE_PATH,
AlreadyInstalledError,
NotInstalledError,
} from "./service.js";
export type { Connection } from "./types.js";
export type * from "./types.js";
export * from "./handler.js";
Loading
Loading