├── .devcontainer └── devcontainer.json ├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── CHANGELOG.md ├── Caddyfile ├── Dockerfile ├── LICENSE ├── README.md ├── diagram.drawio ├── diagram.drawio.svg ├── docker-compose.yml ├── package-lock.json ├── package.json ├── src ├── cli.ts ├── h2tunnel.test.ts └── h2tunnel.ts └── tsconfig.json /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "image": "mcr.microsoft.com/devcontainers/typescript-node" 3 | } 4 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 2 | name: Node.js CI 3 | 4 | on: 5 | push: 6 | branches: ["main"] 7 | pull_request: 8 | branches: ["main"] 9 | workflow_dispatch: 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 17 | node-version: [18.x, 20.x, 22.x] 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | cache: "npm" 25 | - run: npm ci 26 | - run: npm run build 27 | - run: npm run test 28 | - run: npm run test_ipv4_docker 29 | if: ${{ matrix.node-version == '22.x' }} 30 | 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist 3 | /build 4 | /coverage 5 | # npm pack output 6 | /*.tgz 7 | /*.crt 8 | /*.key 9 | /.env -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### 0.4.1 2 | 3 | - Fix for IPv4 only environments (e.g. default docker-compose) 4 | 5 | ### 0.4.0 6 | 7 | - Clean up logging 8 | - Make sure latest tunnel kills previous tunnel, so if a connection hangs, the client is able to reset it 9 | - Implement keepalive using HTTP2 PING and TCP timeout 10 | - Gracefully exit server on EADDRINUSE 11 | 12 | ### 0.3.1 13 | 14 | - Add IPv6 support 15 | - Add Node.js v18 support 16 | 17 | ### 0.3.0 18 | 19 | - Support tunneling half-closed TCP connections, these are sometimes killed by middleboxes but they will be safe in h2tunnel 20 | - Remove mux/demux port configuration, instead take a random port assigned by the OS 21 | - Allow specifying the origin host for advanced use cases, default is localhost 22 | 23 | ### 0.2.0 24 | 25 | - Tunnel TCP instead of HTTP1, supporting a wide range of protocols 26 | - Prevent double TLS encryption by using Node.js unencrypted HTTP/2 connection 27 | - Lots of testing improvements 28 | - Reduce code size to <500 LOC 29 | 30 | ### 0.1.1 31 | 32 | - Improved testing and reconnection logic 33 | 34 | ### 0.1.0 35 | 36 | - Proof of concept 37 | - Supports tunneling HTTP1 over HTTP/2 + TLS 38 | -------------------------------------------------------------------------------- /Caddyfile: -------------------------------------------------------------------------------- 1 | {$TUNNEL_DOMAIN} { 2 | reverse_proxy h2tunnel:80 3 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22 2 | RUN npm install -g h2tunnel 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 Alexei Boronine 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # h2tunnel - TCP over HTTP/2 2 | 3 | [![NPM Version](https://img.shields.io/npm/v/h2tunnel)](https://www.npmjs.com/package/h2tunnel) 4 | [![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/boronine/h2tunnel/node.js.yml)](https://github.com/boronine/h2tunnel/actions/workflows/node.js.yml) 5 | 6 | A CLI tool and Node.js library for a popular "tunneling" workflow, like the proprietary [ngrok](https://ngrok.com/) 7 | or the [`ssh -R` solution](https://www.ssh.com/academy/ssh/tunneling-example#remote-forwarding). 8 | 9 | The client (localhost) establishes a tunnel to the server (public IP), and the server forwards incoming connections to 10 | your local machine through this tunnel. In effect, your local server becomes publically available. 11 | 12 | All this in [less than 500 LOC](https://github.com/boronine/h2tunnel/blob/main/src/h2tunnel.ts) 13 | with no dependencies. 14 | 15 | ![Diagram](https://raw.githubusercontent.com/boronine/h2tunnel/main/diagram.drawio.svg) 16 | 17 | ## How does h2tunnel work? 18 | 19 | h2tunnel is unique among [its many alternatives](https://github.com/anderspitman/awesome-tunneling) for the way it 20 | leverages existing protocols: 21 | 22 | 1. The client initiates a TLS connection to the server and uses this socket to listen for HTTP/2 sessions 23 | 2. The server receives this TLS connection and initiates a persistent HTTP/2 session through the socket back to the client 24 | 3. The server takes incoming TCP connections, converts them into HTTP/2 streams, and forwards them to the client 25 | 4. The client receives these HTTP/2 streams, converts them back into TCP connections and forwards them to the local server 26 | 27 | We use [HTTP/2](https://en.wikipedia.org/wiki/HTTP/2) to take advantage of its built-in multiplexing feature. This 28 | allows simultaneous duplex streams to be processed on a single TCP connection (the "tunnel"). 29 | 30 | For authentication we use a self-signed [TLS](https://en.wikipedia.org/wiki/Transport_Layer_Security) certificate + 31 | private key pair. This pair is used by both the client and the server, and both are configured to reject any other 32 | credential. The pair is effectively a shared password. TLS has a ["pre-shared key" mode](https://en.wikipedia.org/wiki/TLS-PSK) 33 | which would be more appropriate but Node.js documentation [warns against using it](https://github.com/boronine/h2tunnel/issues/5). 34 | 35 | ## Installation 36 | 37 | You can add the [h2tunnel npm package](https://www.npmjs.com/package/h2tunnel) to your `package.json` or install 38 | h2tunnel globally like so: 39 | 40 | ```bash 41 | npm install -g h2tunnel 42 | ``` 43 | 44 | Minimum Node.js version: v18. Ubuntu 24.04+ and Debian 12+ have this version in their repositories: 45 | 46 | ```bash 47 | sudo apt install nodejs npm 48 | ``` 49 | 50 | For other operating systems, you may need to [install Node.js another way](https://nodejs.org/en/download/package-manager). 51 | 52 | ## Usage 53 | 54 | ``` 55 | usage: h2tunnel [options] 56 | 57 | commands: 58 | client 59 | server 60 | 61 | client options: 62 | --crt Path to certificate file (.crt) 63 | --key Path to private key file (.key) 64 | --tunnel-host Host for the tunnel server 65 | --tunnel-port Port for the tunnel server (default 15900) 66 | --origin-host Host for the local TCP server (default: localhost) 67 | --origin-port Port for the local TCP server 68 | 69 | server options: 70 | --crt Path to certificate file (.crt) 71 | --key Path to private key file (.key) 72 | --tunnel-listen-ip IP for the tunnel server to bind on (default: ::0) 73 | --tunnel-listen-port Port for the tunnel server to listen on (default 15900) 74 | --proxy-listen-ip IP for the remote TCP proxy server to bind on (default: ::0) 75 | --proxy-listen-port Port for the remote TCP proxy server to listen on 76 | 77 | The tunnel and proxy servers will bind to ::0 by default which will make them publically available. This requires 78 | superuser permissions on Linux. You can change this setting to bind to a specific network interface, e.g. a VPN, but 79 | this is advanced usage. Note that on most operating systems, binding to ::0 will also bind to 0.0.0.0. 80 | ``` 81 | 82 | Generate `h2tunnel.key` and `h2tunnel.crt` files using `openssl` command: 83 | 84 | ```bash 85 | openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:secp384r1 -days 3650 -nodes -keyout h2tunnel.key -out h2tunnel.crt -subj "/CN=localhost" 86 | ``` 87 | 88 | You can inspect your key and certificate files using these commands: 89 | 90 | ```bash 91 | openssl ec -in h2tunnel.key -text -noout 92 | openssl x509 -in h2tunnel.crt -text -noout 93 | ``` 94 | 95 | ### Forward localhost:8000 to http://mysite.example.com 96 | 97 | On your server (mysite.example.com), we will be listening for tunnel connections on port 15001, and providing an HTTP 98 | proxy on port 80. Make sure these are open in your firewall. 99 | 100 | ```bash 101 | # sudo is required to bind to ::0, which is necessary for public access 102 | sudo h2tunnel server \ 103 | --crt h2tunnel.crt \ 104 | --key h2tunnel.key \ 105 | --proxy-listen-port 80 106 | ``` 107 | 108 | On your local machine, we will connect to the tunnel and forward a local HTTP server on port 8000. 109 | 110 | ```bash 111 | h2tunnel client \ 112 | --crt h2tunnel.crt \ 113 | --key h2tunnel.key \ 114 | --tunnel-host mysite.example.com \ 115 | --origin-port 8000 116 | ``` 117 | 118 | If you have python3 installed, you can test using this built-in HTTP server: 119 | 120 | ```bash 121 | python3 -m http.server 122 | ``` 123 | 124 | ### Forward localhost:8000 to https://mysite.example.com 125 | 126 | This is the same as the previous example, but with an extra layer: a [Caddy](https://caddyserver.com/) reverse proxy 127 | that will auto-provision TLS certificates for your domain. This is useful if you want to expose a local HTTP server 128 | as HTTPS. 129 | 130 | Specify your domain in the `.env` file: 131 | 132 | ``` 133 | TUNNEL_DOMAIN=mysite.example.com 134 | ``` 135 | 136 | Push the necessary files to the server: 137 | 138 | ```bash 139 | scp .env Caddyfile Dockerfile docker-compose.yml h2tunnel.crt h2tunnel.key myuser@mysite.example.com:/home/myuser 140 | ``` 141 | 142 | ```bash 143 | npx tsc && scp -r h2tunnel.key h2tunnel.crt Dockerfile Caddyfile build docker-compose.yml boronine-ginernet-es-1-workstation:/home/alexei 144 | ``` 145 | 146 | Start the server: 147 | 148 | ```bash 149 | ssh myuser@mysite.example.com 150 | docker compose up 151 | ``` 152 | 153 | To connect to your tunnel, run the same client command as in the above recipe. 154 | 155 | ### Use as a library 156 | 157 | You can integrate h2tunnel into your own Node.js application by importing the `TunnelServer` and `TunnelClient` classes. 158 | 159 | ```typescript 160 | import { TunnelClient } from "h2tunnel"; 161 | 162 | const client = new TunnelClient({ 163 | logger: (line) => console.log(line), // optional 164 | key: `-----BEGIN PRIVATE KEY----- ...`, 165 | cert: `-----BEGIN CERTIFICATE----- ...`, 166 | originHost: "localhost", // optional 167 | originPort: 8000, 168 | tunnelHost: `mysite.example.com`, 169 | tunnelPort: 15900, // optional 170 | }); 171 | 172 | // Start the client 173 | client.start(); 174 | 175 | // Wait until client is connected 176 | await client.waitUntilConnected(); 177 | 178 | // Stop the client 179 | await client.stop(); 180 | ``` 181 | 182 | ```typescript 183 | import { TunnelServer } from "h2tunnel"; 184 | 185 | const server = new TunnelServer({ 186 | logger: (line) => console.log(line), // optional 187 | key: `-----BEGIN PRIVATE KEY----- ...`, 188 | cert: `-----BEGIN CERTIFICATE----- ...`, 189 | tunnelListenIp: "::0", // optional 190 | tunnelListenPort: 15900, // optional 191 | proxyListenIp: "::0", // optional 192 | proxyListenPort: 80, 193 | }); 194 | 195 | // Start the server 196 | server.start(); 197 | 198 | // Wait until server is listening 199 | await server.waitUntilListening(); 200 | 201 | // Wait until server is connected 202 | await server.waitUntilConnected(); 203 | 204 | // Stop the server 205 | await server.stop(); 206 | ``` 207 | 208 | ## Testing 209 | 210 | ```bash 211 | npm run test 212 | npm run coverage # See build/index.html 213 | ``` 214 | 215 | ## CHANGELOG 216 | 217 | See [CHANGELOG.md](./CHANGELOG.md) file for full text. 218 | 219 | ## LICENSE 220 | 221 | See [LICENSE](./LICENSE) file for full text. 222 | -------------------------------------------------------------------------------- /diagram.drawio: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | caddy: 3 | image: caddy 4 | restart: unless-stopped 5 | cap_add: 6 | - NET_ADMIN 7 | ports: 8 | - "80:80" 9 | - "443:443" 10 | - "443:443/udp" 11 | depends_on: 12 | - h2tunnel 13 | # Use this to provide TUNNEL_DOMAIN 14 | env_file: .env 15 | volumes: 16 | - $PWD/Caddyfile:/etc/caddy/Caddyfile 17 | - /data 18 | - /config 19 | h2tunnel: 20 | image: node:22 21 | restart: unless-stopped 22 | cap_add: 23 | - NET_ADMIN 24 | secrets: 25 | - crt 26 | - key 27 | ports: 28 | - "80" # for caddy 29 | - "15900:15900" 30 | volumes: 31 | - $PWD/build:/h2tunnel 32 | command: node h2tunnel/cli.js server --crt /run/secrets/crt --key /run/secrets/key --proxy-listen-port 80 33 | secrets: 34 | crt: 35 | file: ./h2tunnel.crt 36 | key: 37 | file: ./h2tunnel.key 38 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "h2tunnel", 3 | "version": "0.4.1", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "h2tunnel", 9 | "version": "0.4.1", 10 | "license": "MIT", 11 | "bin": { 12 | "h2tunnel": "build/cli.js" 13 | }, 14 | "devDependencies": { 15 | "@types/node": "^22.13.1", 16 | "c8": "^10.1.3", 17 | "prettier": "^3.4.2", 18 | "typescript": "^5.7.3" 19 | }, 20 | "engines": { 21 | "node": ">=18" 22 | } 23 | }, 24 | "node_modules/@bcoe/v8-coverage": { 25 | "version": "1.0.2", 26 | "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", 27 | "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", 28 | "dev": true, 29 | "license": "MIT", 30 | "engines": { 31 | "node": ">=18" 32 | } 33 | }, 34 | "node_modules/@isaacs/cliui": { 35 | "version": "8.0.2", 36 | "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", 37 | "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", 38 | "dev": true, 39 | "dependencies": { 40 | "string-width": "^5.1.2", 41 | "string-width-cjs": "npm:string-width@^4.2.0", 42 | "strip-ansi": "^7.0.1", 43 | "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", 44 | "wrap-ansi": "^8.1.0", 45 | "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" 46 | }, 47 | "engines": { 48 | "node": ">=12" 49 | } 50 | }, 51 | "node_modules/@istanbuljs/schema": { 52 | "version": "0.1.3", 53 | "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", 54 | "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", 55 | "dev": true, 56 | "engines": { 57 | "node": ">=8" 58 | } 59 | }, 60 | "node_modules/@jridgewell/resolve-uri": { 61 | "version": "3.1.2", 62 | "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", 63 | "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", 64 | "dev": true, 65 | "engines": { 66 | "node": ">=6.0.0" 67 | } 68 | }, 69 | "node_modules/@jridgewell/sourcemap-codec": { 70 | "version": "1.5.0", 71 | "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", 72 | "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", 73 | "dev": true 74 | }, 75 | "node_modules/@jridgewell/trace-mapping": { 76 | "version": "0.3.25", 77 | "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", 78 | "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", 79 | "dev": true, 80 | "dependencies": { 81 | "@jridgewell/resolve-uri": "^3.1.0", 82 | "@jridgewell/sourcemap-codec": "^1.4.14" 83 | } 84 | }, 85 | "node_modules/@pkgjs/parseargs": { 86 | "version": "0.11.0", 87 | "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", 88 | "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", 89 | "dev": true, 90 | "optional": true, 91 | "engines": { 92 | "node": ">=14" 93 | } 94 | }, 95 | "node_modules/@types/istanbul-lib-coverage": { 96 | "version": "2.0.6", 97 | "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", 98 | "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", 99 | "dev": true 100 | }, 101 | "node_modules/@types/node": { 102 | "version": "22.13.1", 103 | "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz", 104 | "integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==", 105 | "dev": true, 106 | "license": "MIT", 107 | "dependencies": { 108 | "undici-types": "~6.20.0" 109 | } 110 | }, 111 | "node_modules/ansi-regex": { 112 | "version": "6.1.0", 113 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", 114 | "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", 115 | "dev": true, 116 | "engines": { 117 | "node": ">=12" 118 | }, 119 | "funding": { 120 | "url": "https://github.com/chalk/ansi-regex?sponsor=1" 121 | } 122 | }, 123 | "node_modules/ansi-styles": { 124 | "version": "6.2.1", 125 | "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", 126 | "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", 127 | "dev": true, 128 | "engines": { 129 | "node": ">=12" 130 | }, 131 | "funding": { 132 | "url": "https://github.com/chalk/ansi-styles?sponsor=1" 133 | } 134 | }, 135 | "node_modules/balanced-match": { 136 | "version": "1.0.2", 137 | "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", 138 | "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", 139 | "dev": true 140 | }, 141 | "node_modules/brace-expansion": { 142 | "version": "2.0.1", 143 | "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", 144 | "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", 145 | "dev": true, 146 | "dependencies": { 147 | "balanced-match": "^1.0.0" 148 | } 149 | }, 150 | "node_modules/c8": { 151 | "version": "10.1.3", 152 | "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz", 153 | "integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==", 154 | "dev": true, 155 | "license": "ISC", 156 | "dependencies": { 157 | "@bcoe/v8-coverage": "^1.0.1", 158 | "@istanbuljs/schema": "^0.1.3", 159 | "find-up": "^5.0.0", 160 | "foreground-child": "^3.1.1", 161 | "istanbul-lib-coverage": "^3.2.0", 162 | "istanbul-lib-report": "^3.0.1", 163 | "istanbul-reports": "^3.1.6", 164 | "test-exclude": "^7.0.1", 165 | "v8-to-istanbul": "^9.0.0", 166 | "yargs": "^17.7.2", 167 | "yargs-parser": "^21.1.1" 168 | }, 169 | "bin": { 170 | "c8": "bin/c8.js" 171 | }, 172 | "engines": { 173 | "node": ">=18" 174 | }, 175 | "peerDependencies": { 176 | "monocart-coverage-reports": "^2" 177 | }, 178 | "peerDependenciesMeta": { 179 | "monocart-coverage-reports": { 180 | "optional": true 181 | } 182 | } 183 | }, 184 | "node_modules/cliui": { 185 | "version": "8.0.1", 186 | "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", 187 | "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", 188 | "dev": true, 189 | "dependencies": { 190 | "string-width": "^4.2.0", 191 | "strip-ansi": "^6.0.1", 192 | "wrap-ansi": "^7.0.0" 193 | }, 194 | "engines": { 195 | "node": ">=12" 196 | } 197 | }, 198 | "node_modules/cliui/node_modules/ansi-regex": { 199 | "version": "5.0.1", 200 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", 201 | "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", 202 | "dev": true, 203 | "engines": { 204 | "node": ">=8" 205 | } 206 | }, 207 | "node_modules/cliui/node_modules/ansi-styles": { 208 | "version": "4.3.0", 209 | "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", 210 | "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", 211 | "dev": true, 212 | "dependencies": { 213 | "color-convert": "^2.0.1" 214 | }, 215 | "engines": { 216 | "node": ">=8" 217 | }, 218 | "funding": { 219 | "url": "https://github.com/chalk/ansi-styles?sponsor=1" 220 | } 221 | }, 222 | "node_modules/cliui/node_modules/emoji-regex": { 223 | "version": "8.0.0", 224 | "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", 225 | "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", 226 | "dev": true 227 | }, 228 | "node_modules/cliui/node_modules/string-width": { 229 | "version": "4.2.3", 230 | "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", 231 | "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", 232 | "dev": true, 233 | "dependencies": { 234 | "emoji-regex": "^8.0.0", 235 | "is-fullwidth-code-point": "^3.0.0", 236 | "strip-ansi": "^6.0.1" 237 | }, 238 | "engines": { 239 | "node": ">=8" 240 | } 241 | }, 242 | "node_modules/cliui/node_modules/strip-ansi": { 243 | "version": "6.0.1", 244 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", 245 | "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", 246 | "dev": true, 247 | "dependencies": { 248 | "ansi-regex": "^5.0.1" 249 | }, 250 | "engines": { 251 | "node": ">=8" 252 | } 253 | }, 254 | "node_modules/cliui/node_modules/wrap-ansi": { 255 | "version": "7.0.0", 256 | "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", 257 | "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", 258 | "dev": true, 259 | "dependencies": { 260 | "ansi-styles": "^4.0.0", 261 | "string-width": "^4.1.0", 262 | "strip-ansi": "^6.0.0" 263 | }, 264 | "engines": { 265 | "node": ">=10" 266 | }, 267 | "funding": { 268 | "url": "https://github.com/chalk/wrap-ansi?sponsor=1" 269 | } 270 | }, 271 | "node_modules/color-convert": { 272 | "version": "2.0.1", 273 | "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", 274 | "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", 275 | "dev": true, 276 | "dependencies": { 277 | "color-name": "~1.1.4" 278 | }, 279 | "engines": { 280 | "node": ">=7.0.0" 281 | } 282 | }, 283 | "node_modules/color-name": { 284 | "version": "1.1.4", 285 | "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", 286 | "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", 287 | "dev": true 288 | }, 289 | "node_modules/convert-source-map": { 290 | "version": "2.0.0", 291 | "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", 292 | "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", 293 | "dev": true 294 | }, 295 | "node_modules/cross-spawn": { 296 | "version": "7.0.6", 297 | "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", 298 | "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", 299 | "dev": true, 300 | "license": "MIT", 301 | "dependencies": { 302 | "path-key": "^3.1.0", 303 | "shebang-command": "^2.0.0", 304 | "which": "^2.0.1" 305 | }, 306 | "engines": { 307 | "node": ">= 8" 308 | } 309 | }, 310 | "node_modules/eastasianwidth": { 311 | "version": "0.2.0", 312 | "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", 313 | "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", 314 | "dev": true 315 | }, 316 | "node_modules/emoji-regex": { 317 | "version": "9.2.2", 318 | "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", 319 | "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", 320 | "dev": true 321 | }, 322 | "node_modules/escalade": { 323 | "version": "3.2.0", 324 | "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", 325 | "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", 326 | "dev": true, 327 | "engines": { 328 | "node": ">=6" 329 | } 330 | }, 331 | "node_modules/find-up": { 332 | "version": "5.0.0", 333 | "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", 334 | "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", 335 | "dev": true, 336 | "dependencies": { 337 | "locate-path": "^6.0.0", 338 | "path-exists": "^4.0.0" 339 | }, 340 | "engines": { 341 | "node": ">=10" 342 | }, 343 | "funding": { 344 | "url": "https://github.com/sponsors/sindresorhus" 345 | } 346 | }, 347 | "node_modules/foreground-child": { 348 | "version": "3.3.0", 349 | "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", 350 | "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", 351 | "dev": true, 352 | "dependencies": { 353 | "cross-spawn": "^7.0.0", 354 | "signal-exit": "^4.0.1" 355 | }, 356 | "engines": { 357 | "node": ">=14" 358 | }, 359 | "funding": { 360 | "url": "https://github.com/sponsors/isaacs" 361 | } 362 | }, 363 | "node_modules/get-caller-file": { 364 | "version": "2.0.5", 365 | "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", 366 | "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", 367 | "dev": true, 368 | "engines": { 369 | "node": "6.* || 8.* || >= 10.*" 370 | } 371 | }, 372 | "node_modules/glob": { 373 | "version": "10.4.5", 374 | "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", 375 | "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", 376 | "dev": true, 377 | "dependencies": { 378 | "foreground-child": "^3.1.0", 379 | "jackspeak": "^3.1.2", 380 | "minimatch": "^9.0.4", 381 | "minipass": "^7.1.2", 382 | "package-json-from-dist": "^1.0.0", 383 | "path-scurry": "^1.11.1" 384 | }, 385 | "bin": { 386 | "glob": "dist/esm/bin.mjs" 387 | }, 388 | "funding": { 389 | "url": "https://github.com/sponsors/isaacs" 390 | } 391 | }, 392 | "node_modules/has-flag": { 393 | "version": "4.0.0", 394 | "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", 395 | "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", 396 | "dev": true, 397 | "engines": { 398 | "node": ">=8" 399 | } 400 | }, 401 | "node_modules/html-escaper": { 402 | "version": "2.0.2", 403 | "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", 404 | "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", 405 | "dev": true 406 | }, 407 | "node_modules/is-fullwidth-code-point": { 408 | "version": "3.0.0", 409 | "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", 410 | "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", 411 | "dev": true, 412 | "engines": { 413 | "node": ">=8" 414 | } 415 | }, 416 | "node_modules/isexe": { 417 | "version": "2.0.0", 418 | "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", 419 | "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", 420 | "dev": true 421 | }, 422 | "node_modules/istanbul-lib-coverage": { 423 | "version": "3.2.2", 424 | "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", 425 | "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", 426 | "dev": true, 427 | "engines": { 428 | "node": ">=8" 429 | } 430 | }, 431 | "node_modules/istanbul-lib-report": { 432 | "version": "3.0.1", 433 | "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", 434 | "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", 435 | "dev": true, 436 | "dependencies": { 437 | "istanbul-lib-coverage": "^3.0.0", 438 | "make-dir": "^4.0.0", 439 | "supports-color": "^7.1.0" 440 | }, 441 | "engines": { 442 | "node": ">=10" 443 | } 444 | }, 445 | "node_modules/istanbul-reports": { 446 | "version": "3.1.7", 447 | "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", 448 | "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", 449 | "dev": true, 450 | "dependencies": { 451 | "html-escaper": "^2.0.0", 452 | "istanbul-lib-report": "^3.0.0" 453 | }, 454 | "engines": { 455 | "node": ">=8" 456 | } 457 | }, 458 | "node_modules/jackspeak": { 459 | "version": "3.4.3", 460 | "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", 461 | "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", 462 | "dev": true, 463 | "dependencies": { 464 | "@isaacs/cliui": "^8.0.2" 465 | }, 466 | "funding": { 467 | "url": "https://github.com/sponsors/isaacs" 468 | }, 469 | "optionalDependencies": { 470 | "@pkgjs/parseargs": "^0.11.0" 471 | } 472 | }, 473 | "node_modules/locate-path": { 474 | "version": "6.0.0", 475 | "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", 476 | "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", 477 | "dev": true, 478 | "dependencies": { 479 | "p-locate": "^5.0.0" 480 | }, 481 | "engines": { 482 | "node": ">=10" 483 | }, 484 | "funding": { 485 | "url": "https://github.com/sponsors/sindresorhus" 486 | } 487 | }, 488 | "node_modules/lru-cache": { 489 | "version": "10.4.3", 490 | "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", 491 | "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", 492 | "dev": true 493 | }, 494 | "node_modules/make-dir": { 495 | "version": "4.0.0", 496 | "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", 497 | "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", 498 | "dev": true, 499 | "dependencies": { 500 | "semver": "^7.5.3" 501 | }, 502 | "engines": { 503 | "node": ">=10" 504 | }, 505 | "funding": { 506 | "url": "https://github.com/sponsors/sindresorhus" 507 | } 508 | }, 509 | "node_modules/minimatch": { 510 | "version": "9.0.5", 511 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", 512 | "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", 513 | "dev": true, 514 | "dependencies": { 515 | "brace-expansion": "^2.0.1" 516 | }, 517 | "engines": { 518 | "node": ">=16 || 14 >=14.17" 519 | }, 520 | "funding": { 521 | "url": "https://github.com/sponsors/isaacs" 522 | } 523 | }, 524 | "node_modules/minipass": { 525 | "version": "7.1.2", 526 | "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", 527 | "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", 528 | "dev": true, 529 | "engines": { 530 | "node": ">=16 || 14 >=14.17" 531 | } 532 | }, 533 | "node_modules/p-limit": { 534 | "version": "3.1.0", 535 | "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", 536 | "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", 537 | "dev": true, 538 | "dependencies": { 539 | "yocto-queue": "^0.1.0" 540 | }, 541 | "engines": { 542 | "node": ">=10" 543 | }, 544 | "funding": { 545 | "url": "https://github.com/sponsors/sindresorhus" 546 | } 547 | }, 548 | "node_modules/p-locate": { 549 | "version": "5.0.0", 550 | "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", 551 | "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", 552 | "dev": true, 553 | "dependencies": { 554 | "p-limit": "^3.0.2" 555 | }, 556 | "engines": { 557 | "node": ">=10" 558 | }, 559 | "funding": { 560 | "url": "https://github.com/sponsors/sindresorhus" 561 | } 562 | }, 563 | "node_modules/package-json-from-dist": { 564 | "version": "1.0.1", 565 | "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", 566 | "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", 567 | "dev": true 568 | }, 569 | "node_modules/path-exists": { 570 | "version": "4.0.0", 571 | "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", 572 | "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", 573 | "dev": true, 574 | "engines": { 575 | "node": ">=8" 576 | } 577 | }, 578 | "node_modules/path-key": { 579 | "version": "3.1.1", 580 | "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", 581 | "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", 582 | "dev": true, 583 | "engines": { 584 | "node": ">=8" 585 | } 586 | }, 587 | "node_modules/path-scurry": { 588 | "version": "1.11.1", 589 | "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", 590 | "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", 591 | "dev": true, 592 | "dependencies": { 593 | "lru-cache": "^10.2.0", 594 | "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" 595 | }, 596 | "engines": { 597 | "node": ">=16 || 14 >=14.18" 598 | }, 599 | "funding": { 600 | "url": "https://github.com/sponsors/isaacs" 601 | } 602 | }, 603 | "node_modules/prettier": { 604 | "version": "3.4.2", 605 | "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", 606 | "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", 607 | "dev": true, 608 | "license": "MIT", 609 | "bin": { 610 | "prettier": "bin/prettier.cjs" 611 | }, 612 | "engines": { 613 | "node": ">=14" 614 | }, 615 | "funding": { 616 | "url": "https://github.com/prettier/prettier?sponsor=1" 617 | } 618 | }, 619 | "node_modules/require-directory": { 620 | "version": "2.1.1", 621 | "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", 622 | "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", 623 | "dev": true, 624 | "engines": { 625 | "node": ">=0.10.0" 626 | } 627 | }, 628 | "node_modules/semver": { 629 | "version": "7.6.3", 630 | "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", 631 | "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", 632 | "dev": true, 633 | "bin": { 634 | "semver": "bin/semver.js" 635 | }, 636 | "engines": { 637 | "node": ">=10" 638 | } 639 | }, 640 | "node_modules/shebang-command": { 641 | "version": "2.0.0", 642 | "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", 643 | "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", 644 | "dev": true, 645 | "dependencies": { 646 | "shebang-regex": "^3.0.0" 647 | }, 648 | "engines": { 649 | "node": ">=8" 650 | } 651 | }, 652 | "node_modules/shebang-regex": { 653 | "version": "3.0.0", 654 | "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", 655 | "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", 656 | "dev": true, 657 | "engines": { 658 | "node": ">=8" 659 | } 660 | }, 661 | "node_modules/signal-exit": { 662 | "version": "4.1.0", 663 | "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", 664 | "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", 665 | "dev": true, 666 | "engines": { 667 | "node": ">=14" 668 | }, 669 | "funding": { 670 | "url": "https://github.com/sponsors/isaacs" 671 | } 672 | }, 673 | "node_modules/string-width": { 674 | "version": "5.1.2", 675 | "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", 676 | "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", 677 | "dev": true, 678 | "dependencies": { 679 | "eastasianwidth": "^0.2.0", 680 | "emoji-regex": "^9.2.2", 681 | "strip-ansi": "^7.0.1" 682 | }, 683 | "engines": { 684 | "node": ">=12" 685 | }, 686 | "funding": { 687 | "url": "https://github.com/sponsors/sindresorhus" 688 | } 689 | }, 690 | "node_modules/string-width-cjs": { 691 | "name": "string-width", 692 | "version": "4.2.3", 693 | "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", 694 | "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", 695 | "dev": true, 696 | "dependencies": { 697 | "emoji-regex": "^8.0.0", 698 | "is-fullwidth-code-point": "^3.0.0", 699 | "strip-ansi": "^6.0.1" 700 | }, 701 | "engines": { 702 | "node": ">=8" 703 | } 704 | }, 705 | "node_modules/string-width-cjs/node_modules/ansi-regex": { 706 | "version": "5.0.1", 707 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", 708 | "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", 709 | "dev": true, 710 | "engines": { 711 | "node": ">=8" 712 | } 713 | }, 714 | "node_modules/string-width-cjs/node_modules/emoji-regex": { 715 | "version": "8.0.0", 716 | "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", 717 | "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", 718 | "dev": true 719 | }, 720 | "node_modules/string-width-cjs/node_modules/strip-ansi": { 721 | "version": "6.0.1", 722 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", 723 | "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", 724 | "dev": true, 725 | "dependencies": { 726 | "ansi-regex": "^5.0.1" 727 | }, 728 | "engines": { 729 | "node": ">=8" 730 | } 731 | }, 732 | "node_modules/strip-ansi": { 733 | "version": "7.1.0", 734 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", 735 | "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", 736 | "dev": true, 737 | "dependencies": { 738 | "ansi-regex": "^6.0.1" 739 | }, 740 | "engines": { 741 | "node": ">=12" 742 | }, 743 | "funding": { 744 | "url": "https://github.com/chalk/strip-ansi?sponsor=1" 745 | } 746 | }, 747 | "node_modules/strip-ansi-cjs": { 748 | "name": "strip-ansi", 749 | "version": "6.0.1", 750 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", 751 | "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", 752 | "dev": true, 753 | "dependencies": { 754 | "ansi-regex": "^5.0.1" 755 | }, 756 | "engines": { 757 | "node": ">=8" 758 | } 759 | }, 760 | "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { 761 | "version": "5.0.1", 762 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", 763 | "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", 764 | "dev": true, 765 | "engines": { 766 | "node": ">=8" 767 | } 768 | }, 769 | "node_modules/supports-color": { 770 | "version": "7.2.0", 771 | "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", 772 | "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", 773 | "dev": true, 774 | "dependencies": { 775 | "has-flag": "^4.0.0" 776 | }, 777 | "engines": { 778 | "node": ">=8" 779 | } 780 | }, 781 | "node_modules/test-exclude": { 782 | "version": "7.0.1", 783 | "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", 784 | "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", 785 | "dev": true, 786 | "dependencies": { 787 | "@istanbuljs/schema": "^0.1.2", 788 | "glob": "^10.4.1", 789 | "minimatch": "^9.0.4" 790 | }, 791 | "engines": { 792 | "node": ">=18" 793 | } 794 | }, 795 | "node_modules/typescript": { 796 | "version": "5.7.3", 797 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", 798 | "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", 799 | "dev": true, 800 | "license": "Apache-2.0", 801 | "bin": { 802 | "tsc": "bin/tsc", 803 | "tsserver": "bin/tsserver" 804 | }, 805 | "engines": { 806 | "node": ">=14.17" 807 | } 808 | }, 809 | "node_modules/undici-types": { 810 | "version": "6.20.0", 811 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", 812 | "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", 813 | "dev": true, 814 | "license": "MIT" 815 | }, 816 | "node_modules/v8-to-istanbul": { 817 | "version": "9.3.0", 818 | "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", 819 | "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", 820 | "dev": true, 821 | "dependencies": { 822 | "@jridgewell/trace-mapping": "^0.3.12", 823 | "@types/istanbul-lib-coverage": "^2.0.1", 824 | "convert-source-map": "^2.0.0" 825 | }, 826 | "engines": { 827 | "node": ">=10.12.0" 828 | } 829 | }, 830 | "node_modules/which": { 831 | "version": "2.0.2", 832 | "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", 833 | "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", 834 | "dev": true, 835 | "dependencies": { 836 | "isexe": "^2.0.0" 837 | }, 838 | "bin": { 839 | "node-which": "bin/node-which" 840 | }, 841 | "engines": { 842 | "node": ">= 8" 843 | } 844 | }, 845 | "node_modules/wrap-ansi": { 846 | "version": "8.1.0", 847 | "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", 848 | "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", 849 | "dev": true, 850 | "dependencies": { 851 | "ansi-styles": "^6.1.0", 852 | "string-width": "^5.0.1", 853 | "strip-ansi": "^7.0.1" 854 | }, 855 | "engines": { 856 | "node": ">=12" 857 | }, 858 | "funding": { 859 | "url": "https://github.com/chalk/wrap-ansi?sponsor=1" 860 | } 861 | }, 862 | "node_modules/wrap-ansi-cjs": { 863 | "name": "wrap-ansi", 864 | "version": "7.0.0", 865 | "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", 866 | "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", 867 | "dev": true, 868 | "dependencies": { 869 | "ansi-styles": "^4.0.0", 870 | "string-width": "^4.1.0", 871 | "strip-ansi": "^6.0.0" 872 | }, 873 | "engines": { 874 | "node": ">=10" 875 | }, 876 | "funding": { 877 | "url": "https://github.com/chalk/wrap-ansi?sponsor=1" 878 | } 879 | }, 880 | "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { 881 | "version": "5.0.1", 882 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", 883 | "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", 884 | "dev": true, 885 | "engines": { 886 | "node": ">=8" 887 | } 888 | }, 889 | "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { 890 | "version": "4.3.0", 891 | "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", 892 | "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", 893 | "dev": true, 894 | "dependencies": { 895 | "color-convert": "^2.0.1" 896 | }, 897 | "engines": { 898 | "node": ">=8" 899 | }, 900 | "funding": { 901 | "url": "https://github.com/chalk/ansi-styles?sponsor=1" 902 | } 903 | }, 904 | "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { 905 | "version": "8.0.0", 906 | "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", 907 | "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", 908 | "dev": true 909 | }, 910 | "node_modules/wrap-ansi-cjs/node_modules/string-width": { 911 | "version": "4.2.3", 912 | "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", 913 | "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", 914 | "dev": true, 915 | "dependencies": { 916 | "emoji-regex": "^8.0.0", 917 | "is-fullwidth-code-point": "^3.0.0", 918 | "strip-ansi": "^6.0.1" 919 | }, 920 | "engines": { 921 | "node": ">=8" 922 | } 923 | }, 924 | "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { 925 | "version": "6.0.1", 926 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", 927 | "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", 928 | "dev": true, 929 | "dependencies": { 930 | "ansi-regex": "^5.0.1" 931 | }, 932 | "engines": { 933 | "node": ">=8" 934 | } 935 | }, 936 | "node_modules/y18n": { 937 | "version": "5.0.8", 938 | "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", 939 | "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", 940 | "dev": true, 941 | "engines": { 942 | "node": ">=10" 943 | } 944 | }, 945 | "node_modules/yargs": { 946 | "version": "17.7.2", 947 | "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", 948 | "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", 949 | "dev": true, 950 | "dependencies": { 951 | "cliui": "^8.0.1", 952 | "escalade": "^3.1.1", 953 | "get-caller-file": "^2.0.5", 954 | "require-directory": "^2.1.1", 955 | "string-width": "^4.2.3", 956 | "y18n": "^5.0.5", 957 | "yargs-parser": "^21.1.1" 958 | }, 959 | "engines": { 960 | "node": ">=12" 961 | } 962 | }, 963 | "node_modules/yargs-parser": { 964 | "version": "21.1.1", 965 | "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", 966 | "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", 967 | "dev": true, 968 | "engines": { 969 | "node": ">=12" 970 | } 971 | }, 972 | "node_modules/yargs/node_modules/ansi-regex": { 973 | "version": "5.0.1", 974 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", 975 | "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", 976 | "dev": true, 977 | "engines": { 978 | "node": ">=8" 979 | } 980 | }, 981 | "node_modules/yargs/node_modules/emoji-regex": { 982 | "version": "8.0.0", 983 | "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", 984 | "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", 985 | "dev": true 986 | }, 987 | "node_modules/yargs/node_modules/string-width": { 988 | "version": "4.2.3", 989 | "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", 990 | "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", 991 | "dev": true, 992 | "dependencies": { 993 | "emoji-regex": "^8.0.0", 994 | "is-fullwidth-code-point": "^3.0.0", 995 | "strip-ansi": "^6.0.1" 996 | }, 997 | "engines": { 998 | "node": ">=8" 999 | } 1000 | }, 1001 | "node_modules/yargs/node_modules/strip-ansi": { 1002 | "version": "6.0.1", 1003 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", 1004 | "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", 1005 | "dev": true, 1006 | "dependencies": { 1007 | "ansi-regex": "^5.0.1" 1008 | }, 1009 | "engines": { 1010 | "node": ">=8" 1011 | } 1012 | }, 1013 | "node_modules/yocto-queue": { 1014 | "version": "0.1.0", 1015 | "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", 1016 | "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", 1017 | "dev": true, 1018 | "engines": { 1019 | "node": ">=10" 1020 | }, 1021 | "funding": { 1022 | "url": "https://github.com/sponsors/sindresorhus" 1023 | } 1024 | } 1025 | }, 1026 | "dependencies": { 1027 | "@bcoe/v8-coverage": { 1028 | "version": "1.0.2", 1029 | "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", 1030 | "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", 1031 | "dev": true 1032 | }, 1033 | "@isaacs/cliui": { 1034 | "version": "8.0.2", 1035 | "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", 1036 | "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", 1037 | "dev": true, 1038 | "requires": { 1039 | "string-width": "^5.1.2", 1040 | "string-width-cjs": "npm:string-width@^4.2.0", 1041 | "strip-ansi": "^7.0.1", 1042 | "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", 1043 | "wrap-ansi": "^8.1.0", 1044 | "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" 1045 | } 1046 | }, 1047 | "@istanbuljs/schema": { 1048 | "version": "0.1.3", 1049 | "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", 1050 | "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", 1051 | "dev": true 1052 | }, 1053 | "@jridgewell/resolve-uri": { 1054 | "version": "3.1.2", 1055 | "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", 1056 | "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", 1057 | "dev": true 1058 | }, 1059 | "@jridgewell/sourcemap-codec": { 1060 | "version": "1.5.0", 1061 | "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", 1062 | "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", 1063 | "dev": true 1064 | }, 1065 | "@jridgewell/trace-mapping": { 1066 | "version": "0.3.25", 1067 | "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", 1068 | "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", 1069 | "dev": true, 1070 | "requires": { 1071 | "@jridgewell/resolve-uri": "^3.1.0", 1072 | "@jridgewell/sourcemap-codec": "^1.4.14" 1073 | } 1074 | }, 1075 | "@pkgjs/parseargs": { 1076 | "version": "0.11.0", 1077 | "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", 1078 | "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", 1079 | "dev": true, 1080 | "optional": true 1081 | }, 1082 | "@types/istanbul-lib-coverage": { 1083 | "version": "2.0.6", 1084 | "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", 1085 | "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", 1086 | "dev": true 1087 | }, 1088 | "@types/node": { 1089 | "version": "22.13.1", 1090 | "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.1.tgz", 1091 | "integrity": "sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==", 1092 | "dev": true, 1093 | "requires": { 1094 | "undici-types": "~6.20.0" 1095 | } 1096 | }, 1097 | "ansi-regex": { 1098 | "version": "6.1.0", 1099 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", 1100 | "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", 1101 | "dev": true 1102 | }, 1103 | "ansi-styles": { 1104 | "version": "6.2.1", 1105 | "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", 1106 | "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", 1107 | "dev": true 1108 | }, 1109 | "balanced-match": { 1110 | "version": "1.0.2", 1111 | "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", 1112 | "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", 1113 | "dev": true 1114 | }, 1115 | "brace-expansion": { 1116 | "version": "2.0.1", 1117 | "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", 1118 | "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", 1119 | "dev": true, 1120 | "requires": { 1121 | "balanced-match": "^1.0.0" 1122 | } 1123 | }, 1124 | "c8": { 1125 | "version": "10.1.3", 1126 | "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz", 1127 | "integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==", 1128 | "dev": true, 1129 | "requires": { 1130 | "@bcoe/v8-coverage": "^1.0.1", 1131 | "@istanbuljs/schema": "^0.1.3", 1132 | "find-up": "^5.0.0", 1133 | "foreground-child": "^3.1.1", 1134 | "istanbul-lib-coverage": "^3.2.0", 1135 | "istanbul-lib-report": "^3.0.1", 1136 | "istanbul-reports": "^3.1.6", 1137 | "test-exclude": "^7.0.1", 1138 | "v8-to-istanbul": "^9.0.0", 1139 | "yargs": "^17.7.2", 1140 | "yargs-parser": "^21.1.1" 1141 | } 1142 | }, 1143 | "cliui": { 1144 | "version": "8.0.1", 1145 | "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", 1146 | "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", 1147 | "dev": true, 1148 | "requires": { 1149 | "string-width": "^4.2.0", 1150 | "strip-ansi": "^6.0.1", 1151 | "wrap-ansi": "^7.0.0" 1152 | }, 1153 | "dependencies": { 1154 | "ansi-regex": { 1155 | "version": "5.0.1", 1156 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", 1157 | "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", 1158 | "dev": true 1159 | }, 1160 | "ansi-styles": { 1161 | "version": "4.3.0", 1162 | "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", 1163 | "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", 1164 | "dev": true, 1165 | "requires": { 1166 | "color-convert": "^2.0.1" 1167 | } 1168 | }, 1169 | "emoji-regex": { 1170 | "version": "8.0.0", 1171 | "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", 1172 | "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", 1173 | "dev": true 1174 | }, 1175 | "string-width": { 1176 | "version": "4.2.3", 1177 | "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", 1178 | "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", 1179 | "dev": true, 1180 | "requires": { 1181 | "emoji-regex": "^8.0.0", 1182 | "is-fullwidth-code-point": "^3.0.0", 1183 | "strip-ansi": "^6.0.1" 1184 | } 1185 | }, 1186 | "strip-ansi": { 1187 | "version": "6.0.1", 1188 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", 1189 | "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", 1190 | "dev": true, 1191 | "requires": { 1192 | "ansi-regex": "^5.0.1" 1193 | } 1194 | }, 1195 | "wrap-ansi": { 1196 | "version": "7.0.0", 1197 | "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", 1198 | "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", 1199 | "dev": true, 1200 | "requires": { 1201 | "ansi-styles": "^4.0.0", 1202 | "string-width": "^4.1.0", 1203 | "strip-ansi": "^6.0.0" 1204 | } 1205 | } 1206 | } 1207 | }, 1208 | "color-convert": { 1209 | "version": "2.0.1", 1210 | "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", 1211 | "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", 1212 | "dev": true, 1213 | "requires": { 1214 | "color-name": "~1.1.4" 1215 | } 1216 | }, 1217 | "color-name": { 1218 | "version": "1.1.4", 1219 | "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", 1220 | "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", 1221 | "dev": true 1222 | }, 1223 | "convert-source-map": { 1224 | "version": "2.0.0", 1225 | "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", 1226 | "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", 1227 | "dev": true 1228 | }, 1229 | "cross-spawn": { 1230 | "version": "7.0.6", 1231 | "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", 1232 | "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", 1233 | "dev": true, 1234 | "requires": { 1235 | "path-key": "^3.1.0", 1236 | "shebang-command": "^2.0.0", 1237 | "which": "^2.0.1" 1238 | } 1239 | }, 1240 | "eastasianwidth": { 1241 | "version": "0.2.0", 1242 | "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", 1243 | "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", 1244 | "dev": true 1245 | }, 1246 | "emoji-regex": { 1247 | "version": "9.2.2", 1248 | "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", 1249 | "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", 1250 | "dev": true 1251 | }, 1252 | "escalade": { 1253 | "version": "3.2.0", 1254 | "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", 1255 | "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", 1256 | "dev": true 1257 | }, 1258 | "find-up": { 1259 | "version": "5.0.0", 1260 | "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", 1261 | "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", 1262 | "dev": true, 1263 | "requires": { 1264 | "locate-path": "^6.0.0", 1265 | "path-exists": "^4.0.0" 1266 | } 1267 | }, 1268 | "foreground-child": { 1269 | "version": "3.3.0", 1270 | "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", 1271 | "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", 1272 | "dev": true, 1273 | "requires": { 1274 | "cross-spawn": "^7.0.0", 1275 | "signal-exit": "^4.0.1" 1276 | } 1277 | }, 1278 | "get-caller-file": { 1279 | "version": "2.0.5", 1280 | "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", 1281 | "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", 1282 | "dev": true 1283 | }, 1284 | "glob": { 1285 | "version": "10.4.5", 1286 | "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", 1287 | "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", 1288 | "dev": true, 1289 | "requires": { 1290 | "foreground-child": "^3.1.0", 1291 | "jackspeak": "^3.1.2", 1292 | "minimatch": "^9.0.4", 1293 | "minipass": "^7.1.2", 1294 | "package-json-from-dist": "^1.0.0", 1295 | "path-scurry": "^1.11.1" 1296 | } 1297 | }, 1298 | "has-flag": { 1299 | "version": "4.0.0", 1300 | "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", 1301 | "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", 1302 | "dev": true 1303 | }, 1304 | "html-escaper": { 1305 | "version": "2.0.2", 1306 | "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", 1307 | "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", 1308 | "dev": true 1309 | }, 1310 | "is-fullwidth-code-point": { 1311 | "version": "3.0.0", 1312 | "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", 1313 | "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", 1314 | "dev": true 1315 | }, 1316 | "isexe": { 1317 | "version": "2.0.0", 1318 | "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", 1319 | "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", 1320 | "dev": true 1321 | }, 1322 | "istanbul-lib-coverage": { 1323 | "version": "3.2.2", 1324 | "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", 1325 | "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", 1326 | "dev": true 1327 | }, 1328 | "istanbul-lib-report": { 1329 | "version": "3.0.1", 1330 | "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", 1331 | "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", 1332 | "dev": true, 1333 | "requires": { 1334 | "istanbul-lib-coverage": "^3.0.0", 1335 | "make-dir": "^4.0.0", 1336 | "supports-color": "^7.1.0" 1337 | } 1338 | }, 1339 | "istanbul-reports": { 1340 | "version": "3.1.7", 1341 | "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", 1342 | "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", 1343 | "dev": true, 1344 | "requires": { 1345 | "html-escaper": "^2.0.0", 1346 | "istanbul-lib-report": "^3.0.0" 1347 | } 1348 | }, 1349 | "jackspeak": { 1350 | "version": "3.4.3", 1351 | "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", 1352 | "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", 1353 | "dev": true, 1354 | "requires": { 1355 | "@isaacs/cliui": "^8.0.2", 1356 | "@pkgjs/parseargs": "^0.11.0" 1357 | } 1358 | }, 1359 | "locate-path": { 1360 | "version": "6.0.0", 1361 | "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", 1362 | "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", 1363 | "dev": true, 1364 | "requires": { 1365 | "p-locate": "^5.0.0" 1366 | } 1367 | }, 1368 | "lru-cache": { 1369 | "version": "10.4.3", 1370 | "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", 1371 | "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", 1372 | "dev": true 1373 | }, 1374 | "make-dir": { 1375 | "version": "4.0.0", 1376 | "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", 1377 | "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", 1378 | "dev": true, 1379 | "requires": { 1380 | "semver": "^7.5.3" 1381 | } 1382 | }, 1383 | "minimatch": { 1384 | "version": "9.0.5", 1385 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", 1386 | "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", 1387 | "dev": true, 1388 | "requires": { 1389 | "brace-expansion": "^2.0.1" 1390 | } 1391 | }, 1392 | "minipass": { 1393 | "version": "7.1.2", 1394 | "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", 1395 | "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", 1396 | "dev": true 1397 | }, 1398 | "p-limit": { 1399 | "version": "3.1.0", 1400 | "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", 1401 | "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", 1402 | "dev": true, 1403 | "requires": { 1404 | "yocto-queue": "^0.1.0" 1405 | } 1406 | }, 1407 | "p-locate": { 1408 | "version": "5.0.0", 1409 | "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", 1410 | "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", 1411 | "dev": true, 1412 | "requires": { 1413 | "p-limit": "^3.0.2" 1414 | } 1415 | }, 1416 | "package-json-from-dist": { 1417 | "version": "1.0.1", 1418 | "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", 1419 | "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", 1420 | "dev": true 1421 | }, 1422 | "path-exists": { 1423 | "version": "4.0.0", 1424 | "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", 1425 | "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", 1426 | "dev": true 1427 | }, 1428 | "path-key": { 1429 | "version": "3.1.1", 1430 | "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", 1431 | "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", 1432 | "dev": true 1433 | }, 1434 | "path-scurry": { 1435 | "version": "1.11.1", 1436 | "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", 1437 | "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", 1438 | "dev": true, 1439 | "requires": { 1440 | "lru-cache": "^10.2.0", 1441 | "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" 1442 | } 1443 | }, 1444 | "prettier": { 1445 | "version": "3.4.2", 1446 | "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", 1447 | "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", 1448 | "dev": true 1449 | }, 1450 | "require-directory": { 1451 | "version": "2.1.1", 1452 | "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", 1453 | "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", 1454 | "dev": true 1455 | }, 1456 | "semver": { 1457 | "version": "7.6.3", 1458 | "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", 1459 | "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", 1460 | "dev": true 1461 | }, 1462 | "shebang-command": { 1463 | "version": "2.0.0", 1464 | "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", 1465 | "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", 1466 | "dev": true, 1467 | "requires": { 1468 | "shebang-regex": "^3.0.0" 1469 | } 1470 | }, 1471 | "shebang-regex": { 1472 | "version": "3.0.0", 1473 | "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", 1474 | "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", 1475 | "dev": true 1476 | }, 1477 | "signal-exit": { 1478 | "version": "4.1.0", 1479 | "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", 1480 | "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", 1481 | "dev": true 1482 | }, 1483 | "string-width": { 1484 | "version": "5.1.2", 1485 | "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", 1486 | "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", 1487 | "dev": true, 1488 | "requires": { 1489 | "eastasianwidth": "^0.2.0", 1490 | "emoji-regex": "^9.2.2", 1491 | "strip-ansi": "^7.0.1" 1492 | } 1493 | }, 1494 | "string-width-cjs": { 1495 | "version": "npm:string-width@4.2.3", 1496 | "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", 1497 | "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", 1498 | "dev": true, 1499 | "requires": { 1500 | "emoji-regex": "^8.0.0", 1501 | "is-fullwidth-code-point": "^3.0.0", 1502 | "strip-ansi": "^6.0.1" 1503 | }, 1504 | "dependencies": { 1505 | "ansi-regex": { 1506 | "version": "5.0.1", 1507 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", 1508 | "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", 1509 | "dev": true 1510 | }, 1511 | "emoji-regex": { 1512 | "version": "8.0.0", 1513 | "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", 1514 | "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", 1515 | "dev": true 1516 | }, 1517 | "strip-ansi": { 1518 | "version": "6.0.1", 1519 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", 1520 | "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", 1521 | "dev": true, 1522 | "requires": { 1523 | "ansi-regex": "^5.0.1" 1524 | } 1525 | } 1526 | } 1527 | }, 1528 | "strip-ansi": { 1529 | "version": "7.1.0", 1530 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", 1531 | "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", 1532 | "dev": true, 1533 | "requires": { 1534 | "ansi-regex": "^6.0.1" 1535 | } 1536 | }, 1537 | "strip-ansi-cjs": { 1538 | "version": "npm:strip-ansi@6.0.1", 1539 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", 1540 | "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", 1541 | "dev": true, 1542 | "requires": { 1543 | "ansi-regex": "^5.0.1" 1544 | }, 1545 | "dependencies": { 1546 | "ansi-regex": { 1547 | "version": "5.0.1", 1548 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", 1549 | "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", 1550 | "dev": true 1551 | } 1552 | } 1553 | }, 1554 | "supports-color": { 1555 | "version": "7.2.0", 1556 | "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", 1557 | "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", 1558 | "dev": true, 1559 | "requires": { 1560 | "has-flag": "^4.0.0" 1561 | } 1562 | }, 1563 | "test-exclude": { 1564 | "version": "7.0.1", 1565 | "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", 1566 | "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", 1567 | "dev": true, 1568 | "requires": { 1569 | "@istanbuljs/schema": "^0.1.2", 1570 | "glob": "^10.4.1", 1571 | "minimatch": "^9.0.4" 1572 | } 1573 | }, 1574 | "typescript": { 1575 | "version": "5.7.3", 1576 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", 1577 | "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", 1578 | "dev": true 1579 | }, 1580 | "undici-types": { 1581 | "version": "6.20.0", 1582 | "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", 1583 | "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", 1584 | "dev": true 1585 | }, 1586 | "v8-to-istanbul": { 1587 | "version": "9.3.0", 1588 | "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", 1589 | "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", 1590 | "dev": true, 1591 | "requires": { 1592 | "@jridgewell/trace-mapping": "^0.3.12", 1593 | "@types/istanbul-lib-coverage": "^2.0.1", 1594 | "convert-source-map": "^2.0.0" 1595 | } 1596 | }, 1597 | "which": { 1598 | "version": "2.0.2", 1599 | "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", 1600 | "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", 1601 | "dev": true, 1602 | "requires": { 1603 | "isexe": "^2.0.0" 1604 | } 1605 | }, 1606 | "wrap-ansi": { 1607 | "version": "8.1.0", 1608 | "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", 1609 | "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", 1610 | "dev": true, 1611 | "requires": { 1612 | "ansi-styles": "^6.1.0", 1613 | "string-width": "^5.0.1", 1614 | "strip-ansi": "^7.0.1" 1615 | } 1616 | }, 1617 | "wrap-ansi-cjs": { 1618 | "version": "npm:wrap-ansi@7.0.0", 1619 | "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", 1620 | "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", 1621 | "dev": true, 1622 | "requires": { 1623 | "ansi-styles": "^4.0.0", 1624 | "string-width": "^4.1.0", 1625 | "strip-ansi": "^6.0.0" 1626 | }, 1627 | "dependencies": { 1628 | "ansi-regex": { 1629 | "version": "5.0.1", 1630 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", 1631 | "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", 1632 | "dev": true 1633 | }, 1634 | "ansi-styles": { 1635 | "version": "4.3.0", 1636 | "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", 1637 | "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", 1638 | "dev": true, 1639 | "requires": { 1640 | "color-convert": "^2.0.1" 1641 | } 1642 | }, 1643 | "emoji-regex": { 1644 | "version": "8.0.0", 1645 | "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", 1646 | "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", 1647 | "dev": true 1648 | }, 1649 | "string-width": { 1650 | "version": "4.2.3", 1651 | "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", 1652 | "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", 1653 | "dev": true, 1654 | "requires": { 1655 | "emoji-regex": "^8.0.0", 1656 | "is-fullwidth-code-point": "^3.0.0", 1657 | "strip-ansi": "^6.0.1" 1658 | } 1659 | }, 1660 | "strip-ansi": { 1661 | "version": "6.0.1", 1662 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", 1663 | "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", 1664 | "dev": true, 1665 | "requires": { 1666 | "ansi-regex": "^5.0.1" 1667 | } 1668 | } 1669 | } 1670 | }, 1671 | "y18n": { 1672 | "version": "5.0.8", 1673 | "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", 1674 | "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", 1675 | "dev": true 1676 | }, 1677 | "yargs": { 1678 | "version": "17.7.2", 1679 | "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", 1680 | "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", 1681 | "dev": true, 1682 | "requires": { 1683 | "cliui": "^8.0.1", 1684 | "escalade": "^3.1.1", 1685 | "get-caller-file": "^2.0.5", 1686 | "require-directory": "^2.1.1", 1687 | "string-width": "^4.2.3", 1688 | "y18n": "^5.0.5", 1689 | "yargs-parser": "^21.1.1" 1690 | }, 1691 | "dependencies": { 1692 | "ansi-regex": { 1693 | "version": "5.0.1", 1694 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", 1695 | "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", 1696 | "dev": true 1697 | }, 1698 | "emoji-regex": { 1699 | "version": "8.0.0", 1700 | "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", 1701 | "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", 1702 | "dev": true 1703 | }, 1704 | "string-width": { 1705 | "version": "4.2.3", 1706 | "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", 1707 | "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", 1708 | "dev": true, 1709 | "requires": { 1710 | "emoji-regex": "^8.0.0", 1711 | "is-fullwidth-code-point": "^3.0.0", 1712 | "strip-ansi": "^6.0.1" 1713 | } 1714 | }, 1715 | "strip-ansi": { 1716 | "version": "6.0.1", 1717 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", 1718 | "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", 1719 | "dev": true, 1720 | "requires": { 1721 | "ansi-regex": "^5.0.1" 1722 | } 1723 | } 1724 | } 1725 | }, 1726 | "yargs-parser": { 1727 | "version": "21.1.1", 1728 | "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", 1729 | "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", 1730 | "dev": true 1731 | }, 1732 | "yocto-queue": { 1733 | "version": "0.1.0", 1734 | "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", 1735 | "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", 1736 | "dev": true 1737 | } 1738 | } 1739 | } 1740 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "h2tunnel", 3 | "description": "Zero-dependency remote port forwarding (TCP over HTTP/2)", 4 | "version": "0.4.1", 5 | "type": "module", 6 | "license": "MIT", 7 | "homepage": "https://github.com/boronine/h2tunnel#readme", 8 | "keywords": [ 9 | "tls", 10 | "http2", 11 | "tunnel", 12 | "localhost", 13 | "multiplexing", 14 | "ngrok" 15 | ], 16 | "author": { 17 | "name": "Alexei Boronine", 18 | "email": "alexei@boronine.com", 19 | "url": "https://www.boronine.com" 20 | }, 21 | "bugs": { 22 | "url": "https://github.com/boronine/h2tunnel/issues" 23 | }, 24 | "devDependencies": { 25 | "@types/node": "^22.13.1", 26 | "typescript": "^5.7.3", 27 | "prettier": "^3.4.2", 28 | "c8": "^10.1.3" 29 | }, 30 | "repository": { 31 | "type": "git", 32 | "url": "git+https://github.com/boronine/h2tunnel.git" 33 | }, 34 | "exports": { 35 | ".": { 36 | "types": "./build/h2tunnel.d.ts", 37 | "import": "./build/h2tunnel.js" 38 | } 39 | }, 40 | "files": [ 41 | "README.md", 42 | "LICENSE", 43 | "package.json", 44 | "build/h2tunnel.js", 45 | "build/h2tunnel.d.ts", 46 | "build/cli.js", 47 | "build/cli.d.ts" 48 | ], 49 | "bin": { 50 | "h2tunnel": "./build/cli.js" 51 | }, 52 | "scripts": { 53 | "format": "npx prettier --write .", 54 | "build": "npx tsc", 55 | "test": "npx tsc && node --enable-source-maps --test build/h2tunnel.test.js", 56 | "test_only": "npx tsc && node --enable-source-maps --test --test-only build/h2tunnel.test.js", 57 | "test_ipv4": "npx tsc && node --enable-source-maps --test --test-name-pattern ipv4 build/h2tunnel.test.js", 58 | "test_ipv4_docker": "docker run --rm --sysctl net.ipv6.conf.all.disable_ipv6=1 -e TIME_MULTIPLIER -v ./:/app -w /app node:22 npm run test_ipv4", 59 | "coverage": "npx tsc && c8 --reporter html node --enable-source-maps --test build/h2tunnel.test.js" 60 | }, 61 | "engines": { 62 | "node": ">=18" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { parseArgs } from "node:util"; 3 | import { 4 | AbstractTunnel, 5 | DEFAULT_LISTEN_IP, 6 | DEFAULT_ORIGIN_HOST, 7 | DEFAULT_TUNNEL_PORT, 8 | TunnelClient, 9 | TunnelServer, 10 | } from "./h2tunnel.js"; 11 | import * as fs from "node:fs"; 12 | 13 | const ARGS = [ 14 | "crt", 15 | "key", 16 | "tunnel-listen-ip", 17 | "tunnel-listen-port", 18 | "proxy-listen-ip", 19 | "proxy-listen-port", 20 | "tunnel-host", 21 | "tunnel-port", 22 | "origin-host", 23 | "origin-port", 24 | ] as const; 25 | 26 | const { positionals, values } = parseArgs({ 27 | options: Object.fromEntries(ARGS.map((a) => [a, { type: "string" }])), 28 | allowPositionals: true, 29 | }); 30 | 31 | type Param = (typeof ARGS)[number]; 32 | 33 | function getStringMaybe(k: Param): string | undefined { 34 | return values[k]; 35 | } 36 | 37 | function getString(k: Param): string { 38 | const s = getStringMaybe(k); 39 | if (!s) { 40 | process.stderr.write(`Missing argument --${k}\n`); 41 | process.exit(1); 42 | } 43 | return s; 44 | } 45 | 46 | function getInt(k: Param): number { 47 | const s = getString(k); 48 | const i = parseInt(s); 49 | if (isNaN(i)) { 50 | process.stderr.write(`Invalid integer --${k} ${s}\n`); 51 | process.exit(1); 52 | } 53 | return i; 54 | } 55 | 56 | function getIntMaybe(k: Param): number | undefined { 57 | const s = getStringMaybe(k); 58 | if (s) { 59 | const i = parseInt(s); 60 | if (isNaN(i)) { 61 | process.stderr.write(`Invalid integer --${k} ${s}\n`); 62 | process.exit(1); 63 | } 64 | return i; 65 | } 66 | } 67 | 68 | const HELP_TEXT = ` 69 | h2tunnel - https://github.com/boronine/h2tunnel 70 | 71 | usage: h2tunnel [options] 72 | 73 | commands: 74 | client 75 | server 76 | 77 | client options: 78 | --${"crt" satisfies Param} Path to certificate file (.crt) 79 | --${"key" satisfies Param} Path to private key file (.key) 80 | --${"tunnel-host" satisfies Param} Host for the tunnel server 81 | --${"tunnel-port" satisfies Param} Port for the tunnel server (default ${DEFAULT_TUNNEL_PORT}) 82 | --${"origin-host" satisfies Param} Host for the local TCP server (default: ${DEFAULT_ORIGIN_HOST}) 83 | --${"origin-port" satisfies Param} Port for the local TCP server 84 | 85 | server options: 86 | --${"crt" satisfies Param} Path to certificate file (.crt) 87 | --${"key" satisfies Param} Path to private key file (.key) 88 | --${"tunnel-listen-ip" satisfies Param} IP for the tunnel server to bind on (default: ${DEFAULT_LISTEN_IP}) 89 | --${"tunnel-listen-port" satisfies Param} Port for the tunnel server to listen on (default ${DEFAULT_TUNNEL_PORT}) 90 | --${"proxy-listen-ip" satisfies Param} IP for the remote TCP proxy server to bind on (default: ${DEFAULT_LISTEN_IP}) 91 | --${"proxy-listen-port" satisfies Param} Port for the remote TCP proxy server to listen on 92 | 93 | The tunnel and proxy servers will bind to ::0 by default which will make them publically available. This requires 94 | superuser permissions on Linux. You can change this setting to bind to a specific network interface, e.g. a VPN, but 95 | this is advanced usage. Note that on most operating systems, binding to ::0 will also bind to 0.0.0.0. 96 | `; 97 | 98 | if (positionals.length === 0) { 99 | process.stdout.write(HELP_TEXT); 100 | } else { 101 | const command = positionals[0]; 102 | let tunnel: AbstractTunnel; 103 | if (command === "client") { 104 | tunnel = new TunnelClient({ 105 | key: fs.readFileSync(getString("key"), "utf8"), 106 | cert: fs.readFileSync(getString("crt"), "utf8"), 107 | tunnelHost: getString("tunnel-host"), 108 | tunnelPort: getIntMaybe("tunnel-port"), 109 | originHost: getStringMaybe("origin-host"), 110 | originPort: getInt("origin-port"), 111 | }); 112 | } else if (command === "server") { 113 | tunnel = new TunnelServer({ 114 | key: fs.readFileSync(getString("key"), "utf8"), 115 | cert: fs.readFileSync(getString("crt"), "utf8"), 116 | tunnelListenIp: getStringMaybe("tunnel-listen-ip"), 117 | tunnelListenPort: getIntMaybe("tunnel-listen-port"), 118 | proxyListenIp: getStringMaybe("proxy-listen-ip"), 119 | proxyListenPort: getInt("proxy-listen-port"), 120 | }); 121 | } else { 122 | throw new Error(`Unknown command: ${command}`); 123 | } 124 | 125 | process.on("SIGINT", () => tunnel.stop()); 126 | process.on("SIGTERM", () => tunnel.stop()); 127 | tunnel.start(); 128 | } 129 | -------------------------------------------------------------------------------- /src/h2tunnel.test.ts: -------------------------------------------------------------------------------- 1 | import { test, TestContext } from "node:test"; 2 | import child_process from "node:child_process"; 3 | import assert from "node:assert"; 4 | import fs from "node:fs"; 5 | import os from "node:os"; 6 | import net from "node:net"; 7 | import path from "node:path"; 8 | import stream from "node:stream"; 9 | import readline from "node:readline"; 10 | import { 11 | ClientOptions, 12 | LogLine, 13 | ServerOptions, 14 | Stoppable, 15 | TunnelClient, 16 | TunnelServer, 17 | } from "./h2tunnel.js"; 18 | 19 | // localhost echo server 20 | const LOCAL_PORT = 15000; 21 | // localhost echo server passed through network emulator 22 | const LOCAL2_PORT = 15007; 23 | 24 | // remote public echo server forwarded by h2tunnel 25 | const PROXY_PORT = 15004; 26 | 27 | // remote TLS server for establishing a tunnel 28 | const TUNNEL_PORT = 15005; 29 | // remote TLS server for establishing a tunnel passed through network emulator 30 | const TUNNEL2_PORT = 15008; 31 | 32 | // Reduce this to make tests faster 33 | const TIME_MULTIPLIER = Number(process.env["TIME_MULTIPLIER"] ?? "0.1"); 34 | 35 | const TEST_TIMEOUT = 100000 * TIME_MULTIPLIER; 36 | 37 | // This keypair is issued for example.com: openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:secp384r1 -days 3650 -nodes -keyout h2tunnel.key -out h2tunnel.crt -subj "/CN=example.com" 38 | 39 | const TLS_KEY_EXAMPLECOM = `-----BEGIN PRIVATE KEY----- 40 | MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDCDzcLnOqzvCrnUyd4P 41 | 1QcIG/Xi/VPpA5dVIwPVkutr9y/wZo3aJsYUX5xExQMsEeihZANiAAQfSPquV3P/ 42 | uhHm2D5czJoFyldutJrQswri0brL99gHSsOmQ34cH7bddcSTVToAZfwkv2yEZPNf 43 | eLM7tASBpINt8uuOjJhCp034thS1V0HH/qDEHzEfy5wZEDrwevuzD+k= 44 | -----END PRIVATE KEY-----`; 45 | 46 | const TLS_CRT_EXAMPLECOM = `-----BEGIN CERTIFICATE----- 47 | MIIB7DCCAXKgAwIBAgIUIyesgpQMVroHhiDuFa56b+bf7UwwCgYIKoZIzj0EAwIw 48 | FjEUMBIGA1UEAwwLZXhhbXBsZS5jb20wHhcNMjQwNTMwMTAzMTM3WhcNMzQwNTI4 49 | MTAzMTM3WjAWMRQwEgYDVQQDDAtleGFtcGxlLmNvbTB2MBAGByqGSM49AgEGBSuB 50 | BAAiA2IABB9I+q5Xc/+6EebYPlzMmgXKV260mtCzCuLRusv32AdKw6ZDfhwftt11 51 | xJNVOgBl/CS/bIRk8194szu0BIGkg23y646MmEKnTfi2FLVXQcf+oMQfMR/LnBkQ 52 | OvB6+7MP6aOBgDB+MB0GA1UdDgQWBBROAP/JNaVvPWqbGcB6zGLA8zSWljAfBgNV 53 | HSMEGDAWgBROAP/JNaVvPWqbGcB6zGLA8zSWljAPBgNVHRMBAf8EBTADAQH/MCsG 54 | A1UdEQQkMCKCC2V4YW1wbGUuY29tgg0qLmV4YW1wbGUuY29thwQKAAABMAoGCCqG 55 | SM49BAMCA2gAMGUCMQCJ2CU2Qh9UsHzmgpDXiIwAtA6YvBKSlR+MO22CcuFC45aM 56 | JN+yjDEXE/TgT+bxgfcCMFFZkqT7GYLc18lW6sv6GZvhzFPV8eTePa2xwVyBgaca 57 | 93vJMc5HXDLt7XPK+Iz90g== 58 | -----END CERTIFICATE-----`; 59 | 60 | const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "h2tunnel-test-")); 61 | const TLS_KEY_FILE = path.join(tmpDir, "h2tunnel.key"); 62 | const TLS_CRT_FILE = path.join(tmpDir, "h2tunnel.crt"); 63 | fs.writeFileSync(TLS_KEY_FILE, TLS_KEY_EXAMPLECOM); 64 | fs.writeFileSync(TLS_CRT_FILE, TLS_CRT_EXAMPLECOM); 65 | 66 | // This keypair is issued for localhost: openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:secp384r1 -days 3650 -nodes -keyout h2tunnel.key -out h2tunnel.crt -subj "/CN=localhost" 67 | 68 | const TLS_KEY_LOCALHOST = `-----BEGIN PRIVATE KEY----- 69 | MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDDittBDK95KNEY62DbX 70 | 7YdaqtpqEVJLt+6fg1CIhkbDd8ZtrZLF98d8o0qTBJyr/xuhZANiAAShciJg7L29 71 | VczOqPMG1YmTOh5t9ZfEwCQRqaQcUuilm5uFGf4eZbx3cyc3YypvjONIykSMPShM 72 | NeCoOEX13zU5d5vJb01zEpBijunhS0/YD08kmLvq7S8pR6TPlzCiDqc= 73 | -----END PRIVATE KEY-----`; 74 | 75 | const TLS_CRT_LOCALHOST = `-----BEGIN CERTIFICATE----- 76 | MIIBujCCAUCgAwIBAgIUB/l/jY39X+YnVsApRJ2qF7fLYlYwCgYIKoZIzj0EAwIw 77 | FDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI1MDIwNTA5NDAzOVoXDTM1MDIwMzA5 78 | NDAzOVowFDESMBAGA1UEAwwJbG9jYWxob3N0MHYwEAYHKoZIzj0CAQYFK4EEACID 79 | YgAEoXIiYOy9vVXMzqjzBtWJkzoebfWXxMAkEamkHFLopZubhRn+HmW8d3MnN2Mq 80 | b4zjSMpEjD0oTDXgqDhF9d81OXebyW9NcxKQYo7p4UtP2A9PJJi76u0vKUekz5cw 81 | og6no1MwUTAdBgNVHQ4EFgQUrs/3sLZ2MxxLsg2iFxSp8XCi1SgwHwYDVR0jBBgw 82 | FoAUrs/3sLZ2MxxLsg2iFxSp8XCi1SgwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjO 83 | PQQDAgNoADBlAjEAxuAidOxI5IHINYbBTRPugLuEQssk2ofAc9RxlyOyyBbNKswL 84 | NIO0NAnTpBdpTWf0AjB79TWx1dVF1WKTUOfO7taYmjj5NTwwPvjfQVuP1zMGpxd0 85 | 5H/5nMUHDime5raC/gw= 86 | -----END CERTIFICATE-----`; 87 | 88 | type LogLineTest = 89 | | LogLine 90 | | "sending garbage" 91 | | "connection" 92 | | `${"recv" | "send"} ${number | "FIN"}` 93 | | `listening on ${number}` 94 | | "send RST" 95 | | `error ${string}`; 96 | 97 | let LOG_LINES: string[] = []; 98 | 99 | type LogName = 100 | | "client" 101 | | "client1" 102 | | "client2" 103 | | "server" 104 | | "bad-tls" 105 | | "network" 106 | | "origin" 107 | | "browser"; 108 | 109 | type L = `${LogName} ${LogLineTest}`; 110 | 111 | function logPos() { 112 | try { 113 | throw new Error(); 114 | } catch (e) { 115 | const line: string = e.stack.trim().split("\n")[2]; 116 | const pos = line.match(/\((.*)\)/); 117 | console.log(pos?.[1]); 118 | } 119 | } 120 | 121 | const getLogger = (name: LogName, colorCode: number) => (line: LogLineTest) => { 122 | // process.stdout.write(`${name.padEnd(10)} \x1b[${colorCode}m${line}\x1b[0m\n`); 123 | if ( 124 | name === "client" || 125 | name === "client1" || 126 | name === "client2" || 127 | name === "server" 128 | ) { 129 | LOG_LINES.push(`${name} ${line}`); 130 | } 131 | }; 132 | 133 | function linesToRegex(lines: L[]): RegExp { 134 | return RegExp( 135 | "^" + 136 | lines 137 | .join("\n") 138 | .replaceAll(".", "\\.") 139 | .replaceAll("[", "\\[") 140 | .replaceAll("]", "\\]") 141 | .replaceAll("*", ".*") 142 | .replaceAll("00", "\\d+") + 143 | "$", 144 | ); 145 | } 146 | 147 | function assertLastLines(...expectedLines: L[][]) { 148 | // get last lines 149 | const actual = LOG_LINES.join("\n"); 150 | LOG_LINES = []; 151 | // if (!expectedLines.some((lines) => linesToRegex(lines).test(actual))) { 152 | // throw new Error(); 153 | // } 154 | assert.match(actual, linesToRegex(expectedLines[0])); 155 | } 156 | 157 | async function expectExitCode( 158 | child: child_process.ChildProcess, 159 | expected: number | null, 160 | ): Promise { 161 | await new Promise((resolve, reject) => { 162 | child.on("exit", (code) => { 163 | if (code === expected) { 164 | resolve(); 165 | } else { 166 | reject(new Error(`Unexpected exit code ${code}`)); 167 | } 168 | }); 169 | }); 170 | } 171 | 172 | type Conn = { browserSocket: net.Socket; originSocket: net.Socket }; 173 | 174 | async function sleep(ms: number) { 175 | return new Promise((resolve) => setTimeout(resolve, ms * TIME_MULTIPLIER)); 176 | } 177 | 178 | async function createBadTlsServer(port: number): Promise<() => Promise> { 179 | const stoppable = new Stoppable(); 180 | const server = net.createServer(); 181 | stoppable.addCloseable(server); 182 | const logger = getLogger("bad-tls", 34); 183 | server.on("connection", (socket) => { 184 | stoppable.addDestroyable(socket); 185 | logger("sending garbage"); 186 | socket.write("bad TLS handshake"); 187 | }); 188 | server.listen(port); 189 | await new Promise((resolve) => 190 | server.on("listening", () => { 191 | logger(`listening on ${port}`); 192 | resolve(); 193 | }), 194 | ); 195 | return () => stoppable.stop(); 196 | } 197 | 198 | async function createBadTlsClient( 199 | port: number, 200 | localHost: string, 201 | ): Promise<() => Promise> { 202 | const stoppable = new Stoppable(); 203 | const socket = net.createConnection(port, localHost); 204 | stoppable.addDestroyable(socket); 205 | const logger = getLogger("bad-tls", 34); 206 | socket.on("connect", () => { 207 | logger("sending garbage"); 208 | socket.write("bad TLS handshake"); 209 | }); 210 | return () => stoppable.stop(); 211 | } 212 | 213 | interface NetworkEmulatorParams { 214 | listenHost: string; 215 | listenPort: number; 216 | forwardHost: string; 217 | forwardPort: number; 218 | } 219 | 220 | /** 221 | * To avoid confusion, instead of calling these client and server, we call these "origin" and "browser" 222 | */ 223 | class EchoOriginAndBrowser extends Stoppable { 224 | // ID is first byte that is sent by the browser 225 | readonly originSocketByID: Map = new Map(); 226 | readonly browserSockets: Set = new Set(); 227 | readonly dataBySocket: Map = new Map(); 228 | 229 | constructor( 230 | readonly params: EndToEndTestParams, 231 | readonly loggerOrigin = getLogger("origin", 35), 232 | readonly loggerBrowser = getLogger("browser", 36), 233 | readonly server = net.createServer({ allowHalfOpen: true }), 234 | ) { 235 | super(); 236 | this.server.on("connection", (socket) => { 237 | this.addDestroyable(socket); 238 | this.setupEavesdrop(socket); 239 | loggerOrigin("connection"); 240 | socket.on("error", (err) => { 241 | loggerOrigin(`error ${err.toString()}`); 242 | }); 243 | socket.once("data", (data) => { 244 | this.originSocketByID.set(data.toString("utf-8").charAt(0), socket); 245 | }); 246 | socket.on("data", (data) => { 247 | this.loggerOrigin(`recv ${data.length}`); 248 | if (!socket.writableEnded) { 249 | this.loggerOrigin(`send ${data.length}`); 250 | socket.write(data); 251 | } 252 | }); 253 | // Make sure other end stays half-open long enough to receive the last byte 254 | socket.on("end", () => { 255 | this.loggerOrigin("recv FIN"); 256 | this.setTimeout(() => { 257 | this.loggerOrigin(`send 1`); 258 | this.loggerOrigin(`send FIN`); 259 | socket.end("z"); 260 | }, 100 * TIME_MULTIPLIER); 261 | }); 262 | }); 263 | } 264 | 265 | async startAndWaitUntilListening() { 266 | this.addCloseable(this.server); 267 | await new Promise((resolve, reject) => { 268 | this.server.on("listening", () => { 269 | this.loggerOrigin(`listening on ${this.params.originListenPort}`); 270 | resolve(); 271 | }); 272 | this.server.on("error", (err) => reject(err)); 273 | this.server.listen(this.params.originListenPort); 274 | }); 275 | } 276 | 277 | setupEavesdrop(socket: net.Socket) { 278 | socket.on("data", (data) => { 279 | const s = this.dataBySocket.get(socket) ?? ""; 280 | this.dataBySocket.set(socket, s + data.toString()); 281 | }); 282 | } 283 | 284 | createBrowserSocket(): net.Socket { 285 | this.loggerBrowser("connecting"); 286 | const socket = net.createConnection({ 287 | host: this.params.proxyHost, 288 | port: this.params.proxyPort, 289 | allowHalfOpen: true, 290 | }); 291 | this.setupEavesdrop(socket); 292 | this.browserSockets.add(socket); 293 | this.addDestroyable(socket); 294 | // Make sure other end stays half-open long enough to receive the last byte 295 | socket.on("end", () => { 296 | this.loggerBrowser("recv FIN"); 297 | this.setTimeout(() => { 298 | this.loggerBrowser(`send 1`); 299 | this.loggerBrowser(`send FIN`); 300 | socket.end("z"); 301 | }, 100 * TIME_MULTIPLIER); 302 | }); 303 | socket.on("close", () => this.browserSockets.delete(socket)); 304 | return socket; 305 | } 306 | 307 | async expectEconn() { 308 | return new Promise((resolve, reject) => { 309 | const socket = this.createBrowserSocket(); 310 | socket.on("error", () => {}); 311 | socket.on("close", (hadError) => { 312 | if (hadError) { 313 | this.loggerBrowser(`error ${socket.errored}`); 314 | resolve(); 315 | } else { 316 | reject(new Error("Unexpected success")); 317 | } 318 | }); 319 | }); 320 | } 321 | 322 | async expectPingPongAndClose() { 323 | const conn = await this.createConn(); 324 | conn.browserSocket.end(); 325 | await new Promise((resolve) => conn.browserSocket.on("close", resolve)); 326 | } 327 | 328 | async createConn(): Promise { 329 | const browserSocket = this.createBrowserSocket(); 330 | const curConnectionId = this.browserSockets.size.toString(); 331 | await new Promise((resolve) => browserSocket.on("connect", resolve)); 332 | // Send ID byte and wait for it to come back 333 | await sleep(400); 334 | browserSocket.write(curConnectionId); 335 | const chunk = await new Promise((resolve) => 336 | browserSocket.once("data", resolve), 337 | ); 338 | assert.strictEqual(chunk.toString(), curConnectionId); 339 | await sleep(100); 340 | for (const [connectionId, originSocket] of this.originSocketByID) { 341 | if (connectionId === curConnectionId) { 342 | this.originSocketByID.delete(connectionId); 343 | return { browserSocket, originSocket }; 344 | } 345 | } 346 | throw new Error(`Socket not found`); 347 | } 348 | 349 | async testConn( 350 | numBytes: number, 351 | term: "FIN" | "RST", 352 | by: "browser" | "localhost", 353 | delay: number = 0, 354 | ) { 355 | await sleep(delay); 356 | const conn = await this.createConn(); 357 | // send 1, recv 1, send 1, recv 1, etc. 358 | for (let i = 0; i < numBytes; i++) { 359 | await new Promise((resolve) => { 360 | conn.originSocket.once("data", (pong) => { 361 | assert.strictEqual(pong.toString(), "a"); 362 | resolve(); 363 | }); 364 | conn.browserSocket.write("a"); 365 | }); 366 | await sleep(50); 367 | } 368 | 369 | const [socket1, socket2] = 370 | by === "browser" 371 | ? [conn.browserSocket, conn.originSocket] 372 | : [conn.originSocket, conn.browserSocket]; 373 | 374 | if (term === "FIN") { 375 | assert.strictEqual(socket2.readyState, "open"); 376 | assert.strictEqual(socket1.readyState, "open"); 377 | socket1.end(); 378 | // socket1 sent FIN, but socket2 didn't receive it yet 379 | assert.strictEqual(socket2.readyState, "open"); 380 | assert.strictEqual(socket1.readyState, "readOnly"); 381 | await Promise.all([ 382 | new Promise((resolve) => { 383 | socket2.on("end", () => { 384 | // socket1 sent FIN and socket2 received it 385 | assert.strictEqual(socket2.readyState, "writeOnly"); 386 | assert.strictEqual(socket1.readyState, "readOnly"); 387 | resolve(); 388 | }); 389 | }), 390 | new Promise((resolve) => { 391 | socket2.on("close", (hasError) => { 392 | assert.strictEqual(hasError, false); 393 | assert.strictEqual(socket2.errored, null); 394 | assert.strictEqual(socket2.readyState, "closed"); 395 | resolve(); 396 | }); 397 | }), 398 | new Promise((resolve) => { 399 | socket1.on("close", (hasError) => { 400 | assert.strictEqual(hasError, false); 401 | assert.strictEqual(socket1.errored, null); 402 | assert.strictEqual(socket1.readyState, "closed"); 403 | resolve(); 404 | }); 405 | }), 406 | ]); 407 | // Make sure last byte was successfully communicated in half-open state 408 | const socket1data = this.dataBySocket.get(socket1); 409 | const socket2data = this.dataBySocket.get(socket2); 410 | assert.strictEqual(socket1data, socket2data + "z"); 411 | } else if (term == "RST") { 412 | if (by === "browser") { 413 | this.loggerBrowser("send RST"); 414 | } else { 415 | this.loggerOrigin("send RST"); 416 | } 417 | socket1.resetAndDestroy(); 418 | assert.strictEqual(socket1.readyState, "closed"); 419 | assert.strictEqual(socket2.readyState, "open"); 420 | await Promise.all([ 421 | new Promise((resolve) => { 422 | socket2.on("error", (err) => { 423 | assert.strictEqual(err["code"], "ECONNRESET"); 424 | assert.strictEqual(socket2.readyState, "closed"); 425 | assert.strictEqual(socket2.destroyed, true); 426 | resolve(); 427 | }); 428 | }), 429 | new Promise((resolve) => { 430 | socket1.on("close", (hasError) => { 431 | // No error on our end because we initiated the RST 432 | assert.strictEqual(hasError, false); 433 | assert.strictEqual(socket1.readyState, "closed"); 434 | assert.strictEqual(socket1.destroyed, true); 435 | resolve(); 436 | }); 437 | }), 438 | ]); 439 | } 440 | } 441 | } 442 | 443 | class NetworkEmulator extends Stoppable { 444 | incomingSocket: net.Socket | null = null; 445 | outgoingSocket: net.Socket | null = null; 446 | 447 | constructor( 448 | readonly params: NetworkEmulatorParams, 449 | readonly server = net.createServer({ allowHalfOpen: true }), 450 | readonly logger = getLogger("network", 31), 451 | ) { 452 | super(); 453 | } 454 | 455 | async breakConn() { 456 | this.incomingSocket!.resetAndDestroy(); 457 | // Sleep to make logs more predictable 458 | await sleep(100); 459 | this.outgoingSocket!.resetAndDestroy(); 460 | } 461 | 462 | unpipe() { 463 | this.incomingSocket!.unpipe(this.outgoingSocket!); 464 | this.outgoingSocket!.unpipe(this.incomingSocket!); 465 | } 466 | 467 | async startAndWaitUntilReady() { 468 | this.addCloseable(this.server); 469 | return new Promise((resolve) => { 470 | this.server.on("connection", (incomingSocket: net.Socket) => { 471 | this.addDestroyable(incomingSocket); 472 | this.incomingSocket = incomingSocket; 473 | const outgoingSocket = net.createConnection({ 474 | host: this.params.forwardHost, 475 | port: this.params.forwardPort, 476 | allowHalfOpen: true, 477 | }); 478 | this.addDestroyable(outgoingSocket); 479 | this.outgoingSocket = outgoingSocket; 480 | this.logger("connection"); 481 | outgoingSocket.on("error", () => incomingSocket.resetAndDestroy()); 482 | incomingSocket.on("error", () => outgoingSocket.resetAndDestroy()); 483 | incomingSocket.pipe(outgoingSocket); 484 | outgoingSocket.pipe(incomingSocket); 485 | }); 486 | this.server.on("listening", () => resolve()); 487 | this.server.listen(this.params.listenPort, this.params.listenHost); 488 | }); 489 | } 490 | } 491 | 492 | interface EndToEndTestParams { 493 | originListenHost: string; 494 | originListenPort: number; 495 | proxyHost: string; 496 | proxyPort: number; 497 | } 498 | 499 | const TIMEOUT = 5000; 500 | 501 | for (const IPVERSION of [4, 6]) { 502 | const LOCAL_HOST = IPVERSION === 4 ? "127.0.0.1" : "::1"; 503 | const LOCAL_HOST_FMT = IPVERSION === 4 ? "127.0.0.1" : "[::1]"; 504 | 505 | const DEFAULT_SERVER_OPTIONS: ServerOptions = { 506 | logger: getLogger("server", 32), 507 | key: TLS_KEY_EXAMPLECOM, 508 | cert: TLS_CRT_EXAMPLECOM, 509 | tunnelListenIp: LOCAL_HOST, 510 | tunnelListenPort: TUNNEL_PORT, 511 | proxyListenIp: LOCAL_HOST, 512 | proxyListenPort: PROXY_PORT, 513 | }; 514 | 515 | const DEFAULT_CLIENT_OPTIONS: ClientOptions = { 516 | logger: getLogger("client", 33), 517 | key: TLS_KEY_EXAMPLECOM, 518 | cert: TLS_CRT_EXAMPLECOM, 519 | tunnelHost: LOCAL_HOST, 520 | tunnelPort: TUNNEL_PORT, 521 | originHost: LOCAL_HOST, 522 | originPort: LOCAL_PORT, 523 | timeout: TIMEOUT * TIME_MULTIPLIER, 524 | }; 525 | 526 | const DEFAULT_PARAMS: EndToEndTestParams = { 527 | originListenHost: LOCAL_HOST, 528 | originListenPort: LOCAL_PORT, 529 | proxyHost: LOCAL_HOST, 530 | proxyPort: PROXY_PORT, 531 | }; 532 | 533 | async function setupClientAndServer( 534 | t: TestContext, 535 | clientOverrides: Partial, 536 | serverOverrides: Partial, 537 | ): Promise<{ client: TunnelClient; server: TunnelServer }> { 538 | LOG_LINES = []; 539 | const server = new TunnelServer({ 540 | ...DEFAULT_SERVER_OPTIONS, 541 | ...serverOverrides, 542 | }); 543 | const client = new TunnelClient({ 544 | ...DEFAULT_CLIENT_OPTIONS, 545 | ...clientOverrides, 546 | }); 547 | t.after(() => server.stop()); 548 | t.after(() => client.stop()); 549 | server.start(); 550 | await server.waitUntilListening(); 551 | client.start(); 552 | await server.waitUntilConnected(); 553 | await client.waitUntilConnected(); 554 | 555 | return { client, server }; 556 | } 557 | 558 | async function testHalfClosed(t: TestContext, params: EndToEndTestParams) { 559 | const echo = new EchoOriginAndBrowser(params); 560 | t.after(() => echo.stop()); 561 | await echo.startAndWaitUntilListening(); 562 | 563 | for (const term of ["FIN", "RST"] satisfies ("FIN" | "RST")[]) { 564 | for (const by of ["browser", "localhost"] satisfies ( 565 | | "browser" 566 | | "localhost" 567 | )[]) { 568 | await t.test( 569 | `clean termination by ${by} ${term} on ${params.proxyHost}:${params.proxyPort}`, 570 | async () => { 571 | // Test single 572 | await echo.testConn(1, term, by, 0); 573 | await echo.testConn(4, term, by, 0); 574 | // Test double simultaneous 575 | await Promise.all([ 576 | echo.testConn(3, term, by, 0), 577 | echo.testConn(3, term, by, 0), 578 | ]); 579 | // Test triple delayed 580 | await Promise.all([ 581 | echo.testConn(4, term, by, 0), 582 | echo.testConn(4, term, by, 10), 583 | echo.testConn(4, term, by, 100), 584 | ]); 585 | }, 586 | ); 587 | } 588 | } 589 | } 590 | 591 | // -------------------------------------------------------------------------------------------------------- 592 | // TESTS 593 | 594 | await test( 595 | `localhost and non-localhost key/crt pairs ipv${IPVERSION}`, 596 | {}, 597 | async (t) => { 598 | // Localhost certificate support is ensured by this option: https://nodejs.org/api/tls.html#tlsconnectoptions-callback 599 | const PAIRS: Partial[] = [ 600 | { key: TLS_KEY_EXAMPLECOM, cert: TLS_CRT_EXAMPLECOM }, 601 | { key: TLS_KEY_LOCALHOST, cert: TLS_CRT_LOCALHOST }, 602 | ]; 603 | for (const pair of PAIRS) { 604 | await t.test(async (t) => { 605 | await setupClientAndServer(t, pair, pair); 606 | const echo = new EchoOriginAndBrowser(DEFAULT_PARAMS); 607 | t.after(() => echo.stop()); 608 | await echo.startAndWaitUntilListening(); 609 | await echo.expectPingPongAndClose(); 610 | }); 611 | } 612 | }, 613 | ); 614 | 615 | await test( 616 | `logging test ipv${IPVERSION}`, 617 | { timeout: TEST_TIMEOUT }, 618 | async (t) => { 619 | await setupClientAndServer(t, {}, {}); 620 | const echo = new EchoOriginAndBrowser(DEFAULT_PARAMS); 621 | t.after(() => echo.stop()); 622 | await echo.startAndWaitUntilListening(); 623 | 624 | LOG_LINES = []; 625 | await echo.testConn(0, "FIN", "browser", 0); 626 | assertLastLines([ 627 | `server stream0 forwarded from ${LOCAL_HOST_FMT}:00`, 628 | `client stream0 forwarding to ${LOCAL_HOST_FMT}:00`, 629 | // Browser sends ID byte 630 | "server stream0 send 1", 631 | "client stream0 recv 1", 632 | // Localhost sends ID byte back 633 | "client stream0 send 1", 634 | "server stream0 recv 1", 635 | // Browser sends FIN 636 | "server stream0 send FIN", 637 | "client stream0 recv FIN", 638 | // Localhost received FIN and is now write-only, it sends last byte and FIN 639 | "client stream0 send 1", 640 | "client stream0 send FIN", 641 | "client stream0 closed", 642 | // Browser recieves last byte and FIN 643 | "server stream0 recv 1", 644 | "server stream0 recv FIN", 645 | "server stream0 closed", 646 | ]); 647 | 648 | await echo.testConn(0, "FIN", "localhost", 0); 649 | assertLastLines([ 650 | `server stream0 forwarded from ${LOCAL_HOST_FMT}:00`, 651 | `client stream0 forwarding to ${LOCAL_HOST_FMT}:00`, 652 | // Browser sends ID byte 653 | "server stream0 send 1", 654 | "client stream0 recv 1", 655 | // Localhost sends ID byte back 656 | "client stream0 send 1", 657 | "server stream0 recv 1", 658 | // Localhost sends FIN 659 | "client stream0 send FIN", 660 | "server stream0 recv FIN", 661 | // Browser received FIN and is now write-only, it sends last byte and FIN 662 | "server stream0 send 1", 663 | "server stream0 send FIN", 664 | "server stream0 closed", 665 | // Localhost recieves last byte and FIN 666 | "client stream0 recv 1", 667 | "client stream0 recv FIN", 668 | "client stream0 closed", 669 | ]); 670 | 671 | await echo.testConn(0, "RST", "browser", 0); 672 | assertLastLines([ 673 | `server stream0 forwarded from ${LOCAL_HOST_FMT}:00`, 674 | `client stream0 forwarding to ${LOCAL_HOST_FMT}:00`, 675 | // Browser sends ID byte 676 | "server stream0 send 1", 677 | "client stream0 recv 1", 678 | // Localhost sends ID byte back 679 | "client stream0 send 1", 680 | "server stream0 recv 1", 681 | // Browser breaks connection 682 | "server stream0 error *", 683 | "server stream0 send RST", 684 | "server stream0 closed", 685 | // Localhost receives RST 686 | "client stream0 recv RST", 687 | "client stream0 closed", 688 | ]); 689 | 690 | await echo.testConn(0, "RST", "localhost", 0); 691 | assertLastLines([ 692 | `server stream0 forwarded from ${LOCAL_HOST_FMT}:00`, 693 | `client stream0 forwarding to ${LOCAL_HOST_FMT}:00`, 694 | // Browser sends ID byte 695 | "server stream0 send 1", 696 | "client stream0 recv 1", 697 | // Localhost sends ID byte back 698 | "client stream0 send 1", 699 | "server stream0 recv 1", 700 | // Browser breaks connection 701 | "client stream0 error *", 702 | "client stream0 send RST", 703 | "client stream0 closed", 704 | // Localhost receives RST 705 | "server stream0 recv RST", 706 | "server stream0 closed", 707 | ]); 708 | }, 709 | ); 710 | 711 | await test( 712 | `test-testing-utils ipv${IPVERSION}`, 713 | { timeout: TEST_TIMEOUT }, 714 | async (t) => { 715 | // Run EchoServer tests without proxy or tunnel 716 | await t.test(`test-echo-server-no-proxy-no-tunnel`, async (t) => { 717 | await testHalfClosed(t, { 718 | originListenHost: LOCAL_HOST, 719 | originListenPort: LOCAL_PORT, 720 | proxyHost: LOCAL_HOST, 721 | proxyPort: LOCAL_PORT, 722 | }); 723 | }); 724 | 725 | await t.test(`test-network-emulator-using-echo-server`, async (t) => { 726 | // Test NetworkEmulator using EchoServer 727 | const net = new NetworkEmulator({ 728 | listenHost: LOCAL_HOST, 729 | listenPort: LOCAL2_PORT, 730 | forwardHost: LOCAL_HOST, 731 | forwardPort: LOCAL_PORT, 732 | }); 733 | t.after(() => net.stop()); 734 | await net.startAndWaitUntilReady(); 735 | await testHalfClosed(t, { 736 | originListenHost: LOCAL_HOST, 737 | originListenPort: LOCAL_PORT, 738 | proxyHost: LOCAL_HOST, 739 | proxyPort: LOCAL2_PORT, 740 | }); 741 | }); 742 | }, 743 | ); 744 | 745 | await test( 746 | `test-half-closed ipv${IPVERSION}`, 747 | { timeout: TEST_TIMEOUT }, 748 | async (t) => { 749 | // Test EchoServer through default tunnel 750 | await setupClientAndServer(t, {}, {}); 751 | await testHalfClosed(t, { 752 | originListenHost: LOCAL_HOST, 753 | originListenPort: LOCAL_PORT, 754 | proxyHost: LOCAL_HOST, 755 | proxyPort: PROXY_PORT, 756 | }); 757 | }, 758 | ); 759 | 760 | await test( 761 | `happy-path ipv${IPVERSION}`, 762 | { timeout: TEST_TIMEOUT }, 763 | async (t) => { 764 | const { client, server } = await setupClientAndServer(t, {}, {}); 765 | const echo = new EchoOriginAndBrowser(DEFAULT_PARAMS); 766 | t.after(() => echo.stop()); 767 | await echo.startAndWaitUntilListening(); 768 | 769 | LOG_LINES = []; 770 | 771 | // Make one request 772 | await echo.expectPingPongAndClose(); 773 | 774 | assertLastLines([ 775 | `server stream0 forwarded from ${LOCAL_HOST_FMT}:00`, 776 | `client stream0 forwarding to ${LOCAL_HOST_FMT}:00`, 777 | "server stream0 send 1", 778 | "client stream0 recv 1", 779 | "client stream0 send 1", 780 | "server stream0 recv 1", 781 | "server stream0 send FIN", 782 | "client stream0 recv FIN", 783 | "client stream0 send 1", 784 | "client stream0 send FIN", 785 | "client stream0 closed", 786 | "server stream0 recv 1", 787 | "server stream0 recv FIN", 788 | "server stream0 closed", 789 | ]); 790 | 791 | // Make two simultaneous slow requests 792 | await Promise.all([ 793 | echo.expectPingPongAndClose(), 794 | sleep(10).then(() => echo.expectPingPongAndClose()), 795 | ]); 796 | 797 | // NOTE: Log lines are unreliable here 798 | LOG_LINES = []; 799 | 800 | await echo.stop(); 801 | await client.stop(); 802 | await sleep(50); 803 | 804 | assertLastLines([ 805 | "client stopping", 806 | "server disconnected", 807 | "client disconnected", 808 | "client stopped", 809 | ]); 810 | 811 | await server.stop(); 812 | 813 | assertLastLines(["server stopping", "server stopped"]); 814 | }, 815 | ); 816 | 817 | await test(`use-before-ready ipv${IPVERSION}`, async () => { 818 | const server = new TunnelServer(DEFAULT_SERVER_OPTIONS); 819 | const client = new TunnelClient(DEFAULT_CLIENT_OPTIONS); 820 | const echo = new EchoOriginAndBrowser(DEFAULT_PARAMS); 821 | await echo.startAndWaitUntilListening(); 822 | 823 | LOG_LINES = []; 824 | server.start(); 825 | 826 | // Make a request before server is listening 827 | await echo.expectEconn(); 828 | 829 | assertLastLines([ 830 | "server listening", 831 | `server rejecting connection from ${LOCAL_HOST_FMT}:00`, 832 | ]); 833 | 834 | await server.waitUntilListening(); 835 | client.start(); 836 | 837 | // Make a request after server is listening but before tunnel is established 838 | await echo.expectEconn(); 839 | 840 | await client.waitUntilConnected(); 841 | await server.waitUntilConnected(); 842 | 843 | assertLastLines([ 844 | "client connecting", 845 | `server rejecting connection from ${LOCAL_HOST_FMT}:00`, 846 | `server connected to ${LOCAL_HOST_FMT}:${TUNNEL_PORT} from ${LOCAL_HOST_FMT}:00`, 847 | `client connected to ${LOCAL_HOST_FMT}:${TUNNEL_PORT} from ${LOCAL_HOST_FMT}:00`, 848 | ]); 849 | 850 | await echo.stop(); 851 | await client.stop(); 852 | await server.stop(); 853 | }); 854 | 855 | await test( 856 | `restart-client-while-server-running ipv${IPVERSION}`, 857 | { timeout: TEST_TIMEOUT }, 858 | async (t) => { 859 | const { client, server } = await setupClientAndServer(t, {}, {}); 860 | const echo = new EchoOriginAndBrowser(DEFAULT_PARAMS); 861 | t.after(() => echo.stop()); 862 | await echo.startAndWaitUntilListening(); 863 | LOG_LINES = []; 864 | // Restart server while client is running 865 | await server.stop(); 866 | await echo.expectEconn(); 867 | await sleep(1000); 868 | server.start(); 869 | await server.waitUntilConnected(); 870 | await client.waitUntilConnected(); 871 | 872 | assertLastLines([ 873 | "server stopping", 874 | "server disconnected", 875 | "server stopped", 876 | // "client tunnel error This socket has been ended by the other party", 877 | "client disconnected", 878 | "server listening", 879 | "client restarting", 880 | `server connected to ${LOCAL_HOST_FMT}:${TUNNEL_PORT} from ${LOCAL_HOST_FMT}:00`, 881 | `client connected to ${LOCAL_HOST_FMT}:${TUNNEL_PORT} from ${LOCAL_HOST_FMT}:00`, 882 | ]); 883 | 884 | // Make sure client reconnected and request succeeds 885 | await echo.expectPingPongAndClose(); 886 | await echo.stop(); 887 | }, 888 | ); 889 | 890 | await test( 891 | `restart-server-while-client-running ipv${IPVERSION}`, 892 | { timeout: TEST_TIMEOUT }, 893 | async (t) => { 894 | const { client, server } = await setupClientAndServer(t, {}, {}); 895 | const echo = new EchoOriginAndBrowser(DEFAULT_PARAMS); 896 | t.after(() => echo.stop()); 897 | await echo.startAndWaitUntilListening(); 898 | LOG_LINES = []; 899 | 900 | await client.stop(); 901 | client.start(); 902 | 903 | // Wait until client reconnected and make a request 904 | await sleep(30); 905 | await echo.expectEconn(); 906 | await server.waitUntilConnected(); 907 | await client.waitUntilConnected(); 908 | 909 | assertLastLines([ 910 | "client stopping", 911 | "client disconnected", 912 | "client stopped", 913 | "client connecting", 914 | // "server stream0 forwarded from ${LOCAL_HOST_FMT}:00", 915 | "server disconnected", 916 | `server rejecting connection from ${LOCAL_HOST_FMT}:00`, 917 | `server connected to ${LOCAL_HOST_FMT}:${TUNNEL_PORT} from ${LOCAL_HOST_FMT}:00`, 918 | `client connected to ${LOCAL_HOST_FMT}:${TUNNEL_PORT} from ${LOCAL_HOST_FMT}:00`, 919 | ]); 920 | 921 | // Make sure client reconnected and request succeeds 922 | await echo.expectPingPongAndClose(); 923 | }, 924 | ); 925 | 926 | await test( 927 | `bad-network ipv${IPVERSION}`, 928 | { timeout: TEST_TIMEOUT }, 929 | async (t) => { 930 | LOG_LINES = []; 931 | const echo = new EchoOriginAndBrowser(DEFAULT_PARAMS); 932 | t.after(() => echo.stop()); 933 | const net = new NetworkEmulator({ 934 | listenHost: LOCAL_HOST, 935 | listenPort: TUNNEL2_PORT, 936 | forwardHost: LOCAL_HOST, 937 | forwardPort: TUNNEL_PORT, 938 | }); 939 | t.after(() => net.stop()); 940 | const server = new TunnelServer(DEFAULT_SERVER_OPTIONS); 941 | t.after(() => server.stop()); 942 | const client = new TunnelClient({ 943 | ...DEFAULT_CLIENT_OPTIONS, 944 | tunnelPort: TUNNEL2_PORT, 945 | }); 946 | t.after(() => client.stop()); 947 | 948 | await echo.startAndWaitUntilListening(); 949 | await net.startAndWaitUntilReady(); 950 | 951 | server.start(); 952 | await server.waitUntilListening(); 953 | 954 | // Wait until client is connected and test 200 955 | client.start(); 956 | await client.waitUntilConnected(); 957 | await server.waitUntilConnected(); 958 | 959 | assertLastLines([ 960 | "server listening", 961 | "client connecting", 962 | `server connected to ${LOCAL_HOST_FMT}:${TUNNEL_PORT} from ${LOCAL_HOST_FMT}:00`, 963 | `client connected to ${LOCAL_HOST_FMT}:${TUNNEL2_PORT} from ${LOCAL_HOST_FMT}:00`, 964 | ]); 965 | 966 | // Make one request 967 | await echo.expectPingPongAndClose(); 968 | 969 | assertLastLines([ 970 | `server stream0 forwarded from ${LOCAL_HOST_FMT}:00`, 971 | `client stream0 forwarding to ${LOCAL_HOST_FMT}:00`, 972 | "server stream0 send 1", 973 | "client stream0 recv 1", 974 | "client stream0 send 1", 975 | "server stream0 recv 1", 976 | "server stream0 send FIN", 977 | "client stream0 recv FIN", 978 | "client stream0 send 1", 979 | "client stream0 send FIN", 980 | "client stream0 closed", 981 | "server stream0 recv 1", 982 | "server stream0 recv FIN", 983 | "server stream0 closed", 984 | ]); 985 | 986 | // Break tunnel while no requests are taking place 987 | await net.breakConn(); 988 | 989 | await sleep(100); 990 | await echo.expectEconn(); 991 | 992 | assertLastLines([ 993 | "client disconnected", 994 | "server disconnected", 995 | `server rejecting connection from ${LOCAL_HOST_FMT}:00`, 996 | ]); 997 | 998 | // Wait until client reconnected and make a request 999 | await server.waitUntilConnected(); 1000 | await client.waitUntilConnected(); 1001 | await echo.expectPingPongAndClose(); 1002 | 1003 | assertLastLines([ 1004 | "client restarting", 1005 | `server connected to ${LOCAL_HOST_FMT}:${TUNNEL_PORT} from ${LOCAL_HOST_FMT}:00`, 1006 | `client connected to ${LOCAL_HOST_FMT}:${TUNNEL2_PORT} from ${LOCAL_HOST_FMT}:00`, 1007 | `server stream0 forwarded from ${LOCAL_HOST_FMT}:00`, 1008 | `client stream0 forwarding to ${LOCAL_HOST_FMT}:00`, 1009 | "server stream0 send 1", 1010 | "client stream0 recv 1", 1011 | "client stream0 send 1", 1012 | "server stream0 recv 1", 1013 | "server stream0 send FIN", 1014 | "client stream0 recv FIN", 1015 | "client stream0 send 1", 1016 | "client stream0 send FIN", 1017 | "client stream0 closed", 1018 | "server stream0 recv 1", 1019 | "server stream0 recv FIN", 1020 | "server stream0 closed", 1021 | ]); 1022 | 1023 | // Break tunnel during a request 1024 | const promise1 = echo.expectEconn(); 1025 | await sleep(5); 1026 | await net.breakConn(); 1027 | await sleep(10); 1028 | await promise1; 1029 | 1030 | await client.waitUntilConnected(); 1031 | await server.waitUntilConnected(); 1032 | 1033 | LOG_LINES = []; 1034 | 1035 | net.unpipe(); 1036 | 1037 | await sleep(TIMEOUT * 0.25); 1038 | 1039 | // Too early to detect timeout 1040 | assertLastLines([]); 1041 | 1042 | await sleep(TIMEOUT * 4); 1043 | 1044 | // Timeout activated because ping frame could not go through 1045 | assertLastLines([ 1046 | "client disconnected", 1047 | "client restarting", 1048 | "server disconnected", 1049 | `server connected to ${LOCAL_HOST_FMT}:${TUNNEL_PORT} from ${LOCAL_HOST_FMT}:00`, 1050 | `client connected to ${LOCAL_HOST_FMT}:${TUNNEL2_PORT} from ${LOCAL_HOST_FMT}:00`, 1051 | ]); 1052 | }, 1053 | ); 1054 | 1055 | await test( 1056 | `garbage-to-client ipv${IPVERSION}`, 1057 | { timeout: TEST_TIMEOUT }, 1058 | async (t) => { 1059 | const echoServer = new EchoOriginAndBrowser(DEFAULT_PARAMS); 1060 | t.after(() => echoServer.stop()); 1061 | await echoServer.startAndWaitUntilListening(); 1062 | const stopBadServer = await createBadTlsServer(TUNNEL_PORT); 1063 | t.after(stopBadServer); 1064 | const client = new TunnelClient(DEFAULT_CLIENT_OPTIONS); 1065 | t.after(() => client.stop()); 1066 | client.start(); 1067 | 1068 | // Still no connection after a second 1069 | await sleep(1000); 1070 | await echoServer.expectEconn(); 1071 | assert.strictEqual(client.activeSession, null); 1072 | 1073 | // Let the network recover and make a successful connection 1074 | await stopBadServer(); 1075 | const server = new TunnelServer(DEFAULT_SERVER_OPTIONS); 1076 | t.after(() => server.stop()); 1077 | server.start(); 1078 | 1079 | await server.waitUntilConnected(); 1080 | await echoServer.expectPingPongAndClose(); 1081 | }, 1082 | ); 1083 | 1084 | await test( 1085 | `garbage-to-server ipv${IPVERSION}`, 1086 | { timeout: TEST_TIMEOUT }, 1087 | async (t) => { 1088 | const echoServer = new EchoOriginAndBrowser(DEFAULT_PARAMS); 1089 | t.after(() => echoServer.stop()); 1090 | await echoServer.startAndWaitUntilListening(); 1091 | const server = new TunnelServer(DEFAULT_SERVER_OPTIONS); 1092 | t.after(() => server.stop()); 1093 | server.start(); 1094 | await server.waitUntilListening(); 1095 | 1096 | // Still no connection after a second 1097 | const stopBadClient = await createBadTlsClient(TUNNEL_PORT, LOCAL_HOST); 1098 | t.after(stopBadClient); 1099 | await sleep(1000); 1100 | await echoServer.expectEconn(); 1101 | assert.strictEqual(server.activeSession, null); 1102 | 1103 | // Let the network recover and make a successful connection 1104 | await stopBadClient(); 1105 | const client = new TunnelClient(DEFAULT_CLIENT_OPTIONS); 1106 | t.after(() => client.stop()); 1107 | client.start(); 1108 | await server.waitUntilConnected(); 1109 | await echoServer.expectPingPongAndClose(); 1110 | }, 1111 | ); 1112 | 1113 | await test( 1114 | `latest-client-wins ipv${IPVERSION}`, 1115 | { timeout: TEST_TIMEOUT }, 1116 | async (t) => { 1117 | const echoServer = new EchoOriginAndBrowser(DEFAULT_PARAMS); 1118 | t.after(() => echoServer.stop()); 1119 | await echoServer.startAndWaitUntilListening(); 1120 | const server = new TunnelServer(DEFAULT_SERVER_OPTIONS); 1121 | t.after(() => server.stop()); 1122 | server.start(); 1123 | await server.waitUntilListening(); 1124 | 1125 | const client1 = new TunnelClient({ 1126 | ...DEFAULT_CLIENT_OPTIONS, 1127 | logger: getLogger("client1", 33), 1128 | }); 1129 | t.after(() => client1.stop()); 1130 | const client2 = new TunnelClient({ 1131 | ...DEFAULT_CLIENT_OPTIONS, 1132 | logger: getLogger("client2", 33), 1133 | }); 1134 | t.after(() => client2.stop()); 1135 | 1136 | client1.start(); 1137 | 1138 | await client1.waitUntilConnected(); 1139 | await server.waitUntilConnected(); 1140 | 1141 | await echoServer.expectPingPongAndClose(); 1142 | 1143 | LOG_LINES = []; 1144 | 1145 | client2.start(); 1146 | await client2.waitUntilConnected(); 1147 | await server.waitUntilConnected(); 1148 | 1149 | await echoServer.expectPingPongAndClose(); 1150 | 1151 | assertLastLines([ 1152 | "client2 connecting", 1153 | "server disconnected", 1154 | "client1 disconnected", 1155 | `server connected to ${LOCAL_HOST_FMT}:${TUNNEL_PORT} from ${LOCAL_HOST_FMT}:00`, 1156 | `client2 connected to ${LOCAL_HOST_FMT}:${TUNNEL_PORT} from ${LOCAL_HOST_FMT}:00`, 1157 | `server stream0 forwarded from ${LOCAL_HOST_FMT}:00`, 1158 | `client2 stream0 forwarding to ${LOCAL_HOST_FMT}:00`, 1159 | "server stream0 send 1", 1160 | "client2 stream0 recv 1", 1161 | "client2 stream0 send 1", 1162 | "server stream0 recv 1", 1163 | "server stream0 send FIN", 1164 | "client2 stream0 recv FIN", 1165 | "client2 stream0 send 1", 1166 | "client2 stream0 send FIN", 1167 | "client2 stream0 closed", 1168 | "server stream0 recv 1", 1169 | "server stream0 recv FIN", 1170 | "server stream0 closed", 1171 | ]); 1172 | }, 1173 | ); 1174 | 1175 | await test( 1176 | `addr-in-use ipv${IPVERSION}`, 1177 | { timeout: TEST_TIMEOUT }, 1178 | async (t) => { 1179 | const server1 = new TunnelServer(DEFAULT_SERVER_OPTIONS); 1180 | const server2 = new TunnelServer(DEFAULT_SERVER_OPTIONS); 1181 | t.after(() => server1.stop()); 1182 | t.after(() => server2.stop()); 1183 | server1.start(); 1184 | await server1.waitUntilListening(); 1185 | server2.start(); 1186 | await assert.rejects(() => server2.waitUntilListening(), { 1187 | message: /EADDRINUSE/, 1188 | }); 1189 | }, 1190 | ); 1191 | 1192 | function spawnServer(): child_process.ChildProcessByStdio< 1193 | stream.Writable, 1194 | stream.Readable, 1195 | stream.Readable 1196 | > { 1197 | return child_process.spawn( 1198 | process.execPath, 1199 | [ 1200 | path.join("build", "cli.js"), 1201 | "server", 1202 | "--crt", 1203 | TLS_CRT_FILE, 1204 | "--key", 1205 | TLS_KEY_FILE, 1206 | "--tunnel-listen-ip", 1207 | LOCAL_HOST, 1208 | "--tunnel-listen-port", 1209 | TUNNEL_PORT.toString(), 1210 | "--proxy-listen-port", 1211 | PROXY_PORT.toString(), 1212 | ], 1213 | { 1214 | // timeout: 1000, 1215 | stdio: "pipe", 1216 | }, 1217 | ); 1218 | } 1219 | 1220 | await test( 1221 | `cli-exit-1 ipv${IPVERSION}`, 1222 | { timeout: TEST_TIMEOUT }, 1223 | async (t) => { 1224 | const child1 = spawnServer(); 1225 | t.after(() => child1.kill()); 1226 | 1227 | // Wait until listening 1228 | await new Promise((resolve) => { 1229 | readline 1230 | .createInterface({ input: child1.stdout }) 1231 | .on("line", (line) => { 1232 | if (line === "listening") { 1233 | resolve(); 1234 | } 1235 | }); 1236 | }); 1237 | 1238 | const child2 = spawnServer(); 1239 | t.after(() => child2.kill()); 1240 | 1241 | // Expect exit code 1 because address is already in use 1242 | await expectExitCode(child2, 1); 1243 | }, 1244 | ); 1245 | 1246 | for (const signal of ["SIGTERM", "SIGINT"] as const) { 1247 | await test( 1248 | `cli-exit-sigterm-sigint ${signal} ipv${IPVERSION}`, 1249 | { timeout: TEST_TIMEOUT }, 1250 | async (t) => { 1251 | const child = spawnServer(); 1252 | t.after(() => child.kill()); 1253 | 1254 | const rl = readline.createInterface({ input: child.stdout }); 1255 | 1256 | // Wait until listening 1257 | await new Promise((resolve) => { 1258 | rl.on("line", (line) => { 1259 | if (line === "listening") { 1260 | resolve(); 1261 | } 1262 | }); 1263 | }); 1264 | 1265 | child.kill(signal); 1266 | 1267 | await expectExitCode(child, 0); 1268 | }, 1269 | ); 1270 | } 1271 | } 1272 | -------------------------------------------------------------------------------- /src/h2tunnel.ts: -------------------------------------------------------------------------------- 1 | import events from "node:events"; 2 | import stream from "node:stream"; 3 | import net from "node:net"; 4 | import tls from "node:tls"; 5 | import http2 from "node:http2"; 6 | 7 | export const DEFAULT_LISTEN_IP = "::0"; 8 | export const DEFAULT_ORIGIN_HOST = "localhost"; 9 | export const DEFAULT_TIMEOUT = 5000; 10 | export const DEFAULT_TUNNEL_PORT = 15900; 11 | 12 | interface CommonOptions { 13 | logger?: (line: any) => void; 14 | key: string; 15 | cert: string; 16 | } 17 | 18 | export interface ServerOptions extends CommonOptions { 19 | tunnelListenIp?: string; 20 | tunnelListenPort?: number; 21 | proxyListenIp?: string; 22 | proxyListenPort: number; 23 | } 24 | 25 | export interface ClientOptions extends CommonOptions { 26 | originHost?: string; 27 | originPort: number; 28 | tunnelHost: string; 29 | tunnelPort?: number; 30 | timeout?: number; 31 | } 32 | 33 | const formatAddr = (family?: string, address?: string, port?: number) => 34 | family === "IPv6" ? `[${address}]:${port}` : `${address}:${port}`; 35 | 36 | const formatRemote = (socket: net.Socket) => 37 | formatAddr(socket.remoteFamily, socket.remoteAddress, socket.remotePort); 38 | 39 | const formatLocal = (socket: net.Socket) => 40 | formatAddr(socket.localFamily, socket.localAddress, socket.localPort); 41 | 42 | type Servers = "muxServer" | "proxyServer" | "tunnelServer"; 43 | type Stream = `stream${number}`; 44 | 45 | export type LogLine = 46 | | `connected to ${string} from ${string}` 47 | | `rejecting connection from ${string}` 48 | | `${Stream} ${"send" | "recv"} ${"FIN" | "RST" | number}` 49 | | `${Stream} closed` 50 | | `${Servers | Stream | "tunnel"} error ${string}` 51 | | `${Stream} forwarding to ${string}` // client: local address which we connect to 52 | | `${Stream} forwarded from ${string}` // server: remote address connecting to proxy server 53 | | "connecting" 54 | | "disconnected" 55 | | "listening" 56 | | "stopping" 57 | | "stopped" 58 | | `restarting`; 59 | 60 | interface Closeable { 61 | close(): void; 62 | on(event: "close", listener: () => void): void; 63 | } 64 | 65 | interface Destroyable { 66 | destroy(): void; 67 | on(event: "close", listener: () => void): void; 68 | } 69 | 70 | export class Stoppable { 71 | closeables: Set = new Set(); 72 | destroyables: Set = new Set(); 73 | timeouts: Set = new Set(); 74 | addCloseable(closeable: Closeable) { 75 | this.closeables.add(closeable); 76 | closeable.on("close", () => this.closeables.delete(closeable)); 77 | } 78 | addDestroyable(destroyable: Destroyable) { 79 | this.destroyables.add(destroyable); 80 | destroyable.on("close", () => this.destroyables.delete(destroyable)); 81 | } 82 | setTimeout(callback: () => void, ms: number): NodeJS.Timeout { 83 | const timeout = setTimeout(() => { 84 | this.timeouts.delete(timeout); 85 | callback(); 86 | }, ms); 87 | this.timeouts.add(timeout); 88 | return timeout; 89 | } 90 | async stop() { 91 | [...this.timeouts].forEach(clearTimeout); 92 | [...this.closeables].forEach((closeable) => closeable.close()); 93 | [...this.destroyables].forEach((closeable) => closeable.destroy()); 94 | await Promise.all( 95 | [...this.closeables, ...this.destroyables].map( 96 | (closeable) => 97 | new Promise((resolve) => closeable.on("close", resolve)), 98 | ), 99 | ); 100 | } 101 | } 102 | 103 | export abstract class AbstractTunnel< 104 | S extends http2.Http2Session, 105 | M extends net.Server, 106 | > extends Stoppable { 107 | activeSession: S | null = null; 108 | tunnelSocket: tls.TLSSocket | null = null; 109 | activeStreams: Map = new Map(); 110 | aborted: boolean = false; 111 | connectedEvent = new events.EventEmitter>(); 112 | 113 | protected constructor( 114 | readonly log: (line: LogLine) => void = (line) => 115 | process.stdout.write(line + "\n"), 116 | readonly muxServer: M, 117 | ) { 118 | super(); 119 | muxServer.on("error", (err) => 120 | this.log(`muxServer error ${err.toString()}`), 121 | ); 122 | } 123 | 124 | addStream(socket: net.Socket, stream: http2.Http2Stream): number { 125 | const streamId = this.activeStreams.size; 126 | this.activeStreams.set(stream, socket); 127 | // Error can be on the socket side or on the stream side. Socket error is logged as error, stream error is logged as RST 128 | socket.on("error", (error) => { 129 | this.log(`stream${streamId} error ${error.toString()}`); 130 | this.log(`stream${streamId} send RST`); 131 | }); 132 | stream.on("error", () => { 133 | // Make sure stream error is received from the network and not from the socket 134 | if (!socket.errored) { 135 | this.log(`stream${streamId} recv RST`); 136 | } 137 | }); 138 | stream.on("close", () => { 139 | this.log(`stream${streamId} closed`); 140 | this.activeStreams.delete(stream); 141 | }); 142 | const setup = ( 143 | duplex1: stream.Duplex, 144 | duplex2: stream.Duplex, 145 | t: "send" | "recv", 146 | destroyDuplex2: () => void, 147 | ) => { 148 | duplex1.on("data", (chunk: Buffer) => { 149 | this.log(`stream${streamId} ${t} ${chunk.length}`); 150 | duplex2.write(chunk); 151 | }); 152 | duplex1.on("end", () => { 153 | this.log(`stream${streamId} ${t} FIN`); 154 | if (!duplex2.writableEnded) { 155 | duplex2.end(); 156 | } 157 | }); 158 | duplex1.on("close", () => { 159 | if (duplex1.errored && !duplex2.destroyed) { 160 | destroyDuplex2(); 161 | } 162 | }); 163 | }; 164 | 165 | setup(socket, stream, "send", () => stream.destroy(new Error())); 166 | setup(stream, socket, "recv", () => socket.resetAndDestroy()); 167 | return streamId; 168 | } 169 | 170 | start() { 171 | this.aborted = false; 172 | this.addCloseable(this.muxServer); 173 | this.muxServer.listen(0, "127.0.0.1"); // Let the OS pick a port, use IPv4 because it's almost always available 174 | } 175 | 176 | async stop() { 177 | this.log("stopping"); 178 | this.aborted = true; 179 | await super.stop(); 180 | this.log("stopped"); 181 | } 182 | 183 | async waitUntilConnected() { 184 | if (!this.activeSession || this.activeSession.destroyed) { 185 | await new Promise((resolve) => 186 | this.connectedEvent.once("connected", resolve), 187 | ); 188 | } 189 | } 190 | } 191 | 192 | export class TunnelServer extends AbstractTunnel< 193 | http2.ClientHttp2Session, 194 | net.Server 195 | > { 196 | listeningPomise: Promise | null = null; 197 | constructor( 198 | readonly options: ServerOptions, 199 | readonly tunnelServer = tls.createServer({ 200 | key: options.key, 201 | cert: options.cert, 202 | // This is necessary only if using client certificate authentication. 203 | requestCert: true, 204 | // This is necessary only if the client uses a self-signed certificate. 205 | ca: [options.cert], 206 | }), 207 | readonly proxyServer = net.createServer({ allowHalfOpen: true }), 208 | ) { 209 | super(options.logger, net.createServer()); 210 | proxyServer.on("connection", (socket: net.Socket) => { 211 | this.addDestroyable(socket); 212 | if (!this.activeSession || this.activeSession.destroyed || this.aborted) { 213 | this.log(`rejecting connection from ${formatRemote(socket)}`); 214 | socket.resetAndDestroy(); 215 | } else { 216 | const stream = this.activeSession.request({ 217 | [http2.constants.HTTP2_HEADER_METHOD]: "POST", 218 | }); 219 | this.addDestroyable(stream); 220 | const streamId = this.addStream(socket, stream); 221 | this.log(`stream${streamId} forwarded from ${formatRemote(socket)}`); 222 | } 223 | }); 224 | proxyServer.on("error", (err) => 225 | this.log(`proxyServer error ${err.toString()}`), 226 | ); 227 | tunnelServer.on("tlsClientError", (err) => 228 | this.log(`tunnel error ${err.message.trim()}`), 229 | ); 230 | tunnelServer.on("error", (err) => 231 | this.log(`tunnelServer error ${err.toString()}`), 232 | ); 233 | tunnelServer.on("secureConnection", (tunnelSocket: tls.TLSSocket) => { 234 | // Make sure latest tunnel kills previous tunnel 235 | this.tunnelSocket?.destroy(); 236 | this.tunnelSocket = tunnelSocket; 237 | this.addDestroyable(tunnelSocket); 238 | tunnelSocket.on("error", () => {}); 239 | tunnelSocket.on("close", () => { 240 | session.destroy(new Error()); 241 | this.log(`disconnected`); 242 | }); 243 | const address = this.muxServer.address() as net.AddressInfo; 244 | const session: http2.ClientHttp2Session = http2.connect( 245 | `http://${formatAddr(address.family, address.address, address.port)}`, 246 | ); 247 | this.addDestroyable(session); 248 | session.on("error", () => {}); 249 | this.muxServer.once("connection", (muxSocket: net.Socket) => { 250 | this.addDestroyable(muxSocket); 251 | tunnelSocket.on("close", () => muxSocket.destroy()); 252 | tunnelSocket.pipe(muxSocket); 253 | muxSocket.pipe(tunnelSocket); 254 | }); 255 | session.on(`remoteSettings`, () => { 256 | this.activeSession = session; 257 | this.activeSession.on("ping", () => {}); 258 | this.log( 259 | `connected to ${formatLocal(tunnelSocket)} from ${formatRemote(tunnelSocket)}`, 260 | ); 261 | this.connectedEvent.emit("connected"); 262 | }); 263 | }); 264 | } 265 | 266 | start() { 267 | super.start(); 268 | this.addCloseable(this.proxyServer); 269 | this.addCloseable(this.tunnelServer); 270 | let listening = false; 271 | this.listeningPomise = new Promise((resolve, reject) => { 272 | const hook = () => { 273 | if ( 274 | !listening && 275 | this.muxServer.listening && 276 | this.proxyServer.listening && 277 | this.tunnelServer.listening 278 | ) { 279 | listening = true; 280 | this.log("listening"); 281 | this.muxServer.removeListener("error", reject); 282 | this.proxyServer.removeListener("error", reject); 283 | this.tunnelServer.removeListener("error", reject); 284 | resolve(); 285 | } 286 | }; 287 | this.muxServer.on("error", reject); 288 | this.proxyServer.on("error", reject); 289 | this.tunnelServer.on("error", reject); 290 | this.muxServer.once("listening", hook); 291 | this.proxyServer.once("listening", hook); 292 | this.tunnelServer.once("listening", hook); 293 | }); 294 | 295 | this.proxyServer.listen( 296 | this.options.proxyListenPort, 297 | this.options.proxyListenIp ?? DEFAULT_LISTEN_IP, 298 | ); 299 | this.tunnelServer.listen( 300 | this.options.tunnelListenPort ?? DEFAULT_TUNNEL_PORT, 301 | this.options.tunnelListenIp ?? DEFAULT_LISTEN_IP, 302 | ); 303 | } 304 | 305 | async waitUntilListening() { 306 | await this.listeningPomise; 307 | } 308 | } 309 | 310 | export class TunnelClient extends AbstractTunnel< 311 | http2.ServerHttp2Session, 312 | http2.Http2Server 313 | > { 314 | // The tunnel will not restart as long as this property is not null 315 | restartTimeout: NodeJS.Timeout | null = null; 316 | pingTimeout: NodeJS.Timeout | null = null; 317 | 318 | constructor(readonly options: ClientOptions) { 319 | super(options.logger, http2.createServer()); 320 | this.muxServer.on("listening", () => this.startTunnel()); 321 | this.muxServer.on("stream", (stream: http2.ClientHttp2Stream) => { 322 | this.addDestroyable(stream); 323 | const socket = net.createConnection({ 324 | host: this.options.originHost ?? DEFAULT_ORIGIN_HOST, 325 | port: this.options.originPort, 326 | allowHalfOpen: true, 327 | }); 328 | this.addDestroyable(socket); 329 | // Wait for connection so we know the local port 330 | socket.on("connect", () => { 331 | const streamId = this.addStream(socket, stream); 332 | this.log(`stream${streamId} forwarding to ${formatLocal(socket)}`); 333 | }); 334 | }); 335 | } 336 | 337 | start() { 338 | super.start(); 339 | this.log("connecting"); 340 | } 341 | 342 | startTunnel() { 343 | if (this.restartTimeout) { 344 | clearTimeout(this.restartTimeout); 345 | } 346 | if (this.pingTimeout) { 347 | clearTimeout(this.pingTimeout); 348 | } 349 | const timeout = this.options.timeout ?? DEFAULT_TIMEOUT; 350 | const tunnelSocket = tls.connect({ 351 | host: this.options.tunnelHost, 352 | port: this.options.tunnelPort ?? DEFAULT_TUNNEL_PORT, 353 | cert: this.options.cert, 354 | key: this.options.key, 355 | ca: [this.options.cert], 356 | timeout: timeout, 357 | checkServerIdentity: () => undefined, 358 | }); 359 | tunnelSocket.on("timeout", () => tunnelSocket.destroy(new Error())); 360 | this.tunnelSocket = tunnelSocket; 361 | const address = this.muxServer.address() as net.AddressInfo; 362 | const muxSocket = net.createConnection({ 363 | port: address.port, 364 | host: address.address, 365 | family: Number(address.family.charAt(3)), 366 | }); 367 | this.addDestroyable(tunnelSocket); 368 | this.addDestroyable(muxSocket); 369 | tunnelSocket.pipe(muxSocket); 370 | muxSocket.pipe(tunnelSocket); 371 | tunnelSocket.on("error", () => {}); 372 | tunnelSocket.on("close", () => { 373 | this.log(`disconnected`); 374 | muxSocket.destroy(); 375 | if (!this.aborted) { 376 | this.restartTimeout = this.setTimeout(() => { 377 | this.log("restarting"); 378 | this.startTunnel(); 379 | }, timeout); 380 | } 381 | }); 382 | this.muxServer.once("session", (session: http2.ServerHttp2Session) => { 383 | this.addDestroyable(session); 384 | session.on("error", () => {}); 385 | tunnelSocket.on("close", () => session.destroy(new Error())); 386 | session.on("remoteSettings", () => { 387 | const ping = () => { 388 | this.pingTimeout = this.setTimeout(() => { 389 | if (!session.destroyed) { 390 | session.ping((err) => { 391 | // When session is destroyed we get ERR_HTTP2_PING_CANCEL 392 | if (!err) { 393 | ping(); 394 | } 395 | }); 396 | } 397 | }, timeout * 0.5); 398 | }; 399 | ping(); 400 | this.log( 401 | `connected to ${formatRemote(tunnelSocket)} from ${formatLocal(tunnelSocket)}`, 402 | ); 403 | this.connectedEvent.emit("connected"); 404 | }); 405 | }); 406 | } 407 | } 408 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ES2024"], 4 | "types": ["node"], 5 | "module": "NodeNext", 6 | "declaration": true, 7 | "sourceMap": true, 8 | "outDir": "build", 9 | "strictNullChecks": true 10 | }, 11 | "include": ["src/*"] 12 | } 13 | --------------------------------------------------------------------------------