Skip to content

Commit c197c60

Browse files
committed
feat!: add Node.js 20+ compatibility via runtime abstraction layer
Isolate all Deno-specific code behind a runtime/ adapter so the library works on both Deno and Node.js (via JSR). Same test suite runs on both runtimes using Deno.test and node:test natively. BREAKING CHANGE: connect() returns Conn instead of Deno.Conn.
1 parent 0c60f99 commit c197c60

24 files changed

Lines changed: 1398 additions & 117 deletions

.github/workflows/ci.yml

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,39 @@ jobs:
3939
run: deno task test
4040

4141
e2e:
42-
name: e2e
42+
name: e2e (deno)
4343
runs-on: ubuntu-latest
4444
steps:
4545
- uses: actions/checkout@v4
4646
- uses: denoland/setup-deno@v2
4747
with:
4848
deno-version: v2.x
4949
- run: deno task test:e2e
50+
51+
test-node:
52+
name: tests node (${{ matrix.node-version }})
53+
runs-on: ubuntu-latest
54+
55+
strategy:
56+
matrix:
57+
node-version: [20, 22]
58+
fail-fast: true
59+
60+
steps:
61+
- uses: actions/checkout@v4
62+
- uses: actions/setup-node@v4
63+
with:
64+
node-version: ${{ matrix.node-version }}
65+
- run: npm install
66+
- run: npm test
67+
68+
e2e-node:
69+
name: e2e (node)
70+
runs-on: ubuntu-latest
71+
steps:
72+
- uses: actions/checkout@v4
73+
- uses: actions/setup-node@v4
74+
with:
75+
node-version: 22
76+
- run: npm install
77+
- run: npm run test:e2e

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
coverage
2+
node_modules
23
.vscode
34
.zed

.npmrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
@jsr:registry=https://npm.jsr.io

API.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1295,7 +1295,7 @@ Default port to `6667`.
12951295

12961296
Resolves when connected.
12971297

1298-
`async connect(hostname: string, options?: ConnectOptions): Promise<Deno.Conn | null>`
1298+
`async connect(hostname: string, options?: ConnectOptions): Promise<Conn | null>`
12991299

13001300
```ts
13011301
client.connect("irc.libera.chat");
@@ -1315,9 +1315,9 @@ client.connect("irc.libera.chat", {
13151315
client.connect("irc.libera.chat", {
13161316
port: 6697,
13171317
tls: true,
1318-
cert: Deno.readTextFileSync("client.pem"),
1319-
key: Deno.readTextFileSync("client-key.pem"),
1320-
caCerts: [Deno.readTextFileSync("ca.pem")],
1318+
cert: certContent,
1319+
key: keyContent,
1320+
caCerts: [caContent],
13211321
});
13221322
```
13231323

CONTRIBUTING.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ feature implementations to decoupled _plugins_ parts.
1414

1515
The core contains some internal parts related to IRC protocol, TCP sockets and
1616
event system. Plugins contain all the extra features built on top of the core
17-
client.
17+
client. Runtime-specific code (Deno, Node.js) is isolated in the `runtime/`
18+
directory behind a common interface.
1819

1920
In most of the cases, it is quite handy to add new features using plugins
2021
without touching the core.
@@ -26,13 +27,18 @@ All added parts (core and plugins):
2627

2728
## Development
2829

