@christianirack/brainllm-runtime 0.4.0 → 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/brain-ch.js +18 -4
- package/brain-live.js +364 -0
- package/package.json +1 -1
- package/server/native-sockets.mjs +156 -0
- package/server/server.mjs +9 -0
package/brain-ch.js
CHANGED
|
@@ -60,10 +60,10 @@ const safe = (k, v) => (k === '__proto__' || k === 'constructor' || k === 'proto
|
|
|
60
60
|
// global instance) and inside `provide(K, store(…))` (one instance per subtree, §41.2). sync: 'tab' (BroadcastChannel) | 'storage'
|
|
61
61
|
// (localStorage + storage event) | 'ws' (WebSocket at `url`, see ws() below). `id` is set by the codegen (channel
|
|
62
62
|
// path); if missing, it is derived from init.
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
// Shared signal/descriptor construction. Compiler-selected plain stores retain the
|
|
64
|
+
// same reset, serialization and inspector contracts without reaching transport code.
|
|
65
|
+
function storeState(init, name, changed) {
|
|
65
66
|
const S = {}, s = {}
|
|
66
|
-
let last = '' // JSON of the last state sent or received: cuts the echo between tabs
|
|
67
67
|
for (const k of Object.keys(init)) {
|
|
68
68
|
S[k] = sig(init[k], name + '.' + k) // named: dev tools (brain_why, HMR) see `cart.total`
|
|
69
69
|
Object.defineProperty(s, k, {
|
|
@@ -80,7 +80,8 @@ export function store(init, o = {}, id) {
|
|
|
80
80
|
const apply = d => {
|
|
81
81
|
const n = snap()
|
|
82
82
|
for (const k in d) if (k in S) n[k] = d[k]
|
|
83
|
-
|
|
83
|
+
const encoded = JSON.stringify(n) // Preserve serialization errors and toJSON effects, even without sync.
|
|
84
|
+
if (changed) changed(encoded) // Before the batch: a sync effect must see the new echo marker.
|
|
84
85
|
batch(() => {
|
|
85
86
|
for (const k in n) S[k].v = n[k]
|
|
86
87
|
})
|
|
@@ -90,6 +91,19 @@ export function store(init, o = {}, id) {
|
|
|
90
91
|
$snap: { value: snap },
|
|
91
92
|
$reset: { value: () => apply({ ...init }) }, // back to the initial state (persisted and synced like any write)
|
|
92
93
|
})
|
|
94
|
+
return { S, s, snap, apply }
|
|
95
|
+
}
|
|
96
|
+
// Compiler-only: public store() still accepts all options and retains its full fallback.
|
|
97
|
+
export function plainStore(init, id) {
|
|
98
|
+
const name = id || 'store:' + Object.keys(init).join(',')
|
|
99
|
+
const state = storeState(init, name, null)
|
|
100
|
+
JSON.stringify(state.snap()) // The public plain path performs this validation too.
|
|
101
|
+
return state.s
|
|
102
|
+
}
|
|
103
|
+
export function store(init, o = {}, id) {
|
|
104
|
+
const name = id || o.name || 'store:' + Object.keys(init).join(',')
|
|
105
|
+
let last = '' // JSON of the last state sent or received: cuts the echo between tabs
|
|
106
|
+
const { S, s, snap, apply } = storeState(init, name, value => { last = value })
|
|
93
107
|
const K = 'brain:' + name
|
|
94
108
|
if (o.persist) {
|
|
95
109
|
// Persistence (INTERNALS §35.4), versioned by the shape of init. Small states live in localStorage and are restored
|
package/brain-live.js
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
// Native, same-origin invalidation metadata. HTTP resource reads remain authoritative.
|
|
2
|
+
import { sig, fx, untrack, _rt } from './brain.js'
|
|
3
|
+
|
|
4
|
+
const G = globalThis, I = _rt()
|
|
5
|
+
const MAX_OWNERS = 128, MAX_ROOMS = 32, FRAME = 4096, BUFFER = 32768
|
|
6
|
+
const COALESCE = 100, READY_MS = 8000, REFRESH_MS = 10000, RETRY_MS = 60000
|
|
7
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
8
|
+
const own = (value, key) => Object.prototype.hasOwnProperty.call(value, key)
|
|
9
|
+
const RESERVED = new Set(['constructor', 'prototype', '__proto__', 'id', 'owner', 'created_at', 'updated_at', 'search'])
|
|
10
|
+
let broker
|
|
11
|
+
const fault = fix => Object.assign(Error('B0918: ' + fix), { code: 'B0918', fix })
|
|
12
|
+
const fail = fix => { throw fault(fix) }
|
|
13
|
+
const exact = (value, keys) => value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).every(key => keys.includes(key))
|
|
14
|
+
const resourceName = name => typeof name === 'string' && /^[a-z][a-z0-9_]{0,47}$/.test(name) && !RESERVED.has(name)
|
|
15
|
+
|
|
16
|
+
function roomsOf(value) {
|
|
17
|
+
if (!Array.isArray(value) || value.length > MAX_ROOMS) fail('Use at most 32 native resource rooms')
|
|
18
|
+
const rooms = new Map()
|
|
19
|
+
for (const room of value) {
|
|
20
|
+
let command, key
|
|
21
|
+
const id = room && typeof room === 'object' && own(room, 'id') ? room.id : undefined
|
|
22
|
+
if (exact(room, ['room']) && own(room, 'room') && room.room === 'user:self') {
|
|
23
|
+
command = { room: 'user:self' }; key = 'user:self'
|
|
24
|
+
} else if (exact(room, ['resource', 'id']) && own(room, 'resource') && resourceName(room.resource) && (id === undefined || (typeof id === 'string' && UUID.test(id)))) {
|
|
25
|
+
command = id === undefined ? { resource: room.resource } : { resource: room.resource, id: id.toLowerCase() }
|
|
26
|
+
key = command.resource + ':' + (command.id || '')
|
|
27
|
+
} else fail('Use a declared resource, an optional UUID, or the user:self room')
|
|
28
|
+
rooms.set(key, Object.freeze(command))
|
|
29
|
+
}
|
|
30
|
+
return rooms
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function endpoint() {
|
|
34
|
+
const location = G.location
|
|
35
|
+
if (!location || !/^https?:$/.test(location.protocol)) fail('Serve live resources from an HTTP(S) application origin')
|
|
36
|
+
const meta = G.document && document.querySelector('meta[name=brain-base]')
|
|
37
|
+
const base = meta && meta.getAttribute('content') || ''
|
|
38
|
+
if (typeof base !== 'string' || base.length > 512 || (base && (!base.startsWith('/') || base.startsWith('//') || /[\\?#\x00-\x20\x7f]/.test(base)))) fail('Use a normalized same-origin application base path')
|
|
39
|
+
const origin = new URL(location.origin)
|
|
40
|
+
if (origin.origin !== location.origin || origin.username || origin.password) fail('Use a canonical application origin')
|
|
41
|
+
const normalized = base.replace(/\/$/, '')
|
|
42
|
+
const url = new URL(normalized + '/_brain/socket', origin)
|
|
43
|
+
if (url.origin !== origin.origin || url.pathname !== normalized + '/_brain/socket') fail('Use a normalized same-origin application base path')
|
|
44
|
+
url.protocol = origin.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
45
|
+
return url.href
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function metadata(text) {
|
|
49
|
+
if (typeof text !== 'string' || text.length > FRAME || /[^\x09\x0a\x0d\x20-\x7e]/.test(text)) return null
|
|
50
|
+
let value
|
|
51
|
+
try {
|
|
52
|
+
value = JSON.parse(text, (key, item) => {
|
|
53
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') throw Error('Unsafe metadata')
|
|
54
|
+
return item
|
|
55
|
+
})
|
|
56
|
+
} catch { return null }
|
|
57
|
+
if (exact(value, ['type', 'protocol', 'durable', 'resyncRequired']) && value.type === 'ready' && value.protocol === 1 && value.durable === false && value.resyncRequired === true) return Object.freeze(value)
|
|
58
|
+
if (exact(value, ['type', 'reason']) && value.type === 'resync' && value.reason === 'joined') return Object.freeze(value)
|
|
59
|
+
if (exact(value, ['type']) && value.type === 'pong') return Object.freeze(value)
|
|
60
|
+
if (exact(value, ['type', 'code']) && value.type === 'error' && value.code === 'ROOM_FORBIDDEN') return Object.freeze(value)
|
|
61
|
+
if (exact(value, ['type', 'resource', 'id', 'operation', 'eventId']) && value.type === 'invalidate' && resourceName(value.resource) && typeof value.id === 'string' && UUID.test(value.id) && typeof value.eventId === 'string' && UUID.test(value.eventId) && ['create', 'update', 'delete'].includes(value.operation)) return Object.freeze(value)
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function createBroker() {
|
|
66
|
+
const members = new Set(), joined = new Map(), waiting = [], events = new Map()
|
|
67
|
+
let socket = null, epoch = 0, scope = null, terminal = '', ready = false
|
|
68
|
+
let reconcileQueued = false, retryTimer, readyTimer, heartbeat, pongTimer, flushTimer
|
|
69
|
+
let attempts = 0, retryStart = 0, lastConnect = 0
|
|
70
|
+
|
|
71
|
+
const update = () => {
|
|
72
|
+
for (const member of members) {
|
|
73
|
+
const active = [...member.rooms.keys()].some(key => joined.get(key) === 'ready')
|
|
74
|
+
const forbidden = member.rooms.size > 0 && [...member.rooms.keys()].every(key => joined.get(key) === 'forbidden')
|
|
75
|
+
member.up.v = !!(socket && ready && member.scope && member.scope === scope && active)
|
|
76
|
+
member.state.v = !member.scope || !member.rooms.size ? 'idle' : terminal || (member.refreshState || (forbidden ? 'forbidden' : member.up.v ? 'open' : retryTimer ? 'retrying' : 'connecting'))
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const clearMember = member => {
|
|
80
|
+
member.generation++
|
|
81
|
+
member.dirty = false
|
|
82
|
+
member.pending = member.resync = null
|
|
83
|
+
member.refreshState = member.flight ? 'refresh-pending' : ''
|
|
84
|
+
if (member.flight) {
|
|
85
|
+
const flight = member.flight
|
|
86
|
+
clearTimeout(flight.deadline); clearTimeout(flight.tick)
|
|
87
|
+
flight.deadline = flight.tick = null
|
|
88
|
+
if (flight.resolveTick) { flight.resolveTick(); flight.resolveTick = null }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const stop = () => {
|
|
92
|
+
epoch++; ready = false
|
|
93
|
+
clearTimeout(retryTimer); retryTimer = null
|
|
94
|
+
clearTimeout(readyTimer); clearInterval(heartbeat); clearTimeout(pongTimer)
|
|
95
|
+
readyTimer = heartbeat = pongTimer = null
|
|
96
|
+
clearTimeout(flushTimer); flushTimer = null
|
|
97
|
+
joined.clear(); waiting.length = 0; events.clear(); currentRooms.clear()
|
|
98
|
+
const previous = socket; socket = null
|
|
99
|
+
if (previous) {
|
|
100
|
+
previous.onopen = previous.onmessage = previous.onclose = previous.onerror = null
|
|
101
|
+
try { previous.close(1000, 'Live resource lifecycle ended') } catch {}
|
|
102
|
+
}
|
|
103
|
+
for (const member of members) { clearMember(member); member.up.v = false }
|
|
104
|
+
}
|
|
105
|
+
const reject = state => { stop(); terminal = state; update() }
|
|
106
|
+
const send = value => {
|
|
107
|
+
if (!socket || socket.readyState !== 1 || socket.bufferedAmount > BUFFER) return false
|
|
108
|
+
const text = JSON.stringify(value)
|
|
109
|
+
if (text.length > FRAME) return false
|
|
110
|
+
try { socket.send(text); return true } catch { return false }
|
|
111
|
+
}
|
|
112
|
+
const refresh = member => {
|
|
113
|
+
if (!members.has(member) || !member.up.v || !member.dirty || member.flight) return
|
|
114
|
+
member.dirty = false; member.refreshState = ''; update()
|
|
115
|
+
const pending = member.pending, resync = member.resync
|
|
116
|
+
member.pending = member.resync = null
|
|
117
|
+
const flight = { generation: member.generation, epoch, deadline: null, tick: null, resolveTick: null }
|
|
118
|
+
member.flight = flight
|
|
119
|
+
flight.deadline = setTimeout(timeoutFlight.bind(null, member.cell, flight), REFRESH_MS)
|
|
120
|
+
try {
|
|
121
|
+
if (resync && own(member.options, 'onResync') && member.options.onResync) observeMetadata(member.options.onResync, resync)
|
|
122
|
+
if (pending && own(member.options, 'onInvalidate') && member.options.onInvalidate) observeMetadata(member.options.onInvalidate, pending)
|
|
123
|
+
untrack(() => member.node.refetch())
|
|
124
|
+
// Equivalent post-effect tick, with an owned cancellation handle. The
|
|
125
|
+
// standard tick() intentionally exposes no handle for owner cancellation.
|
|
126
|
+
const next = new Promise(resolve => {
|
|
127
|
+
flight.resolveTick = resolve
|
|
128
|
+
flight.tick = setTimeout(resolve, 0)
|
|
129
|
+
})
|
|
130
|
+
next.then(readResource.bind(null, member.cell, flight)).then(
|
|
131
|
+
settleFlight.bind(null, member.cell, flight, false),
|
|
132
|
+
settleFlight.bind(null, member.cell, flight, true),
|
|
133
|
+
)
|
|
134
|
+
} catch {
|
|
135
|
+
I.oops(fault('Keep live metadata observers synchronous and handle resource failures in the view'), member.owner, 'B0918')
|
|
136
|
+
dispose(member.cell)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const valid = (member, flight) => members.has(member) && member.generation === flight.generation && epoch === flight.epoch
|
|
140
|
+
function complete(member, flight, failed) {
|
|
141
|
+
if (member.flight !== flight) return
|
|
142
|
+
clearTimeout(flight.deadline); clearTimeout(flight.tick)
|
|
143
|
+
flight.deadline = flight.tick = flight.resolveTick = null
|
|
144
|
+
member.flight = null; member.refreshState = ''
|
|
145
|
+
if (valid(member, flight) && failed) member.refreshState = 'refresh-error'
|
|
146
|
+
if (members.has(member) && member.dirty) schedule()
|
|
147
|
+
if (members.has(member)) update()
|
|
148
|
+
}
|
|
149
|
+
function schedule() {
|
|
150
|
+
if (!flushTimer) flushTimer = setTimeout(() => {
|
|
151
|
+
flushTimer = null
|
|
152
|
+
for (const member of members) refresh(member)
|
|
153
|
+
}, COALESCE)
|
|
154
|
+
}
|
|
155
|
+
const invalidate = (value, roomKey) => {
|
|
156
|
+
for (const member of members) {
|
|
157
|
+
if (!member.scope || member.scope !== scope) continue
|
|
158
|
+
const interested = roomKey ? member.rooms.has(roomKey) : [...member.rooms].some(([key, room]) => joined.get(key) === 'ready' && (room.room === 'user:self' || (room.resource === value.resource && (!room.id || room.id === value.id))))
|
|
159
|
+
if (interested) { member.dirty = true; if (value.type === 'resync') member.resync = value; else member.pending = value; schedule() }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const retry = () => {
|
|
163
|
+
stop()
|
|
164
|
+
if (++attempts > 5 || (retryStart && Date.now() - retryStart >= RETRY_MS)) { terminal = 'exhausted'; update(); return }
|
|
165
|
+
if (!retryStart) retryStart = Date.now()
|
|
166
|
+
const delay = Math.floor(Math.min(8000, 250 * 2 ** (attempts - 1)) * (0.75 + Math.random() * 0.25))
|
|
167
|
+
retryTimer = setTimeout(() => { retryTimer = null; reconcile() }, delay)
|
|
168
|
+
update()
|
|
169
|
+
}
|
|
170
|
+
const desired = () => {
|
|
171
|
+
const rooms = new Map(), scopes = new Set()
|
|
172
|
+
for (const member of members) if (member.scope && member.rooms.size) {
|
|
173
|
+
scopes.add(member.scope)
|
|
174
|
+
for (const [key, value] of member.rooms) rooms.set(key, value)
|
|
175
|
+
}
|
|
176
|
+
return { rooms, scopes }
|
|
177
|
+
}
|
|
178
|
+
const syncRooms = rooms => {
|
|
179
|
+
const changed = [...rooms.keys()].some(key => !joined.has(key)) || [...joined.keys()].some(key => !rooms.has(key))
|
|
180
|
+
if (waiting.length && changed) { retry(); return }
|
|
181
|
+
for (const [key] of joined) if (!rooms.has(key)) {
|
|
182
|
+
const room = currentRooms.get(key)
|
|
183
|
+
if (!room || !send({ type: 'leave', ...room })) { retry(); return }
|
|
184
|
+
joined.delete(key)
|
|
185
|
+
}
|
|
186
|
+
currentRooms = rooms
|
|
187
|
+
for (const [key, room] of rooms) if (!joined.has(key)) {
|
|
188
|
+
if (waiting.length >= MAX_ROOMS || !send({ type: 'join', ...room })) { retry(); return }
|
|
189
|
+
joined.set(key, 'pending'); waiting.push(key)
|
|
190
|
+
}
|
|
191
|
+
update()
|
|
192
|
+
}
|
|
193
|
+
let currentRooms = new Map()
|
|
194
|
+
const connect = () => {
|
|
195
|
+
const captured = ++epoch
|
|
196
|
+
lastConnect = Date.now()
|
|
197
|
+
let address
|
|
198
|
+
try { address = endpoint() } catch { reject('invalid-config'); return }
|
|
199
|
+
try { socket = new G.WebSocket(address, 'brain.v1') } catch { retry(); return }
|
|
200
|
+
readyTimer = setTimeout(() => { if (epoch === captured) retry() }, READY_MS)
|
|
201
|
+
socket.onopen = () => {
|
|
202
|
+
if (epoch !== captured) return
|
|
203
|
+
if (socket.protocol !== 'brain.v1') reject('protocol-error')
|
|
204
|
+
}
|
|
205
|
+
socket.onerror = () => { /* close or the ready deadline owns retries; arbitrary browser errors are not retained. */ }
|
|
206
|
+
socket.onclose = event => {
|
|
207
|
+
if (epoch !== captured) return
|
|
208
|
+
if (event.code === 1008) reject(event.reason === 'Authentication expired' ? 'auth-required' : 'policy-closed')
|
|
209
|
+
else retry()
|
|
210
|
+
}
|
|
211
|
+
socket.onmessage = event => {
|
|
212
|
+
if (epoch !== captured) return
|
|
213
|
+
const value = metadata(event.data)
|
|
214
|
+
if (!value) { reject('protocol-error'); return }
|
|
215
|
+
if (value.type === 'ready') {
|
|
216
|
+
if (ready) { reject('protocol-error'); return }
|
|
217
|
+
clearTimeout(readyTimer); readyTimer = null; ready = true
|
|
218
|
+
syncRooms(desired().rooms)
|
|
219
|
+
heartbeat = setInterval(() => {
|
|
220
|
+
if (!send({ type: 'ping' })) { retry(); return }
|
|
221
|
+
clearTimeout(pongTimer)
|
|
222
|
+
pongTimer = setTimeout(() => { if (epoch === captured) retry() }, 12000)
|
|
223
|
+
}, 25000)
|
|
224
|
+
} else if (!ready) reject('protocol-error')
|
|
225
|
+
else if (value.type === 'pong') { clearTimeout(pongTimer); pongTimer = null }
|
|
226
|
+
else if (value.type === 'resync' || value.type === 'error') {
|
|
227
|
+
const key = waiting.shift()
|
|
228
|
+
if (!key) { reject('protocol-error'); return }
|
|
229
|
+
joined.set(key, value.type === 'resync' ? 'ready' : 'forbidden')
|
|
230
|
+
if (value.type === 'resync') { attempts = 0; retryStart = 0 }
|
|
231
|
+
update()
|
|
232
|
+
if (value.type === 'resync') invalidate(value, key)
|
|
233
|
+
} else {
|
|
234
|
+
const now = Date.now()
|
|
235
|
+
for (const [id, time] of events) if (now - time > 60000) events.delete(id)
|
|
236
|
+
if (events.has(value.eventId)) return
|
|
237
|
+
events.set(value.eventId, now)
|
|
238
|
+
while (events.size > 128) events.delete(events.keys().next().value)
|
|
239
|
+
invalidate(value)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
update()
|
|
243
|
+
}
|
|
244
|
+
function reconcile() {
|
|
245
|
+
reconcileQueued = false
|
|
246
|
+
const { rooms, scopes } = desired()
|
|
247
|
+
if (!rooms.size) { stop(); scope = null; terminal = ''; attempts = 0; retryStart = 0; update(); return }
|
|
248
|
+
if (scopes.size !== 1) { reject('scope-conflict'); return }
|
|
249
|
+
const next = scopes.values().next().value
|
|
250
|
+
if (next !== scope || terminal === 'scope-conflict') { stop(); scope = next; terminal = ''; attempts = 0; retryStart = 0 }
|
|
251
|
+
if (rooms.size > MAX_ROOMS) { reject('room-limit'); return }
|
|
252
|
+
if (terminal || retryTimer) { update(); return }
|
|
253
|
+
if (socket && ready) syncRooms(rooms)
|
|
254
|
+
else if (!socket) {
|
|
255
|
+
const elapsed = Date.now() - lastConnect
|
|
256
|
+
const delay = elapsed < 0 ? 0 : Math.max(0, 250 - elapsed)
|
|
257
|
+
if (delay) retryTimer = setTimeout(() => { retryTimer = null; reconcile() }, delay)
|
|
258
|
+
else connect()
|
|
259
|
+
}
|
|
260
|
+
update()
|
|
261
|
+
}
|
|
262
|
+
const queue = () => { if (!reconcileQueued) { reconcileQueued = true; queueMicrotask(reconcile) } }
|
|
263
|
+
return {
|
|
264
|
+
valid, complete,
|
|
265
|
+
stats() { return { owners: members.size, rooms: joined.size, pendingJoins: waiting.length, dedupe: events.size, connection: socket ? 1 : 0, timers: [retryTimer, readyTimer, heartbeat, pongTimer, flushTimer].filter(Boolean).length + [...members].filter(member => member.flight && member.flight.deadline).length, refreshes: [...members].filter(member => member.flight).length, dirty: [...members].filter(member => member.dirty).length } },
|
|
266
|
+
add(member) {
|
|
267
|
+
if (!members.has(member) && members.size >= MAX_OWNERS) fail('Close unused live resource owners before adding another')
|
|
268
|
+
members.add(member)
|
|
269
|
+
if (scope && member.scope && member.scope !== scope) stop()
|
|
270
|
+
queue()
|
|
271
|
+
},
|
|
272
|
+
remove(member) {
|
|
273
|
+
members.delete(member); clearMember(member); member.up.v = false
|
|
274
|
+
if (!members.size) { stop(); scope = null; terminal = ''; attempts = 0; retryStart = 0 }
|
|
275
|
+
else queue()
|
|
276
|
+
},
|
|
277
|
+
reconnect(member) {
|
|
278
|
+
if (!members.has(member)) return
|
|
279
|
+
stop(); terminal = ''; attempts = 0; retryStart = 0; queue()
|
|
280
|
+
},
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function observeMetadata(callback, value) {
|
|
285
|
+
const result = untrack(() => callback(value))
|
|
286
|
+
if (result && typeof result.then === 'function') {
|
|
287
|
+
// Observe rejection without retaining an owner or the user-provided error.
|
|
288
|
+
Promise.resolve(result).catch(() => {})
|
|
289
|
+
fail('Use synchronous metadata observers; resource owns asynchronous fetching')
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Promise reactions retain only a nullable cell and scalar flight metadata. An
|
|
294
|
+
// externally retained, never-settling Promise cannot keep a disposed resource alive.
|
|
295
|
+
function readResource(cell, flight) {
|
|
296
|
+
flight.tick = flight.resolveTick = null
|
|
297
|
+
const member = cell.record
|
|
298
|
+
if (member && broker.valid(member, flight)) return member.node.v
|
|
299
|
+
}
|
|
300
|
+
function settleFlight(cell, flight, failed) {
|
|
301
|
+
const member = cell.record
|
|
302
|
+
if (member) broker.complete(member, flight, failed)
|
|
303
|
+
}
|
|
304
|
+
function timeoutFlight(cell, flight) {
|
|
305
|
+
const member = cell.record
|
|
306
|
+
if (member && broker.valid(member, flight)) {
|
|
307
|
+
// Never release a pending fetch slot merely because its deadline elapsed.
|
|
308
|
+
member.refreshState = 'refresh-timeout'
|
|
309
|
+
member.state.v = 'refresh-timeout'
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
function detach(cell) {
|
|
313
|
+
const member = cell.record
|
|
314
|
+
if (member && broker) broker.remove(member)
|
|
315
|
+
}
|
|
316
|
+
function dispose(cell) {
|
|
317
|
+
const member = cell.record
|
|
318
|
+
if (!member) return
|
|
319
|
+
cell.record = null
|
|
320
|
+
if (member.stopWatch) member.stopWatch()
|
|
321
|
+
if (broker) broker.remove(member)
|
|
322
|
+
member.node = member.options = member.owner = member.stopWatch = null
|
|
323
|
+
member.scope = null; member.rooms.clear(); member.flight = null
|
|
324
|
+
member.state.v = 'closed'
|
|
325
|
+
}
|
|
326
|
+
function observe(cell) {
|
|
327
|
+
const member = cell.record
|
|
328
|
+
if (!member) return
|
|
329
|
+
const scope = member.options.scope()
|
|
330
|
+
if (scope !== null && (typeof scope !== 'string' || !scope.length || scope.length > 160 || /[\x00-\x20\x7f]/.test(scope))) fail('Use null or a bounded public user/revision string as scope; never credentials')
|
|
331
|
+
member.scope = scope
|
|
332
|
+
member.rooms = roomsOf(typeof member.options.rooms === 'function' ? member.options.rooms() : member.options.rooms)
|
|
333
|
+
broker = broker || createBroker()
|
|
334
|
+
broker.add(member)
|
|
335
|
+
return detach.bind(null, cell)
|
|
336
|
+
}
|
|
337
|
+
function ownerCleanup(cell) { return dispose.bind(null, cell) }
|
|
338
|
+
function reconnect(cell) { if (cell.record && broker) broker.reconnect(cell.record) }
|
|
339
|
+
|
|
340
|
+
// Internal, metadata-only capacity evidence; never scope identifiers, rooms or values.
|
|
341
|
+
export function _liveStats() {
|
|
342
|
+
return broker ? broker.stats() : { owners: 0, rooms: 0, pendingJoins: 0, dedupe: 0, connection: 0, timers: 0, refreshes: 0, dirty: 0 }
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function liveResource(node, options = {}) {
|
|
346
|
+
if (!node || typeof node !== 'object' || typeof node.refetch !== 'function' || !('v' in node) || !exact(options, ['rooms', 'scope', 'onInvalidate', 'onResync']) || !own(options, 'rooms') || !own(options, 'scope') || typeof options.scope !== 'function') fail('Use liveResource(rows, {rooms, scope: () => publicUserRevision}) with a resource handle')
|
|
347
|
+
if (own(options, 'onInvalidate') && options.onInvalidate !== undefined && typeof options.onInvalidate !== 'function') fail('Use a metadata callback for onInvalidate')
|
|
348
|
+
if (own(options, 'onResync') && options.onResync !== undefined && typeof options.onResync !== 'function') fail('Use a metadata callback for onResync')
|
|
349
|
+
const state = sig('idle'), up = sig(false), cell = { record: null }
|
|
350
|
+
const out = {
|
|
351
|
+
get connected() { return up.v },
|
|
352
|
+
get state() { return state.v },
|
|
353
|
+
close() { dispose(cell); up.v = false; state.v = 'closed' },
|
|
354
|
+
reconnect: reconnect.bind(null, cell),
|
|
355
|
+
}
|
|
356
|
+
if (G.__brainSSR || G.__brainRoute) return out
|
|
357
|
+
if (typeof G.WebSocket !== 'function') { state.v = 'unsupported'; return out }
|
|
358
|
+
const owner = I.owner()
|
|
359
|
+
if (!owner) fail('Create liveResource inside a component so unmount closes its subscription')
|
|
360
|
+
cell.record = { cell, node, options, state, up, owner, scope: null, rooms: new Map(), generation: 0, flight: null, dirty: false, pending: null, resync: null, refreshState: '', stopWatch: null }
|
|
361
|
+
fx(ownerCleanup.bind(null, cell))
|
|
362
|
+
cell.record.stopWatch = fx(observe.bind(null, cell))
|
|
363
|
+
return out
|
|
364
|
+
}
|
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": {
|
|
@@ -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
|
+
}
|
package/server/server.mjs
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
// `<!--brain:props-->` inside <script type="application/json" id="brain-props">.
|
|
20
20
|
import { createServer as http, STATUS_CODES, request as httpRequest, Agent as HttpAgent } from 'node:http'
|
|
21
21
|
import net from 'node:net'
|
|
22
|
+
import { installNativeSockets } from './native-sockets.mjs'
|
|
22
23
|
import { Readable } from 'node:stream'
|
|
23
24
|
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
|
|
24
25
|
import { existsSync, readFileSync, realpathSync, statSync, lstatSync } from 'node:fs'
|
|
@@ -389,6 +390,7 @@ export function nativeAuthBridge(raw) {
|
|
|
389
390
|
headers: () => req.$nativeLoginHeaders || {},
|
|
390
391
|
}
|
|
391
392
|
},
|
|
393
|
+
_socketCookie: req => cookies(req, false),
|
|
392
394
|
close() { stopped = true; for (const request of owned) request.destroy(); agent.destroy() },
|
|
393
395
|
}
|
|
394
396
|
}
|
|
@@ -397,6 +399,7 @@ export function createServer(o = {}) {
|
|
|
397
399
|
const dist = resolve(o.dist || process.cwd())
|
|
398
400
|
const env = Object.freeze({ ...(o.env || ENV) }) // ctx.env: what the supervisor passed, read-only
|
|
399
401
|
const native = nativeAuthBridge(o.backendURL ?? env.BRAIN_BACKEND_URL)
|
|
402
|
+
if (o.nativeSockets !== undefined && typeof o.nativeSockets !== 'boolean') throw Error('nativeSockets must be an explicit boolean')
|
|
400
403
|
const TIMEOUT = +(o.timeout ?? env.BRAIN_TIMEOUT_MS ?? 10000)
|
|
401
404
|
const LIMIT = +(o.rateLimit ?? env.BRAIN_RPC_LIMIT ?? 60) // per IP + function, per minute
|
|
402
405
|
const MAX_BODY = 64 * 1024
|
|
@@ -419,6 +422,10 @@ export function createServer(o = {}) {
|
|
|
419
422
|
sink(level, msg, safe)
|
|
420
423
|
}
|
|
421
424
|
const mf = readManifest(dist)
|
|
425
|
+
const hasManifestSockets = Object.hasOwn(mf,'nativeSockets')
|
|
426
|
+
if (hasManifestSockets && typeof mf.nativeSockets !== 'boolean') throw Error('Manifest nativeSockets must be an explicit boolean')
|
|
427
|
+
const nativeSockets = o.nativeSockets === undefined ? hasManifestSockets && mf.nativeSockets : o.nativeSockets
|
|
428
|
+
if (nativeSockets && !native) throw Error('Native sockets require a configured native backend')
|
|
422
429
|
// the build's single route table (#187): already in match order, each with its RegExp source (named groups = params)
|
|
423
430
|
const routes = mf.routes.map(r => ({ ...r, rx: r.rx ? new RegExp(r.rx) : exact(r.path) }))
|
|
424
431
|
// the real path: Node imports file URLs through realpath, and the /_b/ hook must name the same modules (a symlinked
|
|
@@ -1049,6 +1056,7 @@ export function createServer(o = {}) {
|
|
|
1049
1056
|
}
|
|
1050
1057
|
})
|
|
1051
1058
|
if (native) server.once('close', () => native.close())
|
|
1059
|
+
if (native && nativeSockets) server.$brainSocketStop = installNativeSockets(server,{target:o.backendURL ?? env.BRAIN_BACKEND_URL,base:BASE,cookie:native._socketCookie,log})
|
|
1052
1060
|
server.headersTimeout = 15000
|
|
1053
1061
|
server.requestTimeout = 30000
|
|
1054
1062
|
return server
|
|
@@ -1426,6 +1434,7 @@ if (process.argv[1] && real(process.argv[1]) === real(fileURLToPath(import.meta.
|
|
|
1426
1434
|
const drain = () => {
|
|
1427
1435
|
if (s.draining) return
|
|
1428
1436
|
s.draining = true
|
|
1437
|
+
s.$brainSocketStop?.()
|
|
1429
1438
|
const ms = +(ENV.BRAIN_DRAIN_MS || 10000)
|
|
1430
1439
|
const force = setTimeout(() => {
|
|
1431
1440
|
s.closeAllConnections()
|