Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 17 additions & 13 deletions packages/cli/src/services/remote-tunnel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ import { Cause, Effect, Schedule } from "effect"
import { EOL } from "os"
import { OPENCODE_CHANNEL } from "../version"

// Each channel runs its own service, and an OpenTunnel profile holds one tunnel whose routes one bridge
// may claim at a time, so channels get separate profiles.
const profile =
OPENCODE_CHANNEL === "latest" ? "opencode" : `opencode-${OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")}`
// OpenTunnel keeps one tunnel per device in its default profile, shared by the opentunnel CLI and every app
// using the SDK; each claims its own routes. The service claims a subdomain rather than the tunnel hostname,
// which the CLI may route, and each channel's service gets its own subdomain.
export function route(channel = OPENCODE_CHANNEL) {
if (channel === "latest") return "opencode"
return `opencode-${channel.toLowerCase().replace(/[^a-z0-9-]/g, "-")}`.slice(0, 63).replace(/-+$/, "")
}

// Holds the tunnel for the life of the service. The SDK reconnects through network failures itself, so only
// Holds the route for the life of the service. The SDK reconnects through network failures itself, so only
// setup failures reach the retry here; a rejected token or failed certificate stops it for good.
export const run = Effect.fnUntraced(function* (input: {
readonly target: string
Expand All @@ -22,8 +25,8 @@ export const run = Effect.fnUntraced(function* (input: {
(error.cause instanceof OpenTunnelAttachError || error.message.startsWith("Certificate issuance failed"))
yield* Effect.gen(function* () {
const client = yield* OpenTunnelClient
const connection = yield* client.tunnel.connect({ profile, routes: { "@": input.target } })
input.onURL(`https://${connection.tunnel.hostname}`)
const connection = yield* client.tunnel.connect({ routes: { [route()]: input.target } })
input.onURL(`https://${route()}.${connection.tunnel.hostname}`)
yield* connection.closed
}).pipe(
Effect.scoped,
Expand All @@ -44,15 +47,15 @@ export const run = Effect.fnUntraced(function* (input: {
)
})

// Creating a tunnel waits for certificate issuance, so do it in the foreground when remote access is enabled;
// the service then only reattaches on start. An interrupted issuance resumes on the next attempt.
// A device without a tunnel creates its shared one here, in the foreground, because issuing the certificate
// takes a while; the service then only attaches its route on start. An interrupted issuance resumes next time.
export const ensure = Effect.fnUntraced(function* () {
const { OpenTunnelClient } = yield* Effect.promise(() => import("@opentunnel/client/effect"))
return yield* Effect.gen(function* () {
const client = yield* OpenTunnelClient
if ((yield* client.tunnel.get({ profile })) === undefined)
process.stderr.write("Creating the remote access tunnel; this can take a minute..." + EOL)
return (yield* client.tunnel.ensure({ profile })).hostname
if ((yield* client.tunnel.get()) === undefined)
process.stderr.write("Setting up remote access; this can take a minute..." + EOL)
return `${route()}.${(yield* client.tunnel.ensure()).hostname}`
}).pipe(
Effect.provide(OpenTunnelClient.layer()),
Effect.timeoutOrElse({
Expand All @@ -65,5 +68,6 @@ export const ensure = Effect.fnUntraced(function* () {
// The tunnel hostname is persisted once the certificate is ready, so this is undefined until then.
export const hostname = Effect.fnUntraced(function* () {
const { OpenTunnelStorage } = yield* Effect.promise(() => import("@opentunnel/client/effect"))
return (yield* OpenTunnelStorage.xdg().load(profile))?.hostname
const identity = yield* OpenTunnelStorage.xdg().load("default")
return identity === undefined ? undefined : `${route()}.${identity.hostname}`
})
13 changes: 13 additions & 0 deletions packages/cli/test/remote-tunnel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { validateRoutes } from "@opentunnel/client/effect"
import { expect, test } from "bun:test"
import { RemoteTunnel } from "../src/services/remote-tunnel"

test("each channel serves remote access on its own valid subdomain of the shared tunnel", () => {
const channels = ["latest", "dev", "beta", "Preview/Feature_X.1", "a".repeat(80), "trailing-"]
const routes = channels.map((channel) => RemoteTunnel.route(channel))

expect(routes.slice(0, 3)).toEqual(["opencode", "opencode-dev", "opencode-beta"])
expect(new Set(routes).size).toBe(routes.length)
// The SDK rejects invalid route names, which would keep the service from ever attaching.
expect(() => validateRoutes(Object.fromEntries(routes.map((name) => [name, "127.0.0.1:4096"])))).not.toThrow()
})
Loading