Compare commits

...

5 Commits

Author SHA1 Message Date
dependabot[bot] 24f64c9689 Bump qs and express in /wasm/client/internal-dev
Bumps [qs](https://github.com/ljharb/qs) and [express](https://github.com/expressjs/express). These dependencies needed to be updated together.

Updates `qs` from 6.13.0 to 6.15.2
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.13.0...v6.15.2)

Updates `express` from 4.22.1 to 4.22.2
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/v4.22.2/History.md)
- [Commits](https://github.com/expressjs/express/compare/v4.22.1...v4.22.2)

---
updated-dependencies:
- dependency-name: express
  dependency-version: 4.22.2
  dependency-type: indirect
- dependency-name: qs
  dependency-version: 6.15.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-05 10:39:25 +00:00
mfahampshire 08dc353e82 New TS SDK packages (#6839)
* First sweep packages + some minor tweaking

* Second sweep

* Regenerate lockfile + package.json mods

* Regenerate lockfile again

* Fix CI

* Fix CI again

* All building properly

* unblock

* Tweak examples

* Comments + readme + fix rotten unit test
2026-06-05 10:36:36 +00:00
import this 495f020730 [DOCs/operators]: Menu v2 (#6853) 2026-06-05 11:29:04 +02:00
import this c7780d2d34 Feat: Node orchestration UX improvements (#6848)
* improve nginx playbook

* improve configure-vm script

* improve initialise-vm script

* expand config naming options

* provide args docs

* syntax fix

* address rabbitai comments

* cleanup ansible

* document ansible changes

* fix review comments

* update scraed data

* fix max comment review
2026-06-04 12:59:50 +02:00
mfahampshire 4ad00dba3d Smolmix RTT storm fix (#6846)
* RT fix for TLS

* Condense comment

* Coderabbit nits

* Clippy fix?

* Clippy 2:electric boogaloo

* Logging aggregate for very noisy tcp stuff
2026-06-03 17:31:15 +00:00
247 changed files with 4421 additions and 29283 deletions
+9 -3
View File
@@ -7,7 +7,10 @@ on:
paths:
- "documentation/docs/**"
- "sdk/typescript/packages/sdk/src/**"
- "sdk/typescript/packages/mix-tunnel/src/**"
- "sdk/typescript/packages/mix-fetch/src/**"
- "sdk/typescript/packages/mix-dns/src/**"
- "sdk/typescript/packages/mix-websocket/src/**"
- ".github/workflows/ci-docs.yml"
jobs:
@@ -47,7 +50,7 @@ jobs:
- name: Check if TypeScript SDK source changed
id: check-ts-sdk
run: |
if git diff --name-only ${{ github.event.before }} ${{ github.sha }} | grep -qE '^sdk/typescript/packages/(sdk|mix-fetch)/src/'; then
if git diff --name-only ${{ github.event.before }} ${{ github.sha }} | grep -qE '^sdk/typescript/packages/(sdk|mix-tunnel|mix-fetch|mix-dns|mix-websocket)/src/'; then
echo "changed=true" >> $GITHUB_OUTPUT
else
echo "changed=false" >> $GITHUB_OUTPUT
@@ -58,8 +61,11 @@ jobs:
if: steps.check-ts-sdk.outputs.changed == 'true'
run: |
npm install -g typedoc@0.25.13 typedoc-plugin-markdown@4.0.3
cd ${{ github.workspace }}/sdk/typescript/packages/sdk && typedoc --skipErrorChecking
cd ${{ github.workspace }}/sdk/typescript/packages/mix-fetch && typedoc --skipErrorChecking
cd ${{ github.workspace }}/sdk/typescript/packages/sdk && typedoc --skipErrorChecking
cd ${{ github.workspace }}/sdk/typescript/packages/mix-tunnel && typedoc --skipErrorChecking
cd ${{ github.workspace }}/sdk/typescript/packages/mix-fetch && typedoc --skipErrorChecking
cd ${{ github.workspace }}/sdk/typescript/packages/mix-dns && typedoc --skipErrorChecking
cd ${{ github.workspace }}/sdk/typescript/packages/mix-websocket && typedoc --skipErrorChecking
- name: Verify doc versions
run: ${{ github.workspace }}/documentation/scripts/verify-doc-versions.sh
+6 -4
View File
@@ -40,10 +40,12 @@ jobs:
- name: Install wasm-opt
run: cargo install wasm-opt
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "1.24.6"
# Produce wasm/smolmix/pkg/package.json before any pnpm step. The
# `pnpm dev:on` in `prebuild:ci` adds wasm/smolmix/pkg to the dynamic
# workspace; mix-tunnel's `workspace:*` lookup against @nymproject/
# smolmix-wasm needs the package.json to be present.
- name: Build smolmix wasm
run: make -C wasm/smolmix
- name: Install
run: pnpm i
-5
View File
@@ -30,11 +30,6 @@ jobs:
override: true
components: rustfmt, clippy
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "1.24.6"
- name: Install wasm-pack
run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh
-8
View File
@@ -33,14 +33,6 @@ jobs:
- name: Install wasm-opt
run: cargo install wasm-opt
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "1.24.6"
- name: Update root CA certificate bundle
run: ./wasm/mix-fetch/go-mix-conn/scripts/update-root-certs.sh
- name: Install dependencies
run: pnpm i
Generated
+21 -32
View File
@@ -5414,32 +5414,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "mix-fetch-wasm"
version = "1.4.3"
dependencies = [
"async-trait",
"futures",
"js-sys",
"nym-bin-common",
"nym-http-api-client",
"nym-ordered-buffer",
"nym-service-providers-common",
"nym-socks5-requests",
"nym-validator-client",
"nym-wasm-client-core",
"nym-wasm-utils",
"rand 0.8.6",
"serde",
"serde-wasm-bindgen 0.6.5",
"thiserror 2.0.18",
"tokio",
"tsify",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
]
[[package]]
name = "mixnet-connectivity-check"
version = "0.1.0"
@@ -11571,7 +11545,7 @@ dependencies = [
"nym-sdk",
"reqwest 0.13.1",
"rustls 0.23.37",
"smoltcp",
"smoltcp 0.12.0 (registry+https://github.com/rust-lang/crates.io-index)",
"thiserror 2.0.18",
"tokio",
"tokio-rustls 0.26.2",
@@ -11583,7 +11557,7 @@ dependencies = [
[[package]]
name = "smolmix-wasm"
version = "1.21.0"
version = "0.1.0"
dependencies = [
"async-tungstenite",
"bytes",
@@ -11609,7 +11583,7 @@ dependencies = [
"semver 1.0.27",
"serde",
"serde-wasm-bindgen 0.6.5",
"smoltcp",
"smoltcp 0.12.0 (git+https://github.com/nymtech/smoltcp?rev=62ac5b8b3287d4773694f19a3b55e4c004354a0b)",
"thiserror 2.0.18",
"tokio",
"tsify",
@@ -11636,6 +11610,21 @@ dependencies = [
"managed",
]
[[package]]
name = "smoltcp"
version = "0.12.0"
source = "git+https://github.com/nymtech/smoltcp?rev=62ac5b8b3287d4773694f19a3b55e4c004354a0b#62ac5b8b3287d4773694f19a3b55e4c004354a0b"
dependencies = [
"bitflags 1.3.2",
"byteorder",
"cfg-if",
"defmt 0.3.100",
"heapless",
"libc",
"log",
"managed",
]
[[package]]
name = "snafu"
version = "0.7.5"
@@ -12642,7 +12631,7 @@ dependencies = [
"futures",
"parking_lot",
"pin-project-lite",
"smoltcp",
"smoltcp 0.12.0 (registry+https://github.com/rust-lang/crates.io-index)",
"tokio",
"tokio-util",
]
@@ -15000,9 +14989,9 @@ dependencies = [
[[package]]
name = "zeroize_derive"
version = "1.4.2"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69"
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
dependencies = [
"proc-macro2",
"quote",
-5
View File
@@ -174,7 +174,6 @@ members = [
"tools/nymvisor",
"tools/ts-rs-cli",
"wasm/client",
"wasm/mix-fetch",
"wasm/smolmix",
"wasm/zknym-lib",
"nym-network-monitor-v3/nym-network-monitor-orchestrator",
@@ -605,10 +604,6 @@ opt-level = 3
# lto = true
opt-level = 'z'
[profile.release.package.mix-fetch-wasm]
# lto = true
opt-level = 'z'
[profile.release.package.smolmix-wasm]
# lto = true
opt-level = 'z'
+5 -3
View File
@@ -105,14 +105,17 @@ sdk-wasm: sdk-wasm-build sdk-wasm-test sdk-wasm-lint
sdk-wasm-build:
$(MAKE) -C wasm/client
$(MAKE) -C wasm/mix-fetch
$(MAKE) -C wasm/smolmix
# $(MAKE) -C wasm/zknym-lib
# run this from npm/yarn to ensure tools are in the path, e.g. yarn build:sdk from root of repo
#
# `mix-tunnel` must build before the three feature packages — they import it
# via `workspace:*` and the lerna topological sort will respect that as long
# as we keep them in the same `--scope` invocation.
sdk-typescript-build:
npx lerna run --scope @nymproject/sdk build --stream
npx lerna run --scope @nymproject/mix-fetch build --stream
npx lerna run --scope '{@nymproject/mix-tunnel,@nymproject/mix-fetch,@nymproject/mix-dns,@nymproject/mix-websocket}' build --stream
pnpm --pwd sdk/typescript/codegen/contract-clients build
# NOTE: These targets are part of the main workspace (but not as wasm32-unknown-unknown)
@@ -124,7 +127,6 @@ sdk-wasm-test:
sdk-wasm-lint:
RUSTFLAGS='--cfg getrandom_backend="wasm_js"' cargo clippy $(addprefix -p , $(WASM_CRATES)) --target wasm32-unknown-unknown -- -Dwarnings
$(MAKE) -C wasm/mix-fetch check-fmt
$(MAKE) -C wasm/smolmix check-fmt
# Add to top-level targets
+30 -40
View File
@@ -1,21 +1,4 @@
---
ansible_ssh_private_key_file: ~/.ssh/<SSH_KEY>
cli_url: "https://github.com/nymtech/nym/releases/download/nym-binaries-{{ nym_version }}/nym-cli"
tunnel_manager_url: "https://github.com/nymtech/nym/raw/refs/heads/develop/scripts/nym-node-setup/network-tunnel-manager.sh"
quic_bridge_deployment_url: "https://raw.githubusercontent.com/nymtech/nym/refs/heads/develop/scripts/nym-node-setup/quic_bridge_deployment.sh"
###############################################################################
## GLOBAL VARS
## These values will be used globally unless overwritten per node in inventory/all
###############################################################################
ansible_user: root # used for ssh, like `ssh root@nym-exit.ch-1.mynodes.net`
email: "<EMAIL>" # used in certbot, description.toml and landing page
website: "<WEBSITE>" # it is used in the description.toml
description: "<NODE_PUBLIC_DESCRIPTION>" # or define per node in inventory/all
# operator_name: "<OPERATOR_NAME>" # used in landing page if provided
###############################################################################
## GLOBAL VARS
## These values will be used globally unless overwritten per node in inventory/all
@@ -23,16 +6,41 @@ description: "<NODE_PUBLIC_DESCRIPTION>" # or define per node in inventory/all
## Per node changes in inventory/all will overwrite these global vars
###############################################################################
# moniker: "<MONIKER>" # if not setup here not in inventory/all it get's derived from the hostname
# mode: <MODE> # entry-gateway/exit-gateway/mixnode
# wireguard_enabled: <WIREGUARD_ENABLED> # true/false
hostname: "" # this is a fallback, keep it and setup hostname per node in inventory/all
## MANDATORY - uncomment & define
## --SSH--
#ansible_user: root # used for ssh, like `ssh root@nym-exit.ch-1.mynodes.net`
# ansible_ssh_private_key_file: ~/.ssh/<SSH_KEY>
## --Operator info--
# email: "<EMAIL>" # used in certbot, description.toml and landing page
# website: "<WEBSITE>" # it is used in the description.toml
# description: "<NODE_PUBLIC_DESCRIPTION>" # or define per node in inventory/all
# moniker: "<MONIKER>"
## --Node defaults (can override per node in inventory/all)--
# accept_operator_terms: true # controls --accept-operator-terms-and-conditions, read: https://nym.com/docs/operators/nodes/nym-node/setup#terms--conditions
# mode: exit-gateway # entry-gateway/exit-gateway/mixnode
# wireguard_enabled: true # true/false
hostname: "" # keep this fallback, keep it and setup hostname per node in inventory/all
## OPTIONAL - uncomment & define
# operator_name: "<OPERATOR_NAME>" # used in landing page if provided
# nym_version: "nym-binaries-v2026.7-tola" # to use particular version instead of Latest, provide in such form:
## alternative SSH key var setting, instead of a hardcoded path
## useful if the playbook is shared in a repo by more admins with each having own local key
# ansible_ssh_private_key_file: "{{ lookup('env', '<YOUR_ANSIBLE SSH_KEY_ENV_VAR>') }}"
###############################################################################
## GLOBAL PACKAGES
## GLOBAL PACKAGES & URLs
## These will be installed during deployment
###############################################################################
nym_cli_url: "https://github.com/nymtech/nym/releases/download/{{ nym_version }}/nym-cli"
tunnel_manager_url: "https://github.com/nymtech/nym/raw/refs/heads/develop/scripts/nym-node-setup/network-tunnel-manager.sh"
quic_bridge_deployment_url: "https://raw.githubusercontent.com/nymtech/nym/refs/heads/develop/scripts/nym-node-setup/quic_bridge_deployment.sh"
packages:
- tmux
@@ -50,24 +58,6 @@ packages:
- ufw
###############################################################################
## OPTIONAL OVERRIDES
## All values below already have defaults in the playbook/roles
## Uncomment only if you want to override them
###############################################################################
###############################################################################
## SYSTEM MAINTENANCE PLAYBOOK KNOBS
###############################################################################
# To use particular version instead of Latest, provide in such form:
# nym_version: "nym-binaries-v2026.7-tola"
## NOTE:
## if you want to pin Nym to a specific version instead of using the
## latest release from GitHub in /tasks/main.yml then
## uncomment the line above and set the tag
###############################################################################
## SYSTEM MAINTENANCE PLAYBOOK KNOBS
###############################################################################
+28 -23
View File
@@ -1,34 +1,39 @@
[nym_nodes]
# READ CONFIGURATION GUIDE:
# https://nym.com//docs/operators/orchestration/ansible#configuration
## READ CONFIGURATION GUIDE:
## https://nym.com/docs/operators/orchestration/ansible#configuration
# VARIABLES INFO
# required vars to set values per node:
# `ansible_host`, `hostname`, `location`
##############
## TEMPLATE ##
##############
## uncomment and exchange the <VARIABLES> with your real values for each node without the <> brackets
# global vars can be set in the group_vars/all.yml, for example:
# `email`, `ansible_user`, `moniker`, `description`, `mode`, `wireguard_enabled`
# othersise they must be set per node!
############
# TEMPLATE #
############
# node1 ansible_host=<YOUR_SERVER_IP> ansible_user=<USER> hostname=<HOSTNAME> location=<LOCATION> email=<EMAIL> mode=<MODE> wireguard_enabled=<true/false> moniker=<MONIKER> description=<DESCRIPTION>
# remove all comments and exchange the <VARIABLES> with your real values for each node
# without <> brackets
####################
## VARIABLES INFO ##
####################
# PRIORITY ORDER
# anything setup globaly can be overwritten in this file per node
# if provided here, it takes priority over the global setting
## --REQUIRED VARS--
## required per node:
## ansible_host, hostname, location
# EXAMPLES
# exit + wireguard gateway:
## --OPTIONAL VARS--
## can be set in the group_vars/all.yml or per node here:
## email, ansible_user, moniker, description, mode, wireguard_enabled
## --PRIORITY ORDER--
## anything setup globaly can be overwritten in this file per node
## if provided here, it takes priority over the global setting
## --EXAMPLES--
## exit + wireguard gateway:
# node2 ansible_host=11.12.13.14 hostname=nym-exit.ch-1.mydomain.net mode=exit-gateway location=CH wireguard_enabled=true
# entry gateway, no wireguard:
## entry gateway, no wireguard:
# node3 ansible_host=12.13.14.15 hostname=nym-entry.ch-2.mydomain.net mode=entry-gateway location=CH wireguard_enabled=false
# NOTE:
# all examples above don't have defined user, email nor description as we use the definition from group_vars/main.yml without an attempt of overwriting it
# all examples above don't have moniker defined as there is a function in /templates/description.toml.j2 deriving it from the hostname
## mixnode (comment out tunnel+quic roles in deploy.yml for these)
# mix-de-1 ansible_host=13.14.15.16 hostname=nym-mix.de-1.example.net location=DE mode=mixnode wireguard_enabled=false
## NOTE:
## all examples above don't have defined user, email nor description as we use global vars from playbooks/group_vars/all.yml
+5 -11
View File
@@ -89,7 +89,6 @@
loop:
- "/etc/nginx/sites-enabled/{{ hostname }}-ssl"
- "/etc/nginx/sites-enabled/nym-wss-config"
when: not le_cert.stat.exists
notify: Restart nginx
- name: Ensure nginx is enabled and running (needed for ACME http-01)
@@ -111,18 +110,13 @@
- name: Obtain/renew certificate
command:
cmd: >-
{% if le_cert.stat.exists %}
certbot certonly --webroot
-w /var/www/{{ hostname }}
certbot certonly --nginx
--non-interactive --agree-tos --keep-until-expiring
-m {{ email }} -d {{ hostname }}
{% else %}
certbot --nginx
--non-interactive --agree-tos --redirect
-m {{ email }} -d {{ hostname }}
{% endif %}
register: certbot_result
failed_when: false
failed_when: false
# re-check cert after certbot attempt
- name: Re-check whether certificate exists after certbot
@@ -170,4 +164,4 @@
changed_when: false
- name: Flush handlers (apply restart after successful tests)
meta: flush_handlers
meta: flush_handlers
+2 -2
View File
@@ -10,7 +10,7 @@ mixnet_bind_address: "0.0.0.0:1789" # maps to --mixnet-bind-address
landing_page_assets_base_dir: "/var/www"
# Flag toggles
# accept_operator_terms: true # controls --accept-operator-terms-and-conditions
accept_operator_terms: false # override in group_vars or inventory
nym_write_flag: true # controls -w
nym_init_only_flag: true # controls --init-only
wss_port: 9001 # controlls --announce-wss-port
@@ -18,7 +18,7 @@ wss_port: 9001 # controlls --announce-wss-port
# Optional: extra flags if you want to append more later
nym_extra_flags: ""
# CLI URL (nym_version can be set elsewhere / via GitHub API)
# CLI URL
nym_cli_url: "https://github.com/nymtech/nym/releases/download/{{ nym_version }}/nym-cli"
# UFW
@@ -5,7 +5,7 @@
},
"mixmining_reserve": {
"denom": "unym",
"amount": "166613455567357"
"amount": "164623226345363"
},
"vesting_tokens": {
"denom": "unym",
@@ -13,6 +13,6 @@
},
"circulating_supply": {
"denom": "unym",
"amount": "833386544432643"
"amount": "835376773654637"
}
}
@@ -1,6 +1,6 @@
{
"nodes": 682,
"locations": 78,
"mixnodes": 242,
"exit_gateways": 432
"nodes": 677,
"locations": 77,
"mixnodes": 240,
"exit_gateways": 429
}
@@ -1 +1 @@
833_386_544
835_376_773
@@ -1 +1 @@
61_194_673
61_340_814
@@ -1 +1 @@
61_194_672
61_340_813
@@ -1,7 +1,7 @@
| **Item** | **Description** | **Amount in NYM** |
|:-------------------|:------------------------------------------------------|--------------------:|
| Total Supply | Maximum amount of NYM token in existence | 1_000_000_000 |
| Mixmining Reserve | Tokens releasing for operators rewards | 166_613_455 |
| Mixmining Reserve | Tokens releasing for operators rewards | 164_623_226 |
| Vesting Tokens | Tokens locked outside of circulation for future claim | 0 |
| Circulating Supply | Amount of unlocked tokens | 833_386_544 |
| Stake Saturation | Optimal size of node self-bond + delegation | 254_977 |
| Circulating Supply | Amount of unlocked tokens | 835_376_773 |
| Stake Saturation | Optimal size of node self-bond + delegation | 255_586 |
@@ -1,10 +1,10 @@
{
"interval": {
"reward_pool": "166613455567357.391753168447944888",
"staking_supply": "61194672938727.325886595653401629",
"reward_pool": "164623226345363.285226429762152046",
"staking_supply": "61340813321050.793375219026221616",
"staking_supply_scale_factor": "0.07342892",
"epoch_reward_budget": "4628151543.537705326476901331",
"stake_saturation_point": "254977803911.363857860815222506",
"epoch_reward_budget": "4572867398.482313478511937837",
"stake_saturation_point": "255586722171.04497239674594259",
"sybil_resistance": "0.3",
"active_set_work_factor": "10",
"interval_pool_emission": "0.02"
@@ -1 +1 @@
Wednesday, May 27th 2026, 11:42:58 UTC
Thursday, June 4th 2026, 11:40:35 UTC
@@ -3,17 +3,23 @@
"changelog": "Changelog",
"release-cycle": "Release Cycle",
"variables": "Variables & Parameters",
"sandbox": "Sandbox Testnet",
"binaries": "Binaries",
"---1": {
"type": "separator"
},
"nodes": "Nodes & Validators Guides",
"binaries": "Binaries",
"orchestration" : "Orchestration",
"performance-and-testing": "Performance Measurement",
"tools": "Tools",
"troubleshooting": "Troubleshooting",
"---2": {
"type": "separator"
},
"sandbox": "Sandbox Testnet",
"tokenomics": "Tokenomics",
"faq": "FAQ",
"community-counsel": "Community Counsel",
"---": {
"---3": {
"type": "separator"
},
"archive": "Archive",
@@ -291,17 +291,26 @@ KVM network is gone.
##### 3. Setup KVM public bridge for new VMs
To create a KVM network bridge on Ubuntu, edit a config file located in `/etc/netplan/` either called `00-installer.yaml` or `00-installer-config.yaml` and add the bridge details.
To create a KVM network bridge on Ubuntu, edit a config file located in `/etc/netplan/` often called `50-cloud-init.yaml` or `00-installer.yaml` or `00-installer-config.yaml` and add the bridge details.
- Before you edit the file, make a backup to stay on the save side:
- Before you edit the file, make a backup to stay on the save side, the file should be named something like this:
```bash
cp /etc/netplan/00-installer-config.yaml /etc/netplan/00-installer-config.yaml.bak
cp /etc/netplan/50-cloud-init.yaml /etc/netplan/50-cloud-init.yaml.bak
# or
cp /etc/netplan/00-installer.yaml /etc/netplan/00-installer.yaml.bak
# or
cp /etc/netplan/00-installer-config.yaml /etc/netplan/00-installer-config.yaml.bak
```
- Open `00-installer-config.yaml` or `00-installer.yaml.`config in a text editor:
- If none of these files existed, simply check the name of your config by this command and apply the same backup logic:
```sh
ls /etc/netplan/
```
- Open the original config in a text editor:
```bash
nano /etc/netplan/50-cloud-init.yaml
# or
nano /etc/netplan/00-installer.yaml
# or
nano /etc/netplan/00-installer-config.yaml
@@ -380,12 +389,13 @@ netplan --debug apply
```bash
apt install yamllint -y
yamllint /etc/netplan/50-cloud-init.yaml
# or
yamllint /etc/netplan/00-installer.yaml
# or
yamllint /etc/netplan/00-installer-config.yaml
```
- Apply correct permissions:
```bash
chmod 600 /etc/netplan/00-installer.yaml
@@ -410,9 +420,12 @@ systemctl enable --now systemd-networkd
- If things went wrong, you can always revert from the backed up file:
```bash
cp /etc/netplan/50-cloud-init.yaml.bak /etc/netplan/50-cloud-init.yaml
# or
cp /etc/netplan/00-installer-config.yaml.bak /etc/netplan/00-installer-config.yaml
# or
cp /etc/netplan/00-installer.yaml.bak /etc/netplan/00-installer.yaml
# and
netplan apply
```
@@ -535,19 +548,50 @@ Using the scripts is a two-step process. First, initialisation part is done from
<Steps>
##### 1. Initialise VM from the host machine
- Log in to your host as `root`
- Run this block and follow the prompts carefully:
- Get the script:
```bash
wget "https://raw.githubusercontent.com/nymtech/nym/refs/heads/develop/scripts/kvm-setup/initialise-vm.sh"
chmod +x ./initialise-vm.sh
./initialise-vm.sh
```
- Run this block and follow the prompts carefully or provide with arguments:
```sh
# interactive CLI
./initialise-vm.sh
# arguments - see --help menu
./initialise-vm.sh --help
# example
# ./initialise-vm.sh --name ubuntu1 --password topsecretrootpassword --cpus 4 --ram 8192 --size 60
```
##### 2. Configure VM from within
- After logging into your VM run this block and follow the prompts carefully:
- After logging into your VM, using `login: root` and `password: <YOUR_PASSWORD>`, get second script to configure the VM from within:
```bash
wget "https://raw.githubusercontent.com/nymtech/nym/refs/heads/develop/scripts/kvm-setup/configure-vm.sh"
chmod +x ./configure-vm.sh
```
- Likely your connection won't work and therefore you need to create the script manually, open a text editor:
```sh
nano configure-vm.sh
```
- Paste there [this content](https://raw.githubusercontent.com/nymtech/nym/refs/heads/develop/scripts/kvm-setup/configure-vm.sh)
- Save and exit
- Run this block and follow the prompts carefully, or provide with arguments:
```sh
# interactive CLI
./configure-vm.sh
# arguments - see --help menu
./configure-vm.sh --help
# example
# ./configure-vm.sh --interface enp1s0 --ipv4 192.168.1.100 --gateway4 192.168.1.1 --ipv6 2001:db8::1 --gateway6 2001:db8::fffe
```
</ Steps>
@@ -3,6 +3,7 @@ import { Tabs } from 'nextra/components';
import { Steps } from 'nextra/components';
import { RunTabs } from 'components/operators/nodes/node-run-command-tabs';
import { VarInfo } from 'components/variable-info.tsx';
import { AccordionTemplate } from 'components/accordion-template.tsx';
# Orchestrating Nym Nodes with Ansible
@@ -95,14 +96,13 @@ Before starting Ansible, ensure that your `A` and `AAAA` records are pointed to
<Steps>
###### 1. Configure global variables:
- Open `playbooks/group_vars/all.yml`
- Setup any variables which you want to have propagated on all your nodes globally
- Define all values under the section labeled as `## MANDATORY - uncomment & define` - these values will be propagated on all your nodes globally
- Optionally define values of your choice under the section `## OPTIONAL - uncomment & define`
- Note that in the next step we will be setting up a node inventory, where each of the variable can be configured per node, taking priority over the global ones.
- Setup a correct path for your SSH kety to `ansible_ssh_private_key_file:`
- Use these variables or comment them out with `#`:
- `ansible_user`
- `email`
- `website`
- `description`
- Setup a correct path for your SSH kety to `ansible_ssh_private_key_file:`, alternatively export your SSH key as an env var and use this:
```sh
ansible_ssh_private_key_file: "{{ lookup('env', '<YOUR_ANSIBLE SSH_KEY_ENV_VAR>') }}"
```
- Keep `hostname=""` as a fallback for nodes without a hostname
###### 2. Create node inventory:
@@ -111,11 +111,11 @@ Before starting Ansible, ensure that your `A` and `AAAA` records are pointed to
```sh
node1 ansible_host=<YOUR_SERVER_IP> ansible_user=<USER> hostname=<HOSTNAME> location=<LOCATION> email=<EMAIL> mode=<MODE> wireguard_enabled=<true/false> moniker=<MONIKER> description=<DESCRIPTION>
```
- These are mandatory values specific for each node - must be defined in the inventory:
- Mandatory values specific for each node - must be defined in the inventory:
- `ansible_host`: IPv4 host address
- `hostname`: node domain, otherwise fallbacks to `""` for nodes without domain
- `location`: node server location
- These are mandatory values which can be setup per node or in `group_vars/all` globally:
- Mandatory values which can be setup per node or in `group_vars/all` globally:
- `ansible_user`
- `email`
- `website`
@@ -123,6 +123,7 @@ node1 ansible_host=<YOUR_SERVER_IP> ansible_user=<USER> hostname=<HOSTNAME> loca
- `description`
- `mode`
- `wireguard_enabled`
- `accept_operator_terms` - **Make sure to read [Nym Operators Terms & Conditions](/operators/nodes/nym-node/setup#terms--conditions) first!**
###### 3. Test your setup
Run this command to check if everything is configured correctly in your inventory:
@@ -131,17 +132,12 @@ cd playbooks
ansible-inventory --graph
```
###### 4. Configure `nym-node run` command arguments
###### 4. Optional: remove some of `nym-node run` command arguments
These variables are read by the main task for `nym-node` installation: `roles/nym/tasks/config.yaml`
Open `roles/nym/defaults/main.yml` and have a look on the variables used:
- If you agree with [Terms and conditions](/operators/nodes/nym-node/setup#terms--conditions) uncomment the line: `accept_operator_terms: true` without which your node can never take part in Nym Network.
- The rest is up to your configuration but generally these flags workflows
These variables are read by the main task for `nym-node` installation: `roles/nym/tasks/config.yaml`
- Open that yaml and have a look on the flags
- In case of not needing some of the, delete them (ie when running `--mode mixnode` you can delete everything from `--hostname` to `--announce-wss-port`)
###### 5. Configure `deploy.yml` playbook
###### 5. Optional: Configure `deploy.yml` playbook
Open `playbooks/deploy.yml` and comment out `tunnel` and `quic` roles in case of running your playbook for nodes in a mode `mixnode`.
Save all the files and test with:
@@ -159,11 +155,13 @@ This chapter describes fundamental commands for using Ansible playbooks in relat
### Logic
<AccordionTemplate name="See Ansible logic description">
The main logic of the playbook flow when running with a basic command and playbook like this:
```sh
ansible-playbook <PLAYBOOK>.yml
```
<Steps>
<Steps>
###### 1. Read inventory
Ansible parses `inventory/all` and performs the playbook on all entries in it, unless specified otherwise
@@ -175,7 +173,8 @@ Ansible parses `group_vars/all.yml` and asigns global variables to all inventory
###### 3. Follow roles in the playbook
Ansible reads the roles defined in `<PLAYBOOK>.yml` passed with the command and executes the tasks defined under each role
</ Steps>
</ Steps>
</AccordionTemplate>
### Usage
@@ -237,6 +236,17 @@ cd playbooks
ansible-playbook system-maintenance.yml
```
###### 5. Mitigate kernel CVE
This playbook is to mitigate some of the Kernel issues found in April and May 2026.
This playbook will run roles on all the inventory entries in parallel by default.
```sh
cd playbooks
ansible-playbook mitigate_kernel_CVE.yml
```
</ Steps>
@@ -248,7 +258,7 @@ ansible-playbook system-maintenance.yml
<Steps>
###### One node at a time
###### Node limit
To test new configuration, it's advised to try it on one server at first. Of course you can comment out any entries in the inventory, but there are easier ways to do it.
- Provide flag `-l` followed by inventory entry and Ansible will change state only of that entry:
@@ -261,11 +271,14 @@ ansible-playbook upgrade.yml -l node1
# point to multiple entries
ansible-playbook upgrade.yml -l "node1,node2"
# use regex
# use regex - ie all exit nodes
ansible-playbook upgrade.yml -l "*exit*"
# use group in inventory labeled as [group]
ansible-playbook deploy.yml -l new_nodes
```
###### Role limit
###### Tag selection
<Callout>
To update your exit policy by using the newest [NTM](https://github.com/nymtech/nym/blob/develop/scripts/nym-node-setup/network-tunnel-manager.sh) via Ansible, just run:
+12
View File
@@ -537,3 +537,15 @@ html:not(.dark) .landing-card {
margin-bottom: 1.5rem;
}
}
/* Bold pages/operators/nodes sidebar item */
html.dark .nextra-sidebar-container a[href="/docs/operators/nodes"] {
color: var(--textPrimary) !important;
font-weight: 700;
}
html:not(.dark) .nextra-sidebar-container a[href="/docs/operators/nodes"] {
color: #111 !important;
font-weight: 700;
}
+1 -1
View File
@@ -7,7 +7,7 @@
"audit:fix": "pnpm audit --fix",
"build": "run-s build:types build:packages",
"build:ci": "run-s build:types build:packages build:wasm build:ci:sdk",
"build:ci:sdk": "lerna run --scope '{@nymproject/sdk,@nymproject/sdk-react,@nymproject/mix-fetch,@nymproject/nodejs-client,@nymproject/mix-fetch-node}' build --stream",
"build:ci:sdk": "lerna run --scope '{@nymproject/mix-tunnel,@nymproject/mix-fetch,@nymproject/mix-dns,@nymproject/mix-websocket}' build --stream",
"build:ci:storybook": "pnpm build && pnpm dev:on && run-p build:playground && pnpm build:ci:storybook:collect-artifacts ",
"build:ci:storybook:collect-artifacts": "mkdir -p ts-packages/dist && mv sdk/typescript/packages/react-components/storybook-static ts-packages/dist/storybook ",
"build:packages": "run-s build:packages:theme build:packages:react",
+565 -993
View File
File diff suppressed because it is too large Load Diff
+13 -3
View File
@@ -1,9 +1,15 @@
# The static workspace below is intentionally minimal. It contains only what
# CI workflows without a Rust toolchain (e.g. ci-build-ts, storybook) need to
# resolve. Anything that depends on `@nymproject/smolmix-wasm` (mix-tunnel +
# mix-fetch + mix-dns + mix-websocket + internal-dev) or the wasm-pack output
# dir itself lives in `dev-mode-add.mjs` and is only injected when `pnpm
# dev:on` runs. Build the wasm first if dev:on will be called:
# `make -C wasm/smolmix` populates wasm/smolmix/pkg/.
packages:
- 'ts-packages/*'
- 'nym-wallet'
- 'explorer-v2'
- 'types'
- 'sdk/typescript/packages/mix-fetch/internal-dev'
- 'sdk/typescript/packages/react-components'
- 'sdk/typescript/packages/mui-theme'
@@ -21,8 +27,12 @@ allowBuilds:
msgpackr-extract: true
nx: true
protobufjs: true
sharp: true
tiny-secp256k1: true
# Native C++ bindings; both fail to compile against Node 24's node-addon-api.
# Skipping the build leaves the JS shells in place, which is all our SDK
# packages need (only nym-wallet / explorer-v2 image stack actually uses them
# at runtime). Mirrors the fix on `max/fix-pnpm-ci` for documentation/docs/.
sharp: false
tiny-secp256k1: false
unrs-resolver: true
catalog:
+103 -62
View File
@@ -1,36 +1,101 @@
#!/bin/bash
# detect active network interface
INTERFACE=$(ip -o link show | awk -F': ' '{print $2}' | grep -v lo | head -n 1)
usage() {
local code="${1:-0}"
cat <<EOF
Usage: $0 [OPTIONS]
echo "Detected active network interface: $INTERFACE"
read -p "Is this correct? (y/n): " CONFIRM
if [[ "$CONFIRM" != "y" ]]; then
echo "Exiting. Please manually specify the correct network interface."
Options:
-i, --interface Network interface (optional; auto-detected if omitted)
-4, --ipv4 IPv4 address for the VM (optional)
-6, --ipv6 IPv6 address for the VM (optional)
-g, --gateway4 IPv4 gateway of the host server (optional)
-G, --gateway6 IPv6 gateway of the host server (optional)
-y, --yes Skip all confirmation prompts (auto-confirm)
-h, --help Show this help message
Example:
$0 --ipv4 192.168.1.100 --gateway4 192.168.1.1 --ipv6 2001:db8::1 --gateway6 2001:db8::fffe
$0 --ipv4 192.168.1.100 --gateway4 192.168.1.1 --yes
EOF
exit "$code"
}
# --- parse flags ---
INTERFACE=""
IPv4_VM=""
IPv6_VM=""
IPv4_GATEWAY_HOST_SERVER=""
IPv6_GATEWAY_HOST_SERVER=""
AUTO_YES=false
while [[ $# -gt 0 ]]; do
case "$1" in
-i|--interface)
[[ -n "${2:-}" && "${2:0:1}" != "-" ]] || { echo "Error: --interface requires a value."; exit 1; }
INTERFACE="$2"; shift 2 ;;
-4|--ipv4)
[[ -n "${2:-}" && "${2:0:1}" != "-" ]] || { echo "Error: --ipv4 requires a value."; exit 1; }
IPv4_VM="$2"; shift 2 ;;
-6|--ipv6)
[[ -n "${2:-}" && "${2:0:1}" != "-" ]] || { echo "Error: --ipv6 requires a value."; exit 1; }
IPv6_VM="$2"; shift 2 ;;
-g|--gateway4)
[[ -n "${2:-}" && "${2:0:1}" != "-" ]] || { echo "Error: --gateway4 requires a value."; exit 1; }
IPv4_GATEWAY_HOST_SERVER="$2"; shift 2 ;;
-G|--gateway6)
[[ -n "${2:-}" && "${2:0:1}" != "-" ]] || { echo "Error: --gateway6 requires a value."; exit 1; }
IPv6_GATEWAY_HOST_SERVER="$2"; shift 2 ;;
-y|--yes) AUTO_YES=true; shift ;;
-h|--help) usage ;;
*)
echo "Error: Unknown option: $1"
usage 1
;;
esac
done
# at least one of IPv4 or IPv6 must be provided
if [[ -z "$IPv4_VM" && -z "$IPv6_VM" ]]; then
echo "Error: At least one of --ipv4 or --ipv6 must be provided."
echo "Run '$0 --help' for usage."
exit 1
fi
# prompt for network settings
read -p "Enter IPv4 address for VM (leave blank if not used): " IPv4_VM
read -p "Enter IPv6 address for VM (leave blank if not used): " IPv6_VM
read -p "Enter IPv4 gateway (host server, leave blank if not used): " IPv4_GATEWAY_HOST_SERVER
read -p "Enter IPv6 gateway (host server, leave blank if not used): " IPv6_GATEWAY_HOST_SERVER
confirm() {
local prompt="$1"
if $AUTO_YES; then
echo "${prompt} [Y/n] (auto-confirmed)"
return 0
fi
read -p "${prompt} [Y/n]: " REPLY
[[ -z "$REPLY" || "$REPLY" == "y" || "$REPLY" == "Y" ]]
}
# resize partition
# --- detect or validate interface ---
if [[ -z "$INTERFACE" ]]; then
INTERFACE=$(ip -o link show | awk -F': ' '{print $2}' | grep -v lo | head -n 1)
echo "Detected active network interface: $INTERFACE"
if ! confirm "Is this correct?"; then
echo "Exiting. Re-run with --interface <name> to specify one manually."
exit 1
fi
else
echo "Using network interface: $INTERFACE"
fi
# --- resize partition ---
echo "Expanding partition and resizing filesystem..."
growpart /dev/vda 1
resize2fs /dev/vda1
df -h
# ask before continuing
read -p "Continue with network configuration? (y/n): " CONTINUE
if [[ "$CONTINUE" != "y" ]]; then
if ! confirm "Continue with network configuration?"; then
echo "Exiting."
exit 1
fi
# generate Netplan config
# --- generate Netplan config ---
NETPLAN_CONFIG="/etc/netplan/01-network-config.yaml"
echo "Creating Netplan configuration at $NETPLAN_CONFIG..."
@@ -45,28 +110,19 @@ network:
addresses:
EOF
# append IPv4 address if provided
if [[ -n "$IPv4_VM" ]]; then
echo " - $IPv4_VM/24" >> $NETPLAN_CONFIG
fi
[[ -n "$IPv4_VM" ]] && echo " - $IPv4_VM/24" >> $NETPLAN_CONFIG
[[ -n "$IPv6_VM" ]] && echo " - $IPv6_VM/64" >> $NETPLAN_CONFIG
# append IPv6 address if provided
if [[ -n "$IPv6_VM" ]]; then
echo " - $IPv6_VM/64" >> $NETPLAN_CONFIG
fi
echo " routes:" >> $NETPLAN_CONFIG
# append IPv4 route if provided
if [[ -n "$IPv4_GATEWAY_HOST_SERVER" ]]; then
echo " - to: default" >> $NETPLAN_CONFIG
echo " via: $IPv4_GATEWAY_HOST_SERVER" >> $NETPLAN_CONFIG
fi
# append IPv6 route if provided
if [[ -n "$IPv6_GATEWAY_HOST_SERVER" ]]; then
echo " - to: default" >> $NETPLAN_CONFIG
echo " via: $IPv6_GATEWAY_HOST_SERVER" >> $NETPLAN_CONFIG
if [[ -n "$IPv4_GATEWAY_HOST_SERVER" || -n "$IPv6_GATEWAY_HOST_SERVER" ]]; then
echo " routes:" >> $NETPLAN_CONFIG
if [[ -n "$IPv4_GATEWAY_HOST_SERVER" ]]; then
echo " - to: default" >> $NETPLAN_CONFIG
echo " via: $IPv4_GATEWAY_HOST_SERVER" >> $NETPLAN_CONFIG
fi
if [[ -n "$IPv6_GATEWAY_HOST_SERVER" ]]; then
echo " - to: default" >> $NETPLAN_CONFIG
echo " via: $IPv6_GATEWAY_HOST_SERVER" >> $NETPLAN_CONFIG
fi
fi
cat <<EOF >> $NETPLAN_CONFIG
@@ -78,58 +134,43 @@ cat <<EOF >> $NETPLAN_CONFIG
- 2001:4860:4860::8888 # Google IPv6 DNS
EOF
# secure Netplan config
chmod 600 $NETPLAN_CONFIG
# generate Netplan configuration
netplan generate
# ask before applying Netplan
read -p "Apply Netplan changes? (y/n): " CONTINUE
if [[ "$CONTINUE" != "y" ]]; then
if ! confirm "Apply Netplan changes?"; then
echo "Exiting."
exit 1
fi
# apply Netplan and verify settings
netplan --debug apply
# show IP configurations
ip -4 a
ip -6 a
ip -4 r
ip -6 r
# test network connectivity
echo "Testing IPv4 connectivity for 10 seconds..."
timeout 10 ping -4 google.com
echo "Testing IPv6 connectivity for 10 seconds..."
timeout 10 ping -6 google.com
# ask before updating system
read -p "Proceed with system update and upgrade? (y/n): " CONTINUE
if [[ "$CONTINUE" != "y" ]]; then
echo "Skipping updates."
else
if confirm "Proceed with system update and upgrade?"; then
apt update && apt upgrade -y
else
echo "Skipping updates."
fi
# generate SSH host keys without password
# --- SSH setup ---
echo "Generating SSH host keys..."
ssh-keygen -t rsa -f /etc/ssh/ssh_host_rsa_key -N ""
ssh-keygen -t dsa -f /etc/ssh/ssh_host_dsa_key -N ""
ssh-keygen -t ecdsa -f /etc/ssh/ssh_host_ecdsa_key -N ""
ssh-keygen -t rsa -f /etc/ssh/ssh_host_rsa_key -N ""
ssh-keygen -t dsa -f /etc/ssh/ssh_host_dsa_key -N ""
ssh-keygen -t ecdsa -f /etc/ssh/ssh_host_ecdsa_key -N ""
ssh-keygen -t ed25519 -f /etc/ssh/ssh_host_ed25519_key -N ""
# restart SSH service
systemctl restart ssh.service
# ensure ~/.ssh directory exists
mkdir -p ~/.ssh
# Open authorized_keys file for user input
echo "# Add your admin SSH keys here, save and exit!" > ~/.ssh/authorized_keys
nano ~/.ssh/authorized_keys
echo "Setup complete! Try to ping and ssh from the outside before killing this console"
echo "Setup complete! Try to ping and ssh from the outside before killing this console"
+101 -80
View File
@@ -1,6 +1,86 @@
#!/bin/bash
# check if noble-server-cloudimg-amd64.img is in working dir - if not, wget it
usage() {
local code="${1:-0}"
cat <<EOF
Usage: $0 [OPTIONS]
Options:
-n, --name VM name (required)
-p, --password Root password (required)
-c, --cpus Number of vCPUs (required)
-r, --ram RAM in MB (required); e.g. 2048, 4096, 8192
-s, --size Disk size to add in GB (required); e.g. 50, 100, 200
-h, --help Show this help message
Example:
$0 --name myvm --password secret --cpus 4 --ram 8192 --size 100
EOF
exit "$code"
}
# --- parse flags ---
VM_NAME=""
PASSWORD=""
VCPUS=""
RAM=""
SIZE=""
while [[ $# -gt 0 ]]; do
case "$1" in
-n|--name)
[[ -n "${2:-}" && "${2:0:1}" != "-" ]] || { echo "Error: --name requires a value."; exit 1; }
VM_NAME="$2"; shift 2 ;;
-p|--password)
[[ -n "${2:-}" && "${2:0:1}" != "-" ]] || { echo "Error: --password requires a value."; exit 1; }
PASSWORD="$2"; shift 2 ;;
-c|--cpus)
[[ -n "${2:-}" && "${2:0:1}" != "-" ]] || { echo "Error: --cpus requires a value."; exit 1; }
VCPUS="$2"; shift 2 ;;
-r|--ram)
[[ -n "${2:-}" && "${2:0:1}" != "-" ]] || { echo "Error: --ram requires a value."; exit 1; }
RAM="$2"; shift 2 ;;
-s|--size)
[[ -n "${2:-}" && "${2:0:1}" != "-" ]] || { echo "Error: --size requires a value."; exit 1; }
SIZE="$2"; shift 2 ;;
-h|--help) usage ;;
*)
echo "Error: Unknown option: $1"
usage 1
;;
esac
done
# --- validate required flags ---
MISSING=()
[[ -z "$VM_NAME" ]] && MISSING+=("--name")
[[ -z "$PASSWORD" ]] && MISSING+=("--password")
[[ -z "$VCPUS" ]] && MISSING+=("--cpus")
[[ -z "$RAM" ]] && MISSING+=("--ram")
[[ -z "$SIZE" ]] && MISSING+=("--size")
if [[ ${#MISSING[@]} -gt 0 ]]; then
echo "Error: Missing required flags: ${MISSING[*]}"
echo "Run '$0 --help' for usage."
exit 1
fi
if [[ ! "$VCPUS" =~ ^[0-9]+$ || "$VCPUS" -lt 1 ]]; then
echo "Error: --cpus must be a positive integer."
exit 1
fi
if [[ ! "$RAM" =~ ^[0-9]+$ || "$RAM" -lt 256 ]]; then
echo "Error: --ram must be a positive integer (minimum 256 MB)."
exit 1
fi
if [[ ! "$SIZE" =~ ^[0-9]+$ || "$SIZE" -lt 1 ]]; then
echo "Error: --size must be a positive integer in GB."
exit 1
fi
# --- check / download base image ---
if [[ ! -f noble-server-cloudimg-amd64.img ]]; then
echo "Base image not found. Downloading noble-server-cloudimg-amd64.img..."
wget https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img
@@ -10,99 +90,40 @@ if [[ ! -f noble-server-cloudimg-amd64.img ]]; then
fi
fi
# prompt for VM_NAME
read -p "Enter VM name: " VM_NAME
if [[ -z "$VM_NAME" ]]; then
echo "Error: VM_NAME cannot be empty. Exiting."
exit 1
fi
# prompt for PASSWORD w silent input
read -s -p "Enter password for the VM: " PASSWORD
echo
if [[ -z "$PASSWORD" ]]; then
echo "Error: PASSWORD cannot be empty. Exiting."
exit 1
fi
# prompt for number of vCPUs
read -p "Enter the number of vCPUs for the VM: " VCPUS
if [[ -z "$VCPUS" || ! "$VCPUS" =~ ^[0-9]+$ ]]; then
echo "Error: Invalid number of vCPUs. Exiting."
exit 1
fi
# prompt for RAM size with suggestions
DEFAULT_RAM=4096
HALF_RAM=$((DEFAULT_RAM / 2))
DOUBLE_RAM=$((DEFAULT_RAM * 2))
TRIPLE_RAM=$((DEFAULT_RAM * 3))
FOUR_TIMES_RAM=$((DEFAULT_RAM * 4))
SIX_TIMES_RAM=$((DEFAULT_RAM * 6))
EIGHT_TIMES_RAM=$((DEFAULT_RAM * 8))
echo "Choose the amount of RAM for the VM:"
echo "1) $HALF_RAM MB"
echo "2) $DEFAULT_RAM MB (recommended)"
echo "3) $DOUBLE_RAM MB"
echo "4) $TRIPLE_RAM MB"
echo "5) $FOUR_TIMES_RAM MB"
echo "6) $SIX_TIMES_RAM MB"
echo "7) $EIGHT_TIMES_RAM MB"
read -p "Enter your choice (1-7) or specify a custom amount in MB: " RAM_CHOICE
case $RAM_CHOICE in
1) RAM=$HALF_RAM ;;
2) RAM=$DEFAULT_RAM ;;
3) RAM=$DOUBLE_RAM ;;
4) RAM=$TRIPLE_RAM ;;
5) RAM=$FOUR_TIMES_RAM ;;
6) RAM=$SIX_TIMES_RAM ;;
7) RAM=$EIGHT_TIMES_RAM ;;
*)
if [[ "$RAM_CHOICE" =~ ^[0-9]+$ ]]; then
RAM=$RAM_CHOICE
else
echo "Invalid choice. Exiting."
exit 1
fi
;;
esac
# define image path
IMAGE_PATH="/var/lib/libvirt/images/${VM_NAME}.img"
# copy the base image
if [[ -e "$IMAGE_PATH" ]]; then
echo "Error: $IMAGE_PATH already exists. Choose a different --name or remove the old image first."
exit 1
fi
echo "Copying the base image to $IMAGE_PATH..."
cp noble-server-cloudimg-amd64.img "$IMAGE_PATH"
# install guestfs-tools if missing
echo "Checking and installing guestfs-tools if needed..."
if ! dpkg -l | grep -q guestfs-tools; then
sudo apt update && sudo apt install guestfs-tools -y
fi
# set root password inside the image
echo "Setting root password inside the VM image..."
virt-customize -a "$IMAGE_PATH" --root-password password:"$PASSWORD"
# resize the image
echo "Resizing the image by +100G..."
qemu-img resize "$IMAGE_PATH" +100G
echo "Resizing the image by +${SIZE}G..."
qemu-img resize "$IMAGE_PATH" +${SIZE}G
# install the VM and run log in prompt
echo "Starting VM installation..."
virt-install \
--name "$VM_NAME" \
--ram="$RAM" \
--vcpus="$VCPUS" \
--cpu host \
--hvm \
--disk bus=virtio,path="$IMAGE_PATH" \
--network bridge=br0 \
--graphics none \
--console pty,target_type=serial \
--osinfo ubuntunoble \
--import
--name "$VM_NAME" \
--ram="$RAM" \
--vcpus="$VCPUS" \
--cpu host \
--hvm \
--disk bus=virtio,path="$IMAGE_PATH" \
--network bridge=br0 \
--graphics none \
--console pty,target_type=serial \
--osinfo ubuntunoble \
--import
echo "VM $VM_NAME has been successfully installed!"
echo "VM $VM_NAME has been successfully installed!"
@@ -10,8 +10,10 @@ const os = require('os');
/**
* Creates the default Webpack config
* @param baseDir The base directory path, e.g. pass `__dirname` of the webpack config file using this method
* @param htmlPath Path or array of HtmlWebpackPlugin opts
* @param opts Optional flags: `{ skipFavicon: true }` to omit the WebpackFavicons plugin
*/
const webpackCommon = (baseDir, htmlPath) => ({
const webpackCommon = (baseDir, htmlPath, opts = {}) => ({
module: {
rules: [
{
@@ -80,9 +82,13 @@ const webpackCommon = (baseDir, htmlPath) => ({
},
}),
new WebpackFavicons({
src: path.resolve(__dirname, '../../assets/favicon/favicon.png'), // the asset directory is relative to THIS file
}),
...(opts.skipFavicon
? []
: [
new WebpackFavicons({
src: path.resolve(__dirname, '../../../../assets/favicon/favicon.png'), // repo-root /assets/favicon/favicon.png
}),
]),
new Dotenv(),
],
@@ -0,0 +1,27 @@
# mixDNS Usage Example
Shows how to resolve hostnames over the Nym mixnet using
`@nymproject/mix-dns`. Resolution travels the IPR's DNS path (UDP, no TCP/TLS).
```ts
import { setupMixTunnel, mixDNS } from '@nymproject/mix-dns';
await setupMixTunnel();
const ip = await mixDNS('nymtech.net');
```
## Running the example
```
npm install
npm run start
```
Open http://localhost:1234. The example resolves three hostnames and prints
the round-trip time for each.
## Sharing the tunnel
`setupMixTunnel()` is a no-op after the first call across all three smolmix
SDKs (`mix-fetch`, `mix-dns`, `mix-websocket`). If you already imported one
of the others, you can skip the setup line.
@@ -0,0 +1,16 @@
{
"name": "@nymproject/mix-dns-example-parcel",
"version": "0.1.0",
"license": "Apache-2.0",
"scripts": {
"build": "parcel build --no-cache --no-content-hash",
"serve": "serve dist",
"start": "parcel --no-cache"
},
"dependencies": {
"@nymproject/mix-dns": "workspace:*",
"parcel": "^2.9.3"
},
"private": true,
"source": "src/index.html"
}
@@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>mixDNS</title>
<script type="module" src="./index.ts"></script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
padding: 2rem;
}
pre { background: #f4f4f4; padding: 0.5rem; border-radius: 4px; }
</style>
</head>
<body>
<h1>mixDNS</h1>
<p>Resolves a handful of hostnames over the Nym mixnet using the IPR's DNS path.</p>
<pre id="output"></pre>
</body>
</html>
@@ -0,0 +1,54 @@
import { setupMixTunnel, mixDNS, type SetupMixTunnelOpts } from '@nymproject/mix-dns';
function append(line: string) {
const el = document.getElementById('output') as HTMLPreElement;
el.appendChild(document.createTextNode(`${line}\n`));
}
// Tunnel configuration. Every field is optional.
//
// `debug: true` turns on smolmix-wasm's verbose tracing so you can see the
// UDP DNS query and response in DevTools. Leave it off in production.
const setupOpts: SetupMixTunnelOpts = {
debug: true,
// Pin a specific exit IPR. Otherwise auto-discovered from the topology.
// preferredIpr: 'D1rrUqJY9pesL3pTaMaxLnpZGGYQ4ZpZwpQXCqaeBXTW.6PpFkRvF...',
// DNS resolver overrides. Defaults: 1.1.1.1 (primary) / 8.8.8.8 (fallback).
// Set these to test against a specific resolver, e.g. Quad9 for filtered DNS.
// primaryDns: '9.9.9.9',
// fallbackDns: '149.112.112.112',
// Per-query timeout. Default: 30s.
// dnsTimeoutMs: 5_000,
};
// Hostnames cover a mix of cases: the Nym site itself, the de facto smoke
// test (example.com), a major CDN (cloudflare), and a host that takes
// several A records.
const hostnames = ['nymtech.net', 'example.com', 'cloudflare.com', 'github.com'];
async function main() {
append('Setting up mixnet tunnel...');
await setupMixTunnel(setupOpts);
append('Tunnel ready.\n');
for (const host of hostnames) {
const start = performance.now();
try {
const ip = await mixDNS(host);
const ms = Math.round(performance.now() - start);
append(`${host} -> ${ip} (${ms} ms)`);
} catch (err) {
append(`${host} -> error: ${err instanceof Error ? err.message : String(err)}`);
}
}
}
window.addEventListener('DOMContentLoaded', () => {
main().catch((err) => {
console.error(err);
append(`Error: ${err}`);
});
});
@@ -8,9 +8,9 @@
"start": "parcel --no-cache"
},
"dependencies": {
"@nymproject/mix-fetch": ">=1.4.2-rc.0 || ^1",
"@nymproject/mix-fetch": "workspace:*",
"parcel": "^2.9.3"
},
"private": false,
"private": true,
"source": "src/index.html"
}
@@ -1,46 +1,71 @@
import { mixFetch } from '@nymproject/mix-fetch';
import { setupMixTunnel, mixFetch, type SetupMixTunnelOpts } from '@nymproject/mix-fetch';
import { appendOutput, appendImageOutput } from './utils';
// Tunnel configuration. Every field is optional; uncomment to tweak.
//
// `debug: true` turns on smolmix-wasm's verbose tracing so you can watch the
// IPR handshake, DNS lookups, and per-request lifecycle in DevTools. Leave
// it off in production.
const setupOpts: SetupMixTunnelOpts = {
debug: true,
// Pin a specific exit IPR. Otherwise auto-discovered from the topology.
// preferredIpr: 'D1rrUqJY9pesL3pTaMaxLnpZGGYQ4ZpZwpQXCqaeBXTW.6PpFkRvF...',
// Anonymity / performance trade-off. Cover traffic + Poisson padding
// smear timing patterns at the cost of bandwidth. Default: both on.
// disableCoverTraffic: true,
// disablePoissonTraffic: true,
// Custom DNS resolvers (over UDP through the IPR). Default: 1.1.1.1 / 8.8.8.8.
// primaryDns: '9.9.9.9',
// fallbackDns: '149.112.112.112',
// Connect / DNS budgets. Defaults: 60s / 30s respectively.
// connectTimeoutMs: 30_000,
// dnsTimeoutMs: 15_000,
// mixFetch redirect chain depth. Default: 5.
// maxRedirects: 10,
};
async function main() {
// options for mixFetch (you can also set these with the `createMixFetch` function
const mixFetchOptions = {
preferredGateway: '6Gb7ftQdKveMjPyrxDXeAtfYAX7Zg5mVZHtnRC5MmZ1B', // with WSS
preferredNetworkRequester:
'8rRGWy54oC8drFL9DepMegBt2DLrsqQwCoHMXt9nsnTo.2XjCPVbb4FpQ9hNRcXwb9mTzEAVVk1zf1tcch3wdtNEA@6Gb7ftQdKveMjPyrxDXeAtfYAX7Zg5mVZHtnRC5MmZ1B',
mixFetchOverride: {
requestTimeoutMs: 60_000,
},
};
appendOutput('Setting up mixnet tunnel...');
await setupMixTunnel(setupOpts);
appendOutput('Tunnel ready.\n');
// disable CORS (in your app, you probably don't want to disable CORS, it is a good thing to leave it enabled)
const args = { mode: 'unsafe-ignore-cors' };
// this is the URL of standard list of allow hosts the you can request data from with mixFetch and the Nym SOCKS5
// client - you can request to have more hosts added by getting in touch on Discord or Telegram
// Standard allowlist for the Nym network-requester. The IPR enforces its own
// exit policy, so the URL must pass that policy regardless of the source.
let url = 'https://nymtech.net/.wellknown/network-requester/standard-allowed-list.txt';
appendOutput('Get a text file:');
appendOutput(`Downloading ${url}...\n`);
let resp = await mixFetch(url, args, mixFetchOptions); // NB: you only need to pass options to the 1st call
console.log({ resp });
let resp = await mixFetch(url);
const text = await resp.text();
appendOutput(text);
// get an image
appendOutput('\nGet an image:\n');
url = 'https://nymtech.net/favicon.svg';
resp = await mixFetch(url, args);
console.log({ resp });
url = 'https://httpbin.org/image/png';
resp = await mixFetch(url);
const buffer = await resp.arrayBuffer();
const type = resp.headers.get('Content-Type') || 'image/svg';
const type = resp.headers.get('Content-Type') || 'image/png';
const blobUrl = URL.createObjectURL(new Blob([buffer], { type }));
appendImageOutput(blobUrl);
// Per-request header override. smolmix-wasm ships a browser-shape header
// shim (User-Agent + Accept + Accept-Language + Accept-Encoding); anything
// you pass in `init.headers` wins over the shim defaults.
appendOutput('\nOverride User-Agent for one request:\n');
url = 'https://httpbin.org/headers';
resp = await mixFetch(url, {
headers: { 'User-Agent': 'mix-fetch-example/0.1' },
});
appendOutput(await resp.text());
}
// wait for the html to load
window.addEventListener('DOMContentLoaded', () => {
// let's do this!
main();
main().catch((err) => {
console.error(err);
appendOutput(`Error: ${err}`);
});
});
@@ -1,54 +0,0 @@
const { createMixFetch, disconnectMixFetch } = require('@nymproject/mix-fetch-node-commonjs');
/**
* The main entry point
*/
(async () => {
console.log('Tester is starting up...');
const addr =
'D274yd1h3L3pNJzdxE5VgJ7izAsAVMsDrQtFSkKUegfk.8J67cGbcwvrJKF3Kb16HVWWc9AnrFnEibNCm9zCkuVFu@Emswx6KXyjRfq1c2k4d4uD2e6nBSbH1biorCZUei8UNS';
console.log('About to set up mixFetch...');
const { mixFetch } = await createMixFetch({
preferredNetworkRequester: addr,
clientId: 'node-client1',
clientOverride: {
coverTraffic: { disableLoopCoverTrafficStream: true },
traffic: { disableMainPoissonPacketDistribution: true },
},
mixFetchOverride: { requestTimeoutMs: 60000 },
responseBodyConfigMap: {},
extra: {},
});
globalThis.mixFetch = mixFetch;
if (!globalThis.mixFetch) {
console.error('Oh no! Could not create mixFetch');
} else {
console.log('Ready!');
}
let url = 'https://nymtech.net/.wellknown/network-requester/standard-allowed-list.txt';
console.log(`Using mixFetch to get ${url}...`);
const args = { mode: 'unsafe-ignore-cors' };
let resp = await mixFetch(url, args);
console.log({ resp });
const text = await resp.text();
console.log('disconnecting');
await disconnectMixFetch();
console.log('disconnected! all further usages should fail');
// get an image
url = 'https://nymtech.net/favicon.svg';
resp = await mixFetch(url, args);
console.log({ resp });
const buffer = await resp.arrayBuffer();
const type = resp.headers.get('Content-Type') || 'image/svg';
const blobUrl = URL.createObjectURL(new Blob([buffer], { type }));
console.log(JSON.stringify({ bufferBytes: buffer.byteLength, blobUrl }, null, 2));
console.log(blobUrl);
})();
@@ -1,14 +0,0 @@
{
"name": "@nymproject/mix-fetch-node-js-example",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"scripts": {
"start": "node index.js",
"start:server": "node server.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"@nymproject/mix-fetch-node-commonjs": "^1.2.1-rc.2"
}
}
@@ -1,55 +0,0 @@
const express = require('express');
const { mixFetch } = require('@nymproject/mix-fetch-node-commonjs');
const app = express();
app.use(express.static('public'));
app.get('/nym-fetch', async (req, res) => {
try {
const args = {
mode: 'unsafe-ignore-cors',
headers: {
'Content-Type': 'application/json',
},
};
const url = req.query.url;
if (!url) {
return res.status(400).send('input a valid url');
}
const extra = {
hiddenGateways: [
{
owner: 'n1ns3v70ul9gnl9l9fkyz8cyxfq75vjcmx8el0t3',
host: 'sandbox-gateway1.nymtech.net',
explicitIp: '35.158.238.80',
identityKey: 'HjNEDJuotWV8VD4ufeA1jeheTnfNJ7Jorevp57hgaZua',
sphinxKey: 'BoXeUD7ERGmzRauMjJD3itVNnQiH42ncUb6kcVLrb3dy',
},
],
};
const mixFetchOptions = {
nymApiUrl: 'https://sandbox-nym-api1.nymtech.net/api',
preferredGateway: 'HjNEDJuotWV8VD4ufeA1jeheTnfNJ7Jorevp57hgaZua',
preferredNetworkRequester:
'AzGdJ4MU78Ex22NEWfeycbN7bt3PFZr1MtKstAdhfELG.GSxnKnvKPjjQm3FdtsgG5KyhP6adGbPHRmFWDH4XfUpP@HjNEDJuotWV8VD4ufeA1jeheTnfNJ7Jorevp57hgaZua',
mixFetchOverride: {
requestTimeoutMs: 60_000,
},
forceTls: false,
extra,
};
const response = await mixFetch(url, args, mixFetchOptions);
const json = await response.json();
res.send(json);
} catch (error) {
console.log(error);
res.status(500).send(error.message);
}
});
app.listen(3000, () => console.log('Server running on port 3000'));
@@ -0,0 +1,37 @@
# mixWebSocket Usage Example
Shows how to open a WSS connection over the Nym mixnet using
`@nymproject/mix-websocket`. The `MixWebSocket` class mirrors the browser
`WebSocket` API where it makes sense.
```ts
import { setupMixTunnel, MixWebSocket } from '@nymproject/mix-websocket';
await setupMixTunnel();
const ws = new MixWebSocket('wss://echo.websocket.events');
await ws.opened();
ws.addEventListener('message', (e) => console.log(e.data));
await ws.send('hello');
```
## Running the example
```
npm install
npm run start
```
Open http://localhost:1234. The example echoes whatever you send via
`echo.websocket.events`.
## Differences from the browser WebSocket
- `await ws.opened()` blocks until the upgrade completes.
- `binaryType` is fixed to `arraybuffer`.
- No `bufferedAmount`; writes queue through the tunnel worker.
## Sharing the tunnel
`setupMixTunnel()` is shared across `mix-fetch`, `mix-dns`, and
`mix-websocket`. If another of those is already initialised, you can skip
the setup line.
@@ -0,0 +1,16 @@
{
"name": "@nymproject/mix-websocket-example-parcel",
"version": "0.1.0",
"license": "Apache-2.0",
"scripts": {
"build": "parcel build --no-cache --no-content-hash",
"serve": "serve dist",
"start": "parcel --no-cache"
},
"dependencies": {
"@nymproject/mix-websocket": "workspace:*",
"parcel": "^2.9.3"
},
"private": true,
"source": "src/index.html"
}
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>mixWebSocket</title>
<script type="module" src="./index.ts"></script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
padding: 2rem;
}
pre { background: #f4f4f4; padding: 0.5rem; border-radius: 4px; max-height: 320px; overflow: auto; }
.row { display: flex; gap: 0.5rem; margin: 0.5rem 0; }
input { flex: 1; padding: 0.4rem; font-family: monospace; }
button { padding: 0.4rem 0.8rem; cursor: pointer; }
</style>
</head>
<body>
<h1>mixWebSocket</h1>
<p>Opens a WSS connection to <code>wss://echo.websocket.events</code> through the Nym mixnet.</p>
<div class="row">
<input id="message" type="text" value="hello mixnet" placeholder="message">
<button id="send" disabled>send</button>
<button id="close" disabled>close</button>
</div>
<pre id="output"></pre>
</body>
</html>
@@ -0,0 +1,90 @@
import { setupMixTunnel, MixWebSocket, type SetupMixTunnelOpts } from '@nymproject/mix-websocket';
function log(line: string) {
const el = document.getElementById('output') as HTMLPreElement;
el.appendChild(document.createTextNode(`${line}\n`));
el.scrollTop = el.scrollHeight;
}
// Tunnel configuration. Every field is optional.
//
// `debug: true` turns on smolmix-wasm's verbose tracing so you can watch
// the TLS handshake and WebSocket frame exchange in DevTools. Leave it off
// in production.
const setupOpts: SetupMixTunnelOpts = {
debug: true,
// Pin a specific exit IPR. Otherwise auto-discovered from the topology.
// preferredIpr: 'D1rrUqJY9pesL3pTaMaxLnpZGGYQ4ZpZwpQXCqaeBXTW.6PpFkRvF...',
// Anonymity / performance trade-off. Cover traffic + Poisson padding
// smear timing patterns at the cost of bandwidth. Default: both on.
// disableCoverTraffic: true,
// disablePoissonTraffic: true,
// TCP keepalive cadence for the underlying smoltcp socket. Default: 10s.
// Lower it if you need quicker dead-peer detection on idle WebSockets.
// tcpKeepaliveMs: 5_000,
// Connect budget for the TCP + TLS + WS handshake. Default: 60s.
// connectTimeoutMs: 30_000,
};
// Public echo server. Sends each frame back to the client.
const WS_URL = 'wss://echo.websocket.org';
async function main() {
log('Setting up mixnet tunnel...');
await setupMixTunnel(setupOpts);
log('Tunnel ready.');
log(`Connecting to ${WS_URL}...`);
const ws = new MixWebSocket(WS_URL);
ws.addEventListener('open', () => log('< open'));
ws.addEventListener('message', (e) => {
const data = (e as MessageEvent).data;
if (typeof data === 'string') {
log(`< text: ${data}`);
} else if (data instanceof ArrayBuffer) {
log(`< binary: ${data.byteLength} bytes`);
}
});
ws.addEventListener('close', (e) => {
const ce = e as CloseEvent;
log(`< close: code=${ce.code} reason=${ce.reason || '(empty)'}`);
});
ws.addEventListener('error', (e) => {
const evt = e as Event & { message?: string };
log(`< error: ${evt.message ?? '(no detail)'}`);
});
await ws.opened();
const sendBtn = document.getElementById('send') as HTMLButtonElement;
const closeBtn = document.getElementById('close') as HTMLButtonElement;
const input = document.getElementById('message') as HTMLInputElement;
sendBtn.disabled = false;
closeBtn.disabled = false;
sendBtn.addEventListener('click', async () => {
const value = input.value;
if (!value) return;
log(`> ${value}`);
await ws.send(value);
});
closeBtn.addEventListener('click', async () => {
sendBtn.disabled = true;
closeBtn.disabled = true;
await ws.close(1000, 'user requested');
});
}
window.addEventListener('DOMContentLoaded', () => {
main().catch((err) => {
console.error(err);
log(`Error: ${err instanceof Error ? err.message : String(err)}`);
});
});
@@ -0,0 +1,72 @@
# smolmix SDK playground (internal-dev)
Internal-only browser playground that exercises the smolmix-based TS SDK
family end-to-end against a live mixnet:
- `@nymproject/mix-tunnel` &mdash; tunnel lifecycle (setup, state, disconnect)
- `@nymproject/mix-fetch` &mdash; HTTP/HTTPS through the mixnet
- `@nymproject/mix-dns` &mdash; hostname resolution via the IPR
- `@nymproject/mix-websocket` &mdash; WS/WSS through the mixnet
Use at your own risk; this is dev scaffolding, not a polished demo.
## Getting started
From the repo root:
```
make sdk-wasm-build # build smolmix-wasm pkg/
pnpm i # install workspace deps (resolves workspace:* refs)
```
Then build the four TS SDK packages so the playground can resolve them:
```
pnpm build:ci:sdk
```
Then start the dev server (webpack):
```
cd sdk/typescript/packages/internal-dev
pnpm start
```
Open <http://localhost:3000/>. The page has four sections (Tunnel, Fetch,
DNS, WebSocket); start with **Setup tunnel** and wait for the state to flip
green. The other three sections are then live.
Alternative: parcel-based playground in `parcel/` &mdash; same source, different
bundler.
## Iterating on the SDKs
If you edit a TS SDK package (e.g. `mix-fetch`) the playground won't see the
change until the package is rebuilt. From the repo root:
```
pnpm --filter @nymproject/mix-fetch build
```
webpack-dev-server picks up the new `dist/` via the workspace symlink and
hot-reloads.
If you edit `mix-tunnel`'s rollup config or anything touching the inlined
worker bundle, run the package's own `pnpm build` so the rollup chain
re-runs:
```
pnpm --filter @nymproject/mix-tunnel build
```
If you edit `wasm/smolmix` itself, run `make -C wasm/smolmix build-debug`
to regenerate `pkg/` &mdash; then `pnpm --filter @nymproject/mix-tunnel build`
to pick up the new wasm bytes.
## Stuck?
```
rm -rf node_modules && pnpm i && pnpm start
```
Often resets pnpm-link state if the workspace package symlinks have drifted.
@@ -1,14 +1,19 @@
{
"name": "@nymproject/mix-fetch-tester-webpack",
"version": "1.0.6",
"name": "@nymproject/smolmix-internal-dev",
"version": "0.0.0",
"private": true,
"license": "Apache-2.0",
"description": "Internal playground exercising the smolmix-based TS SDK family (mix-tunnel, mix-fetch, mix-dns, mix-websocket).",
"scripts": {
"build": "webpack build --progress --config webpack.prod.js",
"serve": "npx serve dist",
"start": "webpack serve --progress --port 3000"
},
"dependencies": {
"@nymproject/mix-fetch": ">=1.4.2-rc.0 || ^1"
"@nymproject/mix-tunnel": "workspace:*",
"@nymproject/mix-fetch": "workspace:*",
"@nymproject/mix-dns": "workspace:*",
"@nymproject/mix-websocket": "workspace:*"
},
"devDependencies": {
"@babel/core": "catalog:",
@@ -53,6 +58,5 @@
"webpack-cli": "^5.1.4",
"webpack-dev-server": "catalog:",
"webpack-merge": "catalog:"
},
"private": false
}
}
@@ -0,0 +1,18 @@
{
"name": "@nymproject/smolmix-internal-dev-parcel",
"version": "0.0.0",
"license": "Apache-2.0",
"scripts": {
"build": "npx parcel build --no-cache --no-content-hash",
"serve": "npx serve dist",
"start": "npx parcel --no-cache"
},
"dependencies": {
"@nymproject/mix-tunnel": "workspace:*",
"@nymproject/mix-fetch": "workspace:*",
"@nymproject/mix-dns": "workspace:*",
"@nymproject/mix-websocket": "workspace:*"
},
"private": true,
"source": "../src/index.html"
}
@@ -0,0 +1,233 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>smolmix TS SDK dev</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6' fill='%23333'/%3E%3Ccircle cx='8' cy='8' r='2' fill='%23c8f7c5'/%3E%3C/svg%3E">
<style>
body { font-family: system-ui, sans-serif; max-width: 1000px; margin: 16px auto; padding: 0 12px; }
fieldset { margin-bottom: 12px; }
legend { font-weight: bold; }
.row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.status { font-size: 0.85em; color: gray; }
.local-log {
background: #f5f5f5; border: 1px solid #ddd; padding: 6px;
margin: 8px 0 0; max-height: 140px; overflow-y: auto;
font-size: 0.82em; white-space: pre-wrap;
font-family: ui-monospace, monospace;
}
</style>
<!-- HtmlWebpackPlugin injects <script src="index.js"> at build time. -->
</head>
<body>
<h1>smolmix TS SDK dev</h1>
<p class="status">
Drives <code>@nymproject/mix-tunnel</code>, <code>mix-fetch</code>,
<code>mix-dns</code>, <code>mix-websocket</code> against a live mixnet.
Mirrors <code>wasm/smolmix/internal-dev</code> so you can A/B the SDK
layer against the raw WASM.
</p>
<!-- Connection -->
<fieldset id="startup-controls">
<legend>Connection</legend>
<div>
<label>IPR address: </label>
<input type="text" size="80" id="ipr-address" disabled
value="HEpAyUwqTeYQ6sxrL8Pbp6UNVPG6ta1CcbzP9q7vBggp.2byzbUSC6J5t9gpFFXPPnB7tZKW3c6cX1QSfmzYRq3wp@FQBbq1crAkCrjVBnEN85VqgZgGRMLJV65NJk8bPADdw"
placeholder="<nym-address of IPR exit node>" />
<label style="margin-left: 8px">
<input type="checkbox" id="opt-random-ipr" checked /> Use random IPR
</label>
</div>
<details style="margin-top: 8px" open>
<summary style="cursor: pointer; font-size: 0.9em; color: #555">Advanced Options</summary>
<div style="margin-top: 6px; padding: 8px; background: #f9f9f9; font-size: 0.9em">
<div>
<label><input type="checkbox" id="opt-force-tls" checked /> Force TLS</label>
</div>
<div style="margin-top: 4px">
<label>Client ID: <input type="text" id="opt-client-id" size="20" /></label>
<span class="status">(randomised on load for clean state)</span>
</div>
<div style="margin-top: 4px">
<label><input type="checkbox" id="opt-disable-poisson" /> Disable Poisson traffic</label>
</div>
<div style="margin-top: 4px">
<label><input type="checkbox" id="opt-disable-cover" /> Disable cover traffic</label>
</div>
<div style="margin-top: 4px">
<label>Open reply SURBs:
<input type="number" id="opt-open-surbs" value="10" min="1" max="50" style="width: 60px" />
</label>
<label style="margin-left: 8px">Data reply SURBs:
<input type="number" id="opt-data-surbs" value="10" min="0" max="50" style="width: 60px" />
</label>
</div>
<div style="margin-top: 4px">
<label>Primary DNS:
<input type="text" id="opt-primary-dns" placeholder="8.8.8.8:53" size="18" />
</label>
<label style="margin-left: 8px">Fallback DNS:
<input type="text" id="opt-fallback-dns" placeholder="1.1.1.1:53" size="18" />
</label>
</div>
<div style="margin-top: 4px">
<label>User-Agent override:
<input type="text" id="opt-user-agent" size="40" placeholder="(empty → wasm-shim default)" />
</label>
</div>
</div>
</details>
<div style="margin-top: 8px">
<button id="btn-setup">setupMixTunnel</button>
<button id="btn-disconnect" disabled>disconnectMixTunnel</button>
<label style="margin-left: 16px">
<input type="checkbox" id="opt-debug-logging" checked /> Debug logging
</label>
<span id="tunnel-status" class="status" style="margin-left: 10px">Not started</span>
</div>
</fieldset>
<!-- DNS Resolve: tunnel (mixDNS) vs clearnet (DoH JSON) -->
<fieldset id="dns-controls">
<legend>DNS Resolve</legend>
<div class="row">
<input type="text" size="40" id="dns-host" value="example.com" />
<button id="btn-dns-tunnel" disabled>via tunnel</button>
<button id="btn-dns-clearnet">via DoH (clearnet)</button>
</div>
<pre class="local-log" id="dns-log"></pre>
</fieldset>
<!-- GET: tunnel vs clearnet, same URL. Preset buttons fill the URL field. -->
<fieldset id="get-controls">
<legend>GET</legend>
<div class="row">
<input type="text" size="60" id="get-url" value="https://nymtech.net/.wellknown/network-requester/standard-allowed-list.txt" />
<button id="btn-get-tunnel" disabled>via tunnel</button>
<button id="btn-get-clearnet">via window.fetch (clearnet)</button>
</div>
<div class="row" style="margin-top: 4px">
<span class="status">presets:</span>
<button class="preset" data-url="https://upload.wikimedia.org/wikipedia/commons/8/80/Wikipedia-logo-v2.svg" data-render="image">Wikimedia logo (image)</button>
<button class="preset" data-url="https://www.cloudflare.com/cdn-cgi/trace">Cloudflare trace</button>
<button class="preset" data-url="https://httpbin.org/get">httpbin /get</button>
</div>
<pre class="local-log" id="get-log"></pre>
<div id="get-image-output"></div>
</fieldset>
<!-- WebSocket -->
<fieldset id="ws-controls" disabled>
<legend>WebSocket</legend>
<div class="row">
<input type="text" size="60" id="ws-url" value="wss://echo.websocket.org" />
<button id="btn-ws-connect">Connect</button>
<button id="btn-ws-close" disabled>Close</button>
<span id="ws-status" class="status">Not connected</span>
</div>
<div class="row" style="margin-top: 4px">
<input type="text" size="50" id="ws-message" value="Hello from mix-websocket!" />
<button id="btn-ws-send" disabled>Send</button>
</div>
<div class="row" style="margin-top: 4px">
<label>Echo burst:</label>
<input type="number" id="ws-burst-count" value="10" min="1" max="500" style="width: 60px" />
<label>Size:</label>
<input type="number" id="ws-burst-min" value="64" min="1" max="1048576" style="width: 80px" />
<span>&ndash;</span>
<input type="number" id="ws-burst-max" value="1024" min="1" max="1048576" style="width: 80px" />
<span class="status">bytes</span>
<button id="btn-ws-burst" disabled>Send Burst</button>
</div>
<pre class="local-log" id="ws-log"></pre>
</fieldset>
<!-- Stress Test -->
<fieldset id="stress-controls" disabled>
<legend>Stress Test</legend>
<div class="row">
<label>Requests:</label>
<input type="number" id="stress-count" value="10" min="1" max="200" style="width: 60px" />
<label>Mode:</label>
<select id="stress-mode">
<option value="uniform">Uniform</option>
<option value="mixed" selected>Mixed sizes</option>
<option value="drip">Slow drip</option>
</select>
</div>
<div id="stress-uniform-opts" style="display: none; margin-top: 8px; padding: 8px; background: #f9f9f9">
<label>Base URL:</label>
<input type="text" size="50" id="stress-url" value="https://jsonplaceholder.typicode.com/posts/" />
</div>
<div id="stress-mixed-opts" style="margin-top: 8px; padding: 8px; background: #f9f9f9">
<table style="font-size: 0.9em; border-collapse: collapse">
<tr><td style="padding: 1px 10px 1px 0"><b>tiny</b></td><td>128 B</td></tr>
<tr><td style="padding: 1px 10px 1px 0"><b>small</b></td><td>1 KB</td></tr>
<tr><td style="padding: 1px 10px 1px 0"><b>medium</b></td><td>10 KB</td></tr>
<tr><td style="padding: 1px 10px 1px 0"><b>large</b></td><td>100 KB</td></tr>
<tr><td style="padding: 1px 10px 1px 0"><b>xlarge</b></td><td>1 MB</td></tr>
</table>
</div>
<div id="stress-drip-opts" style="display: none; margin-top: 8px; padding: 8px; background: #f9f9f9">
<table style="font-size: 0.9em; border-collapse: collapse">
<tr><td style="padding: 1px 10px 1px 0"><b>safe</b></td><td>~50% of timeout</td></tr>
<tr><td style="padding: 1px 10px 1px 0"><b>boundary</b></td><td>~92% of timeout</td></tr>
<tr><td style="padding: 1px 10px 1px 0"><b>over</b></td><td>~108% of timeout</td></tr>
<tr><td style="padding: 1px 10px 1px 0"><b>slow-start</b></td><td>~17% delay + ~83% drip</td></tr>
</table>
<div style="margin-top: 6px">
<label>Request timeout (s):</label>
<input type="number" id="stress-timeout" value="60" min="5" max="300" style="width: 60px" />
</div>
</div>
<div class="row" style="margin-top: 8px">
<button id="btn-stress">Run Stress Test</button>
<span id="stress-status" class="status"></span>
</div>
<pre class="local-log" id="stress-log"></pre>
</fieldset>
<!-- File Download -->
<fieldset id="download-controls" disabled>
<legend>File Download</legend>
<div style="display: flex; gap: 12px; flex-wrap: wrap">
<div style="flex: 1; min-width: 220px; padding: 8px; background: #f9f9f9; border: 1px solid #ddd">
<b>UTF-8 Demo</b>
<div class="status">Unicode text (Cambridge CS)</div>
<button id="btn-verify-text">Fetch</button>
<span id="verify-text-status" class="status"></span>
<pre id="verify-text-output" style="margin-top: 6px; max-height: 200px; overflow-y: auto; font-size: 0.8em; white-space: pre-wrap; display: none; background: #fff; padding: 6px; border: 1px solid #eee"></pre>
</div>
<div style="flex: 1; min-width: 220px; padding: 8px; background: #f9f9f9; border: 1px solid #ddd">
<b>File Download</b>
<div style="margin: 4px 0 6px">
<input type="text" size="50" id="download-url"
value="https://nymtech.net/uploads/Nym_WFP_Paper_5_58a1105679.pdf" />
</div>
<button id="btn-verify-pdf">Fetch</button>
<button id="btn-save-pdf" disabled>Save</button>
<span id="verify-pdf-status" class="status"></span>
<div id="verify-pdf-output" style="margin-top: 6px; font-size: 0.8em; display: none">
<div>Size: <code id="verify-pdf-size"></code></div>
<div>SHA-256: <code id="verify-pdf-sha"></code></div>
</div>
</div>
</div>
<pre class="local-log" id="download-log"></pre>
</fieldset>
<!-- Master timeline -->
<fieldset>
<legend>Output (master timeline)</legend>
<pre id="output" style="background: #f5f5f5; padding: 8px; max-height: 300px; overflow-y: auto; font-size: 0.85em; white-space: pre-wrap"></pre>
</fieldset>
</body>
</html>
@@ -0,0 +1,680 @@
// smolmix TS SDK dev — mirrors wasm/smolmix/internal-dev so the two
// playgrounds run the same scenarios; the SDK layer adds (mostly) nothing
// the raw WASM doesn't already do, so observed behaviour should match.
import {
setupMixTunnel,
disconnectMixTunnel,
SetupMixTunnelOpts,
} from '@nymproject/mix-tunnel';
import { mixFetch } from '@nymproject/mix-fetch';
import { mixDNS } from '@nymproject/mix-dns';
import { MixWebSocket } from '@nymproject/mix-websocket';
// Helpers ============================================================
const $ = <T extends HTMLElement = HTMLElement>(id: string): T => {
const el = document.getElementById(id);
if (!el) throw new Error(`#${id} not found`);
return el as T;
};
type LogColour = 'green' | 'red' | 'orange' | 'gray' | undefined;
function display(msg: string, colour?: LogColour) {
const ts = new Date().toISOString().slice(11, 23);
const line = document.createElement('div');
if (colour) line.style.color = colour;
line.textContent = `[${ts}] ${msg}`;
const out = $('output');
out.appendChild(line);
out.scrollTop = out.scrollHeight;
if (colour === 'red') console.error('[sdk-dev]', msg);
}
function logTo(targetId: string, msg: string, colour?: LogColour) {
const target = document.getElementById(targetId);
if (!target) return;
const ts = new Date().toISOString().slice(11, 23);
const line = document.createElement('div');
if (colour) line.style.color = colour;
line.textContent = `[${ts}] ${msg}`;
target.appendChild(line);
target.scrollTop = target.scrollHeight;
if (colour === 'red') console.error(`[sdk-dev:${targetId}]`, msg);
}
const formatSize = (bytes: number): string => {
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${bytes} B`;
};
const formatRate = (bytes: number, ms: number): string =>
`${(bytes / 1024 / (ms / 1000)).toFixed(1)} KB/s`;
const hexPreview = (data: Uint8Array | ArrayBuffer, maxBytes = 64): string => {
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
const len = Math.min(bytes.length, maxBytes);
const hex = Array.from(bytes.slice(0, len), (b) => b.toString(16).padStart(2, '0')).join(' ');
return bytes.length > maxBytes ? `${hex} ...` : hex;
};
async function sha256hex(bytes: BufferSource): Promise<string> {
const hash = await crypto.subtle.digest('SHA-256', bytes);
return Array.from(new Uint8Array(hash), (b) => b.toString(16).padStart(2, '0')).join('');
}
function saveFile(buf: BlobPart, filename: string, mimeType: string) {
const blob = new Blob([buf], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
// Tunnel-gated UI bits. GET + DNS fieldsets stay enabled so their clearnet
// buttons work without a tunnel; only the per-button gates are toggled.
const GATED_FIELDSETS = ['ws-controls', 'stress-controls', 'download-controls'];
const GATED_BUTTONS = ['btn-get-tunnel', 'btn-dns-tunnel'];
function setTunnelButtonsEnabled(enabled: boolean) {
for (const id of GATED_FIELDSETS) ($(id) as HTMLFieldSetElement).disabled = !enabled;
for (const id of GATED_BUTTONS) ($(id) as HTMLButtonElement).disabled = !enabled;
}
const strField = (id: string): string | undefined => {
const v = ($(id) as HTMLInputElement).value.trim();
return v.length === 0 ? undefined : v;
};
const numField = (id: string): number | undefined => {
const v = ($(id) as HTMLInputElement).value.trim();
if (!v) return undefined;
const n = Number(v);
return Number.isFinite(n) ? n : undefined;
};
// Override the default User-Agent header injected by the wasm-shim. Kept in
// module scope so each fetch can reach it without re-reading the input.
let userAgentOverride: string | undefined;
const fetchInit = (): RequestInit | undefined =>
userAgentOverride ? { headers: { 'User-Agent': userAgentOverride } } : undefined;
// Connection =========================================================
$('opt-random-ipr').addEventListener('change', (e) => {
$('ipr-address').toggleAttribute('disabled', (e.target as HTMLInputElement).checked);
});
$('btn-setup').addEventListener('click', async () => {
const useRandom = ($('opt-random-ipr') as HTMLInputElement).checked;
const ipr = useRandom ? undefined : ($('ipr-address') as HTMLInputElement).value.trim();
if (!useRandom && !ipr) {
display('IPR address is required (or check "Use random IPR")', 'red');
return;
}
const statusEl = $('tunnel-status');
($('btn-setup') as HTMLButtonElement).disabled = true;
statusEl.textContent = 'Connecting...';
statusEl.style.color = 'orange';
userAgentOverride = strField('opt-user-agent');
const opts: SetupMixTunnelOpts = {
...(ipr ? { preferredIpr: ipr } : {}),
clientId: strField('opt-client-id'),
forceTls: ($('opt-force-tls') as HTMLInputElement).checked,
disablePoissonTraffic: ($('opt-disable-poisson') as HTMLInputElement).checked,
disableCoverTraffic: ($('opt-disable-cover') as HTMLInputElement).checked,
openReplySurbs: numField('opt-open-surbs'),
dataReplySurbs: numField('opt-data-surbs'),
primaryDns: strField('opt-primary-dns'),
fallbackDns: strField('opt-fallback-dns'),
debug: ($('opt-debug-logging') as HTMLInputElement).checked,
};
display(`setupMixTunnel (clientId=${opts.clientId}, IPR: ${ipr ? `${ipr.slice(0, 30)}...` : 'auto-discover'})`);
try {
const t0 = performance.now();
await setupMixTunnel(opts);
const ms = (performance.now() - t0).toFixed(0);
display(`tunnel ready in ${ms} ms`, 'green');
statusEl.textContent = `Connected (${ms} ms)`;
statusEl.style.color = 'green';
setTunnelButtonsEnabled(true);
($('btn-disconnect') as HTMLButtonElement).disabled = false;
} catch (e) {
const msg = String(e);
display(`setupMixTunnel failed: ${msg}`, 'red');
statusEl.textContent = `Failed: ${msg}`;
statusEl.style.color = 'red';
statusEl.title = msg;
($('btn-setup') as HTMLButtonElement).disabled = false;
}
});
$('btn-disconnect').addEventListener('click', async () => {
display('Disconnecting...');
try {
await disconnectMixTunnel();
display('Disconnected', 'green');
$('tunnel-status').textContent = 'Disconnected';
$('tunnel-status').style.color = 'gray';
setTunnelButtonsEnabled(false);
($('btn-disconnect') as HTMLButtonElement).disabled = true;
// Tunnel uses OnceLock semantics — no re-setup without page reload.
($('btn-setup') as HTMLButtonElement).disabled = true;
} catch (e) {
display(`Disconnect failed: ${e}`, 'red');
}
});
// DNS Resolve ========================================================
$('btn-dns-tunnel').addEventListener('click', async () => {
const host = ($('dns-host') as HTMLInputElement).value.trim();
if (!host) { logTo('dns-log', 'Hostname is required', 'red'); return; }
const btn = $('btn-dns-tunnel') as HTMLButtonElement;
btn.disabled = true;
logTo('dns-log', `tunnel resolve ${host}`);
const t0 = performance.now();
try {
const ip = await mixDNS(host);
const ms = (performance.now() - t0).toFixed(0);
logTo('dns-log', `tunnel ${host} => ${ip} (${ms} ms)`, 'green');
} catch (e) {
logTo('dns-log', `tunnel resolve failed: ${e}`, 'red');
} finally {
btn.disabled = false;
}
});
// Browsers expose no raw DNS API; the closest analogue from JS is DoH via
// HTTPS to a public resolver. Google's JSON API is CORS-friendly.
$('btn-dns-clearnet').addEventListener('click', async () => {
const host = ($('dns-host') as HTMLInputElement).value.trim();
if (!host) { logTo('dns-log', 'Hostname is required', 'red'); return; }
logTo('dns-log', `clearnet DoH resolve ${host}`);
const t0 = performance.now();
try {
const resp = await window.fetch(
`https://dns.google/resolve?name=${encodeURIComponent(host)}&type=A`,
{ mode: 'cors' },
);
const json = (await resp.json()) as { Status: number; Answer?: Array<{ type: number; data: string }> };
const ms = (performance.now() - t0).toFixed(0);
if (json.Status !== 0) {
logTo('dns-log', `clearnet DoH error: status=${json.Status} (${ms} ms)`, 'red');
return;
}
const a = json.Answer?.find((r) => r.type === 1);
if (!a) { logTo('dns-log', `clearnet DoH: no A record (${ms} ms)`, 'orange'); return; }
logTo('dns-log', `clearnet ${host} => ${a.data} (${ms} ms); visible in DevTools Network`, 'green');
} catch (e) {
logTo('dns-log', `clearnet DoH failed: ${e}`, 'red');
}
});
// GET ===============================================================
// `asImage` is a per-call argument rather than module state because GET
// requests interleave: a preset-fired image fetch may still be awaiting its
// body when a second preset click arrives. Closing over the flag in the
// caller's scope keeps each invocation's intent isolated.
async function getViaTunnel(url: string, asImage: boolean) {
logTo('get-log', `tunnel GET ${url}`);
const t0 = performance.now();
try {
const resp = await mixFetch(url, fetchInit());
const ms = (performance.now() - t0).toFixed(0);
const ct = resp.headers.get('content-type') ?? '?';
logTo('get-log', `tunnel ${resp.status} ${resp.statusText} (${ms} ms, ${ct})`, 'green');
if (!resp.ok) {
const errText = await resp.text();
logTo('get-log', errText.length > 600 ? `${errText.slice(0, 600)}\n... (${errText.length} bytes total)` : errText);
return;
}
if (asImage) {
const buf = await resp.arrayBuffer();
const type = resp.headers.get('content-type') ?? 'image/svg+xml';
const blobUrl = URL.createObjectURL(new Blob([buf], { type }));
const img = document.createElement('img');
img.src = blobUrl;
img.style.maxWidth = '120px';
img.style.marginRight = '4px';
img.title = url;
$('get-image-output').appendChild(img);
} else {
const text = await resp.text();
logTo('get-log', text.length > 400 ? `${text.slice(0, 400)}\n... (${text.length} bytes total)` : text);
}
} catch (e) {
logTo('get-log', `tunnel GET failed: ${e}`, 'red');
}
}
$('btn-get-tunnel').addEventListener('click', () => {
const url = ($('get-url') as HTMLInputElement).value.trim();
if (!url) { logTo('get-log', 'URL is required', 'red'); return; }
getViaTunnel(url, false);
});
$('btn-get-clearnet').addEventListener('click', async () => {
const url = ($('get-url') as HTMLInputElement).value.trim();
if (!url) { logTo('get-log', 'URL is required', 'red'); return; }
logTo('get-log', `clearnet GET ${url}`);
const t0 = performance.now();
try {
const resp = await window.fetch(url, { mode: 'cors' });
const ms = (performance.now() - t0).toFixed(0);
logTo('get-log', `clearnet ${resp.status} ${resp.statusText} (${ms} ms); visible in DevTools Network`, 'green');
} catch (e) {
logTo('get-log', `clearnet fetch failed: ${e}`, 'red');
}
});
// Preset buttons fill the URL field and immediately tunnel-fetch. The
// `data-render="image"` hint asks the GET handler to render the body as an
// inline <img> rather than logging the response text.
for (const btn of Array.from(document.querySelectorAll<HTMLButtonElement>('button.preset'))) {
btn.addEventListener('click', () => {
const url = btn.dataset.url ?? '';
if (!url) return;
($('get-url') as HTMLInputElement).value = url;
getViaTunnel(url, btn.dataset.render === 'image');
});
}
// WebSocket =========================================================
let activeWs: MixWebSocket | undefined;
let wsConnectT0 = 0;
const wsSendQueue: number[] = [];
// Burst-mode state: collected silently to avoid log spam during 500-msg runs.
let wsBurstActive = false;
let wsBurstRtts: number[] = [];
let wsBurstExpected = 0;
let wsBurstResolve: (() => void) | null = null;
let wsBurstHashes: string[] = [];
let wsBurstVerified = 0;
let wsBurstMismatches = 0;
function setWsButtonState(state: 'connected' | 'connecting' | 'disconnected') {
const connected = state === 'connected';
const connecting = state === 'connecting';
($('btn-ws-connect') as HTMLButtonElement).disabled = connected || connecting;
($('btn-ws-send') as HTMLButtonElement).disabled = !connected;
($('btn-ws-close') as HTMLButtonElement).disabled = !connected;
($('btn-ws-burst') as HTMLButtonElement).disabled = !connected;
}
$('btn-ws-connect').addEventListener('click', () => {
const url = ($('ws-url') as HTMLInputElement).value.trim();
if (!url) { logTo('ws-log', 'WebSocket URL is required', 'red'); return; }
// Tear down any prior connection so a rapid double-click doesn't leak it.
if (activeWs && activeWs.readyState !== 3 /* CLOSED */) activeWs.close();
const statusEl = $('ws-status');
statusEl.textContent = 'Connecting...';
statusEl.style.color = 'orange';
setWsButtonState('connecting');
wsSendQueue.length = 0;
logTo('ws-log', `connecting to ${url}`);
wsConnectT0 = performance.now();
const ws = new MixWebSocket(url);
activeWs = ws;
ws.addEventListener('open', () => {
const ms = (performance.now() - wsConnectT0).toFixed(0);
logTo('ws-log', `connected in ${ms} ms`, 'green');
statusEl.textContent = `Connected (${ms} ms)`;
statusEl.style.color = 'green';
setWsButtonState('connected');
});
ws.addEventListener('message', async (ev) => {
const data = (ev as MessageEvent).data;
let preview: string;
let bytes: Uint8Array<ArrayBuffer> | undefined;
if (typeof data === 'string') {
preview = data.length <= 200 ? data : `${data.slice(0, 200)}...`;
} else if (data instanceof ArrayBuffer) {
bytes = new Uint8Array(data as ArrayBuffer);
preview = `[binary ${bytes.length} bytes] ${hexPreview(bytes)}`;
} else {
preview = `[unknown ${typeof data}]`;
}
const rttMs = wsSendQueue.length > 0 ? performance.now() - (wsSendQueue.shift() as number) : null;
if (wsBurstActive) {
if (rttMs !== null) wsBurstRtts.push(rttMs);
// Verify echo content against the recorded send hash.
if (bytes) {
const hash = await sha256hex(bytes);
if (hash === wsBurstHashes[wsBurstVerified + wsBurstMismatches]) wsBurstVerified += 1;
else wsBurstMismatches += 1;
}
if (wsBurstRtts.length >= wsBurstExpected && wsBurstResolve) wsBurstResolve();
return;
}
if (rttMs !== null) logTo('ws-log', `recv (${rttMs.toFixed(0)} ms RTT): ${preview}`, 'green');
else logTo('ws-log', `recv: ${preview}`, 'green');
});
ws.addEventListener('close', (ev) => {
const ce = ev as CloseEvent;
logTo('ws-log', `closed: ${ce.code} ${ce.reason}`, 'orange');
statusEl.textContent = 'Closed';
statusEl.style.color = 'gray';
setWsButtonState('disconnected');
activeWs = undefined;
});
ws.addEventListener('error', (ev) => {
// MixWebSocket attaches a non-standard `.message` to its error events so
// the playground can surface the underlying cause.
const msg = (ev as Event & { message?: string }).message ?? '(no detail)';
logTo('ws-log', `error: ${msg}`, 'red');
statusEl.textContent = 'Error';
statusEl.style.color = 'red';
});
});
$('btn-ws-send').addEventListener('click', async () => {
if (!activeWs || activeWs.readyState !== 1 /* OPEN */) return;
const msg = ($('ws-message') as HTMLInputElement).value;
wsSendQueue.push(performance.now());
try {
await activeWs.send(msg);
logTo('ws-log', `send: ${msg}`);
} catch (e) {
logTo('ws-log', `send failed: ${e}`, 'red');
}
});
$('btn-ws-close').addEventListener('click', async () => {
if (!activeWs) return;
logTo('ws-log', 'closing...');
try {
await activeWs.close(1000, 'done');
} catch (e) {
logTo('ws-log', `close failed: ${e}`, 'red');
}
});
$('btn-ws-burst').addEventListener('click', async () => {
if (!activeWs || activeWs.readyState !== 1) return;
const count = parseInt(($('ws-burst-count') as HTMLInputElement).value, 10);
const minSize = parseInt(($('ws-burst-min') as HTMLInputElement).value, 10);
const maxSize = parseInt(($('ws-burst-max') as HTMLInputElement).value, 10);
if (count < 1 || count > 500) { logTo('ws-log', 'burst count must be 1-500', 'red'); return; }
if (minSize < 1 || maxSize < minSize) { logTo('ws-log', 'invalid size range', 'red'); return; }
($('btn-ws-burst') as HTMLButtonElement).disabled = true;
($('btn-ws-send') as HTMLButtonElement).disabled = true;
// Generate random payloads + pre-compute their hashes for echo verification.
const payloads: Uint8Array[] = [];
wsBurstHashes = [];
let totalBytes = 0;
for (let i = 0; i < count; i++) {
const size = minSize + Math.floor(Math.random() * (maxSize - minSize + 1));
const buf = new Uint8Array(size);
crypto.getRandomValues(buf);
payloads.push(buf);
totalBytes += size;
// eslint-disable-next-line no-await-in-loop
wsBurstHashes.push(await sha256hex(buf));
}
wsBurstActive = true;
wsBurstRtts = [];
wsBurstExpected = count;
wsBurstVerified = 0;
wsBurstMismatches = 0;
wsSendQueue.length = 0;
logTo('ws-log', `burst: ${count} msgs, ${formatSize(totalBytes)} total`);
const burstDone = new Promise<void>((resolve) => {
wsBurstResolve = resolve;
});
const t0 = performance.now();
for (const payload of payloads) {
wsSendQueue.push(performance.now());
// eslint-disable-next-line no-await-in-loop
await activeWs.send(payload);
}
await burstDone;
const totalMs = performance.now() - t0;
wsBurstActive = false;
wsBurstResolve = null;
const rtts = wsBurstRtts.slice().sort((a, b) => a - b);
const rttMin = rtts[0]?.toFixed(0) ?? '?';
const rttMax = rtts[rtts.length - 1]?.toFixed(0) ?? '?';
const rttAvg = rtts.length ? (rtts.reduce((a, b) => a + b, 0) / rtts.length).toFixed(0) : '?';
const p50 = rtts[Math.floor(rtts.length * 0.5)]?.toFixed(0) ?? '?';
const p95 = rtts[Math.floor(rtts.length * 0.95)]?.toFixed(0) ?? '?';
const msgPerSec = (count / (totalMs / 1000)).toFixed(1);
logTo('ws-log', `burst done: ${count} msgs in ${(totalMs / 1000).toFixed(2)}s (${msgPerSec} msg/s, ${formatRate(totalBytes, totalMs)})`, 'green');
logTo('ws-log', `verify: ${wsBurstVerified}/${count} OK${wsBurstMismatches > 0 ? `, ${wsBurstMismatches} MISMATCH` : ''}`, wsBurstMismatches === 0 ? 'green' : 'red');
logTo('ws-log', `RTT: min=${rttMin} avg=${rttAvg} p50=${p50} p95=${p95} max=${rttMax} ms`);
($('btn-ws-burst') as HTMLButtonElement).disabled = false;
($('btn-ws-send') as HTMLButtonElement).disabled = false;
});
// Stress Test =======================================================
interface SizeProfile { label: string; bytes: number; }
const SIZE_PROFILES: SizeProfile[] = [
{ label: 'tiny', bytes: 128 },
{ label: 'small', bytes: 1024 },
{ label: 'medium', bytes: 10240 },
{ label: 'large', bytes: 102400 },
{ label: 'xlarge', bytes: 1048576 },
];
interface DripProfile { label: string; duration: number; delay: number; bytes: number; }
const buildDripProfiles = (timeoutSec: number): DripProfile[] => [
{ label: 'safe', duration: Math.round(timeoutSec * 0.5), delay: 0, bytes: 100 },
{ label: 'boundary', duration: Math.round(timeoutSec * 0.92), delay: 0, bytes: 100 },
{ label: 'over', duration: Math.round(timeoutSec * 1.08), delay: 0, bytes: 100 },
{ label: 'slow-start', duration: Math.round(timeoutSec * 0.83), delay: Math.round(timeoutSec * 0.17), bytes: 100 },
];
interface StressRequest { id: number; url: string; label: string; }
function generateRequests(count: number, mode: string, timeoutSec: number): StressRequest[] {
const requests: StressRequest[] = [];
if (mode === 'uniform') {
const baseUrl = ($('stress-url') as HTMLInputElement).value.trim();
for (let i = 1; i <= count; i++) requests.push({ id: i, url: `${baseUrl}${i}`, label: 'uniform' });
} else if (mode === 'mixed') {
for (let i = 1; i <= count; i++) {
const p = SIZE_PROFILES[Math.floor(Math.random() * SIZE_PROFILES.length)];
requests.push({ id: i, url: `https://httpbin.org/bytes/${p.bytes}`, label: p.label });
}
} else if (mode === 'drip') {
const profiles = buildDripProfiles(timeoutSec);
for (let i = 1; i <= count; i++) {
const p = profiles[Math.floor(Math.random() * profiles.length)];
requests.push({
id: i,
url: `https://httpbin.org/drip?duration=${p.duration}&numbytes=${p.bytes}&delay=${p.delay}&code=200`,
label: p.label,
});
}
}
return requests;
}
interface StressResult { id: number; label: string; ok: boolean; elapsed: string; status?: number; textLength?: number; error?: string; }
async function runOneStressRequest(req: StressRequest): Promise<StressResult> {
const tag = `#${req.id} ${req.label}`;
const start = performance.now();
try {
const resp = await mixFetch(req.url, fetchInit());
const body = await resp.text();
const elapsed = ((performance.now() - start) / 1000).toFixed(2);
logTo('stress-log', `[${tag}] ${resp.status} OK ${elapsed}s (${body.length}B)`, 'green');
return { id: req.id, label: req.label, ok: true, elapsed, status: resp.status, textLength: body.length };
} catch (e) {
const elapsed = ((performance.now() - start) / 1000).toFixed(2);
logTo('stress-log', `[${tag}] FAIL ${elapsed}s: ${e}`, 'red');
return { id: req.id, label: req.label, ok: false, elapsed, error: String(e) };
}
}
$('stress-mode').addEventListener('change', (ev) => {
const mode = (ev.target as HTMLSelectElement).value;
$('stress-uniform-opts').style.display = mode === 'uniform' ? 'block' : 'none';
$('stress-mixed-opts').style.display = mode === 'mixed' ? 'block' : 'none';
$('stress-drip-opts').style.display = mode === 'drip' ? 'block' : 'none';
});
$('btn-stress').addEventListener('click', async () => {
const count = parseInt(($('stress-count') as HTMLInputElement).value, 10);
const mode = ($('stress-mode') as HTMLSelectElement).value;
const timeoutSec = parseInt(($('stress-timeout') as HTMLInputElement)?.value || '60', 10);
const statusEl = $('stress-status');
($('btn-stress') as HTMLButtonElement).disabled = true;
statusEl.textContent = 'Running...';
const requests = generateRequests(count, mode, timeoutSec);
if (mode === 'mixed' || mode === 'drip') {
const breakdown: Record<string, number> = {};
for (const r of requests) breakdown[r.label] = (breakdown[r.label] || 0) + 1;
logTo('stress-log', `${count} requests, ${mode} mode, profiles: ${JSON.stringify(breakdown)}`);
} else {
logTo('stress-log', `${count} requests, ${mode} mode`);
}
const t0 = performance.now();
const settled = await Promise.allSettled(requests.map((r) => runOneStressRequest(r)));
const totalSec = ((performance.now() - t0) / 1000).toFixed(2);
const results: StressResult[] = settled.map((s) =>
s.status === 'fulfilled' ? s.value : ({ id: -1, label: '?', ok: false, elapsed: '?', error: String(s.reason) } as StressResult),
);
const ok = results.filter((r) => r.ok).length;
const fail = results.filter((r) => !r.ok).length;
logTo('stress-log', `done: ${ok}/${count} OK, ${fail} failed (${totalSec}s total)`, fail === 0 ? 'green' : 'red');
if (fail > 0) {
for (const r of results.filter((r) => !r.ok)) {
logTo('stress-log', ` FAIL #${r.id} ${r.label} (${r.elapsed}s): ${r.error}`);
}
}
statusEl.textContent = `Done: ${ok}/${count} OK, ${fail} failed (${totalSec}s)`;
($('btn-stress') as HTMLButtonElement).disabled = false;
});
// File Download =====================================================
// UCS Cambridge UTF-8 demo file — small, public, character-rich. Good for
// confirming byte-for-byte preservation across the tunnel.
const VERIFY_TEXT_URL = 'https://www.cl.cam.ac.uk/~mgk25/ucs/examples/UTF-8-demo.txt';
let cachedPdf: ArrayBuffer | null = null;
$('btn-verify-text').addEventListener('click', async () => {
const statusEl = $('verify-text-status');
const outputEl = $('verify-text-output') as HTMLPreElement;
($('btn-verify-text') as HTMLButtonElement).disabled = true;
statusEl.textContent = 'Fetching...';
statusEl.style.color = 'orange';
const t0 = performance.now();
try {
const resp = await mixFetch(VERIFY_TEXT_URL, fetchInit());
const text = await resp.text();
const ms = (performance.now() - t0).toFixed(0);
statusEl.textContent = `${resp.status} OK (${ms} ms, ${text.length} chars)`;
statusEl.style.color = 'green';
outputEl.textContent = text;
outputEl.style.display = 'block';
logTo('download-log', `UTF-8 demo: ${text.length} chars (${ms} ms)`, 'green');
} catch (e) {
statusEl.textContent = `Failed: ${e}`;
statusEl.style.color = 'red';
logTo('download-log', `UTF-8 demo failed: ${e}`, 'red');
} finally {
($('btn-verify-text') as HTMLButtonElement).disabled = false;
}
});
$('btn-verify-pdf').addEventListener('click', async () => {
const url = ($('download-url') as HTMLInputElement).value.trim();
if (!url) { logTo('download-log', 'PDF URL is required', 'red'); return; }
const statusEl = $('verify-pdf-status');
const outputEl = $('verify-pdf-output');
($('btn-verify-pdf') as HTMLButtonElement).disabled = true;
statusEl.textContent = 'Fetching...';
statusEl.style.color = 'orange';
const t0 = performance.now();
try {
const resp = await mixFetch(url, fetchInit());
const buf = await resp.arrayBuffer();
const ms = (performance.now() - t0).toFixed(0);
const hash = await sha256hex(buf);
cachedPdf = buf;
$('verify-pdf-size').textContent = `${formatSize(buf.byteLength)} (${buf.byteLength} bytes)`;
$('verify-pdf-sha').textContent = hash;
outputEl.style.display = 'block';
($('btn-save-pdf') as HTMLButtonElement).disabled = false;
statusEl.textContent = `${resp.status} OK (${ms} ms)`;
statusEl.style.color = 'green';
logTo('download-log', `PDF: ${formatSize(buf.byteLength)} (${ms} ms), sha256=${hash.slice(0, 16)}...`, 'green');
} catch (e) {
statusEl.textContent = `Failed: ${e}`;
statusEl.style.color = 'red';
logTo('download-log', `PDF failed: ${e}`, 'red');
} finally {
($('btn-verify-pdf') as HTMLButtonElement).disabled = false;
}
});
$('btn-save-pdf').addEventListener('click', () => {
if (!cachedPdf) return;
const url = ($('download-url') as HTMLInputElement).value.trim();
const name = url.split('/').pop() || 'download.pdf';
saveFile(cachedPdf, name, 'application/pdf');
});
// Page load ==========================================================
window.addEventListener('DOMContentLoaded', () => {
// Pre-populate clientId so each page load uses a fresh keystore slot.
($('opt-client-id') as HTMLInputElement).value = `sdk-${Math.random().toString(36).slice(2, 8)}`;
display('SDK dev ready. Click setupMixTunnel to connect.');
});
@@ -1,5 +1,5 @@
{
"extends": "../../../tsconfig.json",
"extends": "../../tsconfig.json",
"compilerOptions": {
"module": "ESNext",
"outDir": "./dist"
@@ -0,0 +1,38 @@
const path = require('path');
const { mergeWithRules } = require('webpack-merge');
const { webpackCommon } = require('../../examples/.webpack/webpack.base');
// smolmix-wasm is base64-inlined into the @nymproject/mix-tunnel worker bundle
// (see mix-tunnel/rollup/worker.mjs `maxFileSize`), which is itself base64-inlined
// into mix-tunnel/dist/esm/index.js. No sibling .wasm asset to copy.
module.exports = mergeWithRules({
module: {
rules: {
test: 'match',
use: 'replace',
},
},
})(
webpackCommon(
__dirname,
[
{
inject: true,
filename: 'index.html',
template: path.resolve(__dirname, 'src/index.html'),
chunks: ['index'],
},
],
{ skipFavicon: true },
),
{
entry: {
index: path.resolve(__dirname, 'src/index.ts'),
},
output: {
path: path.resolve(__dirname, 'dist'),
publicPath: '/',
},
},
);
+30
View File
@@ -0,0 +1,30 @@
# @nymproject/mix-dns
Hostname-to-IP resolution over the Nym mixnet. Uses the IP Packet Router's
DNS path (UDP), so no TCP socket or TLS handshake is set up.
## Usage
```ts
import { setupMixTunnel, mixDNS } from '@nymproject/mix-dns';
await setupMixTunnel();
const ip = await mixDNS('example.com');
console.log(ip); // "93.184.216.34"
```
The tunnel is shared with `@nymproject/mix-fetch` and
`@nymproject/mix-websocket` via `@nymproject/mix-tunnel`; calling
`setupMixTunnel` once is enough for all three.
## Consumer build requirements
Ships as raw ESM with a bare `import` of `@nymproject/mix-tunnel`. Use a
bundler that follows package imports (webpack, rollup, parcel, vite,
esbuild).
Runs in any environment exposing `Worker`, `WebAssembly`, `Blob`, and
`URL.createObjectURL`. That covers modern browsers, Electron renderers,
and mobile WebViews (Capacitor, Cordova, Ionic, iOS WKWebView, Android
WebView). A Node-direct entry point is not yet ported from v1.
@@ -0,0 +1,52 @@
{
"name": "@nymproject/mix-dns",
"version": "0.1.0",
"description": "Hostname-to-IP resolution over the Nym mixnet (DNS over UDP/IPR, no TCP/TLS).",
"license": "Apache-2.0",
"author": "Nym Technologies SA",
"homepage": "https://nym.com",
"repository": {
"type": "git",
"url": "https://github.com/nymtech/nym",
"directory": "sdk/typescript/packages/mix-dns"
},
"bugs": {
"url": "https://github.com/nymtech/nym/issues"
},
"keywords": ["nym", "mixnet", "privacy", "dns"],
"sideEffects": false,
"files": [
"dist/**/*"
],
"main": "dist/index.js",
"module": "dist/index.js",
"browser": "dist/index.js",
"scripts": {
"build": "tsc",
"clean": "rimraf dist",
"docs:generate": "typedoc",
"docs:generate:prod": "typedoc --basePath ./docs/tsdoc/nymproject/mix-dns/",
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"start": "tsc -w",
"tsc": "tsc --noEmit true"
},
"dependencies": {
"@nymproject/mix-tunnel": "workspace:*"
},
"devDependencies": {
"@nymproject/eslint-config-react-typescript": "workspace:*",
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
"eslint": "^8.10.0",
"rimraf": "catalog:",
"tslib": "catalog:",
"typescript": "^4.8.4"
},
"private": false,
"type": "module",
"types": "./dist/index.d.ts",
"publishConfig": {
"access": "public"
}
}
@@ -0,0 +1,26 @@
// @nymproject/mix-dns
//
// Hostname-to-IP resolution over the Nym mixnet. Travels the IPR's DNS
// path (UDP) without setting up a TCP or TLS connection.
import {
getMixTunnel,
setupMixTunnel,
disconnectMixTunnel,
getTunnelState,
SetupMixTunnelOpts,
} from '@nymproject/mix-tunnel';
export { setupMixTunnel, disconnectMixTunnel, getTunnelState };
export type { SetupMixTunnelOpts };
/**
* Resolve a hostname through the mixnet. Returns the IP as a string
* (e.g. `"93.184.216.34"`).
*
* The tunnel must already be set up via `setupMixTunnel()`.
*/
export const mixDNS = async (hostname: string): Promise<string> => {
const tunnel = await getMixTunnel();
return tunnel.mixDNS(hostname);
};
@@ -0,0 +1,20 @@
{
"compileOnSave": false,
"compilerOptions": {
"lib": ["es2021", "dom", "esnext"],
"outDir": "./dist/",
"module": "esnext",
"target": "esnext",
"allowJs": false,
"strict": true,
"moduleResolution": "node",
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"declaration": true,
"sourceMap": true,
"baseUrl": ".",
"downlevelIteration": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "**/node_modules", "dist", "**/dist"]
}
@@ -1,8 +1,9 @@
{
"sort": ["kind"],
"entryPoints": ["./src/index.ts"],
"out": "./docs",
"exclude": ["./src/worker/**"],
"out": "../../../../documentation/docs/pages/developers/mix-dns/api",
"plugin": ["typedoc-plugin-markdown"],
"entryFileName": "globals",
"kindSortOrder": [
"Function",
"Interface",
@@ -1,2 +0,0 @@
src/worker/*.js
docs
@@ -1,14 +0,0 @@
# Nym MixFetch
This package is a drop-in replacement for `fetch` in NodeJS to send HTTP requests over the Nym Mixnet.
## Usage
```js
const { mixFetch } = require('@nymproject/mix-fetch-node-commonjs');
...
const response = await mixFetch('https://nymtech.net');
const html = await response.text();
```
@@ -1,54 +0,0 @@
const { createMixFetch, disconnectMixFetch } = require('../dist/cjs/index.js');
/**
* The main entry point
*/
(async () => {
console.log('Tester is starting up...');
const addr =
'D274yd1h3L3pNJzdxE5VgJ7izAsAVMsDrQtFSkKUegfk.8J67cGbcwvrJKF3Kb16HVWWc9AnrFnEibNCm9zCkuVFu@Emswx6KXyjRfq1c2k4d4uD2e6nBSbH1biorCZUei8UNS';
console.log('About to set up mixFetch...');
const { mixFetch } = await createMixFetch({
preferredNetworkRequester: addr,
clientId: 'node-client1',
clientOverride: {
coverTraffic: { disableLoopCoverTrafficStream: true },
traffic: { disableMainPoissonPacketDistribution: true },
},
mixFetchOverride: { requestTimeoutMs: 60000 },
responseBodyConfigMap: {},
extra: {},
});
globalThis.mixFetch = mixFetch;
if (!globalThis.mixFetch) {
console.error('Oh no! Could not create mixFetch');
} else {
console.log('Ready!');
}
let url = 'https://nymtech.net/.wellknown/network-requester/standard-allowed-list.txt';
console.log(`Using mixFetch to get ${url}...`);
const args = { mode: 'unsafe-ignore-cors' };
let resp = await mixFetch(url, args);
console.log({ resp });
const text = await resp.text();
console.log('disconnecting');
await disconnectMixFetch();
console.log('disconnected! all further usages should fail');
// get an image
url = 'https://nymtech.net/favicon.svg';
resp = await mixFetch(url, args);
console.log({ resp });
const buffer = await resp.arrayBuffer();
const type = resp.headers.get('Content-Type') || 'image/svg';
const blobUrl = URL.createObjectURL(new Blob([buffer], { type }));
console.log(JSON.stringify({ bufferBytes: buffer.byteLength, blobUrl }, null, 2));
console.log(blobUrl);
})();
@@ -1,16 +0,0 @@
import preset from 'ts-jest/presets/index.js';
/** @type {import('ts-jest').JestConfigWithTsJest} */
export default {
...preset.defaults,
verbose: true,
transform: {
'^.+\\.(ts|tsx)$': [
'ts-jest',
{
tsconfig: 'tsconfig.jest.json',
useESM: true,
},
],
},
};
@@ -1,73 +0,0 @@
{
"name": "@nymproject/mix-fetch-node",
"version": "1.4.3",
"description": "This package is a drop-in replacement for `fetch` in NodeJS to send HTTP requests over the Nym Mixnet.",
"license": "Apache-2.0",
"author": "Nym Technologies SA",
"files": [
"dist/cjs/worker.js",
"dist/**/*"
],
"main": "dist/cjs/index.js",
"scripts": {
"build": "scripts/build-prod.sh",
"build:dev": "scripts/build.sh",
"build:worker": "rollup -c rollup-worker.config.mjs",
"clean": "rimraf dist",
"docs:dev": "run-p docs:watch docs:serve ",
"docs:generate": "typedoc",
"docs:generate:prod": "typedoc --basePath ./docs/tsdoc/nymproject/sdk/",
"docs:prod:build": "scripts/build-prod-docs-collect.sh",
"docs:serve": "reload -b -d ./docs -p 3000",
"docs:watch": "nodemon --ext ts --watch './src/**/*' --watch './typedoc.json' --exec \"pnpm docs:generate\"",
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"start": "tsc -w",
"start:dev": "nodemon --watch src -e ts,json --exec 'pnpm build:dev:esm'",
"test": "node --experimental-fetch --experimental-vm-modules node_modules/jest/bin/jest.js -c=jest.config.mjs --no-cache",
"tsc": "tsc --noEmit true"
},
"dependencies": {
"@nymproject/mix-fetch-wasm-node": ">=1.4.2-rc.0 || ^1",
"comlink": "^4.3.1",
"fake-indexeddb": "^5.0.0",
"node-fetch": "^3.3.2",
"ws": "^8.14.2"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^24.0.1",
"@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-replace": "^5.0.2",
"@rollup/plugin-typescript": "^10.0.1",
"@rollup/plugin-wasm": "^6.1.1",
"@types/jest": "^27.0.1",
"@types/node": "^16.7.13",
"@types/ws": "catalog:",
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
"eslint": "^8.10.0",
"eslint-config-airbnb": "catalog:",
"eslint-config-airbnb-typescript": "catalog:",
"eslint-config-prettier": "catalog:",
"eslint-import-resolver-root-import": "catalog:",
"eslint-plugin-import": "catalog:",
"eslint-plugin-jest": "catalog:",
"eslint-plugin-jsx-a11y": "catalog:",
"eslint-plugin-prettier": "catalog:",
"jest": "^29.5.0",
"nodemon": "^2.0.21",
"reload": "^3.2.1",
"rimraf": "catalog:",
"rollup": "^3.9.1",
"rollup-plugin-base64": "^1.0.1",
"rollup-plugin-modify": "^3.0.0",
"rollup-plugin-web-worker-loader": "^1.6.1",
"ts-jest": "^29.1.0",
"ts-loader": "catalog:",
"tslib": "catalog:",
"typedoc": "^0.24.8",
"typescript": "^4.8.4"
},
"private": false,
"types": "./dist/cjs/index.d.ts"
}
@@ -1,31 +0,0 @@
/* eslint-disable import/no-extraneous-dependencies */
import replace from '@rollup/plugin-replace';
import resolve from '@rollup/plugin-node-resolve';
import typescript from '@rollup/plugin-typescript';
import webWorkerLoader from 'rollup-plugin-web-worker-loader';
import { wasm } from '@rollup/plugin-wasm';
export default {
input: 'src/index.ts',
output: {
dir: 'dist/cjs',
format: 'cjs',
},
plugins: [
webWorkerLoader({ targetPlatform: 'node', inline: false }),
replace({
values: {
"createURLWorkerFactory('web-worker-0.js')":
"createURLWorkerFactory(require('path').resolve(__dirname, 'web-worker-0.js'))",
},
delimiters: ['', ''],
preventAssignment: true,
}),
resolve({ browser: false, extensions: ['.js', '.ts'] }),
wasm({ targetEnv: 'node', maxFileSize: 0 }),
typescript({
compilerOptions: { outDir: 'dist/cjs', target: 'es5' },
exclude: ['src/worker.ts'],
}),
],
};
@@ -1,43 +0,0 @@
/* eslint-disable import/no-extraneous-dependencies */
import commonjs from '@rollup/plugin-commonjs';
import modify from 'rollup-plugin-modify';
import resolve from '@rollup/plugin-node-resolve';
import typescript from '@rollup/plugin-typescript';
import { wasm } from '@rollup/plugin-wasm';
export default {
input: 'src/worker/index.ts',
output: {
dir: 'dist/cjs',
format: 'cjs',
},
external: ['util', 'fake-indexeddb'],
plugins: [
resolve({
browser: false,
preferBuiltins: true,
extensions: ['.js', '.ts'],
}),
commonjs(),
modify({
find: 'const ret = new WebSocket(getStringFromWasm0(arg0, arg1));',
replace: 'const ws = require("ws"); const ret = new ws.WebSocket(getStringFromWasm0(arg0, arg1));',
}),
// TODO: `getObject(...).require` seems to generate a warning on Webpack but with Rollup we get a panic since it can't require.
// By hard coding the require here, we can workaround that.
// Reference: https://github.com/rust-random/getrandom/issues/224
modify({ find: 'getObject(arg0).require(getStringFromWasm0(arg1, arg2));', replace: 'require("crypto");' }),
modify({
find: 'getObject(arg0).getRandomValues(getObject(arg1));',
replace: 'require("crypto").getRandomValues(getObject(arg1));',
}),
wasm({ targetEnv: 'node', maxFileSize: 0, fileName: '[name].wasm' }),
typescript({
compilerOptions: {
outDir: 'dist/cjs',
declaration: false,
target: 'es5',
},
}),
],
};
@@ -1,17 +0,0 @@
#!/usr/bin/env bash
set -o errexit
set -o nounset
set -o pipefail
rm -rf ../../../../dist/ts/docs/tsdoc/nymproject/mix-fetch-node || true
# run the build
yarn docs:generate:prod
# move the output outside of the yarn/npm workspaces
mkdir -p ../../../../dist/ts/docs/tsdoc/nymproject
mv docs ../../../../dist/ts/docs/tsdoc/nymproject/mix-fetch-node
echo "Output can be found in:"
realpath ../../../../dist/ts/docs/tsdoc/nymproject/mix-fetch-node
@@ -1,20 +0,0 @@
#!/usr/bin/env bash
set -o errexit
set -o nounset
set -o pipefail
rm -rf dist || true
rm -rf ../../../../dist/ts/sdk/mix-fetch-node || true
# run the build
scripts/build.sh
node scripts/buildPackageJson.mjs
# move the output outside of the yarn/npm workspaces
mkdir -p ../../../../dist/ts/sdk
mv dist ../../../../dist/ts/sdk
mv ../../../../dist/ts/sdk/dist ../../../../dist/ts/sdk/mix-fetch-node
echo "Output can be found in:"
realpath ../../../../dist/ts/sdk/mix-fetch-node
@@ -1,48 +0,0 @@
#!/usr/bin/env bash
set -o errexit
set -o nounset
set -o pipefail
rm -rf dist || true
#-------------------------------------------------------
# WEB WORKER (mix-fetch WASM)
#-------------------------------------------------------
# The web worker needs to be bundled because the WASM bundle needs to be loaded synchronously and all dependencies
# must be included in the worker script (because it is not loaded as an ES Module)
# build the worker
rollup -c rollup-worker.config.mjs
# move it next to the Typescript `src/index.ts` so it can be inlined by rollup
rm -f src/worker/*.js
rm -f src/worker/*.wasm
mv dist/cjs/index.js src/worker/worker.js
# move WASM files out of build area
mkdir -p dist/worker
mv dist/cjs/*.wasm dist/worker
#-------------------------------------------------------
# COMMON JS
#-------------------------------------------------------
# Some old build systems cannot fully handle ESM or ES2021, so build
# a CommonJS bundle targeting ES5
# build the SDK as a CommonJS bundle
rollup -c rollup-cjs.config.mjs
# move WASM files into place
cp dist/worker/*.wasm dist/cjs
#-------------------------------------------------------
# CLEAN UP
#-------------------------------------------------------
rm -rf dist/cjs/worker
# copy README
cp README.md dist/cjs/README.md
@@ -1,22 +0,0 @@
import * as fs from 'fs';
// parse the package.json from the SDK, so we can keep fields like the name and version
const json = JSON.parse(fs.readFileSync('package.json').toString());
// defaults (NB: these are in the output file locations)
const browser = 'index.js';
const main = 'index.js';
const types = 'index.d.ts';
const getPackageJson = (type, suffix) => ({
name: `${json.name}${suffix ? `-${suffix}` : ''}`,
version: json.version,
license: json.license,
author: json.author,
type,
browser,
main,
types,
});
fs.writeFileSync('dist/cjs/package.json', JSON.stringify(getPackageJson('commonjs', 'commonjs'), null, 2));
@@ -1,17 +0,0 @@
#!/usr/bin/env bash
set -o errexit
set -o nounset
set -o pipefail
rm -rf dist || true
rm -rf ../../../../dist || true
yarn
yarn build
cd ../../../../dist/sdk
cd cjs
echo "Publishing CommonJS package to NPM.."
npm publish --access=public
cd ..
@@ -1,77 +0,0 @@
/* eslint-disable-next-line no-console */
import * as Comlink from 'comlink';
import InlineWasmWebWorker from 'web-worker:./worker/worker';
import { Worker } from 'node:worker_threads';
import nodeEndpoint from './node-adapter';
import type { IMixFetchWebWorker } from './types';
import { EventKinds, IMixFetch } from './types';
const createWorker = async () =>
new Promise<Worker>((resolve, reject) => {
// rollup will inline the built worker script, so that when the SDK is used in
// other projects, they will not need to mess around trying to bundle it
// however, it will make this SDK bundle bigger because of Base64 inline data
const worker = new InlineWasmWebWorker();
worker.addListener('error', reject);
worker.addListener('message', (msg: any) => {
worker.removeListener('error', reject);
if (msg.kind === EventKinds.Loaded) {
resolve(worker);
} else {
reject(msg);
}
});
});
const convertHeaders = (headers: any): Headers => {
const out = new Headers();
Object.keys(headers).forEach((key) => {
out.append(key, headers[key]);
});
return out;
};
/**
* Use this method to initialise `mixFetch`.
*
* @returns An instance of `mixFetch` that you can use to make your requests using the same interface as `fetch`.
*/
export const createMixFetch = async (): Promise<IMixFetch> => {
// start the worker
const worker = await createWorker();
// bind with Comlink
const wrappedWorker = Comlink.wrap<IMixFetchWebWorker>(nodeEndpoint(worker));
// handle the responses
const mixFetchWebWorker: IMixFetch = {
setupMixFetch: wrappedWorker.setupMixFetch,
mixFetch: async (url: string, args: any) => {
const workerResponse = await wrappedWorker.mixFetch(url, args);
if (!workerResponse) {
throw new Error('No response received');
}
const { headers: headersRaw, status, statusText } = workerResponse;
// reconstruct the Headers object instance from a plain object
const headers = convertHeaders(headersRaw);
// handle blobs
if (workerResponse.body.blobUrl) {
const blob = await (await fetch(workerResponse.body.blobUrl)).blob();
const body = await blob.arrayBuffer();
return new Response(body, { headers, status, statusText });
}
// handle everything else
const body = Object.values(workerResponse.body)[0]; // we are expecting only one value to be set in `.body`
return new Response(body, { headers, status, statusText });
},
disconnectMixFetch: wrappedWorker.disconnectMixFetch,
};
return mixFetchWebWorker;
};
@@ -1,56 +0,0 @@
/* eslint-disable no-underscore-dangle */
import type { SetupMixFetchOps, IMixFetchFn } from './types';
import { createMixFetch as createMixFetchInternal } from './create-mix-fetch';
// this is the default timeout for getting a response
const REQUEST_TIMEOUT_MILLISECONDS = 60_000;
export * from './types';
/**
* Create a global mixFetch instance and optionally configure settings.
*
* @param opts Optional settings
*/
export const createMixFetch = async (opts?: SetupMixFetchOps) => {
if (!(globalThis as any).__mixFetchGlobal) {
// load the worker and set up mixFetch with defaults
(globalThis as any).__mixFetchGlobal = await createMixFetchInternal();
await (globalThis as any).__mixFetchGlobal.setupMixFetch(opts);
}
return (globalThis as any).__mixFetchGlobal;
};
/**
* mixFetch is a drop-in replacement for the standard `fetch` interface.
*
* @param url The URL to fetch from.
* @param args Fetch options.
* @param opts Optionally configure mixFetch when it gets created. This only happens once, the first time it gets used.
*/
export const mixFetch: IMixFetchFn = async (url, args, opts?: SetupMixFetchOps) => {
// ensure mixFetch instance exists
const instance = await createMixFetch({
mixFetchOverride: {
requestTimeoutMs: REQUEST_TIMEOUT_MILLISECONDS,
},
...opts,
});
// execute user request
return instance.mixFetch(url, args);
};
/**
* Stops the usage of mixFetch and disconnect the client from the mixnet.
*/
export const disconnectMixFetch = async (): Promise<void> => {
// JS: I'm ignoring this lint (no-else-return) because I want to explicitly state
// that `__mixFetchGlobal` is definitely not null in the else branch.
if (!(globalThis as any).__mixFetchGlobal) {
throw new Error("mixFetch hasn't been setup");
} else {
return (globalThis as any).__mixFetchGlobal.disconnectMixFetch();
}
};
@@ -1,43 +0,0 @@
/**
* @license
* Copyright 2019 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// Borrowed from https://github.com/GoogleChromeLabs/comlink/blob/main/src/node-adapter.ts
import { Endpoint } from 'comlink';
export interface NodeEndpoint {
postMessage(message: any, transfer?: any[]): void;
on(type: string, listener: EventListenerOrEventListenerObject, options?: {}): void;
off(type: string, listener: EventListenerOrEventListenerObject, options?: {}): void;
start?: () => void;
}
export default function nodeEndpoint(nep: NodeEndpoint): Endpoint {
const listeners = new WeakMap();
return {
postMessage: nep.postMessage.bind(nep),
addEventListener: (_, eh) => {
const l = (data: any) => {
if ('handleEvent' in eh) {
eh.handleEvent({ data } as MessageEvent);
} else {
eh({ data } as MessageEvent);
}
};
nep.on('message', l);
listeners.set(eh, l);
},
removeEventListener: (_, eh) => {
const l = listeners.get(eh);
if (!l) {
return;
}
nep.off('message', l);
listeners.delete(eh);
},
start: nep.start && nep.start.bind(nep),
};
}
@@ -1,98 +0,0 @@
import type { MixFetchOpts } from '@nymproject/mix-fetch-wasm-node';
type IMixFetchWorkerFn = (url: string, args: any) => Promise<MixFetchWebWorkerResponse>;
// export type IMixFetchFn = typeof fetch;
export type IMixFetchFn = (url: string, args: any, opts?: SetupMixFetchOps) => Promise<Response>;
export type SetupMixFetchOps = MixFetchOpts & {
responseBodyConfigMap?: ResponseBodyConfigMap;
};
export interface IMixFetchWebWorker {
mixFetch: IMixFetchWorkerFn;
setupMixFetch: (opts?: SetupMixFetchOps) => Promise<void>;
disconnectMixFetch: () => Promise<void>;
}
export interface IMixFetch {
mixFetch: IMixFetchFn;
setupMixFetch: (opts?: SetupMixFetchOps) => Promise<void>;
disconnectMixFetch: () => Promise<void>;
}
export enum EventKinds {
Loaded = 'Loaded',
}
export interface LoadedEvent {
kind: EventKinds.Loaded;
args: {
loaded: true;
};
}
export interface ResponseBody {
uint8array?: Uint8Array;
json?: any;
text?: string;
formData?: any;
blobUrl?: string;
}
export type ResponseBodyMethod = 'uint8array' | 'json' | 'text' | 'formData' | 'blob';
export interface ResponseBodyConfigMap {
/**
* Set the response `Content-Type`s to decode as uint8array.
*/
uint8array?: Array<RegExp | string>;
/**
* Set the response `Content-Type`s to decode with the `json()` response body method.
*/
json?: Array<RegExp | string>;
/**
* Set the response `Content-Type`s to decode with the `text()` response body method.
*/
text?: Array<RegExp | string>;
/**
* Set the response `Content-Type`s to decode with the `formData()` response body method.
*/
formData?: Array<RegExp | string>;
/**
* Set the response `Content-Type`s to decode with the `blob()` response body method.
*/
blob?: Array<RegExp | string>;
/**
* Set this to the default fallback method. Set to `undefined` if you want to ignore unknown types.
*/
fallback?: ResponseBodyMethod;
}
/**
* Default values for the handling of response bodies.
*/
export const ResponseBodyConfigMapDefaults: ResponseBodyConfigMap = {
uint8array: ['application/octet-stream'],
json: ['application/json', 'text/json', /application\/json.*/, /text\/json\+.*/],
text: ['text/plain', /text\/plain.*/, 'text/html', /text\/html.*/],
formData: ['application/x-www-form-urlencoded', 'multipart/form-data'],
blob: [/image\/.*/, /video\/.*/],
fallback: 'blob',
};
export interface MixFetchWebWorkerResponse {
body: ResponseBody;
url: string;
headers: any;
status: number;
statusText: string;
type: string;
ok: boolean;
redirected: boolean;
}
@@ -1,73 +0,0 @@
import { handleResponseMimeTypes } from './handle-response-mime-types';
describe('handleResponseMimeTypes', () => {
test('gracefully handles empty values', async () => {
const resp = await handleResponseMimeTypes(new Response());
expect(Object.values(resp)).toHaveLength(0);
});
test('handles text', async () => {
const TEXT = 'This is text';
const resp = await handleResponseMimeTypes(
new Response(TEXT, { headers: new Headers([['Content-Type', 'text/plain']]) }),
);
expect(resp.text).toBe(TEXT);
});
test('handles text (charset=utf-8)', async () => {
const TEXT = 'This is text';
const resp = await handleResponseMimeTypes(
new Response(TEXT, { headers: new Headers([['Content-Type', 'text/plain; charset=utf-8']]) }),
);
expect(resp.text).toBe(TEXT);
});
test('handles html', async () => {
const TEXT = 'This is html';
const resp = await handleResponseMimeTypes(
new Response(TEXT, { headers: new Headers([['Content-Type', 'text/html']]) }),
);
expect(resp.text).toBe(TEXT);
});
test('handles html (charset=utf-8)', async () => {
const TEXT = 'This is html';
const resp = await handleResponseMimeTypes(
new Response(TEXT, { headers: new Headers([['Content-Type', 'text/html; charset=utf-8']]) }),
);
expect(resp.text).toBe(TEXT);
});
test('handles images', async () => {
const DATA = Buffer.from(new Uint8Array([0, 1, 2, 3]));
const resp = await handleResponseMimeTypes(
new Response(DATA, { headers: new Headers([['Content-Type', 'image/jpeg']]) }),
);
expect(resp.blobUrl).toBeDefined();
});
test('handles videos', async () => {
const DATA = Buffer.from(new Uint8Array([0, 1, 2, 3]));
const resp = await handleResponseMimeTypes(
new Response(DATA, { headers: new Headers([['Content-Type', 'video/mpeg4']]) }),
);
expect(resp.blobUrl).toBeDefined();
});
test('handles form data when URL encoded', async () => {
const formData = 'foo=bar&baz=42';
const resp = await handleResponseMimeTypes(
new Response(formData, { headers: new Headers([['Content-Type', 'application/x-www-form-urlencoded']]) }),
);
expect(resp.formData.foo).toBe('bar');
expect(resp.formData.baz).toBe('42');
});
test('handles JSON data', async () => {
const json = '{ "foo": "bar", "baz": 42 }';
const resp = await handleResponseMimeTypes(
new Response(json, { headers: new Headers([['Content-Type', 'application/json']]) }),
);
expect(resp.text).toBe(json);
});
test('handles JSON data (charset=utf-8)', async () => {
const json = '{ "foo": "bar", "baz": 42 }';
const resp = await handleResponseMimeTypes(
new Response(json, { headers: new Headers([['Content-Type', 'application/json; charset=utf-8']]) }),
);
expect(resp.text).toBe(json);
});
});
@@ -1,111 +0,0 @@
import type { ResponseBody, ResponseBodyConfigMap, ResponseBodyMethod } from '../types';
import { ResponseBodyConfigMapDefaults } from '../types';
const getContentType = (response?: Response) => {
if (!response) {
return undefined;
}
// this is what should be returned in the headers
if (response.headers.has('Content-Type')) {
return response.headers.get('Content-Type') as string;
}
// handle weird servers that use lowercase headers
if (response.headers.has('content-type')) {
return response.headers.get('content-type') as string;
}
// the Content-Type/content-type header is not part of the response
return undefined;
};
const doHandleResponseMethod = async (response: Response, method?: ResponseBodyMethod): Promise<ResponseBody> => {
switch (method) {
case 'uint8array':
return {
uint8array: new Uint8Array(await response.arrayBuffer()),
};
case 'json':
case 'text':
return { text: await response.text() };
case 'blob': {
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
return { blobUrl };
}
case 'formData': {
const formData: any = {};
const data = await response.formData();
// eslint-disable-next-line no-restricted-syntax
for (const pair of data.entries()) {
const [key, value] = pair;
formData[key] = value;
}
return { formData };
}
default:
return {};
}
};
const testIfIncluded = (value?: string, tests?: Array<string | RegExp>): boolean => {
if (!tests) {
return false;
}
if (!value) {
return false;
}
for (let i = 0; i < tests.length; i += 1) {
const test = tests[i];
if (typeof test === 'string' && value === test) {
return true;
}
if ((test as RegExp).test && (test as RegExp).test(value)) {
return true;
}
}
// default return is false, because nothing above matched
return false;
};
export const handleResponseMimeTypes = async (
response: Response,
config?: ResponseBodyConfigMap,
): Promise<ResponseBody> => {
// combine the user supplied config with the default
const finalConfig: ResponseBodyConfigMap = { ...ResponseBodyConfigMapDefaults, ...config };
const contentType = getContentType(response);
// check if the headers say what the content type are, otherwise return the bytes of the response as a blob
if (!contentType) {
// no content type, or body, so the response is only the status, e.g. GET
if (!response.body) {
return {};
}
// handle fallback method
return doHandleResponseMethod(response, config?.fallback || 'blob');
}
if (testIfIncluded(contentType, finalConfig.uint8array)) {
return doHandleResponseMethod(response, 'uint8array');
}
if (testIfIncluded(contentType, finalConfig.json)) {
return doHandleResponseMethod(response, 'json');
}
if (testIfIncluded(contentType, finalConfig.text)) {
return doHandleResponseMethod(response, 'text');
}
if (testIfIncluded(contentType, finalConfig.formData)) {
return doHandleResponseMethod(response, 'formData');
}
if (testIfIncluded(contentType, finalConfig.blob)) {
return doHandleResponseMethod(response, 'blob');
}
return {};
};
@@ -1,9 +0,0 @@
import './polyfill';
import { loadWasm } from './wasm-loading';
import { run } from './main';
(async function main() {
await loadWasm();
await run();
})().catch((e: any) => console.error('Unhandled exception in mixFetch worker', e));
@@ -1,74 +0,0 @@
/* eslint-disable no-console */
import * as Comlink from 'comlink';
import { parentPort } from 'node:worker_threads';
import { setupMixFetch, disconnectMixFetch } from '@nymproject/mix-fetch-wasm-node';
import type { IMixFetchWebWorker, LoadedEvent } from '../types';
import nodeEndpoint from '../node-adapter';
import { EventKinds, ResponseBodyConfigMap, ResponseBodyConfigMapDefaults } from '../types';
import { handleResponseMimeTypes } from './handle-response-mime-types';
/**
* Helper method to send typed messages.
* @param event The strongly typed message to send back to the calling thread.
*/
const postMessageWithType = <E>(event: E) => parentPort?.postMessage(event);
export async function run() {
const { mixFetch } = globalThis as any;
let responseBodyConfigMap: ResponseBodyConfigMap = ResponseBodyConfigMapDefaults;
const mixFetchWebWorker: IMixFetchWebWorker = {
mixFetch: async (url, args) => {
console.log('[Worker] --- mixFetch ---', { url, args });
const response: Response = await mixFetch(url, args);
console.log('[Worker]', { response, json: JSON.stringify(response, null, 2) });
const bodyResponse = await handleResponseMimeTypes(response, responseBodyConfigMap);
console.log('[Worker]', { bodyResponse });
const headers: any = {};
response.headers.forEach((value, key) => {
headers[key] = value;
});
const output = {
body: bodyResponse,
url: response.url,
headers,
status: response.status,
statusText: response.statusText,
type: response.type,
ok: response.ok,
redirected: response.redirected,
};
console.log('[Worker]', { output });
return output;
},
setupMixFetch: async (opts) => {
console.log('[Worker] --- setupMixFetch ---', { opts });
if (opts?.responseBodyConfigMap) {
responseBodyConfigMap = opts.responseBodyConfigMap;
}
await setupMixFetch(opts || {});
},
disconnectMixFetch: async () => {
console.log('[Worker] --- disconnectMixFetch ---');
await disconnectMixFetch();
},
};
// start comlink listening for messages and handle them above
if (parentPort) {
Comlink.expose(mixFetchWebWorker, nodeEndpoint(parentPort));
}
// notify any listeners that the web worker has loaded - HOWEVER, mixFetch hasn't been setup and the client started
// call `setupMixFetch` from the main thread to start the Nym client
postMessageWithType<LoadedEvent>({ kind: EventKinds.Loaded, args: { loaded: true } });
}
@@ -1,37 +0,0 @@
import { TextDecoder, TextEncoder } from 'node:util';
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import WebSocket from 'ws';
import fetch, { Headers, Request, Response } from 'node-fetch';
import { Worker } from 'node:worker_threads';
import { indexedDB } from 'fake-indexeddb';
(globalThis as any).performance = {
now() {
const [sec, nsec] = process.hrtime();
return sec * 1000 + nsec / 1000000;
},
};
(globalThis as any).TextDecoder = TextDecoder;
(globalThis as any).fetch = fetch;
(globalThis as any).Headers = Headers;
(globalThis as any).Request = Request;
(globalThis as any).Response = Response;
(globalThis as any).fs = fs;
(globalThis as any).crypto = crypto;
(globalThis as any).WebSocket = WebSocket;
(globalThis as any).Worker = Worker;
globalThis.process = process;
globalThis.TextEncoder = TextEncoder;
globalThis.Reflect = Reflect;
globalThis.Proxy = Proxy;
globalThis.Error = Error;
globalThis.Promise = Promise;
globalThis.Object = Object;
globalThis.indexedDB = indexedDB;
// has to be loaded after all the polyfill action
// eslint-disable-next-line import/extensions, import/no-extraneous-dependencies
import('@nymproject/mix-fetch-wasm-node/wasm_exec');
@@ -1,69 +0,0 @@
/* eslint-disable no-console */
/// <reference types="@nymproject/mix-fetch-wasm-node" />
// Copyright 2020-2023 Nym Technologies SA
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import '@nymproject/mix-fetch-wasm-node/mix_fetch_wasm_bg.wasm';
// @ts-ignore
import getGoConnectionWasmBytes from '@nymproject/mix-fetch-wasm-node/go_conn.wasm';
import {
send_client_data,
start_new_mixnet_connection,
mix_fetch_initialised,
finish_mixnet_connection,
set_panic_hook,
} from '@nymproject/mix-fetch-wasm-node';
export async function loadGoWasm() {
// rollup will provide a function to get the Go connection WASM bytes here
const bytes = await getGoConnectionWasmBytes();
const go = new Go(); // Defined in wasm_exec.js
// the WebAssembly runtime will parse the bytes and then start the Go runtime
const wasmObj = await WebAssembly.instantiate(bytes, go.importObject);
// eslint-disable-next-line no-console
console.log('Loaded GO WASM');
go.run(wasmObj);
}
function setupRsGoBridge() {
const rsGoBridge = {
send_client_data,
start_new_mixnet_connection,
mix_fetch_initialised,
finish_mixnet_connection,
};
// and to discourage users from trying to call those methods directly)
// @ts-expect-error globalThis has index signature of any
// eslint-disable-next-line no-underscore-dangle
globalThis.__rs_go_bridge__ = rsGoBridge;
}
export async function loadWasm() {
// load go WASM package
await loadGoWasm();
console.log('Loaded GO WASM');
// sets up better stack traces in case of in-rust panics
set_panic_hook();
setupRsGoBridge();
}
@@ -1,35 +0,0 @@
{
"compileOnSave": false,
"compilerOptions": {
"lib": ["es2021", "dom", "dom.iterable", "esnext", "webworker"],
"module": "esnext",
"target": "esnext",
"strict": true,
"moduleResolution": "node",
"skipLibCheck": true,
"skipDefaultLibCheck": true,
"declaration": true,
"baseUrl": ".",
"esModuleInterop": true,
"allowJs": true,
"downlevelIteration": true
},
"include": ["src/**/*.ts"],
"exclude": [
"jest.*",
"webpack.config.js",
"webpack.prod.js",
"webpack.common.js",
"node_modules",
"**/node_modules",
"dist",
"**/dist",
"scripts",
"jest",
"__tests__",
"**/__tests__",
"__jest__",
"**/__jest__",
"config/*"
]
}
@@ -1,6 +0,0 @@
declare module 'web-worker:*' {
import { Worker } from 'node:worker_threads';
const WorkerFactory: new () => Worker;
export default WorkerFactory;
}
@@ -1,11 +0,0 @@
declare module '@nymproject/mix-fetch-wasm-node/wasm_exec' {
export declare global {
class Go {
constructor();
importObject: any;
run(goWasm: any);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,3 +0,0 @@
{
"presets": ["@babel/env"]
}
@@ -1,14 +0,0 @@
# Nym MixFetch
This package is a drop-in replacement for `fetch` to send HTTP requests over the Nym Mixnet.
## Usage
```js
const { mixFetch } = require('@nymproject/mix-fetch');
...
const response = await mixFetch('https://nymtech.net');
const html = await response.text();
```
@@ -1,14 +0,0 @@
# Nym MixFetch
This package is a drop-in replacement for `fetch` to send HTTP requests over the Nym Mixnet.
## Usage
```js
const { mixFetch } = require('@nymproject/mix-fetch');
...
const response = await mixFetch('https://nymtech.net');
const html = await response.text();
```
@@ -1,14 +0,0 @@
# Nym MixFetch
This package is a drop-in replacement for `fetch` to send HTTP requests over the Nym Mixnet.
## Usage
```js
import { mixFetch } from '@nymproject/mix-fetch';
...
const response = await mixFetch('https://nymtech.net');
const html = await response.text();
```
+120 -9
View File
@@ -1,17 +1,128 @@
# Nym MixFetch
# @nymproject/mix-fetch
This package is a drop-in replacement for `fetch` to send HTTP requests over the Nym Mixnet.
Drop-in replacement for `fetch()` that routes HTTP/HTTPS through the Nym
mixnet.
## Usage
Use `mixFetch` in your own project with:
```ts
import { setupMixTunnel, mixFetch } from '@nymproject/mix-fetch';
```js
import { mixFetch } from '@nymproject/mix-fetch';
await setupMixTunnel();
...
const response = await mixFetch('https://nymtech.net');
const html = await response.text();
const res = await mixFetch('https://example.com/');
const html = await res.text();
```
The `setupMixTunnel` call accepts the full `SetupMixTunnelOpts` surface: IPR
pinning, cover-traffic toggles, SURB budgets, DNS overrides, TCP/connect
timeouts, etc. See `@nymproject/mix-tunnel`'s typings for the complete list.
### Convenience: setup + fetch in one call
```ts
import { createMixFetch } from '@nymproject/mix-fetch';
const mixFetch = await createMixFetch({ preferredIpr: '...' });
const res = await mixFetch('https://example.com/');
```
### Shared tunnel
Setting up the tunnel once unlocks all three smolmix SDKs simultaneously:
```ts
import { setupMixTunnel, mixFetch } from '@nymproject/mix-fetch';
import { mixDNS } from '@nymproject/mix-dns';
import { MixWebSocket } from '@nymproject/mix-websocket';
await setupMixTunnel();
await mixFetch('https://example.com/');
await mixDNS('example.com');
const ws = new MixWebSocket('wss://echo.websocket.events');
```
All three packages delegate to `@nymproject/mix-tunnel`, which owns the single
Web Worker hosting `@nymproject/smolmix-wasm`.
## Default request headers
`mixFetch` ships a small browser-shape header shim. If the caller doesn't set
these headers, smolmix-wasm fills them in before the request leaves the
tunnel. Caller-supplied values always win.
| Header | Injected default |
|--------|------------------|
| `User-Agent` | `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36` |
| `Accept` | `text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8` |
| `Accept-Language` | `en-US,en;q=0.9` |
| `Accept-Encoding` | `identity` (the wasm build has no decompressor) |
Rationale: many CDNs (cloudflare bot management) and host policies (wikimedia)
reject requests that lack browser-canonical headers. The shim is a floor:
it does not attempt TLS-fingerprint or HTTP/2 impersonation, just the
header-shaped tells. See the smolmix-wasm README "Browser-shape header
shim" section for the full story and the JA3 caveats.
Override per-request like any other header:
```ts
const res = await mixFetch('https://example.com', {
headers: { 'User-Agent': 'my-app/1.0' },
});
```
## Migrating from v1.x
The legacy v1.x mix-fetch was a thin wrapper around a Go-based wasm network
stack. v2.x is a thin wrapper around the smolmix-wasm Rust stack. The API
surface is **not** identical; if your v1 code looks like the left column,
update it to look like the right:
| v1.x | v2.x |
|---|---|
| `await createMixFetch({ preferredNetworkRequester, clientId, mixFetchOverride, responseBodyConfigMap })` | `await setupMixTunnel({ preferredIpr, clientId, connectTimeoutMs, ... })` |
| `mixFetch(url, args, opts)` (3-arg) | `mixFetch(url, args)` (2-arg) + `setupMixTunnel(opts)` separately |
| `args.mode = 'unsafe-ignore-cors'` | not needed; the IPR enforces its own egress policy, browser CORS doesn't apply |
| `disconnectMixFetch()` | `disconnectMixTunnel()` |
Notable differences:
- **Gateway routing**: v1's `preferredGateway` and `preferredNetworkRequester`
are gone. v2 uses smolmix's IPR auto-discovery by default; pin one with
`preferredIpr` if needed.
- **Response body handling**: v1's `responseBodyConfigMap` (used to opt
particular MIME types into specific body parsers) is gone. v2 returns a
real `Response` object; call `.text()`, `.arrayBuffer()`, `.json()`,
`.blob()` as usual.
- **Cover traffic**: v1's `clientOverride.coverTraffic` is now flat opts
(`disableCoverTraffic`, `disablePoissonTraffic`).
- **Bundle size**: v2 inlines the wasm + worker into a single ESM module.
No sibling assets to ship, at the cost of a large single chunk. Plan
code-splitting around it (dynamic `import('@nymproject/mix-fetch')` is
the usual move).
- **Runtime target**: v2 ships a single ESM bundle that runs in any environment
exposing `Worker`, `WebAssembly`, `Blob`, and `URL.createObjectURL`. That
covers modern browsers, Electron renderers, and mobile WebViews (Capacitor,
Cordova, Ionic, iOS WKWebView, Android WebView). The v1
`@nymproject/mix-fetch-node` companion for Node is not yet ported to the
smolmix backend.
See `@nymproject/mix-tunnel`'s `SetupMixTunnelOpts` for the full v2 options
surface.
## Consumer build requirements
Ships as raw ESM with a bare `import` of `@nymproject/mix-tunnel`. Use a
bundler that follows package imports (webpack, rollup, parcel, vite,
esbuild).
Runs in any environment exposing `Worker`, `WebAssembly`, `Blob`, and
`URL.createObjectURL`. That covers modern browsers, Electron renderers,
and mobile WebViews (Capacitor, Cordova, Ionic, iOS WKWebView, Android
WebView). A Node-direct entry point is not yet ported from v1.
The wasm payload lives inside `@nymproject/mix-tunnel`, so your bundler
will surface a single large chunk. Plan code-splitting around it
(dynamic `import('@nymproject/mix-fetch')` is the usual move).
@@ -1,44 +0,0 @@
# MixFetch Internal Tester
This project is for use by Nym developers only. Use at your own risk!
## Getting started
From the root of this repository run:
```
pnpm i
make sdk-wasm-build
```
Then:
```
cd sdk/typescript/packages/mix-fetch
```
You can run in watch mode:
```
pnpm start
```
Or do a single build:
```
pnpm build:dev:esm-no-inline
```
Then, in another terminal:
```
cd sdk/typescript/packages/mix-fetch/internal-dev/parcel
pnpm i && pnpm start
```
If you have trouble with changes not propagating:
```
rm -rf node_modules && pnpm i && pnpm start
```
@@ -1,15 +0,0 @@
{
"name": "@nymproject/mix-fetch-tester-parcel",
"version": "1.0.6",
"license": "Apache-2.0",
"scripts": {
"build": "npx parcel build --no-cache --no-content-hash",
"serve": "npx serve dist",
"start": "npx parcel --no-cache"
},
"dependencies": {
"@nymproject/mix-fetch": ">=1.4.2-rc.0 || ^1"
},
"private": false,
"source": "../src/index.html"
}
@@ -1,18 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Internal Tester</title>
<script type="module" src="./index.ts"></script>
</head>
<body>
<h1>Internal tester</h1>
<p>Open dev tools to see more output and errors</p>
<pre id="output"></pre>
<div id="outputImage"></div>
</body>
</html>
@@ -1,77 +0,0 @@
import { createMixFetch, disconnectMixFetch } from '@nymproject/mix-fetch';
function appendOutput(value: string) {
const el = document.getElementById('output') as HTMLPreElement;
const text = document.createTextNode(`${value}\n`);
el.appendChild(text);
}
function appendImageOutput(url: string) {
const el = document.getElementById('outputImage') as HTMLPreElement;
const imgNode = document.createElement('img');
imgNode.src = url;
el.appendChild(imgNode);
}
/**
* The main entry point
*/
async function main() {
appendOutput('Tester is starting up...');
// const addr =
// 'EVdJ66jqpoVzmktVecy5UJxsTCEWo5gMn5zDZR7Hm8jy.GXNpoX7RcYcxKvBkV3dSHqC78WaPuWieweRPWzYqNhh5@GAjhJcrd6f1edaqUkfWCff6zdHoqo756qYrc2TfPuCXJ';
// const addr = '7Y9eyF1p1JmzHnd7TVZufnQHkh93ASc9sRBCFY57ZGr8.F8KPyVMVqFQ5yJC3LqeP2ZC7fukzj9a1T426rjo432yT@q2A2cbooyC16YJzvdYaSMH9X3cSiieZNtfBr8cE8Fi1';
const addr = undefined;
appendOutput('About to set up mixFetch...');
const { mixFetch } = await createMixFetch({
preferredNetworkRequester: addr,
clientId: 'my-new-client-16',
clientOverride: {
coverTraffic: { disableLoopCoverTrafficStream: true },
traffic: { disableMainPoissonPacketDistribution: true },
},
mixFetchOverride: { requestTimeoutMs: 60000 },
responseBodyConfigMap: {},
});
(window as any).mixFetch = mixFetch;
if (!(window as any).mixFetch) {
console.error('Oh no! Could not create mixFetch');
appendOutput('Oh no! Could not create mixFetch');
} else {
appendOutput('Ready!');
}
let url = 'https://nymtech.net/.wellknown/network-requester/standard-allowed-list.txt';
appendOutput(`Using mixFetch to get ${url}...`);
const args = { mode: 'unsafe-ignore-cors' };
let resp = await mixFetch(url, args);
console.log({ resp });
const text = await resp.text();
appendOutput(JSON.stringify(resp, null, 2));
appendOutput(JSON.stringify({ text }, null, 2));
// console.log('disconnecting');
// await disconnectMixFetch();
// console.log('disconnected! all further usages should fail');
// get an image
url = 'https://matrix.org/assets/frontpage/github-mark.svg';
resp = await mixFetch(url, args);
console.log({ resp });
const buffer = await resp.arrayBuffer();
const type = resp.headers.get('Content-Type') || 'image/svg';
const blobUrl = URL.createObjectURL(new Blob([buffer], { type }));
appendOutput(JSON.stringify({ bufferBytes: buffer.byteLength, blobUrl }, null, 2));
appendImageOutput(blobUrl);
}
// wait for the html to load
window.addEventListener('DOMContentLoaded', () => {
// let's do this!
main();
});
@@ -1,46 +0,0 @@
const path = require('path');
const { mergeWithRules } = require('webpack-merge');
const CopyPlugin = require('copy-webpack-plugin');
const { webpackCommon } = require('../../../examples/.webpack/webpack.base');
console.log('mix-fetch package path is: ', path.dirname(require.resolve('@nymproject/mix-fetch/package.json')));
module.exports = mergeWithRules({
module: {
rules: {
test: 'match',
use: 'replace',
},
},
})(
webpackCommon(__dirname, [
{
inject: true,
filename: 'index.html',
template: path.resolve(__dirname, 'src/index.html'),
chunks: ['index'],
},
]),
{
entry: {
index: path.resolve(__dirname, 'src/index.ts'),
},
output: {
path: path.resolve(__dirname, 'dist'),
publicPath: '/',
},
plugins: [
new CopyPlugin({
patterns: [
{
// copy the WASM files, because webpack doesn't do this automatically even though there are
// `new URL(..., import.meta.url)` statements in the web worker
// from: path.resolve(path.dirname(require.resolve('@nymproject/mix-fetch/package.json')), 'dist/esm/*.wasm'),
from: path.resolve(path.dirname(require.resolve('@nymproject/mix-fetch/package.json')), '*.wasm'),
to: '[name][ext]',
},
],
}),
],
},
);
@@ -1,15 +0,0 @@
import preset from 'ts-jest/presets/index.js'
/** @type {import('ts-jest').JestConfigWithTsJest} */
export default {
...preset.defaults,
transform: {
'^.+\\.(ts|tsx)$': [
'ts-jest',
{
tsconfig: 'tsconfig.jest.json',
useESM: true,
},
],
},
}
+23 -57
View File
@@ -1,86 +1,52 @@
{
"name": "@nymproject/mix-fetch",
"version": "1.4.3",
"description": "This package is a drop-in replacement for `fetch` to send HTTP requests over the Nym Mixnet.",
"version": "2.0.0",
"description": "Drop-in `fetch` replacement that routes HTTP/HTTPS requests through the Nym mixnet.",
"license": "Apache-2.0",
"author": "Nym Technologies SA",
"homepage": "https://nym.com",
"repository": {
"type": "git",
"url": "https://github.com/nymtech/nym",
"directory": "sdk/typescript/packages/mix-fetch"
},
"bugs": {
"url": "https://github.com/nymtech/nym/issues"
},
"keywords": ["nym", "mixnet", "privacy", "fetch", "http"],
"sideEffects": false,
"files": [
"dist/esm/worker.js",
"dist/cjs/worker.js",
"dist/**/*"
],
"main": "dist/cjs/index.js",
"browser": "dist/esm/index.js",
"main": "dist/index.js",
"module": "dist/index.js",
"browser": "dist/index.js",
"scripts": {
"build": "scripts/build-prod.sh",
"build:dev": "scripts/build.sh",
"build:dev:esm": "MIX_FETCH_DEV_MODE=true scripts/build-dev-esm.sh",
"build:dev:esm-no-inline": "scripts/build-dev-esm.sh",
"build:worker": "rollup -c rollup-worker.config.mjs",
"build:worker:full-fat": "rollup -c rollup-worker-full-fat.config.mjs",
"build": "tsc",
"clean": "rimraf dist",
"docs:dev": "run-p docs:watch docs:serve ",
"docs:generate": "typedoc",
"docs:generate:prod": "typedoc --basePath ./docs/tsdoc/nymproject/sdk/",
"docs:prod:build": "scripts/build-prod-docs-collect.sh",
"docs:serve": "reload -b -d ./docs -p 3000",
"docs:watch": "nodemon --ext ts --watch './src/**/*' --watch './typedoc.json' --exec \"pnpm docs:generate\"",
"docs:generate:prod": "typedoc --basePath ./docs/tsdoc/nymproject/mix-fetch/",
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"prebuild": "node scripts/showDependencyLocation.cjs",
"start": "tsc -w",
"start:dev": "nodemon --watch src -e ts,json --exec 'pnpm build:dev:esm'",
"test": "node --experimental-vm-modules --no-warnings node_modules/jest/bin/jest.js -c=jest.config.mjs --no-cache",
"tsc": "tsc --noEmit true"
},
"dependencies": {
"@nymproject/mix-fetch-wasm": ">=1.4.2-rc.0 || ^1",
"comlink": "^4.3.1"
"@nymproject/mix-tunnel": "workspace:*"
},
"devDependencies": {
"@babel/core": "catalog:",
"@babel/plugin-transform-async-to-generator": "catalog:",
"@babel/preset-env": "catalog:",
"@babel/preset-react": "catalog:",
"@babel/preset-typescript": "catalog:",
"@nymproject/eslint-config-react-typescript": "workspace:*",
"@rollup/plugin-commonjs": "^24.0.1",
"@rollup/plugin-inject": "^5.0.3",
"@rollup/plugin-json": "^6.0.0",
"@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-replace": "^5.0.2",
"@rollup/plugin-terser": "^0.2.1",
"@rollup/plugin-typescript": "^10.0.1",
"@rollup/plugin-wasm": "^6.1.1",
"@types/jest": "^27.0.1",
"@types/node": "^16.7.13",
"@typescript-eslint/eslint-plugin": "catalog:",
"@typescript-eslint/parser": "catalog:",
"eslint": "^8.10.0",
"eslint-config-airbnb": "catalog:",
"eslint-config-airbnb-typescript": "catalog:",
"eslint-config-prettier": "catalog:",
"eslint-import-resolver-root-import": "catalog:",
"eslint-plugin-import": "catalog:",
"eslint-plugin-jest": "catalog:",
"eslint-plugin-jsx-a11y": "catalog:",
"eslint-plugin-prettier": "catalog:",
"eslint-plugin-react": "catalog:",
"eslint-plugin-react-hooks": "catalog:",
"jest": "^29.5.0",
"nodemon": "^2.0.21",
"reload": "^3.2.1",
"rimraf": "catalog:",
"rollup": "^3.9.1",
"rollup-plugin-base64": "^1.0.1",
"rollup-plugin-web-worker-loader": "^1.6.1",
"ts-jest": "^29.1.0",
"ts-loader": "catalog:",
"tslib": "catalog:",
"typedoc": "^0.24.8",
"typescript": "^4.8.4"
},
"private": false,
"type": "module",
"types": "./dist/esm/index.d.ts"
"types": "./dist/index.d.ts",
"publishConfig": {
"access": "public"
}
}
@@ -1,8 +0,0 @@
import { getConfig } from './rollup/cjs.mjs';
export default {
...getConfig({
inline: true,
outputDir: 'dist/cjs-full-fat',
}),
};
@@ -1,5 +0,0 @@
import { getConfig } from './rollup/cjs.mjs';
export default {
...getConfig({ inline: false }),
};
@@ -1,8 +0,0 @@
import { getConfig } from './rollup/esm.mjs';
export default {
...getConfig({
inline: true,
outputDir: 'dist/esm-full-fat',
}),
};
@@ -1,8 +0,0 @@
import { getConfig } from './rollup/esm.mjs';
export default {
...getConfig({
// by default, the web worker will not be inlined, in local development mode it will be
inline: process.env.MIX_FETCH_DEV_MODE === 'true',
}),
};

Some files were not shown because too many files have changed in this diff Show More