Skip to content

feat: run on Node and Bun, and fix the broken pipeline - #3

Open
neonbyte1 wants to merge 7 commits into
NeaByteLab:mainfrom
neonbyte1:feat/node-bun-support
Open

neonbyte1 wants to merge 7 commits into
NeaByteLab:mainfrom
neonbyte1:feat/node-bun-support

Conversation

@neonbyte1

Copy link
Copy Markdown
Contributor

Two things here. They're related, because the first one is what stopped me from trusting anything else.

The pipeline

The red runs on main aren't a code problem. The annotation on the failed job says it straight out:

The job was not started because your account is locked due to a billing issue.

Runs 14 and 15 finished in three seconds with no steps executed, and run 13 never created a job at all. Run 12 was just waiting on fork-PR approval. None of that is fixable from this repo.

But while checking that, I found a real failure the pin was hiding:

TS2345 'Timeout' is not assignable to 'number'   src/index.ts:157

setTimeout returns an object from Deno 2.6 onwards. CI pinned v2.5.4, which is the last release where this still type-checks, so deno task check passes on exactly one version of Deno and fails on everything newer. The moment anyone bumps the pin, main goes red.

Fixed by naming the handle type where the timer helpers live and using it for the pool map. CI now runs the pin and the latest v2.x together so this can't drift silently again.

One more thing in that area: deno task check was running deno fmt in write mode. It rewrote the files it was supposed to be checking, so formatting drift could never fail CI. It's --check now, with deno task fmt for formatting.

Node and Bun

The coupling turned out to be narrow — Deno.connect, Deno.startTls, and Deno.Conn/Deno.TlsConn leaking into the exported SmtpConnectionState. Everything else (crypto.subtle, btoa, Promise.withResolvers) was already portable.

So: an SmtpTransport interface in front of the socket. Deno keeps its native path, so nothing changes for anyone on JSR today. Node and Bun get a node:net/node:tls implementation that buffers incoming chunks and parks one reader, which is what lets SmtpCommand keep its pull-based read(target) against push-based streams. The right one is picked on first connect via dynamic import, so neither runtime loads the other's module.

No npm build. I wrote one with dnt first, then deleted it — JSR already publishes an npm-consumable package, and I confirmed it by installing the live 0.3.0 through npx jsr add and importing it on Node 26 and Bun 1.4. A second artifact on npmjs.com would only have bought npm install ergonomics in exchange for a build script and a version that can drift. deno task bundle covers the one thing still genuinely needed: something Node can actually run for the smoke tests.

Three bugs the port shook out

  • TLS socket leaked on handshake timeout. Collapsing startTls + handshake() into one call left the new socket unreachable, so a timeout orphaned it. Deno 2.5.4's leak detector caught this. Both connect and startTls take an AbortSignal now. This one affected the Deno path too.
  • servername set to an IP. node:tls rejects it outright; Deno.startTls accepts it. Connecting to an SMTP host by address would have failed on Node and Bun only. SNI is omitted for IP literals now, with host carrying the identity check.
  • STARTTLS command injection. The Node upgrade refuses to proceed if plaintext is already buffered.

Breaking

Version goes to 0.4.0:

  • SmtpConnectionState carries one transport field instead of conn + tlsConn.
  • SmtpConnection is gone. Nothing reached it — SmtpClient builds SmtpCommand/SmtpAuth/SmtpMessage and never touched it. It was also a worse copy of the live code: no timeouts, a single write() that silently truncates, a reply reader that stops at the first CRLF and mangles multi-line 250- responses, HELO-only handshake. Porting it meant carrying all of that onto the new transport for zero callers.

Verification

Check Result
deno task check / test on 2.5.4 clean, 116 passed
deno task check / test on 2.9.7 clean, 116 passed
smoke on Node 24 and Node 22 plaintext and starttls
smoke on Bun 1.4 plaintext and starttls
deno publish --dry-run Success

The smoke test sends a real message through the bundled library to a loopback SMTP server, once plaintext and once over a STARTTLS upgrade with a throwaway cert, and checks both the returned result and the bytes the server received — so a transport that truncates writes or quietly skips the upgrade fails it.

One question before you merge

Are you planning to sort out the billing lock on the account?

I'm asking because none of the above can actually be verified by you until you do. Every job in here will be skipped the same way runs 13 to 15 were, the badge in the README stays red, and deno publish won't run either, so 0.4.0 can't reach JSR. I ran the full matrix locally and the results are in the table above, but you'd be merging on my word rather than on a green check.

