Skip to content

Commit 52570e5

Browse files
committed
feat: add Tunnel class
1 parent 9681e9c commit 52570e5

File tree

9 files changed

+390
-119
lines changed

9 files changed

+390
-119
lines changed

.changeset/tall-gorillas-taste.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"cloudflared": minor
3+
---
4+
5+
Tunnel class with custom output parser

.github/workflows/test.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@ jobs:
2020
- macos-latest
2121
version:
2222
- "latest"
23-
- "2024.8.2"
23+
- "2024.12.1"
24+
- "2024.10.1"
25+
- "2024.8.3"
2426
- "2024.6.1"
25-
- "2024.4.1"
26-
- "2024.2.1"
2727

2828
name: "${{ matrix.os }} - ${{ matrix.version }}"
2929
runs-on: ${{ matrix.os }}

README.md

Lines changed: 16 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -77,29 +77,30 @@ spawn(bin, ["--version"], { stdio: "inherit" });
7777

7878
Checkout [`examples/tunnel.js`](examples/tunnel.js).
7979

80+
`Tunnel` is inherited from `EventEmitter`, so you can listen to the events it emits, checkout [`examples/events.mjs`](examples/events.mjs).
81+
8082
```js
81-
import { tunnel } from "cloudflared";
83+
import { Tunnel } from "cloudflared";
8284

8385
console.log("Cloudflared Tunnel Example.");
8486
main();
8587

8688
async function main() {
8789
// run: cloudflared tunnel --hello-world
88-
const { url, connections, child, stop } = tunnel({ "--hello-world": null });
90+
const tunnel = Tunnel.quick();
8991

9092
// show the url
93+
const url = new Promise((resolve) => tunnel.once("url", resolve));
9194
console.log("LINK:", await url);
9295

93-
// wait for the all 4 connections to be established
94-
const conns = await Promise.all(connections);
95-
96-
// show the connections
97-
console.log("Connections Ready!", conns);
96+
// wait for connection to be established
97+
const conn = new Promise((resolve) => tunnel.once("connected", resolve));
98+
console.log("CONN:", await conn);
9899

99100
// stop the tunnel after 15 seconds
100-
setTimeout(stop, 15_000);
101+
setTimeout(tunnel.stop, 15_000);
101102

102-
child.on("exit", (code) => {
103+
tunnel.on("exit", (code) => {
103104
console.log("tunnel process exited with code", code);
104105
});
105106
}
@@ -108,29 +109,12 @@ async function main() {
108109
```sh
109110
❯ node examples/tunnel.js
110111
Cloudflared Tunnel Example.
111-
LINK: https://aimed-our-bite-brought.trycloudflare.com
112-
Connections Ready! [
113-
{
114-
id: 'd4681cd9-217d-40e2-9e15-427f9fb77856',
115-
ip: '198.41.200.23',
116-
location: 'MIA'
117-
},
118-
{
119-
id: 'b40d2cdd-0b99-4838-b1eb-9a58a6999123',
120-
ip: '198.41.192.107',
121-
location: 'LAX'
122-
},
123-
{
124-
id: '55545211-3f63-4722-99f1-d5fea688dabf',
125-
ip: '198.41.200.53',
126-
location: 'MIA'
127-
},
128-
{
129-
id: 'f3d5938a-d48c-463c-a4f7-a158782a0ddb',
130-
ip: '198.41.192.77',
131-
location: 'LAX'
132-
}
133-
]
112+
LINK: https://mailto-davis-wilderness-facts.trycloudflare.com
113+
CONN: {
114+
id: 'df1b8330-44ea-4ecb-bb93-8a32400f6d1c',
115+
ip: '198.41.200.193',
116+
location: 'tpe01'
117+
}
134118
tunnel process exited with code 0
135119
```
136120

examples/events.mjs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { Tunnel, ConfigHandler } from "cloudflared";
2+
3+
const token = process.env.CLOUDFLARED_TOKEN;
4+
if (!token) {
5+
throw new Error("CLOUDFLARED_TOKEN is not set");
6+
}
7+
8+
const tunnel = Tunnel.withToken(token);
9+
const handler = new ConfigHandler(tunnel);
10+
11+
handler.on("config", ({ config }) => {
12+
console.log("Config", config);
13+
});
14+
15+
tunnel.on("url", (url) => {
16+
console.log("Tunnel is ready at", url);
17+
});
18+
19+
tunnel.on("connected", (connection) => {
20+
console.log("Connected to", connection);
21+
});
22+
23+
tunnel.on("disconnected", (connection) => {
24+
console.log("Disconnected from", connection);
25+
});
26+
27+
tunnel.on("stdout", (data) => {
28+
console.log("Tunnel stdout", data);
29+
});
30+
31+
tunnel.on("stderr", (data) => {
32+
console.error("Tunnel stderr", data);
33+
});
34+
35+
tunnel.on("exit", (code, signal) => {
36+
console.log("Tunnel exited with code", code, "and signal", signal);
37+
});
38+
39+
tunnel.on("error", (error) => {
40+
console.error("Error", error);
41+
});
42+
43+
process.on("SIGINT", () => {
44+
console.log("Tunnel stopped", tunnel.stop());
45+
});

examples/tunnel.js

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,23 @@
1-
const { tunnel } = require("cloudflared");
1+
const { Tunnel } = require("cloudflared");
22

33
console.log("Cloudflared Tunnel Example.");
44
main();
55

66
async function main() {
77
// run: cloudflared tunnel --hello-world
8-
const { url, connections, child, stop } = tunnel({ "--hello-world": null });
8+
const tunnel = Tunnel.quick();
99

1010
// show the url
11+
const url = new Promise((resolve) => tunnel.once("url", resolve));
1112
console.log("LINK:", await url);
1213

13-
// wait for the all 4 connections to be established
14-
const conns = await Promise.all(connections);
15-
16-
// show the connections
17-
console.log("Connections Ready!", conns);
14+
const conn = new Promise((resolve) => tunnel.once("connected", resolve));
15+
console.log("CONN:", await conn);
1816

1917
// stop the tunnel after 15 seconds
20-
setTimeout(stop, 15_000);
18+
setTimeout(tunnel.stop, 15_000);
2119

22-
child.on("exit", (code) => {
20+
tunnel.on("exit", (code) => {
2321
console.log("tunnel process exited with code", code);
2422
});
2523
}

examples/tunnel.mjs

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,23 @@
1-
import { tunnel } from "cloudflared";
1+
import { Tunnel } from "cloudflared";
22

33
console.log("Cloudflared Tunnel Example.");
44
main();
55

66
async function main() {
77
// run: cloudflared tunnel --hello-world
8-
const { url, connections, child, stop } = tunnel({ "--hello-world": null });
8+
const tunnel = Tunnel.quick();
99

1010
// show the url
11+
const url = new Promise((resolve) => tunnel.once("url", resolve));
1112
console.log("LINK:", await url);
1213

13-
// wait for the all 4 connections to be established
14-
const conns = await Promise.all(connections);
15-
16-
// show the connections
17-
console.log("Connections Ready!", conns);
14+
const conn = new Promise((resolve) => tunnel.once("connected", resolve));
15+
console.log("CONN:", await conn);
1816

1917
// stop the tunnel after 15 seconds
20-
setTimeout(stop, 15_000);
18+
setTimeout(tunnel.stop, 15_000);
2119

22-
child.on("exit", (code) => {
20+
tunnel.on("exit", (code) => {
2321
console.log("tunnel process exited with code", code);
2422
});
2523
}

src/handler.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { EventEmitter } from "node:stream";
2+
import { conn_regex, ip_regex, location_regex, index_regex } from "./regex";
3+
import type { OutputHandler, Tunnel } from "./tunnel.js";
4+
import type { Connection } from "./types.js";
5+
6+
export class ConnectionHandler {
7+
private connections: (Connection | undefined)[] = [];
8+
9+
constructor(tunnel: Tunnel) {
10+
tunnel.addHandler(this.connected_handler.bind(this));
11+
tunnel.addHandler(this.disconnected_handler.bind(this));
12+
}
13+
14+
private connected_handler: OutputHandler = (output, tunnel) => {
15+
// Registered tunnel connection connIndex=0 connection=4db5ec6e-4076-45c5-8752-745071bc2567 event=0 ip=198.41.200.193 location=tpe01 protocol=quic
16+
const conn_match = output.match(conn_regex);
17+
const ip_match = output.match(ip_regex);
18+
const location_match = output.match(location_regex);
19+
const index_match = output.match(index_regex);
20+
21+
if (conn_match && ip_match && location_match && index_match) {
22+
const connection = {
23+
id: conn_match[1],
24+
ip: ip_match[1],
25+
location: location_match[1],
26+
};
27+
this.connections[Number(index_match[1])] = connection;
28+
tunnel.emit("connected", connection);
29+
}
30+
};
31+
32+
private disconnected_handler: OutputHandler = (output, tunnel) => {
33+
// Connection terminated error="connection with edge closed" connIndex=1
34+
const index_match = output.includes("terminated") ? output.match(index_regex) : null;
35+
if (index_match) {
36+
const index = Number(index_match[1]);
37+
if (this.connections[index]) {
38+
tunnel.emit("disconnected", this.connections[index]);
39+
this.connections[index] = undefined;
40+
}
41+
}
42+
};
43+
}
44+
45+
export class TryCloudflareHandler {
46+
constructor(tunnel: Tunnel) {
47+
tunnel.addHandler(this.url_handler.bind(this));
48+
}
49+
50+
private url_handler: OutputHandler = (output, tunnel) => {
51+
// https://xxxxxxxxxx.trycloudflare.com
52+
const url_match = output.match(/https:\/\/([a-z0-9-]+)\.trycloudflare\.com/);
53+
if (url_match) {
54+
tunnel.emit("url", url_match[0]);
55+
}
56+
};
57+
}
58+
59+
export interface ConfigHandlerEvents<T> {
60+
config: (config: { config: T; version: number }) => void;
61+
error: (error: Error) => void;
62+
}
63+
64+
export interface TunnelConfig {
65+
ingress: Record<string, string>[];
66+
warp_routing: { enabled: boolean };
67+
}
68+
69+
export class ConfigHandler<T = TunnelConfig> extends EventEmitter {
70+
constructor(tunnel: Tunnel) {
71+
super();
72+
tunnel.addHandler(this.config_handler.bind(this));
73+
}
74+
75+
private config_handler: OutputHandler = (output, tunnel) => {
76+
// Updated to new configuration config="{\"ingress\":[{\"hostname\":\"host.mydomain.com\", \"service\":\"http://localhost:1234\"}, {\"service\":\"http_status:404\"}], \"warp-routing\":{\"enabled\":false}}" version=1
77+
const config_match = output.match(/\bconfig="(.+?)" version=(\d+)/);
78+
79+
if (config_match) {
80+
try {
81+
// Parse the escaped JSON string
82+
const config_str = config_match[1].replace(/\\"/g, '"');
83+
const config: T = JSON.parse(config_str);
84+
const version = parseInt(config_match[2], 10);
85+
86+
this.emit("config", {
87+
config,
88+
version,
89+
});
90+
91+
if (
92+
config &&
93+
typeof config === "object" &&
94+
"ingress" in config &&
95+
Array.isArray(config.ingress)
96+
) {
97+
for (const ingress of config.ingress) {
98+
if ("hostname" in ingress) {
99+
tunnel.emit("url", ingress.hostname);
100+
}
101+
}
102+
}
103+
} catch (error) {
104+
this.emit("error", new Error(`Failed to parse config: ${error}`));
105+
}
106+
}
107+
};
108+
109+
public on<E extends keyof ConfigHandlerEvents<T>>(
110+
event: E,
111+
listener: ConfigHandlerEvents<T>[E],
112+
): this {
113+
return super.on(event, listener);
114+
}
115+
public once<E extends keyof ConfigHandlerEvents<T>>(
116+
event: E,
117+
listener: ConfigHandlerEvents<T>[E],
118+
): this {
119+
return super.once(event, listener);
120+
}
121+
public off<E extends keyof ConfigHandlerEvents<T>>(
122+
event: E,
123+
listener: ConfigHandlerEvents<T>[E],
124+
): this {
125+
return super.off(event, listener);
126+
}
127+
public emit<E extends keyof ConfigHandlerEvents<T>>(
128+
event: E,
129+
...args: Parameters<ConfigHandlerEvents<T>[E]>
130+
): boolean {
131+
return super.emit(event, ...args);
132+
}
133+
}

src/lib.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
export { bin } from "./constants.js";
2-
export { install } from "./install.js";
3-
export { tunnel } from "./tunnel.js";
1+
export * from "./constants.js";
2+
export * from "./install.js";
3+
export * from "./tunnel.js";
44
export {
55
service,
66
identifier,
77
MACOS_SERVICE_PATH,
88
AlreadyInstalledError,
99
NotInstalledError,
1010
} from "./service.js";
11-
export type { Connection } from "./types.js";
11+
export type * from "./types.js";
12+
export * from "./handler.js";

0 commit comments

Comments
 (0)