backeer-opencode 0.8.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,24 +1,22 @@
1
- const ID = /^[A-Za-z0-9_-]{1,160}$/
1
+ const ID = /^[A-Za-z0-9_-]+$/
2
2
  export const validBridgeID = (value) => typeof value === "string" && ID.test(value)
3
3
 
4
4
  export function createBridgeSanitizer(secrets = []) {
5
5
  const known = secrets.filter((value) => typeof value === "string" && value.length > 0)
6
6
  const assistants = new Map()
7
- function clipped(value, max = 12_000) {
8
- if (typeof value !== "string") return { value: "", truncated: false }
9
- let result = value.slice(0, 64_000)
7
+ function text(value) {
8
+ if (typeof value !== "string") return ""
9
+ let result = value
10
10
  for (const secret of known) result = result.split(secret).join("[REDACTED]")
11
11
  result = result
12
12
  .replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?(?:-----END [^-]*PRIVATE KEY-----|$)/g, "[REDACTED PRIVATE KEY]")
13
13
  .replace(/\bBearer\s+[^\s"'<>]+/gi, "Bearer [REDACTED]")
14
14
  .replace(/\b(?:sk|bgi)_[A-Za-z0-9_-]{12,}|\bsk-[A-Za-z0-9_-]{12,}/g, "[REDACTED]")
15
15
  .replace(/((?:api[_-]?key|access[_-]?token|secret|password|authorization)\s*[=:]\s*)[^\s,;]+/gi, "$1[REDACTED]")
16
- return { value: result.slice(0, max), truncated: value.length > 64_000 || result.length > max }
16
+ return result
17
17
  }
18
- function text(value, max) { return clipped(value, max).value }
19
18
  function content(key, value) {
20
- const result = clipped(value)
21
- return { [key]: result.value, ...(result.truncated ? { truncated: true } : {}) }
19
+ return { [key]: text(value) }
22
20
  }
23
21
  function sanitize(event) {
24
22
  const p = event?.properties ?? event?.data ?? {}
@@ -30,7 +28,6 @@ export function createBridgeSanitizer(secrets = []) {
30
28
  if (!validBridgeID(info?.id)) return undefined
31
29
  if (info.role === "assistant") {
32
30
  assistants.set(info.id, sessionID)
33
- if (assistants.size > 256) assistants.delete(assistants.keys().next().value)
34
31
  }
35
32
  return info.role === "assistant" ? { ...base, data: {
36
33
  messageID: info.id, completed: !!info.time?.completed, failed: !!info.error,
@@ -1,12 +1,12 @@
1
1
  import { createServer } from "node:http"
2
- import { randomUUID, timingSafeEqual } from "node:crypto"
2
+ import { randomUUID, timingSafeEqual, createHash } from "node:crypto"
3
+ import { resolve } from "node:path"
3
4
  import { createV1LiveClient } from "./live-bridge-client.js"
4
5
  import { createLiveConnectionNotifier, LIVE_HEARTBEAT_MS, LIVE_RECONNECT_MS } from "./live-connection.js"
5
6
  import { createBridgeSanitizer, validBridgeID } from "./live-bridge-events.js"
6
7
  import { questionSnapshot, resolveQuestionAnswers } from "./live-questions.js"
7
8
  import { PLUGIN_VERSION } from "./updates.js"
8
9
 
9
- const LIMITS = { events: 256, bytes: 2_000_000, clients: 8, commands: 1024, commandTTL: 86_400_000, body: 32_768, requests: 16 }
10
10
  function failure(status, code) { return Object.assign(new Error(code), { status, code }) }
11
11
  function json(response, status, value) {
12
12
  if (!response.destroyed && !response.headersSent) {
@@ -16,10 +16,7 @@ function json(response, status, value) {
16
16
  }
17
17
  async function readBody(request) {
18
18
  const chunks = []
19
- let bytes = 0
20
19
  for await (const chunk of request) {
21
- bytes += chunk.length
22
- if (bytes > LIMITS.body) throw failure(413, "body_too_large")
23
20
  chunks.push(chunk)
24
21
  }
25
22
  try { return JSON.parse(Buffer.concat(chunks).toString("utf8")) } catch { throw failure(400, "invalid_json") }
@@ -27,7 +24,10 @@ async function readBody(request) {
27
24
 
28
25
  export async function createLiveBridge({ config, client, directory, reporter }) {
29
26
  const options = config.liveBridge
30
- if (!options?.enabled) return { handle() {}, async dispose() {}, address: undefined }
27
+ if (!options?.enabled || (options.projectDirectory !== undefined &&
28
+ resolve(options.projectDirectory) !== resolve(directory))) {
29
+ return { handle() {}, async dispose() {}, address: undefined }
30
+ }
31
31
  const token = options.token
32
32
  if (typeof token !== "string" || token.length < 32 || token === config.token) {
33
33
  throw new Error("Live bridge requires a separate credential of at least 32 characters")
@@ -37,41 +37,46 @@ export async function createLiveBridge({ config, client, directory, reporter })
37
37
  const sanitizer = createBridgeSanitizer([token, config.token])
38
38
  const epoch = randomUUID()
39
39
  const events = [], streams = new Set(), commands = new Map(), states = new Map()
40
- let sequence = 0, bytes = 0, active = 0, disposed = false, disposeTask
41
- let lookups = 0
42
- const lookupWaiters = new Set()
40
+ let sequence = 0, disposed = false, disposeTask
41
+ const lookupWaiters = new Set(), lookups = new Map()
43
42
  const cursor = () => `${epoch}:${sequence}`
44
43
 
45
- function lookup(id) {
44
+ function lookup(id, signal) {
46
45
  if (disposed) return Promise.reject(failure(503, "bridge_closed"))
47
- if (lookups >= 16) return Promise.reject(failure(429, "session_lookup_busy"))
48
- lookups++
46
+ let entry = lookups.get(id)
47
+ if (!entry) {
48
+ entry = { waiters: new Set() }
49
+ lookups.set(id, entry)
50
+ const settled = (ok, value) => {
51
+ lookups.delete(id)
52
+ for (const finish of [...entry.waiters]) finish(ok, value)
53
+ }
54
+ // Retrying readers share one in-flight native lookup for the exact ID.
55
+ // Aborted HTTP readers are removable subscribers, not retained Promise callbacks.
56
+ Promise.resolve().then(() => api.get(id)).then(value => settled(true, value), error => settled(false, error))
57
+ }
49
58
  return new Promise((resolve, reject) => {
50
- let finished = false
51
- const finish = (complete, value) => {
52
- if (finished) return
53
- finished = true
54
- clearTimeout(timer)
59
+ const finish = (ok, value) => {
60
+ if (!entry.waiters.delete(finish)) return
55
61
  lookupWaiters.delete(cancel)
56
- complete(value)
62
+ signal?.removeEventListener("abort", aborted)
63
+ if (ok) resolve(value)
64
+ else reject(value)
57
65
  }
58
- const cancel = () => finish(reject, failure(503, "bridge_closed"))
59
- const timer = setTimeout(() => finish(reject, failure(504, "session_lookup_timed_out")), 1000)
66
+ const cancel = () => finish(false, failure(503, "bridge_closed"))
67
+ const aborted = () => finish(false, failure(499, "request_cancelled"))
68
+ entry.waiters.add(finish)
60
69
  lookupWaiters.add(cancel)
61
- // The caller's deadline does not release this slot: unsupported SDK
62
- // cancellation must not let timed-out, still-running calls accumulate.
63
- Promise.resolve().then(() => api.get(id)).then(
64
- (value) => { lookups--; finish(resolve, value) },
65
- (error) => { lookups--; finish(reject, error) },
66
- )
70
+ signal?.addEventListener("abort", aborted, { once: true })
71
+ if (signal?.aborted) aborted()
67
72
  })
68
73
  }
69
74
 
70
- async function session(id) {
75
+ async function session(id, signal) {
71
76
  if (!validBridgeID(id) || id === "global") throw failure(400, "invalid_session")
72
77
  let info
73
- try { info = await lookup(id) } catch (error) {
74
- if (["session_lookup_busy", "session_lookup_timed_out", "bridge_closed"].includes(error.code)) throw error
78
+ try { info = await lookup(id, signal) } catch (error) {
79
+ if (["bridge_closed", "request_cancelled"].includes(error.code)) throw error
75
80
  throw failure(404, "session_not_found")
76
81
  }
77
82
  if (info?.id !== id || typeof info.time?.archived === "number") {
@@ -91,18 +96,12 @@ export async function createLiveBridge({ config, client, directory, reporter })
91
96
  const removed = streams.delete(stream)
92
97
  clearInterval(stream.heartbeat)
93
98
  stream.queue.length = 0
94
- stream.queuedBytes = 0
95
99
  if (removed && !streams.size && !disposed) connectionNotifications.update(false)
96
100
  }
97
101
  function write(stream, value) {
98
102
  if (!streams.has(stream)) return
99
103
  if (stream.blocked) {
100
104
  stream.queue.push(value)
101
- stream.queuedBytes += Buffer.byteLength(value)
102
- if (stream.queuedBytes > LIMITS.bytes) {
103
- removeStream(stream)
104
- stream.response.destroy()
105
- }
106
105
  return
107
106
  }
108
107
  if (!stream.response.write(value)) {
@@ -120,17 +119,9 @@ export async function createLiveBridge({ config, client, directory, reporter })
120
119
  if (safe.type === "session.status") states.set(safe.sessionID, safe.data.status)
121
120
  if (safe.type === "session.idle") states.set(safe.sessionID, "idle")
122
121
  if (safe.type === "session.deleted") states.delete(safe.sessionID)
123
- if (states.size > 256) states.delete(states.keys().next().value)
124
122
  const record = { id: `${epoch}:${++sequence}`, ...safe, timestamp: Date.now() }
125
- let frame = `id: ${record.id}\nevent: backeer.event\ndata: ${JSON.stringify(record)}\n\n`
126
- if (Buffer.byteLength(frame) > 32_768) {
127
- record.data = { truncated: true }
128
- frame = `id: ${record.id}\nevent: backeer.event\ndata: ${JSON.stringify(record)}\n\n`
129
- }
130
- const size = Buffer.byteLength(frame)
131
- events.push({ sequence, sessionID: record.sessionID, frame, size })
132
- bytes += size
133
- while (events.length > LIMITS.events || bytes > LIMITS.bytes) bytes -= events.shift().size
123
+ const frame = `id: ${record.id}\nevent: backeer.event\ndata: ${JSON.stringify(record)}\n\n`
124
+ events.push({ sequence, sessionID: record.sessionID, frame })
134
125
  for (const stream of streams) if (!stream.sessionID || stream.sessionID === record.sessionID) write(stream, frame)
135
126
  }
136
127
  function authorized(request) {
@@ -141,7 +132,7 @@ export async function createLiveBridge({ config, client, directory, reporter })
141
132
  }
142
133
  function parseCursor(value) {
143
134
  if (!value) return { after: 0 }
144
- const match = /^([a-f0-9-]{36}):(\d{1,16})$/.exec(value)
135
+ const match = /^([a-f0-9-]{36}):(\d+)$/.exec(value)
145
136
  if (!match) throw failure(400, "invalid_cursor")
146
137
  if (match[1] !== epoch) return { gap: "stream_restarted" }
147
138
  const after = Number(match[2])
@@ -150,9 +141,6 @@ export async function createLiveBridge({ config, client, directory, reporter })
150
141
  return { after }
151
142
  }
152
143
  function cachedCommand(requestId, sessionID, signature) {
153
- for (const [id, command] of commands) {
154
- if (command.settled && Date.now() - command.created >= LIMITS.commandTTL) commands.delete(id)
155
- }
156
144
  const existing = commands.get(requestId)
157
145
  if (existing && (existing.sessionID !== sessionID || existing.signature !== signature)) {
158
146
  throw failure(409, "request_id_conflict")
@@ -162,17 +150,16 @@ export async function createLiveBridge({ config, client, directory, reporter })
162
150
  async function execute(requestId, sessionID, signature, operation, questionId) {
163
151
  let command = cachedCommand(requestId, sessionID, signature)
164
152
  if (!command) {
165
- if (commands.size >= LIMITS.commands) throw failure(429, "command_window_full")
166
153
  // Another chat/mobile/native UI may answer too: OpenCode is authoritative.
167
154
  // Reserve locally before calling it so concurrent request IDs cannot submit twice.
168
155
  if (questionId && [...commands.values()].some(item => item.sessionID === sessionID && item.questionId === questionId)) {
169
156
  throw failure(409, "question_reply_already_submitted")
170
157
  }
171
- command = { sessionID, signature, questionId, created: Date.now(), settled: false,
158
+ command = { sessionID, signature, questionId,
172
159
  result: { requestId, sessionID, ...(questionId ? { questionId } : {}),
173
160
  status: questionId ? "answered" : "accepted" } }
174
161
  commands.set(requestId, command)
175
- command.task = Promise.resolve().then(operation).finally(() => { command.settled = true })
162
+ command.task = Promise.resolve().then(operation).then(() => undefined)
176
163
  }
177
164
  await command.task
178
165
  return command.result
@@ -182,7 +169,6 @@ export async function createLiveBridge({ config, client, directory, reporter })
182
169
  const pending = await api.questions(sessionID)
183
170
  if (!Array.isArray(pending)) throw failure(502, "invalid_question_list")
184
171
  const own = pending.filter(item => item.sessionID === sessionID)
185
- if (own.length > 16) throw failure(413, "too_many_questions")
186
172
  return own
187
173
  }
188
174
  async function route(request, response) {
@@ -191,10 +177,9 @@ export async function createLiveBridge({ config, client, directory, reporter })
191
177
  request.headers["x-backeer-project"] !== encodeURIComponent(directory)) {
192
178
  return json(response, 409, { error: "project_mismatch" })
193
179
  }
194
- if (active >= LIMITS.requests) return json(response, 429, { error: "bridge_busy" })
195
- active++
196
- const timeout = setTimeout(() => json(response, 504, { error: "request_timed_out" }), 15_000)
197
- timeout.unref()
180
+ const controller = new AbortController()
181
+ const closed = () => controller.abort()
182
+ response.once("close", closed)
198
183
  try {
199
184
  const url = new URL(request.url, "http://127.0.0.1")
200
185
  if (url.username || url.password) throw failure(400, "invalid_url")
@@ -203,7 +188,7 @@ export async function createLiveBridge({ config, client, directory, reporter })
203
188
  const values = Array.isArray(result) ? result : result?.data
204
189
  if (!Array.isArray(values)) throw failure(502, "invalid_session_list")
205
190
  const own = []
206
- for (const info of values.slice(0, 100)) {
191
+ for (const info of values) {
207
192
  if (!validBridgeID(info?.id) || typeof info.time?.archived === "number") continue
208
193
  if (api.ownsSession ? await api.ownsSession(info) : (info.location?.directory ?? info.directory) === directory) own.push(info)
209
194
  }
@@ -220,11 +205,9 @@ export async function createLiveBridge({ config, client, directory, reporter })
220
205
  }
221
206
  if (request.method === "GET" && url.pathname === "/v1/events") {
222
207
  const selected = url.searchParams.get("sessionID") || undefined
223
- if (selected) await session(selected)
208
+ if (selected) await session(selected, controller.signal)
224
209
  const parsed = parseCursor(url.searchParams.get("after") ?? request.headers["last-event-id"])
225
210
  if (response.headersSent) return
226
- if (streams.size >= LIMITS.clients) throw failure(429, "too_many_streams")
227
- clearTimeout(timeout)
228
211
  response.writeHead(200, {
229
212
  "content-type": "text/event-stream", "cache-control": "no-cache, no-transform",
230
213
  "x-accel-buffering": "no", connection: "keep-alive",
@@ -234,7 +217,7 @@ export async function createLiveBridge({ config, client, directory, reporter })
234
217
  return
235
218
  }
236
219
  const stream = { response, sessionID: selected, heartbeat: undefined,
237
- blocked: false, queue: [], queuedBytes: 0 }
220
+ blocked: false, queue: [] }
238
221
  streams.add(stream)
239
222
  connectionNotifications.update(true)
240
223
  request.socket.setTimeout(0)
@@ -244,7 +227,6 @@ export async function createLiveBridge({ config, client, directory, reporter })
244
227
  stream.blocked = false
245
228
  while (streams.has(stream) && !stream.blocked && stream.queue.length) {
246
229
  const next = stream.queue.shift()
247
- stream.queuedBytes -= Buffer.byteLength(next)
248
230
  write(stream, next)
249
231
  }
250
232
  })
@@ -259,13 +241,12 @@ export async function createLiveBridge({ config, client, directory, reporter })
259
241
  }
260
242
  return
261
243
  }
262
- const match = /^\/v1\/sessions\/([A-Za-z0-9_-]{1,160})(\/instructions|\/questions(?:\/([A-Za-z0-9_-]{1,160})\/answers)?)?$/.exec(url.pathname)
244
+ const match = /^\/v1\/sessions\/([A-Za-z0-9_-]+)(\/instructions|\/questions(?:\/([A-Za-z0-9_-]+)\/answers)?)?$/.exec(url.pathname)
263
245
  if (!match) throw failure(404, "route_not_found")
264
- if (request.method === "GET" && !match[2]) return json(response, 200, await session(match[1]))
246
+ if (request.method === "GET" && !match[2]) return json(response, 200, await session(match[1], controller.signal))
265
247
  if (request.method === "GET" && match[2] === "/questions") {
266
- await session(match[1])
248
+ await session(match[1], controller.signal)
267
249
  const result = { questions: (await questions(match[1])).map(item => questionSnapshot(item, sanitizer.text)) }
268
- if (Buffer.byteLength(JSON.stringify(result)) > 240_000) throw failure(413, "questions_too_large")
269
250
  return json(response, 200, result)
270
251
  }
271
252
  if (request.method !== "POST" || !match[2]) throw failure(405, "method_not_allowed")
@@ -274,8 +255,8 @@ export async function createLiveBridge({ config, client, directory, reporter })
274
255
  if (match[3]) {
275
256
  if (!body || !validBridgeID(body.requestId) || !/^[a-f0-9]{64}$/.test(body.revision) ||
276
257
  Object.keys(body).some(key => !["requestId", "revision", "answers"].includes(key))) throw failure(400, "invalid_question_answer")
277
- await session(match[1])
278
- const signature = JSON.stringify({ questionId: match[3], revision: body.revision, answers: body.answers })
258
+ await session(match[1], controller.signal)
259
+ const signature = createHash("sha256").update(JSON.stringify({ questionId: match[3], revision: body.revision, answers: body.answers })).digest("hex")
279
260
  const existing = cachedCommand(body.requestId, match[1], signature)
280
261
  if (existing) { await existing.task; return json(response, 200, existing.result) }
281
262
  const pending = (await questions(match[1])).find(item => item.id === match[3])
@@ -289,25 +270,24 @@ export async function createLiveBridge({ config, client, directory, reporter })
289
270
  }
290
271
  if (match[2] !== "/instructions") throw failure(405, "method_not_allowed")
291
272
  if (!body || !validBridgeID(body.requestId) || typeof body.prompt !== "string" ||
292
- !body.prompt.trim() || body.prompt.length > 16_000 ||
273
+ !body.prompt.trim() ||
293
274
  Object.keys(body).some((key) => !["requestId", "prompt"].includes(key))) throw failure(400, "invalid_instruction")
294
- await session(match[1])
275
+ await session(match[1], controller.signal)
295
276
  if (response.headersSent || disposed) return
296
- const signature = JSON.stringify({ prompt: body.prompt })
277
+ const signature = createHash("sha256").update(JSON.stringify({ prompt: body.prompt })).digest("hex")
297
278
  const existing = cachedCommand(body.requestId, match[1], signature)
298
279
  const result = await execute(body.requestId, match[1], signature, () => api.prompt(match[1], body.prompt))
299
280
  return json(response, existing ? 200 : 202, result)
300
281
  } catch (error) {
301
282
  json(response, error.status ?? 502, { error: error.code ?? "opencode_request_failed" })
302
283
  } finally {
303
- active--
304
- clearTimeout(timeout)
284
+ response.removeListener("close", closed)
305
285
  }
306
286
  }
307
287
  const server = createServer((request, response) => { void route(request, response) })
308
- server.requestTimeout = 15_000
309
- server.headersTimeout = 10_000
310
- server.keepAliveTimeout = 5_000
288
+ server.requestTimeout = 0
289
+ server.headersTimeout = 0
290
+ server.keepAliveTimeout = 0
311
291
  await new Promise((resolve, reject) => {
312
292
  server.once("error", reject)
313
293
  server.listen(options.port ?? 43871, "127.0.0.1", () => { server.off("error", reject); resolve() })
@@ -325,6 +305,7 @@ export async function createLiveBridge({ config, client, directory, reporter })
325
305
  events.length = 0
326
306
  states.clear()
327
307
  commands.clear()
308
+ lookups.clear()
328
309
  sanitizer.clear()
329
310
  api.dispose?.()
330
311
  })