@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-sqlite.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Official SQLite Wasm adapter for a project-owned worker. IndexedDB remains the dependency-free default.
|
|
2
|
+
const G = globalThis
|
|
3
|
+
const fault = (code, fix) => Object.assign(new Error(fix), { code, fix })
|
|
4
|
+
const nameOK = x => typeof x === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,47}$/.test(x)
|
|
5
|
+
|
|
6
|
+
export function sqlite(initialize, options = {}) {
|
|
7
|
+
const name = options.name || 'app', namespace = options.namespace || 'app'
|
|
8
|
+
if (!nameOK(name) || !nameOK(namespace) || typeof initialize !== 'function') throw fault('B0916', 'sqlite: provide the official initializer and stable names')
|
|
9
|
+
let opening, db, pool, release, closed = false, pending = 0, chain = Promise.resolve()
|
|
10
|
+
const lease = new Promise(resolve => { release = resolve })
|
|
11
|
+
async function open() {
|
|
12
|
+
if (closed) throw fault('B0916', 'Local SQL handle is closed; create a new worker')
|
|
13
|
+
if (opening) return opening
|
|
14
|
+
if (G.document || !G.navigator?.storage?.getDirectory || !G.navigator?.locks?.request || typeof G.postMessage !== 'function') throw fault('B0916', 'Persistent SQLite needs an owned worker with OPFS and Web Locks; use database() when unavailable')
|
|
15
|
+
opening = new Promise((resolve, reject) => {
|
|
16
|
+
G.navigator.locks.request(`brain:sqlite:${namespace}:${name}`, { ifAvailable: true }, async lock => {
|
|
17
|
+
if (!lock) throw fault('B0916', 'This local SQL database is open in another worker; close its handle before retrying')
|
|
18
|
+
let stage = 'official SDK initialization'
|
|
19
|
+
try {
|
|
20
|
+
const engine = await initialize({ print() {}, printErr() {} })
|
|
21
|
+
if (closed) throw fault('B0916', 'Local SQL handle closed while initializing')
|
|
22
|
+
if (!engine.capi || engine.capi.sqlite3_libversion_number() < 3054000) throw fault('B0916', 'SQLite 3.54 or newer is required for non-destructive OPFS initialization')
|
|
23
|
+
stage = 'non-destructive OPFS initialization'
|
|
24
|
+
pool = await engine.installOpfsSAHPoolVfs({ name: `brain-${namespace}-${name}`, directory: `/brain-sqlite/${namespace}/${name}`, initialCapacity: 6, clearOnInit: false, preserveOnInitFailure: true })
|
|
25
|
+
stage = 'OPFS database open'
|
|
26
|
+
db = new pool.OpfsSAHPoolDb(`/${name}.sqlite3`)
|
|
27
|
+
stage = 'SQLite resource limits'
|
|
28
|
+
const c = engine.capi
|
|
29
|
+
for (const [constant, value] of [['SQLITE_LIMIT_LENGTH',1048576],['SQLITE_LIMIT_SQL_LENGTH',65536],['SQLITE_LIMIT_COLUMN',128],['SQLITE_LIMIT_EXPR_DEPTH',100],['SQLITE_LIMIT_COMPOUND_SELECT',16],['SQLITE_LIMIT_VARIABLE_NUMBER',128],['SQLITE_LIMIT_ATTACHED',0],['SQLITE_LIMIT_TRIGGER_DEPTH',16],['SQLITE_LIMIT_VDBE_OP',100000]]) {
|
|
30
|
+
if (typeof c[constant] !== 'number') throw fault('B0916', 'Official SQLite limit API is incomplete; verify the installed SDK version')
|
|
31
|
+
c.sqlite3_limit(db.pointer, c[constant], value)
|
|
32
|
+
}
|
|
33
|
+
db.exec('PRAGMA page_size=4096; PRAGMA cache_size=-2048; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=1000;')
|
|
34
|
+
const pageSize = Number(db.selectValue('PRAGMA page_size')), pages = Number(db.selectValue('PRAGMA page_count'))
|
|
35
|
+
if (!Number.isSafeInteger(pageSize) || pageSize < 512 || pageSize > 65536 || pages * pageSize > 67108864) throw fault('B0914', 'Local SQL database exceeds its 64 MiB budget; archive or migrate it explicitly')
|
|
36
|
+
db.exec('PRAGMA max_page_count=' + Math.floor(67108864 / pageSize))
|
|
37
|
+
if (closed) throw fault('B0916', 'Local SQL handle closed while initializing')
|
|
38
|
+
resolve({ db, c })
|
|
39
|
+
await lease
|
|
40
|
+
} catch (e) {
|
|
41
|
+
reject(e?.code === 'B0916' ? e : fault('B0916', 'Persistent SQLite failed during ' + stage + '; existing data is preserved. Check storage capabilities and the official SDK assets'))
|
|
42
|
+
} finally {
|
|
43
|
+
try { db?.close() } catch {}
|
|
44
|
+
try { pool?.pauseVfs() } catch {}
|
|
45
|
+
db = null
|
|
46
|
+
}
|
|
47
|
+
}).catch(e => reject(e?.code === 'B0916' ? e : fault('B0916', 'Local SQL lock failed; close competing workers and retry')))
|
|
48
|
+
})
|
|
49
|
+
return opening
|
|
50
|
+
}
|
|
51
|
+
function enqueue(work) {
|
|
52
|
+
if (closed) return Promise.reject(fault('B0916', 'Local SQL handle is closed'))
|
|
53
|
+
if (pending >= 32) return Promise.reject(fault('B0914', 'Local SQL queue is full; await pending queries'))
|
|
54
|
+
pending++
|
|
55
|
+
const result = chain.then(async () => { const state = await open(); if (closed) throw fault('B0916', 'Local SQL handle is closed'); return work(state) })
|
|
56
|
+
chain = result.catch(() => {}).finally(() => pending--)
|
|
57
|
+
return result
|
|
58
|
+
}
|
|
59
|
+
function statement(state, sql, bind = [], readonly = false) {
|
|
60
|
+
if (typeof sql !== 'string' || !sql.trim() || new TextEncoder().encode(sql).length > 65536 || sql.includes('\0')) throw fault('B0916', 'Local SQL needs one statement of at most 64 KiB; use bound values')
|
|
61
|
+
if (!Array.isArray(bind) || bind.length > 128 || bind.some(v => !(v == null || ['string','number','bigint'].includes(typeof v) || v instanceof Uint8Array))) throw fault('B0916', 'Local SQL bindings must be at most 128 scalar or binary values')
|
|
62
|
+
let bytes = 0
|
|
63
|
+
for (const value of bind) {
|
|
64
|
+
if (typeof value === 'number' && !Number.isFinite(value)) throw fault('B0916', 'Local SQL numeric bindings must be finite')
|
|
65
|
+
bytes += value instanceof Uint8Array ? value.byteLength : typeof value === 'string' ? new TextEncoder().encode(value).length : 8
|
|
66
|
+
if (bytes > 1048576) throw fault('B0914', 'Local SQL bindings exceed 1 MiB; split the operation')
|
|
67
|
+
}
|
|
68
|
+
const leading = sql.replace(/^(?:\s+|--[^\n]*(?:\n|$)|\/\*[\s\S]*?\*\/)+/, '')
|
|
69
|
+
if (/^(?:begin|commit|rollback|savepoint|release|attach|detach|pragma|vacuum)\b/i.test(leading)) throw fault('B0916', 'Use native batch() for transactions; attachment, pragma and maintenance commands are not public query operations')
|
|
70
|
+
let stmt, phase = "prepare"
|
|
71
|
+
try {
|
|
72
|
+
stmt = state.db.prepare(sql)
|
|
73
|
+
phase = "statement boundary"
|
|
74
|
+
const consumed = state.c.sqlite3_sql(stmt.pointer)
|
|
75
|
+
if (typeof consumed !== 'string' || !/^[\s;]*$/.test(sql.slice(consumed.length))) throw fault('B0916', 'Use batch() for multiple SQL statements')
|
|
76
|
+
phase = "read-only validation"
|
|
77
|
+
if (readonly && !state.c.sqlite3_stmt_readonly(stmt.pointer)) throw fault('B0916', 'query() accepts read-only statements; use execute() or batch() for mutations')
|
|
78
|
+
phase = "binding"
|
|
79
|
+
if (stmt.parameterCount !== bind.length) throw fault('B0916', 'Local SQL binding count does not match the prepared statement')
|
|
80
|
+
if (bind.length) stmt.bind(bind)
|
|
81
|
+
phase = "execution"
|
|
82
|
+
const rows = []
|
|
83
|
+
let size = 0
|
|
84
|
+
while (stmt.step()) {
|
|
85
|
+
if (rows.length >= 200) throw fault('B0914', 'Local SQL returned more than 200 rows; add LIMIT and keyset pagination')
|
|
86
|
+
const row = stmt.get(Object.create(null))
|
|
87
|
+
for (const key of Object.keys(row)) {
|
|
88
|
+
if (typeof row[key] === 'bigint') row[key] = String(row[key])
|
|
89
|
+
const value = row[key]
|
|
90
|
+
if (typeof value === 'number' && !Number.isFinite(value)) throw fault('B0916','Local SQL returned a non-finite number; cast it to a bounded application value')
|
|
91
|
+
size += key.length * 2 + (value instanceof Uint8Array ? value.byteLength : typeof value === 'string' ? new TextEncoder().encode(value).length : 8)
|
|
92
|
+
}
|
|
93
|
+
if (size > 2097152) throw fault('B0914', 'Local SQL response exceeds 2 MiB; select fewer columns or rows')
|
|
94
|
+
rows.push(row)
|
|
95
|
+
}
|
|
96
|
+
return { rows, changes: state.db.changes(), bytes:size }
|
|
97
|
+
} catch (e) {
|
|
98
|
+
throw /^B091[46]$/.test(e?.code || '') ? e : fault('B0916', 'Local SQL operation failed during ' + phase + '; inspect the query and schema in development without exposing SQL values in production')
|
|
99
|
+
} finally { try { stmt?.finalize() } catch { throw fault('B0916','Local SQL statement cleanup failed; close the owned worker before retrying') } }
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
query(sql, bind) { return enqueue(state => statement(state, sql, bind, true).rows) },
|
|
103
|
+
execute(sql, bind) { return enqueue(state => { let result; state.db.transaction(() => { result = statement(state, sql, bind) }); return result }) },
|
|
104
|
+
batch(operations) {
|
|
105
|
+
if (!Array.isArray(operations) || operations.length > 64) return Promise.reject(fault('B0914', 'Local SQL batches accept at most 64 operations'))
|
|
106
|
+
return enqueue(state => {
|
|
107
|
+
let result
|
|
108
|
+
state.db.transaction(() => {
|
|
109
|
+
let bytes=0, rows=0
|
|
110
|
+
result=operations.map(o => {
|
|
111
|
+
const value=statement(state,o.sql,o.bind); bytes+=value.bytes; rows+=value.rows.length
|
|
112
|
+
if (bytes>2097152 || rows>200) throw fault('B0914','Local SQL batch response exceeds 200 rows or 2 MiB; paginate the operation')
|
|
113
|
+
return value
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
return result
|
|
117
|
+
})
|
|
118
|
+
},
|
|
119
|
+
close() { closed = true; release(); return chain.then(() => true) },
|
|
120
|
+
info() { return { engine:'official-sqlite-wasm', storage:'opfs-sahpool', namespace, name, pending, closed, maxRows:200, maxDatabaseBytes:67108864 } },
|
|
121
|
+
}
|
|
122
|
+
}
|
package/brain-storage.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Native structured local data. Browser storage is untrusted, evictable and never an authentication authority.
|
|
2
|
+
const G = globalThis
|
|
3
|
+
const error = (code, fix, cause) => Object.assign(new Error(fix), { code, fix, cause })
|
|
4
|
+
const identifier = n => typeof n === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/.test(n)
|
|
5
|
+
|
|
6
|
+
export async function storageInfo() {
|
|
7
|
+
const s = G.navigator?.storage
|
|
8
|
+
let estimate = null, persisted = false
|
|
9
|
+
try { estimate = s?.estimate ? await s.estimate() : null; persisted = s?.persisted ? await s.persisted() : false } catch {}
|
|
10
|
+
return { indexedDB: !!G.indexedDB, opfs: !!s?.getDirectory, persisted,
|
|
11
|
+
usage: Number.isFinite(estimate?.usage) ? estimate.usage : null,
|
|
12
|
+
quota: Number.isFinite(estimate?.quota) ? estimate.quota : null }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Request only from a user action; browsers may refuse. Never promise permanent storage.
|
|
16
|
+
export async function persistStorage() {
|
|
17
|
+
return G.navigator?.storage?.persist ? !!(await G.navigator.storage.persist()) : false
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function database(name, options = {}) {
|
|
21
|
+
if (!identifier(name)) throw error('B0913', 'database: use a stable alphanumeric name of at most 80 characters')
|
|
22
|
+
const namespace = options.namespace || 'app'
|
|
23
|
+
if (!identifier(namespace)) throw error('B0913', 'database: use an explicit stable application namespace')
|
|
24
|
+
const build = options.build == null ? '' : String(options.build)
|
|
25
|
+
if (build.length > 128) throw error('B0913', 'database: build identifiers must be at most 128 characters')
|
|
26
|
+
let opening = null, connection = null, closed = false, pending = 0
|
|
27
|
+
const active = new Set()
|
|
28
|
+
const deadline = Math.max(100, Math.min(30000, Number(options.timeout) || 10000))
|
|
29
|
+
const fail = e => error(e?.name === 'QuotaExceededError' ? 'B0914' : 'B0913',
|
|
30
|
+
e?.name === 'QuotaExceededError' ? 'Local storage quota exceeded; evict owned cache records or reduce stored data' : 'Local database unavailable; keep authoritative data on the server and retry the operation', e)
|
|
31
|
+
function open() {
|
|
32
|
+
if (closed) return Promise.reject(error('B0913', 'database: connection was closed; create a new handle'))
|
|
33
|
+
if (opening) return opening
|
|
34
|
+
if (!G.indexedDB) return Promise.reject(error('B0913', 'IndexedDB is unavailable; use a server-backed data source'))
|
|
35
|
+
opening = new Promise((resolve, reject) => {
|
|
36
|
+
let settled = false
|
|
37
|
+
const r = G.indexedDB.open(`brain:db:${namespace}:${name}`, 1)
|
|
38
|
+
const timer = setTimeout(() => done(error('B0913', 'Local database open timed out; close an older tab holding an upgrade lock')), deadline)
|
|
39
|
+
const done = e => { if (!settled) { settled = true; clearTimeout(timer); reject(e) } }
|
|
40
|
+
r.onupgradeneeded = () => { if (!r.result.objectStoreNames.contains('records')) r.result.createObjectStore('records') }
|
|
41
|
+
r.onblocked = () => done(error('B0913', 'Local database upgrade blocked; close older tabs and retry'))
|
|
42
|
+
r.onerror = () => done(fail(r.error))
|
|
43
|
+
r.onsuccess = () => {
|
|
44
|
+
if (settled || closed) { r.result.close(); return }
|
|
45
|
+
settled = true; clearTimeout(timer)
|
|
46
|
+
connection = r.result
|
|
47
|
+
connection.onversionchange = () => { connection?.close(); connection = null; opening = null }
|
|
48
|
+
connection.onclose = () => { connection = null; opening = null }
|
|
49
|
+
resolve(connection)
|
|
50
|
+
}
|
|
51
|
+
}).catch(e => { opening = null; throw e })
|
|
52
|
+
return opening
|
|
53
|
+
}
|
|
54
|
+
function key(k) {
|
|
55
|
+
if (typeof k !== 'string' || !k.length || k.length > 256) throw error('B0913', 'database: keys must contain between 1 and 256 characters')
|
|
56
|
+
return k
|
|
57
|
+
}
|
|
58
|
+
function record(value, ttl) {
|
|
59
|
+
if (!Number.isFinite(ttl) || ttl < 0 || ttl > 31536000000) throw error('B0913', 'database: ttl must be between zero and one year in milliseconds')
|
|
60
|
+
let bytes, json
|
|
61
|
+
try { json = JSON.stringify(value); if (json === undefined) throw new TypeError('undefined'); bytes = new TextEncoder().encode(json).length } catch (e) { throw error('B0913', 'database: values must be JSON-serializable', e) }
|
|
62
|
+
if (bytes > 1048576) throw error('B0914', 'database: split records larger than 1 MiB or use OPFS for large files')
|
|
63
|
+
return { v: 1, build, expires: ttl ? Date.now() + ttl : 0, value: JSON.parse(json) }
|
|
64
|
+
}
|
|
65
|
+
const valid = r => r?.v === 1 && r.build === build && (!r.expires || r.expires > Date.now())
|
|
66
|
+
async function transaction(mode, work) {
|
|
67
|
+
if (pending >= 32) throw error('B0914', 'database: await pending local operations before scheduling more work')
|
|
68
|
+
pending++
|
|
69
|
+
try {
|
|
70
|
+
const db = await open()
|
|
71
|
+
if (closed) throw error('B0913', 'database: handle was closed')
|
|
72
|
+
return await new Promise((resolve, reject) => {
|
|
73
|
+
const tx = db.transaction('records', mode), store = tx.objectStore('records')
|
|
74
|
+
active.add(tx)
|
|
75
|
+
let result, reason
|
|
76
|
+
const timer = setTimeout(() => { reason = error('B0913', 'Local transaction timed out; reduce the batch size'); try { tx.abort() } catch {} }, deadline)
|
|
77
|
+
tx.oncomplete = () => { clearTimeout(timer); active.delete(tx); resolve(result) }
|
|
78
|
+
tx.onabort = tx.onerror = () => { clearTimeout(timer); active.delete(tx); reject(reason || fail(tx.error)) }
|
|
79
|
+
try { work(store, v => { result = v }) } catch (e) { reason = e; try { tx.abort() } catch { clearTimeout(timer); reject(e) } }
|
|
80
|
+
})
|
|
81
|
+
} catch (e) { throw e?.code ? e : fail(e) } finally { pending-- }
|
|
82
|
+
}
|
|
83
|
+
const api = {
|
|
84
|
+
get(k) { key(k); return transaction('readonly', (s, done) => { const r = s.get(k); r.onsuccess = () => done(valid(r.result) ? r.result.value : null) }) },
|
|
85
|
+
put(k, value, o = {}) { key(k); const r = record(value, o.ttl ?? 0); return transaction('readwrite', s => s.put(r, k)) },
|
|
86
|
+
delete(k) { key(k); return transaction('readwrite', s => s.delete(k)) },
|
|
87
|
+
clear() { return transaction('readwrite', s => s.clear()) },
|
|
88
|
+
batch(operations) {
|
|
89
|
+
if (!Array.isArray(operations) || operations.length > 128) throw error('B0914', 'database: atomic batches accept at most 128 operations')
|
|
90
|
+
let bytes = 0
|
|
91
|
+
const ops = operations.map(o => {
|
|
92
|
+
key(o.key)
|
|
93
|
+
if (o.op === 'delete') return { op: 'delete', key: o.key }
|
|
94
|
+
if (o.op !== 'put') throw error('B0913', 'database: batch operations must be put or delete')
|
|
95
|
+
const r = record(o.value, o.ttl ?? 0)
|
|
96
|
+
bytes += new TextEncoder().encode(JSON.stringify(r)).length
|
|
97
|
+
if (bytes > 4194304) throw error('B0914', 'database: atomic batch exceeds 4 MiB')
|
|
98
|
+
return { op: 'put', key: o.key, record: r }
|
|
99
|
+
})
|
|
100
|
+
return transaction('readwrite', s => { for (const o of ops) o.op === 'put' ? s.put(o.record, o.key) : s.delete(o.key) })
|
|
101
|
+
},
|
|
102
|
+
scan(o = {}) {
|
|
103
|
+
const limit = Math.max(1, Math.min(200, Number(o.limit) || 50)), prefix = o.prefix || '', after = o.after || ''
|
|
104
|
+
if (typeof prefix !== 'string' || prefix.length > 256 || typeof after !== 'string' || after.length > 256) throw error('B0913', 'database: scan prefixes and cursors must be strings of at most 256 characters')
|
|
105
|
+
return transaction('readonly', (s, done) => {
|
|
106
|
+
const items = [], r = s.openCursor(after ? G.IDBKeyRange.lowerBound(after, true) : prefix ? G.IDBKeyRange.lowerBound(prefix) : null)
|
|
107
|
+
let visited = 0, bytes = 0, previous = null
|
|
108
|
+
r.onsuccess = () => {
|
|
109
|
+
const c = r.result
|
|
110
|
+
if (!c) return done({ items, cursor: null })
|
|
111
|
+
const k = String(c.key)
|
|
112
|
+
if (prefix && k < prefix) { c.continue(prefix); return }
|
|
113
|
+
if (prefix && !k.startsWith(prefix)) return done({ items, cursor: null })
|
|
114
|
+
visited++
|
|
115
|
+
if (valid(c.value)) {
|
|
116
|
+
bytes += new TextEncoder().encode(JSON.stringify(c.value.value)).length
|
|
117
|
+
if (bytes > 2097152) return done({ items, cursor: previous })
|
|
118
|
+
items.push({ key: k, value: c.value.value })
|
|
119
|
+
}
|
|
120
|
+
previous = k
|
|
121
|
+
if (items.length >= limit || visited >= 1000) return done({ items, cursor: k })
|
|
122
|
+
c.continue()
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
},
|
|
126
|
+
prune() {
|
|
127
|
+
return transaction('readwrite', (s, done) => {
|
|
128
|
+
let visited = 0, deleted = 0
|
|
129
|
+
const r = s.openCursor()
|
|
130
|
+
r.onsuccess = () => {
|
|
131
|
+
const c = r.result
|
|
132
|
+
if (!c || visited >= 1000) return done({ visited, deleted, truncated: !!c })
|
|
133
|
+
visited++
|
|
134
|
+
if (!valid(c.value)) { c.delete(); deleted++ }
|
|
135
|
+
c.continue()
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
},
|
|
139
|
+
close() { closed = true; for (const tx of active) { try { tx.abort() } catch {} }; connection?.close(); connection = null },
|
|
140
|
+
info: storageInfo,
|
|
141
|
+
}
|
|
142
|
+
return api
|
|
143
|
+
}
|
package/brain-threads.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Thin adapters over platform APIs. Experimental worklets are capabilities, never assumed browser support.
|
|
2
|
+
const G = globalThis
|
|
3
|
+
const fail = fix => Object.assign(new Error(fix), {code:'B0917',fix})
|
|
4
|
+
function local(url) {
|
|
5
|
+
if (!G.location) throw fail('Thread module loading requires a browser context')
|
|
6
|
+
const value = new URL(url,G.location.href)
|
|
7
|
+
if (value.origin !== G.location.origin || value.username || value.password || !/^https?:$/.test(value.protocol)) throw fail('Use a same-origin versioned module URL without credentials')
|
|
8
|
+
return value.href
|
|
9
|
+
}
|
|
10
|
+
export function threadCapabilities() {
|
|
11
|
+
return { dedicated:typeof G.Worker==='function', shared:typeof G.SharedWorker==='function',
|
|
12
|
+
service:!!G.navigator?.serviceWorker, messageChannel:typeof G.MessageChannel==='function',
|
|
13
|
+
transferableArrayBuffer:typeof G.ArrayBuffer==='function', sharedMemory:!!G.crossOriginIsolated&&typeof G.SharedArrayBuffer==='function'&&!!G.Atomics,
|
|
14
|
+
offscreenCanvas:typeof G.OffscreenCanvas==='function', wasm:!!G.WebAssembly,
|
|
15
|
+
audioWorklet:typeof G.AudioWorkletNode==='function', paintWorklet:!!G.CSS?.paintWorklet?.addModule,
|
|
16
|
+
layoutWorklet:!!G.CSS?.layoutWorklet?.addModule, animationWorklet:!!G.CSS?.animationWorklet?.addModule,
|
|
17
|
+
indexedDB:!!G.indexedDB, opfs:!!G.navigator?.storage?.getDirectory,
|
|
18
|
+
backgroundSync:!!G.ServiceWorkerRegistration?.prototype&&'sync' in G.ServiceWorkerRegistration.prototype,
|
|
19
|
+
push:typeof G.PushManager==='function' }
|
|
20
|
+
}
|
|
21
|
+
export async function loadWorklet(kind,url,options={}) {
|
|
22
|
+
const target = kind==='audio' ? options.context?.audioWorklet : kind==='paint' ? G.CSS?.paintWorklet : kind==='layout' ? G.CSS?.layoutWorklet : kind==='animation' ? G.CSS?.animationWorklet : null
|
|
23
|
+
if (!target?.addModule) {
|
|
24
|
+
if (typeof options.fallback==='function') return options.fallback()
|
|
25
|
+
throw fail('Requested worklet is unavailable; supply an explicit rendering/audio fallback')
|
|
26
|
+
}
|
|
27
|
+
// addModule has no portable cancellation. Bound outstanding requests and avoid spawning an AudioContext implicitly.
|
|
28
|
+
if (loading>=4) throw fail('Too many worklet loads; await existing modules')
|
|
29
|
+
loading++
|
|
30
|
+
try { await target.addModule(local(url),{credentials:'omit'}); return {available:true,kind} }
|
|
31
|
+
catch { throw fail('Worklet module could not load; check its same-origin asset URL and production CSP') }
|
|
32
|
+
finally { loading-- }
|
|
33
|
+
}
|
|
34
|
+
let loading=0
|
|
35
|
+
export function messageChannel() {
|
|
36
|
+
if (!G.MessageChannel) throw fail('MessageChannel is unavailable in this context')
|
|
37
|
+
const c=new G.MessageChannel()
|
|
38
|
+
return {port1:c.port1,port2:c.port2,close(){c.port1.close();c.port2.close()}}
|
|
39
|
+
}
|
|
40
|
+
export function offscreen(canvas) {
|
|
41
|
+
if (!canvas?.transferControlToOffscreen) throw fail('OffscreenCanvas transfer is unavailable; keep the canvas on the main thread')
|
|
42
|
+
try { return canvas.transferControlToOffscreen() }
|
|
43
|
+
catch { throw fail('Canvas was already initialized or transferred; transfer it before acquiring a rendering context') }
|
|
44
|
+
}
|
|
45
|
+
export function sharedMemory(bytes) {
|
|
46
|
+
if (!threadCapabilities().sharedMemory) throw fail('Shared memory needs cross-origin isolation and browser support; use transferable ArrayBuffers otherwise')
|
|
47
|
+
if (!Number.isSafeInteger(bytes)||bytes<1||bytes>8388608) throw fail('Shared memory buffers must contain between 1 byte and 8 MiB')
|
|
48
|
+
return new G.SharedArrayBuffer(bytes)
|
|
49
|
+
}
|
package/brain-time.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Pure native date operations. Calendar arithmetic is UTC; timezone projection never changes the instant.
|
|
2
|
+
const MS = { ms: 1, second: 1000, minute: 60000, hour: 3600000, day: 86400000, week: 604800000 }
|
|
3
|
+
let zones
|
|
4
|
+
const monthDays = (y, m) => m === 1 ? (y % 4 === 0 && (y % 100 !== 0 || y % 400 === 0) ? 29 : 28) : [31, 0, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][m]
|
|
5
|
+
export function dateParse(value) {
|
|
6
|
+
if (value instanceof Date) value = value.getTime()
|
|
7
|
+
if (typeof value === 'number') {
|
|
8
|
+
const instant = new Date(value).getTime()
|
|
9
|
+
if (!Number.isFinite(value) || !Number.isFinite(instant)) throw RangeError('dateParse: invalid epoch milliseconds')
|
|
10
|
+
return instant
|
|
11
|
+
}
|
|
12
|
+
if (typeof value !== 'string') throw TypeError('dateParse: use epoch milliseconds, Date or an ISO date')
|
|
13
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2}))?$/.exec(value)
|
|
14
|
+
if (!match) throw RangeError('dateParse: datetime strings require an explicit timezone')
|
|
15
|
+
const y = +match[1], m = +match[2], d = +match[3]
|
|
16
|
+
if (m < 1 || m > 12 || d < 1 || d > monthDays(y, m - 1)) throw RangeError('dateParse: invalid calendar date')
|
|
17
|
+
const result = Date.parse(value)
|
|
18
|
+
if (!Number.isFinite(result)) throw RangeError('dateParse: invalid ISO date')
|
|
19
|
+
return result
|
|
20
|
+
}
|
|
21
|
+
export function dateAdd(value, amount, unit = 'day') {
|
|
22
|
+
const start = dateParse(value)
|
|
23
|
+
if (!Number.isFinite(amount)) throw RangeError('dateAdd: amount must be finite')
|
|
24
|
+
if (Object.hasOwn(MS, unit)) return dateParse(start + amount * MS[unit])
|
|
25
|
+
if (!['month', 'year'].includes(unit) || !Number.isSafeInteger(amount)) throw RangeError('dateAdd: use a supported unit and integer calendar amount')
|
|
26
|
+
const date = new Date(start), day = date.getUTCDate()
|
|
27
|
+
const months = date.getUTCFullYear() * 12 + date.getUTCMonth() + (unit === 'month' ? amount : 0)
|
|
28
|
+
const year = Math.floor(months / 12) + (unit === 'year' ? amount : 0), month = months - Math.floor(months / 12) * 12
|
|
29
|
+
// Set the complete calendar date once: day 1 or the end of a month can be outside Date's valid range.
|
|
30
|
+
date.setUTCFullYear(year, month, Math.min(day, monthDays(year, month)))
|
|
31
|
+
if (!Number.isFinite(date.getTime())) throw RangeError('dateAdd: calendar overflow')
|
|
32
|
+
return dateParse(date)
|
|
33
|
+
}
|
|
34
|
+
export function dateDiff(left, right, unit = 'day') {
|
|
35
|
+
if (!Object.hasOwn(MS, unit)) throw RangeError('dateDiff: elapsed units are ms, second, minute, hour, day or week')
|
|
36
|
+
return (dateParse(left) - dateParse(right)) / MS[unit]
|
|
37
|
+
}
|
|
38
|
+
export function dateRange(start, end, options = {}) {
|
|
39
|
+
let cursor = dateParse(start)
|
|
40
|
+
const stop = dateParse(end), step = options.step ?? 1, unit = options.unit ?? 'day', limit = options.limit ?? 1000
|
|
41
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 10000 || !Number.isFinite(step) || step === 0) throw RangeError('dateRange: nonzero step and limit 1..10000 required')
|
|
42
|
+
if (!Object.hasOwn(MS, unit) && !(['month', 'year'].includes(unit) && Number.isSafeInteger(step))) throw RangeError('dateRange: supported unit and integer calendar step required')
|
|
43
|
+
if ((stop - cursor) * step < 0) throw RangeError('dateRange: step must point toward the end')
|
|
44
|
+
const out = []
|
|
45
|
+
while (step > 0 ? cursor <= stop : cursor >= stop) {
|
|
46
|
+
if (out.length === limit) throw RangeError('dateRange: result exceeds limit')
|
|
47
|
+
out.push(cursor)
|
|
48
|
+
if (cursor === stop) break
|
|
49
|
+
const next = dateAdd(cursor, step, unit)
|
|
50
|
+
if (step > 0 ? next <= cursor : next >= cursor) throw RangeError('dateRange: step does not advance')
|
|
51
|
+
cursor = next
|
|
52
|
+
}
|
|
53
|
+
return out
|
|
54
|
+
}
|
|
55
|
+
export function dateZone(value, timeZone) {
|
|
56
|
+
const instant = dateParse(value)
|
|
57
|
+
if (typeof timeZone !== 'string' || !timeZone || timeZone.length > 128 || /^[+-]/.test(timeZone)) throw TypeError('dateZone: explicit IANA timezone required')
|
|
58
|
+
zones ||= new Map()
|
|
59
|
+
let formatter = zones.get(timeZone)
|
|
60
|
+
if (!formatter) {
|
|
61
|
+
formatter = new Intl.DateTimeFormat('en-US-u-ca-gregory-nu-latn', { timeZone, era: 'short', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' })
|
|
62
|
+
if (zones.size === 16) zones.delete(zones.keys().next().value)
|
|
63
|
+
zones.set(timeZone, formatter)
|
|
64
|
+
}
|
|
65
|
+
const parts = {}
|
|
66
|
+
for (const part of formatter.formatToParts(instant)) if (part.type !== 'literal') parts[part.type] = part.type === 'era' ? part.value : Number(part.value)
|
|
67
|
+
if (parts.era === 'BC') parts.year = 1 - parts.year
|
|
68
|
+
// Gregorian calendars repeat every 400 years. Project both calendar dates into the same safe cycle,
|
|
69
|
+
// preserving the difference even when the local wall clock lies beyond Date's global epoch bounds.
|
|
70
|
+
const reference = new Date(instant), cycle = Math.floor(reference.getUTCFullYear() / 400) * 400
|
|
71
|
+
reference.setUTCFullYear(reference.getUTCFullYear() - cycle)
|
|
72
|
+
reference.setUTCMilliseconds(0)
|
|
73
|
+
const local = new Date(0)
|
|
74
|
+
local.setUTCFullYear(parts.year - cycle, parts.month - 1, parts.day)
|
|
75
|
+
local.setUTCHours(parts.hour, parts.minute, parts.second, 0)
|
|
76
|
+
return { timeZone, instant, year: parts.year, month: parts.month, day: parts.day, hour: parts.hour, minute: parts.minute, second: parts.second, offsetMinutes: (local.getTime() - reference.getTime()) / 60000 }
|
|
77
|
+
}
|
package/brain-url.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Shareable public navigation state. Credentials, form contents and transient busy/error state do not belong in URLs.
|
|
2
|
+
import { sig, fx, _rt, untrack } from './brain.js'
|
|
3
|
+
const G = globalThis, I = _rt(), bindings = new Map(), known = new Set()
|
|
4
|
+
const privateKey = /password|secret|token|authorization|session|csrf|jwt|email|credit/i
|
|
5
|
+
let listening = false, reports = 0
|
|
6
|
+
const report = fix => { if (reports++ < 32) { const e = Object.assign(new Error(fix), { code: 'B0915', fix }); G.reportError ? G.reportError(e) : console.warn(e.message) } }
|
|
7
|
+
const scalar = v => typeof v === 'string' || typeof v === 'boolean' || (typeof v === 'number' && Number.isFinite(v))
|
|
8
|
+
const safe = v => scalar(v) && String(v).length <= 512 && !/[\u0000-\u001f\u007f]/.test(String(v)) && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(v))
|
|
9
|
+
const encode = v => Array.isArray(v) ? (v.length <= 32 && v.every(safe) ? JSON.stringify(v) : null) : safe(v) ? String(v) : null
|
|
10
|
+
function decode(raw, base) {
|
|
11
|
+
if (raw == null) return base
|
|
12
|
+
let v = raw
|
|
13
|
+
if (typeof base === 'boolean') { if (!/^(true|false)$/.test(raw)) return base; v = raw === 'true' }
|
|
14
|
+
else if (typeof base === 'number') { if (!/^-?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(raw)) return base; v = Number(raw) }
|
|
15
|
+
else if (Array.isArray(base)) { try { v = JSON.parse(raw) } catch { return base }; if (!Array.isArray(v) || (base.length && v.some(x => typeof x !== typeof base[0]))) return base }
|
|
16
|
+
return encode(v) == null ? base : v
|
|
17
|
+
}
|
|
18
|
+
function restore() {
|
|
19
|
+
if (!G.location) return
|
|
20
|
+
const url = new URL(G.location.href)
|
|
21
|
+
for (const [key, b] of bindings) if (b.path === url.pathname) {
|
|
22
|
+
const value = decode(url.searchParams.get(key), b.base)
|
|
23
|
+
b.last = encode(value)
|
|
24
|
+
b.cell.v = value
|
|
25
|
+
}
|
|
26
|
+
G.__brainRouted?.()
|
|
27
|
+
}
|
|
28
|
+
G.__brainURLOnly = (from, to) => {
|
|
29
|
+
try {
|
|
30
|
+
const a = new URL(from, G.location.href), b = new URL(to, G.location.href)
|
|
31
|
+
if (a.origin !== b.origin || a.pathname !== b.pathname || a.hash !== b.hash) return false
|
|
32
|
+
if (a.href === b.href) return true
|
|
33
|
+
for (const key of known) { a.searchParams.delete(key); b.searchParams.delete(key) }
|
|
34
|
+
return a.href === b.href
|
|
35
|
+
} catch { return false }
|
|
36
|
+
}
|
|
37
|
+
G.__brainURLRestore = restore
|
|
38
|
+
|
|
39
|
+
export function urlState(initial, options) {
|
|
40
|
+
const cell = sig(initial)
|
|
41
|
+
if (!options?.disabled) urlBind(cell, options?.key, options)
|
|
42
|
+
return cell
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// A native UI primitive stays stateless in author source; its DOM adapter owns navigation state and cleanup.
|
|
46
|
+
export function urlDialog(element, key) {
|
|
47
|
+
if (!element || !key || G.__brainSSR || G.__brainRoute || !G.location) return
|
|
48
|
+
return I.root(dispose => {
|
|
49
|
+
let alive = true
|
|
50
|
+
const opened = urlState(!!element.open, {key})
|
|
51
|
+
const read = () => { if (alive) opened.v = !!element.open }
|
|
52
|
+
const apply = value => {
|
|
53
|
+
if (!alive || !element.isConnected) return
|
|
54
|
+
if (value && !element.open) element.showModal()
|
|
55
|
+
else if (!value && element.open) element.close()
|
|
56
|
+
}
|
|
57
|
+
fx(() => {
|
|
58
|
+
const value = opened.v
|
|
59
|
+
if (element.isConnected) apply(value)
|
|
60
|
+
else queueMicrotask(() => apply(value))
|
|
61
|
+
})
|
|
62
|
+
const observer = G.MutationObserver ? new G.MutationObserver(read) : null
|
|
63
|
+
observer?.observe(element, {attributes:true,attributeFilter:['open']})
|
|
64
|
+
element.addEventListener('close',read)
|
|
65
|
+
element.addEventListener('toggle',read)
|
|
66
|
+
fx(() => () => {
|
|
67
|
+
alive = false
|
|
68
|
+
observer?.disconnect()
|
|
69
|
+
element.removeEventListener('close',read)
|
|
70
|
+
element.removeEventListener('toggle',read)
|
|
71
|
+
})
|
|
72
|
+
return dispose
|
|
73
|
+
}, I.owner())
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Internal compiler binding for public select/tab/modal signals, once during component setup.
|
|
77
|
+
export function urlBind(cell, key, options = {}) {
|
|
78
|
+
if (G.__brainRoute || G.__brainSSR) {
|
|
79
|
+
if (typeof key === 'string' && key.length <= 200 && !privateKey.test(key) && encode(untrack(() => cell.v)) != null) {
|
|
80
|
+
const raw = G.__brainRoute?.query?.[key]
|
|
81
|
+
cell.v = decode(typeof raw === 'string' ? raw : null, untrack(() => cell.v))
|
|
82
|
+
}
|
|
83
|
+
return cell
|
|
84
|
+
}
|
|
85
|
+
if (!G.location || !G.history) return cell
|
|
86
|
+
if (typeof key !== 'string' || !key.length || key.length > 200 || privateKey.test(key) || encode(untrack(() => cell.v)) == null) {
|
|
87
|
+
report('URL state needs a stable public key and bounded scalar value; keep credentials and form contents in private state')
|
|
88
|
+
return cell
|
|
89
|
+
}
|
|
90
|
+
const owner = I.owner()
|
|
91
|
+
if (!owner) { report('URL state must belong to a component so its listeners can be disposed'); return cell }
|
|
92
|
+
const previous = bindings.get(key)
|
|
93
|
+
if (previous) {
|
|
94
|
+
if (previous.cell !== cell) report('Repeated URL state key; give repeated components distinct stable urlState keys')
|
|
95
|
+
return cell
|
|
96
|
+
}
|
|
97
|
+
if (bindings.size >= 128 || known.size >= 512) { report('URL state key capacity exceeded; reduce public state or reuse stable keys'); return cell }
|
|
98
|
+
const base = untrack(() => cell.v), url = new URL(G.location.href)
|
|
99
|
+
if (url.search.length > 8192) { report('URL state exceeds 8 KiB; shorten public selections'); return cell }
|
|
100
|
+
cell.v = decode(url.searchParams.get(key), base)
|
|
101
|
+
const b = { cell, base, path: url.pathname, last: encode(cell.v) }
|
|
102
|
+
bindings.set(key, b); known.add(key)
|
|
103
|
+
if (!listening) { G.addEventListener('popstate', restore); listening = true }
|
|
104
|
+
fx(() => {
|
|
105
|
+
const value = encode(cell.v)
|
|
106
|
+
if (value === b.last) return
|
|
107
|
+
b.last = value
|
|
108
|
+
if (value == null) { report('URL state value is private, unsupported or too large; keep it out of navigation state'); return }
|
|
109
|
+
const next = new URL(G.location.href)
|
|
110
|
+
if (next.pathname !== b.path) return
|
|
111
|
+
if (value === encode(base)) next.searchParams.delete(key)
|
|
112
|
+
else next.searchParams.set(key, value)
|
|
113
|
+
if (next.search.length > 8192) { report('URL state exceeds 8 KiB; shorten public selections'); return }
|
|
114
|
+
if (next.href === G.location.href) return
|
|
115
|
+
try {
|
|
116
|
+
G.history[options.history === 'replace' ? 'replaceState' : 'pushState'](G.history.state, '', next.href)
|
|
117
|
+
G.__brainRouted?.()
|
|
118
|
+
} catch { report('URL state history write failed; reduce the frequency of changes or use history: replace') }
|
|
119
|
+
})
|
|
120
|
+
fx(() => () => {
|
|
121
|
+
if (bindings.get(key) === b) bindings.delete(key)
|
|
122
|
+
if (!bindings.size && listening) { G.removeEventListener('popstate', restore); listening = false }
|
|
123
|
+
})
|
|
124
|
+
return cell
|
|
125
|
+
}
|