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
This commit is contained in:
mfahampshire
2026-06-05 10:36:36 +00:00
committed by GitHub
parent 495f020730
commit 08dc353e82
222 changed files with 3553 additions and 28918 deletions
+7 -1
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
@@ -59,7 +62,10 @@ jobs:
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-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
+1 -27
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"
@@ -11583,7 +11557,7 @@ dependencies = [
[[package]]
name = "smolmix-wasm"
version = "1.21.0"
version = "0.1.0"
dependencies = [
"async-tungstenite",
"bytes",
-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
+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:
@@ -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) => ({
},
}),
...(opts.skipFavicon
? []
: [
new WebpackFavicons({
src: path.resolve(__dirname, '../../assets/favicon/favicon.png'), // the asset directory is relative to THIS file
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';
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,
},
// 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,
};
// 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' };
async function main() {
appendOutput('Setting up mixnet tunnel...');
await setupMixTunnel(setupOpts);
appendOutput('Tunnel ready.\n');
// 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',
}),
};
@@ -1,8 +0,0 @@
import { getConfig } from './rollup/worker.mjs';
export default {
...getConfig({
inlineWasm: true,
format: 'cjs',
}),
};
@@ -1,30 +0,0 @@
import typescript from '@rollup/plugin-typescript';
import resolve from '@rollup/plugin-node-resolve';
import { wasm } from '@rollup/plugin-wasm';
import webWorkerLoader from 'rollup-plugin-web-worker-loader';
const extensions = ['.js', '.jsx', '.ts', '.tsx'];
/**
* Gets the config for bundling the package as a CommonJS module.
*
* @param opts Options:
* `{ inline: boolean }` - set inline to true to inline the web worker in the main bundle
* `{ outputDir: string }` - override the destination
*/
export const getConfig = (opts) => ({
input: 'src/index.ts',
output: {
dir: opts.outputDir || 'dist/cjs',
format: 'cjs',
},
plugins: [
webWorkerLoader({ targetPlatform: 'browser', inline: opts.inline }), // the inline param is used here
resolve({ extensions }),
wasm({ maxFileSize: 10000000, targetEnv: 'browser' }),
typescript({
compilerOptions: { outDir: opts.outputDir || 'dist/cjs', target: 'es5' },
exclude: ['worker/*'],
}),
],
});
@@ -1,45 +0,0 @@
import typescript from '@rollup/plugin-typescript';
import resolve from '@rollup/plugin-node-resolve';
import webWorkerLoader from 'rollup-plugin-web-worker-loader';
import replace from '@rollup/plugin-replace';
const extensions = ['.js', '.jsx', '.ts', '.tsx'];
/**
* Gets the config for bundling the package as an ES Module.
*
* @param opts Options:
* `{ inline: boolean }` - set inline to true to inline the web worker in the main bundle
* `{ outputDir: string }` - override the destination *
*/
export const getConfig = (opts) => ({
input: 'src/index.ts',
output: {
dir: opts.outputDir || 'dist/esm',
format: 'es',
},
plugins: [
webWorkerLoader({ targetPlatform: 'browser', inline: opts.inline }), // the inline param is used here
replace({
// when loading the web worker as a full ES module, tell pass `new Worker({ type: 'module'})` to tell
// the browser to load and allow imports inside the worker code. Also load as a URL so relative paths work.
// values: opts.inline
// ? undefined
// : {
// "createURLWorkerFactory('web-worker-0.js')":
// "createURLWorkerFactory(new URL('web-worker-0.js', import.meta.url))",
// },
values: {
"createURLWorkerFactory('web-worker-0.js')":
"createURLWorkerFactory(new URL('web-worker-0.js', import.meta.url))",
},
delimiters: ['', ''],
preventAssignment: true,
}),
resolve({ extensions }),
typescript({
exclude: ['worker/*'],
compilerOptions: { outDir: opts.outputDir || 'dist/esm' },
}),
],
});
@@ -1,43 +0,0 @@
import typescript from '@rollup/plugin-typescript';
import resolve from '@rollup/plugin-node-resolve';
import { wasm } from '@rollup/plugin-wasm';
import replace from '@rollup/plugin-replace';
const extensions = ['.js', '.jsx', '.ts', '.tsx'];
/**
* Configure worker output
*
* @param opts
* `format`: `es` or `cjs`,
* `inlineWasm`: true or false,
* `tsTarget`: `es5` or `es6`
*/
export const getConfig = (opts) => ({
input: 'src/worker/index.ts',
output: {
dir: 'dist',
format: opts?.format || 'es',
},
plugins: [
resolve({ extensions }),
// this is some nasty monkey patching that removes the WASM URL (because it is handled by the `wasm` plugin)
replace({
values: { "input = new URL('mix_fetch_wasm_bg.wasm', import.meta.url);": 'input = undefined;' },
delimiters: ['', ''],
preventAssignment: true,
}),
opts?.inlineWasm === true
? wasm({ maxFileSize: 10_000_000, targetEnv: 'browser' }) // force the wasm plugin to embed the wasm bundle - this means no downstream bundlers have to worry about handling it
: wasm({
targetEnv: 'browser',
fileName: '[name].wasm',
}),
typescript({
compilerOptions: {
declaration: false,
target: opts?.tsTarget || 'es6',
},
}),
],
});
@@ -1,46 +0,0 @@
#!/usr/bin/env bash
set -o errexit
set -o nounset
set -o pipefail
#-------------------------------------------------------
# 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/index.js src/worker/worker.js
# move WASM files out of build area
mkdir -p dist/worker
mv dist/*.wasm dist/worker
#-------------------------------------------------------
# ESM
#-------------------------------------------------------
# build the SDK as a ESM bundle
rollup -c rollup-esm.config.mjs
# move WASM files into place
cp dist/worker/*.wasm dist/esm
node scripts/postBuildReplace.mjs dist
#-------------------------------------------------------
# CLEAN UP
#-------------------------------------------------------
# remove typings that aren't needed
rm -rf dist/esm/worker
# clear staging area
rm -rf dist/worker
@@ -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 || true
# run the build
pnpm 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
echo "Output can be found in:"
realpath ../../../../dist/ts/docs/tsdoc/nymproject/mix-fetch
@@ -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 || 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
echo "Output can be found in:"
realpath ../../../../dist/ts/sdk/mix-fetch
@@ -1,96 +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/index.js src/worker/worker.js
# move WASM files out of build area
mkdir -p dist/worker
mv dist/*.wasm dist/worker
#-------------------------------------------------------
# ESM
#-------------------------------------------------------
# build the SDK as a ESM bundle
rollup -c rollup-esm.config.mjs
# move WASM files into place
cp dist/worker/*.wasm dist/esm
#-------------------------------------------------------
# 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
node scripts/postBuildReplace.mjs dist
#-------------------------------------------------------
# WEB WORKER (full-fat)
#-------------------------------------------------------
# build the worker
rollup -c rollup-worker-full-fat.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/index.js src/worker/worker.js
# move WASM files out of build area
mkdir -p dist/worker
#-------------------------------------------------------
# ESM (full-fat)
#-------------------------------------------------------
# build the SDK as a ESM bundle (with worker inlined as a blob)
rollup -c rollup-esm-full-fat.config.mjs
#-------------------------------------------------------
# COMMON JS (full-fat)
#-------------------------------------------------------
# Some old build systems cannot fully handle ESM or ES2021, so build
# a CommonJS bundle targeting ES5
# build the SDK as a CommonJS bundle (with worker inlined as a blob)
rollup -c rollup-cjs-full-fat.config.mjs
#-------------------------------------------------------
# CLEAN UP
#-------------------------------------------------------
rm -rf dist/cjs/worker
rm -rf dist/esm/worker
rm -rf dist/cjs-full-fat/worker
rm -rf dist/esm-full-fat/worker
# copy README
cp README.md dist/esm
cp README-CommonJS.md dist/cjs/README.md
cp README-CommonJS-full-fat.md dist/cjs-full-fat/README.md
cp README-full-fat.md dist/esm-full-fat/README.md
@@ -1,29 +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));
fs.writeFileSync('dist/esm/package.json', JSON.stringify(getPackageJson('module'), null, 2));
fs.writeFileSync(
'dist/cjs-full-fat/package.json',
JSON.stringify(getPackageJson('commonjs', 'commonjs-full-fat'), null, 2),
);
fs.writeFileSync('dist/esm-full-fat/package.json', JSON.stringify(getPackageJson('module', 'full-fat'), null, 2));
@@ -1,45 +0,0 @@
import fs from 'fs';
import path from 'path';
// first level is a list of file names to process, then for each copying the @rollup/replace plugin syntax here:
// key is what to search for, value is what to replace it with
const replaceConfig = {
'esm/web-worker-0.js': {
// add `import.meta.url` to make WASM file relative to the worker JS file
"_loadWasmModule(0, 'mix_fetch_wasm_bg.wasm',":
"_loadWasmModule(0, new URL('mix_fetch_wasm_bg.wasm', import.meta.url),",
"_loadWasmModule(0, 'go_conn.wasm',": "_loadWasmModule(0, new URL('go_conn.wasm', import.meta.url),",
},
'esm/index.js': {
// tell the browser the worker is a module, so it should provide `import.meta.url` needed above
'const worker = new WorkerFactory();': "const worker = new WorkerFactory({ type: 'module' });",
},
};
const basePathToFindFilesIn = process.argv[2];
console.log(`Replacing files in "${path.resolve(basePathToFindFilesIn)}"...`);
Object.keys(replaceConfig).forEach((filename) => {
const absFilename = path.resolve(basePathToFindFilesIn, filename);
if (!fs.existsSync(absFilename)) {
console.log(`Skipping replacing ${filename} as does not exist`);
return;
}
const content = fs.readFileSync(absFilename).toString();
console.log(`Replacing values in "${absFilename}"...`);
const replacementMap = replaceConfig[filename];
let newContent = content;
Object.keys(replacementMap).forEach((toFind) => {
const toReplace = replacementMap[toFind];
newContent = newContent.replaceAll(toFind, toReplace);
fs.writeFileSync(absFilename, newContent);
});
console.log('Done');
});
@@ -1,22 +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 ..
cd esm
echo "Publishing ESM package to NPM.."
npm publish --access=public
cd ..
@@ -1,8 +0,0 @@
const fs = require('fs');
const packageName = '@nymproject/mix-fetch-wasm';
const packageJsonPath = require.resolve(packageName + '/package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath).toString());
console.log(`🟢🟢🟢 ${packageName} is at ${packageJsonPath} is version ${packageJson.version}`);
@@ -1,77 +0,0 @@
import InlineWasmWebWorker from 'web-worker:./worker/worker';
import * as Comlink from 'comlink';
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.addEventListener('error', reject);
worker.addEventListener(
'message',
(msg) => {
worker.removeEventListener('error', reject);
if (msg.data?.kind === EventKinds.Loaded) {
resolve(worker);
} else {
reject(msg);
}
},
{ once: true },
);
});
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>(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');
}
console.log({ workerResponse });
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;
};
+52 -66
View File
@@ -1,79 +1,65 @@
/* eslint-disable no-underscore-dangle */
import type { SetupMixFetchOps, IMixFetchFn, IMixFetch } from './types';
import { createMixFetch as createMixFetchInternal } from './create-mix-fetch';
// @nymproject/mix-fetch
//
// Drop-in `fetch()` replacement that routes HTTP/HTTPS through the Nym mixnet.
// Shares a single mixnet tunnel with @nymproject/mix-dns and
// @nymproject/mix-websocket via @nymproject/mix-tunnel.
//
// v2 surface (clean, no legacy shim):
//
// import { setupMixTunnel, mixFetch, disconnectMixTunnel } from '@nymproject/mix-fetch';
// await setupMixTunnel({ preferredIpr, disableCoverTraffic, ... });
// const res = await mixFetch('https://example.com');
// await disconnectMixTunnel();
//
// Or, for the "setup + fetch" convenience:
//
// const mixFetch = await createMixFetch({ ...opts });
// const res = await mixFetch('https://example.com');
//
// See `SetupMixTunnelOpts` (re-exported from @nymproject/mix-tunnel) for the
// full options surface — mirrors smolmix-wasm's SetupOpts.
// this is the default timeout for getting a response
const REQUEST_TIMEOUT_MILLISECONDS = 60_000;
import {
getMixTunnel,
setupMixTunnel,
disconnectMixTunnel,
getTunnelState,
SetupMixTunnelOpts,
} from '@nymproject/mix-tunnel';
export * from './types';
declare global {
interface Window {
/**
* Keep a singleton of the mixFetch interface on the `window` object.
*/
__mixFetchGlobal?: IMixFetch;
}
}
export { setupMixTunnel, disconnectMixTunnel, getTunnelState };
export type { SetupMixTunnelOpts };
/**
* Create a global mixFetch instance and optionally configure settings.
* Fetch over the mixnet. Drop-in replacement for the browser `fetch()`.
*
* @param opts Optional settings
* Requires the tunnel to be up — call `setupMixTunnel(opts)` first, or use
* `createMixFetch(opts)` to combine setup + fetch.
*/
export const createMixFetch = async (opts?: SetupMixFetchOps) => {
if (!window) {
throw new Error('`window` is not defined');
}
export const mixFetch = async (url: string, init?: RequestInit): Promise<Response> => {
const tunnel = await getMixTunnel();
// The wasm-side returns a `{body: Uint8Array, status, statusText,
// headers: [[k,v]...]}` object (see smolmix `serialise_response`).
// The `Headers` constructor accepts the [[k,v]] pair shape directly so
// repeated names like `Set-Cookie` survive.
const raw = await tunnel.mixFetch(url, init ?? {});
if (!window.__mixFetchGlobal) {
// load the worker and set up mixFetch with defaults
window.__mixFetchGlobal = await createMixFetchInternal();
await window.__mixFetchGlobal.setupMixFetch(opts);
window.onunload = async () => {
if (window.__mixFetchGlobal) {
await window.__mixFetchGlobal.disconnectMixFetch();
}
};
}
return window.__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,
// `raw.body` is `Uint8Array<ArrayBufferLike>` — the ArrayBufferLike side
// includes SharedArrayBuffer, which the Response constructor's BodyInit
// doesn't accept. The runtime value is always a non-shared array; cast.
return new Response(raw.body as BodyInit, {
status: raw.status,
statusText: raw.statusText,
headers: new Headers(raw.headers),
});
// execute user request
return instance.mixFetch(url, args);
};
/**
* Stops the usage of mixFetch and disconnect the client from the mixnet.
* Convenience: set up the tunnel and return a fetch-bound function. Equivalent
* to `await setupMixTunnel(opts); return mixFetch;`. Safe to call multiple
* times — the underlying tunnel is a singleton.
*/
export const disconnectMixFetch = async (): Promise<void> => {
if (!window) {
throw new Error('`window` is not defined');
}
// 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 (!window.__mixFetchGlobal) {
throw new Error("mixFetch hasn't been setup");
// eslint-disable-next-line no-else-return
} else {
return window.__mixFetchGlobal.disconnectMixFetch();
}
export const createMixFetch = async (opts?: SetupMixTunnelOpts): Promise<typeof mixFetch> => {
await setupMixTunnel(opts);
return mixFetch;
};
@@ -1,98 +0,0 @@
import type { MixFetchOpts } from '@nymproject/mix-fetch-wasm';
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 = new Uint8Array([0, 1, 2, 3]).buffer;
const resp = await handleResponseMimeTypes(
new Response(DATA, { headers: new Headers([['Content-Type', 'image/jpeg']]) }),
);
expect(resp.blobUrl).toBeDefined();
});
test('handles videos', async () => {
const DATA = new Uint8Array([0, 1, 2, 3]).buffer;
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 { loadWasm } from './wasm-loading';
import { run } from './main';
async function main() {
await loadWasm();
await run();
}
main().catch((e: any) => console.error('Unhandled exception in mixFetch worker', e));
@@ -1,69 +0,0 @@
/* eslint-disable no-restricted-globals */
import { setupMixFetch, disconnectMixFetch } from '@nymproject/mix-fetch-wasm';
import * as Comlink from 'comlink';
import type { IMixFetchWebWorker, LoadedEvent } from '../types';
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.
*/
// eslint-disable-next-line no-restricted-globals
const postMessageWithType = <E>(event: E) => self.postMessage(event);
export async function run() {
const { mixFetch } = self 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
Comlink.expose(mixFetchWebWorker);
// 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,87 +0,0 @@
/* eslint-disable @typescript-eslint/naming-convention,no-restricted-globals */
/// <reference types="@nymproject/mix-fetch-wasm" />
// 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.
//
// Rollup will replace wasmBytes with a function that loads the WASM bundle from a base64 string embedded in the output.
//
// Doing it this way, saves having to support a large variety of bundlers and their quirks.
//
// @ts-ignore
// eslint-disable-next-line import/no-extraneous-dependencies
import getMixFetchWasmBytes from '@nymproject/mix-fetch-wasm/mix_fetch_wasm_bg.wasm';
// @ts-ignore
// eslint-disable-next-line import/no-extraneous-dependencies
import getGoConnectionWasmBytes from '@nymproject/mix-fetch-wasm/go_conn.wasm';
// wasm_bindgen creates a global variable (with the exports attached) that is in scope after `importScripts`
import init, {
set_panic_hook,
send_client_data,
start_new_mixnet_connection,
mix_fetch_initialised,
finish_mixnet_connection,
} from '@nymproject/mix-fetch-wasm';
// see `typings/wasm_exec.d.ts` for the defintion of the `class Go` in global scope
import '@nymproject/mix-fetch-wasm/wasm_exec';
async function loadGoWasm() {
// rollup will provide a function to get the Go connection WASM bytes here
const bytes = await getGoConnectionWasmBytes();
// @ts-ignore
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);
go.run(wasmObj);
}
function setupRsGoBridge() {
const rsGoBridge = {
send_client_data,
start_new_mixnet_connection,
mix_fetch_initialised,
finish_mixnet_connection,
};
// (note: reason for intermediate `__rs_go_bridge__` object is to decrease global scope bloat
// and to discourage users from trying to call those methods directly)
// eslint-disable-next-line no-underscore-dangle
(self as any).__rs_go_bridge__ = rsGoBridge;
}
export async function loadWasm() {
// rollup with provide a function to get the mixFetch WASM bytes
const bytes = await getMixFetchWasmBytes();
// load rust WASM package
await init(bytes);
console.log('Loaded RUST WASM');
// 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();
// goWasmSetLogging('trace');
}

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