feat: support advertised QR hosts for mobile pairing
Allow serve to publish preferred host/domain entries in QR payloads and make mobile choose the first reachable host by QR order so preferred addresses like .ts.net are selected consistently.pull/19545/head
parent
d3ec6f75f4
commit
cb535eef9d
|
|
@ -342,6 +342,7 @@ type DropdownMode = "none" | "server" | "session"
|
||||||
|
|
||||||
type Pair = {
|
type Pair = {
|
||||||
v: 1
|
v: 1
|
||||||
|
name?: string
|
||||||
serverID?: string
|
serverID?: string
|
||||||
relayURL: string
|
relayURL: string
|
||||||
relaySecret: string
|
relaySecret: string
|
||||||
|
|
@ -426,10 +427,13 @@ function parsePair(input: string): Pair | undefined {
|
||||||
if (!Array.isArray((data as { hosts?: unknown }).hosts)) return
|
if (!Array.isArray((data as { hosts?: unknown }).hosts)) return
|
||||||
const hosts = (data as { hosts: unknown[] }).hosts.filter((item): item is string => typeof item === "string")
|
const hosts = (data as { hosts: unknown[] }).hosts.filter((item): item is string => typeof item === "string")
|
||||||
if (!hosts.length) return
|
if (!hosts.length) return
|
||||||
|
const nameRaw = (data as { name?: unknown }).name
|
||||||
|
const name = typeof nameRaw === "string" && nameRaw.trim().length > 0 ? nameRaw.trim() : undefined
|
||||||
const serverIDRaw = (data as { serverID?: unknown }).serverID
|
const serverIDRaw = (data as { serverID?: unknown }).serverID
|
||||||
const serverID = typeof serverIDRaw === "string" && serverIDRaw.length > 0 ? serverIDRaw : undefined
|
const serverID = typeof serverIDRaw === "string" && serverIDRaw.length > 0 ? serverIDRaw : undefined
|
||||||
return {
|
return {
|
||||||
v: 1,
|
v: 1,
|
||||||
|
name,
|
||||||
serverID,
|
serverID,
|
||||||
relayURL: (data as { relayURL: string }).relayURL,
|
relayURL: (data as { relayURL: string }).relayURL,
|
||||||
relaySecret: (data as { relaySecret: string }).relaySecret,
|
relaySecret: (data as { relaySecret: string }).relaySecret,
|
||||||
|
|
@ -444,10 +448,17 @@ function isLoopback(hostname: string): boolean {
|
||||||
return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "0.0.0.0" || hostname === "::1"
|
return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "0.0.0.0" || hostname === "::1"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isCarrierGradeNat(hostname: string): boolean {
|
||||||
|
const match = /^100\.(\d{1,3})\./.exec(hostname)
|
||||||
|
if (!match) return false
|
||||||
|
const octet = Number(match[1])
|
||||||
|
return octet >= 64 && octet <= 127
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Race all non-loopback hosts in parallel by hitting /health.
|
* Probe all non-loopback hosts in parallel by hitting /health, then
|
||||||
* Returns the first one that responds with 200, or falls back to the
|
* choose the first reachable host based on the original ordering.
|
||||||
* first non-loopback entry (preserving server-side ordering) if none respond.
|
* This preserves server-side preference (e.g. tailnet before LAN).
|
||||||
*/
|
*/
|
||||||
async function pickHost(list: string[]): Promise<string | undefined> {
|
async function pickHost(list: string[]): Promise<string | undefined> {
|
||||||
const candidates = list.filter((item) => {
|
const candidates = list.filter((item) => {
|
||||||
|
|
@ -460,27 +471,34 @@ async function pickHost(list: string[]): Promise<string | undefined> {
|
||||||
|
|
||||||
if (!candidates.length) return list[0]
|
if (!candidates.length) return list[0]
|
||||||
|
|
||||||
const controller = new AbortController()
|
const probes = candidates.map(async (host) => {
|
||||||
const timeout = setTimeout(() => controller.abort(), 3000)
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 3000)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${host.replace(/\/+$/, "")}/health`, {
|
||||||
|
method: "GET",
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
return res.ok
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
for (let index = 0; index < candidates.length; index += 1) {
|
||||||
|
const reachable = await probes[index]
|
||||||
|
if (reachable) {
|
||||||
|
return candidates[index]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// none reachable — keep first candidate as deterministic fallback
|
||||||
try {
|
try {
|
||||||
const winner = await Promise.any(
|
|
||||||
candidates.map(async (host) => {
|
|
||||||
const res = await fetch(`${host.replace(/\/+$/, "")}/health`, {
|
|
||||||
method: "GET",
|
|
||||||
signal: controller.signal,
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error(`${res.status}`)
|
|
||||||
return host
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
return winner
|
|
||||||
} catch {
|
|
||||||
// all failed or timed out — fall back to first candidate (server already orders by reachability)
|
|
||||||
return candidates[0]
|
return candidates[0]
|
||||||
} finally {
|
} catch {
|
||||||
clearTimeout(timeout)
|
return list[0]
|
||||||
controller.abort()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -489,11 +507,7 @@ function serverBases(input: string) {
|
||||||
const list = [base]
|
const list = [base]
|
||||||
try {
|
try {
|
||||||
const url = new URL(base)
|
const url = new URL(base)
|
||||||
const local =
|
const local = looksLikeLocalHost(url.hostname)
|
||||||
url.hostname === "127.0.0.1" ||
|
|
||||||
url.hostname === "localhost" ||
|
|
||||||
url.hostname === "::1" ||
|
|
||||||
url.hostname.startsWith("10.")
|
|
||||||
const tailnet = url.hostname.endsWith(".ts.net")
|
const tailnet = url.hostname.endsWith(".ts.net")
|
||||||
const secure = `https://${url.host}`
|
const secure = `https://${url.host}`
|
||||||
const insecure = `http://${url.host}`
|
const insecure = `http://${url.host}`
|
||||||
|
|
@ -514,11 +528,14 @@ function serverBases(input: string) {
|
||||||
|
|
||||||
function looksLikeLocalHost(hostname: string): boolean {
|
function looksLikeLocalHost(hostname: string): boolean {
|
||||||
return (
|
return (
|
||||||
|
hostname === "127.0.0.1" ||
|
||||||
|
hostname === "::1" ||
|
||||||
hostname === "localhost" ||
|
hostname === "localhost" ||
|
||||||
hostname.endsWith(".local") ||
|
hostname.endsWith(".local") ||
|
||||||
hostname.startsWith("10.") ||
|
hostname.startsWith("10.") ||
|
||||||
hostname.startsWith("192.168.") ||
|
hostname.startsWith("192.168.") ||
|
||||||
/^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname)
|
/^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname) ||
|
||||||
|
isCarrierGradeNat(hostname)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1553,7 +1570,12 @@ export default function DictationScreen() {
|
||||||
|
|
||||||
setLocalNetworkPermissionState("pending")
|
setLocalNetworkPermissionState("pending")
|
||||||
|
|
||||||
const localProbes = new Set<string>(["http://192.168.1.1", "http://192.168.0.1", "http://10.0.0.1"])
|
const localProbes = new Set<string>([
|
||||||
|
"http://192.168.1.1",
|
||||||
|
"http://192.168.0.1",
|
||||||
|
"http://10.0.0.1",
|
||||||
|
"http://100.100.100.100",
|
||||||
|
])
|
||||||
|
|
||||||
for (const server of serversRef.current) {
|
for (const server of serversRef.current) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -2127,8 +2149,13 @@ export default function DictationScreen() {
|
||||||
const base = candidates[0] ?? server.url.replace(/\/+$/, "")
|
const base = candidates[0] ?? server.url.replace(/\/+$/, "")
|
||||||
const healthURL = `${base}/health`
|
const healthURL = `${base}/health`
|
||||||
const sessionsURL = `${base}/experimental/session?limit=100`
|
const sessionsURL = `${base}/experimental/session?limit=100`
|
||||||
const insecureRemote =
|
let insecureRemote = false
|
||||||
base.startsWith("http://") && !base.includes("127.0.0.1") && !base.includes("localhost") && !base.includes("10.")
|
try {
|
||||||
|
const parsedBase = new URL(base)
|
||||||
|
insecureRemote = parsedBase.protocol === "http:" && !looksLikeLocalHost(parsedBase.hostname)
|
||||||
|
} catch {
|
||||||
|
insecureRemote = base.startsWith("http://")
|
||||||
|
}
|
||||||
console.log("[Server] refresh:start", {
|
console.log("[Server] refresh:start", {
|
||||||
id: server.id,
|
id: server.id,
|
||||||
name: server.name,
|
name: server.name,
|
||||||
|
|
@ -2517,7 +2544,7 @@ export default function DictationScreen() {
|
||||||
)
|
)
|
||||||
|
|
||||||
const addServer = useCallback(
|
const addServer = useCallback(
|
||||||
(serverURL: string, relayURL: string, relaySecretRaw: string, serverIDRaw?: string) => {
|
(serverURL: string, relayURL: string, relaySecretRaw: string, serverIDRaw?: string, nameRaw?: string) => {
|
||||||
const raw = serverURL.trim()
|
const raw = serverURL.trim()
|
||||||
if (!raw) return false
|
if (!raw) return false
|
||||||
|
|
||||||
|
|
@ -2542,9 +2569,11 @@ export default function DictationScreen() {
|
||||||
const id = `srv-${Date.now()}`
|
const id = `srv-${Date.now()}`
|
||||||
const relaySecret = relaySecretRaw.trim()
|
const relaySecret = relaySecretRaw.trim()
|
||||||
const serverID = typeof serverIDRaw === "string" && serverIDRaw.length > 0 ? serverIDRaw : null
|
const serverID = typeof serverIDRaw === "string" && serverIDRaw.length > 0 ? serverIDRaw : null
|
||||||
|
const explicitName = typeof nameRaw === "string" && nameRaw.trim().length > 0 ? nameRaw.trim() : null
|
||||||
const url = `${parsed.protocol}//${parsed.host}`
|
const url = `${parsed.protocol}//${parsed.host}`
|
||||||
const inferredName =
|
const inferredName =
|
||||||
parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost" ? "Local OpenCode" : parsed.hostname
|
parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost" ? "Local OpenCode" : parsed.hostname
|
||||||
|
const name = explicitName ?? inferredName
|
||||||
const relay = `${relayParsed.protocol}//${relayParsed.host}`
|
const relay = `${relayParsed.protocol}//${relayParsed.host}`
|
||||||
const existing = serversRef.current.find(
|
const existing = serversRef.current.find(
|
||||||
(item) =>
|
(item) =>
|
||||||
|
|
@ -2554,8 +2583,18 @@ export default function DictationScreen() {
|
||||||
(!serverID || item.serverID === serverID || item.serverID === null),
|
(!serverID || item.serverID === serverID || item.serverID === null),
|
||||||
)
|
)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (serverID && existing.serverID !== serverID) {
|
if ((serverID && existing.serverID !== serverID) || (explicitName && existing.name !== explicitName)) {
|
||||||
setServers((prev) => prev.map((item) => (item.id === existing.id ? { ...item, serverID } : item)))
|
setServers((prev) =>
|
||||||
|
prev.map((item) =>
|
||||||
|
item.id === existing.id
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
name: explicitName ?? item.name,
|
||||||
|
serverID: serverID ?? item.serverID,
|
||||||
|
}
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
setActiveServerId(existing.id)
|
setActiveServerId(existing.id)
|
||||||
setActiveSessionId(null)
|
setActiveSessionId(null)
|
||||||
|
|
@ -2568,7 +2607,7 @@ export default function DictationScreen() {
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
name: inferredName,
|
name,
|
||||||
url,
|
url,
|
||||||
serverID,
|
serverID,
|
||||||
relayURL: relay,
|
relayURL: relay,
|
||||||
|
|
@ -2675,7 +2714,7 @@ export default function DictationScreen() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const ok = addServer(host, pair.relayURL, pair.relaySecret, pair.serverID)
|
const ok = addServer(host, pair.relayURL, pair.relaySecret, pair.serverID, pair.name)
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
scanLockRef.current = false
|
scanLockRef.current = false
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -24,17 +24,51 @@ function ipTier(address: string): number {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function hosts(hostname: string, port: number) {
|
function norm(input: string) {
|
||||||
|
return input.replace(/\/+$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
function advertiseURL(input: string, port: number): string | undefined {
|
||||||
|
const raw = input.trim()
|
||||||
|
if (!raw) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(raw.includes("://") ? raw : `http://${raw}`)
|
||||||
|
if (!parsed.hostname) return
|
||||||
|
if (!parsed.port) {
|
||||||
|
parsed.port = String(port)
|
||||||
|
}
|
||||||
|
return norm(`${parsed.protocol}//${parsed.host}`)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hosts(hostname: string, port: number, advertised: string[] = []) {
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
|
const preferred: string[] = []
|
||||||
const entries: Array<{ url: string; tier: number }> = []
|
const entries: Array<{ url: string; tier: number }> = []
|
||||||
|
|
||||||
|
const addPreferred = (value: string) => {
|
||||||
|
const url = advertiseURL(value, port)
|
||||||
|
if (!url) return
|
||||||
|
if (seen.has(url)) return
|
||||||
|
seen.add(url)
|
||||||
|
preferred.push(url)
|
||||||
|
}
|
||||||
|
|
||||||
const add = (item: string) => {
|
const add = (item: string) => {
|
||||||
if (!item) return
|
if (!item) return
|
||||||
if (item === "0.0.0.0") return
|
if (item === "0.0.0.0") return
|
||||||
if (item === "::") return
|
if (item === "::") return
|
||||||
if (seen.has(item)) return
|
const url = `http://${item}:${port}`
|
||||||
seen.add(item)
|
if (seen.has(url)) return
|
||||||
entries.push({ url: `http://${item}:${port}`, tier: ipTier(item) })
|
seen.add(url)
|
||||||
|
entries.push({ url, tier: ipTier(item) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
advertised.forEach(addPreferred)
|
||||||
|
|
||||||
add(hostname)
|
add(hostname)
|
||||||
add("127.0.0.1")
|
add("127.0.0.1")
|
||||||
Object.values(os.networkInterfaces())
|
Object.values(os.networkInterfaces())
|
||||||
|
|
@ -43,7 +77,7 @@ function hosts(hostname: string, port: number) {
|
||||||
.map((item) => item.address)
|
.map((item) => item.address)
|
||||||
.forEach(add)
|
.forEach(add)
|
||||||
entries.sort((a, b) => a.tier - b.tier)
|
entries.sort((a, b) => a.tier - b.tier)
|
||||||
return entries.map((item) => item.url)
|
return [...preferred, ...entries.map((item) => item.url)]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ServeCommand = cmd({
|
export const ServeCommand = cmd({
|
||||||
|
|
@ -57,6 +91,11 @@ export const ServeCommand = cmd({
|
||||||
.option("relay-secret", {
|
.option("relay-secret", {
|
||||||
type: "string",
|
type: "string",
|
||||||
describe: "experimental APN relay secret",
|
describe: "experimental APN relay secret",
|
||||||
|
})
|
||||||
|
.option("advertise-host", {
|
||||||
|
type: "string",
|
||||||
|
array: true,
|
||||||
|
describe: "preferred host/domain for mobile QR (repeatable, supports host[:port] or URL)",
|
||||||
}),
|
}),
|
||||||
describe: "starts a headless opencode server",
|
describe: "starts a headless opencode server",
|
||||||
handler: async (args) => {
|
handler: async (args) => {
|
||||||
|
|
@ -72,6 +111,18 @@ export const ServeCommand = cmd({
|
||||||
process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_URL ??
|
process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_URL ??
|
||||||
"https://apn.dev.opencode.ai"
|
"https://apn.dev.opencode.ai"
|
||||||
).trim()
|
).trim()
|
||||||
|
const advertiseHostArg = args["advertise-host"]
|
||||||
|
const advertiseHostsFromArg = Array.isArray(advertiseHostArg)
|
||||||
|
? advertiseHostArg
|
||||||
|
: typeof advertiseHostArg === "string"
|
||||||
|
? [advertiseHostArg]
|
||||||
|
: []
|
||||||
|
const advertiseHostsFromEnv = (process.env.OPENCODE_EXPERIMENTAL_PUSH_ADVERTISE_HOSTS ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
const advertiseHosts = [...new Set([...advertiseHostsFromArg, ...advertiseHostsFromEnv])]
|
||||||
|
|
||||||
const input = (args["relay-secret"] ?? process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET ?? "").trim()
|
const input = (args["relay-secret"] ?? process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET ?? "").trim()
|
||||||
const relaySecret = input || randomBytes(18).toString("base64url")
|
const relaySecret = input || randomBytes(18).toString("base64url")
|
||||||
if (!input) {
|
if (!input) {
|
||||||
|
|
@ -88,13 +139,14 @@ export const ServeCommand = cmd({
|
||||||
relaySecret,
|
relaySecret,
|
||||||
hostname: host,
|
hostname: host,
|
||||||
port,
|
port,
|
||||||
|
advertiseHosts,
|
||||||
})
|
})
|
||||||
const pair = started ??
|
const pair = started ??
|
||||||
PushRelay.pair() ?? {
|
PushRelay.pair() ?? {
|
||||||
v: 1 as const,
|
v: 1 as const,
|
||||||
relayURL,
|
relayURL,
|
||||||
relaySecret,
|
relaySecret,
|
||||||
hosts: hosts(host, port),
|
hosts: hosts(host, port, advertiseHosts),
|
||||||
}
|
}
|
||||||
if (!started) {
|
if (!started) {
|
||||||
console.log("experimental push relay failed to initialize; showing setup qr anyway")
|
console.log("experimental push relay failed to initialize; showing setup qr anyway")
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ type Input = {
|
||||||
relaySecret: string
|
relaySecret: string
|
||||||
hostname: string
|
hostname: string
|
||||||
port: number
|
port: number
|
||||||
|
advertiseHosts?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
|
|
@ -99,18 +100,47 @@ function ipTier(address: string): number {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function list(hostname: string, port: number) {
|
function advertiseURL(input: string, port: number): string | undefined {
|
||||||
|
const raw = input.trim()
|
||||||
|
if (!raw) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(raw.includes("://") ? raw : `http://${raw}`)
|
||||||
|
if (!parsed.hostname) return
|
||||||
|
if (!parsed.port) {
|
||||||
|
parsed.port = String(port)
|
||||||
|
}
|
||||||
|
return norm(`${parsed.protocol}//${parsed.host}`)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function list(hostname: string, port: number, advertised: string[] = []) {
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
|
const preferred: string[] = []
|
||||||
const hosts: Array<{ url: string; tier: number }> = []
|
const hosts: Array<{ url: string; tier: number }> = []
|
||||||
|
|
||||||
|
const addPreferred = (input: string) => {
|
||||||
|
const url = advertiseURL(input, port)
|
||||||
|
if (!url) return
|
||||||
|
if (seen.has(url)) return
|
||||||
|
seen.add(url)
|
||||||
|
preferred.push(url)
|
||||||
|
}
|
||||||
|
|
||||||
const add = (host: string) => {
|
const add = (host: string) => {
|
||||||
if (!host) return
|
if (!host) return
|
||||||
if (host === "0.0.0.0") return
|
if (host === "0.0.0.0") return
|
||||||
if (host === "::") return
|
if (host === "::") return
|
||||||
if (seen.has(host)) return
|
const url = `http://${host}:${port}`
|
||||||
seen.add(host)
|
if (seen.has(url)) return
|
||||||
hosts.push({ url: `http://${host}:${port}`, tier: ipTier(host) })
|
seen.add(url)
|
||||||
|
hosts.push({ url, tier: ipTier(host) })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
advertised.forEach(addPreferred)
|
||||||
|
|
||||||
add(hostname)
|
add(hostname)
|
||||||
add("127.0.0.1")
|
add("127.0.0.1")
|
||||||
|
|
||||||
|
|
@ -124,7 +154,7 @@ function list(hostname: string, port: number) {
|
||||||
// sort: most externally reachable first, loopback last
|
// sort: most externally reachable first, loopback last
|
||||||
hosts.sort((a, b) => a.tier - b.tier)
|
hosts.sort((a, b) => a.tier - b.tier)
|
||||||
|
|
||||||
return hosts.map((item) => item.url)
|
return [...preferred, ...hosts.map((item) => item.url)]
|
||||||
}
|
}
|
||||||
|
|
||||||
function map(event: Event): { type: Type; sessionID: string } | undefined {
|
function map(event: Event): { type: Type; sessionID: string } | undefined {
|
||||||
|
|
@ -353,7 +383,7 @@ export namespace PushRelay {
|
||||||
serverID: serverID({ relayURL, relaySecret }),
|
serverID: serverID({ relayURL, relaySecret }),
|
||||||
relayURL,
|
relayURL,
|
||||||
relaySecret,
|
relaySecret,
|
||||||
hosts: list(input.hostname, input.port),
|
hosts: list(input.hostname, input.port, input.advertiseHosts ?? []),
|
||||||
}
|
}
|
||||||
|
|
||||||
const callback = (event: { payload: Event }) => {
|
const callback = (event: { payload: Event }) => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue