feat(protocol): add browser contracts
This commit is contained in:
parent
396609b49b
commit
4fd43bd45b
10 changed files with 823 additions and 0 deletions
77
packages/schema/src/browser-control.ts
Normal file
77
packages/schema/src/browser-control.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
export * as BrowserControl from "./browser-control.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Browser } from "./browser.js"
|
||||
import { ascending } from "./identifier.js"
|
||||
import { Session } from "./session.js"
|
||||
import { NonNegativeInt, statics } from "./schema.js"
|
||||
|
||||
const RequestIDSchema = Schema.String.check(Schema.isPattern(/^brr_[0-9A-Za-z]+$/))
|
||||
.pipe(Schema.brand("BrowserControl.RequestID"))
|
||||
.annotate({ identifier: "BrowserControl.RequestID" })
|
||||
|
||||
export const RequestID = RequestIDSchema.pipe(
|
||||
statics((schema: typeof RequestIDSchema) => ({
|
||||
create: () => schema.make("brr_" + ascending()),
|
||||
})),
|
||||
)
|
||||
export type RequestID = typeof RequestID.Type
|
||||
|
||||
export interface Attachment extends Schema.Schema.Type<typeof Attachment> {}
|
||||
export const Attachment = Schema.Struct({
|
||||
sessionID: Session.ID,
|
||||
leaseID: Browser.LeaseID,
|
||||
state: Browser.State,
|
||||
}).annotate({ identifier: "BrowserControl.Attachment" })
|
||||
|
||||
export interface Ready extends Schema.Schema.Type<typeof Ready> {}
|
||||
export const Ready = Schema.Struct({
|
||||
type: Schema.Literal("browser.control.ready"),
|
||||
}).annotate({ identifier: "BrowserControl.Ready" })
|
||||
|
||||
export interface Sync extends Schema.Schema.Type<typeof Sync> {}
|
||||
export const Sync = Schema.Struct({
|
||||
type: Schema.Literal("browser.control.sync"),
|
||||
revision: NonNegativeInt,
|
||||
attachments: Schema.Array(Attachment).check(Schema.isMaxLength(16)),
|
||||
}).annotate({ identifier: "BrowserControl.Sync" })
|
||||
|
||||
export interface Synced extends Schema.Schema.Type<typeof Synced> {}
|
||||
export const Synced = Schema.Struct({
|
||||
type: Schema.Literal("browser.control.synced"),
|
||||
revision: NonNegativeInt,
|
||||
}).annotate({ identifier: "BrowserControl.Synced" })
|
||||
|
||||
export interface Request extends Schema.Schema.Type<typeof Request> {}
|
||||
export const Request = Schema.Struct({
|
||||
type: Schema.Literal("browser.control.request"),
|
||||
requestID: RequestID,
|
||||
sessionID: Session.ID,
|
||||
leaseID: Browser.LeaseID,
|
||||
command: Browser.Command,
|
||||
}).annotate({ identifier: "BrowserControl.Request" })
|
||||
|
||||
export interface Response extends Schema.Schema.Type<typeof Response> {}
|
||||
export const Response = Schema.Struct({
|
||||
type: Schema.Literal("browser.control.response"),
|
||||
requestID: RequestID,
|
||||
leaseID: Browser.LeaseID,
|
||||
outcome: Browser.Outcome,
|
||||
}).annotate({ identifier: "BrowserControl.Response" })
|
||||
|
||||
export interface Cancel extends Schema.Schema.Type<typeof Cancel> {}
|
||||
export const Cancel = Schema.Struct({
|
||||
type: Schema.Literal("browser.control.cancel"),
|
||||
requestID: RequestID,
|
||||
leaseID: Browser.LeaseID,
|
||||
}).annotate({ identifier: "BrowserControl.Cancel" })
|
||||
|
||||
export const FromDesktop = Schema.Union([Sync, Response])
|
||||
.pipe(Schema.toTaggedUnion("type"))
|
||||
.annotate({ identifier: "BrowserControl.FromDesktop" })
|
||||
export type FromDesktop = typeof FromDesktop.Type
|
||||
|
||||
export const FromServer = Schema.Union([Ready, Synced, Request, Cancel])
|
||||
.pipe(Schema.toTaggedUnion("type"))
|
||||
.annotate({ identifier: "BrowserControl.FromServer" })
|
||||
export type FromServer = typeof FromServer.Type
|
||||
111
packages/schema/src/browser-tunnel.ts
Normal file
111
packages/schema/src/browser-tunnel.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
export * as BrowserTunnel from "./browser-tunnel.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Browser } from "./browser.js"
|
||||
import { Session } from "./session.js"
|
||||
|
||||
export const Host = Schema.NonEmptyString.check(Schema.isMaxLength(253), Schema.isPattern(/^[^\s/?#]+$/))
|
||||
.pipe(Schema.brand("BrowserTunnel.Host"))
|
||||
.annotate({ identifier: "BrowserTunnel.Host" })
|
||||
export type Host = typeof Host.Type
|
||||
|
||||
export const Port = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65_535 }))
|
||||
.pipe(Schema.brand("BrowserTunnel.Port"))
|
||||
.annotate({ identifier: "BrowserTunnel.Port" })
|
||||
export type Port = typeof Port.Type
|
||||
|
||||
export const WindowBytes = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 1_048_576 }))
|
||||
.pipe(Schema.brand("BrowserTunnel.WindowBytes"))
|
||||
.annotate({ identifier: "BrowserTunnel.WindowBytes" })
|
||||
export type WindowBytes = typeof WindowBytes.Type
|
||||
|
||||
export const WindowSize = Schema.Int.check(Schema.isBetween({ minimum: 65_536, maximum: 1_048_576 }))
|
||||
.pipe(Schema.brand("BrowserTunnel.WindowSize"))
|
||||
.annotate({ identifier: "BrowserTunnel.WindowSize" })
|
||||
export type WindowSize = typeof WindowSize.Type
|
||||
|
||||
export const FrameWindow = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 64 }))
|
||||
.pipe(Schema.brand("BrowserTunnel.FrameWindow"))
|
||||
.annotate({ identifier: "BrowserTunnel.FrameWindow" })
|
||||
export type FrameWindow = typeof FrameWindow.Type
|
||||
|
||||
export interface Target extends Schema.Schema.Type<typeof Target> {}
|
||||
export const Target = Schema.Struct({
|
||||
host: Host,
|
||||
port: Port,
|
||||
}).annotate({ identifier: "BrowserTunnel.Target" })
|
||||
|
||||
export interface Open extends Schema.Schema.Type<typeof Open> {}
|
||||
export const Open = Schema.Struct({
|
||||
type: Schema.Literal("browser.tunnel.open"),
|
||||
sessionID: Session.ID,
|
||||
leaseID: Browser.LeaseID,
|
||||
target: Target,
|
||||
receiveWindow: WindowSize,
|
||||
receiveFrames: FrameWindow,
|
||||
}).annotate({ identifier: "BrowserTunnel.Open" })
|
||||
|
||||
export interface Ready extends Schema.Schema.Type<typeof Ready> {}
|
||||
export const Ready = Schema.Struct({
|
||||
type: Schema.Literal("browser.tunnel.ready"),
|
||||
}).annotate({ identifier: "BrowserTunnel.Ready" })
|
||||
|
||||
export interface Opened extends Schema.Schema.Type<typeof Opened> {}
|
||||
export const Opened = Schema.Struct({
|
||||
type: Schema.Literal("browser.tunnel.opened"),
|
||||
receiveWindow: WindowSize,
|
||||
receiveFrames: FrameWindow,
|
||||
}).annotate({ identifier: "BrowserTunnel.Opened" })
|
||||
|
||||
export interface Window extends Schema.Schema.Type<typeof Window> {}
|
||||
export const Window = Schema.Struct({
|
||||
type: Schema.Literal("browser.tunnel.window"),
|
||||
bytes: WindowBytes,
|
||||
frames: FrameWindow,
|
||||
}).annotate({ identifier: "BrowserTunnel.Window" })
|
||||
|
||||
export const OpenErrorCode = Schema.Literals([
|
||||
"invalid_open",
|
||||
"not_attached",
|
||||
"stale_lease",
|
||||
"connect_failed",
|
||||
"connect_timeout",
|
||||
]).annotate({ identifier: "BrowserTunnel.OpenErrorCode" })
|
||||
export type OpenErrorCode = typeof OpenErrorCode.Type
|
||||
|
||||
export interface Rejected extends Schema.Schema.Type<typeof Rejected> {}
|
||||
export const Rejected = Schema.Struct({
|
||||
type: Schema.Literal("browser.tunnel.rejected"),
|
||||
code: OpenErrorCode,
|
||||
message: Schema.String.check(Schema.isMaxLength(1_024)),
|
||||
}).annotate({ identifier: "BrowserTunnel.Rejected" })
|
||||
|
||||
export interface End extends Schema.Schema.Type<typeof End> {}
|
||||
export const End = Schema.Struct({
|
||||
type: Schema.Literal("browser.tunnel.end"),
|
||||
}).annotate({ identifier: "BrowserTunnel.End" })
|
||||
|
||||
export const ResetCode = Schema.Literals([
|
||||
"cancelled",
|
||||
"lease_revoked",
|
||||
"message_too_large",
|
||||
"target_error",
|
||||
"protocol_error",
|
||||
]).annotate({ identifier: "BrowserTunnel.ResetCode" })
|
||||
export type ResetCode = typeof ResetCode.Type
|
||||
|
||||
export interface Reset extends Schema.Schema.Type<typeof Reset> {}
|
||||
export const Reset = Schema.Struct({
|
||||
type: Schema.Literal("browser.tunnel.reset"),
|
||||
code: ResetCode,
|
||||
}).annotate({ identifier: "BrowserTunnel.Reset" })
|
||||
|
||||
export const FromDesktop = Schema.Union([Open, Window, End, Reset])
|
||||
.pipe(Schema.toTaggedUnion("type"))
|
||||
.annotate({ identifier: "BrowserTunnel.FromDesktop" })
|
||||
export type FromDesktop = typeof FromDesktop.Type
|
||||
|
||||
export const FromServer = Schema.Union([Ready, Opened, Rejected, Window, End, Reset])
|
||||
.pipe(Schema.toTaggedUnion("type"))
|
||||
.annotate({ identifier: "BrowserTunnel.FromServer" })
|
||||
export type FromServer = typeof FromServer.Type
|
||||
174
packages/schema/src/browser.ts
Normal file
174
packages/schema/src/browser.ts
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
export * as Browser from "./browser.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { ascending } from "./identifier.js"
|
||||
import { NonNegativeInt, PositiveInt, statics } from "./schema.js"
|
||||
|
||||
const LeaseIDSchema = Schema.String.check(Schema.isPattern(/^brl_[0-9A-Za-z]+$/))
|
||||
.pipe(Schema.brand("Browser.LeaseID"))
|
||||
.annotate({ identifier: "Browser.LeaseID" })
|
||||
|
||||
export const LeaseID = LeaseIDSchema.pipe(
|
||||
statics((schema: typeof LeaseIDSchema) => ({
|
||||
create: () => schema.make("brl_" + ascending()),
|
||||
})),
|
||||
)
|
||||
export type LeaseID = typeof LeaseID.Type
|
||||
|
||||
export const Ref = Schema.String.check(Schema.isPattern(/^e[1-9][0-9]*$/))
|
||||
.pipe(Schema.brand("Browser.Ref"))
|
||||
.annotate({ identifier: "Browser.Ref" })
|
||||
export type Ref = typeof Ref.Type
|
||||
|
||||
export interface State extends Schema.Schema.Type<typeof State> {}
|
||||
export const State = Schema.Struct({
|
||||
url: Schema.String.check(Schema.isMaxLength(16_384)),
|
||||
title: Schema.String.check(Schema.isMaxLength(1_024)),
|
||||
loading: Schema.Boolean,
|
||||
canGoBack: Schema.Boolean,
|
||||
canGoForward: Schema.Boolean,
|
||||
generation: NonNegativeInt,
|
||||
}).annotate({ identifier: "Browser.State" })
|
||||
|
||||
export const Key = Schema.Literals([
|
||||
"Enter",
|
||||
"Tab",
|
||||
"Escape",
|
||||
"Backspace",
|
||||
"Delete",
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
"ArrowLeft",
|
||||
"ArrowRight",
|
||||
"PageUp",
|
||||
"PageDown",
|
||||
"Home",
|
||||
"End",
|
||||
"Space",
|
||||
]).annotate({ identifier: "Browser.Key" })
|
||||
export type Key = typeof Key.Type
|
||||
|
||||
export const Direction = Schema.Literals(["up", "down", "left", "right"]).annotate({
|
||||
identifier: "Browser.Direction",
|
||||
})
|
||||
export type Direction = typeof Direction.Type
|
||||
|
||||
const generation = { generation: NonNegativeInt }
|
||||
|
||||
export const Command = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("navigate"),
|
||||
url: Schema.String.check(Schema.isMaxLength(16_384)),
|
||||
...generation,
|
||||
}),
|
||||
Schema.Struct({ type: Schema.Literal("snapshot"), ...generation }),
|
||||
Schema.Struct({ type: Schema.Literal("click"), ref: Ref, ...generation }),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("fill"),
|
||||
ref: Ref,
|
||||
text: Schema.String.check(Schema.isMaxLength(10_000)),
|
||||
...generation,
|
||||
}),
|
||||
Schema.Struct({ type: Schema.Literal("press"), key: Key, ...generation }),
|
||||
Schema.Struct({ type: Schema.Literal("scroll"), direction: Direction, pixels: PositiveInt, ...generation }),
|
||||
Schema.Struct({ type: Schema.Literal("screenshot"), ...generation }),
|
||||
])
|
||||
.pipe(Schema.toTaggedUnion("type"))
|
||||
.annotate({ identifier: "Browser.Command" })
|
||||
export type Command = typeof Command.Type
|
||||
|
||||
export interface NavigateResult extends Schema.Schema.Type<typeof NavigateResult> {}
|
||||
export const NavigateResult = Schema.Struct({
|
||||
type: Schema.Literal("navigate"),
|
||||
state: State,
|
||||
}).annotate({ identifier: "Browser.NavigateResult" })
|
||||
|
||||
export interface SnapshotResult extends Schema.Schema.Type<typeof SnapshotResult> {}
|
||||
export const SnapshotResult = Schema.Struct({
|
||||
type: Schema.Literal("snapshot"),
|
||||
state: State,
|
||||
format: Schema.Literal("opencode.semantic.v1"),
|
||||
content: Schema.String.check(Schema.isMaxLength(100_000)),
|
||||
}).annotate({ identifier: "Browser.SnapshotResult" })
|
||||
|
||||
export interface ClickResult extends Schema.Schema.Type<typeof ClickResult> {}
|
||||
export const ClickResult = Schema.Struct({
|
||||
type: Schema.Literal("click"),
|
||||
state: State,
|
||||
}).annotate({ identifier: "Browser.ClickResult" })
|
||||
|
||||
export interface FillResult extends Schema.Schema.Type<typeof FillResult> {}
|
||||
export const FillResult = Schema.Struct({
|
||||
type: Schema.Literal("fill"),
|
||||
state: State,
|
||||
}).annotate({ identifier: "Browser.FillResult" })
|
||||
|
||||
export interface PressResult extends Schema.Schema.Type<typeof PressResult> {}
|
||||
export const PressResult = Schema.Struct({
|
||||
type: Schema.Literal("press"),
|
||||
state: State,
|
||||
}).annotate({ identifier: "Browser.PressResult" })
|
||||
|
||||
export interface ScrollResult extends Schema.Schema.Type<typeof ScrollResult> {}
|
||||
export const ScrollResult = Schema.Struct({
|
||||
type: Schema.Literal("scroll"),
|
||||
state: State,
|
||||
}).annotate({ identifier: "Browser.ScrollResult" })
|
||||
|
||||
export interface ScreenshotResult extends Schema.Schema.Type<typeof ScreenshotResult> {}
|
||||
export const ScreenshotResult = Schema.Struct({
|
||||
type: Schema.Literal("screenshot"),
|
||||
state: State,
|
||||
mediaType: Schema.Literal("image/png"),
|
||||
data: Schema.Uint8ArrayFromBase64.check(Schema.isMaxLength(5 * 1_024 * 1_024)),
|
||||
width: PositiveInt,
|
||||
height: PositiveInt,
|
||||
}).annotate({ identifier: "Browser.ScreenshotResult" })
|
||||
|
||||
export const Result = Schema.Union([
|
||||
NavigateResult,
|
||||
SnapshotResult,
|
||||
ClickResult,
|
||||
FillResult,
|
||||
PressResult,
|
||||
ScrollResult,
|
||||
ScreenshotResult,
|
||||
])
|
||||
.pipe(Schema.toTaggedUnion("type"))
|
||||
.annotate({ identifier: "Browser.Result" })
|
||||
export type Result = typeof Result.Type
|
||||
|
||||
export type ResultFor<Input extends Command> = Extract<Result, { readonly type: Input["type"] }>
|
||||
|
||||
export const ErrorCode = Schema.Literals([
|
||||
"not_attached",
|
||||
"stale_ref",
|
||||
"invalid_url",
|
||||
"navigation_failed",
|
||||
"timeout",
|
||||
"aborted",
|
||||
"page_crashed",
|
||||
"result_too_large",
|
||||
"overloaded",
|
||||
"protocol",
|
||||
"internal",
|
||||
]).annotate({ identifier: "Browser.ErrorCode" })
|
||||
export type ErrorCode = typeof ErrorCode.Type
|
||||
|
||||
export interface Failure extends Schema.Schema.Type<typeof Failure> {}
|
||||
export const Failure = Schema.Struct({
|
||||
type: Schema.Literal("failure"),
|
||||
code: ErrorCode,
|
||||
message: Schema.String.check(Schema.isMaxLength(1_024)),
|
||||
}).annotate({ identifier: "Browser.Failure" })
|
||||
|
||||
export interface Success extends Schema.Schema.Type<typeof Success> {}
|
||||
export const Success = Schema.Struct({
|
||||
type: Schema.Literal("success"),
|
||||
result: Result,
|
||||
}).annotate({ identifier: "Browser.Success" })
|
||||
|
||||
export const Outcome = Schema.Union([Success, Failure])
|
||||
.pipe(Schema.toTaggedUnion("type"))
|
||||
.annotate({ identifier: "Browser.Outcome" })
|
||||
export type Outcome = typeof Outcome.Type
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
export { Agent } from "./agent.js"
|
||||
export { Browser } from "./browser.js"
|
||||
export { Command } from "./command.js"
|
||||
export { Config } from "./config.js"
|
||||
export { Connection } from "./connection.js"
|
||||
|
|
|
|||
67
packages/schema/test/browser.test.ts
Normal file
67
packages/schema/test/browser.test.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { Browser } from "../src/browser.js"
|
||||
import { BrowserControl } from "../src/browser-control.js"
|
||||
import { BrowserTunnel } from "../src/browser-tunnel.js"
|
||||
import { Session } from "../src/session.js"
|
||||
import { Schema } from "effect"
|
||||
|
||||
const state: Browser.State = {
|
||||
url: "https://example.com/",
|
||||
title: "Example",
|
||||
loading: false,
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
generation: 1,
|
||||
}
|
||||
|
||||
describe("browser contracts", () => {
|
||||
test("creates exact-prefixed identifiers", () => {
|
||||
expect(Browser.LeaseID.create()).toStartWith("brl_")
|
||||
expect(BrowserControl.RequestID.create()).toStartWith("brr_")
|
||||
expect(() => Browser.LeaseID.make("lease_invalid")).toThrow()
|
||||
expect(() => BrowserControl.RequestID.make("request_invalid")).toThrow()
|
||||
})
|
||||
|
||||
test("round trips browser control messages", () => {
|
||||
const codec = Schema.fromJsonString(BrowserControl.FromDesktop)
|
||||
const message: BrowserControl.FromDesktop = {
|
||||
type: "browser.control.sync",
|
||||
revision: 1,
|
||||
attachments: [
|
||||
{
|
||||
sessionID: Session.ID.make("ses_browser_contract"),
|
||||
leaseID: Browser.LeaseID.make("brl_contract"),
|
||||
state,
|
||||
},
|
||||
],
|
||||
}
|
||||
const encoded = Schema.encodeSync(codec)(message)
|
||||
expect(Schema.decodeUnknownSync(codec)(encoded)).toEqual(message)
|
||||
})
|
||||
|
||||
test("encodes screenshot bytes as base64", () => {
|
||||
const codec = Schema.fromJsonString(Browser.Outcome)
|
||||
const outcome: Browser.Outcome = {
|
||||
type: "success",
|
||||
result: {
|
||||
type: "screenshot",
|
||||
state,
|
||||
mediaType: "image/png",
|
||||
data: new Uint8Array([1, 2, 3]),
|
||||
width: 10,
|
||||
height: 20,
|
||||
},
|
||||
}
|
||||
const encoded = Schema.encodeSync(codec)(outcome)
|
||||
expect(encoded).toContain('"data":"AQID"')
|
||||
expect(Schema.decodeUnknownSync(codec)(encoded)).toEqual(outcome)
|
||||
})
|
||||
|
||||
test("rejects invalid tunnel targets", () => {
|
||||
expect(() => Schema.decodeUnknownSync(BrowserTunnel.Target)({ host: "", port: 3000 })).toThrow()
|
||||
expect(() => Schema.decodeUnknownSync(BrowserTunnel.Target)({ host: "https://example.com", port: 443 })).toThrow()
|
||||
expect(() => Schema.decodeUnknownSync(BrowserTunnel.Target)({ host: "local host", port: 3000 })).toThrow()
|
||||
expect(() => Schema.decodeUnknownSync(BrowserTunnel.Target)({ host: "localhost", port: 0 })).toThrow()
|
||||
expect(() => Schema.decodeUnknownSync(BrowserTunnel.Target)({ host: "localhost", port: 65_536 })).toThrow()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue