Skip to main content

concurrent-c-node

Call Node (and npm packages) from Python. Native types, exceptions, callbacks, and async all cross the boundary.

Part of Concurrent-C — a strict C11-superset preprocessor: .ccs lowers to plain C and compiles with your host C compiler. (This bridge itself is pure Python stdlib — no native build.)

Map of the three boundaries (CC hosts JS, native modules, this package bridge): JS / Python interop.

import cc_node

js = cc_node.create()                # always a child `node` process
_ = js.require('lodash')             # cwd node_modules
_.chunk([1, 2, 3, 4, 5], 2)          # [[1, 2], [3, 4], [5]]

semver = js.require('semver')
semver.satisfies('1.2.3', '^1.0.0')  # True

js.destroy()                         # or: with cc_node.create() as js:

The other direction (Python from Node): concurrent-c-python (in-process by default; vs pymport / ncp / pythonia in that README).

Every create() here is a separate Node — real addons, crash isolation, measurable wire. N domains = N processes.

this package CC hosted (cc_js_new(false, …))
API cc_node.create() .ccs program
Where child node libnode in-process
Hot call ~105µs RTT sub-µs (needs libnode)
Bulk shm (~9.5ms / 8MB) in-process
Parallelism N children one process
Crash child dies; parent lives shared fate

Cheat sheet

  • Always a child node. A call blocks until JS answers; thenables wait in the child. No { async: true }.
  • Scalars / None materialize; empty {} stays a handle; everything else is a JsHandle until str() / attrs / a call.
  • import cc_node then %%js or cc_node.get() — one session, not a child per cell. %load_ext still works (idempotent).
  • cc_node.require('path') is get().require('path').
  • eval() is one RTT, no extra globals. %%js / eval_cell install cwd require once.
  • --bind is Object.assign(globalThis, …) of names you name (wire types only; no pickle). Missing names refuse.
  • First Ctrl-C finishes the in-flight call (wire stays in sync). Interrupt again to kill the child.
pip install concurrent-c-node                 # needs node on PATH
pip install 'concurrent-c-node[jupyter]'      # magics (IPython)
python -m cc_node.examples.use_node
python -m cc_node.examples.bench_wire
python -m cc_node.benchmarks.multi_domain
python -m cc_node.benchmarks.vs_alts          # vs DIY node / pythonmonkey / mini-racer

Jupyter / Colab

Colab and the usual Jupyter kernel are Python — this package. Same calling convention as a script: a cell blocks until Node answers; thenables wait in the child. Magics and get() share one session for the kernel, not a spawn per cell (~28ms). Child console.log lands in the cell (inherited stdio, line-buffered).

%pip install concurrent-c-node
# if `node` is missing (typical Colab):
!apt-get install -y nodejs

import cc_node                 # magics register; no %load_ext
path = cc_node.require('path')
path.join('a', 'b')            # 'a/b'
%%js
console.log('hi')              # shows in the cell
require('path').join('a', 'b') # last expression comes back as Python
xs = [1, 2, 3, 4]

%%js -b xs -t chunks
xs.map(x => x * 2)             # wire types only; no pickle fallback
import cc_node registers magics; does not spawn until first %%js / get() / require()
%load_ext cc_node same, idempotent
%js 1+1 / %%js eval on cc_node.get(); last expression is the result
-b xs / --bind xs,n publish those Python names on globalThis for the cell
-t chunks / --to store the result in the notebook namespace
%js_stats handle-table size (spawns if needed)
%js_reset / cc_node.reset() destroy() the session child; %reset does this too
cc_node.get() the session the magics use; create() is still a private child
cc_node.kernel() alias of get()
cc_node.require('fs') get().require('fs')
JsHandle display cheap JsHandle #3 — repr does not cross the wire

eval() is unchanged (one RTT, no extra globals). %%js / eval_cell install cwd require once so cells look like Node (require('path')). --bind is Object.assign(globalThis, …) — missing names and non-wire types (DataFrame, a set) fail articulately. Reserved names (require, process, globalThis, …) refuse so a bind cannot shadow Node.

