@opentunnel/client 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,95 @@
1
- # Temporary Holding Version
1
+ # OpenTunnel Client
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
+ `@opentunnel/client` is a pure TypeScript SDK for Bun that creates tunnels and
4
+ forwards them to local services from inside your process. TLS terminates in
5
+ your process, so the relay never sees plaintext or your private key.
6
+
7
+ It implements the same protocol and on-disk layout as the Rust client and CLI
8
+ (see `docs/protocol.md`), so a tunnel created by the CLI can be used here and
9
+ vice versa.
10
+
11
+ ## Quick start
12
+
13
+ ```ts
14
+ import { create } from "@opentunnel/client"
15
+
16
+ const client = create()
17
+ const connection = await client.tunnel.connect({
18
+ routes: { api: "127.0.0.1:3000" },
19
+ })
20
+ console.log(`https://api.${connection.tunnel.hostname}`)
21
+
22
+ for await (const event of connection.events) console.log(event)
23
+ ```
24
+
25
+ `connect` creates the profile's tunnel if it has none, resolves once the bridge
26
+ first attaches, and reconnects with backoff until you call `close()`. It
27
+ rejects on fatal errors such as an invalid token.
28
+
29
+ The Effect interface exposes the same capabilities, with scoped connections and
30
+ events as a `Stream`:
31
+
32
+ ```ts
33
+ import { OpenTunnelClient } from "@opentunnel/client/effect"
34
+ ```
35
+
36
+ ## Routes
37
+
38
+ Routes map a name to a `host:port` target. A name is a subdomain label, or `@`
39
+ for the tunnel hostname itself. Path routing is not supported.
40
+
41
+ ```ts
42
+ await connection.setRoutes({ api: "127.0.0.1:4000", "@": "127.0.0.1:8080" })
43
+ ```
44
+
45
+ Changing only targets applies to new connections immediately. Adding or
46
+ removing names re-attaches the bridge.
47
+
48
+ ## API
49
+
50
+ ```ts
51
+ interface Client {
52
+ profile: { list(): Promise<string[]> }
53
+ tunnel: {
54
+ list(): Promise<StoredTunnel[]>
55
+ get(options?: { profile?: string }): Promise<Identity | undefined>
56
+ pending(options?): Promise<{ id: string; hostname: string } | undefined>
57
+ create(options?: { profile?: string; onProgress?(stage): void }): Promise<Identity>
58
+ resume(options?): Promise<Identity | undefined>
59
+ ensure(options?: { profile?: string }): Promise<Identity>
60
+ remove(options?: { profile?: string }): Promise<void>
61
+ connect(options: { profile?: string; routes: Routes; signal?: AbortSignal }): Promise<Connection>
62
+ }
63
+ dispose(): Promise<void>
64
+ }
65
+
66
+ interface Connection {
67
+ tunnel: Identity
68
+ events: AsyncIterable<ClientEvent>
69
+ status(): Status
70
+ setRoutes(routes: Routes): Promise<void>
71
+ closed: Promise<void>
72
+ close(): Promise<void>
73
+ }
74
+ ```
75
+
76
+ Events are `connecting`, `connected`, `disconnected`, `reconnecting`,
77
+ `connection-opened`, `connection-closed`, and `stopped`.
78
+
79
+ ## Storage
80
+
81
+ `create()` stores identities under `$XDG_DATA_HOME/opentunnel/<profile>/`, the
82
+ same files the CLI uses. Pass a store to isolate or own persistence:
83
+
84
+ ```ts
85
+ import { create, OpenTunnelStorage } from "@opentunnel/client"
86
+
87
+ const client = create({ store: OpenTunnelStorage.memory() })
88
+ ```
89
+
90
+ ## Backpressure
91
+
92
+ The SDK stops reading from a local socket while more than 1 MiB is queued on
93
+ the bridge WebSocket, and resets a connection whose local side stops reading
94
+ for long enough to buffer 8 MiB. The protocol has no per-connection flow
95
+ control yet, so one slow public reader can delay others on the same bridge.
package/package.json CHANGED
@@ -1,6 +1,42 @@
1
1
  {
2
2
  "name": "@opentunnel/client",
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": "OpenTunnel SDK for Bun: expose local services through a blind TLS tunnel",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/anomalyco/opentunnel.git",
9
+ "directory": "packages/client"
10
+ },
11
+ "type": "module",
12
+ "files": [
13
+ "src"
14
+ ],
15
+ "exports": {
16
+ ".": "./src/promise/index.ts",
17
+ "./promise": "./src/promise/index.ts",
18
+ "./effect": "./src/effect/index.ts"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "engines": {
24
+ "bun": ">=1.4.0"
25
+ },
26
+ "scripts": {
27
+ "build": "tsc --build",
28
+ "test": "bun test"
29
+ },
30
+ "dependencies": {
31
+ "@opentunnel/protocol": "0.0.0",
32
+ "@peculiar/x509": "2.0.0",
33
+ "effect": "4.0.0-beta.42",
34
+ "reflect-metadata": "0.2.2"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "25.5.0",
38
+ "@typescript/native-preview": "7.0.0-dev.20260328.1",
39
+ "typescript": "6.0.2",
40
+ "@types/bun": "1.3.13"
41
+ }
42
+ }
@@ -0,0 +1,39 @@
1
+ import { Effect, Layer, ServiceMap } from "effect";
2
+ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
3
+ import { HttpApiClient } from "effect/unstable/httpapi";
4
+ import { Api } from "@opentunnel/protocol/api/api";
5
+ import { Tunnel } from "@opentunnel/protocol/tunnel";
6
+
7
+ type Client = HttpApiClient.ForApi<typeof Api>;
8
+
9
+ interface OpenTunnelApi {
10
+ readonly client: Client;
11
+ readonly authorized: (token: Tunnel.Token) => Effect.Effect<Client>;
12
+ }
13
+
14
+ export class OpenTunnelApiClient extends ServiceMap.Service<OpenTunnelApiClient, OpenTunnelApi>()(
15
+ "@opentunnel/client/OpenTunnelApiClient",
16
+ ) {
17
+ static layer(options: { readonly api: URL | string }) {
18
+ return Layer.effect(
19
+ OpenTunnelApiClient,
20
+ Effect.gen(function* () {
21
+ const httpClient = yield* HttpClient.HttpClient;
22
+ const client = yield* HttpApiClient.makeWith(Api, {
23
+ baseUrl: options.api,
24
+ httpClient,
25
+ });
26
+ return {
27
+ client,
28
+ authorized: (token) =>
29
+ HttpApiClient.makeWith(Api, {
30
+ baseUrl: options.api,
31
+ httpClient: httpClient.pipe(
32
+ HttpClient.mapRequest(HttpClientRequest.bearerToken(token)),
33
+ ),
34
+ }),
35
+ };
36
+ }),
37
+ ).pipe(Layer.provide(FetchHttpClient.layer));
38
+ }
39
+ }
@@ -0,0 +1,315 @@
1
+ import "reflect-metadata";
2
+ import { Cause, Effect, Layer, Queue, ServiceMap, Stream } from "effect";
3
+ import {
4
+ Pkcs10CertificateRequestGenerator,
5
+ SubjectAlternativeNameExtension,
6
+ } from "@peculiar/x509";
7
+ import { CSR } from "@opentunnel/protocol/csr";
8
+ import { Tunnel } from "@opentunnel/protocol/tunnel";
9
+ import { OpenTunnelApiClient } from "./api.js";
10
+ import { OpenTunnelClientError } from "./errors.js";
11
+ import { OpenTunnelStorage, type OpenTunnelStorage as Storage } from "./storage.js";
12
+ import type {
13
+ OpenTunnelClientEvent,
14
+ OpenTunnelConnection,
15
+ OpenTunnelEffectClient,
16
+ OpenTunnelIdentity,
17
+ OpenTunnelPendingIdentity,
18
+ OpenTunnelProfileOptions,
19
+ OpenTunnelProvisionStage,
20
+ } from "./types.js";
21
+ import { Tunnel as RunningTunnel, validateRoutes } from "./tunnel.js";
22
+
23
+ const profileName = (options?: OpenTunnelProfileOptions) => options?.profile ?? "default";
24
+ const clientError = (message: string, cause: unknown) =>
25
+ new OpenTunnelClientError({ message, cause });
26
+
27
+ const privateKeyPem = (buffer: ArrayBuffer): string => {
28
+ const bytes = new Uint8Array(buffer);
29
+ let binary = "";
30
+ for (const byte of bytes) binary += String.fromCharCode(byte);
31
+ const body = btoa(binary);
32
+ return `-----BEGIN PRIVATE KEY-----\n${body.match(/.{1,64}/g)?.join("\n") ?? body}\n-----END PRIVATE KEY-----\n`;
33
+ };
34
+
35
+ export interface OpenTunnelClientOptions {
36
+ readonly api?: URL | string;
37
+ readonly storage?: Storage;
38
+ }
39
+
40
+ export class OpenTunnelClient extends ServiceMap.Service<
41
+ OpenTunnelClient,
42
+ OpenTunnelEffectClient
43
+ >()("@opentunnel/client/OpenTunnelClient") {
44
+ static layer(options: OpenTunnelClientOptions = {}) {
45
+ const storage = options.storage ?? OpenTunnelStorage.xdg();
46
+ return Layer.effect(
47
+ OpenTunnelClient,
48
+ Effect.gen(function* () {
49
+ const api = yield* OpenTunnelApiClient;
50
+ const apiUrl = new URL(options.api ?? "https://opentunnel.xyz");
51
+
52
+ const get = Effect.fn("OpenTunnelClient.tunnel.get")(function* (
53
+ input?: OpenTunnelProfileOptions,
54
+ ) {
55
+ return yield* storage.load(profileName(input));
56
+ });
57
+
58
+ const completePending = Effect.fn("OpenTunnelClient.tunnel.completePending")(function* (options: {
59
+ readonly profile: string;
60
+ readonly pending: OpenTunnelPendingIdentity;
61
+ readonly onProgress?: (stage: OpenTunnelProvisionStage) => void;
62
+ }) {
63
+ const authorized = yield* api.authorized(Tunnel.Token.makeUnsafe(options.pending.token));
64
+ yield* Effect.sync(() => options.onProgress?.("requesting-certificate"));
65
+ yield* authorized.tunnel["tunnel.bindCertificate"]({
66
+ params: { id: Tunnel.ID.makeUnsafe(options.pending.id) },
67
+ payload: { csr: options.pending.csr as CSR.Raw },
68
+ }).pipe(
69
+ // A resumed provision may already have an issuance in flight.
70
+ Effect.catchTag("CertificateInProgressError", () => Effect.void),
71
+ Effect.mapError((cause) => clientError("Failed to start certificate issuance", cause)),
72
+ );
73
+
74
+ const certificate = yield* Effect.gen(function* () {
75
+ while (true) {
76
+ yield* Effect.sync(() => options.onProgress?.("waiting-certificate"));
77
+ const value = yield* authorized.tunnel["tunnel.getCertificate"]({
78
+ params: { id: Tunnel.ID.makeUnsafe(options.pending.id) },
79
+ }).pipe(
80
+ Effect.mapError((cause) => clientError("Failed to read certificate", cause)),
81
+ );
82
+ if (value.state.type === "ready") return value.state;
83
+ if (value.state.type === "failed") {
84
+ return yield* new OpenTunnelClientError({
85
+ message: `Certificate issuance failed: ${value.state.reason}`,
86
+ });
87
+ }
88
+ yield* Effect.sleep("2 seconds");
89
+ }
90
+ });
91
+ const identity: OpenTunnelIdentity = {
92
+ id: options.pending.id,
93
+ hostname: options.pending.hostname,
94
+ token: options.pending.token,
95
+ privateKey: options.pending.privateKey,
96
+ certificate: certificate.certificate,
97
+ chain: certificate.chain,
98
+ certificateExpiry: new Date(certificate.expiry),
99
+ };
100
+ yield* Effect.sync(() => options.onProgress?.("saving-identity"));
101
+ yield* storage.save(options.profile, identity);
102
+ yield* Effect.sync(() => options.onProgress?.("ready"));
103
+ return identity;
104
+ });
105
+
106
+ const provision = Effect.fn("OpenTunnelClient.tunnel.provision")(function* (options: {
107
+ readonly profile: string;
108
+ readonly id: Tunnel.ID;
109
+ readonly hostname: string;
110
+ readonly token: Tunnel.Token;
111
+ readonly onProgress?: (stage: OpenTunnelProvisionStage) => void;
112
+ }) {
113
+ yield* Effect.sync(() => options.onProgress?.("generating-key"));
114
+ const keys = yield* Effect.tryPromise({
115
+ try: () =>
116
+ crypto.subtle.generateKey(
117
+ { name: "ECDSA", namedCurve: "P-256" },
118
+ true,
119
+ ["sign", "verify"],
120
+ ) as Promise<CryptoKeyPair>,
121
+ catch: (cause) => clientError("Failed to generate certificate key", cause),
122
+ });
123
+ yield* Effect.sync(() => options.onProgress?.("generating-csr"));
124
+ const csr = yield* Effect.tryPromise({
125
+ try: () =>
126
+ Pkcs10CertificateRequestGenerator.create({
127
+ name: `CN=${options.hostname}`,
128
+ extensions: [
129
+ new SubjectAlternativeNameExtension([
130
+ { type: "dns", value: options.hostname },
131
+ { type: "dns", value: `*.${options.hostname}` },
132
+ ]),
133
+ ],
134
+ signingAlgorithm: { name: "ECDSA", hash: "SHA-256" },
135
+ keys,
136
+ }),
137
+ catch: (cause) => clientError("Failed to generate certificate request", cause),
138
+ });
139
+ const exported = yield* Effect.tryPromise({
140
+ try: () => crypto.subtle.exportKey("pkcs8", keys.privateKey),
141
+ catch: (cause) => clientError("Failed to export certificate key", cause),
142
+ });
143
+ const pending: OpenTunnelPendingIdentity = {
144
+ id: String(options.id),
145
+ hostname: options.hostname,
146
+ token: options.token,
147
+ privateKey: privateKeyPem(exported),
148
+ csr: csr.toString(),
149
+ };
150
+ yield* storage.savePending(options.profile, pending);
151
+ return yield* completePending({
152
+ profile: options.profile,
153
+ pending,
154
+ onProgress: options.onProgress,
155
+ });
156
+ });
157
+
158
+ const create = Effect.fn("OpenTunnelClient.tunnel.create")(function* (
159
+ input?: OpenTunnelProfileOptions & {
160
+ readonly onProgress?: (stage: OpenTunnelProvisionStage) => void;
161
+ },
162
+ ) {
163
+ const profile = profileName(input);
164
+ if (yield* storage.load(profile)) {
165
+ return yield* new OpenTunnelClientError({
166
+ message: `Profile '${profile}' already has a tunnel`,
167
+ });
168
+ }
169
+
170
+ const pending = yield* storage.loadPending(profile);
171
+ if (pending) {
172
+ yield* Effect.sync(() => input?.onProgress?.("resuming-certificate"));
173
+ return yield* completePending({ profile, pending, onProgress: input?.onProgress });
174
+ }
175
+
176
+ yield* Effect.sync(() => input?.onProgress?.("creating-tunnel"));
177
+ const created = yield* api.client.tunnel["tunnel.create"]({
178
+ payload: {},
179
+ }).pipe(Effect.mapError((cause) => clientError("Failed to create tunnel", cause)));
180
+ return yield* provision({
181
+ profile,
182
+ id: created.tunnel.id,
183
+ hostname: String(created.tunnel.hostname),
184
+ token: created.token,
185
+ onProgress: input?.onProgress,
186
+ });
187
+ });
188
+
189
+ const ensure = Effect.fn("OpenTunnelClient.tunnel.ensure")(function* (
190
+ input?: OpenTunnelProfileOptions,
191
+ ) {
192
+ const existing = yield* get(input);
193
+ if (!existing) return yield* create(input);
194
+ const authorized = yield* api.authorized(Tunnel.Token.makeUnsafe(existing.token));
195
+ const certificate = yield* authorized.tunnel["tunnel.getCertificate"]({
196
+ params: { id: Tunnel.ID.makeUnsafe(existing.id) },
197
+ }).pipe(
198
+ Effect.mapError((cause) => clientError("Failed to read certificate", cause)),
199
+ );
200
+ const state = certificate.state;
201
+ // The server renewed the certificate while this machine was offline.
202
+ if (state.type === "ready" && state.certificate !== existing.certificate) {
203
+ const renewed: OpenTunnelIdentity = {
204
+ ...existing,
205
+ certificate: state.certificate,
206
+ chain: state.chain,
207
+ certificateExpiry: new Date(state.expiry),
208
+ };
209
+ yield* storage.save(profileName(input), renewed);
210
+ return renewed;
211
+ }
212
+ if (state.type !== "failed") return existing;
213
+ return yield* provision({
214
+ profile: profileName(input),
215
+ id: Tunnel.ID.makeUnsafe(existing.id),
216
+ hostname: existing.hostname,
217
+ token: Tunnel.Token.makeUnsafe(existing.token),
218
+ });
219
+ });
220
+
221
+ const pending = Effect.fn("OpenTunnelClient.tunnel.pending")(function* (
222
+ input?: OpenTunnelProfileOptions,
223
+ ) {
224
+ const value = yield* storage.loadPending(profileName(input));
225
+ return value ? { id: value.id, hostname: value.hostname } : undefined;
226
+ });
227
+
228
+ const resume = Effect.fn("OpenTunnelClient.tunnel.resume")(function* (
229
+ input?: OpenTunnelProfileOptions & {
230
+ readonly onProgress?: (stage: OpenTunnelProvisionStage) => void;
231
+ },
232
+ ) {
233
+ const profile = profileName(input);
234
+ const value = yield* storage.loadPending(profile);
235
+ if (!value) return undefined;
236
+ yield* Effect.sync(() => input?.onProgress?.("resuming-certificate"));
237
+ return yield* completePending({ profile, pending: value, onProgress: input?.onProgress });
238
+ });
239
+
240
+ const client: OpenTunnelEffectClient = {
241
+ profile: { list: storage.profiles },
242
+ tunnel: {
243
+ list: storage.list,
244
+ get,
245
+ pending,
246
+ resume,
247
+ create,
248
+ ensure,
249
+ remove: Effect.fn("OpenTunnelClient.tunnel.remove")(function* (input) {
250
+ const profile = profileName(input);
251
+ const identity = yield* storage.load(profile);
252
+ if (!identity) return;
253
+ const authorized = yield* api.authorized(Tunnel.Token.makeUnsafe(identity.token));
254
+ yield* authorized.tunnel["tunnel.remove"]({
255
+ params: { id: Tunnel.ID.makeUnsafe(identity.id) },
256
+ }).pipe(
257
+ Effect.mapError((cause) => clientError("Failed to remove tunnel", cause)),
258
+ );
259
+ yield* storage.remove(profile);
260
+ }),
261
+ connect: Effect.fn("OpenTunnelClient.tunnel.connect")(function* (input) {
262
+ yield* Effect.try({
263
+ try: () => validateRoutes(input.routes),
264
+ catch: (cause) => clientError(cause instanceof Error ? cause.message : "Invalid routes", cause),
265
+ });
266
+ const identity = yield* ensure(input);
267
+ const events = yield* Queue.unbounded<OpenTunnelClientEvent, Cause.Done>();
268
+ const tunnel = yield* Effect.acquireRelease(
269
+ Effect.sync(() =>
270
+ new RunningTunnel({
271
+ api: apiUrl,
272
+ identity,
273
+ routes: input.routes,
274
+ onEvent: (event) => {
275
+ Queue.offerUnsafe(events, event);
276
+ if (event.type === "stopped") Queue.endUnsafe(events);
277
+ },
278
+ onRenewed: (renewed) => {
279
+ Effect.runFork(storage.save(profileName(input), renewed).pipe(Effect.ignore));
280
+ },
281
+ })
282
+ ),
283
+ (tunnel) =>
284
+ Effect.promise(() => tunnel.close()).pipe(Effect.andThen(Queue.end(events))),
285
+ );
286
+ yield* Effect.tryPromise({
287
+ try: () => tunnel.ready,
288
+ catch: (cause) => clientError("Failed to connect tunnel", cause),
289
+ });
290
+ return {
291
+ tunnel: identity,
292
+ events: Stream.fromQueue(events),
293
+ status: Effect.sync(() => tunnel.getStatus()),
294
+ setRoutes: (routes) =>
295
+ Effect.try({
296
+ try: () => tunnel.setRoutes(routes),
297
+ catch: (cause) =>
298
+ clientError(cause instanceof Error ? cause.message : "Invalid routes", cause),
299
+ }),
300
+ closed: Effect.tryPromise({
301
+ try: () => tunnel.closed,
302
+ catch: (cause) => clientError("Tunnel stopped", cause),
303
+ }),
304
+ close: Effect.promise(() => tunnel.close()),
305
+ } satisfies OpenTunnelConnection;
306
+ }),
307
+ },
308
+ };
309
+ return client;
310
+ }),
311
+ ).pipe(
312
+ Layer.provide(OpenTunnelApiClient.layer({ api: options.api ?? "https://opentunnel.xyz" })),
313
+ );
314
+ }
315
+ }
@@ -0,0 +1,13 @@
1
+ import { Schema } from "effect";
2
+
3
+ export class OpenTunnelClientError extends Schema.TaggedErrorClass<OpenTunnelClientError>()(
4
+ "OpenTunnelClientError",
5
+ { message: Schema.String, cause: Schema.optional(Schema.Defect) },
6
+ ) {}
7
+
8
+ export class OpenTunnelStorageError extends Schema.TaggedErrorClass<OpenTunnelStorageError>()(
9
+ "OpenTunnelStorageError",
10
+ { message: Schema.String, cause: Schema.optional(Schema.Defect) },
11
+ ) {}
12
+
13
+ export type OpenTunnelError = OpenTunnelClientError | OpenTunnelStorageError;
@@ -0,0 +1,6 @@
1
+ export * from "./client.js";
2
+ export * from "./api.js";
3
+ export * from "./tunnel.js";
4
+ export * from "./errors.js";
5
+ export * from "./storage.js";
6
+ export * from "./types.js";