If it's getting fixed soon, I'm happy to wait and rerun everything against real CI. If it isn't, I'd rather publish my fork under its own scope than leave Node and Bun support sitting in a branch — no hard feelings either way, I'd just want to tell people where to get it rather than have two unrelated packages drift apart quietly. Let me know which it is.

Notes

  • I pinned the smoke matrix to Node 22 as the declared floor. If you'd rather drop it and claim >=24, that's a one-line change plus the docs.
  • Action pins were stale, so checkout went v4 to v7 and setup-node v4 to v7 while I was in there.

setTimeout returns an object from Deno 2.6 onwards, so the pooled idle
timer map no longer accepts the handle and `deno check` fails. The CI
pin at v2.5.4 hid this: the check only passes on that exact release.

Name the handle as SMTP.TimerHandle where the timer helpers live and
use it for the map, which is correct on every runtime.

Also stop `deno task check` from running `deno fmt` in write mode.
Formatting drift could never fail CI because the task rewrote the files
it was supposed to be checking. Use `deno task fmt` to format.
Nothing reaches it. SmtpClient builds SmtpCommand, SmtpAuth and
SmtpMessage, never SmtpConnection, so the class has been dead since the
client was reorganised.

It is also a worse copy of the code that does run. No read, write or
connect timeouts, a single write() call that silently truncates
payloads, a reply reader that stops at the first CRLF and so mangles
multi-line 250- responses, and a handshake that only does HELO and only
tries STARTTLS on port 587.

Keeping it means porting all of that to the new transport, or shipping
it broken on Node and Bun too. It is exported, so this is a breaking
removal.
The SMTP layer talked to Deno.connect and Deno.startTls directly, and
SmtpConnectionState exposed Deno.Conn and Deno.TlsConn through the
published types, so neither the code nor its type surface could leave
Deno.

Put an SmtpTransport interface in front of the socket. Deno keeps its
native path, so nothing changes for existing JSR consumers. Node and
Bun get a node:net and node:tls implementation that buffers incoming
chunks and parks one reader, which keeps SmtpCommand's pull-based
read(target) working against push-based streams. The implementation is
picked on first connect and loaded with a dynamic import, so neither
runtime ever loads the other's module.

Two behaviour notes:

startTls now resolves only once the handshake has completed, so a
single withTimeout covers the whole upgrade instead of the old split
between Deno.startTls and handshake(). Because the secured socket only
exists inside that pending call, both connect and startTls take an
AbortSignal; without it a timed-out handshake orphaned the socket.
Deno 2.5.4's leak detector caught exactly that.

The Node upgrade refuses to start when bytes are already buffered.
Plaintext that arrives before the handshake was never asked for, and
folding it into the secured session is the classic STARTTLS command
injection.

SmtpConnectionState now carries one `transport` field instead of `conn`
and `tlsConn`. Breaking, hence 0.4.0.
Deno runs the Deno transport, so the Node one would otherwise ship
untested. Deno's node:net support lets the suite drive it directly:
a full scripted SMTP session with a 56 KiB DATA payload, reads split
across a 4-byte buffer with a null at end of stream, an aborted
connect, the refusal to upgrade with buffered plaintext, and an
abandoned handshake.

The handshake test targets 127.0.0.1 on purpose. node:tls rejects an IP
offered as an SNI name, so that case has to stay covered.
Node cannot run src/ directly because of the @app/ style import map
aliases and the .ts specifiers, so there was no way to exercise the
Node path end to end before publishing.

`deno task bundle` emits a Node-runnable ESM file with node: builtins
left external, and scripts/smoke.mjs sends a real message through it to
a loopback SMTP server: once in plaintext and once over a STARTTLS
upgrade. It checks both sides, the returned result and the bytes the
server actually received, so a transport that truncates writes or skips
the upgrade fails here.

The STARTTLS leg needs the throwaway certificate in the trust store and
NODE_EXTRA_CA_CERTS is only read at startup, so the script generates
the certificate and re-runs itself once.
The single pinned Deno version is what let the timer handle break reach
main unnoticed, so the check and test job now runs the pin and the
latest v2.x side by side.

Adds smoke jobs for Node 24 (current LTS) and Node 22 (the floor the
package claims) plus Bun, and refreshes the action pins: checkout v4 to
v7 and setup-node v4 to v7.
Install instructions for all three runtimes. Node and Bun install from
JSR with `jsr add`, which registers the @jsr registry and serves the
same source as ESM with type declarations, so the import specifier does
not change.

Also drops the Deno.env call from the DKIM example, since it is the one
snippet that would not run as written on Node or Bun.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant