@christianirack/brainllm-runtime 0.9.0 → 0.10.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-analytics.js +29 -5
- package/brain-consent.js +78 -17
- package/brain-google.js +113 -0
- package/brain-lazy.js +4 -1
- package/brain-native-vitals.js +78 -0
- package/brain-sentry.js +71 -0
- package/brain-vitals.js +8 -2
- package/brain.js +3 -2
- package/package.json +9 -2
- package/server/redis-cache.mjs +173 -0
- package/server/sentry.mjs +156 -0
- package/server/server.mjs +224 -60
- package/server/warmup-watchdog.mjs +9 -0
- package/server/warmup.mjs +134 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// Optional public SSR cache. Standalone Redis only; no sessions, locks, socket fanout or cluster coordination.
|
|
2
|
+
// RESP2, one bounded operation per owned connection, no pending queue, no retries and no provider dependencies.
|
|
3
|
+
import net from 'node:net'
|
|
4
|
+
import tls from 'node:tls'
|
|
5
|
+
import dns from 'node:dns'
|
|
6
|
+
import { validateHeaderValue } from 'node:http'
|
|
7
|
+
import { createHash } from 'node:crypto'
|
|
8
|
+
|
|
9
|
+
const ENTRY_BYTES = 1024 * 1024, MAX_ACTIVE = 4, DEADLINE_MS = 250, MAX_TTL_MS = 86400000
|
|
10
|
+
const digest = value => createHash('sha256').update(value).digest('hex')
|
|
11
|
+
const SAFE_REQUEST = new Set(['host', 'connection', 'accept', 'accept-encoding', 'accept-language', 'user-agent', 'if-none-match', 'if-modified-since', 'cache-control', 'pragma', 'upgrade-insecure-requests', 'sec-fetch-site', 'sec-fetch-mode', 'sec-fetch-dest', 'sec-fetch-user', 'sec-ch-ua', 'sec-ch-ua-mobile', 'sec-ch-ua-platform'])
|
|
12
|
+
const SAFE_RESPONSE = new Set(['content-type', 'content-language', 'cache-control', 'link', 'x-robots-tag', 'cross-origin-opener-policy', 'content-security-policy'])
|
|
13
|
+
const privateControl = value => /(?:^|,)\s*(?:private|no-store|no-cache)(?:\s|=|,|$)/i.test(String(value || ''))
|
|
14
|
+
|
|
15
|
+
/** Unknown application headers can carry identity. They bypass rather than becoming cache-key material. */
|
|
16
|
+
export function publicCacheRequest(req) {
|
|
17
|
+
if (!req || !['GET', 'HEAD'].includes(req.method) || !req.headers || privateControl(req.headers['cache-control']) || /no-cache/i.test(req.headers.pragma || '')) return false
|
|
18
|
+
for (const key of Object.keys(req.headers)) if (!SAFE_REQUEST.has(key.toLowerCase())) return false
|
|
19
|
+
return true
|
|
20
|
+
}
|
|
21
|
+
function settings(raw, namespace, build, base) {
|
|
22
|
+
if (typeof namespace !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9:_-]{0,63}$/.test(namespace) || typeof build !== 'string' || !build || build.length > 256 || typeof base !== 'string' || base.length > 2048 || typeof raw !== 'string' || raw.length > 4096) return null
|
|
23
|
+
try {
|
|
24
|
+
const url = new URL(raw)
|
|
25
|
+
if (!['redis:', 'rediss:'].includes(url.protocol) || !url.hostname || url.search || url.hash || !/^\/(?:\d{1,4})?$/.test(url.pathname || '/') || Number(url.pathname.slice(1)) > 1023) return null
|
|
26
|
+
const user = decodeURIComponent(url.username), password = decodeURIComponent(url.password)
|
|
27
|
+
if (Buffer.byteLength(user) > 128 || Buffer.byteLength(password) > 1024 || user && !password) return null
|
|
28
|
+
if (url.port && Number(url.port) < 1) return null
|
|
29
|
+
const host = url.hostname.replace(/^\[|\]$/g, '')
|
|
30
|
+
return { host, port: Number(url.port || 6379), secure: url.protocol === 'rediss:', user, password, db: Number(url.pathname.slice(1) || 0), prefix: `brain:ssr:v1:${digest(namespace)}:${digest(JSON.stringify([build, base]))}:` }
|
|
31
|
+
} catch { return null }
|
|
32
|
+
}
|
|
33
|
+
function frame(parts) {
|
|
34
|
+
const chunks = [Buffer.from(`*${parts.length}\r\n`)]
|
|
35
|
+
for (const part of parts) { const bytes = Buffer.isBuffer(part) ? part : Buffer.from(String(part)); chunks.push(Buffer.from(`$${bytes.length}\r\n`), bytes, Buffer.from('\r\n')) }
|
|
36
|
+
return Buffer.concat(chunks)
|
|
37
|
+
}
|
|
38
|
+
// Only replies required by AUTH/SELECT/GET/SET/DEL. Unexpected aggregates/protocol types are a miss.
|
|
39
|
+
function reply(buffer) {
|
|
40
|
+
const end = buffer.indexOf('\r\n')
|
|
41
|
+
if (end < 0) return buffer.length > 1024 ? false : undefined
|
|
42
|
+
if (end > 1024) return false
|
|
43
|
+
// latin1 preserves every high bit; ascii decoding masks malformed bytes into valid digits or OK.
|
|
44
|
+
const type = buffer[0], text = buffer.toString('latin1', 1, end)
|
|
45
|
+
if (type === 36) {
|
|
46
|
+
if (text === '-1') return buffer.length === end + 2 ? { value: null } : false
|
|
47
|
+
if (!/^(?:0|[1-9]\d{0,6})$/.test(text)) return false
|
|
48
|
+
const size = Number(text)
|
|
49
|
+
if (size > ENTRY_BYTES) return false
|
|
50
|
+
const full = end + 2 + size + 2
|
|
51
|
+
if (buffer.length < full) return undefined
|
|
52
|
+
if (buffer.length !== full || buffer[full - 2] !== 13 || buffer[full - 1] !== 10) return false
|
|
53
|
+
return { value: Buffer.from(buffer.subarray(end + 2, full - 2)) }
|
|
54
|
+
}
|
|
55
|
+
if (buffer.length !== end + 2) return false
|
|
56
|
+
if (type === 43 && text === 'OK') return { value: 'OK' }
|
|
57
|
+
if (type === 58 && /^(?:0|1)$/.test(text)) return { value: Number(text) }
|
|
58
|
+
return false // Errors include OOM/noeviction, NOAUTH, NOPERM and MOVED: no body/value is logged.
|
|
59
|
+
}
|
|
60
|
+
function publicHeaders(headers) {
|
|
61
|
+
if (!headers || typeof headers !== 'object' || Array.isArray(headers) || Object.getPrototypeOf(headers) !== Object.prototype) return false
|
|
62
|
+
let bytes = 0
|
|
63
|
+
const entries = Object.entries(headers)
|
|
64
|
+
if (entries.length > 32) return false
|
|
65
|
+
for (const [key, value] of entries) {
|
|
66
|
+
if (!SAFE_RESPONSE.has(key.toLowerCase()) || typeof value !== 'string' || /[\r\n\x00]/.test(value) || value.length > 4096) return false
|
|
67
|
+
try { validateHeaderValue(key, value) } catch { return false }
|
|
68
|
+
if (key.toLowerCase() === 'cache-control' && privateControl(value)) return false
|
|
69
|
+
bytes += Buffer.byteLength(key) + Buffer.byteLength(value)
|
|
70
|
+
}
|
|
71
|
+
return bytes <= 16384
|
|
72
|
+
}
|
|
73
|
+
const lifetime = seconds => Number.isInteger(seconds) && seconds > 0 && seconds <= Number.MAX_SAFE_INTEGER / 2000 ? Math.min(seconds * 2000, MAX_TTL_MS) : 0
|
|
74
|
+
function record(value, seconds) {
|
|
75
|
+
if (!value || value.v !== 1 || value.seconds !== seconds || typeof value.out !== 'string' || Buffer.byteLength(value.out) > ENTRY_BYTES || !publicHeaders(value.h) || !Number.isSafeInteger(value.t)) return null
|
|
76
|
+
const age = Date.now() - value.t
|
|
77
|
+
if (age < 0 || age >= lifetime(seconds)) return null
|
|
78
|
+
return { out: value.out, h: value.h, t: value.t, bytes: Buffer.byteLength(value.out), cacheable: true, etag: 'W/"' + createHash('sha1').update(value.out).digest('base64url').slice(0, 22) + '"' }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Both environment variables and a real build identity are required. Invalid/partial opt-ins stay disabled. */
|
|
82
|
+
export function createRedisCache({ url, namespace, build, base = '/' } = {}) {
|
|
83
|
+
if (!url || !namespace) return null
|
|
84
|
+
const config = settings(url, namespace, build, base)
|
|
85
|
+
if (!config) return null
|
|
86
|
+
const owned = new Set()
|
|
87
|
+
// libc/libuv DNS cannot be canceled by destroying a socket. A timed-out lookup keeps its admission
|
|
88
|
+
// lease until its callback returns, so repeated misses cannot queue unlimited resolver work.
|
|
89
|
+
const leases = new Set()
|
|
90
|
+
let stopped = false
|
|
91
|
+
const key = identity => typeof identity === 'string' && identity.length <= 16384 ? config.prefix + digest(identity) : null
|
|
92
|
+
async function command(parts) {
|
|
93
|
+
if (stopped || leases.size >= MAX_ACTIVE) return null
|
|
94
|
+
const lease = { lookups: 0 }
|
|
95
|
+
leases.add(lease)
|
|
96
|
+
let socket, finish, receive, offset = 0, done = false
|
|
97
|
+
let buffer = Buffer.allocUnsafe(4096)
|
|
98
|
+
const completed = new Promise(resolve => { finish = value => {
|
|
99
|
+
if (done) return
|
|
100
|
+
done = true; clearTimeout(timer); const pending = receive; receive = null; pending?.(null)
|
|
101
|
+
if (socket) { owned.delete(socket); socket.destroy() }
|
|
102
|
+
if (!lease.lookups) leases.delete(lease)
|
|
103
|
+
resolve(value)
|
|
104
|
+
} })
|
|
105
|
+
const timer = setTimeout(() => finish(null), DEADLINE_MS)
|
|
106
|
+
const exchange = values => new Promise(resolve => {
|
|
107
|
+
if (done || stopped) return resolve(null)
|
|
108
|
+
offset = 0; receive = resolve
|
|
109
|
+
socket.write(frame(values), error => { if (error) finish(null) })
|
|
110
|
+
})
|
|
111
|
+
const lookup = (host, options, callback) => {
|
|
112
|
+
lease.lookups++
|
|
113
|
+
try {
|
|
114
|
+
dns.lookup(host, options, (...args) => {
|
|
115
|
+
lease.lookups--
|
|
116
|
+
if (done || stopped) {
|
|
117
|
+
if (!lease.lookups) leases.delete(lease)
|
|
118
|
+
callback(Object.assign(new Error('Cache lookup expired'), { code: 'ECANCELED' }))
|
|
119
|
+
} else callback(...args)
|
|
120
|
+
})
|
|
121
|
+
} catch (error) { lease.lookups--; throw error }
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
socket = config.secure
|
|
125
|
+
? tls.connect({ host: config.host, port: config.port, lookup, servername: net.isIP(config.host) ? undefined : config.host, rejectUnauthorized: true, checkServerIdentity: (_host, cert) => tls.checkServerIdentity(config.host, cert) })
|
|
126
|
+
: net.createConnection({ host: config.host, port: config.port, lookup })
|
|
127
|
+
owned.add(socket)
|
|
128
|
+
socket.on('error', () => finish(null)); socket.on('end', () => finish(null)); socket.on('close', () => finish(null))
|
|
129
|
+
socket.on('data', chunk => {
|
|
130
|
+
if (done) return
|
|
131
|
+
if (!receive || offset + chunk.length > ENTRY_BYTES + 1028) return finish(null)
|
|
132
|
+
if (offset + chunk.length > buffer.length) {
|
|
133
|
+
const grown = Buffer.allocUnsafe(Math.min(ENTRY_BYTES + 1028, Math.max(offset + chunk.length, buffer.length * 2)))
|
|
134
|
+
buffer.copy(grown, 0, 0, offset); buffer = grown
|
|
135
|
+
}
|
|
136
|
+
chunk.copy(buffer, offset); offset += chunk.length
|
|
137
|
+
const parsed = reply(buffer.subarray(0, offset))
|
|
138
|
+
if (parsed === false) return finish(null)
|
|
139
|
+
if (parsed) { const resolve = receive; receive = null; resolve(parsed.value) }
|
|
140
|
+
})
|
|
141
|
+
socket.once(config.secure ? 'secureConnect' : 'connect', async () => {
|
|
142
|
+
try {
|
|
143
|
+
if (config.password && await exchange(config.user ? ['AUTH', config.user, config.password] : ['AUTH', config.password]) !== 'OK') return finish(null)
|
|
144
|
+
if (config.db && await exchange(['SELECT', config.db]) !== 'OK') return finish(null)
|
|
145
|
+
finish(await exchange(parts))
|
|
146
|
+
} catch { finish(null) }
|
|
147
|
+
})
|
|
148
|
+
} catch { finish(null) }
|
|
149
|
+
return completed
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
async get(identity, seconds) {
|
|
153
|
+
const target = key(identity)
|
|
154
|
+
if (!target || !lifetime(seconds)) return null
|
|
155
|
+
const body = await command(['GET', target])
|
|
156
|
+
if (!Buffer.isBuffer(body) || body.length > ENTRY_BYTES) return null
|
|
157
|
+
try { return record(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(body)), seconds) } catch { return null }
|
|
158
|
+
},
|
|
159
|
+
async set(identity, value, seconds) {
|
|
160
|
+
const target = key(identity)
|
|
161
|
+
if (!target || !value?.cacheable || !lifetime(seconds)) return false
|
|
162
|
+
const envelope = { v: 1, out: value.out, h: value.h, t: value.t, seconds }
|
|
163
|
+
if (!record(envelope, seconds)) return false
|
|
164
|
+
let body
|
|
165
|
+
try { body = JSON.stringify(envelope) } catch { return false }
|
|
166
|
+
const remaining = lifetime(seconds) - (Date.now() - value.t)
|
|
167
|
+
if (Buffer.byteLength(body) > ENTRY_BYTES || remaining < 1) return false
|
|
168
|
+
return await command(['SET', target, body, 'PX', remaining]) === 'OK'
|
|
169
|
+
},
|
|
170
|
+
async delete(identity) { const target = key(identity); if (target) await command(['DEL', target]) },
|
|
171
|
+
close() { stopped = true; for (const socket of owned) socket.destroy(); owned.clear() },
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Server-only, single-project error transport. It accepts our schema, never arbitrary Sentry envelopes.
|
|
2
|
+
import { request as httpsRequest } from 'node:https'
|
|
3
|
+
import { randomUUID } from 'node:crypto'
|
|
4
|
+
import { STATUS_CODES } from 'node:http'
|
|
5
|
+
import dns from 'node:dns'
|
|
6
|
+
|
|
7
|
+
const TYPES = new Set(['Error', 'TypeError', 'RangeError', 'ReferenceError', 'SyntaxError', 'URIError', 'EvalError', 'AggregateError'])
|
|
8
|
+
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/
|
|
9
|
+
const MAX_BODY = 32768
|
|
10
|
+
const ERROR_CODES = { 400: 'SENTRY_INVALID_EVENT', 403: 'SENTRY_ORIGIN_REFUSED', 405: 'SENTRY_METHOD_NOT_ALLOWED', 408: 'SENTRY_BODY_TIMEOUT', 413: 'SENTRY_BODY_TOO_LARGE', 415: 'SENTRY_UNSUPPORTED_MEDIA', 429: 'SENTRY_RATE_LIMITED', 503: 'SENTRY_UNAVAILABLE' }
|
|
11
|
+
const setupError = () => Error('Sentry requires a valid private HTTPS BRAIN_SENTRY_DSN; self-hosted targets also require the exact private BRAIN_SENTRY_HOST. No DSN value was logged.')
|
|
12
|
+
export function parseDSN(raw, approvedHost) {
|
|
13
|
+
try {
|
|
14
|
+
if (typeof raw !== 'string' || raw.length > 2048 || /[\s\\]/.test(raw)) throw 0
|
|
15
|
+
const url = new URL(raw)
|
|
16
|
+
if (url.href !== raw || url.protocol !== 'https:' || url.password || url.search || url.hash || !/^[a-f0-9]{32}$/.test(url.username) || !/^\/[1-9][0-9]{0,19}$/.test(url.pathname)) throw 0
|
|
17
|
+
const hosted = /^(?:[a-z0-9-]+\.)*ingest(?:\.[a-z]{2})?\.sentry\.io$/.test(url.hostname) && !url.port
|
|
18
|
+
if (!hosted && (typeof approvedHost !== 'string' || url.host !== approvedHost)) throw 0
|
|
19
|
+
return Object.freeze({ endpoint: new URL('/api' + url.pathname + '/envelope/', url.origin), key: url.username })
|
|
20
|
+
} catch { throw setupError() }
|
|
21
|
+
}
|
|
22
|
+
const keys = (value, names) => value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === names.length && names.every(key => Object.hasOwn(value, key))
|
|
23
|
+
export function sanitizeEvent(value, assets) {
|
|
24
|
+
if (!keys(value, ['schemaVersion', 'type', 'frames']) || value.schemaVersion !== 1 || !TYPES.has(value.type) || !Array.isArray(value.frames) || value.frames.length < 1 || value.frames.length > 20) return null
|
|
25
|
+
const frames = []
|
|
26
|
+
for (const f of value.frames) {
|
|
27
|
+
if (!keys(f, ['asset', 'line', 'column', 'debugId']) || typeof f.asset !== 'string' || f.asset.length > 512 || !/^\/(?:[a-zA-Z0-9_.-]+\/)*[a-z0-9]{8}\.(?:js|jsbrain)$/.test(f.asset) || !UUID.test(f.debugId) || !Object.hasOwn(assets, f.asset) || assets[f.asset] !== f.debugId || !Number.isSafeInteger(f.line) || f.line < 1 || f.line > 9999999 || !Number.isSafeInteger(f.column) || f.column < 1 || f.column > 9999999) return null
|
|
28
|
+
frames.push({ filename: f.asset, abs_path: f.asset, lineno: f.line, colno: f.column, in_app: true })
|
|
29
|
+
}
|
|
30
|
+
const images = [...new Set(value.frames.map(f => f.asset))].map(asset => ({ type: 'sourcemap', code_file: asset, debug_id: assets[asset] }))
|
|
31
|
+
return { platform: 'javascript', level: 'error', exception: { values: [{ type: value.type, value: 'Application error', stacktrace: { frames: frames.reverse() } }] }, debug_meta: { images } }
|
|
32
|
+
}
|
|
33
|
+
function retrySeconds(headers, status, now) {
|
|
34
|
+
let seconds = 0
|
|
35
|
+
const limits = headers['x-sentry-rate-limits']
|
|
36
|
+
if (typeof limits === 'string') for (const part of limits.slice(0, 2048).split(',').slice(0, 32)) {
|
|
37
|
+
const [delay, categories] = part.trim().split(':')
|
|
38
|
+
if (categories !== undefined && (!categories || categories.split(';').includes('error'))) {
|
|
39
|
+
const n = Number(delay)
|
|
40
|
+
if (Number.isFinite(n) && n >= 0) seconds = Math.max(seconds, n)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (status === 429) {
|
|
44
|
+
const raw = headers['retry-after']
|
|
45
|
+
const number = typeof raw === 'string' && raw.length <= 128 ? Number(raw) : NaN
|
|
46
|
+
const date = typeof raw === 'string' && raw.length <= 128 ? Date.parse(raw) : NaN
|
|
47
|
+
seconds = Math.max(seconds, Number.isFinite(number) ? number : Number.isFinite(date) ? (date - now) / 1000 : 60)
|
|
48
|
+
}
|
|
49
|
+
return Math.min(3600, Math.max(0, Math.ceil(seconds)))
|
|
50
|
+
}
|
|
51
|
+
export function createSentryTunnel({ dsn, approvedHost, assets, publicOrigin, request = httpsRequest, now = Date.now }) {
|
|
52
|
+
const target = parseDSN(dsn, approvedHost)
|
|
53
|
+
let ownOrigin
|
|
54
|
+
if (publicOrigin !== undefined) {
|
|
55
|
+
try {
|
|
56
|
+
const url = new URL(publicOrigin)
|
|
57
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw 0
|
|
58
|
+
ownOrigin = url.origin
|
|
59
|
+
} catch { throw Error('Sentry needs one canonical public HTTP(S) origin; set PUBLIC_SITE_URL for a TLS reverse proxy') }
|
|
60
|
+
}
|
|
61
|
+
if (!assets || typeof assets !== 'object' || Array.isArray(assets) || Object.keys(assets).length > 4096) throw Error('Sentry requires the build asset catalog; rebuild the app with sentry.enabled true')
|
|
62
|
+
const catalog = Object.freeze({ ...assets })
|
|
63
|
+
const clients = new Map(), pending = new Set(), leases = new Set()
|
|
64
|
+
let closed = false, inFlight = 0, cooldown = 0, minute = -1, total = 0
|
|
65
|
+
const answer = (res, status, wait) => {
|
|
66
|
+
if (res.destroyed || res.writableEnded) return
|
|
67
|
+
const body = status >= 400 ? JSON.stringify({ error: { code: ERROR_CODES[status] || 'SENTRY_REQUEST_FAILED', message: STATUS_CODES[status] || 'Request failed' } }) : ''
|
|
68
|
+
res.writeHead(status, { 'cache-control': 'no-store', ...(body ? { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(body) } : {}), ...(status === 405 ? { allow: 'POST' } : {}), ...(wait ? { 'retry-after': String(wait) } : {}) })
|
|
69
|
+
res.end(body)
|
|
70
|
+
}
|
|
71
|
+
const forward = (event, lease) => new Promise(resolve => {
|
|
72
|
+
if (closed) return resolve()
|
|
73
|
+
const eventId = randomUUID().replaceAll('-', '')
|
|
74
|
+
const payload = JSON.stringify({ event_id: eventId, timestamp: now() / 1000, ...event })
|
|
75
|
+
const envelope = JSON.stringify({ event_id: eventId, sent_at: new Date(now()).toISOString() }) + '\n' + JSON.stringify({ type: 'event', length: Buffer.byteLength(payload) }) + '\n' + payload + '\n'
|
|
76
|
+
let outgoing, response, settled = false
|
|
77
|
+
const finish = () => {
|
|
78
|
+
if (settled) return
|
|
79
|
+
settled = true; clearTimeout(timer); pending.delete(cancel)
|
|
80
|
+
outgoing?.destroy(); response?.destroy(); resolve()
|
|
81
|
+
}
|
|
82
|
+
const cancel = () => finish()
|
|
83
|
+
const timer = setTimeout(finish, 3000)
|
|
84
|
+
pending.add(cancel)
|
|
85
|
+
// Destroying a request cannot cancel libc/libuv DNS. Keep its admission lease until every native
|
|
86
|
+
// callback completes, even after timeout or close. An expired callback must never start a connection.
|
|
87
|
+
const lookup = (host, options, callback) => {
|
|
88
|
+
if (settled || closed) return callback(Object.assign(Error('Sentry lookup expired'), { code: 'ECANCELED' }))
|
|
89
|
+
lease.lookups++
|
|
90
|
+
try {
|
|
91
|
+
dns.lookup(host, options, (...args) => {
|
|
92
|
+
lease.lookups--
|
|
93
|
+
if (lease.finished && !lease.lookups) leases.delete(lease)
|
|
94
|
+
if (settled || closed) callback(Object.assign(Error('Sentry lookup expired'), { code: 'ECANCELED' }))
|
|
95
|
+
else callback(...args)
|
|
96
|
+
})
|
|
97
|
+
} catch (error) { lease.lookups--; throw error }
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
outgoing = request(target.endpoint, {
|
|
101
|
+
method: 'POST', agent: false, rejectUnauthorized: true, maxHeaderSize: 8192, lookup,
|
|
102
|
+
headers: { 'content-type': 'application/x-sentry-envelope', 'content-length': Buffer.byteLength(envelope), 'x-sentry-auth': 'Sentry sentry_version=7,sentry_key=' + target.key + ',sentry_client=brain.native/1' },
|
|
103
|
+
}, incoming => {
|
|
104
|
+
response = incoming
|
|
105
|
+
const delay = retrySeconds(incoming.headers, incoming.statusCode, now())
|
|
106
|
+
if (delay) cooldown = Math.max(cooldown, now() + delay * 1000)
|
|
107
|
+
// No response body, redirects or retries. Destroy rather than buffering an untrusted body.
|
|
108
|
+
incoming.on('error', finish); finish()
|
|
109
|
+
})
|
|
110
|
+
outgoing.on('error', finish); outgoing.end(envelope)
|
|
111
|
+
} catch { finish() }
|
|
112
|
+
})
|
|
113
|
+
const handle = async (req, res, client) => {
|
|
114
|
+
if (closed) return answer(res, 503, 60)
|
|
115
|
+
if (req.method !== 'POST') return answer(res, 405)
|
|
116
|
+
let origin
|
|
117
|
+
try { origin = new URL(req.headers.origin) } catch { return answer(res, 403) }
|
|
118
|
+
const expected = ownOrigin || (req.socket.encrypted ? 'https://' : 'http://') + req.headers.host
|
|
119
|
+
if (origin.origin !== expected || origin.host !== req.headers.host || origin.origin !== req.headers.origin || req.headers['sec-fetch-site'] && req.headers['sec-fetch-site'] !== 'same-origin') return answer(res, 403)
|
|
120
|
+
if (req.headers['content-encoding'] || req.headers['content-length'] && (!/^\d{1,6}$/.test(req.headers['content-length']) || +req.headers['content-length'] > MAX_BODY)) return answer(res, 413)
|
|
121
|
+
const media = req.headers['content-type']
|
|
122
|
+
if (typeof media !== 'string' || !/^application\/json(?:\s*;\s*charset\s*=\s*(?:utf-8|"utf-8"))?$/i.test(media.trim())) return answer(res, 415)
|
|
123
|
+
const at = now(), bucket = Math.floor(at / 60000)
|
|
124
|
+
if (minute !== bucket) { minute = bucket; total = 0; clients.clear() }
|
|
125
|
+
if (cooldown > at) return answer(res, 429, Math.max(1, Math.ceil((cooldown - at) / 1000)))
|
|
126
|
+
const key = typeof client === 'string' && client.length <= 128 ? client : 'unknown'
|
|
127
|
+
if (inFlight >= 4 || leases.size >= 4 || total >= 120 || !clients.has(key) && clients.size >= 1024 || (clients.get(key) || 0) >= 8) return answer(res, 429, 60)
|
|
128
|
+
clients.set(key, (clients.get(key) || 0) + 1); total++; inFlight++
|
|
129
|
+
const lease = { lookups: 0, finished: false }
|
|
130
|
+
leases.add(lease)
|
|
131
|
+
let timer
|
|
132
|
+
try {
|
|
133
|
+
const raw = await new Promise(resolve => {
|
|
134
|
+
let size = 0, chunks = [], done = false
|
|
135
|
+
const finish = result => { if (done) return; done = true; clearTimeout(timer); pending.delete(cancel); req.removeListener('data', data); req.removeListener('end', end); req.removeListener('error', error); req.removeListener('aborted', error); chunks = []; resolve(result) }
|
|
136
|
+
const cancel = () => { finish(null); answer(res, 503, 60); req.destroy() }
|
|
137
|
+
const data = chunk => { size += chunk.length; if (size > MAX_BODY) { finish(null); req.resume() } else chunks.push(chunk) }
|
|
138
|
+
const end = () => finish(Buffer.concat(chunks).toString('utf8'))
|
|
139
|
+
const error = () => finish(null)
|
|
140
|
+
timer = setTimeout(() => { finish(null); answer(res, 408); req.destroy() }, 3000)
|
|
141
|
+
pending.add(cancel)
|
|
142
|
+
req.on('data', data); req.once('end', end); req.once('error', error); req.once('aborted', error)
|
|
143
|
+
})
|
|
144
|
+
if (closed) return answer(res, 503, 60)
|
|
145
|
+
if (raw === null) return answer(res, 413)
|
|
146
|
+
let value
|
|
147
|
+
try { value = JSON.parse(raw) } catch { return answer(res, 400) }
|
|
148
|
+
const event = sanitizeEvent(value, catalog)
|
|
149
|
+
value = null
|
|
150
|
+
if (!event) return answer(res, 400)
|
|
151
|
+
await forward(event, lease)
|
|
152
|
+
return answer(res, closed ? 503 : 202, closed ? 60 : undefined)
|
|
153
|
+
} finally { clearTimeout(timer); inFlight--; lease.finished = true; if (!lease.lookups) leases.delete(lease) }
|
|
154
|
+
}
|
|
155
|
+
return { handle, close() { closed = true; for (const cancel of pending) cancel(); pending.clear(); clients.clear() } }
|
|
156
|
+
}
|