JS runtime detection for ClojureScript dialects.
Works with ClojureScript, Squint, nbb, Cherry, and Clojurific
(ns my.app
(:require [js-runtime-detection.core :as rt]))
;; Public defs are computed once, when js-runtime-detection.core loads
rt/js-runtime ;; => "node"
rt/js-runtime-is-server ;; => true| Period | Stage | Release |
|---|---|---|
| 2026 Mar-Nov | Experimental | - |
| 2026 Nov-present | Alpha | 0.1.0 |
| Dialect | Version |
|---|---|
| ClojureScript | 1.9.854 or newer |
| nbb | 1.6.214 |
| Cherry | 0.6.38 |
| Clojurific | 0.12.15 |
| Squint | 0.15.212 |
The library reads JS properties with unchecked-get, which ClojureScript added in 1.9.854, so all versions after that should work. The tests run on ClojureScript 1.12.145.
deps.edn:
io.github.paintparty/js-runtime-detection {:mvn/version "0.1.0"}Leiningen:
[io.github.paintparty/js-runtime-detection "0.1.0"]deps.edn:
io.github.paintparty/js-runtime-detection {:mvn/version "0.1.0"}nbb.edn:
{:deps {io.github.paintparty/js-runtime-detection {:mvn/version "0.1.0"}}}Squint and Cherry read :deps from squint.edn or cherry.edn, and only take git and :local/root dependencies, not jars:
{:paths ["src"]
:deps {io.github.paintparty/js-runtime-detection {:git/tag "v0.1.0" :git/sha "..."}}}Consuming js-runtime-detection as a git dependency has the SHA of the latest release and the other forms the coordinate map can take. The same git coordinates work in deps.edn and nbb.edn.
(ns my.app
(:require [js-runtime-detection.core :as rt]))
;; In Node
rt/js-runtime ;; => "node"
rt/js-platform ;; => nil
rt/js-runtime-is-server ;; => true
rt/js-runtime-has-dom ;; => false
rt/js-runtime-has-jsdom ;; => false
rt/js-runtime-has-node-apis ;; => true
rt/js-runtime-is-strict-node ;; => true
;; The same defs in a browser
rt/js-runtime ;; => "browser"
rt/js-platform ;; => nil
rt/js-runtime-is-server ;; => false
rt/js-runtime-has-dom ;; => true
rt/js-runtime-has-jsdom ;; => false
rt/js-runtime-has-node-apis ;; => false
rt/js-runtime-is-strict-node ;; => falseThe library runs on JS only. From a .cljc file, require it inside a reader conditional:
(ns my.app
(:require #?(:cljs [js-runtime-detection.core :as rt])))
(def server? #?(:clj true :cljs rt/js-runtime-is-server))| def | Value | Answers |
|---|---|---|
js-runtime |
string | Which runtime (Node, Deno, Bun, workerd, browser) |
js-platform |
string or nil | Which deployment platform (Cloudflare, Vercel ...) |
js-runtime-is-server |
boolean | Is this server-side? |
js-runtime-has-dom |
boolean | Are window and document present? |
js-runtime-has-jsdom |
boolean | Is jsdom active? |
js-runtime-has-node-apis |
boolean | Can I use fs, process.env and other Node APIs? |
js-runtime-is-strict-node |
boolean | Is this actual Node, not Bun or Deno in Node mode? |
The value of js-runtime is one of:
"node"
"deno"
"bun"
"workerd"
"edge-light"
"fastly"
"browser"
"service-worker"
"web-worker"
"electron-renderer"
"react-native"
"unknown"
The value of js-platform is one of:
"cloudflare"
"vercel-edge"
"netlify-edge"
"fastly"
nil
| Environment | js-runtime |
js-platform |
js-runtime-is-server |
js-runtime-has-dom |
js-runtime-has-jsdom |
js-runtime-has-node-apis |
js-runtime-is-strict-node |
|---|---|---|---|---|---|---|---|
| Real browser | "browser" |
nil |
false |
true |
false |
false |
false |
| Node | "node" |
nil |
true |
false |
false |
true |
true |
| jsdom in Node | "node" |
nil |
true |
true |
true |
true |
true |
| Deno 2 | "deno" |
nil |
true |
false |
false |
true |
false |
| jsdom in Deno | "deno" |
nil |
true |
true |
true |
true |
false |
| Bun | "bun" |
nil |
true |
false |
false |
true |
false |
| jsdom in Bun | "bun" |
nil |
true |
true |
true |
true |
false |
| Cloudflare Workers | "workerd" |
"cloudflare" |
true |
false |
false |
false |
false |
| Vercel Edge* | "edge-light" |
"vercel-edge" |
true |
false |
false |
false |
false |
| Netlify Edge | "deno" |
"netlify-edge" |
true |
false |
false |
false |
false |
| Fastly Compute* | "fastly" |
"fastly" |
true |
false |
false |
false |
false |
| Electron renderer | "electron-renderer" |
nil |
true |
true |
false |
true |
false |
| Electron main | "node" |
nil |
true |
false |
false |
true |
true |
| React Native | "react-native" |
nil |
false |
false |
false |
false |
false |
| Service Worker | "service-worker" |
nil |
false |
false |
false |
false |
false |
| Web Worker | "web-worker" |
nil |
false |
false |
false |
false |
false |
* Rows marked with an asterisk are unverified. Vercel Edge is detected by the EdgeRuntime global, which Vercel documents. Fastly Compute is detected by a fastly global, which newer versions of Fastly's JS runtime may no longer define. The js-runtime names follow the WinterTC runtime keys, where "edge-light" is Vercel's Edge Runtime.
Under jsdom, js-runtime-is-server and js-runtime-has-node-apis are true, because jsdom runs on a server runtime. Pair them with (not js-runtime-has-dom) to exclude test environments that simulate a browser.
The Electron renderer row assumes nodeIntegration is enabled. With Electron's defaults (context isolation on, nodeIntegration off), the renderer has no process global and reports the same values as a real browser.
Cloudflare Workers with the nodejs_compat flag (compatibility date 2025-09-15 or later) report js-runtime-has-node-apis as true. The rest of the row is the same.
These rows of the detection matrix are checked by the automated tests (see How the tests work):
- Node and jsdom in Node, in every dialect: ClojureScript (
:advancedbuilds), nbb, Cherry, Clojurific and Squint - Node without
globalThis, in ClojureScript and Squint - Cloudflare Workers with and without
nodejs_compat(an:advancedbuild run in workerd through Miniflare)
The other rows in the detection matrix above follow from each platform's documented globals and have not been run on those platforms.
The library also has zero dependencies. It does not import any Node.js built-in modules like fs or os, which don't exist in browsers or edge runtimes. The library should load in any JS environment.
Detection works by reading properties of the global object, which is globalThis in modern engines. In older engines without globalThis (Node before 12, IE11, Safari before 12.1), the library falls back to self, window or global. The exception to this fallback chain is Cherry and Clojurific, which both rely on globalThis for their own runtimes.
The values of all the public defs in the library are computed once, when js-runtime-detection.core loads. If your test setup installs jsdom, load js-runtime-detection.core after jsdom is in place.
Properties are read by string key, for example globalThis["process"]["versions"]["node"] rather than dot notation e.g., globalThis.process.versions.node. The Closure compiler, which ClojureScript uses for :advanced builds, renames dotted property names but never changes string keys, so results are the same in dev builds and :advanced builds.
The library looks for various global vars (Deno, Bun, process, etc.). When they don't exist, the lookup returns nil instead of throwing an error. Detection only looks at which globals exist and what they contain, such as globalThis.Deno or navigator.userAgent. Other JS code running in the same environment, such as a dependency, a polyfill or a test setup, can add or change those globals. For example, a polyfill can define a process global in a browser, and jsdom adds window and document to Node.
js-runtime, js-platform and the other defs can be used to determine control flow in your program, such as how to print output or which APIs to call. Don't treat these values as a guarantee of where your code is running, as they only report what the globals look like. For example, you could have JS code running in a browser that sets process.versions.node, which would result in js-runtime-is-server and js-runtime-has-node-apis returning true. For this reason, the values of the public defs in js-runtime-detection.core should not be relied on for security decisions.
Available dev tasks via bb.edn:
bb test # same as test:nbb
bb test:nbb # run the tests on Node under nbb, plain and with jsdom globals
bb test:advanced # compile the tests with :advanced on the latest ClojureScript, run on Node plain and with jsdom globals
bb test:cloudflare # optional: run a probe in workerd (Cloudflare Workers) via Miniflare, with and without nodejs_compat
bb test:squint # compile the tests with Squint, run on Node
bb test:cherry # compile the tests with Cherry, run on Node
bb test:clojurific # compile the tests with Clojurific, run on Node (needs Java 17 or newer)
bb test:all # all of the above (test:cloudflare skips if Miniflare is not installed)
bb ci # run the tests and build the jar
bb install # install the jar to ~/.m2
bb docs:readme # after tagging a release: update the version numbers in this README
bb docs:git-deps # after tagging a release: regenerate doc/git-deps.md-
Each test run checks one row of the detection matrix. The
JS_RUNTIME_DETECT_ENVenv var determines the row:node(the default) orjsdom-node. -
test:advancedcompiles the tests with:advancedoptimizations, then run them three times on Node. The first run is no-frills. The second preloads test/stubs/jsdom.cjs, which sets thewindow,documentand jsdom user agent globals that global-jsdom and jest-environment-jsdom install. The third preloads test/stubs/no-globalthis.cjs, which deletesglobalThisto imitate older engines. That third run leaves out theNODE_USE_ENV_PROXYenv var, because with it set, Node's own HTTP proxy setup readsglobalThisafter the preload and crashes before the tests start. -
Each run prints a label first, naming the dialect and the row, for example
=== Squint 0.15.212 | Node with jsdom globals. -
The
:advancedruns catch property renaming by the Closure compiler, which dev builds and nbb don't show. -
test:nbbruns the tests twice under nbb, the second time with the jsdom stub preloaded throughNODE_OPTIONS. -
test:squintandtest:cherrycompilesrcandtestto ES modules intarget/squintandtarget/cherry, then install the compiler's npm package into that folder, because the compiled modules import its runtime. Squint runs all three rows. Cherry skips the run withoutglobalThis, because Cherry's runtime needs it. -
test:clojurificcompiles with the:clojurificalias indeps.ednand Clojurific's generated test runner (cljf.esm.node-test/run), then runs it on Node. Like Cherry, it skips the run withoutglobalThis. -
test:advancedandtest:cloudflarebuild with script/build_advanced.clj and the:cljs-latestalias indeps.edn, which uses ClojureScript 1.12.145. -
There are two test entrypoints, plus the one Clojurific generates:
- runner.cljs is for nbb, Squint and Cherry. nbb reports the summary through
cljs.test's:end-run-testsreport method, while Squint and Cherry return it fromrun-tests, so the runner reads both. - advanced_runner.cljs is for the ClojureScript builds. It sets the process exit code from
cljs.test's:end-run-testsreport method.
- runner.cljs is for nbb, Squint and Cherry. nbb reports the summary through
-
A failing test sets the process exit code to 1, so
bb test:allandbb cistop.
bb test:cloudflare runs the library in workerd, the runtime behind Cloudflare Workers. It needs Miniflare, which you can install into a gitignored folder with:
npm install --no-save --prefix test/cloudflare miniflare@4.20260730.0This creates only test/cloudflare/node_modules. Without it, bb test:cloudflare prints SKIPPED and bb test:all continues.
The task builds worker_probe.cljs with :advanced and no :target. test/cloudflare/run.mjs appends a fetch handler to the compiled file, loads it into workerd through Miniflare, and compares the returned values against the Cloudflare Workers row. It runs once without flags and once with nodejs_compat, with the compatibility date pinned to 2026-07-01.
After tagging a release (for example git tag v0.2.0), run both docs tasks:
bb docs:readmeupdates the version numbers in the Setup section of this README to match the tag.bb docs:git-depsregenerates doc/git-deps.md from bb/tasks/git-deps.template.md with the tag and its commit SHA.
Commit both files and push.
Issues for bugs, improvements, or features are very welcome. Please file an issue for discussion before starting or issuing a PR.
Copyright © 2026 Jeremiah Coyle
Distributed under the Eclipse Public License version 2.0.