Interrupt. First Ctrl-C does not abandon the in-flight reply (that would desync the wire and drop callbacks). The call finishes, the result is discarded, the domain stays up. Interrupt again to kill the child — cooperative destroy() cannot stop a JS for (;;) {}. Same honesty as the rest of the bridge.

JS kernels (tslab, Deno Jupyter) are concurrent-c-python; Colab is not that. There, default create() blocks the kernel thread — py.task / { isolated: true } when the loop must stay live.

Measured

bench_wire · cc_node_bridge_py_20260810.txt:

what result
spawn (first eval) 28ms
wire RTT 105µs
Python callback round trip 153µs
8MB array('d') via shm 9.5ms
same 8MB as JSON list 499ms (~52×)

Wire: line-JSON on dedicated fds (stdio stays yours). Bulk spill: private 0700 dir, 0600 files, removed with the bridge.

Vs pythonmonkey / mini-racer / DIY node

Most “JS from Python” libraries are not Node. Bulk is a sum over 1M floats (.length on an in-process wrapper is free and lies). Snapshot: cc_node_vs_alts_20260813.txt · harness: benchmarks/vs_alts.py.

cc-node DIY JSON stdio node -e each pythonmonkey mini-racer
identity RTT 20µs 39µs 25ms <1µs 114µs
callback 36µs 1µs
8MB typed / list 6.2ms shm / 359ms — / 197ms — / 630ms proxy — / 78ms
require('fs') yes yes yes no no
process child node child node new process/call SpiderMonkey in-process V8 isolate

Tiny scalars: pythonmonkey’s in-process SM beats a child. Real Node (require('fs'), native addons, callbacks, stdout stays yours): this package. Isolated node -e per call is ~1000× a persistent child. Optional engines SKIP if not importable — not package deps.

The other direction (Python from Node): concurrent-c-python vs pymport / ncp / pythonia.

Surface

  • Plain data (numbers, str, bool, None, lists, non-empty dicts) by value; else a domain-owned handle (attrs, calls, str()String()). Non-finite floats are tagged.
  • Handles are per-domain. stats() / release() / idempotent destroy(); afterwards: bridge is closed.
  • eval_cell(src, bindings=) is the notebook door (%%js): cwd require once, optional globalThis binds; eval() stays one RTT. get() / require() / eval() at module level share that session.
  • Crash isolation, not a sandbox. destroy() is cooperative; an in-flight CPU-bound call finishes or you kill the child (bridge_stress.md).

Promises

Awaited in the child before the reply — no async/await on the Python side. Same honesty as concurrent-c-python: a call blocks until the other runtime answers.

fetchish = js.eval('async (x) => { return { doubled: x * 2 } }')
fetchish(21)   # {'doubled': 42}

Callbacks

mapped = js.eval('(f) => [1, 2, 3].map(f)')(lambda x, *rest: x * 10)
# map passes (value, index, array) — take *rest

Exceptions cross both ways with messages intact.

Buffers

bytes / array.array / 1-D numpy → typed arrays (and back). Small inline; large via shm.

import array
total = js.eval('(a) => a.reduce((s, x) => s + x, 0)')
total(array.array('d', range(1_000_000)))

Common issues

Cannot find module. require / import resolve from the Python process cwd (node_modules next to your program), not from this wheel’s site-packages. npm install lodash in the project directory is the fix; or create(node=…) / CC_NODE_BIN when the wrong Node is on PATH.

Empty {} stays a handle

An empty object has to stay on the Node side — a materialized Python dict would lose later property use that matches Node. So js.eval('({})') returns a live handle:

o = js.eval('({})')                    # JsHandle, not {}
js.eval('(o) => { o.x = 1; return o.x }')(o)   # 1
js.eval('({a: 1})')                    # {'a': 1} — data return

Non-empty plain objects still cross as Python dicts. Same-domain handles chain (h.update(…).digest(…)).

