@christianirack/brainllm-runtime 0.3.1 → 0.5.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/THIRD_PARTY_NOTICES.txt +211 -0
- package/brain-budget.js +142 -0
- package/brain-ch.js +18 -4
- package/brain-data.js +13 -3
- package/brain-dev.js +191 -28
- package/brain-err.js +168 -43
- package/brain-form.js +58 -23
- package/brain-hash.js +17 -0
- package/brain-inspect.js +6 -4
- package/brain-interop.js +50 -0
- package/brain-live.js +364 -0
- package/brain-overlay.js +12 -0
- package/brain-preferences.js +64 -0
- package/brain-prefetch-shared.js +36 -0
- package/brain-prefetch.js +19 -0
- package/brain-router.js +144 -78
- package/brain-schema.js +69 -18
- package/brain-sqlite.js +122 -0
- package/brain-storage.js +143 -0
- package/brain-threads.js +49 -0
- package/brain-time.js +77 -0
- package/brain-url.js +125 -0
- package/brain-worker.js +105 -13
- package/brain.js +5 -4
- package/package.json +45 -1
- package/server/native-sockets.mjs +156 -0
- package/server/server.mjs +449 -41
- package/server/templates/gcs-adapter.mjs +99 -0
- package/templates/google-signin.js +102 -0
- package/ui/button.brain +1 -1
- package/ui/checkbox.brain +2 -1
- package/ui/dialog.brain +5 -3
- package/ui/error-state.brain +1 -1
- package/ui/input.brain +4 -3
- package/ui/menu.brain +4 -1
- package/ui/select.brain +2 -2
- package/ui/toast.brain +4 -1
package/brain-worker.js
CHANGED
|
@@ -3,17 +3,34 @@
|
|
|
3
3
|
// plus `const parseCsv = (...a) => w.call('parseCsv', a)`. Messages use structured clone: { id, name, args } →
|
|
4
4
|
// { id, ok } | { id, err: { message, name, stack } }. The worker starts on the first call; a remote error is rethrown
|
|
5
5
|
// in the page as an Error with the same message and name (its stack in `remoteStack`).
|
|
6
|
+
const TRANSFER = Symbol('brain-transfer')
|
|
7
|
+
export function transfer(value, list) {
|
|
8
|
+
if (!Array.isArray(list) || list.length > 32 || new Set(list).size !== list.length) throw new TypeError('transfer: provide at most 32 distinct transferable objects')
|
|
9
|
+
return { [TRANSFER]:true, value, list }
|
|
10
|
+
}
|
|
6
11
|
|
|
7
12
|
// Worker side. `name` may be a path into the map ('default.mean'); the result may be a promise.
|
|
8
13
|
export function serve(map, scope = globalThis) {
|
|
14
|
+
if ('onconnect' in scope) {
|
|
15
|
+
scope.onconnect = event => {
|
|
16
|
+
for (const port of event.ports || []) { serve(map,port); port.start() }
|
|
17
|
+
}
|
|
18
|
+
return
|
|
19
|
+
}
|
|
9
20
|
scope.onmessage = async e => {
|
|
10
21
|
const { id, name, args } = e.data || {}
|
|
11
22
|
try {
|
|
23
|
+
if (!Number.isSafeInteger(id) || id < 1 || typeof name !== 'string' || name.length > 256 || !Array.isArray(args)) throw new TypeError('worker: invalid RPC envelope')
|
|
12
24
|
let f = map, self
|
|
13
|
-
for (const k of
|
|
25
|
+
for (const k of name.split('.')) {
|
|
26
|
+
if (!k || /^(?:__proto__|prototype|constructor)$/.test(k)) throw new TypeError('worker: unsafe export path')
|
|
27
|
+
self = f
|
|
28
|
+
f = f == null ? undefined : Object.getOwnPropertyDescriptor(f, k)?.value
|
|
29
|
+
}
|
|
14
30
|
if (typeof f !== 'function') throw new TypeError(`worker: \`${name}\` is not an exported function`)
|
|
15
31
|
const ok = await f.apply(self, args || [])
|
|
16
|
-
scope.postMessage({
|
|
32
|
+
if (ok?.[TRANSFER]) scope.postMessage({id,ok:ok.value},ok.list)
|
|
33
|
+
else scope.postMessage({ id, ok }) // throws DataCloneError for a result that cannot be cloned → reported below
|
|
17
34
|
} catch (x) {
|
|
18
35
|
scope.postMessage({ id, err: { message: (x && x.message) || String(x), name: (x && x.name) || 'Error', stack: x && x.stack } })
|
|
19
36
|
}
|
|
@@ -21,39 +38,109 @@ export function serve(map, scope = globalThis) {
|
|
|
21
38
|
}
|
|
22
39
|
|
|
23
40
|
// Page side.
|
|
24
|
-
export function worker(url) {
|
|
25
|
-
let w = null, n = 0
|
|
41
|
+
export function worker(url, options = {}) {
|
|
42
|
+
let w = null, n = 0, inFlight = 0, idle = null
|
|
26
43
|
const wait = new Map()
|
|
44
|
+
const timeout = Math.max(10, Math.min(60000, Number(options.timeout) || 15000))
|
|
45
|
+
const capacity = Math.max(1, Math.min(256, Number(options.capacity) || 64))
|
|
46
|
+
const maxBytes = Math.max(1024, Math.min(67108864, Number(options.maxBytes) || 4194304))
|
|
47
|
+
const maxInFlight = Math.max(maxBytes, Math.min(67108864, Number(options.maxInFlightBytes) || 8388608))
|
|
48
|
+
function size(value) {
|
|
49
|
+
const seen = new Set(), stack = [[value, 0]]
|
|
50
|
+
let bytes = 0, nodes = 0
|
|
51
|
+
const push = (value, depth) => {
|
|
52
|
+
if (nodes + stack.length >= 4096) throw Object.assign(new Error('worker: payload structure exceeds its budget; transfer a bounded ArrayBuffer instead'), {code:'B0911'})
|
|
53
|
+
stack.push([value, depth])
|
|
54
|
+
}
|
|
55
|
+
while (stack.length) {
|
|
56
|
+
const [v, depth] = stack.pop()
|
|
57
|
+
if (++nodes > 4096 || depth > 24) throw Object.assign(new Error('worker: payload structure exceeds its budget; transfer a bounded ArrayBuffer instead'), { code: 'B0911' })
|
|
58
|
+
if (typeof v === 'string') bytes += v.length * 2
|
|
59
|
+
else if (v && typeof v === 'object' && !seen.has(v)) {
|
|
60
|
+
seen.add(v)
|
|
61
|
+
if (ArrayBuffer.isView(v)) push(v.buffer, depth + 1)
|
|
62
|
+
else if (v instanceof ArrayBuffer || (typeof SharedArrayBuffer === 'function' && v instanceof SharedArrayBuffer)) bytes += v.byteLength
|
|
63
|
+
else if (v instanceof Map) for (const [a,b] of v) { push(a,depth+1); push(b,depth+1) }
|
|
64
|
+
else if (v instanceof Set) for (const a of v) push(a,depth+1)
|
|
65
|
+
else for (const k in v) {
|
|
66
|
+
const d = Object.getOwnPropertyDescriptor(v,k)
|
|
67
|
+
if (!d) continue
|
|
68
|
+
if (!('value' in d)) throw new DOMException('Worker payload accessors are not supported', 'DataCloneError')
|
|
69
|
+
bytes += k.length * 2; push(d.value,depth+1)
|
|
70
|
+
}
|
|
71
|
+
} else bytes += 8
|
|
72
|
+
if (bytes > maxBytes) throw Object.assign(new Error('worker: payload byte budget exceeded; reduce the batch or configure a bounded larger budget'), { code: 'B0911' })
|
|
73
|
+
}
|
|
74
|
+
return bytes
|
|
75
|
+
}
|
|
27
76
|
const failAll = err => {
|
|
28
|
-
|
|
77
|
+
clearTimeout(idle); idle = null
|
|
78
|
+
const old = w
|
|
79
|
+
w = null
|
|
80
|
+
if (old) old.terminate()
|
|
81
|
+
for (const [, p] of wait) { p.clean(); p.fail(err) }
|
|
29
82
|
wait.clear()
|
|
30
|
-
w = null // the next call starts a fresh worker
|
|
31
83
|
}
|
|
32
84
|
const start = () => {
|
|
33
|
-
|
|
85
|
+
if (globalThis.location) {
|
|
86
|
+
const resolved = new URL(url,location.href)
|
|
87
|
+
if (resolved.origin !== location.origin || resolved.username || resolved.password) throw new TypeError('worker: use a same-origin compiled module URL')
|
|
88
|
+
}
|
|
89
|
+
if (options.shared) {
|
|
90
|
+
if (!globalThis.SharedWorker) throw new Error('SharedWorker is unavailable; use a dedicated worker or explicit tab coordination fallback')
|
|
91
|
+
const shared = new SharedWorker(url, {type:'module',name:options.name}), port = shared.port
|
|
92
|
+
const bridge = { postMessage:(m,t) => port.postMessage(m,t), terminate:() => port.close() }
|
|
93
|
+
port.onmessage = e => bridge.onmessage?.(e)
|
|
94
|
+
port.onmessageerror = e => bridge.onmessageerror?.(e)
|
|
95
|
+
shared.onerror = e => bridge.onerror?.(e)
|
|
96
|
+
port.start()
|
|
97
|
+
w = bridge
|
|
98
|
+
} else w = new Worker(url, { type: 'module' })
|
|
99
|
+
const instance = w
|
|
34
100
|
w.onmessage = e => {
|
|
101
|
+
if (w !== instance) return
|
|
35
102
|
const { id, ok, err } = e.data || {}
|
|
36
103
|
const p = wait.get(id)
|
|
37
104
|
if (!p) return
|
|
38
105
|
wait.delete(id)
|
|
39
|
-
|
|
40
|
-
|
|
106
|
+
p.clean()
|
|
107
|
+
if (!wait.size) {
|
|
108
|
+
idle = setTimeout(() => { if (w === instance && !wait.size) failAll(new Error('worker idle')) }, 30000)
|
|
109
|
+
idle.unref?.()
|
|
110
|
+
}
|
|
111
|
+
if (err) p.fail(Object.assign(new Error(err.message), { name: err.name, remoteStack: err.stack }))
|
|
112
|
+
else p.ok(ok)
|
|
41
113
|
}
|
|
42
114
|
w.onerror = e => {
|
|
115
|
+
if (w !== instance) return
|
|
43
116
|
if (e && e.preventDefault) e.preventDefault()
|
|
44
117
|
failAll(new Error('worker failed: ' + ((e && e.message) || url)))
|
|
45
118
|
}
|
|
119
|
+
w.onmessageerror = () => { if (w === instance) failAll(new Error('worker: response could not be decoded')) }
|
|
46
120
|
}
|
|
47
121
|
return {
|
|
48
|
-
call(name, args) {
|
|
49
|
-
if (
|
|
122
|
+
call(name, args = [], o = {}) {
|
|
123
|
+
if (o.signal?.aborted) return Promise.reject(Object.assign(new Error('worker call aborted'), { name: 'AbortError', kind: 'aborted' }))
|
|
124
|
+
if (wait.size >= capacity) return Promise.reject(Object.assign(new Error('worker: pending call capacity exceeded; await existing work'), { code: 'B0911' }))
|
|
125
|
+
if (typeof name !== 'string' || name.length > 256 || !Array.isArray(args)) return Promise.reject(new TypeError('worker: invalid call'))
|
|
126
|
+
let bytes
|
|
127
|
+
try { bytes = size(args) } catch (e) { return Promise.reject(e) }
|
|
128
|
+
if (inFlight + bytes > maxInFlight) return Promise.reject(Object.assign(new Error('worker: in-flight byte capacity exceeded; await pending work'), { code:'B0911' }))
|
|
129
|
+
try { if (!w) start() } catch (e) { return Promise.reject(e) }
|
|
130
|
+
clearTimeout(idle); idle = null
|
|
50
131
|
const id = ++n
|
|
51
132
|
return new Promise((ok, fail) => {
|
|
52
|
-
|
|
133
|
+
const abort = () => failAll(Object.assign(new Error('worker call aborted; shared pending calls cancelled'), { name: 'AbortError', kind: 'aborted' }))
|
|
134
|
+
const timer = setTimeout(() => failAll(Object.assign(new Error('worker: deadline exceeded; move bounded work into smaller batches'), { code: 'B0910' })), timeout)
|
|
135
|
+
inFlight += bytes
|
|
136
|
+
const clean = () => { inFlight -= bytes; clearTimeout(timer); o.signal?.removeEventListener('abort', abort) }
|
|
137
|
+
wait.set(id, { ok, fail, clean })
|
|
138
|
+
o.signal?.addEventListener('abort', abort, { once: true })
|
|
53
139
|
try {
|
|
54
|
-
w.postMessage({ id, name, args })
|
|
140
|
+
w.postMessage({ id, name, args }, o.transfer || [])
|
|
55
141
|
} catch (x) {
|
|
56
142
|
wait.delete(id)
|
|
143
|
+
clean()
|
|
57
144
|
fail(x) // DataCloneError: an argument cannot cross to the worker (functions, DOM nodes…)
|
|
58
145
|
}
|
|
59
146
|
})
|
|
@@ -64,3 +151,8 @@ export function worker(url) {
|
|
|
64
151
|
},
|
|
65
152
|
}
|
|
66
153
|
}
|
|
154
|
+
|
|
155
|
+
export function sharedWorker(url, options = {}) {
|
|
156
|
+
if (typeof options.name !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,79}$/.test(options.name)) throw new TypeError('sharedWorker: provide a stable public name of at most 80 characters')
|
|
157
|
+
return worker(url, { ...options, shared:true })
|
|
158
|
+
}
|
package/brain.js
CHANGED
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
// · r run · q clean · n unlink · d dispose. Short names: brain-min does not rename properties.
|
|
6
6
|
|
|
7
7
|
// L tracker · O owner · Q pending effects · B batch depth · P flush scheduled · H dev hook · E error hook · V provider (brain-ch)
|
|
8
|
-
let L = null, O = null, Q = [], B = 0, P = 0, H = null, E = null, V, A = null
|
|
8
|
+
let L = null, O = null, Q = [], B = 0, P = 0, H = null, E = null, V, A = null, U
|
|
9
9
|
// Errors (INTERNALS §18): fx, drv and handlers are caught and the app keeps running. E (brain-err.js, _err) records
|
|
10
10
|
// them and applies per-component fallbacks; without it, console.error.
|
|
11
11
|
// Errors go to the rich catcher (brain-err) or the console, then to the nearest component boundary (§42, brain-guard.js):
|
|
12
12
|
// up the owner chain for fx/drv, up the DOM for handlers.
|
|
13
13
|
const oops = (e, x, w) => {
|
|
14
14
|
if (e && e.kind == 'aborted') return // a cancelled request (brain-data): never an error to report or show
|
|
15
|
-
E ? E(e, x, w) : globalThis.reportError ? reportError(e) : console.error(e)
|
|
15
|
+
E ? E(e, x, w) : H ? globalThis.reportError ? reportError(e) : console.error(e) : console.error('BRAIN_ERROR: Operation failed. Try again.')
|
|
16
16
|
for (let o = x; o; o = o.nodeType ? o.parentNode : o.p) {
|
|
17
17
|
const g = o.$g
|
|
18
18
|
if (g) return g(e, w)
|
|
@@ -155,7 +155,7 @@ function flush() {
|
|
|
155
155
|
}
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
export const sig = (v, k) => (k && H ? H(new N(v, null, 0), k) : new N(v, null, 0))
|
|
158
|
+
export const sig = (v, k) => (U && U('signals', O, 1), k && H ? H(new N(v, null, 0), k) : new N(v, null, 0))
|
|
159
159
|
export const drv = f => new N(undefined, f, 0)
|
|
160
160
|
export const fx = f => {
|
|
161
161
|
const n = new N(undefined, f, 1)
|
|
@@ -201,7 +201,7 @@ export const rpc = (id, a) => import('./brain-rpc.js').then(m => m.rpc(id, a, oo
|
|
|
201
201
|
// Internals for sibling modules (brain-err, brain-ch): root, current owner and node helpers; _err installs the hook;
|
|
202
202
|
// brain-ch passes its provider for `mount(C, t, props, { provide })`.
|
|
203
203
|
// Optimistic writes (brain-data) and the circuit breaker (brain-guard) wrap N's prototype instead of a core hook (#288).
|
|
204
|
-
export const _rt = p => ((V = V || p), { root, owner: () => O, nodes, rm, oops, guard: (o, g) => (o.$g = g), adopt: a => (A = a), dev: (a, b) => H && H(a, b) })
|
|
204
|
+
export const _rt = p => ((V = V || p), { root, owner: () => O, nodes, rm, oops, guard: (o, g) => (o.$g = g), adopt: a => (A = a), dev: (a, b) => H && H(a, b), development: () => !!H, budget: f => (U = f) })
|
|
205
205
|
export const _err = h => ((E = h), _rt())
|
|
206
206
|
|
|
207
207
|
// ---------- DOM ----------
|
|
@@ -351,6 +351,7 @@ export function list(p, f, key, render, end, empty) {
|
|
|
351
351
|
let rows = new Map(), order = [], emp = null
|
|
352
352
|
fx(() => {
|
|
353
353
|
const items = f() || [], me = O
|
|
354
|
+
if (U) U('rows', me, items.length)
|
|
354
355
|
untrack(() => {
|
|
355
356
|
// Large updates (>500 rows, some already on screen) create new rows in ≤16 ms slices (one frame; QA #302: 5 ms
|
|
356
357
|
// split a 1,000-row update into extra frames): the rest is skipped and this fx re-runs after a yield (INP). A first render (no rows yet) is built at once: slicing it only re-walks the list.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@christianirack/brainllm-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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": {
|
|
@@ -8,6 +8,50 @@
|
|
|
8
8
|
"./*": "./*"
|
|
9
9
|
},
|
|
10
10
|
"sideEffects": false,
|
|
11
|
+
"keywords": [
|
|
12
|
+
"runtime",
|
|
13
|
+
"zero-dependency",
|
|
14
|
+
"dom",
|
|
15
|
+
"router",
|
|
16
|
+
"llm",
|
|
17
|
+
"ai",
|
|
18
|
+
"ai-agents",
|
|
19
|
+
"coding-agent",
|
|
20
|
+
"agentic",
|
|
21
|
+
"claude-code",
|
|
22
|
+
"cursor",
|
|
23
|
+
"mcp",
|
|
24
|
+
"model-context-protocol",
|
|
25
|
+
"frontend",
|
|
26
|
+
"front-end",
|
|
27
|
+
"backend",
|
|
28
|
+
"back-end",
|
|
29
|
+
"fullstack",
|
|
30
|
+
"full-stack",
|
|
31
|
+
"cli",
|
|
32
|
+
"command-line",
|
|
33
|
+
"developer-tools",
|
|
34
|
+
"design-system",
|
|
35
|
+
"css",
|
|
36
|
+
"testing",
|
|
37
|
+
"e2e",
|
|
38
|
+
"postgresql",
|
|
39
|
+
"jwt",
|
|
40
|
+
"web-framework",
|
|
41
|
+
"framework",
|
|
42
|
+
"compiler",
|
|
43
|
+
"rust",
|
|
44
|
+
"fast",
|
|
45
|
+
"performance",
|
|
46
|
+
"react-alternative",
|
|
47
|
+
"vite-alternative",
|
|
48
|
+
"signals",
|
|
49
|
+
"reactive",
|
|
50
|
+
"components",
|
|
51
|
+
"ssr",
|
|
52
|
+
"spa",
|
|
53
|
+
"web"
|
|
54
|
+
],
|
|
11
55
|
"license": "MIT",
|
|
12
56
|
"author": "Christian Irack Flores Maldonado <christian@irack.mx> (https://ingenieria.dev)",
|
|
13
57
|
"repository": {
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Fixed loopback Upgrade transport. Rust owns WebSocket frames, authentication and per-delivery policy.
|
|
2
|
+
import {request} from 'node:http'
|
|
3
|
+
import {createHash, randomUUID} from 'node:crypto'
|
|
4
|
+
|
|
5
|
+
const PROTOCOL = 'brain.v1'
|
|
6
|
+
const LIMITS = Object.freeze({connections:32, headers:16384, credentials:16384, head:4096, upstreamHead:8192, queued:262144, bytes:64*1024*1024, handshake:5000, write:2000, idle:70000, lifetime:15*60*1000, close:1000})
|
|
7
|
+
const SINGLE = new Set(['host','origin','authorization','cookie','upgrade','sec-websocket-key','sec-websocket-version','sec-websocket-protocol'])
|
|
8
|
+
const installed = new WeakSet()
|
|
9
|
+
const connectionUpgrade = value => typeof value === 'string' && value.toLowerCase().split(/\s*,\s*/).includes('upgrade')
|
|
10
|
+
const unique = (headers, names) => {
|
|
11
|
+
const seen = new Set()
|
|
12
|
+
for (let i=0;i<headers.length;i+=2) {
|
|
13
|
+
const name = headers[i].toLowerCase()
|
|
14
|
+
if (names.has(name) && seen.has(name)) return false
|
|
15
|
+
seen.add(name)
|
|
16
|
+
}
|
|
17
|
+
return true
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function installNativeSockets(server, {target, base='/', cookie, log}) {
|
|
21
|
+
if (installed.has(server)) throw Error('The native socket bridge is already installed')
|
|
22
|
+
if (typeof target !== 'string' || target.length>2048) throw Error('Native sockets require a fixed loopback HTTP backend')
|
|
23
|
+
let origin
|
|
24
|
+
try { origin = new URL(target) } catch { throw Error('Native sockets require a fixed loopback HTTP backend') }
|
|
25
|
+
if (origin.protocol !== 'http:' || !['127.0.0.1','localhost','[::1]'].includes(origin.hostname) || origin.username || origin.password || origin.pathname !== '/' || origin.search || origin.hash) throw Error('Native sockets require a fixed loopback HTTP backend')
|
|
26
|
+
if (origin.hostname === 'localhost') origin.hostname = '127.0.0.1'
|
|
27
|
+
if (typeof base !== 'string' || base.length>2048 || !base.startsWith('/') || !base.endsWith('/') || /[\\?#\s]/.test(base) || base.includes('//') || new URL(base,'http://brain.invalid').pathname !== base) throw Error('Native sockets require a canonical application base path')
|
|
28
|
+
if (typeof cookie !== 'function' || typeof log !== 'function') throw Error('Native socket bridge callbacks are required')
|
|
29
|
+
const path = (base === '/' ? '' : base.slice(0,-1)) + '/_brain/socket'
|
|
30
|
+
const owned = new Set()
|
|
31
|
+
let stopped = false, window = 0, reports = 0
|
|
32
|
+
// Fixed fields only: never log target URLs, credentials, frames, request headers or exception messages.
|
|
33
|
+
const report = (code,rid) => {
|
|
34
|
+
const current = Math.floor(Date.now()/60000)
|
|
35
|
+
if (window !== current) { window=current; reports=0 }
|
|
36
|
+
if (reports++ >= 16) return
|
|
37
|
+
try { log('warn','native socket unavailable',{code,rid,fix:'Check native socket activation, the trusted Origin, authentication and backend availability; reconnect after correcting the cause'}) } catch {}
|
|
38
|
+
}
|
|
39
|
+
const reject = (socket,status,rid,code) => {
|
|
40
|
+
report(code,rid)
|
|
41
|
+
if (socket.destroyed) return
|
|
42
|
+
socket.setTimeout(LIMITS.close,()=>socket.destroy())
|
|
43
|
+
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())
|
|
44
|
+
}
|
|
45
|
+
const upgrade = (incoming,browser,head) => {
|
|
46
|
+
const ownedPath=typeof incoming.url === 'string' && incoming.url.split(/[?#]/,1)[0] === path
|
|
47
|
+
// Other trusted listeners own their endpoints. If this is the only listener, reject unknown upgrades promptly
|
|
48
|
+
// instead of retaining a raw socket with no owner. Queries on our exact endpoint always remain ours to reject.
|
|
49
|
+
if (!ownedPath && server.listenerCount('upgrade')>1) return
|
|
50
|
+
if (browser.destroyed) return
|
|
51
|
+
const rid = randomUUID()
|
|
52
|
+
// A socket error can precede admission, including while a rejected response is flushing.
|
|
53
|
+
browser.on('error',()=>{})
|
|
54
|
+
if (stopped || server.draining) return reject(browser,503,rid,'NATIVE_SOCKET_DRAINING')
|
|
55
|
+
if (incoming.method !== 'GET') return reject(browser,405,rid,'NATIVE_SOCKET_METHOD')
|
|
56
|
+
if (incoming.url !== path) return reject(browser,400,rid,'NATIVE_SOCKET_PATH')
|
|
57
|
+
const headerBytes = incoming.rawHeaders.reduce((bytes,value)=>bytes+Buffer.byteLength(value)+2,0)
|
|
58
|
+
if (headerBytes>LIMITS.headers || !unique(incoming.rawHeaders,SINGLE)) return reject(browser,431,rid,'NATIVE_SOCKET_HEADERS')
|
|
59
|
+
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
|
+
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
|
+
const key = incoming.headers['sec-websocket-key']
|
|
64
|
+
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
|
+
let credentials
|
|
66
|
+
try { credentials=cookie(incoming) } catch { return reject(browser,400,rid,'NATIVE_SOCKET_CREDENTIALS') }
|
|
67
|
+
const authorization = incoming.headers.authorization
|
|
68
|
+
if (typeof credentials !== 'string' || authorization !== undefined && typeof authorization !== 'string' || Buffer.byteLength(credentials)+Buffer.byteLength(authorization || '')>LIMITS.credentials) return reject(browser,431,rid,'NATIVE_SOCKET_CREDENTIALS')
|
|
69
|
+
if (owned.size>=LIMITS.connections) return reject(browser,503,rid,'NATIVE_SOCKET_CAPACITY')
|
|
70
|
+
browser.pause()
|
|
71
|
+
let outgoing, upstream, handshake, lifetime, closing, bytes=0, done=false, upgraded=false
|
|
72
|
+
const writes=new Set()
|
|
73
|
+
const finish = (code,status) => {
|
|
74
|
+
if (done) return
|
|
75
|
+
done=true
|
|
76
|
+
clearTimeout(handshake); clearTimeout(lifetime); clearTimeout(closing); owned.delete(finish)
|
|
77
|
+
for (const timer of writes) clearTimeout(timer)
|
|
78
|
+
writes.clear()
|
|
79
|
+
browser.setTimeout(0)
|
|
80
|
+
if (upstream) { upstream.setTimeout(0); browser.unpipe(upstream); upstream.unpipe(browser); upstream.destroy() }
|
|
81
|
+
outgoing?.destroy()
|
|
82
|
+
if (status && !upgraded) reject(browser,status,rid,code)
|
|
83
|
+
else { if (code) report(code,rid); browser.destroy() }
|
|
84
|
+
}
|
|
85
|
+
const relay = (source,destination) => {
|
|
86
|
+
let blocked
|
|
87
|
+
destination.on('drain',()=>{if(blocked){clearTimeout(blocked);writes.delete(blocked);blocked=undefined}})
|
|
88
|
+
source.on('data',chunk=>{
|
|
89
|
+
bytes+=chunk.length
|
|
90
|
+
if (bytes>LIMITS.bytes || destination.writableLength+chunk.length>LIMITS.queued) finish('NATIVE_SOCKET_WRITE_BUDGET')
|
|
91
|
+
})
|
|
92
|
+
source.once('end',()=>{
|
|
93
|
+
if (done) return
|
|
94
|
+
closing ??= setTimeout(()=>finish('NATIVE_SOCKET_HALF_CLOSE'),LIMITS.close)
|
|
95
|
+
closing.unref?.()
|
|
96
|
+
destination.once('finish',()=>finish())
|
|
97
|
+
})
|
|
98
|
+
// Native pipe honors write(false)/drain. highWaterMark alone is a threshold, so the explicit queued cap above
|
|
99
|
+
// also stops a future/custom stream chunk that exceeds the admitted write budget.
|
|
100
|
+
source.pipe(destination)
|
|
101
|
+
// Observe after pipe's synchronous write so an unresponsive destination cannot retain its bounded queue
|
|
102
|
+
// until the much longer idle/lifetime limits. There is at most one stalled-write timer per direction.
|
|
103
|
+
source.on('data',()=>{
|
|
104
|
+
if (!done && destination.writableNeedDrain && !blocked) {
|
|
105
|
+
blocked=setTimeout(()=>finish('NATIVE_SOCKET_WRITE_TIMEOUT'),LIMITS.write)
|
|
106
|
+
blocked.unref?.();writes.add(blocked)
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
owned.add(finish)
|
|
111
|
+
browser.once('error',()=>finish('NATIVE_SOCKET_CLIENT_ERROR'))
|
|
112
|
+
browser.once('close',()=>finish())
|
|
113
|
+
browser.setTimeout(LIMITS.idle,()=>finish('NATIVE_SOCKET_IDLE'))
|
|
114
|
+
handshake=setTimeout(()=>finish('NATIVE_SOCKET_HANDSHAKE_TIMEOUT',504),LIMITS.handshake)
|
|
115
|
+
handshake.unref?.()
|
|
116
|
+
try {
|
|
117
|
+
outgoing=request(new URL('/_brain/socket',origin),{
|
|
118
|
+
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} : {})},
|
|
120
|
+
})
|
|
121
|
+
outgoing.once('error',()=>finish('NATIVE_SOCKET_UPSTREAM_UNAVAILABLE',502))
|
|
122
|
+
outgoing.once('response',response=>{
|
|
123
|
+
response.destroy()
|
|
124
|
+
finish('NATIVE_SOCKET_UPSTREAM_REJECTED',[400,401,403,404,429,503].includes(response.statusCode) ? response.statusCode : 502)
|
|
125
|
+
})
|
|
126
|
+
outgoing.once('upgrade',(response,socket,buffered)=>{
|
|
127
|
+
if (done) { socket.destroy(); return }
|
|
128
|
+
upstream=socket
|
|
129
|
+
socket.on('error',()=>finish('NATIVE_SOCKET_UPSTREAM_ERROR'))
|
|
130
|
+
socket.once('close',()=>finish())
|
|
131
|
+
const expected=createHash('sha1').update(key+'258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64')
|
|
132
|
+
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
|
+
clearTimeout(handshake)
|
|
134
|
+
lifetime=setTimeout(()=>finish('NATIVE_SOCKET_LIFETIME'),LIMITS.lifetime)
|
|
135
|
+
lifetime.unref?.()
|
|
136
|
+
socket.setTimeout(LIMITS.idle,()=>finish('NATIVE_SOCKET_IDLE'))
|
|
137
|
+
upgraded=true
|
|
138
|
+
browser.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${expected}\r\nSec-WebSocket-Protocol: ${PROTOCOL}\r\nCache-Control: no-store\r\nX-Request-Id: ${rid}\r\n\r\n`)
|
|
139
|
+
bytes=buffered.length+head.length
|
|
140
|
+
if (buffered.length) browser.write(buffered)
|
|
141
|
+
if (head.length) socket.write(head)
|
|
142
|
+
relay(browser,socket); relay(socket,browser); browser.resume()
|
|
143
|
+
})
|
|
144
|
+
outgoing.end()
|
|
145
|
+
} catch { finish('NATIVE_SOCKET_UPSTREAM_UNAVAILABLE',502) }
|
|
146
|
+
}
|
|
147
|
+
const closeOwned = () => { for (const finish of [...owned]) finish() }
|
|
148
|
+
const stop = () => { if (stopped) return; stopped=true; closeOwned() }
|
|
149
|
+
installed.add(server)
|
|
150
|
+
server.on('upgrade',upgrade)
|
|
151
|
+
server.once('close',()=>{stop();server.removeListener('upgrade',upgrade)})
|
|
152
|
+
const originalClose=server.close, originalAll=server.closeAllConnections
|
|
153
|
+
server.close=function(...args){stop();return originalClose.apply(this,args)}
|
|
154
|
+
if (originalAll) server.closeAllConnections=function(...args){closeOwned();return originalAll.apply(this,args)}
|
|
155
|
+
return stop
|
|
156
|
+
}
|