@christianirack/brainllm-runtime 0.3.1 → 0.4.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-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-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/server.mjs +440 -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-err.js
CHANGED
|
@@ -12,7 +12,7 @@ const RT = new URL('.', import.meta.url).href
|
|
|
12
12
|
// Core hook: records (capture active) or console.error, and falls back to the nearest boundary's fallback (not for
|
|
13
13
|
// event handlers).
|
|
14
14
|
const I = _err((e, x, w) => {
|
|
15
|
-
on ? record(e, x, w) :
|
|
15
|
+
on ? record(e, x, w) : print(e)
|
|
16
16
|
// §42: a failing event handler never destroys its component (it is reported; brain-guard shows the toast)
|
|
17
17
|
if (String(w).startsWith('on:')) return
|
|
18
18
|
for (; x; x = x.p || x.parentNode) {
|
|
@@ -20,6 +20,7 @@ const I = _err((e, x, w) => {
|
|
|
20
20
|
if (b) return fall(b)
|
|
21
21
|
}
|
|
22
22
|
})
|
|
23
|
+
const print = (e, ref) => G.console && console.error(I.development?.() ? e : `BRAIN_ERROR: Operation failed. Reference: ${ref || 'unavailable'}`)
|
|
23
24
|
// Boundaries: owner N and its root elements → { fb, n: nodes, r: owner, f: already fell }.
|
|
24
25
|
const BW = new WeakMap()
|
|
25
26
|
const fbn = fb => {
|
|
@@ -50,7 +51,7 @@ export const fallback = (C, fb) =>
|
|
|
50
51
|
try {
|
|
51
52
|
a = I.nodes(C($p))
|
|
52
53
|
} catch (e) {
|
|
53
|
-
on ? record(e, r, 'render') :
|
|
54
|
+
on ? record(e, r, 'render') : print(e)
|
|
54
55
|
r.q()
|
|
55
56
|
;(b.f = 1), (a = fbn(fb))
|
|
56
57
|
}
|
|
@@ -67,6 +68,7 @@ const crumbs = []
|
|
|
67
68
|
let opts = {}, on = false, sent = 0, F0 = null
|
|
68
69
|
|
|
69
70
|
export function crumb(k, d) {
|
|
71
|
+
k = clean(k, 40); d = clean(d, 200)
|
|
70
72
|
const l = crumbs[crumbs.length - 1]
|
|
71
73
|
if (l && l.k === k && l.d === d) return void l.n++ // the same repeated action is counted, not stacked
|
|
72
74
|
crumbs.push({ k, d, n: 1, at: Date.now() })
|
|
@@ -82,22 +84,49 @@ const path = u => {
|
|
|
82
84
|
try {
|
|
83
85
|
return new URL(u, G.location && G.location.href).pathname // no query: it may carry sensitive data
|
|
84
86
|
} catch {
|
|
85
|
-
return String(u).split(
|
|
87
|
+
return String(u).split(/[?#]/)[0]
|
|
86
88
|
}
|
|
87
89
|
}
|
|
88
|
-
|
|
90
|
+
const LIMIT = { frames: 24, causes: 6, stackBytes: 16384, stackLines: 256, maps: 64, sourceBytes: 1048576, mapLines: 65536, loads: 2, pending: 64, deadline: 2000 }
|
|
91
|
+
const get = (o, k) => { try { return o && o[k] } catch { return null } }
|
|
92
|
+
const clean = (s, max = 500) => String(s == null ? '' : s).slice(0, max).replace(/(?:https?|file):\/\/[^\s)]+/g, u => u.split(/[?#]/)[0]).replace(/([/\w.:%-]+)[?#][^\s)'"]*/g, '$1')
|
|
93
|
+
// Only structural stack locations are retained, never argument lists or the raw stack header.
|
|
89
94
|
function frames(e) {
|
|
90
|
-
const out = []
|
|
91
|
-
|
|
95
|
+
const raw = String(get(e, 'stack') || ''), out = []
|
|
96
|
+
let omitted = raw.length > LIMIT.stackBytes
|
|
97
|
+
const lines = raw.slice(0, LIMIT.stackBytes).split('\n')
|
|
98
|
+
if (lines.length > LIMIT.stackLines) omitted = true
|
|
99
|
+
for (const l of lines.slice(0, LIMIT.stackLines)) {
|
|
92
100
|
const m = l.match(/((?:https?|file):\/\/[^\s)]+):(\d+):(\d+)/)
|
|
93
|
-
if (
|
|
101
|
+
if (!m) continue
|
|
102
|
+
if (![+m[2], +m[3]].every(n => Number.isSafeInteger(n) && n > 0)) { omitted = true; continue }
|
|
103
|
+
if (out.length >= LIMIT.frames) { omitted = true; break }
|
|
104
|
+
out.push({ url: m[1], line: +m[2], col: +m[3], rt: m[1].startsWith(RT) && !m[1].slice(RT.length).includes('/') })
|
|
94
105
|
}
|
|
95
|
-
return out
|
|
106
|
+
return { frames: out, truncated: omitted }
|
|
107
|
+
}
|
|
108
|
+
const generated = t => t ? { file: path(t.url).slice(-1024), line: t.line, column: t.col } : null
|
|
109
|
+
const location = (t, c) => ({ component: c ? clean(c.name || c.h || '', 120) || null : null, file: t ? path(t.url).slice(-1024) : c && c.file ? c.file : null, line: t ? t.line : null, column: t ? t.col : null, precision: t ? 'generated' : 'component-only' })
|
|
110
|
+
function trace(e, c) {
|
|
111
|
+
const f = frames(e), top = f.frames.find(t => !t.rt) || null
|
|
112
|
+
return { rawFrames: f.frames, location: location(top, c), generatedLocation: generated(top), frames: f.frames.map(t => ({ generated: generated(t), source: null, runtime: t.rt })), truncated: { stack: f.truncated, frames: f.truncated, locations: f.frames.some(t => path(t.url).length > 1024) } }
|
|
113
|
+
}
|
|
114
|
+
function causes(e, c) {
|
|
115
|
+
const seen = new Set([e]), out = []
|
|
116
|
+
let next = get(e, 'cause'), truncated = false
|
|
117
|
+
while (next != null) {
|
|
118
|
+
if (seen.has(next) || out.length >= LIMIT.causes) { truncated = true; break }
|
|
119
|
+
seen.add(next)
|
|
120
|
+
const t = trace(next, c)
|
|
121
|
+
out.push({ depth: out.length + 1, type: clean(get(next, 'name') || typeof next, 80), code: clean(get(next, 'code') || '', 80) || null, msg: clean(get(next, 'message') || (typeof next === 'string' ? next : ''), 300), ...t })
|
|
122
|
+
next = get(next, 'cause')
|
|
123
|
+
}
|
|
124
|
+
return { entries: out, truncated }
|
|
96
125
|
}
|
|
97
126
|
// Component via the DOM: roots with data-b="<hash>" (prod) → parent chain via ancestors with data-b.
|
|
98
127
|
function byDom(el) {
|
|
99
128
|
const chain = []
|
|
100
|
-
for (; el; el = el.parentNode) if (el.getAttribute && el.getAttribute('data-b')) chain.push({ h: el.getAttribute('data-b') })
|
|
129
|
+
for (; el && chain.length < 33; el = el.parentNode) if (el.getAttribute && el.getAttribute('data-b')) chain.push({ h: el.getAttribute('data-b') })
|
|
101
130
|
return chain.length ? { ...chain[0], chain: chain.map(c => c.h) } : null
|
|
102
131
|
}
|
|
103
132
|
// Prod chunk /_b/<hash>.brain → component hash.
|
|
@@ -110,18 +139,21 @@ const byStack = f => {
|
|
|
110
139
|
// line to its .brain line. With `maps`, a recorded error is enriched asynchronously: `src` ('views/home.brain:12', the
|
|
111
140
|
// first app frame) and `brainStack`; subscribers (brain_errors, overlay, doctor) are notified again. The file path
|
|
112
141
|
// comes from the module URL (two `index.brain` in different folders never mix); prod chunks carry no map: no-op.
|
|
113
|
-
const MAPS = new Map()
|
|
142
|
+
const MAPS = new Map(), pending = []
|
|
143
|
+
let loading = 0, enriching = 0
|
|
114
144
|
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
|
115
145
|
export function decode(m) {
|
|
146
|
+
if (typeof m !== 'string' || m.length > LIMIT.sourceBytes) return []
|
|
116
147
|
const lines = []
|
|
117
148
|
let src = 0
|
|
118
|
-
for (const seg of m.split(';')) {
|
|
149
|
+
for (const seg of m.split(';').slice(0, LIMIT.mapLines)) {
|
|
119
150
|
let line = null
|
|
120
151
|
for (const part of seg.split(',')) {
|
|
121
152
|
if (!part) continue
|
|
122
153
|
const v = []
|
|
123
154
|
for (let i = 0, n = 0, sh = 0; i < part.length; i++) {
|
|
124
155
|
const d = B64.indexOf(part[i])
|
|
156
|
+
if (d < 0 || sh > 30) return []
|
|
125
157
|
n += (d & 31) << sh
|
|
126
158
|
if (d & 32) sh += 5
|
|
127
159
|
else (v.push(n & 1 ? -(n >> 1) : n >> 1), (n = 0), (sh = 0))
|
|
@@ -132,54 +164,147 @@ export function decode(m) {
|
|
|
132
164
|
}
|
|
133
165
|
return lines
|
|
134
166
|
}
|
|
135
|
-
|
|
136
|
-
if (
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
Promise.resolve(opts.load ? opts.load(url) : F0(url).then(r => r.text()))
|
|
140
|
-
.then(t => {
|
|
141
|
-
const m = /sourceMappingURL=data:application\/json;base64,([A-Za-z0-9+/=]+)/.exec(t)
|
|
142
|
-
return m ? decode(JSON.parse(atob(m[1])).mappings) : null
|
|
143
|
-
})
|
|
144
|
-
.catch(() => null)
|
|
145
|
-
)
|
|
146
|
-
return MAPS.get(url)
|
|
147
|
-
}
|
|
148
|
-
async function toBrain(r, f) {
|
|
149
|
-
const out = []
|
|
150
|
-
for (const t of f.slice(0, 12)) {
|
|
151
|
-
const lines = t.rt ? null : await mapOf(t.url)
|
|
152
|
-
const l = lines && lines[t.line - 1]
|
|
153
|
-
if (l) out.push(`${path(t.url).replace(/^\//, '')}:${l}`)
|
|
167
|
+
async function source(url, signal) {
|
|
168
|
+
if (opts.load) {
|
|
169
|
+
const text = await opts.load(url, { signal })
|
|
170
|
+
return typeof text === 'string' && text.length <= LIMIT.sourceBytes && new TextEncoder().encode(text).byteLength <= LIMIT.sourceBytes ? text : null
|
|
154
171
|
}
|
|
155
|
-
if (!
|
|
156
|
-
r
|
|
157
|
-
r.
|
|
172
|
+
if (!F0) return null
|
|
173
|
+
const r = await F0(url, { signal, credentials: 'same-origin' })
|
|
174
|
+
if (r.ok === false || +(r.headers && r.headers.get('content-length')) > LIMIT.sourceBytes) return null
|
|
175
|
+
if (!r.body || !r.body.getReader) { const text = await r.text(); return text.length <= LIMIT.sourceBytes && new TextEncoder().encode(text).byteLength <= LIMIT.sourceBytes ? text : null }
|
|
176
|
+
const reader = r.body.getReader(), decoder = new TextDecoder()
|
|
177
|
+
let text = '', bytes = 0
|
|
178
|
+
try {
|
|
179
|
+
for (;;) {
|
|
180
|
+
const part = await reader.read()
|
|
181
|
+
if (part.done) break
|
|
182
|
+
bytes += part.value.byteLength
|
|
183
|
+
if (bytes > LIMIT.sourceBytes) { await reader.cancel(); return null }
|
|
184
|
+
text += decoder.decode(part.value, { stream: true })
|
|
185
|
+
}
|
|
186
|
+
return text + decoder.decode()
|
|
187
|
+
} finally { reader.releaseLock() }
|
|
188
|
+
}
|
|
189
|
+
function pump() {
|
|
190
|
+
while (loading < LIMIT.loads && pending.length) {
|
|
191
|
+
const task = pending.shift()
|
|
192
|
+
if (task.done) continue
|
|
193
|
+
loading++
|
|
194
|
+
Promise.resolve().then(() => source(task.url, task.controller.signal)).then(text => {
|
|
195
|
+
const m = text && /sourceMappingURL=data:application\/json;base64,([A-Za-z0-9+/=]+)/.exec(text)
|
|
196
|
+
const map = m && JSON.parse(atob(m[1]))
|
|
197
|
+
const lines = map && map.version === 3 && decode(map.mappings)
|
|
198
|
+
task.finish({ lines: lines && lines.length ? lines : null, status: lines && lines.length ? 'mapped' : 'unavailable' })
|
|
199
|
+
}).catch(() => task.finish({ lines: null, status: 'unavailable' })).finally(() => { loading--; pump() })
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function mapOf(input) {
|
|
203
|
+
let url
|
|
204
|
+
try {
|
|
205
|
+
const u = new URL(input), origin = G.location && G.location.origin
|
|
206
|
+
if (!['http:', 'https:', 'file:'].includes(u.protocol) || (u.protocol !== 'file:' && origin && origin !== 'null' && u.origin !== origin)) return Promise.resolve({ lines: null, status: 'unavailable' })
|
|
207
|
+
u.hash = ''; url = u.href
|
|
208
|
+
} catch { return Promise.resolve({ lines: null, status: 'unavailable' }) }
|
|
209
|
+
const hit = MAPS.get(url)
|
|
210
|
+
if (hit) { MAPS.delete(url); MAPS.set(url, hit); return hit.promise }
|
|
211
|
+
if (pending.length >= LIMIT.pending) return Promise.resolve({ lines: null, status: 'limited' })
|
|
212
|
+
if (MAPS.size >= LIMIT.maps) {
|
|
213
|
+
const old = [...MAPS].find(([, e]) => e.done)
|
|
214
|
+
if (!old) return Promise.resolve({ lines: null, status: 'limited' })
|
|
215
|
+
MAPS.delete(old[0])
|
|
216
|
+
}
|
|
217
|
+
const entry = { done: false }
|
|
218
|
+
entry.promise = new Promise(resolve => {
|
|
219
|
+
const task = { url, done: false, controller: new AbortController(), finish(result) {
|
|
220
|
+
if (task.done) return
|
|
221
|
+
task.done = entry.done = true
|
|
222
|
+
clearTimeout(timer)
|
|
223
|
+
const at = pending.indexOf(task)
|
|
224
|
+
if (at >= 0) pending.splice(at, 1)
|
|
225
|
+
if (result.status !== 'mapped' && MAPS.get(url) === entry) MAPS.delete(url)
|
|
226
|
+
resolve(result)
|
|
227
|
+
} }
|
|
228
|
+
const timer = setTimeout(() => { task.controller.abort(); task.finish({ lines: null, status: 'timeout' }) }, LIMIT.deadline)
|
|
229
|
+
pending.push(task)
|
|
230
|
+
})
|
|
231
|
+
MAPS.set(url, entry)
|
|
232
|
+
pump()
|
|
233
|
+
return entry.promise
|
|
234
|
+
}
|
|
235
|
+
async function toBrain(r, traces) {
|
|
236
|
+
const jobs = []
|
|
237
|
+
for (const item of traces) for (const [index, t] of item.rawFrames.entries()) {
|
|
238
|
+
if (t.rt) continue
|
|
239
|
+
jobs.push(mapOf(t.url).then(result => {
|
|
240
|
+
const line = result.lines && result.lines[t.line - 1]
|
|
241
|
+
if (Number.isSafeInteger(line) && line > 0 && item.frames[index]) item.frames[index].source = { file: path(t.url).replace(/^\//, '').slice(-1024), line, column: null }
|
|
242
|
+
return result.status
|
|
243
|
+
}))
|
|
244
|
+
}
|
|
245
|
+
const status = await Promise.all(jobs)
|
|
246
|
+
for (const item of traces) {
|
|
247
|
+
const first = item.frames.find(t => t.source)
|
|
248
|
+
if (first) item.location = { component: item.location.component, ...first.source, precision: 'source-line' }
|
|
249
|
+
delete item.rawFrames
|
|
250
|
+
}
|
|
251
|
+
for (let i = 1; i < traces.length; i++) r.causes[i - 1].location = traces[i].location
|
|
252
|
+
r.location = traces[0].location
|
|
253
|
+
r.frames = traces[0].frames
|
|
254
|
+
const out = r.frames.filter(t => t.source).map(t => `${t.source.file}:${t.source.line}`)
|
|
255
|
+
if (out.length) { r.src = out[0]; r.brainStack = out }
|
|
256
|
+
r.mapping = { status: status.includes('timeout') ? 'timeout' : status.includes('limited') ? 'limited' : out.length ? 'mapped' : 'unavailable' }
|
|
257
|
+
r.truncated.mapping = status.some(s => s === 'timeout' || s === 'limited')
|
|
258
|
+
boundRecord(r)
|
|
158
259
|
for (const s of sub) s(r)
|
|
159
260
|
}
|
|
160
261
|
|
|
262
|
+
// Bound the serialized notification too, including mapped locations and nested causes.
|
|
263
|
+
function boundRecord(r) {
|
|
264
|
+
const size = () => new TextEncoder().encode(JSON.stringify(r)).byteLength
|
|
265
|
+
r.truncated.output ||= false
|
|
266
|
+
while (size() > 65536) {
|
|
267
|
+
r.truncated.output = true
|
|
268
|
+
const nested = [...r.causes].reverse().find(c => c.frames.length)
|
|
269
|
+
if (nested) { nested.frames.pop(); nested.truncated.frames = true; continue }
|
|
270
|
+
if (r.frames.length > 1) { r.frames.pop(); r.stack.pop(); if (r.brainStack) r.brainStack.pop(); r.truncated.frames = true; continue }
|
|
271
|
+
if (r.causes.length) { r.causes.pop(); r.truncated.causes = true; continue }
|
|
272
|
+
break
|
|
273
|
+
}
|
|
274
|
+
}
|
|
161
275
|
function record(e, x, w) {
|
|
162
276
|
if (e && e.kind === 'aborted') return // a cancelled request (brain-data ApiError): not an error
|
|
163
|
-
const
|
|
164
|
-
const
|
|
165
|
-
const
|
|
166
|
-
const
|
|
277
|
+
const extracted = frames(e), f = extracted.frames, top = f.find(t => !t.rt) || null
|
|
278
|
+
const owner = (opts.who && opts.who(x)) || (x && x.nodeType ? byDom(x) : null) || byStack(top)
|
|
279
|
+
const c = owner ? { id: typeof owner.id === 'number' ? owner.id : null, h: clean(owner.h || '', 120) || null, name: clean(owner.name || '', 120) || null, file: owner.file ? clean(owner.file, 500).split(/[?#]/)[0] : null, chain: Array.isArray(owner.chain) ? owner.chain.slice(0, 32).map(n => clean(n, 120)) : [] } : null
|
|
280
|
+
const type = clean(get(e, 'code') || get(e, 'name') || typeof e, 80)
|
|
281
|
+
const line = top ? `${path(top.url).slice(-1024)}:${top.line}` : null
|
|
167
282
|
const fp = hash([type, c && c.h, line, w].join('|'))
|
|
168
283
|
let r = errors.find(r => r.fp === fp)
|
|
169
284
|
if (r) (r.n++, (r.last = Date.now()))
|
|
170
285
|
else {
|
|
286
|
+
const t = trace(e, c), chain = causes(e, c)
|
|
171
287
|
r = {
|
|
172
|
-
fp, code: (e
|
|
173
|
-
stack: f.slice(0,
|
|
174
|
-
build: opts.build || null, ua: G.navigator ? navigator.userAgent : null, at: Date.now(), last: Date.now(), n: 1,
|
|
288
|
+
fp, code: clean(get(e, 'code') || '', 80) || null, type, msg: MSG[get(e, 'code')] || clean(get(e, 'message') || e), where: clean(w || '', 120) || null, comp: c, line,
|
|
289
|
+
stack: f.slice(0, LIMIT.frames).map(t => `${path(t.url).slice(-1024)}:${t.line}:${t.col}`), location: t.location, generatedLocation: t.generatedLocation, frames: t.frames, causes: chain.entries.map(({ rawFrames, ...item }) => item), truncated: { ...t.truncated, causes: chain.truncated, componentChain: !!(owner && owner.chain && owner.chain.length > 32), breadcrumbs: crumbs.length >= 30, locations: f.some(t => path(t.url).length > 1024), mapping: false }, mapping: { status: opts.maps ? 'pending' : 'disabled' }, crumbs: crumbs.map(c => ({ ...c })),
|
|
290
|
+
build: clean(opts.build || '', 200) || null, ua: G.navigator ? clean(navigator.userAgent, 300) : null, at: Date.now(), last: Date.now(), n: 1,
|
|
175
291
|
}
|
|
292
|
+
boundRecord(r)
|
|
176
293
|
errors.push(r)
|
|
177
294
|
if (errors.length > 100) errors.shift()
|
|
178
|
-
if (opts.maps
|
|
295
|
+
if (opts.maps && enriching < 100) {
|
|
296
|
+
enriching++
|
|
297
|
+
const traces = [t, ...chain.entries.slice(0, r.causes.length)]
|
|
298
|
+
for (let i = 0; i < r.causes.length; i++) chain.entries[i].frames = r.causes[i].frames
|
|
299
|
+
toBrain(r, traces).finally(() => { enriching-- })
|
|
300
|
+
} else {
|
|
301
|
+
if (opts.maps) { r.mapping.status = 'limited'; r.truncated.mapping = true }
|
|
302
|
+
for (const item of chain.entries) delete item.rawFrames
|
|
303
|
+
}
|
|
179
304
|
if (opts.report && G.navigator && navigator.sendBeacon && sent < (opts.max || 20)) sent++, navigator.sendBeacon(opts.report, JSON.stringify(r))
|
|
180
305
|
}
|
|
181
306
|
for (const s of sub) s(r)
|
|
182
|
-
if (opts.log !== false
|
|
307
|
+
if (opts.log !== false) print(e, r.fp)
|
|
183
308
|
return r
|
|
184
309
|
}
|
|
185
310
|
|
|
@@ -200,7 +325,7 @@ export function capture(o = {}) {
|
|
|
200
325
|
G.addEventListener('error', ev => record(ev.error || ev.message, null, 'onerror'))
|
|
201
326
|
G.addEventListener('unhandledrejection', ev => record(ev.reason, null, 'promise'))
|
|
202
327
|
G.addEventListener('popstate', () => crumb('nav', path(location.href)))
|
|
203
|
-
G.addEventListener('hashchange', () => crumb('nav', path(location.href)
|
|
328
|
+
G.addEventListener('hashchange', () => crumb('nav', path(location.href)))
|
|
204
329
|
}
|
|
205
330
|
const d = G.document
|
|
206
331
|
if (d && d.addEventListener) {
|
package/brain-form.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// Unsaved changes (§41.5): `f.dirty` is true while values differ from the last saved ones (init, reset() or a successful
|
|
16
16
|
// submit); leaving the page (client navigation or closing the tab) then asks first. No API.
|
|
17
17
|
import { sig, fx, batch, untrack, _rt } from './brain.js'
|
|
18
|
-
import { rule, check, msg } from './brain-schema.js'
|
|
18
|
+
import { rule, check, checkText, checkScalar, msg } from './brain-schema.js'
|
|
19
19
|
|
|
20
20
|
export { rule, check } // server.mjs validates posted forms with the same rules (§38)
|
|
21
21
|
|
|
@@ -41,9 +41,18 @@ if (globalThis.addEventListener && !globalThis.__brainDirtyHook) {
|
|
|
41
41
|
for (const f of DIRTY) if (f.dirty && !f.busy) return void (e.preventDefault(), (e.returnValue = ''))
|
|
42
42
|
})
|
|
43
43
|
}
|
|
44
|
-
const empty = v => v == null || v === '' ||
|
|
44
|
+
const empty = v => v == null || typeof v === 'string' && v.trim() === '' || Array.isArray(v) && !v.length
|
|
45
45
|
const init0 = r => (r.list ? [] : r.t === 'bool' ? false : '')
|
|
46
|
-
const out = (r, v) =>
|
|
46
|
+
const out = (r, v) => {
|
|
47
|
+
if (r.list) {
|
|
48
|
+
if (!Array.isArray(v)) return []
|
|
49
|
+
if (r.t !== 'num') return v
|
|
50
|
+
const numbers = new Array(v.length)
|
|
51
|
+
for (let i = 0; i < v.length; i++) numbers[i] = Number(v[i])
|
|
52
|
+
return numbers
|
|
53
|
+
}
|
|
54
|
+
return r.t === 'num' ? (empty(v) ? null : Number(v)) : r.t === 'bool' ? !!v : v
|
|
55
|
+
}
|
|
47
56
|
|
|
48
57
|
// state left by the server: globalThis.__brainForms while it renders, or the page's #brain-forms JSON
|
|
49
58
|
let SEEN
|
|
@@ -62,6 +71,46 @@ function posted(id) {
|
|
|
62
71
|
}
|
|
63
72
|
|
|
64
73
|
export function form(schema, o = {}) {
|
|
74
|
+
if (rule(schema).t !== 'obj') throw Error('form: schema must be a nested field object')
|
|
75
|
+
return createForm(schema, o, rule, check, remoteCheck)
|
|
76
|
+
}
|
|
77
|
+
// Compiler-only flat literal schema bytecode. Dynamic/public form() keeps the full parser.
|
|
78
|
+
export function formStatic(schema, o = {}) {
|
|
79
|
+
return compiledForm(schema, o, ['str', 'email'], checkText, remoteCheck)
|
|
80
|
+
}
|
|
81
|
+
export function formScalars(schema, o = {}) {
|
|
82
|
+
return compiledForm(schema, o, ['str', 'email', 'bool', 'num'], checkScalar, remoteCheck)
|
|
83
|
+
}
|
|
84
|
+
// Compiler-only options proven not to configure or dynamically install async checks.
|
|
85
|
+
export function formStaticSync(schema, o = {}) {
|
|
86
|
+
return compiledForm(schema, o, ['str', 'email'], checkText, null)
|
|
87
|
+
}
|
|
88
|
+
export function formScalarsSync(schema, o = {}) {
|
|
89
|
+
return compiledForm(schema, o, ['str', 'email', 'bool', 'num'], checkScalar, null)
|
|
90
|
+
}
|
|
91
|
+
function compiledForm(schema, o, types, validate, remote) {
|
|
92
|
+
if (remote === null && ('check' in o || 'validate' in o)) throw Error('form: invalid compiled synchronous options')
|
|
93
|
+
if (!schema || Object.getPrototypeOf(schema) !== Object.prototype || Object.keys(schema).length > 4096) throw Error('form: invalid compiled schema')
|
|
94
|
+
return createForm(schema, o, (value, key) => {
|
|
95
|
+
if (['__proto__', 'prototype', 'constructor'].includes(key) || !Array.isArray(value) || value.length !== 2 || !types.includes(value[0]) || typeof value[1] !== 'boolean') throw Error('form: invalid compiled schema')
|
|
96
|
+
return { t: value[0], req: value[1], list: false, min: null, max: null }
|
|
97
|
+
}, validate, remote)
|
|
98
|
+
}
|
|
99
|
+
// Only public/dynamic or check-configured forms retain this optional capability.
|
|
100
|
+
function remoteCheck(o, x, checking) {
|
|
101
|
+
const c = o.check && o.check[x.path], v = untrack(() => x.s.v)
|
|
102
|
+
if (!c || untrack(() => x.e.v)) return Promise.resolve(untrack(() => x.e.v))
|
|
103
|
+
checking.v++
|
|
104
|
+
return Promise.resolve()
|
|
105
|
+
.then(() => c(v))
|
|
106
|
+
.then(m => m || '', e => (e && e.kind === 'aborted' ? '' : (e && e.message) || msg('form.check_failed', 'Could not check this value.')))
|
|
107
|
+
.then(m => {
|
|
108
|
+
if (untrack(() => x.s.v) === v) x.e.v = m
|
|
109
|
+
return untrack(() => x.e.v)
|
|
110
|
+
})
|
|
111
|
+
.finally(() => checking.v--)
|
|
112
|
+
}
|
|
113
|
+
function createForm(schema, o, readRule, validateRule, remote) {
|
|
65
114
|
const id = 'f' + ++seq, owner = I.owner() // aria effects are created later (in handlers) but live with the component
|
|
66
115
|
const busy = sig(false), error = sig(''), shown = sig(0) // shown: bumped when touched set changes
|
|
67
116
|
const checking = sig(0), saved = sig('') // async checks running · JSON of the last saved values
|
|
@@ -72,7 +121,7 @@ export function form(schema, o = {}) {
|
|
|
72
121
|
function build(sch, base, init) {
|
|
73
122
|
const v = {}, err = {}
|
|
74
123
|
for (const k of Object.keys(sch)) {
|
|
75
|
-
const r =
|
|
124
|
+
const r = readRule(sch[k], k), path = base ? base + '.' + k : k
|
|
76
125
|
if (r.t === 'obj') {
|
|
77
126
|
const sub = build(r.s, path, (init && init[k]) || {})
|
|
78
127
|
Object.defineProperty(v, k, { enumerable: true, value: sub.v })
|
|
@@ -115,23 +164,9 @@ export function form(schema, o = {}) {
|
|
|
115
164
|
const touch = x => {
|
|
116
165
|
validate(x)
|
|
117
166
|
if (!x.touched) (x.touched = true), shown.v++
|
|
118
|
-
remote(x)
|
|
119
|
-
}
|
|
120
|
-
// async check of one field (only when its own rule passes); resolves to its message
|
|
121
|
-
const remote = x => {
|
|
122
|
-
const c = o.check && o.check[x.path], v = untrack(() => x.s.v)
|
|
123
|
-
if (!c || untrack(() => x.e.v)) return Promise.resolve(untrack(() => x.e.v))
|
|
124
|
-
checking.v++
|
|
125
|
-
return Promise.resolve()
|
|
126
|
-
.then(() => c(v))
|
|
127
|
-
.then(m => m || '', e => (e && e.kind === 'aborted' ? '' : (e && e.message) || msg('form.check_failed', 'Could not check this value.')))
|
|
128
|
-
.then(m => {
|
|
129
|
-
if (untrack(() => x.s.v) === v) x.e.v = m // a stale answer (the value changed meanwhile) is ignored
|
|
130
|
-
return untrack(() => x.e.v)
|
|
131
|
-
})
|
|
132
|
-
.finally(() => checking.v--)
|
|
167
|
+
if (remote) remote(o, x, checking)
|
|
133
168
|
}
|
|
134
|
-
const validate = x => (x.e.v = untrack(() =>
|
|
169
|
+
const validate = x => (x.e.v = untrack(() => validateRule(x.r, x.s.v)))
|
|
135
170
|
const fid = p => `${id}-${p.replace(/\./g, '-')}-err`
|
|
136
171
|
|
|
137
172
|
// A form posted without JS (§38) comes back from the server with its values and messages: start from them, already
|
|
@@ -196,7 +231,7 @@ export function form(schema, o = {}) {
|
|
|
196
231
|
return checking.v > 0
|
|
197
232
|
},
|
|
198
233
|
get ok() {
|
|
199
|
-
for (const x of F) if (
|
|
234
|
+
for (const x of F) if (validateRule(x.r, x.s.v)) return false
|
|
200
235
|
return true
|
|
201
236
|
},
|
|
202
237
|
id: path => fid(path),
|
|
@@ -244,8 +279,8 @@ export function form(schema, o = {}) {
|
|
|
244
279
|
busy.v = true
|
|
245
280
|
if (fm) fm.setAttribute('aria-busy', 'true') // assistive tech hears the form is working
|
|
246
281
|
try {
|
|
247
|
-
if (o.check) {
|
|
248
|
-
await Promise.all(F.map(remote))
|
|
282
|
+
if (remote && o.check) {
|
|
283
|
+
await Promise.all(F.map(x => remote(o, x, checking)))
|
|
249
284
|
first = F.find(x => untrack(() => x.e.v))
|
|
250
285
|
if (first) return void (shown.v++, first.el && first.el.focus && first.el.focus())
|
|
251
286
|
}
|
package/brain-hash.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Web Crypto owns cryptography in browsers and Node. No hashing implementation is bundled.
|
|
2
|
+
export async function hashText(value, algorithm = 'SHA-256') {
|
|
3
|
+
if (!['SHA-256', 'SHA-384', 'SHA-512'].includes(algorithm)) throw RangeError('hashText: SHA-256, SHA-384 or SHA-512 required')
|
|
4
|
+
if (typeof value !== 'string') throw TypeError('hashText: text input required')
|
|
5
|
+
if (value.length > 8388608) throw RangeError('hashText: input exceeds 8 MiB')
|
|
6
|
+
const bytes = new TextEncoder().encode(value)
|
|
7
|
+
if (bytes.byteLength > 8388608) throw RangeError('hashText: UTF-8 input exceeds 8 MiB')
|
|
8
|
+
if (!globalThis.crypto?.subtle) throw Error('hashText: Web Crypto requires a secure browser context or Node 22+')
|
|
9
|
+
const digest = new Uint8Array(await globalThis.crypto.subtle.digest(algorithm, bytes))
|
|
10
|
+
let hex = ''
|
|
11
|
+
for (let i = 0; i < digest.length; i++) hex += digest[i].toString(16).padStart(2, '0')
|
|
12
|
+
return hex
|
|
13
|
+
}
|
|
14
|
+
export async function shortHash(value, length = 32) {
|
|
15
|
+
if (!Number.isInteger(length) || length < 8 || length > 64) throw RangeError('shortHash: length must be 8..64 hex characters')
|
|
16
|
+
return (await hashText(value)).slice(0, length)
|
|
17
|
+
}
|
package/brain-inspect.js
CHANGED
|
@@ -16,8 +16,8 @@ const now = () => Date.now()
|
|
|
16
16
|
const pnow = () => (G.performance ? performance.now() : now())
|
|
17
17
|
const syncHeap = () => (G.performance && performance.memory ? performance.memory.usedJSHeapSize : G.process && process.memoryUsage ? process.memoryUsage().heapUsed : null)
|
|
18
18
|
|
|
19
|
-
let out = [], seq = 0, opts = {}, started = false
|
|
20
|
-
const push = r => out.push(r)
|
|
19
|
+
let out = [], seq = 0, opts = {}, started = false, dropped = 0
|
|
20
|
+
const push = r => { if (out.length >= 2000) { out.shift(); dropped++ } out.push(r) }
|
|
21
21
|
|
|
22
22
|
// ---------- instance identity and current owner ----------
|
|
23
23
|
const ids = new WeakMap()
|
|
@@ -612,6 +612,8 @@ export function tick() {
|
|
|
612
612
|
export function drain() {
|
|
613
613
|
if (!out.length) return null
|
|
614
614
|
const m = { t: 'm', v: 2, seq: seq++, at: now(), d: out }
|
|
615
|
+
if (dropped) m.dropped = dropped
|
|
616
|
+
dropped = 0
|
|
615
617
|
out = []
|
|
616
618
|
if (opts.send) opts.send(JSON.stringify(m))
|
|
617
619
|
return m
|
|
@@ -629,7 +631,7 @@ export function inspect(o) {
|
|
|
629
631
|
else i = instOf(x)
|
|
630
632
|
if (!i) return null
|
|
631
633
|
const chain = []
|
|
632
|
-
for (let j = i; j; j = instOf(j.p)) chain.push(nameOf(j))
|
|
634
|
+
for (let j = i; j && chain.length < 33; j = instOf(j.p)) chain.push(nameOf(j))
|
|
633
635
|
return { id: idOf(i), h: i.h, name: nameOf(i), file: (i.C && (i.C.f || (i.C.C && i.C.C.f))) || null, chain }
|
|
634
636
|
},
|
|
635
637
|
sigName,
|
|
@@ -643,7 +645,7 @@ export function inspect(o) {
|
|
|
643
645
|
if (done) done.add(key)
|
|
644
646
|
push({ k: 'chan', kind, name, role, id: i ? idOf(i) : null, h: i ? i.h : null, comp: i ? nameOf(i) : null })
|
|
645
647
|
})
|
|
646
|
-
sub.add(r => push({ k: 'err', fp: r.fp, code: r.code, msg: r.msg, where: r.where, comp: r.comp, line: r.line, src: r.src || null, stack: r.brainStack || r.stack, crumbs: r.crumbs, n: r.n, at: r.last }))
|
|
648
|
+
sub.add(r => push({ k: 'err', fp: r.fp, code: r.code, msg: r.msg, where: r.where, comp: r.comp, line: r.line, src: r.src || null, stack: r.brainStack || r.stack, brainStack: r.brainStack || null, location: r.location, generatedLocation: r.generatedLocation, frames: r.frames, causes: r.causes, truncated: r.truncated, mapping: r.mapping, crumbs: r.crumbs, n: r.n, at: r.last }))
|
|
647
649
|
patchTimers()
|
|
648
650
|
patchGlobalListeners()
|
|
649
651
|
patchHandlers()
|
package/brain-interop.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Explicit foreign-DOM ownership. The adapter, SDK and any React runtime belong to the app.
|
|
2
|
+
import { _rt, fx, untrack } from './brain.js'
|
|
3
|
+
|
|
4
|
+
const I = _rt()
|
|
5
|
+
|
|
6
|
+
// Call during component setup. Resolve the bound element only after insertion.
|
|
7
|
+
// mount must synchronously return a cleanup function or { update?, destroy }.
|
|
8
|
+
export function interop(element, adapter, props = {}, options = {}) {
|
|
9
|
+
const owner = I.owner()
|
|
10
|
+
if (!owner) throw Error('interop: call during component setup')
|
|
11
|
+
let alive = true, instance = null, pending = props, ready = false
|
|
12
|
+
const report = (error, phase) => {
|
|
13
|
+
if (typeof options.onError === 'function') options.onError(error, phase)
|
|
14
|
+
else I.oops(error, owner, 'interop:' + phase)
|
|
15
|
+
}
|
|
16
|
+
const destroy = () => {
|
|
17
|
+
if (!alive) return
|
|
18
|
+
alive = false
|
|
19
|
+
const old = instance
|
|
20
|
+
instance = null
|
|
21
|
+
if (old) try { old.destroy() } catch (error) { report(error, 'destroy') }
|
|
22
|
+
}
|
|
23
|
+
const controller = {
|
|
24
|
+
update(next) {
|
|
25
|
+
if (!alive) throw Error('interop: boundary has been destroyed')
|
|
26
|
+
pending = next
|
|
27
|
+
if (!ready) return
|
|
28
|
+
if (!instance || typeof instance.update !== 'function') throw Error('interop: adapter does not support updates')
|
|
29
|
+
try { untrack(() => instance.update(next)) } catch (error) { report(error, 'update') }
|
|
30
|
+
},
|
|
31
|
+
destroy,
|
|
32
|
+
}
|
|
33
|
+
// No SDK execution during SSR or prerender. Disposal still invalidates this controller.
|
|
34
|
+
fx(() => destroy)
|
|
35
|
+
if (globalThis.__brainSSR || globalThis.__brainRoute) return controller
|
|
36
|
+
queueMicrotask(() => {
|
|
37
|
+
if (!alive) return
|
|
38
|
+
try {
|
|
39
|
+
const el = typeof element === 'function' ? untrack(element) : element
|
|
40
|
+
if (!el || el.nodeType !== 1) throw Error('interop: expected a bound DOM element')
|
|
41
|
+
if (!adapter || typeof adapter.mount !== 'function') throw Error('interop: adapter.mount is required')
|
|
42
|
+
const result = untrack(() => adapter.mount(el, pending))
|
|
43
|
+
if (typeof result === 'function') instance = { destroy: result }
|
|
44
|
+
else if (result && typeof result.destroy === 'function' && typeof result.then !== 'function') instance = result
|
|
45
|
+
else throw Error('interop: mount must return cleanup or { update, destroy }; asynchronous mounts are unsupported')
|
|
46
|
+
ready = true
|
|
47
|
+
} catch (error) { report(error, 'mount') }
|
|
48
|
+
})
|
|
49
|
+
return controller
|
|
50
|
+
}
|
package/brain-overlay.js
CHANGED
|
@@ -24,10 +24,21 @@ export function forLLM(d) {
|
|
|
24
24
|
})
|
|
25
25
|
if (d.fix) out.push(`fix: ${d.fix}`)
|
|
26
26
|
if (d.brainStack) out.push('stack:', ...[].concat(d.brainStack))
|
|
27
|
+
if (d.location || d.frames || d.causes || d.rid) out.push('structured trace (development only):', JSON.stringify(traceOf(d)))
|
|
28
|
+
if (d.mapping?.status && d.mapping.status !== 'mapped') out.push('Location mapping is incomplete; do not treat generated coordinates as original source coordinates.')
|
|
29
|
+
out.push('Agent next steps: inspect the exact source revision, apply a focused change, run brain check --json, then reproduce the failing action. Record evidence with brain history and refresh brain quality.')
|
|
27
30
|
if (d.file) out.push('brain check --json:', JSON.stringify((byFile.get(d.file) || [d]).map(({ snippet, ...x }) => x)))
|
|
28
31
|
return out.join('\n')
|
|
29
32
|
}
|
|
30
33
|
|
|
34
|
+
function traceOf(d) {
|
|
35
|
+
return { schemaVersion: 1, requestId: d.rid || d.requestId || null, component: d.comp || null,
|
|
36
|
+
location: d.location || null, generatedLocation: d.generatedLocation || null,
|
|
37
|
+
mapping: d.mapping || null, truncated: d.truncated || null,
|
|
38
|
+
frames: Array.isArray(d.frames) ? d.frames.slice(0, 24) : [],
|
|
39
|
+
causes: Array.isArray(d.causes) ? d.causes.slice(0, 6) : [] }
|
|
40
|
+
}
|
|
41
|
+
|
|
31
42
|
function item(d, i) {
|
|
32
43
|
const loc = d.file ? `${d.file}:${d.line || ''}${d.col ? ':' + d.col : ''}` : d.src || ''
|
|
33
44
|
const snip = d.snippet && d.snippet.text
|
|
@@ -35,6 +46,7 @@ function item(d, i) {
|
|
|
35
46
|
: ''
|
|
36
47
|
return `<li class="${d.level === 'warning' || d.level === 'info' ? d.level : 'error'}"><p class="t"><b>${esc(d.code || 'Error')}</b> <span>${esc(loc)}</span></p>` +
|
|
37
48
|
`<p>${esc(d.msg)}</p>${snip}${d.fix ? `<p class="fix">fix: ${esc(d.fix)}</p>` : ''}` +
|
|
49
|
+
`${d.location || d.frames || d.causes ? `<details><summary>Development trace and mapping</summary><pre>${esc(JSON.stringify(traceOf(d), null, 2))}</pre></details>` : ''}` +
|
|
38
50
|
`<button type="button" data-copy="${i}">Copy for the LLM</button></li>`
|
|
39
51
|
}
|
|
40
52
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Small public preferences only. Authentication belongs to HttpOnly server cookies, never Web Storage.
|
|
2
|
+
const G = globalThis
|
|
3
|
+
const privateName = /password|secret|token|authorization|sessionid|csrf|jwt|credit/i
|
|
4
|
+
const valid = name => typeof name === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/.test(name) && !privateName.test(name)
|
|
5
|
+
const problem = fix => Object.assign(new Error(fix), { code: 'B0913', fix })
|
|
6
|
+
|
|
7
|
+
export function storage(name, options = {}) {
|
|
8
|
+
if (!valid(name) || !valid(options.namespace || 'app')) throw problem('storage: use public preference names; keep authentication in server sessions')
|
|
9
|
+
if (options.scope && !['local', 'session'].includes(options.scope)) throw problem('storage: scope must be local or session')
|
|
10
|
+
const prefix = `brain:pref:${options.namespace || 'app'}:${name}:`, version = options.version || 1
|
|
11
|
+
const backend = () => {
|
|
12
|
+
let s
|
|
13
|
+
try { s = options.scope === 'session' ? G.sessionStorage : G.localStorage } catch {}
|
|
14
|
+
if (!s) throw problem('Web Storage is unavailable; use an in-memory preference or a server data source')
|
|
15
|
+
return s
|
|
16
|
+
}
|
|
17
|
+
const key = k => { if (!valid(k)) throw problem('storage: keys must be bounded public preference names'); return prefix + k }
|
|
18
|
+
const keys = s => {
|
|
19
|
+
if (s.length > 4096) throw problem('storage: origin contains too many keys; reduce stored preferences')
|
|
20
|
+
const result = []
|
|
21
|
+
for (let i = 0; i < s.length; i++) { const k = s.key(i); if (k?.startsWith(prefix)) result.push(k) }
|
|
22
|
+
return result
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
get(k, fallback = null) {
|
|
26
|
+
const s = backend(), raw = s.getItem(key(k))
|
|
27
|
+
if (raw == null) return fallback
|
|
28
|
+
if (raw.length > 8192) throw problem('storage: preference exceeds 8 KiB; use database() for structured data')
|
|
29
|
+
let r; try { r = JSON.parse(raw) } catch { throw problem('storage: malformed preference; explicitly delete or replace this key') }
|
|
30
|
+
return r?.v === version ? r.data : fallback
|
|
31
|
+
},
|
|
32
|
+
set(k, value) {
|
|
33
|
+
const s = backend(), target = key(k)
|
|
34
|
+
let raw
|
|
35
|
+
try { raw = JSON.stringify({v:version,data:value}); if (value === undefined) throw Error() } catch { throw problem('storage: values must be JSON-serializable') }
|
|
36
|
+
if (new TextEncoder().encode(raw).length > 8192) throw problem('storage: preference exceeds 8 KiB; use database() for structured data')
|
|
37
|
+
const existing = keys(s)
|
|
38
|
+
if (existing.length >= 128 && !existing.includes(target)) throw problem('storage: namespace exceeds 128 preference keys')
|
|
39
|
+
let total = new TextEncoder().encode(raw).length
|
|
40
|
+
for (const name of existing) if (name !== target) total += new TextEncoder().encode(s.getItem(name) || '').length
|
|
41
|
+
if (total > 65536) throw problem('storage: namespace exceeds 64 KiB; move structured data to database()')
|
|
42
|
+
try { s.setItem(target, raw) } catch { throw problem('storage: write failed or quota exceeded; reduce stored preferences') }
|
|
43
|
+
},
|
|
44
|
+
delete(k) { backend().removeItem(key(k)) },
|
|
45
|
+
clear() { const s = backend(); for (const k of keys(s)) s.removeItem(k) },
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function cookie(name, value, options = {}) {
|
|
50
|
+
if (!valid(name)) throw problem('cookie: use a public preference name; authentication cookies belong to the backend')
|
|
51
|
+
if (!G.document) throw problem('cookie: browser document unavailable; use ctx.cookies on the server')
|
|
52
|
+
const key = `brain_pref_${name}`
|
|
53
|
+
if (value === undefined) {
|
|
54
|
+
const matches = G.document.cookie.split(';').map(x => x.trim()).filter(x => x.startsWith(key + '='))
|
|
55
|
+
if (matches.length !== 1) return null
|
|
56
|
+
try { return decodeURIComponent(matches[0].slice(key.length + 1)) } catch { return null }
|
|
57
|
+
}
|
|
58
|
+
const encoded = value === null ? '' : encodeURIComponent(String(value))
|
|
59
|
+
if (encoded.length > 2048) throw problem('cookie: preferences must be at most 2 KiB; use database() for larger data')
|
|
60
|
+
const age = value === null ? 0 : options.maxAge ?? 2592000
|
|
61
|
+
if (!Number.isSafeInteger(age) || age < 0 || age > 31536000) throw problem('cookie: maxAge must be between zero and one year in seconds')
|
|
62
|
+
G.document.cookie = `${key}=${encoded}; Path=/; Secure; SameSite=Lax; Max-Age=${age}`
|
|
63
|
+
return value
|
|
64
|
+
}
|