TCP across Node, Bun & Deno
Understand EOF-based byte exchange, half-close, bounded reads and both sides of connection cleanup.
Build and run
Requires Java 21+, Clojure CLI and the runtime named below. The example pins the compiler that supports native ^:async and await. Run these commands in a separate terminal from other examples.
git clone https://github.com/OUTCASTGEEK-TECH/fast-twitch.git
cd fast-twitch
git checkout a4eb73f96836d034c7a24fe09dc7d51e4c581f64
cd examples/07-native-transports
clojure -M:build tcp-node
node target/tcp-node.cjs
Other runtime builds
clojure -M:build tcp-bun
bun target/tcp-bun.cjs
clojure -M:build tcp-deno
deno run -A target/tcp-deno.cjs
All three entry namespaces delegate to native-demo.tcp/run! with a runtime keyword. Successful completion prints Configured client/server TCP loopback passed.
Follow the exchange
- Configuration selects
:transport :tcp, the runtime,:codec :bytesand a 32-byte collection limit. server/listen!binds to loopback on port 0 and returns a listener map containing its actual:port.- The server's connection callback receives a configured peer and starts asynchronous reading through EOF.
- The client connects to that port, sends
[4 5 6]and awaits the send. - The client half-closes its writes. The server's
receive!now sees EOF, sends the collected bytes back and half-closes its writes. - The client's
receive!reaches EOF and returns the reply as aUint8Array. finallycloses the outbound client, accepted server peer and listener.
Why half-close matters
TCP is a byte stream without application message boundaries. A peer waiting for EOF cannot return its collected result while the sender keeps its write side open. Half-close ends writes while allowing the reply to be read; closing the whole socket too early can prevent reading that reply. Send completion means local write completion, not confirmed receipt by the peer.
Exercises and limitations
Change the payload and assertion together. Send more than 32 bytes to exercise the configured collection bound, adding explicit error handling so failure is visible on both peers. A repeated request protocol needs framing, such as length-prefixing or delimiters; this EOF exchange does not provide it. Runtime capability profiles do not complete TLS qualification, so this loopback example makes no cross-runtime TLS claim.
TCP options · connect! API · half-close! API.
Complete source and build files
The files below are copied without abbreviation from Fast-Twitch revision a4eb73f96836d034c7a24fe09dc7d51e4c581f64. The walkthrough and API reference use the same revision.
Source: examples/07-native-transports/src/native_demo/tcp.cljs
View this exact file on GitHub.
(ns native-demo.tcp
(:refer-clojure :exclude [run!])
(:require [cljs.core :refer [await]]
[fast-twitch.client.core :as client]
[fast-twitch.server.core :as server])
(:refer-global :only [Uint8Array console]))
(defn ^:async run!
[runtime]
(let [accepted (atom nil)
configuration
{:transport :tcp :runtime runtime :codec :bytes :streaming {:max-bytes 32}}
listening (await (server/listen! (fn [connection]
(reset! accepted connection)
((^:async fn
[]
(try (await (client/send! connection
(await
(client/receive!
connection))))
(await (client/half-close! connection))
(catch :default _ nil)))))
(assoc configuration
:hostname "127.0.0.1"
:port 0)))
connection (await (client/connect! {:hostname "127.0.0.1" :port (:port listening)}
configuration))]
(try (await (client/send! connection (Uint8Array. #js [4 5 6])))
(await (client/half-close! connection))
(assert (= [4 5 6] (vec (await (client/receive! connection)))))
(.log console "Configured client/server TCP loopback passed")
(finally (client/close! connection)
(when @accepted (server/close! @accepted))
(await (server/close! listening))))))
Source: examples/07-native-transports/src/native_demo/tcp_node.cljs
View this exact file on GitHub.
(ns native-demo.tcp-node
(:require [native-demo.tcp :as demo]))
(defn -main
[]
(demo/run! :node))
(set! *main-cli-fn* -main)
Source: examples/07-native-transports/src/native_demo/tcp_bun.cljs
View this exact file on GitHub.
(ns native-demo.tcp-bun
(:require [native-demo.tcp :as demo]))
(defn -main
[]
(demo/run! :bun))
(set! *main-cli-fn* -main)
Source: examples/07-native-transports/src/native_demo/tcp_deno.cljs
View this exact file on GitHub.
(ns native-demo.tcp-deno
(:require [native-demo.tcp :as demo]))
(defn -main
[]
(demo/run! :deno))
(set! *main-cli-fn* -main)
Source: examples/07-native-transports/deps.edn
View this exact file on GitHub.
{:aliases {:build {:main-opts ["-m" "native-demo.build"]}}
:deps {fast-twitch/fast-twitch {:local/root "../.."}
org.clojure/clojurescript {:git/sha "56a94ac13bfac22108e4d868dfd4763edaceb864"
:git/url "https://github.com/clojure/clojurescript"}}
:paths ["src" "dev"]}
Source: examples/07-native-transports/dev/native_demo/build.clj
View this exact file on GitHub.
(ns native-demo.build
(:require [cljs.build.api :as cljs]))
(def modes
#{"http" "websocket" "sse" "streams" "ports" "tcp-node" "tcp-deno" "tcp-bun"})
(defn -main
[mode]
(doseq [mode (if (= mode "all") (sort modes) [mode])]
(when-not (modes mode) (throw (ex-info "Choose an example" {:modes modes})))
(cljs/build "src"
{:main (symbol (str "native-demo." mode))
:target :nodejs
:output-to (str "target/" mode ".cjs")
:output-dir (str "target/" mode)
:optimizations :simple})
(println mode "COMPILED")))