backeer-opencode 0.1.2 → 0.1.4

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.
package/src/sse.js ADDED
@@ -0,0 +1,139 @@
1
+ import { HttpError, isAbortError, sleep } from "./http.js"
2
+ import { limit } from "./utilities.js"
3
+
4
+ export function parseSseFrame(frame) {
5
+ const data = []
6
+ let event = "message"
7
+ let id
8
+
9
+ for (const line of frame.split(/\r?\n/)) {
10
+ if (!line || line.startsWith(":")) continue
11
+ const separator = line.indexOf(":")
12
+ const field = separator === -1 ? line : line.slice(0, separator)
13
+ let value = separator === -1 ? "" : line.slice(separator + 1)
14
+ if (value.startsWith(" ")) value = value.slice(1)
15
+
16
+ if (field === "event") event = value
17
+ if (field === "id") id = value
18
+ if (field === "data") data.push(value)
19
+ }
20
+
21
+ if (data.length === 0) return
22
+ return { event, id, data: data.join("\n") }
23
+ }
24
+
25
+ export function nextSseFrame(buffer) {
26
+ const lfIndex = buffer.indexOf("\n\n")
27
+ const crlfIndex = buffer.indexOf("\r\n\r\n")
28
+ if (lfIndex === -1 && crlfIndex === -1) return
29
+
30
+ if (crlfIndex !== -1 && (lfIndex === -1 || crlfIndex < lfIndex)) {
31
+ return {
32
+ frame: buffer.slice(0, crlfIndex),
33
+ rest: buffer.slice(crlfIndex + 4),
34
+ }
35
+ }
36
+ return { frame: buffer.slice(0, lfIndex), rest: buffer.slice(lfIndex + 2) }
37
+ }
38
+
39
+ export async function consumeSse(response, state, onCommand, signal, reporter) {
40
+ if (!response.body) throw new Error("Backeer command stream has no body")
41
+
42
+ const reader = response.body.getReader()
43
+ const decoder = new TextDecoder()
44
+ let buffer = ""
45
+
46
+ try {
47
+ while (!signal.aborted) {
48
+ const item = await reader.read()
49
+ if (item.done) break
50
+ buffer += decoder.decode(item.value, { stream: true })
51
+
52
+ for (;;) {
53
+ const next = nextSseFrame(buffer)
54
+ if (!next) break
55
+ buffer = next.rest
56
+ const message = parseSseFrame(next.frame)
57
+ if (!message) continue
58
+ if (message.id) state.lastEventID = message.id
59
+ if (message.event !== "message" && message.event !== "command") continue
60
+
61
+ try {
62
+ await onCommand(JSON.parse(message.data))
63
+ } catch (error) {
64
+ if (error instanceof SyntaxError) {
65
+ reporter.error("Invalid command received", "SSE data is not valid JSON")
66
+ continue
67
+ }
68
+ throw error
69
+ }
70
+ }
71
+ }
72
+ } finally {
73
+ await reader.cancel().catch(() => {})
74
+ }
75
+ }
76
+
77
+ export async function runCommandStream(
78
+ config,
79
+ reporter,
80
+ { signal, hasPending, onCommand },
81
+ ) {
82
+ const state = { lastEventID: "" }
83
+ let reconnectDelay = 1000
84
+ let lastLoggedError = ""
85
+
86
+ while (!signal.aborted && hasPending()) {
87
+ try {
88
+ const headers = {
89
+ Accept: "text/event-stream",
90
+ Authorization: `Bearer ${config.token}`,
91
+ }
92
+ if (state.lastEventID) headers["Last-Event-ID"] = state.lastEventID
93
+
94
+ const response = await fetch(config.commandStreamEndpoint, {
95
+ headers,
96
+ signal,
97
+ })
98
+ if (!response.ok) {
99
+ const responseBody = limit(await response.text(), 500)
100
+ throw new HttpError(
101
+ `Backeer command stream failed with status ${response.status}`,
102
+ response.status,
103
+ responseBody,
104
+ )
105
+ }
106
+ if (!response.headers.get("content-type")?.includes("text/event-stream")) {
107
+ throw new Error("Backeer command stream returned an invalid content type")
108
+ }
109
+
110
+ reconnectDelay = 1000
111
+ lastLoggedError = ""
112
+ await consumeSse(response, state, onCommand, signal, reporter)
113
+ } catch (error) {
114
+ if (signal.aborted || isAbortError(error)) return
115
+ const message = error instanceof HttpError
116
+ ? `${error.message}: ${error.responseBody}`
117
+ : limit(error, 500)
118
+ if (message !== lastLoggedError) {
119
+ const authenticationFailure = error instanceof HttpError &&
120
+ [401, 403].includes(error.status)
121
+ reporter.error(
122
+ authenticationFailure
123
+ ? "Authentication failed — run: npx backeer-opencode@latest configure"
124
+ : "Connection interrupted — retrying",
125
+ message,
126
+ )
127
+ lastLoggedError = message
128
+ }
129
+ if (error instanceof HttpError && [401, 403, 404].includes(error.status)) {
130
+ reconnectDelay = 60_000
131
+ }
132
+ }
133
+
134
+ if (!signal.aborted && hasPending()) {
135
+ await sleep(reconnectDelay, signal)
136
+ reconnectDelay = Math.min(reconnectDelay * 2, 60_000)
137
+ }
138
+ }
139
+ }
@@ -0,0 +1,25 @@
1
+ const MAX_TRACKED_ITEMS = 500
2
+
3
+ export const limit = (value, length = 1000) =>
4
+ String(value ?? "").slice(0, length)
5
+
6
+ export function enabled(value, fallback = true) {
7
+ if (typeof value === "boolean") return value
8
+ if (typeof value !== "string") return fallback
9
+ return !["0", "false", "no", "off"].includes(value.trim().toLowerCase())
10
+ }
11
+
12
+ export function stableHash(value) {
13
+ let hash = 2166136261
14
+ for (let index = 0; index < value.length; index++) {
15
+ hash ^= value.charCodeAt(index)
16
+ hash = Math.imul(hash, 16777619)
17
+ }
18
+ return (hash >>> 0).toString(16).padStart(8, "0")
19
+ }
20
+
21
+ export function rememberBounded(map, key, value) {
22
+ map.delete(key)
23
+ map.set(key, value)
24
+ while (map.size > MAX_TRACKED_ITEMS) map.delete(map.keys().next().value)
25
+ }