Wire cost vs tiny work. Round trip is ~100µs; a one-line JS helper on three numbers loses to pure Python. Prefer Python (or a native CC module) for small/hot work; use the bridge when Node/npm owns the kernel. Multi-core: python -m cc_node.benchmarks.multi_domain (~2.8× on 3 domains here).

Choosing node

  1. create(node='/path/to/node')
  2. CC_NODE_BIN
  3. node on PATH

Packages: cwd node_modules, same as Node itself.

From Concurrent-C (not Python): cc_js_new(false, &a) hosted (libnode), or cc_js_new(true, &a) for this wire — jsdemo.shcc.

Publishing

PyPI Trusted Publishing (OIDC) — no API token:

  1. Publishing settings: owner sreekotay, repo concurrent-c, workflow publish-cc-node.yml, environment pypi
  2. GitHub Environment pypi
  3. Bump pyproject.toml, push, then:
gh workflow run publish-cc-node.yml

Both bridges (npm OIDC + PyPI OIDC):

./scripts/publish_bridges.sh --publish --minor
# fallbacks: --npm-local / --pypi-twine

Examples: use_node, bench_wire, benchmarks.multi_domain, benchmarks.vs_alts. Jupyter: import cc_node then %%js (or %load_ext cc_node). Stress: stress/bridge/.
Own hot path in C/CC → native module (40–90ns) — JS / Python interop.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

concurrent_c_node-0.23.0.tar.gz (27.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

concurrent_c_node-0.23.0-py3-none-any.whl (26.1 kB view details)

Uploaded Python 3

File details

Details for the file concurrent_c_node-0.23.0.tar.gz.

File metadata

  • Download URL: concurrent_c_node-0.23.0.tar.gz
  • Upload date:
  • Size: 27.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for concurrent_c_node-0.23.0.tar.gz
Algorithm Hash digest
SHA256 150e2486c71f0b58238b6b682ba7e54a84116570db398f63a834a800714d4ca6
MD5 7fc7218504d691a1a662a14f4862b07c
BLAKE2b-256 add4d094ee503bd01b969a83c7c61229716e6bc065ba652f49a9f548ec691ec8

See more details on using hashes here.

Provenance

The following attestation bundles were made for concurrent_c_node-0.23.0.tar.gz:

Publisher: publish-cc-node.yml on sreekotay/concurrent-c

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file concurrent_c_node-0.23.0-py3-none-any.whl.

File metadata

File hashes

Hashes for concurrent_c_node-0.23.0-py3-none-any.whl
Algorithm Hash digest
SHA256 fca4e55d7e183f44114ed0028d978ce738970364a1c3e18a5c60943acd408b92
MD5 d33b99eb246763940db12f96f2f81756
BLAKE2b-256 9149f2ea5e33bf1daebc7cdd568397f8f63eeb87bb9207551d44a6ed348d9a4b

See more details on using hashes here.

Provenance

The following attestation bundles were made for concurrent_c_node-0.23.0-py3-none-any.whl:

Publisher: publish-cc-node.yml on sreekotay/concurrent-c

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.23.3

2 files

0.23.2

2 files

0.23.1

2 files

This release

0.23.0 This release

2 files

0.22.1

2 files

0.22.0

2 files

0.21.0

2 files

0.20.0

2 files

0.19.0

2 files

0.18.0

2 files

0.17.11

2 files

0.17.10

2 files

0.17.9

2 files

0.17.8

2 files

0.17.7

2 files

0.17.6

2 files

0.17.5

2 files

0.17.4

2 files

0.17.3

2 files

0.17.2

2 files

0.17.1

2 files

0.17.0

2 files

0.16.0

2 files

0.15.0

2 files

0.14.0

2 files

0.12.0

2 files

0.11.0

2 files

0.10.0

2 files

0.7.0

2 files

0.6.0

2 files

0.5.0

2 files

0.4.1

2 files

0.4.0

2 files

0.3.0

2 files

0.2.1

2 files

0.2.0

2 files

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page