ocremote 1.3.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.
package/lib/rpc.mjs ADDED
@@ -0,0 +1,114 @@
1
+ import { b64 } from './crypto.mjs'
2
+
3
+ const MAX_CONCURRENT = 8
4
+ const MAX_UPLOAD = 24 << 20
5
+
6
+ export class RpcAdapter {
7
+ constructor({ baseUrl, authHeader, logger }) {
8
+ this.baseUrl = baseUrl.replace(/\/+$/, '')
9
+ this.authHeader = authHeader
10
+ this.logger = logger
11
+ this.active = new Map()
12
+ }
13
+
14
+ log(message) {
15
+ this.logger?.(message)
16
+ }
17
+
18
+ sessionMap(session) {
19
+ if (!this.active.has(session.id)) this.active.set(session.id, new Map())
20
+ return this.active.get(session.id)
21
+ }
22
+
23
+ cancel(session, id) {
24
+ const controller = this.sessionMap(session).get(id)
25
+ if (controller) {
26
+ controller.abort()
27
+ this.log(`rpc ${id} cancelled`)
28
+ }
29
+ }
30
+
31
+ cancelAll(session) {
32
+ const map = this.active.get(session.id)
33
+ if (!map) return
34
+ for (const controller of map.values()) {
35
+ try {
36
+ controller.abort()
37
+ } catch {}
38
+ }
39
+ this.active.delete(session.id)
40
+ }
41
+
42
+ async handle(session, msg) {
43
+ const id = msg.id
44
+ if (typeof id !== 'number' || typeof msg.path !== 'string') return
45
+ const map = this.sessionMap(session)
46
+ if (map.size >= MAX_CONCURRENT) {
47
+ session.sendSealed({ t: 'res', id, error: 'too_many_requests' })
48
+ return
49
+ }
50
+ if (!msg.path.startsWith('/') || msg.path.includes('..')) {
51
+ session.sendSealed({ t: 'res', id, error: 'bad_path' })
52
+ return
53
+ }
54
+
55
+ const controller = new AbortController()
56
+ map.set(id, controller)
57
+ const stream = msg.stream === true
58
+ let opened = false
59
+ try {
60
+ const url = new URL(`${this.baseUrl}${msg.path}`)
61
+ if (msg.query && typeof msg.query === 'object') {
62
+ for (const [key, value] of Object.entries(msg.query)) {
63
+ if (value === undefined || value === null) continue
64
+ url.searchParams.set(key, String(value))
65
+ }
66
+ }
67
+ const headers = { authorization: this.authHeader }
68
+ const init = { method: msg.method || 'GET', headers, signal: controller.signal }
69
+ if (msg.body) {
70
+ const body = Buffer.from(String(msg.body), 'base64')
71
+ if (body.length > MAX_UPLOAD) {
72
+ session.sendSealed({ t: 'res', id, error: 'body_too_large' })
73
+ return
74
+ }
75
+ init.body = body
76
+ headers['content-type'] = msg.contentType || 'application/json'
77
+ }
78
+
79
+ const res = await fetch(url, init)
80
+ if (!stream) {
81
+ const buffer = Buffer.from(await res.arrayBuffer())
82
+ session.sendSealed({
83
+ t: 'res',
84
+ id,
85
+ status: res.status,
86
+ contentType: res.headers.get('content-type') || '',
87
+ body: b64(buffer),
88
+ })
89
+ return
90
+ }
91
+
92
+ opened = true
93
+ session.sendSealed({ t: 'open', id, status: res.status, contentType: res.headers.get('content-type') || '' })
94
+ if (!res.body) {
95
+ session.sendSealed({ t: 'end', id })
96
+ return
97
+ }
98
+ for await (const chunk of res.body) {
99
+ if (controller.signal.aborted) break
100
+ session.sendSealed({ t: 'chunk', id, data: b64(chunk) })
101
+ }
102
+ session.sendSealed({ t: 'end', id })
103
+ } catch (err) {
104
+ if (controller.signal.aborted) {
105
+ session.sendSealed(opened ? { t: 'end', id, aborted: true } : { t: 'res', id, error: 'aborted' })
106
+ } else {
107
+ this.log(`rpc ${msg.method} ${msg.path} failed: ${err.message}`)
108
+ session.sendSealed(opened ? { t: 'end', id, error: 'stream_failed' } : { t: 'res', id, error: 'opencode_unreachable' })
109
+ }
110
+ } finally {
111
+ map.delete(id)
112
+ }
113
+ }
114
+ }