feat(server): expose browser transport
This commit is contained in:
parent
aaadc40fbc
commit
57c939f188
15 changed files with 2236 additions and 208 deletions
105
packages/server/test/browser-tunnel.test.ts
Normal file
105
packages/server/test/browser-tunnel.test.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { BrowserHost } from "@opencode-ai/core/browser-host"
|
||||
import { BrowserTunnelProtocol } from "@opencode-ai/protocol/browser-tunnel"
|
||||
import { Browser } from "@opencode-ai/schema/browser"
|
||||
import { BrowserTunnel } from "@opencode-ai/schema/browser-tunnel"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Option, Queue } from "effect"
|
||||
import { Socket } from "effect/unstable/socket"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { BrowserTunnelServer } from "../src/browser-tunnel"
|
||||
|
||||
const sessionID = Session.ID.make("ses_pending_tunnel")
|
||||
const leaseID = Browser.LeaseID.make("brl_pendingtunnel")
|
||||
const state: Browser.State = {
|
||||
url: "https://example.com/",
|
||||
title: "Example",
|
||||
loading: false,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
generation: 1,
|
||||
}
|
||||
const end = Symbol("end")
|
||||
|
||||
const makeSocket = Effect.gen(function* () {
|
||||
const inbound = yield* Queue.unbounded<string | Uint8Array | typeof end>()
|
||||
const outbound = yield* Queue.unbounded<string | Uint8Array | Socket.CloseEvent>()
|
||||
return {
|
||||
inbound,
|
||||
outbound,
|
||||
socket: Socket.make({
|
||||
runRaw: (handler, options) =>
|
||||
Effect.gen(function* () {
|
||||
if (options?.onOpen) yield* options.onOpen
|
||||
while (true) {
|
||||
const message = yield* Queue.take(inbound)
|
||||
if (message === end) return
|
||||
const handled = handler(message)
|
||||
if (Effect.isEffect(handled)) yield* Effect.asVoid(handled)
|
||||
}
|
||||
}),
|
||||
writer: Effect.succeed((message) => Queue.offer(outbound, message).pipe(Effect.asVoid)),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const cancellationCase = (cause: "reader" | "lease") =>
|
||||
Effect.gen(function* () {
|
||||
const revoked = yield* Deferred.make<void>()
|
||||
const started = yield* Deferred.make<void>()
|
||||
const cancelled = yield* Deferred.make<void>()
|
||||
const browser = BrowserHost.Service.of({
|
||||
claim: Effect.die("unused"),
|
||||
lease: () =>
|
||||
Effect.succeed(
|
||||
Option.some({
|
||||
id: leaseID,
|
||||
sessionID,
|
||||
state,
|
||||
revoked: Deferred.await(revoked),
|
||||
request: () => Effect.die("unused"),
|
||||
}),
|
||||
),
|
||||
shutdown: Effect.void,
|
||||
})
|
||||
const tunnels = yield* BrowserTunnelServer.make(() =>
|
||||
Deferred.succeed(started, undefined).pipe(
|
||||
Effect.andThen(Effect.never),
|
||||
Effect.onInterrupt(() => Deferred.succeed(cancelled, undefined).pipe(Effect.asVoid)),
|
||||
),
|
||||
).pipe(Effect.provideService(BrowserHost.Service, browser))
|
||||
const connection = yield* tunnels.acquire
|
||||
const transport = yield* makeSocket
|
||||
const running = yield* Effect.scoped(connection.run(transport.socket)).pipe(Effect.forkChild)
|
||||
|
||||
const ready = yield* Queue.take(transport.outbound)
|
||||
const frame = ready instanceof Uint8Array ? ready : yield* Effect.die("expected tunnel ready frame")
|
||||
expect(yield* BrowserTunnelProtocol.decodeFromServer(frame)).toEqual({
|
||||
type: "control",
|
||||
message: { type: "browser.tunnel.ready" },
|
||||
})
|
||||
yield* Queue.offer(
|
||||
transport.inbound,
|
||||
BrowserTunnelProtocol.encodeFromDesktop({
|
||||
type: "browser.tunnel.open",
|
||||
sessionID,
|
||||
leaseID,
|
||||
target: { host: BrowserTunnel.Host.make("target.example"), port: BrowserTunnel.Port.make(443) },
|
||||
receiveWindow: BrowserTunnel.WindowSize.make(BrowserTunnelProtocol.InitialWindowBytes),
|
||||
receiveFrames: BrowserTunnel.FrameWindow.make(BrowserTunnelProtocol.InitialFrameWindow),
|
||||
}),
|
||||
)
|
||||
yield* Deferred.await(started)
|
||||
|
||||
if (cause === "reader") yield* Queue.offer(transport.inbound, end)
|
||||
if (cause === "lease") yield* Deferred.succeed(revoked, undefined)
|
||||
|
||||
yield* Deferred.await(cancelled)
|
||||
yield* Fiber.join(running)
|
||||
})
|
||||
|
||||
describe("BrowserTunnelServer", () => {
|
||||
it.effect("interrupts a pending target dial when the reader ends or the lease is revoked", () =>
|
||||
Effect.forEach(["reader", "lease"] as const, cancellationCase, { discard: true }),
|
||||
)
|
||||
})
|
||||
468
packages/server/test/browser.test.ts
Normal file
468
packages/server/test/browser.test.ts
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
import { NodeSocket } from "@effect/platform-node"
|
||||
import { Browser } from "@opencode-ai/schema/browser"
|
||||
import { BrowserControl } from "@opencode-ai/schema/browser-control"
|
||||
import { BrowserTunnel } from "@opencode-ai/schema/browser-tunnel"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { BROWSER_CONTROL_PROTOCOL, BROWSER_TUNNEL_PROTOCOL } from "@opencode-ai/protocol/groups/browser"
|
||||
import { BrowserTunnelProtocol } from "@opencode-ai/protocol/browser-tunnel"
|
||||
import { expect } from "bun:test"
|
||||
import { Deferred, Effect, Exit, Fiber, Queue, Schema, Scope } from "effect"
|
||||
import { TestConsole } from "effect/testing"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { randomBytes } from "node:crypto"
|
||||
import { request } from "node:http"
|
||||
import { createServer } from "node:net"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerProcess } from "../src/process"
|
||||
|
||||
const authorization = `Basic ${Buffer.from("opencode:secret").toString("base64")}`
|
||||
const encodeControl = Schema.encodeSync(Schema.fromJsonString(BrowserControl.FromDesktop))
|
||||
const decodeControl = Schema.decodeUnknownSync(Schema.fromJsonString(BrowserControl.FromServer))
|
||||
|
||||
const startServer = Effect.fn("BrowserServerTest.startServer")(function* () {
|
||||
const server = yield* ServerProcess.start<never, never>({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
password: "secret",
|
||||
app: { version: "test-version" },
|
||||
database: { path: ":memory:" },
|
||||
})
|
||||
const url = new URL(HttpServer.formatAddress(server.address))
|
||||
url.protocol = "ws:"
|
||||
return url
|
||||
})
|
||||
|
||||
const open = Effect.fn("BrowserServerTest.open")(function* (
|
||||
url: URL,
|
||||
protocol: string,
|
||||
headers: Record<string, string> = { authorization },
|
||||
) {
|
||||
const messages = yield* Queue.unbounded<{ readonly data: string | Uint8Array; readonly binary: boolean }, Error>()
|
||||
const closed = yield* Deferred.make<{ readonly code: number; readonly reason: string }>()
|
||||
const webSocket = yield* Effect.acquireRelease(
|
||||
Effect.callback<NodeSocket.NodeWS.WebSocket, Error>((resume) => {
|
||||
const webSocket = new NodeSocket.NodeWS.WebSocket(url, protocol, { headers })
|
||||
webSocket.on("message", (data, binary) =>
|
||||
Queue.offerUnsafe(messages, { data: binary ? bytes(data) : Buffer.from(bytes(data)).toString(), binary }),
|
||||
)
|
||||
webSocket.once("close", (code, reason) =>
|
||||
Deferred.doneUnsafe(closed, Effect.succeed({ code, reason: reason.toString() })),
|
||||
)
|
||||
const onOpen = () => {
|
||||
webSocket.off("error", onError)
|
||||
resume(Effect.succeed(webSocket))
|
||||
}
|
||||
const onError = (error: Error) => {
|
||||
webSocket.off("open", onOpen)
|
||||
resume(Effect.fail(error))
|
||||
}
|
||||
webSocket.once("open", onOpen)
|
||||
webSocket.once("error", onError)
|
||||
return Effect.sync(() => webSocket.terminate())
|
||||
}),
|
||||
(webSocket) => Effect.sync(() => webSocket.terminate()),
|
||||
)
|
||||
return { webSocket, messages, closed }
|
||||
})
|
||||
|
||||
const upgradeStatus = (url: URL, protocol: string, headers: Record<string, string> = { authorization }) =>
|
||||
Effect.callback<number, Error>((resume) => {
|
||||
const target = new URL(url)
|
||||
target.protocol = "http:"
|
||||
const upgrade = request(target, {
|
||||
headers: {
|
||||
...headers,
|
||||
connection: "Upgrade",
|
||||
upgrade: "websocket",
|
||||
"sec-websocket-key": randomBytes(16).toString("base64"),
|
||||
"sec-websocket-protocol": protocol,
|
||||
"sec-websocket-version": "13",
|
||||
},
|
||||
})
|
||||
upgrade.once("response", (response) => {
|
||||
resume(Effect.succeed(response.statusCode ?? 0))
|
||||
response.destroy()
|
||||
})
|
||||
upgrade.once("upgrade", (response, socket) => {
|
||||
resume(Effect.succeed(response.statusCode ?? 101))
|
||||
socket.destroy()
|
||||
})
|
||||
upgrade.once("error", (error) => resume(Effect.fail(error)))
|
||||
upgrade.end()
|
||||
return Effect.sync(() => upgrade.destroy())
|
||||
})
|
||||
|
||||
const next = (webSocket: Effect.Success<ReturnType<typeof open>>) => Queue.take(webSocket.messages)
|
||||
|
||||
const exchange = Effect.fn("BrowserServerTest.exchange")(function* (
|
||||
webSocket: Effect.Success<ReturnType<typeof open>>,
|
||||
message: string | Uint8Array,
|
||||
) {
|
||||
yield* Effect.callback<void, Error>((resume) =>
|
||||
webSocket.webSocket.send(message, { binary: typeof message !== "string" }, (error) =>
|
||||
resume(error ? Effect.fail(error) : Effect.void),
|
||||
),
|
||||
)
|
||||
return yield* next(webSocket)
|
||||
})
|
||||
|
||||
function bytes(data: NodeSocket.NodeWS.RawData) {
|
||||
if (data instanceof ArrayBuffer) return new Uint8Array(data)
|
||||
if (Array.isArray(data)) return new Uint8Array(Buffer.concat(data))
|
||||
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
|
||||
}
|
||||
|
||||
const tunnelControl = Effect.fn("BrowserServerTest.tunnelControl")(function* (frame: {
|
||||
readonly data: string | Uint8Array
|
||||
}) {
|
||||
const decoded = yield* BrowserTunnelProtocol.decodeFromServer(frame.data)
|
||||
if (decoded.type !== "control") throw new Error("expected browser tunnel control frame")
|
||||
return decoded.message
|
||||
})
|
||||
|
||||
const echoServer = Effect.gen(function* () {
|
||||
const connected = yield* Deferred.make<void>()
|
||||
const ended = yield* Deferred.make<void>()
|
||||
const sentEnd = yield* Deferred.make<void>()
|
||||
const sockets = new Set<import("node:net").Socket>()
|
||||
const server = yield* Effect.acquireRelease(
|
||||
Effect.callback<ReturnType<typeof createServer>, Error>((resume) => {
|
||||
const server = createServer({ allowHalfOpen: true }, (socket) => {
|
||||
sockets.add(socket)
|
||||
Deferred.doneUnsafe(connected, Effect.void)
|
||||
socket.on("data", (data) => socket.write(data))
|
||||
socket.on("end", () => {
|
||||
Deferred.doneUnsafe(ended, Effect.void)
|
||||
socket.end(() => Deferred.doneUnsafe(sentEnd, Effect.void))
|
||||
})
|
||||
socket.once("close", () => sockets.delete(socket))
|
||||
})
|
||||
server.once("error", (error) => resume(Effect.fail(error)))
|
||||
server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server)))
|
||||
return Effect.sync(() => server.close())
|
||||
}),
|
||||
(server) =>
|
||||
Effect.sync(() => {
|
||||
for (const socket of sockets) socket.destroy()
|
||||
server.close()
|
||||
}),
|
||||
)
|
||||
return { server, connected, ended, sentEnd }
|
||||
})
|
||||
|
||||
const pushServer = Effect.acquireRelease(
|
||||
Effect.callback<ReturnType<typeof createServer>, Error>((resume) => {
|
||||
const server = createServer((socket) => socket.end(Buffer.alloc(512 * 1_024, 7)))
|
||||
server.once("error", (error) => resume(Effect.fail(error)))
|
||||
server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server)))
|
||||
return Effect.sync(() => server.close())
|
||||
}),
|
||||
(server) => Effect.sync(() => server.close()),
|
||||
)
|
||||
|
||||
const collectData = (
|
||||
tunnel: Effect.Success<ReturnType<typeof open>>,
|
||||
bytes = 0,
|
||||
frames = 0,
|
||||
): Effect.Effect<{ readonly bytes: number; readonly frames: number }, Error | BrowserTunnelProtocol.FrameError> =>
|
||||
Effect.gen(function* () {
|
||||
const decoded = yield* BrowserTunnelProtocol.decodeFromServer((yield* next(tunnel)).data)
|
||||
if (decoded.type === "data") {
|
||||
tunnel.webSocket.send(
|
||||
BrowserTunnelProtocol.encodeFromDesktop({
|
||||
type: "browser.tunnel.window",
|
||||
bytes: BrowserTunnel.WindowBytes.make(decoded.data.byteLength),
|
||||
frames: BrowserTunnel.FrameWindow.make(1),
|
||||
}),
|
||||
{ binary: true },
|
||||
)
|
||||
return yield* collectData(tunnel, bytes + decoded.data.byteLength, frames + 1)
|
||||
}
|
||||
if (decoded.message.type === "browser.tunnel.window") return yield* collectData(tunnel, bytes, frames)
|
||||
if (decoded.message.type === "browser.tunnel.end") return { bytes, frames }
|
||||
return yield* Effect.fail(new Error(`Unexpected browser tunnel message: ${decoded.message.type}`))
|
||||
})
|
||||
|
||||
it.live(
|
||||
"authenticates browser upgrades and tunnels TCP through an attached lease",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const base = yield* startServer()
|
||||
const controlURL = new URL("/api/browser/control", base)
|
||||
const tunnelURL = new URL("/api/browser/tunnel", base)
|
||||
|
||||
expect(yield* upgradeStatus(controlURL, BROWSER_CONTROL_PROTOCOL, {})).toBe(401)
|
||||
const queryAuth = new URL(controlURL)
|
||||
queryAuth.searchParams.set("auth_token", Buffer.from("opencode:secret").toString("base64"))
|
||||
expect(yield* upgradeStatus(queryAuth, BROWSER_CONTROL_PROTOCOL, {})).toBe(401)
|
||||
for (const [path, protocol] of [
|
||||
["/api/browser/control/", BROWSER_CONTROL_PROTOCOL],
|
||||
["/%61pi/%62rowser/%63ontrol", BROWSER_CONTROL_PROTOCOL],
|
||||
["/API//browser/control;jsessionid=ignored", BROWSER_CONTROL_PROTOCOL],
|
||||
["/api/browser/tunnel/", BROWSER_TUNNEL_PROTOCOL],
|
||||
["/%61pi/%62rowser/%74unnel", BROWSER_TUNNEL_PROTOCOL],
|
||||
] as const) {
|
||||
const alternate = new URL(path, base)
|
||||
alternate.searchParams.set("auth_token", Buffer.from("opencode:secret").toString("base64"))
|
||||
expect(yield* upgradeStatus(alternate, protocol, {})).toBe(401)
|
||||
}
|
||||
expect(yield* upgradeStatus(controlURL, "opencode.browser.control.invalid")).toBe(426)
|
||||
expect(
|
||||
yield* upgradeStatus(controlURL, BROWSER_CONTROL_PROTOCOL, {
|
||||
authorization,
|
||||
origin: "https://malicious.example",
|
||||
}),
|
||||
).toBe(403)
|
||||
|
||||
const control = yield* open(controlURL, BROWSER_CONTROL_PROTOCOL)
|
||||
expect(decodeControl((yield* next(control)).data)).toEqual({ type: "browser.control.ready" })
|
||||
expect(yield* upgradeStatus(controlURL, BROWSER_CONTROL_PROTOCOL)).toBe(409)
|
||||
|
||||
const sessionID = Session.ID.make("ses_browser_server")
|
||||
const leaseID = Browser.LeaseID.make("brl_browserserver")
|
||||
const sessionURL = new URL("/api/session", base)
|
||||
sessionURL.protocol = "http:"
|
||||
const created = yield* Effect.promise(() =>
|
||||
fetch(sessionURL, {
|
||||
method: "POST",
|
||||
headers: { authorization, "content-type": "application/json" },
|
||||
body: JSON.stringify({ id: sessionID }),
|
||||
}),
|
||||
)
|
||||
expect(created.status).toBe(200)
|
||||
const acknowledged = yield* exchange(
|
||||
control,
|
||||
encodeControl({
|
||||
type: "browser.control.sync",
|
||||
revision: 1,
|
||||
attachments: [
|
||||
{
|
||||
sessionID,
|
||||
leaseID,
|
||||
state: {
|
||||
url: "http://localhost/",
|
||||
title: "Local",
|
||||
loading: false,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
generation: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(acknowledged.binary).toBe(false)
|
||||
expect(decodeControl(acknowledged.data)).toEqual({ type: "browser.control.synced", revision: 1 })
|
||||
|
||||
const stale = yield* open(tunnelURL, BROWSER_TUNNEL_PROTOCOL)
|
||||
expect(yield* tunnelControl(yield* next(stale))).toEqual({ type: "browser.tunnel.ready" })
|
||||
const rejected = yield* exchange(
|
||||
stale,
|
||||
BrowserTunnelProtocol.encodeFromDesktop({
|
||||
type: "browser.tunnel.open",
|
||||
sessionID,
|
||||
leaseID: Browser.LeaseID.make("brl_stale"),
|
||||
target: { host: BrowserTunnel.Host.make("127.0.0.1"), port: BrowserTunnel.Port.make(1) },
|
||||
receiveWindow: BrowserTunnel.WindowSize.make(BrowserTunnelProtocol.InitialWindowBytes),
|
||||
receiveFrames: BrowserTunnel.FrameWindow.make(BrowserTunnelProtocol.InitialFrameWindow),
|
||||
}),
|
||||
)
|
||||
expect(yield* tunnelControl(rejected)).toMatchObject({
|
||||
type: "browser.tunnel.rejected",
|
||||
code: "stale_lease",
|
||||
})
|
||||
stale.webSocket.terminate()
|
||||
|
||||
const target = yield* echoServer
|
||||
const address = target.server.address()
|
||||
if (address === null || typeof address === "string") throw new Error("echo server did not bind TCP")
|
||||
const tunnel = yield* open(tunnelURL, BROWSER_TUNNEL_PROTOCOL)
|
||||
expect(yield* tunnelControl(yield* next(tunnel))).toEqual({ type: "browser.tunnel.ready" })
|
||||
const openFrame = BrowserTunnelProtocol.encodeFromDesktop({
|
||||
type: "browser.tunnel.open",
|
||||
sessionID,
|
||||
leaseID,
|
||||
target: { host: BrowserTunnel.Host.make("127.0.0.1"), port: BrowserTunnel.Port.make(address.port) },
|
||||
receiveWindow: BrowserTunnel.WindowSize.make(BrowserTunnelProtocol.InitialWindowBytes),
|
||||
receiveFrames: BrowserTunnel.FrameWindow.make(BrowserTunnelProtocol.InitialFrameWindow),
|
||||
})
|
||||
yield* Effect.callback<void, Error>((resume) =>
|
||||
tunnel.webSocket.send(openFrame, { binary: true }, (error) => resume(error ? Effect.fail(error) : Effect.void)),
|
||||
)
|
||||
yield* Deferred.await(target.connected)
|
||||
const opened = yield* next(tunnel)
|
||||
expect(opened.binary).toBe(true)
|
||||
expect(yield* tunnelControl(opened)).toEqual({
|
||||
type: "browser.tunnel.opened",
|
||||
receiveWindow: BrowserTunnel.WindowSize.make(BrowserTunnelProtocol.InitialWindowBytes),
|
||||
receiveFrames: BrowserTunnel.FrameWindow.make(BrowserTunnelProtocol.InitialFrameWindow),
|
||||
})
|
||||
|
||||
yield* Effect.callback<void, Error>((resume) =>
|
||||
tunnel.webSocket.send(BrowserTunnelProtocol.data(Buffer.from("browser tunnel")), { binary: true }, (error) =>
|
||||
resume(error ? Effect.fail(error) : Effect.void),
|
||||
),
|
||||
)
|
||||
expect(yield* tunnelControl(yield* next(tunnel))).toEqual({
|
||||
type: "browser.tunnel.window",
|
||||
bytes: BrowserTunnel.WindowBytes.make(Buffer.byteLength("browser tunnel")),
|
||||
frames: BrowserTunnel.FrameWindow.make(1),
|
||||
})
|
||||
const echoed = yield* next(tunnel)
|
||||
expect(echoed.binary).toBe(true)
|
||||
const echoedFrame = yield* BrowserTunnelProtocol.decodeFromServer(echoed.data)
|
||||
if (echoedFrame.type !== "data") throw new Error("expected browser tunnel data frame")
|
||||
expect(Buffer.from(echoedFrame.data).toString()).toBe("browser tunnel")
|
||||
tunnel.webSocket.send(
|
||||
BrowserTunnelProtocol.encodeFromDesktop({
|
||||
type: "browser.tunnel.window",
|
||||
bytes: BrowserTunnel.WindowBytes.make(echoedFrame.data.byteLength),
|
||||
frames: BrowserTunnel.FrameWindow.make(1),
|
||||
}),
|
||||
{ binary: true },
|
||||
)
|
||||
|
||||
tunnel.webSocket.send(BrowserTunnelProtocol.encodeFromDesktop({ type: "browser.tunnel.end" }), { binary: true })
|
||||
yield* Deferred.await(target.ended)
|
||||
yield* Deferred.await(target.sentEnd)
|
||||
const ended = yield* next(tunnel)
|
||||
expect(ended.binary).toBe(true)
|
||||
expect(yield* tunnelControl(ended)).toEqual({ type: "browser.tunnel.end" })
|
||||
|
||||
tunnel.webSocket.terminate()
|
||||
|
||||
const pushing = yield* pushServer
|
||||
const pushingAddress = pushing.address()
|
||||
if (pushingAddress === null || typeof pushingAddress === "string") throw new Error("push server did not bind TCP")
|
||||
const large = yield* open(tunnelURL, BROWSER_TUNNEL_PROTOCOL)
|
||||
yield* tunnelControl(yield* next(large))
|
||||
yield* exchange(
|
||||
large,
|
||||
BrowserTunnelProtocol.encodeFromDesktop({
|
||||
type: "browser.tunnel.open",
|
||||
sessionID,
|
||||
leaseID,
|
||||
target: { host: BrowserTunnel.Host.make("127.0.0.1"), port: BrowserTunnel.Port.make(pushingAddress.port) },
|
||||
receiveWindow: BrowserTunnel.WindowSize.make(BrowserTunnelProtocol.MaxDataBytes),
|
||||
receiveFrames: BrowserTunnel.FrameWindow.make(1),
|
||||
}),
|
||||
)
|
||||
const pushed = yield* collectData(large)
|
||||
expect(pushed.bytes).toBe(512 * 1_024)
|
||||
expect(pushed.frames).toBeGreaterThanOrEqual(8)
|
||||
large.webSocket.send(BrowserTunnelProtocol.encodeFromDesktop({ type: "browser.tunnel.end" }), { binary: true })
|
||||
large.webSocket.terminate()
|
||||
|
||||
control.webSocket.terminate()
|
||||
}),
|
||||
15_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"rejects an oversized raw tunnel frame before opening a tunnel",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const base = yield* startServer()
|
||||
const control = new Uint8Array(BrowserTunnelProtocol.MaxControlBytes + 2)
|
||||
control[0] = BrowserTunnelProtocol.FrameType.Control
|
||||
for (const frame of [new Uint8Array(BrowserTunnelProtocol.MaxDataBytes + 2), control]) {
|
||||
const tunnel = yield* open(new URL("/api/browser/tunnel", base), BROWSER_TUNNEL_PROTOCOL)
|
||||
expect(yield* tunnelControl(yield* next(tunnel))).toEqual({ type: "browser.tunnel.ready" })
|
||||
|
||||
const rejected = yield* exchange(tunnel, frame)
|
||||
expect(yield* tunnelControl(rejected)).toMatchObject({
|
||||
type: "browser.tunnel.rejected",
|
||||
code: "invalid_open",
|
||||
})
|
||||
expect(yield* Deferred.await(tunnel.closed)).toMatchObject({ code: 1009 })
|
||||
}
|
||||
}),
|
||||
10_000,
|
||||
)
|
||||
|
||||
it.live(
|
||||
"closes active browser transports with the service-restart code",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const scope = yield* Scope.make()
|
||||
const server = yield* ServerProcess.start<never, never>({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
password: "secret",
|
||||
app: { version: "test-version" },
|
||||
database: { path: ":memory:" },
|
||||
}).pipe(Effect.provideService(Scope.Scope, scope))
|
||||
const base = new URL(HttpServer.formatAddress(server.address))
|
||||
base.protocol = "ws:"
|
||||
const control = yield* open(new URL("/api/browser/control", base), BROWSER_CONTROL_PROTOCOL)
|
||||
expect(decodeControl((yield* next(control)).data)).toEqual({ type: "browser.control.ready" })
|
||||
|
||||
const sessionID = Session.ID.make("ses_browser_shutdown")
|
||||
const leaseID = Browser.LeaseID.make("brl_browsershutdown")
|
||||
const sessionURL = new URL("/api/session", base)
|
||||
sessionURL.protocol = "http:"
|
||||
const created = yield* Effect.promise(() =>
|
||||
fetch(sessionURL, {
|
||||
method: "POST",
|
||||
headers: { authorization, "content-type": "application/json" },
|
||||
body: JSON.stringify({ id: sessionID }),
|
||||
}),
|
||||
)
|
||||
expect(created.status).toBe(200)
|
||||
expect(
|
||||
decodeControl(
|
||||
(yield* exchange(
|
||||
control,
|
||||
encodeControl({
|
||||
type: "browser.control.sync",
|
||||
revision: 1,
|
||||
attachments: [
|
||||
{
|
||||
sessionID,
|
||||
leaseID,
|
||||
state: {
|
||||
url: "http://localhost/",
|
||||
title: "Local",
|
||||
loading: false,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
generation: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
)).data,
|
||||
),
|
||||
).toEqual({ type: "browser.control.synced", revision: 1 })
|
||||
|
||||
const target = yield* echoServer
|
||||
const address = target.server.address()
|
||||
if (address === null || typeof address === "string") throw new Error("echo server did not bind TCP")
|
||||
const tunnel = yield* open(new URL("/api/browser/tunnel", base), BROWSER_TUNNEL_PROTOCOL)
|
||||
expect(yield* tunnelControl(yield* next(tunnel))).toEqual({ type: "browser.tunnel.ready" })
|
||||
expect(
|
||||
yield* tunnelControl(
|
||||
yield* exchange(
|
||||
tunnel,
|
||||
BrowserTunnelProtocol.encodeFromDesktop({
|
||||
type: "browser.tunnel.open",
|
||||
sessionID,
|
||||
leaseID,
|
||||
target: { host: BrowserTunnel.Host.make("127.0.0.1"), port: BrowserTunnel.Port.make(address.port) },
|
||||
receiveWindow: BrowserTunnel.WindowSize.make(BrowserTunnelProtocol.InitialWindowBytes),
|
||||
receiveFrames: BrowserTunnel.FrameWindow.make(BrowserTunnelProtocol.InitialFrameWindow),
|
||||
}),
|
||||
),
|
||||
),
|
||||
).toMatchObject({ type: "browser.tunnel.opened" })
|
||||
|
||||
const closing = yield* Effect.forkChild(Scope.close(scope, Exit.void), { startImmediately: true })
|
||||
expect(yield* Deferred.await(control.closed)).toMatchObject({ code: 1012 })
|
||||
expect(yield* Deferred.await(tunnel.closed)).toMatchObject({ code: 1012 })
|
||||
yield* Fiber.join(closing)
|
||||
expect((yield* TestConsole.logLines).filter((line) => String(line).includes("Socket already assigned"))).toEqual(
|
||||
[],
|
||||
)
|
||||
}),
|
||||
10_000,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue