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/CHANGELOG.md +16 -0
- package/README.md +44 -0
- package/bin/backeer-opencode.js +1 -1
- package/dist/backeer-permission.js +935 -0
- package/package.json +9 -2
- package/src/config.js +59 -0
- package/src/event-publishers.js +214 -0
- package/src/http.js +87 -0
- package/src/index.js +1 -829
- package/src/management.js +207 -0
- package/src/permission-controller.js +195 -0
- package/src/plugin.js +160 -0
- package/src/reporter.js +60 -0
- package/src/sse.js +139 -0
- package/src/utilities.js +25 -0
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
|
+
}
|
package/src/utilities.js
ADDED
|
@@ -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
|
+
}
|