bolthole 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,143 @@
1
- # Temporary Holding Version
1
+ # bolthole (JavaScript)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Reach any service from anywhere, through a relay that can't read your traffic.**
4
+
5
+ JavaScript SDK for [Bolthole](https://github.com/abcdlsj/bolthole). Runs in
6
+ browsers, Node 22+, Deno, Bun and Cloudflare Workers. Plain ES modules on
7
+ WebCrypto — no build step, no dependencies.
8
+
9
+ ```
10
+ your service ── agent ──outbound WSS──▶ relay ◀──HTTPS/WSS── client (phone, browser, server)
11
+ └──────────── end-to-end encrypted (Noise) ───────────┘
12
+ ```
13
+
14
+ The agent dials **out** to the relay and keeps the connection open. Clients go
15
+ through the relay to the agent. Both ends derive keys from a 256-bit code that
16
+ travels in the URL fragment, so the relay only ever forwards ciphertext.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ npm install bolthole
22
+ ```
23
+
24
+ Need a relay? Deploy one to Cloudflare Workers for free, or run the Go binary:
25
+
26
+ ```bash
27
+ npx wrangler deploy # see github.com/abcdlsj/bolthole
28
+ bolthole serve --addr :8787 # or a relay on your own box
29
+ ```
30
+
31
+ ## Client
32
+
33
+ ```js
34
+ import { Client } from 'bolthole';
35
+
36
+ const c = new Client('https://relay.example.com/#k=6Xo2…');
37
+
38
+ const res = await c.fetch('/api/status', { method: 'POST', body: 'x' });
39
+ const ws = await c.websocket('/live'); // send() / read() / close()
40
+ const sock = await c.tcp(); // raw byte stream
41
+ ```
42
+
43
+ `c.fetch` and `c.websocket` take the same arguments as the globals; paths are
44
+ resolved against the agent's address, not the relay's.
45
+
46
+ | Method | |
47
+ | --- | --- |
48
+ | `fetch(input, init)` | HTTP through the agent, response streamed back |
49
+ | `websocket(path, protocols)` | WebSocket over the tunnel: `send()`, `read()`, `close()`, `protocol` |
50
+ | `tcp(target)` | Raw stream to a target the agent allows |
51
+ | `open(kind, meta)` | Any stream kind, including ones you define; `st.acceptMeta` holds the agent's reply |
52
+ | `session()` | The live encrypted session, dialing if needed |
53
+ | `close()` | Drop the connection |
54
+
55
+ Options: `{ mode: 'auto' | 'relay' | 'direct', direct: [url], relayDelayMs,
56
+ directTimeoutMs, WebSocket }`. In the default `auto` mode the client races
57
+ direct addresses against the relay and settles on whichever answers first.
58
+
59
+ ## Agent
60
+
61
+ ```js
62
+ import { Agent } from 'bolthole';
63
+
64
+ const agent = new Agent({
65
+ relay: 'https://relay.example.com',
66
+ // code: '…', // persistent 43-char code; random if omitted
67
+ fetch: (req) => new Response('hi'), // your service
68
+ });
69
+ agent.run(); // reconnects forever
70
+ console.log(agent.connString); // send this to clients — it is a password
71
+ ```
72
+
73
+ Options: `{ relay, code, fetch, websocket, kinds, public, direct, auth, name,
74
+ onStatus, onSession, WebSocket }`. `websocket` handles `ws` streams,
75
+ `kinds` maps a custom kind name to `(stream, info) => …`.
76
+
77
+ `fetch` is any fetch-style handler, so an existing `Request → Response`
78
+ function works unchanged. Set `public: true` to also serve plaintext HTTP
79
+ through the relay for things meant to be public (webhooks, a landing page) —
80
+ in that mode the relay can see and alter that traffic. `auth` carries a relay
81
+ registration key or JWT; `onStatus` reports `{connected, error, retryMs}`.
82
+
83
+ Shorthands: `connect(connString, opts)` and `serve(opts)`.
84
+
85
+ ## Custom stream kinds
86
+
87
+ HTTP, WebSocket and TCP are just stream kinds. Define your own when you want
88
+ your own protocol inside the encrypted channel:
89
+
90
+ ```js
91
+ // Agent side: register a handler per kind.
92
+ new Agent({
93
+ relay: 'https://relay.example.com',
94
+ fetch: handler,
95
+ kinds: {
96
+ 'myapp.sync': async (st, info) => {
97
+ await st.accept({ ok: true }); // send meta back to the caller
98
+ for await (const chunk of st.readable()) { /* … */ }
99
+ await st.write(reply); // backpressure-aware
100
+ },
101
+ },
102
+ }).run();
103
+
104
+ // Client side: open that kind.
105
+ const st = await c.open('myapp.sync', { room: 'dev' });
106
+ console.log(st.acceptMeta); // { ok: true }
107
+ await st.write(bytes);
108
+ await st.closeWrite();
109
+ for await (const chunk of st.readable()) { /* … */ }
110
+ ```
111
+
112
+ A stream is a byte pipe with flow control: `write`, `read`, `readable()`
113
+ (a web `ReadableStream`), `pipeFrom`, `closeWrite`, `reset(code)`.
114
+
115
+ Stream kinds are documented in the
116
+ [protocol spec](https://github.com/abcdlsj/bolthole/blob/main/spec/PROTOCOL.md).
117
+
118
+ ## Relay
119
+
120
+ `bolthole/relay` is the relay core, so you can host one without the Go binary:
121
+
122
+ ```js
123
+ import { RelayHub } from 'bolthole/relay';
124
+ ```
125
+
126
+ The Workers edition lives in the main repository under `cloudflare/`.
127
+
128
+ ## What this package does not hide
129
+
130
+ The relay sees timing, sizes, and which token hash is talked to. Put it behind
131
+ TLS. A page served through the relay could be altered by a malicious relay
132
+ before it runs — for real deployments, host the client page yourself.
133
+
134
+ ## Credits
135
+
136
+ The approach came from [alma-relay](https://github.com/yetone/alma-relay):
137
+ an outbound-only agent connection, a relay blind to its traffic, and a Workers
138
+ deployment that costs nothing while idle. Bolthole generalizes it into a
139
+ protocol and SDK for any service.
140
+
141
+ ## License
142
+
143
+ MIT
package/package.json CHANGED
@@ -1,6 +1,37 @@
1
1
  {
2
2
  "name": "bolthole",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Reach any service from anywhere through a relay that can't read your traffic. Browser, Node, Deno, Bun and Workers.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "abcdlsj",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/abcdlsj/bolthole.git",
11
+ "directory": "js"
12
+ },
13
+ "homepage": "https://github.com/abcdlsj/bolthole/tree/main/js#readme",
14
+ "bugs": "https://github.com/abcdlsj/bolthole/issues",
15
+ "keywords": [
16
+ "relay",
17
+ "tunnel",
18
+ "reverse-proxy",
19
+ "end-to-end-encryption",
20
+ "noise-protocol",
21
+ "websocket",
22
+ "cloudflare-workers",
23
+ "nat-traversal"
24
+ ],
25
+ "exports": {
26
+ ".": "./src/index.js",
27
+ "./relay": "./src/relay.js"
28
+ },
29
+ "files": ["src"],
30
+ "engines": { "node": ">=22" },
31
+ "scripts": {
32
+ "test": "node --test test/*.test.js"
33
+ },
34
+ "devDependencies": {
35
+ "ws": "^8.18.0"
36
+ }
37
+ }
package/src/agent.js ADDED
@@ -0,0 +1,161 @@
1
+ // Agent: expose a fetch-style handler (and optional WebSocket / custom
2
+ // stream handlers) through a relay, end-to-end encrypted. Runs anywhere
3
+ // with WebSocket + WebCrypto: Node 22+, Deno, Bun.
4
+ import { derive, formatConnString, newCode } from './codes.js';
5
+ import { agentHandshake, PROTO_MUX, MAX_RECORD } from './noise.js';
6
+ import { Session } from './mux.js';
7
+ import { wsConn, serveHttp, WsStream } from './kinds.js';
8
+ import { enc, dec } from './bytes.js';
9
+
10
+ const E2E_PATH = '/.bolthole/e2e';
11
+
12
+ /**
13
+ * @typedef {object} AgentOptions
14
+ * @property {string} relay relay base URL
15
+ * @property {string} [code] connect code (generated if omitted)
16
+ * @property {(req: Request, info: {encrypted: boolean, session: Session}) => Response|Promise<Response>} [fetch] handler for `http` streams
17
+ * @property {(ws: WsStream, meta: object) => void} [websocket] handler for `ws` streams
18
+ * @property {Record<string, (stream: import('./mux.js').Stream, info: object) => void>} [kinds] custom stream kinds
19
+ * @property {(session: Session, hello: object) => void} [onSession] each connected client
20
+ * @property {string} [name]
21
+ * @property {string} [auth] registration key or JWT
22
+ * @property {boolean} [public] also serve plaintext streams from the relay
23
+ * @property {string[]} [direct] direct URLs to advertise to clients
24
+ * @property {(s: {connected: boolean, error?: Error, retryMs?: number}) => void} [onStatus]
25
+ * @property {typeof WebSocket} [WebSocket]
26
+ */
27
+
28
+ export class Agent {
29
+ /** @param {AgentOptions} opts */
30
+ constructor(opts) {
31
+ this.opts = opts;
32
+ this.code = opts.code ?? newCode();
33
+ this.WS = opts.WebSocket ?? globalThis.WebSocket;
34
+ this.keys = derive(this.code);
35
+ this.stopped = false;
36
+ this.outer = null;
37
+ }
38
+
39
+ /** The connection string clients need. */
40
+ get connString() {
41
+ return formatConnString(this.opts.relay, this.code);
42
+ }
43
+
44
+ /** Where plaintext traffic lands when `public` is set. */
45
+ async publicURL() {
46
+ return `${this.opts.relay.replace(/\/+$/, '')}/c/${(await this.keys).routingToken}/`;
47
+ }
48
+
49
+ /** Connects and keeps reconnecting until stop(). Resolves when stopped. */
50
+ async run() {
51
+ let attempt = 0;
52
+ while (!this.stopped) {
53
+ const started = Date.now();
54
+ try {
55
+ const sess = await this._connect();
56
+ attempt = 0;
57
+ this.opts.onStatus?.({ connected: true });
58
+ await Promise.race([
59
+ sess.done,
60
+ new Promise((r) => (sess.onGoAway = r)), // relay draining: redial now
61
+ ]);
62
+ if (sess.remoteGoAway && !this.stopped) continue;
63
+ throw sess.err ?? new Error('bolthole: relay connection closed');
64
+ } catch (e) {
65
+ if (this.stopped) break;
66
+ if (e.permanent) {
67
+ this.opts.onStatus?.({ connected: false, error: e });
68
+ throw e;
69
+ }
70
+ if (Date.now() - started > 30000) attempt = 0;
71
+ let delay = Math.random() * Math.min(30000, 500 * 2 ** attempt) + 250;
72
+ if (e.code === 4000) delay = 30000; // replaced by another agent with our code
73
+ attempt++;
74
+ this.opts.onStatus?.({ connected: false, error: e, retryMs: delay });
75
+ await new Promise((r) => setTimeout(r, delay));
76
+ }
77
+ }
78
+ }
79
+
80
+ stop() {
81
+ this.stopped = true;
82
+ this.outer?.goAway('going_away', 'agent shutting down');
83
+ this.outer?.close();
84
+ }
85
+
86
+ async _connect() {
87
+ const { tokenHash } = await this.keys;
88
+ const base = this.opts.relay.replace(/^http/, 'ws').replace(/\/+$/, '');
89
+ const conn = await wsConn(new this.WS(`${base}/v1/agent?th=${tokenHash}`, ['relay.v1']));
90
+ await conn.write(JSON.stringify({ type: 'hello', v: 1, token_hash: tokenHash, name: this.opts.name, auth: this.opts.auth, version: '0.1.0', caps: ['mux/1'] }));
91
+ let w;
92
+ try {
93
+ w = JSON.parse(dec.decode(await conn.read()));
94
+ } catch (e) {
95
+ if (e.code === 4002 || e.code === 4003) e.permanent = true;
96
+ throw e;
97
+ }
98
+ if (w.type === 'error') {
99
+ const err = new Error(`bolthole: relay refused registration: ${w.code}: ${w.message}`);
100
+ err.permanent = w.code === 'unauthorized' || w.code === 'invalid_token_hash';
101
+ conn.close();
102
+ throw err;
103
+ }
104
+ const sess = new Session(conn, { dialer: true, heartbeatMs: w.heartbeat_ms || 20000, onStream: (st) => this._outer(st) });
105
+ this.outer = sess;
106
+ return sess;
107
+ }
108
+
109
+ _outer(st) {
110
+ if (st.kind === 'ws' && (st.meta?.path || '').split('?')[0] === E2E_PATH) {
111
+ st.accept({}).then(() => this.serveSecure(new WsStream(st).asConn())).catch(() => st.reset('internal'));
112
+ return;
113
+ }
114
+ if (!this.opts.public) return st.reset('e2e_required', 'this agent only accepts end-to-end encrypted sessions');
115
+ this._dispatch(st, { encrypted: false, session: this.outer });
116
+ }
117
+
118
+ /** Runs the E2E handshake on any transport (relay stream or direct socket). */
119
+ async serveSecure(conn) {
120
+ const { psk } = await this.keys;
121
+ let res;
122
+ try {
123
+ res = await agentHandshake(conn, psk, (h) => {
124
+ if (!(h.protos || []).includes(PROTO_MUX)) throw new Error('no common protocol');
125
+ return { proto: PROTO_MUX, name: this.opts.name, direct: this.opts.direct };
126
+ });
127
+ } catch {
128
+ conn.close();
129
+ return;
130
+ }
131
+ const inner = new Session(res.channel, { dialer: false, maxFrame: MAX_RECORD, onStream: (st) => this._dispatch(st, { encrypted: true, session: inner }) });
132
+ this.opts.onSession?.(inner, res.hello);
133
+ await inner.done;
134
+ }
135
+
136
+ _dispatch(st, info) {
137
+ const o = this.opts;
138
+ if (st.kind === 'http' && o.fetch) return void serveHttp(st, o.fetch, info).catch(() => st.reset('internal'));
139
+ if (st.kind === 'ws' && o.websocket) {
140
+ return void st
141
+ .accept({})
142
+ .then(() => o.websocket(new WsStream(st), st.meta || {}, info))
143
+ .catch(() => st.reset('internal'));
144
+ }
145
+ const h = o.kinds?.[st.kind];
146
+ if (h) return void Promise.resolve()
147
+ .then(() => h(st, info))
148
+ .catch(() => st.reset('internal'));
149
+ st.reset('unsupported_kind', st.kind);
150
+ }
151
+ }
152
+
153
+ /** Shorthand: start an agent and return it. */
154
+ export function serve(opts) {
155
+ const a = new Agent(opts);
156
+ a.running = a.run();
157
+ a.running.catch(() => {});
158
+ return a;
159
+ }
160
+
161
+ export { enc };
package/src/bytes.js ADDED
@@ -0,0 +1,93 @@
1
+ // Small byte helpers shared by every module. No dependencies.
2
+
3
+ export const enc = new TextEncoder();
4
+ export const dec = new TextDecoder();
5
+
6
+ /** @param {...Uint8Array} parts */
7
+ export function concat(...parts) {
8
+ let n = 0;
9
+ for (const p of parts) n += p.length;
10
+ const out = new Uint8Array(n);
11
+ let o = 0;
12
+ for (const p of parts) {
13
+ out.set(p, o);
14
+ o += p.length;
15
+ }
16
+ return out;
17
+ }
18
+
19
+ export function b64url(bytes) {
20
+ let s = '';
21
+ for (const b of bytes) s += String.fromCharCode(b);
22
+ return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
23
+ }
24
+
25
+ export function unb64url(s) {
26
+ const t = s.replace(/-/g, '+').replace(/_/g, '/');
27
+ const bin = atob(t + '='.repeat((4 - (t.length % 4)) % 4));
28
+ const out = new Uint8Array(bin.length);
29
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
30
+ return out;
31
+ }
32
+
33
+ export function hex(bytes) {
34
+ let s = '';
35
+ for (const b of bytes) s += b.toString(16).padStart(2, '0');
36
+ return s;
37
+ }
38
+
39
+ export function u32(n) {
40
+ const b = new Uint8Array(4);
41
+ new DataView(b.buffer).setUint32(0, n >>> 0);
42
+ return b;
43
+ }
44
+
45
+ export function readU32(b, off = 0) {
46
+ return new DataView(b.buffer, b.byteOffset, b.byteLength).getUint32(off);
47
+ }
48
+
49
+ /** Normalise ArrayBuffer / views / Node Buffers to a plain Uint8Array. */
50
+ export function toBytes(x) {
51
+ if (x instanceof Uint8Array) return x;
52
+ if (x instanceof ArrayBuffer) return new Uint8Array(x);
53
+ if (ArrayBuffer.isView(x)) return new Uint8Array(x.buffer, x.byteOffset, x.byteLength);
54
+ if (typeof x === 'string') return enc.encode(x);
55
+ throw new TypeError('expected bytes');
56
+ }
57
+
58
+ /** A promise you can resolve from outside. */
59
+ export function deferred() {
60
+ let resolve, reject;
61
+ const promise = new Promise((res, rej) => {
62
+ resolve = res;
63
+ reject = rej;
64
+ });
65
+ return { promise, resolve, reject };
66
+ }
67
+
68
+ /** An unbounded async FIFO: push() items, await shift(). */
69
+ export class Queue {
70
+ constructor() {
71
+ this.items = [];
72
+ this.waiters = [];
73
+ this.error = null;
74
+ }
75
+ push(item) {
76
+ if (this.error) return;
77
+ const w = this.waiters.shift();
78
+ if (w) w.resolve(item);
79
+ else this.items.push(item);
80
+ }
81
+ fail(err) {
82
+ if (this.error) return;
83
+ this.error = err;
84
+ for (const w of this.waiters.splice(0)) w.reject(err);
85
+ }
86
+ shift() {
87
+ if (this.items.length) return Promise.resolve(this.items.shift());
88
+ if (this.error) return Promise.reject(this.error);
89
+ const d = deferred();
90
+ this.waiters.push(d);
91
+ return d.promise;
92
+ }
93
+ }
package/src/client.js ADDED
@@ -0,0 +1,167 @@
1
+ // Client: reach an agent through a relay (or directly) and talk to it with
2
+ // fetch(), WebSockets, raw streams, or your own stream kinds.
3
+ import { parseConnString, derive } from './codes.js';
4
+ import { clientHandshake, PROTO_MUX, MAX_RECORD } from './noise.js';
5
+ import { Session } from './mux.js';
6
+ import { wsConn, fetchOver, WsStream } from './kinds.js';
7
+
8
+ const E2E_PATH = '/.bolthole/e2e';
9
+
10
+ /**
11
+ * @typedef {object} ConnectOptions
12
+ * @property {string} [name] sent to the agent inside the encrypted hello
13
+ * @property {'auto'|'relay'|'direct'} [mode]
14
+ * @property {string[]} [direct] extra direct candidates (ws://host:port/.bolthole/e2e)
15
+ * @property {number} [relayDelayMs] head start for direct candidates (250)
16
+ * @property {number} [directTimeoutMs] (2000)
17
+ * @property {(path: string) => void} [onPath]
18
+ * @property {(stream: import('./mux.js').Stream) => void} [onStream] agent-opened streams
19
+ * @property {typeof WebSocket} [WebSocket] constructor to use (defaults to the global)
20
+ */
21
+
22
+ /**
23
+ * A resilient connection to one agent. Reconnects on demand and remembers
24
+ * direct candidates the agent advertises.
25
+ */
26
+ export class Client {
27
+ /** @param {string} connString @param {ConnectOptions} [opts] */
28
+ constructor(connString, opts = {}) {
29
+ this.cs = parseConnString(connString);
30
+ this.opts = { mode: 'auto', relayDelayMs: 250, directTimeoutMs: 2000, ...opts };
31
+ this.WS = opts.WebSocket ?? globalThis.WebSocket;
32
+ if (!this.WS) throw new Error('bolthole: no WebSocket implementation; pass opts.WebSocket');
33
+ this.direct = [...(opts.direct ?? [])];
34
+ this.keys = derive(this.cs.code);
35
+ this.current = null;
36
+ this.dialing = null;
37
+ this.path = '';
38
+ }
39
+
40
+ /** Returns a live session, dialing if needed. */
41
+ async session() {
42
+ if (this.current && !this.current.session.isClosed && !this.current.session.remoteGoAway) return this.current.session;
43
+ if (!this.dialing) {
44
+ this.dialing = this._race().finally(() => (this.dialing = null));
45
+ }
46
+ const c = await this.dialing;
47
+ return c.session;
48
+ }
49
+
50
+ async _race() {
51
+ const { routingToken, psk } = await this.keys;
52
+ const cands = [];
53
+ if (this.opts.mode !== 'relay') for (const url of this.direct) cands.push({ path: 'direct ' + url, url, delay: 0, timeout: this.opts.directTimeoutMs });
54
+ if (this.opts.mode !== 'direct') {
55
+ const base = this.cs.relay.replace(/^http/, 'ws');
56
+ cands.push({ path: 'relay', url: `${base}/c/${routingToken}${E2E_PATH}`, delay: cands.length ? this.opts.relayDelayMs : 0, timeout: 30000 });
57
+ }
58
+ if (!cands.length) throw new Error('bolthole: no path to the agent');
59
+
60
+ const ctrl = { done: false };
61
+ const attempts = cands.map((c) => this._dialOne(c, psk, ctrl));
62
+ let winner;
63
+ try {
64
+ winner = await Promise.any(attempts);
65
+ } catch (e) {
66
+ throw new AggregateError(e.errors, 'bolthole: could not reach the agent');
67
+ }
68
+ ctrl.done = true;
69
+ // Close any loser that also finished.
70
+ for (const a of attempts) a.then((c) => c !== winner && c.session.close(), () => {});
71
+ this._install(winner);
72
+ return winner;
73
+ }
74
+
75
+ async _dialOne(cand, psk, ctrl) {
76
+ if (cand.delay) await new Promise((r) => setTimeout(r, cand.delay));
77
+ if (ctrl.done) throw new Error('lost the race');
78
+ const ws = new this.WS(cand.url);
79
+ const timer = setTimeout(() => {
80
+ try {
81
+ ws.close();
82
+ } catch {}
83
+ }, cand.timeout);
84
+ try {
85
+ const raw = await wsConn(ws);
86
+ const { channel, hello } = await clientHandshake(raw, psk, { protos: [PROTO_MUX], name: this.opts.name || undefined });
87
+ clearTimeout(timer);
88
+ const session = new Session(channel, { dialer: true, maxFrame: MAX_RECORD, onStream: this.opts.onStream });
89
+ return { session, hello, path: cand.path };
90
+ } catch (e) {
91
+ clearTimeout(timer);
92
+ try {
93
+ ws.close();
94
+ } catch {}
95
+ throw e;
96
+ }
97
+ }
98
+
99
+ _install(c) {
100
+ const old = this.current;
101
+ this.current = c;
102
+ this.path = c.path;
103
+ for (const u of c.hello.direct ?? []) if (!this.direct.includes(u)) this.direct.push(u);
104
+ if (old) {
105
+ old.session.goAway('going_away', 'client switched path');
106
+ const drain = () => (old.session.streams.size === 0 ? old.session.close() : setTimeout(drain, 500));
107
+ drain();
108
+ }
109
+ this.opts.onPath?.(c.path);
110
+ }
111
+
112
+ /** fetch() to the agent. Only the URL's path and query are used. */
113
+ async fetch(input, init) {
114
+ return fetchOver(await this.session(), input, init);
115
+ }
116
+
117
+ /** Opens a WebSocket to `path` on the agent. */
118
+ async websocket(path, protocols = []) {
119
+ const s = await this.session();
120
+ const st = s.open('ws', { path, protocols });
121
+ const meta = await st.accepted;
122
+ const ws = new WsStream(st);
123
+ ws.protocol = meta.protocol ?? '';
124
+ return ws;
125
+ }
126
+
127
+ /** Opens a raw byte stream to a named TCP target on the agent. */
128
+ async tcp(target = '') {
129
+ const s = await this.session();
130
+ const st = s.open('tcp', { target });
131
+ await st.accepted;
132
+ return st;
133
+ }
134
+
135
+ /** Opens a stream of your own kind. Resolves after the agent accepts. */
136
+ async open(kind, meta) {
137
+ const s = await this.session();
138
+ const st = s.open(kind, meta);
139
+ st.acceptMeta = await st.accepted;
140
+ return st;
141
+ }
142
+
143
+ /** Tries direct candidates now and switches if one answers. */
144
+ async tryUpgrade() {
145
+ if (this.path !== 'relay' || !this.direct.length) return false;
146
+ const { psk } = await this.keys;
147
+ const ctrl = { done: false };
148
+ try {
149
+ const c = await Promise.any(this.direct.map((url) => this._dialOne({ path: 'direct ' + url, url, timeout: this.opts.directTimeoutMs }, psk, ctrl)));
150
+ ctrl.done = true;
151
+ this._install(c);
152
+ return true;
153
+ } catch {
154
+ return false;
155
+ }
156
+ }
157
+
158
+ close() {
159
+ this.current?.session.close();
160
+ this.current = null;
161
+ }
162
+ }
163
+
164
+ /** Shorthand: new Client(connString, opts). */
165
+ export function connect(connString, opts) {
166
+ return new Client(connString, opts);
167
+ }
package/src/codes.js ADDED
@@ -0,0 +1,63 @@
1
+ // Connect codes, routing tokens and connection strings (spec §1).
2
+ import { b64url, unb64url, hex, enc } from './bytes.js';
3
+
4
+ const SALT = enc.encode('bolthole/v1');
5
+ const subtle = globalThis.crypto.subtle;
6
+
7
+ /** A fresh random 32-byte connect code, base64url encoded. */
8
+ export function newCode() {
9
+ return b64url(crypto.getRandomValues(new Uint8Array(32)));
10
+ }
11
+
12
+ export function parseCode(s) {
13
+ const b = unb64url(String(s).trim());
14
+ if (b.length !== 32) throw new Error('bolthole: a connect code is 43 base64url characters');
15
+ return b;
16
+ }
17
+
18
+ async function hkdf(ikm, info, len) {
19
+ const key = await subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits']);
20
+ const bits = await subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt: SALT, info: enc.encode(info) }, key, len * 8);
21
+ return new Uint8Array(bits);
22
+ }
23
+
24
+ /** SHA-256 of the routing token, lowercase hex. */
25
+ export async function hashToken(token) {
26
+ return hex(new Uint8Array(await subtle.digest('SHA-256', enc.encode(token))));
27
+ }
28
+
29
+ /**
30
+ * Everything derived from a code.
31
+ * @param {Uint8Array|string} code
32
+ * @returns {Promise<{routingToken: string, tokenHash: string, psk: Uint8Array}>}
33
+ */
34
+ export async function derive(code) {
35
+ const c = typeof code === 'string' ? parseCode(code) : code;
36
+ const routingToken = b64url(await hkdf(c, 'routing-token', 24));
37
+ const psk = await hkdf(c, 'e2e-psk', 32);
38
+ return { routingToken, tokenHash: await hashToken(routingToken), psk };
39
+ }
40
+
41
+ /** "https://relay/#k=<code>" */
42
+ export function formatConnString(relay, code) {
43
+ const u = new URL(relay);
44
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('bolthole: relay URL must be http(s)');
45
+ u.hash = '';
46
+ if (!u.pathname.endsWith('/')) u.pathname += '/';
47
+ return `${u.toString()}#k=${typeof code === 'string' ? code : b64url(code)}`;
48
+ }
49
+
50
+ /**
51
+ * Parses "https://relay/#k=<code>[&name=…]".
52
+ * @returns {{relay: string, code: Uint8Array, name: string}} relay has no trailing slash.
53
+ */
54
+ export function parseConnString(s) {
55
+ const u = new URL(String(s).trim());
56
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('bolthole: connection string must start with http(s)://');
57
+ const frag = new URLSearchParams(u.hash.replace(/^#/, ''));
58
+ const code = parseCode(frag.get('k') || '');
59
+ u.hash = '';
60
+ u.search = '';
61
+ const relay = u.toString().replace(/\/+$/, '');
62
+ return { relay, code, name: frag.get('name') || '' };
63
+ }
package/src/index.js ADDED
@@ -0,0 +1,9 @@
1
+ // bolthole: reach any service from anywhere through a relay that can't read
2
+ // your traffic. This entry point works in browsers, Node 22+, Deno, Bun and
3
+ // Cloudflare Workers.
4
+ export { Client, connect } from './client.js';
5
+ export { Agent, serve } from './agent.js';
6
+ export { newCode, parseCode, derive, hashToken, formatConnString, parseConnString } from './codes.js';
7
+ export { Session, Stream, ResetError, encodeFrame, decodeFrame, T as FrameType } from './mux.js';
8
+ export { WsStream, wsConn, fetchOver, serveHttp, encodeHeaders, decodeHeaders } from './kinds.js';
9
+ export { clientHandshake, agentHandshake, Channel, PROTO_MUX } from './noise.js';