29-
Prerequisites: [Deno](https://deno.land/) v2.x
30+
Prerequisites:
3031

31-
Run `deno task` to see all available commands. Key tasks:
32+
- [Deno](https://deno.land/) 2+
33+
- [Node.js](https://nodejs.org/) 20+ (for cross-runtime testing)
3234

33-
- `deno task test` — run unit tests
35+
Run `deno task` to see all available Deno commands. Key tasks:
36+
37+
- `deno task test` — run unit tests (Deno)
3438
- `deno task lint` — lint the codebase
3539
- `deno task fmt` — format the codebase
40+
- `npm test` — run unit tests (Node.js)
41+
- `npm run test:e2e` — run E2E tests (Node.js)
3642

3743
## Releasing (maintainers)
3844

README.md

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,24 @@
11
# deno-irc
22

33
![ci](https://github.com/jeromeludmann/deno-irc/workflows/ci/badge.svg)
4+
[![JSR](https://jsr.io/badges/@irc/client)](https://jsr.io/@irc/client)
45

5-
IRC client protocol module for [Deno](https://deno.land/) which aims to provide
6-
an easy way to talk with IRC servers.
6+
IRC client protocol module for [Deno](https://deno.land/) 2+ and
7+
[Node.js](https://nodejs.org/) 20+ which aims to provide an easy way to
8+
talk with IRC servers.
9+
10+
> **New:** Node.js is now officially supported. Install via
11+
> `npx jsr add @irc/client` and use the same API as on Deno.
12+
13+
- **Cross-runtime** — runs on Deno and Node.js with the same API
14+
- **Fully typed** — events, commands and state are inferred from TypeScript, no guessing
15+
- **Plugin architecture** — 40+ built-in plugins (SASL, DCC, CTCP, reconnect, flood control...)
16+
- **TLS & SASL** — PLAIN, EXTERNAL (client certificates), NickServ fallback
17+
- **Zero dependencies** — no external runtime dependencies
718

819
Any feedback and contributions are welcome.
920

10-
Now available on [JSR](https://jsr.io/@irc/client).
21+
Available on [JSR](https://jsr.io/@irc/client).
1122

1223
## Documentation
1324

@@ -16,19 +27,27 @@ Now available on [JSR](https://jsr.io/@irc/client).
1627

1728
## Getting Started
1829

19-
The first thing to do is to import the `Client`:
30+
### Installation
31+
32+
**Deno:**
2033

2134
```ts
2235
import { Client } from "jsr:@irc/client";
2336
```
2437

25-
Alternatively, you can use the `deno.land/x` registry:
38+
**Node.js:**
39+
40+
```sh
41+
npx jsr add @irc/client
42+
```
2643

2744
```ts
28-
import { Client } from "https://deno.land/x/irc/mod.ts";
45+
import { Client } from "@irc/client";
2946
```
3047

31-
and just instantiate a new client like this:
48+
### Usage
49+
50+
Instantiate a new client like this:
3251

3352
```ts
3453
const client = new Client({
@@ -60,7 +79,7 @@ await client.connect("irc.libera.chat");
6079
await client.connect("irc.libera.chat", { port: 6697, tls: true });
6180
```
6281

63-
Note that connecting to servers requires the `--allow-net` option:
82+
When using Deno, connecting to servers requires the `--allow-net` permission:
6483

6584
```
6685
deno run --allow-net ./code.ts

core/client.ts

Lines changed: 33 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
type AnyReply,
1111
PROTOCOL,
1212
} from "./protocol.ts";
13+
import type { Conn, Runtime } from "../runtime/types.ts";
14+
import { getRuntime } from "../runtime/mod.ts";
1315

1416
type AnyRawEventName = `raw:${AnyCommand | AnyReply | AnyError}`;
1517

@@ -81,28 +83,15 @@ export interface RemoteAddr {
8183
/** Network address exposed in events — without sensitive fields (cert/key). */
8284
export type PublicAddr = Omit<RemoteAddr, "cert" | "key">;
8385

84-
/** How to connect to a server */
85-
interface ConnectImpl {
86-
noTls(opts: Deno.ConnectOptions): Promise<Deno.Conn>;
87-
withTls(
88-
opts:
89-
| Deno.ConnectTlsOptions
90-
| (Deno.ConnectTlsOptions & Deno.TlsCertifiedKeyPem),
91-
): Promise<Deno.Conn>;
92-
}
93-
9486
/** Low-level IRC client handling TCP connection, raw message parsing, and event dispatching. */
9587
export class CoreClient<
9688
TEvents extends CoreFeatures["events"] = CoreFeatures["events"],
9789
> extends EventEmitter<TEvents> {
9890
readonly state: CoreFeatures["state"];
9991
readonly utils: CoreFeatures["utils"];
10092

101-
protected connectImpl: ConnectImpl = {
102-
noTls: Deno.connect,
103-
withTls: Deno.connectTls,
104-
};
105-
protected conn: Deno.Conn | null = null;
93+
protected runtime: Runtime | null = null;
94+
protected conn: Conn | null = null;
10695
protected hooks: Hooks<CoreClient<TEvents>> = new Hooks<CoreClient<TEvents>>(
10796
this,
10897
);
@@ -148,26 +137,28 @@ export class CoreClient<
148137
async connect(
149138
hostname: string,
150139
options: ConnectOptions = {},
151-
): Promise<Deno.Conn | null> {
140+
): Promise<Conn | null> {
141+
if (!this.runtime) {
142+
this.runtime = await getRuntime();
143+
}
144+
152145
const { port = PORT } = options;
153146
const tls = options.tls ?? false;
154147

155148
let tlsFields: { cert?: string; key?: string; caCerts?: string[] } = {};
156149
if (options.tls) {
157-
const o = options as {
158-
cert?: string;
159-
key?: string;
160-
caCerts?: string[];
161-
certFile?: string;
162-
keyFile?: string;
163-
caCertFile?: string;
164-
};
165-
const cert = o.cert ??
166-
(o.certFile ? Deno.readTextFileSync(o.certFile) : undefined);
167-
const key = o.key ??
168-
(o.keyFile ? Deno.readTextFileSync(o.keyFile) : undefined);
169-
const caCerts = o.caCerts ??
170-
(o.caCertFile ? [Deno.readTextFileSync(o.caCertFile)] : undefined);
150+
const cert = options.cert ??
151+
(options.certFile
152+
? this.runtime.readTextFileSync(options.certFile)
153+
: undefined);
154+
const key = options.key ??
155+
(options.keyFile
156+
? this.runtime.readTextFileSync(options.keyFile)
157+
: undefined);
158+
const caCerts = options.caCerts ??
159+
(options.caCertFile
160+
? [this.runtime.readTextFileSync(options.caCertFile)]
161+
: undefined);
171162
tlsFields = {
172163
...(cert !== undefined && { cert }),
173164
...(key !== undefined && { key }),
@@ -187,12 +178,17 @@ export class CoreClient<
187178
try {
188179
if (tls) {
189180
const { cert, key, caCerts } = this.state.remoteAddr;
190-
const tlsOpts: Deno.ConnectTlsOptions = { hostname, port, caCerts };
191181
this.conn = cert && key
192-
? await this.connectImpl.withTls({ ...tlsOpts, cert, key })
193-
: await this.connectImpl.withTls(tlsOpts);
182+
? await this.runtime.connectTls({
183+
hostname,
184+
port,
185+
caCerts,
186+
cert,
187+
key,
188+
})
189+
: await this.runtime.connectTls({ hostname, port, caCerts });
194190
} else {
195-
this.conn = await this.connectImpl.noTls({ hostname, port });
191+
this.conn = await this.runtime.connect({ hostname, port });
196192
}
197193
this.emit("connected", publicAddr);
198194
} catch (error) {
@@ -210,7 +206,7 @@ export class CoreClient<
210206
return publicAddr;
211207
}
212208

213-
private async loop(conn: Deno.Conn): Promise<void> {
209+
private async loop(conn: Conn): Promise<void> {
214210
for (;;) {
215211
const chunks = await this.read(conn);
216212
if (chunks === null) break;
@@ -225,7 +221,7 @@ export class CoreClient<
225221
this.close();
226222
}
227223

228-
private async read(conn: Deno.Conn): Promise<string | null> {
224+
private async read(conn: Conn): Promise<string | null> {
229225
let read: number | null;
230226

231227
try {
@@ -305,9 +301,7 @@ export class CoreClient<
305301
/** Emits properly an error. */
306302
emitError(...args: ErrorArgs): void {
307303
const [, error] = args;
308-
const isSilentError = error instanceof Deno.errors.BadResource ||
309-
error instanceof Deno.errors.Interrupted;
310-
if (isSilentError) {
304+
if (this.runtime?.isSilentError(error)) {
311305
return;
312306
}
313307
this.emit("error", toClientError(...args));

core/client_test.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { assertEquals, assertRejects, assertThrows } from "@std/assert";
22
import { describe } from "../testing/helpers.ts";
33
import { mockConsole } from "../testing/console.ts";
44
import { MockServer } from "../testing/server.ts";
5-
import { MockCoreClient } from "../testing/client.ts";
5+
import { MockCoreClient, SilentTestError } from "../testing/client.ts";
66
import { type CoreFeatures } from "./client.ts";
77
import { type Raw } from "./parsers.ts";
88
import { createPlugin, type Plugin } from "./plugins.ts";
@@ -353,23 +353,21 @@ describe("core/client", (test) => {
353353
assertEquals("key" in addr, false);
354354
});
355355

356-
test("prefer cert over certFile when both provided", async () => {
356+
test("store TLS cert and key in remoteAddr", async () => {
357357
const { client } = mock();
358358

359359
await client.connect("host", {
360360
tls: true,
361361
cert: "INLINE_CERT",
362-
certFile: "/nonexistent/cert.pem",
363362
key: "INLINE_KEY",
364-
keyFile: "/nonexistent/key.pem",
365363
});
366364

367365
assertEquals(client.state.remoteAddr.cert, "INLINE_CERT");
368366
assertEquals(client.state.remoteAddr.key, "INLINE_KEY");
369367
client.disconnect();
370368
});
371369

372-
test("swallow some Deno errors silently", () => {
370+
test("swallow silent errors", () => {
373371
const { client } = mock();
374372
let triggered = 0;
375373

@@ -378,8 +376,8 @@ describe("core/client", (test) => {
378376
});
379377

380378
client.emitError("write", new Error("Boom!")); // +1
381-
client.emitError("write", new Deno.errors.BadResource()); // should not throw
382-
client.emitError("write", new Deno.errors.Interrupted()); // should not throw
379+
client.emitError("write", new SilentTestError()); // should not throw
380+
client.emitError("write", new SilentTestError()); // should not throw
383381

384382
assertEquals(triggered, 1);
385383
});

0 commit comments

Comments
 (0)