@christianirack/brainllm-runtime 0.6.1 → 0.7.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/brain-data.js +4 -2
- package/brain-env.js +8 -6
- package/brain-form.js +37 -29
- package/brain-inspect.js +19 -6
- package/brain-island.js +2 -1
- package/brain-lazy.js +2 -0
- package/brain-links.js +37 -0
- package/brain-live.js +21 -5
- package/brain-router.js +223 -178
- package/brain-trace.js +269 -0
- package/package.json +1 -1
- package/server/native-sockets.mjs +13 -10
- package/server/server.mjs +593 -86
- package/shim/dom.js +11 -2
- package/sw/brain-sw.js +17 -9
- package/ui/error-state.brain +1 -1
package/brain-trace.js
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// `brain trace`, the browser half (INTERNALS §49). Development only: the dev client (crates/brain-serve/src/client.js)
|
|
2
|
+
// imports it and nothing a production build ships does. It records navigation, prefetch, fetch and RPC with payload and
|
|
3
|
+
// response, console warnings, CSP and CORS failures, sockets and leak findings, and sends them over the HMR socket.
|
|
4
|
+
// Values are redacted by the store before they reach disk. A same-origin request carries `x-brain-trace`, the id the
|
|
5
|
+
// server and the backend then use, so one id follows it through every layer.
|
|
6
|
+
// A new module records without touching any of this: `globalThis.__brainTrace('mymodule', 'mykind', { title, level, data })`.
|
|
7
|
+
const G = globalThis
|
|
8
|
+
const BODY = 65536
|
|
9
|
+
const uuid = () => (G.crypto?.randomUUID ? G.crypto.randomUUID() : '00000000-0000-4000-8000-000000000000'.replace(/0/g, () => (Math.random() * 16 | 0).toString(16)))
|
|
10
|
+
const lasting = () => {
|
|
11
|
+
try {
|
|
12
|
+
const known = G.sessionStorage.getItem('brain:trace')
|
|
13
|
+
if (known) return known
|
|
14
|
+
const made = uuid()
|
|
15
|
+
G.sessionStorage.setItem('brain:trace', made)
|
|
16
|
+
return made
|
|
17
|
+
} catch { return uuid() }
|
|
18
|
+
}
|
|
19
|
+
export const ids = { session: lasting(), tab: uuid() }
|
|
20
|
+
|
|
21
|
+
let queue = [], send = null, timer = null
|
|
22
|
+
const flush = () => {
|
|
23
|
+
timer = null
|
|
24
|
+
if (!queue.length || !send) return
|
|
25
|
+
const events = queue.slice(0, 200)
|
|
26
|
+
if (send(JSON.stringify({ t: 'brain:trace', ...ids, events }))) queue = queue.slice(events.length)
|
|
27
|
+
if (queue.length) timer = setTimeout(flush, 250)
|
|
28
|
+
}
|
|
29
|
+
export function note(module, kind, fields = {}) {
|
|
30
|
+
if (queue.length >= 1000) queue.shift()
|
|
31
|
+
queue.push({ ts: Date.now(), module, kind, route: G.location?.pathname, ...fields })
|
|
32
|
+
timer ??= setTimeout(flush, 100)
|
|
33
|
+
}
|
|
34
|
+
export function attach(sender) {
|
|
35
|
+
send = sender
|
|
36
|
+
timer ??= setTimeout(flush, 0)
|
|
37
|
+
}
|
|
38
|
+
G.__brainTrace = (module, kind, fields) => note(String(module), String(kind), fields)
|
|
39
|
+
|
|
40
|
+
// ---------- locations: a generated line of a dev module back to its .brain line (the module's inline source map) ----------
|
|
41
|
+
const RUNTIME = '/@brain/'
|
|
42
|
+
const nativeFetch = G.fetch?.bind(G)
|
|
43
|
+
let failedId = null
|
|
44
|
+
// the id of the last failed call, for the error report that follows it (brain-err cannot see the request)
|
|
45
|
+
export const lastFailed = () => failedId
|
|
46
|
+
const MAPS = new Map()
|
|
47
|
+
function mapOf(url) {
|
|
48
|
+
if (!MAPS.has(url)) {
|
|
49
|
+
if (MAPS.size > 64) MAPS.clear()
|
|
50
|
+
MAPS.set(url, (async () => {
|
|
51
|
+
const text = await (await nativeFetch(url)).text()
|
|
52
|
+
const found = /sourceMappingURL=data:application\/json;base64,([A-Za-z0-9+/=]+)/.exec(text)
|
|
53
|
+
if (!found) return null
|
|
54
|
+
const { decode } = await import('./brain-err.js')
|
|
55
|
+
return decode(JSON.parse(atob(found[1])).mappings)
|
|
56
|
+
})().catch(() => null))
|
|
57
|
+
}
|
|
58
|
+
return MAPS.get(url)
|
|
59
|
+
}
|
|
60
|
+
export async function mapping(url, line) {
|
|
61
|
+
try {
|
|
62
|
+
const u = new URL(url, G.location.href)
|
|
63
|
+
if (u.origin !== G.location.origin || u.pathname.includes(RUNTIME)) return null
|
|
64
|
+
u.search = ''
|
|
65
|
+
const lines = await mapOf(u.href)
|
|
66
|
+
const original = lines && lines[line - 1]
|
|
67
|
+
return { file: u.pathname.replace(/^\//, ''), line: original || null, precision: original ? 'source-line' : 'module' }
|
|
68
|
+
} catch { return null }
|
|
69
|
+
}
|
|
70
|
+
// The first frame outside the runtime: who made this call.
|
|
71
|
+
const callSite = () => {
|
|
72
|
+
for (const row of String(new Error().stack).split('\n').slice(2)) {
|
|
73
|
+
const found = /((?:https?):\/\/[^\s)]+):(\d+):\d+/.exec(row)
|
|
74
|
+
if (found && !found[1].includes(RUNTIME)) return { url: found[1], line: +found[2] }
|
|
75
|
+
}
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
const located = async (site, fields) => {
|
|
79
|
+
const where = site && await mapping(site.url, site.line)
|
|
80
|
+
return where ? { file: where.file, line: where.line, ...fields } : fields
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------- fetch, RPC and prefetch ----------
|
|
84
|
+
// source modules are not responses anyone debugs, and their literals are not data
|
|
85
|
+
const textual = type => /json|text\/(?!event-stream|javascript)|xml|form-urlencoded/i.test(type || '')
|
|
86
|
+
async function bodyOf(response) {
|
|
87
|
+
const type = response.headers.get('content-type')
|
|
88
|
+
if (!textual(type)) return type ? `[${type}]` : null
|
|
89
|
+
const reader = response.clone().body?.getReader()
|
|
90
|
+
if (!reader) return null
|
|
91
|
+
const decoder = new TextDecoder()
|
|
92
|
+
let text = ''
|
|
93
|
+
try {
|
|
94
|
+
while (text.length < BODY) {
|
|
95
|
+
const { done, value } = await reader.read()
|
|
96
|
+
if (done) return text + decoder.decode()
|
|
97
|
+
text += decoder.decode(value, { stream: true })
|
|
98
|
+
}
|
|
99
|
+
return text.slice(0, BODY) + '…[truncated by the browser]'
|
|
100
|
+
} finally { reader.cancel().catch(() => {}) }
|
|
101
|
+
}
|
|
102
|
+
const payloadOf = (input, init) => {
|
|
103
|
+
const body = init?.body
|
|
104
|
+
if (typeof body === 'string') return body.slice(0, BODY)
|
|
105
|
+
if (body instanceof G.URLSearchParams) return String(body).slice(0, BODY)
|
|
106
|
+
if (body != null) return `[${body.constructor?.name || typeof body}]`
|
|
107
|
+
return input instanceof G.Request && input.method !== 'GET' && input.method !== 'HEAD' ? '[Request body]' : null
|
|
108
|
+
}
|
|
109
|
+
const kindOf = (url, init, headers) =>
|
|
110
|
+
url.pathname.includes('/_r/') ? 'rpc'
|
|
111
|
+
: init?.priority === 'low' || headers.has('x-brain-prefetch') ? 'prefetch'
|
|
112
|
+
: /text\/html/.test(headers.get('accept') || '') ? 'page' : 'fetch'
|
|
113
|
+
function wrapFetch() {
|
|
114
|
+
if (!nativeFetch || G.fetch.$trace) return
|
|
115
|
+
const wrapped = function (input, init) {
|
|
116
|
+
let url
|
|
117
|
+
try { url = new URL(input instanceof G.Request ? input.url : String(input), G.location.href) } catch { return nativeFetch(input, init) }
|
|
118
|
+
if (url.pathname.includes(RUNTIME) || !/^https?:$/.test(url.protocol) || /\.(?:js|mjs|brain|jsbrain|cssbrain)$/.test(url.pathname)) return nativeFetch(input, init)
|
|
119
|
+
const same = url.origin === G.location.origin, id = uuid(), site = callSite(), started = performance.now()
|
|
120
|
+
const headers = new Headers(init?.headers ?? (input instanceof G.Request ? input.headers : undefined))
|
|
121
|
+
const kind = kindOf(url, init, headers), method = (init?.method || (input instanceof G.Request ? input.method : 'GET')).toUpperCase()
|
|
122
|
+
const payload = payloadOf(input, init)
|
|
123
|
+
if (same) headers.set('x-brain-trace', id)
|
|
124
|
+
const route = same ? url.pathname + url.search : url.href
|
|
125
|
+
return nativeFetch(input, same ? { ...init, headers } : init).then(response => {
|
|
126
|
+
const elapsed = +(performance.now() - started).toFixed(1)
|
|
127
|
+
if (same && response.status >= 400) failedId = id
|
|
128
|
+
bodyOf(response).catch(() => null).then(async text => {
|
|
129
|
+
const fields = { request_id: same ? id : null, route, status: response.status, duration_ms: elapsed, data: { method, payload, response: text } }
|
|
130
|
+
note('browser', kind, response.status >= 400 ? await located(site, fields) : fields)
|
|
131
|
+
})
|
|
132
|
+
return response
|
|
133
|
+
}, async error => {
|
|
134
|
+
const elapsed = +(performance.now() - started).toFixed(1), aborted = error?.name === 'AbortError'
|
|
135
|
+
const fields = { request_id: same ? id : null, route, status: aborted ? null : 0, duration_ms: elapsed, level: aborted ? 'info' : 'error', title: aborted ? 'Request aborted' : same ? String(error?.message || error) : `Cross-origin request to ${url.origin} failed: ${error?.message || error}`, data: { method, payload, aborted } }
|
|
136
|
+
note('browser', aborted ? 'abort' : same ? kind : 'cors', aborted ? fields : await located(site, fields))
|
|
137
|
+
throw error
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
wrapped.$trace = 1
|
|
141
|
+
G.fetch = wrapped
|
|
142
|
+
}
|
|
143
|
+
// Uploads with a progress callback use XMLHttpRequest (brain-data).
|
|
144
|
+
function wrapXhr() {
|
|
145
|
+
const X = G.XMLHttpRequest?.prototype
|
|
146
|
+
if (!X || X.$trace) return
|
|
147
|
+
X.$trace = 1
|
|
148
|
+
const open = X.open, sendRequest = X.send
|
|
149
|
+
X.open = function (method, url, ...rest) {
|
|
150
|
+
this.$traced = { method: String(method).toUpperCase(), url: new URL(String(url), G.location.href) }
|
|
151
|
+
return open.call(this, method, url, ...rest)
|
|
152
|
+
}
|
|
153
|
+
X.send = function (body) {
|
|
154
|
+
const info = this.$traced, started = performance.now(), id = uuid()
|
|
155
|
+
if (info) {
|
|
156
|
+
const same = info.url.origin === G.location.origin
|
|
157
|
+
if (same) this.setRequestHeader('x-brain-trace', id)
|
|
158
|
+
this.addEventListener('loadend', () => note('browser', 'xhr', { request_id: same ? id : null, route: same ? info.url.pathname + info.url.search : info.url.href, status: this.status, duration_ms: +(performance.now() - started).toFixed(1), data: { method: info.method, payload: typeof body === 'string' ? body.slice(0, BODY) : body == null ? null : `[${body.constructor?.name}]` } }))
|
|
159
|
+
}
|
|
160
|
+
return sendRequest.call(this, body)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------- navigation ----------
|
|
165
|
+
function watchNavigation() {
|
|
166
|
+
const entry = G.performance?.getEntriesByType?.('navigation')?.[0]
|
|
167
|
+
const server = entry?.serverTiming?.find(item => item.name === 'rid')?.description
|
|
168
|
+
// the document itself: the server's id comes back in Server-Timing, the only header a page can read about its own load
|
|
169
|
+
const first = () => {
|
|
170
|
+
const done = G.performance?.getEntriesByType?.('navigation')?.[0]
|
|
171
|
+
note('browser', 'navigation', { request_id: server || null, route: G.location.pathname + G.location.search, status: done?.responseStatus || null, duration_ms: done?.loadEventEnd ? +done.loadEventEnd.toFixed(1) : null, data: { type: done?.type || 'navigate' } })
|
|
172
|
+
}
|
|
173
|
+
if (G.document.readyState === 'complete') first()
|
|
174
|
+
else G.addEventListener('load', () => setTimeout(first), { once: true })
|
|
175
|
+
G.addEventListener('brain:navigate', () => note('browser', 'navigation', { route: G.location.pathname + G.location.search, data: { type: 'client' } }))
|
|
176
|
+
G.addEventListener('popstate', () => note('browser', 'navigation', { data: { type: 'history' } }))
|
|
177
|
+
// <link rel="prefetch"> and speculation never pass through fetch()
|
|
178
|
+
try {
|
|
179
|
+
new PerformanceObserver(list => {
|
|
180
|
+
for (const item of list.getEntries()) {
|
|
181
|
+
if (item.initiatorType !== 'link' || !G.document.querySelector(`link[rel="prefetch"][href="${new URL(item.name).pathname}"]`)) continue
|
|
182
|
+
note('browser', 'prefetch', { route: new URL(item.name).pathname, duration_ms: +item.duration.toFixed(1), status: item.responseStatus || null, data: { initiator: 'link' } })
|
|
183
|
+
}
|
|
184
|
+
}).observe({ type: 'resource', buffered: true })
|
|
185
|
+
} catch {}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------- console, CSP ----------
|
|
189
|
+
function watchConsole() {
|
|
190
|
+
for (const [method, level] of [['warn', 'warn'], ['error', 'error']]) {
|
|
191
|
+
const original = G.console?.[method]
|
|
192
|
+
if (!original) continue
|
|
193
|
+
G.console[method] = function (...args) {
|
|
194
|
+
// brain-err prints the errors it records: those are already `browser/error` events
|
|
195
|
+
if (!(args[0] instanceof Error) && !String(args[0]).startsWith('BRAIN_ERROR')) {
|
|
196
|
+
const site = callSite(), title = args.map(a => (typeof a === 'string' ? a : (() => { try { return JSON.stringify(a) } catch { return String(a) } })())).join(' ').slice(0, 1024)
|
|
197
|
+
located(site, { level, title }).then(fields => note('browser', 'console', fields))
|
|
198
|
+
}
|
|
199
|
+
return original.apply(this, args)
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function watchCsp() {
|
|
204
|
+
G.document.addEventListener('securitypolicyviolation', async event => {
|
|
205
|
+
const { violatedDirective, effectiveDirective, blockedURI, sourceFile, lineNumber, columnNumber, disposition, sample } = event
|
|
206
|
+
const where = sourceFile && lineNumber ? await mapping(sourceFile, lineNumber) : null
|
|
207
|
+
note('browser', 'csp', {
|
|
208
|
+
level: 'error', title: `CSP ${effectiveDirective || violatedDirective} refused ${blockedURI || 'inline code'}`,
|
|
209
|
+
// a call made through the runtime's own wrappers says nothing about the app: the event keeps its route
|
|
210
|
+
file: where?.file ?? (sourceFile && !sourceFile.includes(RUNTIME) ? new URL(sourceFile, G.location.href).pathname : null), line: where ? where.line : sourceFile?.includes(RUNTIME) ? null : lineNumber || null,
|
|
211
|
+
data: { violation: { directive: effectiveDirective || violatedDirective, blocked: blockedURI, disposition, sample, sourceFile, line: lineNumber, column: columnNumber, precision: where?.precision || 'generated' } },
|
|
212
|
+
})
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---------- sockets ----------
|
|
217
|
+
function wrapSockets() {
|
|
218
|
+
const C = G.WebSocket
|
|
219
|
+
if (!C || C.$trace) return
|
|
220
|
+
const W = new Proxy(C, {
|
|
221
|
+
construct(target, args, next) {
|
|
222
|
+
const socket = Reflect.construct(target, args, next), url = String(args[0])
|
|
223
|
+
if (url.includes(RUNTIME)) return socket
|
|
224
|
+
const route = (() => { try { return new URL(url).pathname } catch { return url } })(), opened = performance.now()
|
|
225
|
+
let received = 0, sent = 0
|
|
226
|
+
socket.addEventListener('message', () => received++)
|
|
227
|
+
const write = socket.send
|
|
228
|
+
socket.send = function (...rest) { sent++; return write.apply(this, rest) }
|
|
229
|
+
socket.addEventListener('open', () => note('browser', 'socket-open', { route, data: { url } }))
|
|
230
|
+
socket.addEventListener('close', event => {
|
|
231
|
+
const clean = event.wasClean || event.code === 1000 || event.code === 1001
|
|
232
|
+
note('browser', clean ? 'socket-close' : 'socket', { route, level: clean ? 'info' : 'error', title: clean ? null : `Socket ${route} closed with code ${event.code}`, duration_ms: +(performance.now() - opened).toFixed(1), data: { url, code: event.code, reason: event.reason, received, sent } })
|
|
233
|
+
})
|
|
234
|
+
return socket
|
|
235
|
+
},
|
|
236
|
+
})
|
|
237
|
+
W.$trace = 1
|
|
238
|
+
G.WebSocket = W
|
|
239
|
+
const S = G.EventSource
|
|
240
|
+
if (!S) return
|
|
241
|
+
G.EventSource = new Proxy(S, {
|
|
242
|
+
construct(target, args, next) {
|
|
243
|
+
const source = Reflect.construct(target, args, next), route = String(args[0])
|
|
244
|
+
source.addEventListener('open', () => note('browser', 'socket-open', { route, data: { transport: 'sse' } }))
|
|
245
|
+
// a stream the browser will not retry is the failure; the ordinary reconnect is not
|
|
246
|
+
source.addEventListener('error', () => { if (source.readyState === 2) note('browser', 'socket', { route, level: 'error', title: `Event stream ${route} failed`, data: { transport: 'sse' } }) })
|
|
247
|
+
return source
|
|
248
|
+
},
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// brain-inspect finds the leaks and already sends them to the same socket; `inspected` reads its batch for this store.
|
|
253
|
+
export function inspected(batch) {
|
|
254
|
+
if (!batch.includes('"leak"')) return
|
|
255
|
+
try {
|
|
256
|
+
for (const record of JSON.parse(batch).d || []) {
|
|
257
|
+
if (record.k === 'leak') note('memory', 'leak', { level: 'warn', title: record.msg, code: record.code, fix: record.fix, component: record.name, file: record.file, line: record.line, data: { fx: record.fx } })
|
|
258
|
+
}
|
|
259
|
+
} catch {}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (G.document) {
|
|
263
|
+
wrapFetch()
|
|
264
|
+
wrapXhr()
|
|
265
|
+
wrapSockets()
|
|
266
|
+
watchConsole()
|
|
267
|
+
watchCsp()
|
|
268
|
+
watchNavigation()
|
|
269
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@christianirack/brainllm-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "The BRAINLLM runtime for the Rust-built web compiler for LLMs: dependency-free ES modules (signals, DOM, router, data, forms, channels). The brain binary embeds this same version.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -17,7 +17,7 @@ const unique = (headers, names) => {
|
|
|
17
17
|
return true
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
export function installNativeSockets(server, {target, base='/', cookie, log}) {
|
|
20
|
+
export function installNativeSockets(server, {target, base='/', cookie, client, log}) {
|
|
21
21
|
if (installed.has(server)) throw Error('The native socket bridge is already installed')
|
|
22
22
|
if (typeof target !== 'string' || target.length>2048) throw Error('Native sockets require a fixed loopback HTTP backend')
|
|
23
23
|
let origin
|
|
@@ -29,15 +29,17 @@ export function installNativeSockets(server, {target, base='/', cookie, log}) {
|
|
|
29
29
|
const path = (base === '/' ? '' : base.slice(0,-1)) + '/_brain/socket'
|
|
30
30
|
const owned = new Set()
|
|
31
31
|
let stopped = false, window = 0, reports = 0
|
|
32
|
-
// Fixed fields only: never log target URLs, credentials, frames
|
|
33
|
-
|
|
32
|
+
// Fixed fields only: never log target URLs, credentials, frames or exception messages. The one request header that
|
|
33
|
+
// is logged is a refused Origin, bounded, because it is the whole evidence of that refusal (B0743).
|
|
34
|
+
const report = (code,rid,origin) => {
|
|
34
35
|
const current = Math.floor(Date.now()/60000)
|
|
35
36
|
if (window !== current) { window=current; reports=0 }
|
|
36
37
|
if (reports++ >= 16) return
|
|
37
|
-
|
|
38
|
+
const refused = code === 'NATIVE_SOCKET_ORIGIN' ? {brainCode:'B0743',seenOrigin:typeof origin === 'string' ? origin.slice(0,256) : null,fix:'This Origin is not one canonical http(s) origin. Open the site at an exact origin listed in brain.json backend.sockets.allowedOrigins (never a wildcard). Run brain explain B0743'} : {fix:'Check native socket activation, the trusted Origin, authentication and backend availability; reconnect after correcting the cause'}
|
|
39
|
+
try { log('warn','native socket unavailable',{code,rid,...refused}) } catch {}
|
|
38
40
|
}
|
|
39
|
-
const reject = (socket,status,rid,code) => {
|
|
40
|
-
report(code,rid)
|
|
41
|
+
const reject = (socket,status,rid,code,origin) => {
|
|
42
|
+
report(code,rid,origin)
|
|
41
43
|
if (socket.destroyed) return
|
|
42
44
|
socket.setTimeout(LIMITS.close,()=>socket.destroy())
|
|
43
45
|
socket.end(`HTTP/1.1 ${status} Socket unavailable\r\nConnection: close\r\nCache-Control: no-store\r\nX-Request-Id: ${rid}\r\nContent-Length: 0\r\n\r\n`,()=>socket.destroy())
|
|
@@ -58,8 +60,8 @@ export function installNativeSockets(server, {target, base='/', cookie, log}) {
|
|
|
58
60
|
if (headerBytes>LIMITS.headers || !unique(incoming.rawHeaders,SINGLE)) return reject(browser,431,rid,'NATIVE_SOCKET_HEADERS')
|
|
59
61
|
if (head.length>LIMITS.head || incoming.headers['content-length'] && incoming.headers['content-length'] !== '0' || incoming.headers['transfer-encoding']) return reject(browser,400,rid,'NATIVE_SOCKET_BODY')
|
|
60
62
|
let site
|
|
61
|
-
try { site=new URL(incoming.headers.origin) } catch { return reject(browser,403,rid,'NATIVE_SOCKET_ORIGIN') }
|
|
62
|
-
if (!['http:','https:'].includes(site.protocol) || site.origin !== incoming.headers.origin || site.origin.length>2048) return reject(browser,403,rid,'NATIVE_SOCKET_ORIGIN')
|
|
63
|
+
try { site=new URL(incoming.headers.origin) } catch { return reject(browser,403,rid,'NATIVE_SOCKET_ORIGIN',incoming.headers.origin) }
|
|
64
|
+
if (!['http:','https:'].includes(site.protocol) || site.origin !== incoming.headers.origin || site.origin.length>2048) return reject(browser,403,rid,'NATIVE_SOCKET_ORIGIN',incoming.headers.origin)
|
|
63
65
|
const key = incoming.headers['sec-websocket-key']
|
|
64
66
|
if (incoming.headers.upgrade?.toLowerCase() !== 'websocket' || !connectionUpgrade(incoming.headers.connection) || incoming.headers['sec-websocket-version'] !== '13' || incoming.headers['sec-websocket-protocol'] !== PROTOCOL || typeof key !== 'string' || !/^[A-Za-z0-9+/]{22}==$/.test(key) || Buffer.from(key,'base64').toString('base64') !== key) return reject(browser,400,rid,'NATIVE_SOCKET_HANDSHAKE')
|
|
65
67
|
let credentials
|
|
@@ -116,7 +118,7 @@ export function installNativeSockets(server, {target, base='/', cookie, log}) {
|
|
|
116
118
|
try {
|
|
117
119
|
outgoing=request(new URL('/_brain/socket',origin),{
|
|
118
120
|
method:'GET',agent:false,maxHeaderSize:8192,
|
|
119
|
-
headers:{host:origin.host,origin:site.origin,connection:'Upgrade',upgrade:'websocket','sec-websocket-version':'13','sec-websocket-key':key,'sec-websocket-protocol':PROTOCOL,'x-request-id':rid,...(credentials ? {cookie:credentials} : {}),...(authorization !== undefined ? {authorization} : {})},
|
|
121
|
+
headers:{host:origin.host,origin:site.origin,connection:'Upgrade',upgrade:'websocket','sec-websocket-version':'13','sec-websocket-key':key,'sec-websocket-protocol':PROTOCOL,'x-request-id':rid,...(credentials ? {cookie:credentials} : {}),...(authorization !== undefined ? {authorization} : {}),...(client?.(incoming) ? {'x-forwarded-for':client(incoming)} : {})},
|
|
120
122
|
})
|
|
121
123
|
outgoing.once('error',()=>finish('NATIVE_SOCKET_UPSTREAM_UNAVAILABLE',502))
|
|
122
124
|
outgoing.once('response',response=>{
|
|
@@ -127,7 +129,8 @@ export function installNativeSockets(server, {target, base='/', cookie, log}) {
|
|
|
127
129
|
if (done) { socket.destroy(); return }
|
|
128
130
|
upstream=socket
|
|
129
131
|
socket.on('error',()=>finish('NATIVE_SOCKET_UPSTREAM_ERROR'))
|
|
130
|
-
|
|
132
|
+
// After a clean EOF the browser may still hold queued bytes: its own finish (or the bounded close timer) ends the tunnel.
|
|
133
|
+
socket.once('close',()=>{if (!socket.readableEnded || browser.writableFinished) finish()})
|
|
131
134
|
const expected=createHash('sha1').update(key+'258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64')
|
|
132
135
|
if (response.statusCode !== 101 || !unique(response.rawHeaders,new Set(['upgrade','sec-websocket-accept','sec-websocket-protocol'])) || response.headers.upgrade?.toLowerCase() !== 'websocket' || !connectionUpgrade(response.headers.connection) || response.headers['sec-websocket-accept'] !== expected || response.headers['sec-websocket-protocol'] !== PROTOCOL || response.headers['sec-websocket-extensions'] || buffered.length>LIMITS.upstreamHead) return finish('NATIVE_SOCKET_BAD_UPSTREAM_HANDSHAKE',502)
|
|
133
136
|
clearTimeout(handshake)
|