feat: add APN relay MVP and experimental push bridge
parent
8ac2fbbd12
commit
f276a8db42
|
|
@ -0,0 +1,11 @@
|
|||
PORT=8787
|
||||
|
||||
DATABASE_HOST=
|
||||
DATABASE_USERNAME=
|
||||
DATABASE_PASSWORD=
|
||||
DATABASE_NAME=main
|
||||
|
||||
APNS_TEAM_ID=
|
||||
APNS_KEY_ID=
|
||||
APNS_PRIVATE_KEY=
|
||||
APNS_DEFAULT_BUNDLE_ID=com.anomalyco.mobilevoice
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
FROM oven/bun:1.3.11-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
COPY tsconfig.json ./
|
||||
COPY drizzle.config.ts ./
|
||||
RUN bun install --production
|
||||
|
||||
COPY src ./src
|
||||
|
||||
EXPOSE 8787
|
||||
|
||||
CMD ["bun", "run", "src/index.ts"]
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
# APN Relay
|
||||
|
||||
Minimal APNs relay for OpenCode mobile background notifications.
|
||||
|
||||
## What it does
|
||||
|
||||
- Registers iOS device tokens for a shared secret.
|
||||
- Receives OpenCode event posts (`complete`, `permission`, `error`).
|
||||
- Sends APNs notifications to mapped devices.
|
||||
- Stores delivery rows in PlanetScale.
|
||||
|
||||
## Routes
|
||||
|
||||
- `GET /health`
|
||||
- `GET /` (simple dashboard)
|
||||
- `POST /v1/device/register`
|
||||
- `POST /v1/device/unregister`
|
||||
- `POST /v1/event`
|
||||
|
||||
## Environment
|
||||
|
||||
Use `.env.example` as a starting point.
|
||||
|
||||
- `DATABASE_HOST`
|
||||
- `DATABASE_USERNAME`
|
||||
- `DATABASE_PASSWORD`
|
||||
- `APNS_TEAM_ID`
|
||||
- `APNS_KEY_ID`
|
||||
- `APNS_PRIVATE_KEY`
|
||||
- `APNS_DEFAULT_BUNDLE_ID`
|
||||
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run src/index.ts
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
Build from this directory:
|
||||
|
||||
```bash
|
||||
docker build -t apn-relay .
|
||||
docker run --rm -p 8787:8787 --env-file .env apn-relay
|
||||
```
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { defineConfig } from "drizzle-kit"
|
||||
|
||||
export default defineConfig({
|
||||
out: "./migration",
|
||||
strict: true,
|
||||
schema: ["./src/**/*.sql.ts"],
|
||||
dialect: "mysql",
|
||||
dbCredentials: {
|
||||
host: process.env.DATABASE_HOST ?? "",
|
||||
user: process.env.DATABASE_USERNAME ?? "",
|
||||
password: process.env.DATABASE_PASSWORD ?? "",
|
||||
database: process.env.DATABASE_NAME ?? "main",
|
||||
ssl: {
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/apn-relay",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "bun run src/index.ts",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@planetscale/database": "1.19.0",
|
||||
"drizzle-orm": "catalog:",
|
||||
"hono": "catalog:",
|
||||
"jose": "6.0.11",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"drizzle-kit": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
import { connect } from "node:http2"
|
||||
import { SignJWT, importPKCS8 } from "jose"
|
||||
import { env } from "./env"
|
||||
|
||||
export type PushEnv = "sandbox" | "production"
|
||||
|
||||
type PushInput = {
|
||||
token: string
|
||||
bundle: string
|
||||
env: PushEnv
|
||||
title: string
|
||||
body: string
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
type PushResult = {
|
||||
ok: boolean
|
||||
code: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
let jwt = ""
|
||||
let exp = 0
|
||||
let pk: Awaited<ReturnType<typeof importPKCS8>> | undefined
|
||||
|
||||
function host(input: PushEnv) {
|
||||
if (input === "sandbox") return "api.sandbox.push.apple.com"
|
||||
return "api.push.apple.com"
|
||||
}
|
||||
|
||||
function key() {
|
||||
if (env.APNS_PRIVATE_KEY.includes("\\n")) return env.APNS_PRIVATE_KEY.replace(/\\n/g, "\n")
|
||||
return env.APNS_PRIVATE_KEY
|
||||
}
|
||||
|
||||
async function sign() {
|
||||
if (!pk) pk = await importPKCS8(key(), "ES256")
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
if (jwt && now < exp) return jwt
|
||||
jwt = await new SignJWT({})
|
||||
.setProtectedHeader({ alg: "ES256", kid: env.APNS_KEY_ID })
|
||||
.setIssuer(env.APNS_TEAM_ID)
|
||||
.setIssuedAt(now)
|
||||
.sign(pk)
|
||||
exp = now + 50 * 60
|
||||
return jwt
|
||||
}
|
||||
|
||||
function post(input: {
|
||||
host: string
|
||||
token: string
|
||||
auth: string
|
||||
bundle: string
|
||||
payload: string
|
||||
}): Promise<{ code: number; body: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const cli = connect(`https://${input.host}`)
|
||||
let done = false
|
||||
let code = 0
|
||||
let body = ""
|
||||
|
||||
const stop = (fn: () => void) => {
|
||||
if (done) return
|
||||
done = true
|
||||
fn()
|
||||
}
|
||||
|
||||
cli.on("error", (err) => {
|
||||
stop(() => reject(err))
|
||||
cli.close()
|
||||
})
|
||||
|
||||
const req = cli.request({
|
||||
":method": "POST",
|
||||
":path": `/3/device/${input.token}`,
|
||||
authorization: `bearer ${input.auth}`,
|
||||
"apns-topic": input.bundle,
|
||||
"apns-push-type": "alert",
|
||||
"apns-priority": "10",
|
||||
"content-type": "application/json",
|
||||
})
|
||||
|
||||
req.setEncoding("utf8")
|
||||
req.on("response", (headers) => {
|
||||
code = Number(headers[":status"] ?? 0)
|
||||
})
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk
|
||||
})
|
||||
req.on("end", () => {
|
||||
stop(() => resolve({ code, body }))
|
||||
cli.close()
|
||||
})
|
||||
req.on("error", (err) => {
|
||||
stop(() => reject(err))
|
||||
cli.close()
|
||||
})
|
||||
req.end(input.payload)
|
||||
})
|
||||
}
|
||||
|
||||
export async function send(input: PushInput): Promise<PushResult> {
|
||||
const auth = await sign().catch((err) => {
|
||||
return `error:${String(err)}`
|
||||
})
|
||||
if (auth.startsWith("error:")) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 0,
|
||||
error: auth,
|
||||
}
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({
|
||||
aps: {
|
||||
alert: {
|
||||
title: input.title,
|
||||
body: input.body,
|
||||
},
|
||||
sound: "default",
|
||||
},
|
||||
...input.data,
|
||||
})
|
||||
|
||||
const out = await post({
|
||||
host: host(input.env),
|
||||
token: input.token,
|
||||
auth,
|
||||
bundle: input.bundle,
|
||||
payload,
|
||||
}).catch((err) => ({
|
||||
code: 0,
|
||||
body: String(err),
|
||||
}))
|
||||
|
||||
if (out.code === 200) {
|
||||
return {
|
||||
ok: true,
|
||||
code: 200,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
code: out.code,
|
||||
error: out.body,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { Client } from "@planetscale/database"
|
||||
import { drizzle } from "drizzle-orm/planetscale-serverless"
|
||||
import { env } from "./env"
|
||||
|
||||
const client = new Client({
|
||||
host: env.DATABASE_HOST,
|
||||
username: env.DATABASE_USERNAME,
|
||||
password: env.DATABASE_PASSWORD,
|
||||
})
|
||||
|
||||
export const db = drizzle(client)
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { z } from "zod"
|
||||
|
||||
const schema = z.object({
|
||||
PORT: z.coerce.number().int().positive().default(8787),
|
||||
DATABASE_HOST: z.string().min(1),
|
||||
DATABASE_USERNAME: z.string().min(1),
|
||||
DATABASE_PASSWORD: z.string().min(1),
|
||||
APNS_TEAM_ID: z.string().min(1),
|
||||
APNS_KEY_ID: z.string().min(1),
|
||||
APNS_PRIVATE_KEY: z.string().min(1),
|
||||
APNS_DEFAULT_BUNDLE_ID: z.string().min(1),
|
||||
})
|
||||
|
||||
export const env = schema.parse(process.env)
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { createHash } from "node:crypto"
|
||||
|
||||
export function hash(input: string) {
|
||||
return createHash("sha256").update(input).digest("hex")
|
||||
}
|
||||
|
|
@ -0,0 +1,275 @@
|
|||
import { randomUUID } from "node:crypto"
|
||||
import { and, desc, eq, sql } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
import { z } from "zod"
|
||||
import { send } from "./apns"
|
||||
import { db } from "./db"
|
||||
import { env } from "./env"
|
||||
import { hash } from "./hash"
|
||||
import { delivery_log, device_registration } from "./schema.sql"
|
||||
import { setup } from "./setup"
|
||||
|
||||
const reg = z.object({
|
||||
secret: z.string().min(1),
|
||||
deviceToken: z.string().min(1),
|
||||
bundleId: z.string().min(1).optional(),
|
||||
apnsEnv: z.enum(["sandbox", "production"]).default("production"),
|
||||
})
|
||||
|
||||
const unreg = z.object({
|
||||
secret: z.string().min(1),
|
||||
deviceToken: z.string().min(1),
|
||||
})
|
||||
|
||||
const evt = z.object({
|
||||
secret: z.string().min(1),
|
||||
eventType: z.enum(["complete", "permission", "error"]),
|
||||
sessionID: z.string().min(1),
|
||||
title: z.string().min(1).optional(),
|
||||
body: z.string().min(1).optional(),
|
||||
})
|
||||
|
||||
function title(input: z.infer<typeof evt>["eventType"]) {
|
||||
if (input === "complete") return "Session complete"
|
||||
if (input === "permission") return "Action needed"
|
||||
return "Session error"
|
||||
}
|
||||
|
||||
function body(input: z.infer<typeof evt>["eventType"]) {
|
||||
if (input === "complete") return "OpenCode finished your session."
|
||||
if (input === "permission") return "OpenCode needs your permission decision."
|
||||
return "OpenCode reported an error for your session."
|
||||
}
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
app.onError((err, c) => {
|
||||
return c.json(
|
||||
{
|
||||
ok: false,
|
||||
error: err.message,
|
||||
},
|
||||
500,
|
||||
)
|
||||
})
|
||||
|
||||
app.notFound((c) => {
|
||||
return c.json(
|
||||
{
|
||||
ok: false,
|
||||
error: "Not found",
|
||||
},
|
||||
404,
|
||||
)
|
||||
})
|
||||
|
||||
app.get("/health", async (c) => {
|
||||
const [a] = await db.select({ value: sql<number>`count(*)` }).from(device_registration)
|
||||
const [b] = await db.select({ value: sql<number>`count(*)` }).from(delivery_log)
|
||||
return c.json({
|
||||
ok: true,
|
||||
devices: Number(a?.value ?? 0),
|
||||
deliveries: Number(b?.value ?? 0),
|
||||
})
|
||||
})
|
||||
|
||||
app.get("/", async (c) => {
|
||||
const [a] = await db.select({ value: sql<number>`count(*)` }).from(device_registration)
|
||||
const [b] = await db.select({ value: sql<number>`count(*)` }).from(delivery_log)
|
||||
const rows = await db.select().from(delivery_log).orderBy(desc(delivery_log.created_at)).limit(20)
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>APN Relay</title>
|
||||
<style>
|
||||
body { font-family: ui-sans-serif, system-ui, sans-serif; margin: 24px; color: #111827; }
|
||||
h1 { margin: 0 0 12px 0; }
|
||||
.stats { display: flex; gap: 16px; margin: 0 0 18px 0; }
|
||||
.card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 10px 12px; min-width: 160px; }
|
||||
.muted { color: #6b7280; font-size: 12px; }
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { border: 1px solid #e5e7eb; text-align: left; padding: 8px; font-size: 12px; }
|
||||
th { background: #f9fafb; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>APN Relay</h1>
|
||||
<p class="muted">MVP dashboard</p>
|
||||
<div class="stats">
|
||||
<div class="card">
|
||||
<div class="muted">Registered devices</div>
|
||||
<div>${Number(a?.value ?? 0)}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="muted">Delivery log rows</div>
|
||||
<div>${Number(b?.value ?? 0)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>time</th>
|
||||
<th>event</th>
|
||||
<th>session</th>
|
||||
<th>status</th>
|
||||
<th>error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows
|
||||
.map(
|
||||
(row) => `<tr>
|
||||
<td>${new Date(row.created_at).toISOString()}</td>
|
||||
<td>${row.event_type}</td>
|
||||
<td>${row.session_id}</td>
|
||||
<td>${row.status}</td>
|
||||
<td>${row.error ?? ""}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
return c.html(html)
|
||||
})
|
||||
|
||||
app.post("/v1/device/register", async (c) => {
|
||||
const raw = await c.req.json().catch(() => undefined)
|
||||
const check = reg.safeParse(raw)
|
||||
if (!check.success) {
|
||||
return c.json(
|
||||
{
|
||||
ok: false,
|
||||
error: "Invalid request body",
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const key = hash(check.data.secret)
|
||||
const row = {
|
||||
id: randomUUID(),
|
||||
secret_hash: key,
|
||||
device_token: check.data.deviceToken,
|
||||
bundle_id: check.data.bundleId ?? env.APNS_DEFAULT_BUNDLE_ID,
|
||||
apns_env: check.data.apnsEnv,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
|
||||
await db
|
||||
.insert(device_registration)
|
||||
.values(row)
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
bundle_id: row.bundle_id,
|
||||
apns_env: row.apns_env,
|
||||
updated_at: now,
|
||||
},
|
||||
})
|
||||
|
||||
return c.json({ ok: true })
|
||||
})
|
||||
|
||||
app.post("/v1/device/unregister", async (c) => {
|
||||
const raw = await c.req.json().catch(() => undefined)
|
||||
const check = unreg.safeParse(raw)
|
||||
if (!check.success) {
|
||||
return c.json(
|
||||
{
|
||||
ok: false,
|
||||
error: "Invalid request body",
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(device_registration)
|
||||
.where(
|
||||
and(
|
||||
eq(device_registration.secret_hash, hash(check.data.secret)),
|
||||
eq(device_registration.device_token, check.data.deviceToken),
|
||||
),
|
||||
)
|
||||
|
||||
return c.json({ ok: true })
|
||||
})
|
||||
|
||||
app.post("/v1/event", async (c) => {
|
||||
const raw = await c.req.json().catch(() => undefined)
|
||||
const check = evt.safeParse(raw)
|
||||
if (!check.success) {
|
||||
return c.json(
|
||||
{
|
||||
ok: false,
|
||||
error: "Invalid request body",
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
|
||||
const key = hash(check.data.secret)
|
||||
const list = await db.select().from(device_registration).where(eq(device_registration.secret_hash, key))
|
||||
if (!list.length) {
|
||||
return c.json({
|
||||
ok: true,
|
||||
sent: 0,
|
||||
failed: 0,
|
||||
})
|
||||
}
|
||||
|
||||
const out = await Promise.all(
|
||||
list.map((row) =>
|
||||
send({
|
||||
token: row.device_token,
|
||||
bundle: row.bundle_id,
|
||||
env: row.apns_env === "sandbox" ? "sandbox" : "production",
|
||||
title: check.data.title ?? title(check.data.eventType),
|
||||
body: check.data.body ?? body(check.data.eventType),
|
||||
data: {
|
||||
eventType: check.data.eventType,
|
||||
sessionID: check.data.sessionID,
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const now = Date.now()
|
||||
await db.insert(delivery_log).values(
|
||||
out.map((item) => ({
|
||||
id: randomUUID(),
|
||||
secret_hash: key,
|
||||
event_type: check.data.eventType,
|
||||
session_id: check.data.sessionID,
|
||||
status: item.ok ? "sent" : "failed",
|
||||
error: item.error,
|
||||
created_at: now,
|
||||
})),
|
||||
)
|
||||
|
||||
const sent = out.filter((item) => item.ok).length
|
||||
return c.json({
|
||||
ok: true,
|
||||
sent,
|
||||
failed: out.length - sent,
|
||||
})
|
||||
})
|
||||
|
||||
await setup()
|
||||
|
||||
if (import.meta.main) {
|
||||
Bun.serve({
|
||||
port: env.PORT,
|
||||
fetch: app.fetch,
|
||||
})
|
||||
console.log(`apn-relay listening on http://0.0.0.0:${env.PORT}`)
|
||||
}
|
||||
|
||||
export default app
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { bigint, index, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
|
||||
|
||||
export const device_registration = mysqlTable(
|
||||
"device_registration",
|
||||
{
|
||||
id: varchar("id", { length: 36 }).primaryKey(),
|
||||
secret_hash: varchar("secret_hash", { length: 64 }).notNull(),
|
||||
device_token: varchar("device_token", { length: 255 }).notNull(),
|
||||
bundle_id: varchar("bundle_id", { length: 255 }).notNull(),
|
||||
apns_env: varchar("apns_env", { length: 16 }).notNull().default("production"),
|
||||
created_at: bigint("created_at", { mode: "number" }).notNull(),
|
||||
updated_at: bigint("updated_at", { mode: "number" }).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("device_registration_secret_token_idx").on(table.secret_hash, table.device_token),
|
||||
index("device_registration_secret_hash_idx").on(table.secret_hash),
|
||||
],
|
||||
)
|
||||
|
||||
export const delivery_log = mysqlTable(
|
||||
"delivery_log",
|
||||
{
|
||||
id: varchar("id", { length: 36 }).primaryKey(),
|
||||
secret_hash: varchar("secret_hash", { length: 64 }).notNull(),
|
||||
event_type: varchar("event_type", { length: 32 }).notNull(),
|
||||
session_id: varchar("session_id", { length: 255 }).notNull(),
|
||||
status: varchar("status", { length: 16 }).notNull(),
|
||||
error: varchar("error", { length: 1024 }),
|
||||
created_at: bigint("created_at", { mode: "number" }).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index("delivery_log_secret_hash_idx").on(table.secret_hash),
|
||||
index("delivery_log_created_at_idx").on(table.created_at),
|
||||
],
|
||||
)
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { sql } from "drizzle-orm"
|
||||
import { db } from "./db"
|
||||
|
||||
export async function setup() {
|
||||
await db.execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS device_registration (
|
||||
id varchar(36) NOT NULL,
|
||||
secret_hash varchar(64) NOT NULL,
|
||||
device_token varchar(255) NOT NULL,
|
||||
bundle_id varchar(255) NOT NULL,
|
||||
apns_env varchar(16) NOT NULL DEFAULT 'production',
|
||||
created_at bigint NOT NULL,
|
||||
updated_at bigint NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY device_registration_secret_token_idx (secret_hash, device_token),
|
||||
KEY device_registration_secret_hash_idx (secret_hash)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`)
|
||||
|
||||
await db.execute(sql`
|
||||
CREATE TABLE IF NOT EXISTS delivery_log (
|
||||
id varchar(36) NOT NULL,
|
||||
secret_hash varchar(64) NOT NULL,
|
||||
event_type varchar(32) NOT NULL,
|
||||
session_id varchar(255) NOT NULL,
|
||||
status varchar(16) NOT NULL,
|
||||
error varchar(1024) NULL,
|
||||
created_at bigint NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY delivery_log_secret_hash_idx (secret_hash),
|
||||
KEY delivery_log_created_at_idx (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`)
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "@tsconfig/bun/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"noUncheckedIndexedAccess": false
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,173 @@
|
|||
# APN Relay MVP Layout
|
||||
|
||||
This is the minimum setup to get reliable mobile background notifications working with OpenCode.
|
||||
|
||||
## Part 1: APN Relay spec and routes
|
||||
|
||||
### Goal
|
||||
|
||||
- Receive event posts from OpenCode.
|
||||
- Look up device tokens by shared secret.
|
||||
- Send APNs notifications to iOS devices.
|
||||
- Keep the service small and easy to run in Docker.
|
||||
|
||||
### Stack
|
||||
|
||||
- Runtime: Bun
|
||||
- Web framework: Hono
|
||||
- Database: PlanetScale MySQL (via Drizzle ORM)
|
||||
- Deployment artifact: Docker image (`packages/apn-relay`)
|
||||
|
||||
### Minimal data model
|
||||
|
||||
- `device_registration`
|
||||
- `id`
|
||||
- `secret_hash` (hash of shared secret)
|
||||
- `device_token` (APNs token)
|
||||
- `bundle_id`
|
||||
- `apns_env` (`sandbox` or `production`)
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
- `delivery_log` (optional but recommended)
|
||||
- `id`
|
||||
- `secret_hash`
|
||||
- `event_type`
|
||||
- `session_id`
|
||||
- `status` (`sent` or `failed`)
|
||||
- `error`
|
||||
- `created_at`
|
||||
|
||||
### API routes
|
||||
|
||||
#### `GET /health`
|
||||
|
||||
- Response: `{ ok: true }`
|
||||
|
||||
#### `POST /v1/device/register`
|
||||
|
||||
- Purpose: upsert device token for a shared secret.
|
||||
- Body:
|
||||
- `secret` (string)
|
||||
- `deviceToken` (string)
|
||||
- `bundleId` (string)
|
||||
- `apnsEnv` (`sandbox` or `production`)
|
||||
- Response: `{ ok: true }`
|
||||
|
||||
#### `POST /v1/device/unregister`
|
||||
|
||||
- Purpose: remove token mapping for a shared secret.
|
||||
- Body:
|
||||
- `secret` (string)
|
||||
- `deviceToken` (string)
|
||||
- Response: `{ ok: true }`
|
||||
|
||||
#### `POST /v1/event`
|
||||
|
||||
- Purpose: receive event from OpenCode and push to all devices for that secret.
|
||||
- Body:
|
||||
- `secret` (string)
|
||||
- `eventType` (`complete` or `permission` or `error`)
|
||||
- `sessionID` (string)
|
||||
- `title` (optional string)
|
||||
- `body` (optional string)
|
||||
- Response:
|
||||
- `{ ok: true, sent: number, failed: number }`
|
||||
|
||||
### APNs behavior for MVP
|
||||
|
||||
- Use APNs auth key (`.p8`) with JWT auth.
|
||||
- Default to user-visible alert pushes for reliability.
|
||||
- `apns-push-type: alert`
|
||||
- `apns-priority: 10`
|
||||
- Payload includes `eventType` and `sessionID` in `data`.
|
||||
- Keep advanced silent/background tuning out of scope for MVP.
|
||||
|
||||
### Env vars
|
||||
|
||||
- `PORT`
|
||||
- `DATABASE_URL`
|
||||
- `APNS_TEAM_ID`
|
||||
- `APNS_KEY_ID`
|
||||
- `APNS_PRIVATE_KEY`
|
||||
- `APNS_DEFAULT_BUNDLE_ID` (fallback)
|
||||
|
||||
## Part 2: Mobile app setup (`packages/mobile-voice`)
|
||||
|
||||
### Goal
|
||||
|
||||
- Pair app with OpenCode server using QR data.
|
||||
- Register APNs token in relay using shared secret.
|
||||
- Keep existing foreground SSE behavior.
|
||||
- Receive APNs when app is backgrounded or terminated.
|
||||
|
||||
### Pairing flow (simple)
|
||||
|
||||
1. User runs OpenCode serve with relay enabled.
|
||||
2. OpenCode prints a QR code that includes:
|
||||
- `hosts` (array of server URLs)
|
||||
- `relayURL`
|
||||
- `relaySecret`
|
||||
3. User scans QR in mobile app.
|
||||
4. App saves `relaySecret` in secure storage and server profile metadata.
|
||||
|
||||
### Token registration flow
|
||||
|
||||
1. App gets APNs token (`Notifications.getDevicePushTokenAsync()`).
|
||||
2. App calls `POST {relayURL}/v1/device/register` with secret and token.
|
||||
3. App re-registers on token change and on app startup.
|
||||
|
||||
### Prompt and monitoring flow
|
||||
|
||||
1. App sends prompt to OpenCode (`POST /session/:id/prompt_async`).
|
||||
2. If app stays foregrounded, existing SSE monitor still updates UI quickly.
|
||||
3. If app goes backgrounded, APNs notification from relay carries state updates.
|
||||
|
||||
### Mobile changes
|
||||
|
||||
- Replace Expo push relay integration with APNs relay integration.
|
||||
- Keep local notification behavior for handling incoming payload data.
|
||||
- Store `relaySecret` with secure storage, not plain AsyncStorage.
|
||||
- Remove session-specific monitor start/stop calls for MVP.
|
||||
|
||||
## Part 3: OpenCode serve setup and modifications (`packages/opencode`)
|
||||
|
||||
### Goal
|
||||
|
||||
- Watch all sessions for the current OpenCode server.
|
||||
- Detect target events in OpenCode server.
|
||||
- Forward those events to APN relay using shared secret.
|
||||
|
||||
### Serve config and terminal UX
|
||||
|
||||
- Add serve options:
|
||||
- `--relay-url`
|
||||
- `--relay-secret` (optional; generate random if missing)
|
||||
- Default relay URL: `https://relay.opencode.ai`
|
||||
- If relay is configured, print QR payload in terminal:
|
||||
- `hosts` (local LAN and configured host, including Tailscale IP when present)
|
||||
- `relayURL`
|
||||
- `relaySecret`
|
||||
|
||||
### New experimental routes
|
||||
|
||||
- No required monitor routes for MVP.
|
||||
- Optional debug route:
|
||||
- `POST /experimental/push/test`
|
||||
- Purpose: force-send a test event to relay to validate config.
|
||||
|
||||
### Event forwarding behavior
|
||||
|
||||
- Subscribe to existing OpenCode events.
|
||||
- For all sessions under the running OpenCode server:
|
||||
- On `permission.asked` -> send `eventType=permission`
|
||||
- On `session.error` -> send `eventType=error`
|
||||
- On `session.status` idle (or `session.idle`) -> send `eventType=complete`
|
||||
- Include `sessionID` in every relay request so the mobile app can label the event.
|
||||
- Best effort posting only for MVP (log failures, no complex retry queue yet).
|
||||
|
||||
### Out of scope for this MVP
|
||||
|
||||
- Certificate-based trust between OpenCode and relay.
|
||||
- Complex key rotation UX.
|
||||
- Multi-tenant dashboard auth model.
|
||||
- Guaranteed delivery semantics.
|
||||
Loading…
Reference in New Issue