feat: add APN relay MVP and experimental push bridge
This commit is contained in:
@@ -5,10 +5,20 @@ import { Flag } from "../../flag/flag"
|
||||
import { Workspace } from "../../control-plane/workspace"
|
||||
import { Project } from "../../project/project"
|
||||
import { Installation } from "../../installation"
|
||||
import { PushRelay } from "../../server/push-relay"
|
||||
|
||||
export const ServeCommand = cmd({
|
||||
command: "serve",
|
||||
builder: (yargs) => withNetworkOptions(yargs),
|
||||
builder: (yargs) =>
|
||||
withNetworkOptions(yargs)
|
||||
.option("relay-url", {
|
||||
type: "string",
|
||||
describe: "experimental APN relay URL",
|
||||
})
|
||||
.option("relay-secret", {
|
||||
type: "string",
|
||||
describe: "experimental APN relay secret",
|
||||
}),
|
||||
describe: "starts a headless opencode server",
|
||||
handler: async (args) => {
|
||||
if (!Flag.OPENCODE_SERVER_PASSWORD) {
|
||||
@@ -18,6 +28,28 @@ export const ServeCommand = cmd({
|
||||
const server = Server.listen(opts)
|
||||
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
|
||||
|
||||
const relayURL = (
|
||||
args["relay-url"] ??
|
||||
process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_URL ??
|
||||
"https://relay.opencode.ai"
|
||||
).trim()
|
||||
const relaySecret = (args["relay-secret"] ?? process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET ?? "").trim()
|
||||
if (relayURL && relaySecret) {
|
||||
const host = server.hostname ?? opts.hostname
|
||||
const port = server.port || opts.port || 4096
|
||||
const pair = PushRelay.start({
|
||||
relayURL,
|
||||
relaySecret,
|
||||
hostname: host,
|
||||
port,
|
||||
})
|
||||
if (pair) {
|
||||
console.log("experimental push relay enabled")
|
||||
console.log("qr payload")
|
||||
console.log(JSON.stringify(pair, null, 2))
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise(() => {})
|
||||
await server.stop()
|
||||
},
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
import os from "node:os"
|
||||
import { Bus } from "@/bus"
|
||||
import { Log } from "@/util/log"
|
||||
|
||||
type Type = "complete" | "permission" | "error"
|
||||
|
||||
type Pair = {
|
||||
v: 1
|
||||
relayURL: string
|
||||
relaySecret: string
|
||||
hosts: string[]
|
||||
}
|
||||
|
||||
type Input = {
|
||||
relayURL: string
|
||||
relaySecret: string
|
||||
hostname: string
|
||||
port: number
|
||||
}
|
||||
|
||||
type State = {
|
||||
relayURL: string
|
||||
relaySecret: string
|
||||
pair: Pair
|
||||
stop: () => void
|
||||
seen: Map<string, number>
|
||||
gc: number
|
||||
}
|
||||
|
||||
type Event = {
|
||||
type: string
|
||||
properties: unknown
|
||||
}
|
||||
|
||||
const log = Log.create({ service: "push-relay" })
|
||||
|
||||
let state: State | undefined
|
||||
|
||||
function obj(input: unknown): input is Record<string, unknown> {
|
||||
return typeof input === "object" && input !== null
|
||||
}
|
||||
|
||||
function str(input: unknown) {
|
||||
return typeof input === "string" && input.length > 0 ? input : undefined
|
||||
}
|
||||
|
||||
function norm(input: string) {
|
||||
return input.replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
function list(hostname: string, port: number) {
|
||||
const urls = new Set<string>()
|
||||
const add = (host: string) => {
|
||||
if (!host) return
|
||||
if (host === "0.0.0.0") return
|
||||
if (host === "::") return
|
||||
urls.add(`http://${host}:${port}`)
|
||||
}
|
||||
|
||||
add(hostname)
|
||||
add("127.0.0.1")
|
||||
|
||||
const nets = Object.values(os.networkInterfaces())
|
||||
.flatMap((item) => item ?? [])
|
||||
.filter((item) => item.family === "IPv4" && !item.internal)
|
||||
.map((item) => item.address)
|
||||
|
||||
nets.forEach(add)
|
||||
|
||||
return [...urls]
|
||||
}
|
||||
|
||||
function map(event: Event): { type: Type; sessionID: string } | undefined {
|
||||
if (!obj(event.properties)) return
|
||||
|
||||
if (event.type === "permission.asked") {
|
||||
const sessionID = str(event.properties.sessionID)
|
||||
if (!sessionID) return
|
||||
return { type: "permission", sessionID }
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const sessionID = str(event.properties.sessionID)
|
||||
if (!sessionID) return
|
||||
return { type: "error", sessionID }
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
const sessionID = str(event.properties.sessionID)
|
||||
if (!sessionID) return
|
||||
return { type: "complete", sessionID }
|
||||
}
|
||||
|
||||
if (event.type !== "session.status") return
|
||||
const sessionID = str(event.properties.sessionID)
|
||||
if (!sessionID) return
|
||||
if (!obj(event.properties.status)) return
|
||||
if (event.properties.status.type !== "idle") return
|
||||
return { type: "complete", sessionID }
|
||||
}
|
||||
|
||||
function dedupe(input: { type: Type; sessionID: string }) {
|
||||
if (input.type !== "complete") return false
|
||||
const next = state
|
||||
if (!next) return false
|
||||
const now = Date.now()
|
||||
|
||||
if (next.seen.size > 2048 || now - next.gc > 60_000) {
|
||||
next.gc = now
|
||||
for (const [key, time] of next.seen) {
|
||||
if (now - time > 60_000) {
|
||||
next.seen.delete(key)
|
||||
}
|
||||
}
|
||||
const drop = next.seen.size - 2048
|
||||
if (drop > 0) {
|
||||
let i = 0
|
||||
for (const key of next.seen.keys()) {
|
||||
next.seen.delete(key)
|
||||
i += 1
|
||||
if (i >= drop) break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const key = `${input.type}:${input.sessionID}`
|
||||
const prev = next.seen.get(key)
|
||||
next.seen.set(key, now)
|
||||
if (!prev) return false
|
||||
return now - prev < 5_000
|
||||
}
|
||||
|
||||
function post(input: { type: Type; sessionID: string }) {
|
||||
const next = state
|
||||
if (!next) return false
|
||||
if (dedupe(input)) return true
|
||||
|
||||
void fetch(`${next.relayURL}/v1/event`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
secret: next.relaySecret,
|
||||
eventType: input.type,
|
||||
sessionID: input.sessionID,
|
||||
}),
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (res.ok) return
|
||||
const error = await res.text().catch(() => "")
|
||||
log.warn("relay post failed", {
|
||||
status: res.status,
|
||||
type: input.type,
|
||||
sessionID: input.sessionID,
|
||||
error,
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
log.warn("relay post failed", {
|
||||
type: input.type,
|
||||
sessionID: input.sessionID,
|
||||
error: String(error),
|
||||
})
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export namespace PushRelay {
|
||||
export function start(input: Input) {
|
||||
const relayURL = norm(input.relayURL.trim())
|
||||
const relaySecret = input.relaySecret.trim()
|
||||
if (!relayURL) return
|
||||
if (!relaySecret) return
|
||||
|
||||
stop()
|
||||
|
||||
const pair: Pair = {
|
||||
v: 1,
|
||||
relayURL,
|
||||
relaySecret,
|
||||
hosts: list(input.hostname, input.port),
|
||||
}
|
||||
|
||||
let unsub: (() => void) | undefined
|
||||
try {
|
||||
unsub = Bus.subscribeAll((event) => {
|
||||
const next = map(event)
|
||||
if (!next) return
|
||||
post(next)
|
||||
})
|
||||
} catch (error) {
|
||||
log.warn("failed to subscribe", {
|
||||
error: String(error),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!unsub) return
|
||||
|
||||
state = {
|
||||
relayURL,
|
||||
relaySecret,
|
||||
pair,
|
||||
stop: unsub,
|
||||
seen: new Map(),
|
||||
gc: 0,
|
||||
}
|
||||
|
||||
log.info("enabled", {
|
||||
relayURL,
|
||||
hosts: pair.hosts,
|
||||
})
|
||||
|
||||
return pair
|
||||
}
|
||||
|
||||
export function stop() {
|
||||
const next = state
|
||||
if (!next) return
|
||||
state = undefined
|
||||
next.stop()
|
||||
}
|
||||
|
||||
export function status() {
|
||||
const next = state
|
||||
if (!next) {
|
||||
return {
|
||||
enabled: false,
|
||||
relaySecretSet: false,
|
||||
} as const
|
||||
}
|
||||
return {
|
||||
enabled: true,
|
||||
relaySecretSet: next.relaySecret.length > 0,
|
||||
} as const
|
||||
}
|
||||
|
||||
export function pair() {
|
||||
return state?.pair
|
||||
}
|
||||
|
||||
export function test(input: { type: Type; sessionID: string }) {
|
||||
return post(input)
|
||||
}
|
||||
|
||||
export function auth(input: string) {
|
||||
const next = state
|
||||
if (!next) return false
|
||||
return next.relaySecret === input
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { zodToJsonSchema } from "zod-to-json-schema"
|
||||
import { errors } from "../error"
|
||||
import { lazy } from "../../util/lazy"
|
||||
import { WorkspaceRoutes } from "./workspace"
|
||||
import { PushRelay } from "../push-relay"
|
||||
|
||||
export const ExperimentalRoutes = lazy(() =>
|
||||
new Hono()
|
||||
@@ -267,5 +268,98 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
async (c) => {
|
||||
return c.json(await MCP.resources())
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/push",
|
||||
describeRoute({
|
||||
summary: "Get push relay status",
|
||||
description: "Get experimental push relay runtime status for this server.",
|
||||
operationId: "experimental.push.status",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Push relay status",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.object({
|
||||
enabled: z.boolean(),
|
||||
relaySecretSet: z.boolean(),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json(PushRelay.status())
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/push/test",
|
||||
describeRoute({
|
||||
summary: "Send test push event",
|
||||
description: "Send a test push event through the experimental APN relay integration.",
|
||||
operationId: "experimental.push.test",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Test event accepted",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(
|
||||
z.object({
|
||||
ok: z.boolean(),
|
||||
enabled: z.boolean(),
|
||||
}),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
secret: z.string(),
|
||||
sessionID: z.string().optional(),
|
||||
eventType: z.enum(["complete", "permission", "error"]).optional(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const body = c.req.valid("json")
|
||||
const status = PushRelay.status()
|
||||
if (!status.enabled) {
|
||||
return c.json(
|
||||
{
|
||||
data: { enabled: false },
|
||||
errors: [{ message: "Push relay is not enabled" }],
|
||||
success: false,
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
|
||||
if (!PushRelay.auth(body.secret)) {
|
||||
return c.json(
|
||||
{
|
||||
data: { enabled: true },
|
||||
errors: [{ message: "Invalid push relay secret" }],
|
||||
success: false,
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
|
||||
const ok = PushRelay.test({
|
||||
type: body.eventType ?? "permission",
|
||||
sessionID: body.sessionID ?? `test-${Date.now()}`,
|
||||
})
|
||||
|
||||
return c.json({
|
||||
ok,
|
||||
enabled: true,
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user