@christianirack/brainllm-runtime 0.1.0 → 0.3.1
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/README.md +1 -1
- package/brain-ai.js +129 -0
- package/brain-data.js +2 -0
- package/brain-form.js +21 -6
- package/brain-mcp.js +32 -17
- package/brain-router.js +156 -6
- package/brain-rpc.js +3 -0
- package/brain-schema.js +40 -16
- package/brain.js +1 -1
- package/package.json +2 -2
- package/server/server.mjs +178 -14
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
# @christianirack/brainllm-runtime
|
|
2
2
|
|
|
3
|
-
The BRAINLLM
|
|
3
|
+
The runtime of [BRAINLLM](https://chrisifm.github.io/BRAINLLM/), the web compiler for LLMs built in Rust: dependency-free ES modules (signals, DOM, router, data, forms, channels, service worker, server).
|
|
4
4
|
The `brain` binary embeds this same version and serves it at `/@brain/*`; install this package only for tooling that
|
|
5
5
|
needs the modules on disk.
|
package/brain-ai.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Built-in AI (Chrome's on-device Gemini Nano, INTERNALS §19, docs checked 2026-10-29): `ai` is a primitive, so a view
|
|
2
|
+
// uses it without an import. Every call feature-detects the browser API; on the server or in a browser without it,
|
|
3
|
+
// `ai.ready()` answers 'unavailable' and the other calls throw an Error with `code: 'unavailable'`, so the app decides
|
|
4
|
+
// what to show instead. What the model does is up to the app: the runtime only opens the door.
|
|
5
|
+
// await ai.ready() 'available' | 'downloadable' | 'downloading' | 'unavailable' (Prompt API)
|
|
6
|
+
// await ai.ready('summarizer') same for summarizer, detector, writer, rewriter, proofreader
|
|
7
|
+
// await ai.ready('translator', { from: 'en', to: 'es' }) the translator needs its two languages
|
|
8
|
+
// await ai.prompt('Name three colors', { system, schema, signal, onProgress }) → string (stateless call)
|
|
9
|
+
// for await (const part of ai.stream('Tell a story')) … → text chunks as they arrive
|
|
10
|
+
// const chat = await ai.session({ system }) native session: chat.prompt(), append(), clone(), destroy()
|
|
11
|
+
// await ai.summarize(text, { type, format, length, context }) → string
|
|
12
|
+
// await ai.translate(text, { from: 'en', to: 'es' }) → string
|
|
13
|
+
// await ai.detect(text) → [{ detectedLanguage, confidence }]
|
|
14
|
+
// await ai.write(task) · ai.rewrite(text, { tone }) · ai.proofread(text)
|
|
15
|
+
// Status (Chrome 138+ stable): Summarizer, Translator and LanguageDetector on the web; the Prompt API is an origin
|
|
16
|
+
// trial on the web (Chrome 148) and stable in extensions; Writer, Rewriter and Proofreader are trials. The Prompt API
|
|
17
|
+
// runs in the top window and same-origin iframes (`allow="language-model"` for others), not in Web Workers. A model
|
|
18
|
+
// that is 'downloadable' starts downloading on the first create, which Chrome only allows after a user gesture: call
|
|
19
|
+
// these from an event handler the first time; `onProgress(0..1)` follows the download.
|
|
20
|
+
const G = globalThis
|
|
21
|
+
const API = {
|
|
22
|
+
prompt: 'LanguageModel',
|
|
23
|
+
summarizer: 'Summarizer',
|
|
24
|
+
translator: 'Translator',
|
|
25
|
+
detector: 'LanguageDetector',
|
|
26
|
+
writer: 'Writer',
|
|
27
|
+
rewriter: 'Rewriter',
|
|
28
|
+
proofreader: 'Proofreader',
|
|
29
|
+
}
|
|
30
|
+
const native = kind => {
|
|
31
|
+
const name = API[kind]
|
|
32
|
+
if (!name) throw Error(`ai: unknown model \`${kind}\`; use one of ${Object.keys(API).join(', ')}`)
|
|
33
|
+
return G[name] || null
|
|
34
|
+
}
|
|
35
|
+
const missing = kind => Object.assign(Error(`Built-in AI is not available in this browser (${API[kind]})`), { code: 'unavailable' })
|
|
36
|
+
|
|
37
|
+
// `{ from, to }` are the translator's languages, as in ai.translate (Translator.availability needs them)
|
|
38
|
+
const langs = ({ from, to, ...o } = {}) => ({ ...o, ...(from ? { sourceLanguage: from } : {}), ...(to ? { targetLanguage: to } : {}) })
|
|
39
|
+
async function ready(kind = 'prompt', options) {
|
|
40
|
+
const N = native(kind)
|
|
41
|
+
if (!N || !N.availability) return 'unavailable'
|
|
42
|
+
try {
|
|
43
|
+
return await N.availability(langs(options))
|
|
44
|
+
} catch {
|
|
45
|
+
return 'unavailable'
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// create(options) with the download progress wired to onProgress (e.loaded is 0..1)
|
|
50
|
+
async function create(kind, o = {}) {
|
|
51
|
+
const N = native(kind)
|
|
52
|
+
if (!N || !N.create) throw missing(kind)
|
|
53
|
+
const { onProgress, ...rest } = o
|
|
54
|
+
if (onProgress) rest.monitor = m => m.addEventListener('downloadprogress', e => onProgress(e.loaded))
|
|
55
|
+
return N.create(rest)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// one base session per system prompt; each stateless call works on a clone, so calls never share history
|
|
59
|
+
const bases = new Map()
|
|
60
|
+
async function base(o) {
|
|
61
|
+
const k = o.system || ''
|
|
62
|
+
if (!bases.has(k)) {
|
|
63
|
+
const p = create('prompt', { ...(o.system ? { initialPrompts: [{ role: 'system', content: o.system }] } : {}), ...(o.onProgress ? { onProgress: o.onProgress } : {}) })
|
|
64
|
+
p.catch(() => bases.delete(k))
|
|
65
|
+
bases.set(k, p)
|
|
66
|
+
}
|
|
67
|
+
return (await bases.get(k)).clone()
|
|
68
|
+
}
|
|
69
|
+
const opts = o => ({ ...(o.signal ? { signal: o.signal } : {}), ...(o.schema ? { responseConstraint: o.schema } : {}) })
|
|
70
|
+
|
|
71
|
+
async function prompt(text, o = {}) {
|
|
72
|
+
const s = await base(o)
|
|
73
|
+
try {
|
|
74
|
+
const out = await s.prompt(text, opts(o))
|
|
75
|
+
return o.schema && typeof out === 'string' ? JSON.parse(out) : out
|
|
76
|
+
} finally {
|
|
77
|
+
s.destroy && s.destroy()
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function* stream(text, o = {}) {
|
|
82
|
+
const s = await base(o)
|
|
83
|
+
try {
|
|
84
|
+
for await (const part of s.promptStreaming(text, opts(o))) yield part
|
|
85
|
+
} finally {
|
|
86
|
+
s.destroy && s.destroy()
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// a native session that keeps history (a chat); `system` becomes its first prompt
|
|
91
|
+
const session = (o = {}) => {
|
|
92
|
+
const { system, ...rest } = o
|
|
93
|
+
return create('prompt', { ...(system ? { initialPrompts: [{ role: 'system', content: system }, ...(rest.initialPrompts || [])] } : {}), ...rest })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// one model per kind and options, created on first use and reused
|
|
97
|
+
const tools = new Map()
|
|
98
|
+
const tool = (kind, o) => {
|
|
99
|
+
const { onProgress, signal, ...rest } = o || {}
|
|
100
|
+
const k = kind + JSON.stringify(rest)
|
|
101
|
+
if (!tools.has(k)) {
|
|
102
|
+
const p = create(kind, { ...rest, ...(onProgress ? { onProgress } : {}) })
|
|
103
|
+
p.catch(() => tools.delete(k))
|
|
104
|
+
tools.set(k, p)
|
|
105
|
+
}
|
|
106
|
+
return tools.get(k)
|
|
107
|
+
}
|
|
108
|
+
const call = (kind, method, keys) => async (text, o = {}) => {
|
|
109
|
+
const make = {}, run = {}
|
|
110
|
+
for (const [k, v] of Object.entries(o)) (keys.includes(k) ? run : make)[k] = v
|
|
111
|
+
if (o.signal) run.signal = o.signal
|
|
112
|
+
return (await tool(kind, make))[method](text, run)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const ai = {
|
|
116
|
+
ready,
|
|
117
|
+
prompt,
|
|
118
|
+
stream,
|
|
119
|
+
session,
|
|
120
|
+
summarize: call('summarizer', 'summarize', ['context']),
|
|
121
|
+
translate: (text, o = {}) => {
|
|
122
|
+
const { from, to, ...rest } = o
|
|
123
|
+
return call('translator', 'translate', ['signal'])(text, { sourceLanguage: from, targetLanguage: to, ...rest })
|
|
124
|
+
},
|
|
125
|
+
detect: call('detector', 'detect', []),
|
|
126
|
+
write: call('writer', 'write', ['context']),
|
|
127
|
+
rewrite: call('rewriter', 'rewrite', ['context']),
|
|
128
|
+
proofread: call('proofreader', 'proofread', []),
|
|
129
|
+
}
|
package/brain-data.js
CHANGED
|
@@ -355,6 +355,8 @@ async function once(u, M, body, sg, pri, up) {
|
|
|
355
355
|
} finally {
|
|
356
356
|
release()
|
|
357
357
|
if (low) LOW.delete(low)
|
|
358
|
+
// a write tells the router's prefetch that pages it fetched may be stale (§19)
|
|
359
|
+
if (M !== 'GET' && G.dispatchEvent && G.Event) G.dispatchEvent(new Event('brain:write'))
|
|
358
360
|
if (G.__brainDev && G.dispatchEvent && G.CustomEvent) G.dispatchEvent(new CustomEvent('brain:net', { detail: { method: M, url, status: st, ms: Date.now() - t0, size: text.length } }))
|
|
359
361
|
}
|
|
360
362
|
let j
|
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 } from './brain-schema.js'
|
|
18
|
+
import { rule, check, msg } from './brain-schema.js'
|
|
19
19
|
|
|
20
20
|
export { rule, check } // server.mjs validates posted forms with the same rules (§38)
|
|
21
21
|
|
|
@@ -23,7 +23,7 @@ const I = _rt()
|
|
|
23
23
|
|
|
24
24
|
const D = globalThis.document
|
|
25
25
|
let seq = 0
|
|
26
|
-
const GENERIC = 'Something went wrong. Try again.' // the text callForm (server.mjs) shows without JS
|
|
26
|
+
const GENERIC = 'Something went wrong. Try again.' // the text callForm (server.mjs) shows without JS (key form.error)
|
|
27
27
|
// navigate through the client router when it is loaded (no reload, base path applied), else a normal load
|
|
28
28
|
const go = u => {
|
|
29
29
|
const G = globalThis
|
|
@@ -124,7 +124,7 @@ export function form(schema, o = {}) {
|
|
|
124
124
|
checking.v++
|
|
125
125
|
return Promise.resolve()
|
|
126
126
|
.then(() => c(v))
|
|
127
|
-
.then(m => m || '', e => (e && e.kind === 'aborted' ? '' : (e && e.message) || 'Could not check this value.'))
|
|
127
|
+
.then(m => m || '', e => (e && e.kind === 'aborted' ? '' : (e && e.message) || msg('form.check_failed', 'Could not check this value.')))
|
|
128
128
|
.then(m => {
|
|
129
129
|
if (untrack(() => x.s.v) === v) x.e.v = m // a stale answer (the value changed meanwhile) is ignored
|
|
130
130
|
return untrack(() => x.e.v)
|
|
@@ -165,9 +165,24 @@ export function form(schema, o = {}) {
|
|
|
165
165
|
})
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
// `f.v` and `f.err` are the form's live fields: replacing the object (`f.v = { … }`) would leave the inputs bound to
|
|
169
|
+
// signals the form no longer reads (submit then sees every field empty), so it fails loudly instead (§14, B0133)
|
|
170
|
+
const fixed = k => {
|
|
171
|
+
throw Error(`form: \`${k}\` cannot be replaced; start from values with form(schema, { init }) or load them later with f.reset(values)`)
|
|
172
|
+
}
|
|
168
173
|
const f = {
|
|
169
|
-
v
|
|
170
|
-
|
|
174
|
+
get v() {
|
|
175
|
+
return v
|
|
176
|
+
},
|
|
177
|
+
set v(_) {
|
|
178
|
+
fixed('f.v')
|
|
179
|
+
},
|
|
180
|
+
get err() {
|
|
181
|
+
return err
|
|
182
|
+
},
|
|
183
|
+
set err(_) {
|
|
184
|
+
fixed('f.err')
|
|
185
|
+
},
|
|
171
186
|
get busy() {
|
|
172
187
|
return busy.v
|
|
173
188
|
},
|
|
@@ -247,7 +262,7 @@ export function form(schema, o = {}) {
|
|
|
247
262
|
if (x && x.errors) mark(x.errors)
|
|
248
263
|
// a server failure reads the same as without JS: 4xx and the author's own errors keep their message; 5xx and a
|
|
249
264
|
// TypeError (the network dropped, or a bug) get the generic one
|
|
250
|
-
else if (!(x && x.kind === 'aborted')) error.v = (x && x.message && !(x.status >= 500) && x.name !== 'TypeError' && x.message) || GENERIC
|
|
265
|
+
else if (!(x && x.kind === 'aborted')) error.v = (x && x.message && !(x.status >= 500) && x.name !== 'TypeError' && x.message) || msg('form.error', GENERIC)
|
|
251
266
|
} finally {
|
|
252
267
|
busy.v = false
|
|
253
268
|
if (fm) fm.removeAttribute('aria-busy')
|
package/brain-mcp.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// parameters use form()'s mini-language and become a JSON Schema; arguments are validated before `run` is called and
|
|
6
6
|
// a bad call answers with the field's message instead of running. Tools act with the page's permissions only:
|
|
7
7
|
// anything destructive should still ask for confirmation in the UI.
|
|
8
|
+
// Optional 5th argument (WebMCP draft 2026-10-27): { title, readOnly, consequential, untrusted } → `title` and
|
|
9
|
+
// `annotations: { readOnlyHint, consequentialHint, untrustedContentHint }` on the registration; only the keys given.
|
|
8
10
|
import { fx } from './brain.js'
|
|
9
11
|
import { rule, check } from './brain-schema.js'
|
|
10
12
|
|
|
@@ -32,27 +34,40 @@ export function schema(params = {}) {
|
|
|
32
34
|
|
|
33
35
|
const text = v => ({ content: [{ type: 'text', text: typeof v === 'string' ? v : JSON.stringify(v ?? null) }] })
|
|
34
36
|
|
|
35
|
-
|
|
37
|
+
const HINTS = [['readOnly', 'readOnlyHint'], ['consequential', 'consequentialHint'], ['untrusted', 'untrustedContentHint']]
|
|
38
|
+
|
|
39
|
+
// The registration dictionary: name, description, inputSchema, then title/annotations only when given.
|
|
40
|
+
export function definition(name, description, params, opts) {
|
|
41
|
+
const def = { name, description, inputSchema: schema(params) }
|
|
42
|
+
if (!opts) return def
|
|
43
|
+
if (typeof opts.title === 'string' && opts.title) def.title = opts.title
|
|
44
|
+
let annotations = null
|
|
45
|
+
for (let i = 0; i < HINTS.length; i++) {
|
|
46
|
+
const v = opts[HINTS[i][0]]
|
|
47
|
+
if (v != null) (annotations || (annotations = {}))[HINTS[i][1]] = !!v
|
|
48
|
+
}
|
|
49
|
+
if (annotations) def.annotations = annotations
|
|
50
|
+
return def
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function tool(name, description, params, run, opts) {
|
|
36
54
|
const mc = context()
|
|
37
55
|
if (!mc || !mc.registerTool || G.__brainSSR || G.__brainRoute) return
|
|
38
56
|
const rules = Object.fromEntries(Object.entries(params || {}).map(([k, s]) => [k, rule(s)]))
|
|
39
57
|
fx(() => {
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
},
|
|
55
|
-
})
|
|
58
|
+
const def = definition(name, description, params, opts)
|
|
59
|
+
def.execute = async (args = {}) => {
|
|
60
|
+
for (const k in rules) {
|
|
61
|
+
const m = check(rules[k], args[k])
|
|
62
|
+
if (m) return text(`${k}: ${m}`)
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
return text(await run(args))
|
|
66
|
+
} catch (e) {
|
|
67
|
+
return text(`Error: ${(e && e.message) || e}`)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const reg = mc.registerTool(def)
|
|
56
71
|
return () => (reg && reg.unregister ? reg.unregister() : mc.unregisterTool && mc.unregisterTool(name))
|
|
57
72
|
})
|
|
58
73
|
}
|
package/brain-router.js
CHANGED
|
@@ -90,6 +90,7 @@ export function router(o = {}) {
|
|
|
90
90
|
const region = o.region || 'main'
|
|
91
91
|
const parse = o.parse || (h => new DOMParser().parseFromString(h, 'text/html'))
|
|
92
92
|
let seq = 0, through = null, stale = false
|
|
93
|
+
const ahead = predict(o, u => same(u))
|
|
93
94
|
// Build signature (§35.7): the page's <meta name="brain-build">. A navigation response from another build, or the
|
|
94
95
|
// service worker announcing one, turns client navigation into full loads so old and new code never mix.
|
|
95
96
|
const build = () => {
|
|
@@ -110,14 +111,20 @@ export function router(o = {}) {
|
|
|
110
111
|
const url = new URL(u, location.href), path = strip(url.pathname) // guards see app paths (no base)
|
|
111
112
|
for (const [pre, g] of gs) {
|
|
112
113
|
if (!(path === pre || path.startsWith(pre.endsWith('/') ? pre : pre + '/'))) continue
|
|
113
|
-
|
|
114
|
+
// The browser has no session (the cookie is HttpOnly): a guard that reads ctx.user cannot decide here. If it would
|
|
115
|
+
// stop the navigation, the server decides on a full load instead (F12b: `ctx.user ? true : redirect('/login')`
|
|
116
|
+
// used to send a signed-in user to /login on every client navigation).
|
|
117
|
+
let v, asked = false
|
|
118
|
+
const ctx = { url, path, query: Object.fromEntries(url.searchParams) }
|
|
119
|
+
Object.defineProperty(ctx, 'user', { enumerable: true, get: () => ((asked = true), undefined) })
|
|
114
120
|
try {
|
|
115
|
-
v = await g(
|
|
121
|
+
v = await g(ctx)
|
|
116
122
|
} catch (e) {
|
|
123
|
+
if (asked) return null
|
|
117
124
|
return e && typeof e === 'object' && (R in e || 'redirect' in e) ? outcome(e) : null
|
|
118
125
|
}
|
|
119
126
|
v = outcome(v)
|
|
120
|
-
if (v !== true) return v
|
|
127
|
+
if (v !== true) return asked ? null : v
|
|
121
128
|
}
|
|
122
129
|
return true
|
|
123
130
|
}
|
|
@@ -154,7 +161,7 @@ export function router(o = {}) {
|
|
|
154
161
|
G.dispatchEvent && G.dispatchEvent(new Event('brain:navigate'))
|
|
155
162
|
} catch {}
|
|
156
163
|
try {
|
|
157
|
-
r = await fetch(url, { headers: { 'x-brain-nav': '1' } })
|
|
164
|
+
r = (await ahead.take(url)) || (await fetch(url, { headers: { 'x-brain-nav': '1' } }))
|
|
158
165
|
const b = r.headers && r.headers.get && r.headers.get('x-brain-build')
|
|
159
166
|
if (b && build() && b !== build()) return void (my === seq && hard(url)) // a new deploy: load it whole
|
|
160
167
|
const ct = (r.headers && r.headers.get && r.headers.get('content-type')) || 'text/html'
|
|
@@ -216,6 +223,7 @@ export function router(o = {}) {
|
|
|
216
223
|
if (pb && document.head) document.head.appendChild(document.adoptNode ? document.adoptNode(pb) : pb)
|
|
217
224
|
routed()
|
|
218
225
|
current()
|
|
226
|
+
ahead.scan()
|
|
219
227
|
}
|
|
220
228
|
if (document.startViewTransition) {
|
|
221
229
|
const vt = document.startViewTransition(swap)
|
|
@@ -261,10 +269,11 @@ export function router(o = {}) {
|
|
|
261
269
|
})
|
|
262
270
|
}
|
|
263
271
|
nav.addEventListener('navigate', h)
|
|
272
|
+
ahead.scan()
|
|
264
273
|
return (active = {
|
|
265
274
|
go: (u, how) => nav.navigate(app(u), how === 'replace' ? { history: 'replace' } : undefined),
|
|
266
275
|
check,
|
|
267
|
-
stop: () => ((active = null), nav.removeEventListener('navigate', h), sw && sw.removeEventListener && sw.removeEventListener('message', onMsg)),
|
|
276
|
+
stop: () => ((active = null), ahead.stop(), nav.removeEventListener('navigate', h), sw && sw.removeEventListener && sw.removeEventListener('message', onMsg)),
|
|
268
277
|
})
|
|
269
278
|
}
|
|
270
279
|
|
|
@@ -305,13 +314,154 @@ export function router(o = {}) {
|
|
|
305
314
|
}
|
|
306
315
|
document.addEventListener('click', click)
|
|
307
316
|
G.addEventListener && G.addEventListener('popstate', pop)
|
|
317
|
+
ahead.scan()
|
|
308
318
|
return (active = {
|
|
309
319
|
go: (u, push = true) => go(app(u), push),
|
|
310
320
|
check,
|
|
311
|
-
stop: () => ((active = null), document.removeEventListener('click', click), G.removeEventListener && G.removeEventListener('popstate', pop)),
|
|
321
|
+
stop: () => ((active = null), ahead.stop(), document.removeEventListener('click', click), G.removeEventListener && G.removeEventListener('popstate', pop)),
|
|
312
322
|
})
|
|
313
323
|
}
|
|
314
324
|
|
|
325
|
+
// Predictive prefetch (§19, 2026-10-29): no dead time during a visit. Every internal link that scrolls into view is
|
|
326
|
+
// fetched in the background while the browser is idle (at most 2 at a time, 12 per page), and hovering, focusing or
|
|
327
|
+
// touching a link moves it to the front of the queue. The fetched page is kept in memory and the next navigation to it
|
|
328
|
+
// swaps without waiting for the network; its module chunks get `<link rel=modulepreload>` so the view mounts at once.
|
|
329
|
+
// The current view never waits: requests run at low priority and only when idle. Skipped: Save-Data and 2G (only
|
|
330
|
+
// intent prefetches there, and never with Save-Data), `data-prefetch="false"` on the link or an ancestor, download or
|
|
331
|
+
// target links, files (`.pdf`, `.zip`…), `/api/` and `/auth/`, action-style paths (log out, unsubscribe, confirm,
|
|
332
|
+
// verify), URLs carrying a token in the query, and everything with `router({ prefetch: false })` or
|
|
333
|
+
// `<meta name="brain-prefetch" content="off">`. A page served `private`/`no-store` is reused for 30 s, others for 5 min,
|
|
334
|
+
// and any write empties the memory first: a form submit, a `brain:write` (brain-data writes, every server function
|
|
335
|
+
// call, log-out included), so the next view never shows data from before the write or another session. Each fetch says
|
|
336
|
+
// `x-brain-prefetch: 1` (`ctx.prefetch` in the server's `load`), so a server can tell it apart from a click.
|
|
337
|
+
const FRESH = 300000, FRESH_PRIVATE = 30000, MAX_PAGE = 12, MAX_KEPT = 24, PARALLEL = 2
|
|
338
|
+
const NOPE = /(^|\/)(log-?out|sign-?out|logoff|unsubscribe|confirm|verify|api|auth)(\.html?)?(\/|$)/i
|
|
339
|
+
const FILE = /\.(?!html?$)[a-z0-9]{2,5}$/i
|
|
340
|
+
const SECRET = /(^|[?&])(token|code|key|sig|signature|auth|otp|state)=/i
|
|
341
|
+
export function predict(o = {}, same = () => false) {
|
|
342
|
+
const off = { scan() {}, stop() {}, take: async () => null }
|
|
343
|
+
const meta = G.document && document.querySelector && document.querySelector('meta[name=brain-prefetch]')
|
|
344
|
+
if (o.prefetch === false || (meta && /^(off|false|0)$/i.test(meta.getAttribute('content') || '')) || !G.document || !G.fetch) return off
|
|
345
|
+
const net = G.navigator && navigator.connection
|
|
346
|
+
const thin = () => !!net && (net.saveData || /(^|-)2g$/.test(net.effectiveType || ''))
|
|
347
|
+
const cache = new Map(), queue = [], seen = new WeakSet(), pre = new Set()
|
|
348
|
+
let busy = 0, count = 0, io = null, idle = 0
|
|
349
|
+
const key = h => {
|
|
350
|
+
try {
|
|
351
|
+
const u = new URL(h, location.href)
|
|
352
|
+
u.hash = ''
|
|
353
|
+
return u.href
|
|
354
|
+
} catch {
|
|
355
|
+
return null
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const ok = a => {
|
|
359
|
+
if (!a || !a.getAttribute || !a.getAttribute('href') || a.getAttribute('target') || a.hasAttribute('download')) return null
|
|
360
|
+
for (let n = a; n && n.getAttribute; n = n.parentNode) if (n.getAttribute('data-prefetch') === 'false') return null
|
|
361
|
+
const k = key(a.href || a.getAttribute('href'))
|
|
362
|
+
if (!k || !same(k) || k === key(location.href)) return null
|
|
363
|
+
const u = new URL(k)
|
|
364
|
+
if (NOPE.test(u.pathname) || FILE.test(u.pathname) || SECRET.test(u.search)) return null
|
|
365
|
+
return k
|
|
366
|
+
}
|
|
367
|
+
// the next page's chunks: module scripts and static imports in its HTML
|
|
368
|
+
const chunks = html => {
|
|
369
|
+
if (!document.head) return
|
|
370
|
+
const re = /<script[^>]+type="module"[^>]+src="([^"]+)"|\bfrom\s*"([^"]+\.js)"|\bimport\(\s*"([^"]+\.js)"\s*\)/g
|
|
371
|
+
let m, n = 0
|
|
372
|
+
while ((m = re.exec(html)) && n < 8) {
|
|
373
|
+
const u = key(m[1] || m[2] || m[3])
|
|
374
|
+
if (!u || pre.has(u) || !same(u)) continue
|
|
375
|
+
pre.add(u), n++
|
|
376
|
+
const l = document.createElement('link')
|
|
377
|
+
l.setAttribute('rel', 'modulepreload')
|
|
378
|
+
l.setAttribute('href', u)
|
|
379
|
+
document.head.appendChild(l)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
// drop what expired, then the oldest past MAX_KEPT (a Map keeps insertion order)
|
|
383
|
+
const prune = () => {
|
|
384
|
+
const now = Date.now()
|
|
385
|
+
for (const [k, c] of cache) if (now - c.at > FRESH) cache.delete(k)
|
|
386
|
+
for (const k of cache.keys()) if (cache.size >= MAX_KEPT) cache.delete(k)
|
|
387
|
+
}
|
|
388
|
+
const get = k => {
|
|
389
|
+
prune()
|
|
390
|
+
const at = Date.now()
|
|
391
|
+
const p = fetch(k, { headers: { 'x-brain-nav': '1', 'x-brain-prefetch': '1' }, priority: 'low', credentials: 'same-origin' })
|
|
392
|
+
.then(async r => {
|
|
393
|
+
const ct = (r.headers && r.headers.get && r.headers.get('content-type')) || 'text/html'
|
|
394
|
+
if (!r.ok || !/html/.test(ct)) return null
|
|
395
|
+
const text = await r.text()
|
|
396
|
+
chunks(text)
|
|
397
|
+
const cc = (r.headers.get && r.headers.get('cache-control')) || ''
|
|
398
|
+
// a stand-in for the Response that load() reads: headers, status, redirect and the body once per use
|
|
399
|
+
return { ok: r.ok, status: r.status, redirected: r.redirected, url: r.url, headers: r.headers, text: async () => text, ttl: /private|no-store/i.test(cc) ? FRESH_PRIVATE : FRESH }
|
|
400
|
+
})
|
|
401
|
+
.catch(() => null)
|
|
402
|
+
cache.set(k, { at, p })
|
|
403
|
+
return p
|
|
404
|
+
}
|
|
405
|
+
const pump = () => {
|
|
406
|
+
while (busy < PARALLEL && queue.length) {
|
|
407
|
+
const k = queue.shift()
|
|
408
|
+
if (cache.has(k)) continue
|
|
409
|
+
busy++
|
|
410
|
+
get(k).finally(() => (busy--, pump()))
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
const later = f => (G.requestIdleCallback ? G.requestIdleCallback(f, { timeout: 2000 }) : setTimeout(f, 200))
|
|
414
|
+
const want = (k, now) => {
|
|
415
|
+
if (!k || cache.has(k)) return
|
|
416
|
+
const i = queue.indexOf(k)
|
|
417
|
+
if (i >= 0) queue.splice(i, 1)
|
|
418
|
+
if (now) queue.unshift(k), pump()
|
|
419
|
+
else if (count < MAX_PAGE) count++, queue.push(k), idle || (idle = 1) && later(() => ((idle = 0), pump()))
|
|
420
|
+
}
|
|
421
|
+
const intent = e => {
|
|
422
|
+
if (net && net.saveData) return
|
|
423
|
+
let a = e.target
|
|
424
|
+
while (a && a.tagName !== 'A') a = a.parentNode
|
|
425
|
+
want(ok(a), true)
|
|
426
|
+
}
|
|
427
|
+
const evs = ['pointerover', 'focusin', 'touchstart']
|
|
428
|
+
for (const t of evs) document.addEventListener(t, intent, { passive: true, capture: true })
|
|
429
|
+
// a write makes every fetched page possibly stale (or another session's after log-out): forget them all
|
|
430
|
+
const forget = () => ((queue.length = 0), cache.clear())
|
|
431
|
+
document.addEventListener('submit', forget, { capture: true })
|
|
432
|
+
if (G.addEventListener) G.addEventListener('brain:write', forget)
|
|
433
|
+
if (G.IntersectionObserver)
|
|
434
|
+
io = new IntersectionObserver(
|
|
435
|
+
es => {
|
|
436
|
+
for (const e of es) if (e.isIntersecting) io.unobserve(e.target), thin() || want(ok(e.target), false)
|
|
437
|
+
},
|
|
438
|
+
{ rootMargin: '200px' }
|
|
439
|
+
)
|
|
440
|
+
return {
|
|
441
|
+
scan() {
|
|
442
|
+
count = 0
|
|
443
|
+
queue.length = 0 // the previous view's links are no longer the likely next step
|
|
444
|
+
if (!io) return
|
|
445
|
+
for (const a of document.querySelectorAll('a[href]')) if (!seen.has(a)) seen.add(a), io.observe(a)
|
|
446
|
+
},
|
|
447
|
+
stop() {
|
|
448
|
+
io && io.disconnect()
|
|
449
|
+
for (const t of evs) document.removeEventListener(t, intent, { capture: true })
|
|
450
|
+
document.removeEventListener('submit', forget, { capture: true })
|
|
451
|
+
G.removeEventListener && G.removeEventListener('brain:write', forget)
|
|
452
|
+
cache.clear()
|
|
453
|
+
},
|
|
454
|
+
// the prefetched page for this URL when it is still fresh (used once: a later visit fetches again)
|
|
455
|
+
async take(u) {
|
|
456
|
+
const k = key(u), c = k && cache.get(k)
|
|
457
|
+
if (!c) return null
|
|
458
|
+
cache.delete(k)
|
|
459
|
+
const r = await c.p
|
|
460
|
+
return r && Date.now() - c.at < r.ttl ? r : null
|
|
461
|
+
},
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
315
465
|
const HOLD = ['click', 'submit']
|
|
316
466
|
function hold(el) {
|
|
317
467
|
if (el.$hold) el.$hold() // a newer navigation drops what the previous page held
|
package/brain-rpc.js
CHANGED
|
@@ -10,6 +10,9 @@ export async function rpc(id, a, oops, base) {
|
|
|
10
10
|
j = t ? JSON.parse(t) : undefined
|
|
11
11
|
} catch (e) {
|
|
12
12
|
throw (oops(e, null, 'rpc'), e)
|
|
13
|
+
} finally {
|
|
14
|
+
// a server function may write or change the session: pages the router prefetched may be stale (§19)
|
|
15
|
+
if (globalThis.dispatchEvent && globalThis.Event) globalThis.dispatchEvent(new Event('brain:write'))
|
|
13
16
|
}
|
|
14
17
|
if (r.ok) return j
|
|
15
18
|
const e = Object.assign(Error((j && j.error) || 'rpc ' + id + ': ' + r.status), { code: 'rpc', status: r.status, id, errors: j && j.errors })
|
package/brain-schema.js
CHANGED
|
@@ -3,6 +3,30 @@
|
|
|
3
3
|
// Per field: `str num bool email url date` + `!` required + `<=N >=N` (length for str and lists, value for num) + `[T]`
|
|
4
4
|
// list of T, or a nested schema object.
|
|
5
5
|
const RX_EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
6
|
+
const G = globalThis
|
|
7
|
+
|
|
8
|
+
// Validation messages (§14, §37): the app's dictionary wins when it has the key (`"form.required": "Obligatorio"` in
|
|
9
|
+
// i18n/<locale>.json; plural objects allowed for keys with `{n}`), else the English text. The table is read directly
|
|
10
|
+
// (globalThis.__brainDict, filled by brain-i18n.js `add`) so a page that only validates does not load brain-i18n.js.
|
|
11
|
+
// Keys: form.required form.list form.min_items form.max_items form.item form.number form.min form.max form.email
|
|
12
|
+
// form.url form.date form.min_chars form.max_chars.
|
|
13
|
+
export function msg(key, en, vars) {
|
|
14
|
+
const D = G.__brainDict
|
|
15
|
+
if (D) {
|
|
16
|
+
const h = G.document && G.document.documentElement
|
|
17
|
+
const loc = G.__brainLocale || (h && h.getAttribute('lang')) || 'en'
|
|
18
|
+
const def = (h && h.getAttribute('data-default-lang')) || 'en'
|
|
19
|
+
let m = (D[loc] && D[loc][key]) ?? (D[def] && D[def][key])
|
|
20
|
+
if (m != null) {
|
|
21
|
+
if (typeof m === 'object') {
|
|
22
|
+
const n = vars && vars.n
|
|
23
|
+
m = m['=' + n] ?? m[new Intl.PluralRules(loc).select(+n || 0)] ?? m.other ?? en
|
|
24
|
+
}
|
|
25
|
+
return vars ? String(m).replace(/\{(\w+)\}/g, (s, k) => (k in vars ? String(vars[k]) : s)) : String(m)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return en
|
|
29
|
+
}
|
|
6
30
|
|
|
7
31
|
// 'str!<=80' → { t: 'str', req: true, min, max, list: false }
|
|
8
32
|
export function rule(s) {
|
|
@@ -23,47 +47,47 @@ const plural = (n, w) => `${n} ${w}${n === 1 ? '' : 's'}`
|
|
|
23
47
|
// Message for one value, or '' when valid.
|
|
24
48
|
export function check(r, v) {
|
|
25
49
|
if (r.list) {
|
|
26
|
-
if (empty(v)) return r.req || r.min > 0 ? (r.req && !r.min ? 'Required' : `Choose at least ${r.min}
|
|
27
|
-
if (!Array.isArray(v)) return 'Must be a list'
|
|
28
|
-
if (r.min != null && v.length < r.min) return `Choose at least ${r.min}
|
|
29
|
-
if (r.max != null && v.length > r.max) return `At most ${plural(r.max, 'item')}
|
|
50
|
+
if (empty(v)) return r.req || r.min > 0 ? (r.req && !r.min ? msg('form.required', 'Required') : msg('form.min_items', `Choose at least ${r.min}`, { n: r.min })) : ''
|
|
51
|
+
if (!Array.isArray(v)) return msg('form.list', 'Must be a list')
|
|
52
|
+
if (r.min != null && v.length < r.min) return msg('form.min_items', `Choose at least ${r.min}`, { n: r.min })
|
|
53
|
+
if (r.max != null && v.length > r.max) return msg('form.max_items', `At most ${plural(r.max, 'item')}`, { n: r.max })
|
|
30
54
|
const one = { ...r, list: false, req: true, min: null, max: null }
|
|
31
55
|
for (let i = 0; i < v.length; i++) {
|
|
32
56
|
const e = check(one, v[i])
|
|
33
|
-
if (e) return `Item ${i + 1}: ${e}
|
|
57
|
+
if (e) return msg('form.item', `Item ${i + 1}: ${e}`, { n: i + 1, error: e })
|
|
34
58
|
}
|
|
35
59
|
return ''
|
|
36
60
|
}
|
|
37
|
-
if (r.t === 'bool') return r.req && v !== true ? 'Required' : ''
|
|
38
|
-
if (empty(v)) return r.req ? 'Required' : ''
|
|
61
|
+
if (r.t === 'bool') return r.req && v !== true ? msg('form.required', 'Required') : ''
|
|
62
|
+
if (empty(v)) return r.req ? msg('form.required', 'Required') : ''
|
|
39
63
|
const s = String(v)
|
|
40
64
|
switch (r.t) {
|
|
41
65
|
case 'num': {
|
|
42
66
|
const n = typeof v === 'number' ? v : s.trim() === '' ? NaN : Number(s)
|
|
43
|
-
if (!Number.isFinite(n)) return 'Enter a number'
|
|
44
|
-
if (r.min != null && n < r.min) return `Must be at least ${r.min}
|
|
45
|
-
if (r.max != null && n > r.max) return `Must be at most ${r.max}
|
|
67
|
+
if (!Number.isFinite(n)) return msg('form.number', 'Enter a number')
|
|
68
|
+
if (r.min != null && n < r.min) return msg('form.min', `Must be at least ${r.min}`, { n: r.min })
|
|
69
|
+
if (r.max != null && n > r.max) return msg('form.max', `Must be at most ${r.max}`, { n: r.max })
|
|
46
70
|
return ''
|
|
47
71
|
}
|
|
48
72
|
case 'email':
|
|
49
|
-
if (!RX_EMAIL.test(s)) return 'Enter a valid email'
|
|
73
|
+
if (!RX_EMAIL.test(s)) return msg('form.email', 'Enter a valid email')
|
|
50
74
|
break
|
|
51
75
|
case 'url':
|
|
52
76
|
try {
|
|
53
|
-
if (!/^https?:$/.test(new URL(s).protocol)) return 'Enter a valid URL'
|
|
77
|
+
if (!/^https?:$/.test(new URL(s).protocol)) return msg('form.url', 'Enter a valid URL')
|
|
54
78
|
} catch {
|
|
55
|
-
return 'Enter a valid URL'
|
|
79
|
+
return msg('form.url', 'Enter a valid URL')
|
|
56
80
|
}
|
|
57
81
|
break
|
|
58
82
|
case 'date':
|
|
59
|
-
if (Number.isNaN(Date.parse(s))) return 'Enter a valid date'
|
|
83
|
+
if (Number.isNaN(Date.parse(s))) return msg('form.date', 'Enter a valid date')
|
|
60
84
|
return ''
|
|
61
85
|
case 'str':
|
|
62
86
|
break
|
|
63
87
|
default:
|
|
64
88
|
throw Error(`form: unknown type '${r.t}'`)
|
|
65
89
|
}
|
|
66
|
-
if (r.min != null && s.length < r.min) return `At least ${plural(r.min, 'character')}
|
|
67
|
-
if (r.max != null && s.length > r.max) return `At most ${plural(r.max, 'character')}
|
|
90
|
+
if (r.min != null && s.length < r.min) return msg('form.min_chars', `At least ${plural(r.min, 'character')}`, { n: r.min })
|
|
91
|
+
if (r.max != null && s.length > r.max) return msg('form.max_chars', `At most ${plural(r.max, 'character')}`, { n: r.max })
|
|
68
92
|
return ''
|
|
69
93
|
}
|
package/brain.js
CHANGED
|
@@ -278,7 +278,7 @@ export function attr(el, k, f) {
|
|
|
278
278
|
f,
|
|
279
279
|
k.startsWith('style:')
|
|
280
280
|
? ((k = k.slice(6)), v => (v == null || v === false ? el.style.removeProperty(k) : el.style.setProperty(k, v)))
|
|
281
|
-
: /^(value|checked|selected)$/.test(k)
|
|
281
|
+
: /^(value|checked|selected)$/.test(k) && !(k == 'value' && el.tagName == 'OPTION') // an option's value is its attribute (SSR, value="")
|
|
282
282
|
? v => {
|
|
283
283
|
if (el[k] !== v) el[k] = v
|
|
284
284
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@christianirack/brainllm-runtime",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "The BRAINLLM runtime: dependency-free ES modules (signals, DOM, router, data, forms, channels). The brain binary embeds this same version.",
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "The BRAINLLM runtime for the Rust-built web compiler for LLMs: dependency-free ES modules (signals, DOM, router, data, forms, channels). The brain binary embeds this same version.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./brain.js",
|
package/server/server.mjs
CHANGED
|
@@ -43,6 +43,8 @@ const SECURITY = {
|
|
|
43
43
|
'x-frame-options': 'DENY',
|
|
44
44
|
'permissions-policy': 'camera=(), microphone=(), geolocation=(), payment=(), usb=()',
|
|
45
45
|
'cross-origin-opener-policy': 'same-origin',
|
|
46
|
+
// what serves the app and where it lives, for crawlers and LLMs that read headers (no version: that stays in the HTML)
|
|
47
|
+
'x-powered-by': 'BRAINLLM (https://chrisifm.github.io/BRAINLLM/)',
|
|
46
48
|
}
|
|
47
49
|
// The build's policy (manifest `csp`, without hashes: the same one static hosting uses) plus this response's nonce in
|
|
48
50
|
// script-src; without it, a default that lets api() reach HTTPS APIs of other origins.
|
|
@@ -155,6 +157,8 @@ export function createServer(o = {}) {
|
|
|
155
157
|
const { user, session: ses } = sessionFrom(cookies, secret)
|
|
156
158
|
return {
|
|
157
159
|
url, query: Object.fromEntries(url.searchParams), cookies, user, session: ses, env,
|
|
160
|
+
// `prefetch`: the router fetched this page ahead of a click (§19); a `load` with side effects can skip them
|
|
161
|
+
prefetch: req.headers['x-brain-prefetch'] === '1',
|
|
158
162
|
req: { method: req.method, headers: req.headers, ip: ipOf(req) }, ...extra,
|
|
159
163
|
}
|
|
160
164
|
}
|
|
@@ -218,6 +222,10 @@ export function createServer(o = {}) {
|
|
|
218
222
|
}
|
|
219
223
|
if (!nocache && route.cache > 0 && !forms && !fixed && status === 200 && anonymous(req)) return cached(req, res, route, params, url, rid)
|
|
220
224
|
const ctx = context(req, res, url, { params })
|
|
225
|
+
// a page rendered for a signed-in user is never stored (§35): after logout, Back must not show the previous
|
|
226
|
+
// user's page from the HTTP cache or bfcache. Anonymous pages keep `no-cache` + ETag.
|
|
227
|
+
const own = ctx.user != null || ctx.cookies[COOKIE] !== undefined
|
|
228
|
+
const keep = h => (own ? { ...h, 'cache-control': 'private, no-store' } : h)
|
|
221
229
|
let props = fixed || {}, extra = {}
|
|
222
230
|
if (!fixed && ((route.guards && route.guards.length) || (route.middleware && route.middleware.length))) {
|
|
223
231
|
let r
|
|
@@ -234,7 +242,7 @@ export function createServer(o = {}) {
|
|
|
234
242
|
if (r.deny !== undefined) return forbidden(req, res, url, rid, r.deny)
|
|
235
243
|
extra = r.headers
|
|
236
244
|
}
|
|
237
|
-
if (route.stream && route.server && !fixed && !forms && status === 200 && !res.$capture) return streamed(req, res, route, params, url, rid, ctx, extra)
|
|
245
|
+
if (route.stream && route.server && !fixed && !forms && status === 200 && !res.$capture) return streamed(req, res, route, params, url, rid, ctx, keep(extra || {}))
|
|
238
246
|
try {
|
|
239
247
|
if (!fixed && route.server) {
|
|
240
248
|
const m = await load(route.server)
|
|
@@ -242,12 +250,13 @@ export function createServer(o = {}) {
|
|
|
242
250
|
}
|
|
243
251
|
} catch (e) {
|
|
244
252
|
if (e && e.redirect) return send(res, e.status || 303, '', { location: withBase(e.redirect), ...ctx.session.headers() })
|
|
245
|
-
if (e instanceof HttpError && e.status === 404) return notFound(req, res)
|
|
253
|
+
if ((e instanceof HttpError && e.status === 404) || (e && e.notFound === true)) return notFound(req, res) // `throw notFound()` (F11)
|
|
246
254
|
const status = e instanceof HttpError && e.status === 504 ? 504 : 500
|
|
247
255
|
log('error', 'load failed', { rid, path: url.pathname, status, stack: String((e && e.stack) || e) })
|
|
248
256
|
return status === 500 ? errorPage(req, res, route, params, url, rid) : failPage(res, status, rid)
|
|
249
257
|
}
|
|
250
258
|
if (props && props.redirect) return send(res, props.status || 303, '', { location: withBase(props.redirect), ...ctx.session.headers() })
|
|
259
|
+
if (props && props.notFound === true) return notFound(req, res) // `return notFound()` (F11)
|
|
251
260
|
let body, seo, failed
|
|
252
261
|
try {
|
|
253
262
|
const at = { path: url.pathname, params: params || {}, query: Object.fromEntries(url.searchParams) } // route() (§41.5)
|
|
@@ -261,12 +270,12 @@ export function createServer(o = {}) {
|
|
|
261
270
|
const page = readFileSync(file(route.page), 'utf8')
|
|
262
271
|
.replace('<!--brain:ssr-->', () => body)
|
|
263
272
|
.replace('<!--brain:props-->', () => jsonInHtml(props))
|
|
264
|
-
let out = head(page, seo)
|
|
273
|
+
let out = head(page, seo, url.pathname)
|
|
265
274
|
// route params for route() in the browser (the client has no route table)
|
|
266
275
|
if (params && Object.keys(params).length) out = out.replace('</head>', () => `<meta name="brain-params" content="${esc(JSON.stringify(params))}"></head>`)
|
|
267
276
|
if (forms) out = out.replace('</body>', () => `<script type="application/json" id="brain-forms">${jsonInHtml(forms)}</script></body>`)
|
|
268
|
-
if (res.$capture) return res.$capture(out, { ...extra, ...ctx.session.headers() }, status)
|
|
269
|
-
html(res, status, out, { ...extra, ...ctx.session.headers() }, 'W/"' + tag(out) + '"')
|
|
277
|
+
if (res.$capture) return res.$capture(out, keep({ ...extra, ...ctx.session.headers() }), status)
|
|
278
|
+
html(res, status, out, keep({ ...extra, ...ctx.session.headers() }), own ? undefined : 'W/"' + tag(out) + '"')
|
|
270
279
|
}
|
|
271
280
|
// Streaming SSR (#232, `route.stream`: the build sets it when the view's <meta seo> is static and it has a load()):
|
|
272
281
|
// the page up to the region (head, CSS, layout shell) is sent at once, the view when load() ends. The status and
|
|
@@ -293,7 +302,7 @@ export function createServer(o = {}) {
|
|
|
293
302
|
const loadP = typeof sm.load === 'function' ? withTimeout(Promise.resolve().then(() => sm.load(ctx)), TIMEOUT) : Promise.resolve(null)
|
|
294
303
|
loadP.catch(() => {}) // handled below; never an unhandled rejection while the loading view renders
|
|
295
304
|
const at = { path: url.pathname, params: params || {}, query: Object.fromEntries(url.searchParams) }
|
|
296
|
-
res.write(n(head(top, seo0)))
|
|
305
|
+
res.write(n(head(top, seo0, url.pathname)))
|
|
297
306
|
// QA #253: the folder's _loading.brain goes in the region with the first chunk (SPEC: shows while load runs),
|
|
298
307
|
// between two marker comments; the last chunk starts with a script that removes them and what is between
|
|
299
308
|
let clear = ''
|
|
@@ -322,9 +331,11 @@ export function createServer(o = {}) {
|
|
|
322
331
|
let props
|
|
323
332
|
try {
|
|
324
333
|
props = (await loadP) ?? {}
|
|
325
|
-
if (props.redirect) throw props
|
|
334
|
+
if (props.redirect || props.notFound === true) throw props
|
|
326
335
|
} catch (e) {
|
|
327
336
|
if (e && e.redirect) return end(`<script>location.replace(${jsonInHtml(withBase(e.redirect))})</script>`)
|
|
337
|
+
// a streamed page already sent its 200 head: the region gets the not-found notice and the page is noindex
|
|
338
|
+
if (e && e.notFound === true) return end(`<meta name="robots" content="noindex"><p role="alert" class="brain-fallback">Page not found.</p>`, {}, bottom.replace(/<script type="module">(?:(?!<\/script>)[\s\S])*?brain-view[\s\S]*?<\/script>/g, ''))
|
|
328
339
|
log('error', 'load failed (streamed)', { rid, path: url.pathname, status: 200, stack: String((e && e.stack) || e) }) // headers already out
|
|
329
340
|
return fail()
|
|
330
341
|
}
|
|
@@ -483,7 +494,92 @@ export function createServer(o = {}) {
|
|
|
483
494
|
plain(r.status, r.body.error, r.headers)
|
|
484
495
|
}
|
|
485
496
|
|
|
497
|
+
// ---------- dynamic sitemap (§32) ----------
|
|
498
|
+
// A dynamic view without paths() can export `sitemap()` (manifest route `sitemap: true`): it returns the params of its
|
|
499
|
+
// pages, plus an optional `lastmod`, at request time. /sitemap.xml is the build's file with those URLs added (a sitemap
|
|
500
|
+
// index gets one more child, /sitemap-dynamic.xml). A file holds at most 50 000 URLs (sitemaps.org): when the build's
|
|
501
|
+
// URLs plus the dynamic ones pass that, /sitemap.xml becomes an index of /sitemap-static.xml (the build's file) and
|
|
502
|
+
// /sitemap-dynamic.xml; dynamic URLs past 50 000 are dropped with a warning. Absolute URLs take the origin of the
|
|
503
|
+
// build's sitemap (brain.json `site`); a build without a sitemap serves none. The list is kept 5 min (one sitemap() run
|
|
504
|
+
// at a time when it expires) and the response says max-age=300; a sitemap() that throws is logged and skipped.
|
|
505
|
+
const DYN = routes.filter(r => r.sitemap && r.server)
|
|
506
|
+
let dynList = null
|
|
507
|
+
const fill = (pattern, p) => {
|
|
508
|
+
let ok = true
|
|
509
|
+
const out = pattern.replace(/([:*])(\w+)/g, (_, k, n) => {
|
|
510
|
+
const v = p && p[n]
|
|
511
|
+
if (v == null || v === '') return (ok = false), ''
|
|
512
|
+
return k === '*' ? String(v).split('/').map(encodeURIComponent).join('/') : encodeURIComponent(String(v))
|
|
513
|
+
})
|
|
514
|
+
return ok ? out : null
|
|
515
|
+
}
|
|
516
|
+
const day = v => (v instanceof Date ? v.toISOString().slice(0, 10) : typeof v === 'string' && /^\d{4}-\d{2}-\d{2}/.test(v) ? v : null)
|
|
517
|
+
const MAX_URLS = 50000
|
|
518
|
+
let dynRun = null
|
|
519
|
+
async function collect(origin) {
|
|
520
|
+
const urls = []
|
|
521
|
+
let dropped = 0
|
|
522
|
+
for (const r of DYN) {
|
|
523
|
+
try {
|
|
524
|
+
const sm = await load(r.server)
|
|
525
|
+
const list = typeof sm.sitemap === 'function' ? await withTimeout(Promise.resolve().then(() => sm.sitemap({ env })), TIMEOUT) : []
|
|
526
|
+
for (const p of Array.isArray(list) ? list : []) {
|
|
527
|
+
const path = fill(r.path, p)
|
|
528
|
+
if (!path) continue
|
|
529
|
+
if (urls.length < MAX_URLS) urls.push({ loc: origin + withBase(path), lastmod: day(p.lastmod) })
|
|
530
|
+
else dropped++
|
|
531
|
+
}
|
|
532
|
+
} catch (e) {
|
|
533
|
+
log('error', 'sitemap() failed', { view: r.view, stack: String((e && e.stack) || e) })
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
if (dropped) log('warn', 'sitemap(): more than 50000 URLs, the rest are not listed', { dropped })
|
|
537
|
+
dynList = { at: Date.now(), urls }
|
|
538
|
+
return urls
|
|
539
|
+
}
|
|
540
|
+
async function dynamicUrls(origin) {
|
|
541
|
+
if (dynList && Date.now() - dynList.at < 300000) return dynList.urls
|
|
542
|
+
// concurrent requests after expiry share one run of every sitemap()
|
|
543
|
+
if (!dynRun) dynRun = collect(origin).finally(() => (dynRun = null))
|
|
544
|
+
return dynRun
|
|
545
|
+
}
|
|
546
|
+
async function serveSitemap(req, res, url) {
|
|
547
|
+
const f = file('/sitemap.xml')
|
|
548
|
+
if (!f) return false
|
|
549
|
+
const text = readFileSync(f, 'utf8')
|
|
550
|
+
const origin = [...text.matchAll(/<loc>(https?:\/\/[^<]+)<\/loc>/g)].map(m => URL.canParse(m[1]) && new URL(m[1]).origin).find(Boolean)
|
|
551
|
+
if (!origin) return false
|
|
552
|
+
const index = text.includes('<sitemapindex')
|
|
553
|
+
const urls = await dynamicUrls(origin)
|
|
554
|
+
// a urlset that would pass 50 000 URLs with the dynamic ones is split: index → static file + dynamic file
|
|
555
|
+
const split = !index && (text.match(/<url>/g) || []).length + urls.length > MAX_URLS
|
|
556
|
+
const xml = body => send(res, 200, req.method === 'HEAD' ? '' : body, { 'content-type': MIME['.xml'], 'cache-control': 'public, max-age=300' })
|
|
557
|
+
if (url.pathname === '/sitemap-static.xml') return split ? (xml(text), true) : false
|
|
558
|
+
if (url.pathname === '/sitemap-dynamic.xml' && !index && !split) return false
|
|
559
|
+
const child = p => ` <sitemap><loc>${esc(origin + withBase(p))}</loc></sitemap>\n`
|
|
560
|
+
const set = urls.map(u => ` <url><loc>${esc(u.loc)}</loc>${u.lastmod ? `<lastmod>${esc(u.lastmod)}</lastmod>` : ''}</url>\n`).join('')
|
|
561
|
+
xml(
|
|
562
|
+
url.pathname === '/sitemap-dynamic.xml'
|
|
563
|
+
? `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${set}</urlset>\n`
|
|
564
|
+
: index
|
|
565
|
+
? text.replace('</sitemapindex>', `${child('/sitemap-dynamic.xml')}</sitemapindex>`)
|
|
566
|
+
: split
|
|
567
|
+
? `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${child('/sitemap-static.xml')}${child('/sitemap-dynamic.xml')}</sitemapindex>\n`
|
|
568
|
+
: text.replace('</urlset>', set + '</urlset>'),
|
|
569
|
+
)
|
|
570
|
+
return true
|
|
571
|
+
}
|
|
572
|
+
|
|
486
573
|
// ---------- static ----------
|
|
574
|
+
// §33 content negotiation: `Accept` preferring text/markdown gets the page's Markdown twin (`/pricing` → pricing.md,
|
|
575
|
+
// `/` → index.md) when the build published one (`seo.markdown`, default true; never for noindex pages).
|
|
576
|
+
function serveTwin(req, res, url) {
|
|
577
|
+
if (!prefersMarkdown(req.headers.accept)) return false
|
|
578
|
+
const md = file(mdPath(decodeURIComponent(url.pathname)))
|
|
579
|
+
if (!md) return false
|
|
580
|
+
send(res, 200, req.method === 'HEAD' ? '' : readFileSync(md), { 'content-type': MIME['.md'], 'cache-control': 'public, max-age=3600', vary: 'accept' })
|
|
581
|
+
return true
|
|
582
|
+
}
|
|
487
583
|
function serveStatic(req, res, url) {
|
|
488
584
|
let p = decodeURIComponent(url.pathname)
|
|
489
585
|
const candidates = p.endsWith('/') ? [p + 'index.html'] : [p, p + '.html', p + '/index.html']
|
|
@@ -492,9 +588,12 @@ export function createServer(o = {}) {
|
|
|
492
588
|
if (!f) continue
|
|
493
589
|
const ext = extname(f), type = FEEDS[basename(f)] || MIME[ext] || 'application/octet-stream'
|
|
494
590
|
if (ext === '.html') {
|
|
591
|
+
// §33: the page's Markdown twin (`/pricing` → pricing.md) when the client prefers text/markdown
|
|
592
|
+
if (serveTwin(req, res, url)) return true
|
|
593
|
+
const md = file(mdPath(p))
|
|
495
594
|
const page = readFileSync(f, 'utf8')
|
|
496
595
|
// static HTML changes only with a deploy: its ETag is the build signature (content hash without a manifest)
|
|
497
|
-
return html(res, 200, page, {}, '"' + (mf.build || tag(page)) + '"'), true
|
|
596
|
+
return html(res, 200, page, md ? { vary: 'accept' } : {}, '"' + (mf.build || tag(page)) + '"'), true
|
|
498
597
|
}
|
|
499
598
|
const headers = {
|
|
500
599
|
'content-type': type,
|
|
@@ -552,9 +651,11 @@ export function createServer(o = {}) {
|
|
|
552
651
|
if (!m) continue
|
|
553
652
|
// a form page without load is prerendered: GET serves that file (build ETag); only its POST needs the server
|
|
554
653
|
if (!post && route.forms && serveStatic(req, res, url)) return
|
|
654
|
+
if (!post && serveTwin(req, res, url)) return
|
|
555
655
|
return await (post ? formPost : ssr)(req, res, route, m.groups ? { ...m.groups } : {}, url, rid)
|
|
556
656
|
}
|
|
557
657
|
if (post) return send(res, 405, 'Method not allowed', { allow: 'GET, HEAD' })
|
|
658
|
+
if (DYN.length && (url.pathname === '/sitemap.xml' || url.pathname === '/sitemap-dynamic.xml' || url.pathname === '/sitemap-static.xml') && (await serveSitemap(req, res, url))) return
|
|
558
659
|
if (serveStatic(req, res, url)) return
|
|
559
660
|
notFound(req, res)
|
|
560
661
|
} catch (e) {
|
|
@@ -604,8 +705,11 @@ export function sessionFrom(cookies, secret) {
|
|
|
604
705
|
set.push(cookie('', 0))
|
|
605
706
|
cleared = true
|
|
606
707
|
},
|
|
607
|
-
// logout wipes this origin's
|
|
608
|
-
|
|
708
|
+
// logout wipes this origin's storage on the device: localStorage, IndexedDB, the Cache API and service workers
|
|
709
|
+
// (§35: the next user sees nothing). Not "cache": Chrome clears the whole HTTP cache before it finishes the
|
|
710
|
+
// response, so the logout call took ~18 s (F16). Nothing private is in that cache: RPC is no-store and SSR
|
|
711
|
+
// HTML rendered with a session cookie is `private, no-store` (ssr() → keep).
|
|
712
|
+
headers: () => ({ ...(set.length ? { 'set-cookie': set } : {}), ...(cleared ? { 'clear-site-data': '"storage"' } : {}) }),
|
|
609
713
|
},
|
|
610
714
|
}
|
|
611
715
|
}
|
|
@@ -798,30 +902,90 @@ function fillValues(n) {
|
|
|
798
902
|
else if (t === 'input' && c.type !== 'password') {
|
|
799
903
|
if (c.value != null && c.value !== '') c.setAttribute('value', String(c.value))
|
|
800
904
|
if (c.checked === true) c.setAttribute('checked', '')
|
|
905
|
+
} else if (t === 'option' && c.selected === true) c.setAttribute('selected', '')
|
|
906
|
+
// <select value={x}>: the option with that value starts selected (an option's value is its attribute or text)
|
|
907
|
+
else if (t === 'select' && c.value != null && c.value !== '') {
|
|
908
|
+
const pick = o => {
|
|
909
|
+
for (const x of o.childNodes || []) {
|
|
910
|
+
if (x.nodeType !== 1) continue
|
|
911
|
+
if (x.tagName === 'OPTION' && (x.getAttribute('value') ?? x.textContent) === String(c.value)) x.setAttribute('selected', '')
|
|
912
|
+
else pick(x)
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
pick(c)
|
|
801
916
|
}
|
|
802
917
|
fillValues(c)
|
|
803
918
|
}
|
|
804
919
|
}
|
|
920
|
+
// `/` → index.md, `/docs/setup/` → docs/setup.md (brain-seo files::md_path)
|
|
921
|
+
const mdPath = p => (p.replace(/^\/+|\/+$/g, '') || 'index') + '.md'
|
|
922
|
+
// true when `Accept` ranks text/markdown above text/html (an equal weight goes to whichever is listed first);
|
|
923
|
+
// wildcards alone never select Markdown, so browsers keep getting HTML
|
|
924
|
+
export function prefersMarkdown(accept) {
|
|
925
|
+
if (!accept) return false
|
|
926
|
+
let md = -1, html = -1, mdAt = 0, htmlAt = 0, any = -1
|
|
927
|
+
accept.split(',').forEach((part, i) => {
|
|
928
|
+
const [type, ...params] = part.trim().toLowerCase().split(';')
|
|
929
|
+
const qp = params.map(x => x.trim()).find(x => x.startsWith('q='))
|
|
930
|
+
const q = qp ? Number(qp.slice(2)) : 1
|
|
931
|
+
if (!(q >= 0)) return
|
|
932
|
+
if (type === 'text/markdown' || type === 'text/x-markdown') (md = Math.max(md, q)), (mdAt = mdAt || i + 1)
|
|
933
|
+
else if (type === 'text/html' || type === 'application/xhtml+xml') (html = Math.max(html, q)), (htmlAt = htmlAt || i + 1)
|
|
934
|
+
else if (type === 'text/*' || type === '*/*') any = Math.max(any, q)
|
|
935
|
+
})
|
|
936
|
+
if (html < 0) (html = any), (htmlAt = Infinity) // an explicit type beats a wildcard of the same weight
|
|
937
|
+
return md > 0 && (md > html || (md === html && mdAt < htmlAt))
|
|
938
|
+
}
|
|
805
939
|
// SEO head from View.seo(props): title, description, canonical, og:*/twitter:*, JSON-LD and the view's <head> block
|
|
806
|
-
|
|
807
|
-
|
|
940
|
+
// F5: the build writes an SSR route's canonical/og:url from its pattern (/jobs/:slug); the request has the real path.
|
|
941
|
+
// A view's own <head> tags replace the generated ones with the same name/property (one tag per key, SPEC «SEO»).
|
|
942
|
+
function head(page, seo, path) {
|
|
808
943
|
let h = page
|
|
944
|
+
if (path) {
|
|
945
|
+
// the attribute is already escaped HTML: only the request path that replaces a pattern is escaped
|
|
946
|
+
const real = v => v.replace(/^(https?:\/\/[^/]+)?(\/[^"]*)$/, (m, o = '', p) => (/\/[:*]/.test(p) ? o + esc(path) : m))
|
|
947
|
+
h = h.replace(/(<link[^>]*rel=["']canonical["'][^>]*href=")([^"]*)(")/i, (m, a, v, b) => a + real(v) + b)
|
|
948
|
+
h = h.replace(/(<meta[^>]*property=["']og:url["'][^>]*content=")([^"]*)(")/i, (m, a, v, b) => a + real(v) + b)
|
|
949
|
+
}
|
|
950
|
+
if (!seo) return h
|
|
809
951
|
const meta = (attr, key, val) => {
|
|
810
952
|
const tag = `<meta ${attr}="${esc(key)}" content="${esc(val)}">`
|
|
811
953
|
const rx = new RegExp(`<meta[^>]*\\b${attr}=["']${key.replace(/[.*+?^${}()|[\]\\:]/g, '\\$&')}["'][^>]*>`, 'i')
|
|
812
954
|
h = rx.test(h) ? h.replace(rx, () => tag) : h.replace('</head>', () => tag + '</head>')
|
|
813
955
|
}
|
|
814
956
|
for (const [k, v] of Object.entries(seo)) {
|
|
815
|
-
|
|
957
|
+
// `schema` becomes JSON-LD below and `lastmod` feeds it: neither is a meta tag (prerender seo_head does the same)
|
|
958
|
+
if (v == null || k === '$head' || k === 'schema' || k === 'lastmod') continue
|
|
816
959
|
if (k === 'title') h = /<title>[\s\S]*?<\/title>/i.test(h) ? h.replace(/<title>[\s\S]*?<\/title>/i, () => `<title>${esc(v)}</title>`) : h.replace('</head>', () => `<title>${esc(v)}</title></head>`)
|
|
817
960
|
else if (k === 'canonical') {
|
|
818
961
|
const tag = `<link rel="canonical" href="${esc(v)}">`
|
|
819
962
|
h = /<link[^>]*rel=["']canonical["'][^>]*>/i.test(h) ? h.replace(/<link[^>]*rel=["']canonical["'][^>]*>/i, () => tag) : h.replace('</head>', () => tag + '</head>')
|
|
963
|
+
if (seo['og:url'] == null) meta('property', 'og:url', v) // og:url follows the canonical unless the view sets its own
|
|
820
964
|
} else if (k === 'jsonld') h = h.replace('</head>', () => `<script type="application/ld+json">${jsonInHtml(v)}</script></head>`)
|
|
821
965
|
else if (/^(og|twitter|article):/.test(k)) meta('property', k, v)
|
|
822
966
|
else if (typeof v === 'string') meta('name', k, v)
|
|
823
967
|
}
|
|
824
|
-
|
|
968
|
+
// `<meta seo schema="Article">` without `jsonld` (§32): one node with this request's values. It replaces the node
|
|
969
|
+
// the build wrote into the template from the static attributes (same @type), so the page carries exactly one.
|
|
970
|
+
if (typeof seo.schema === 'string' && seo.jsonld == null && /^[A-Za-z]+$/.test(seo.schema)) {
|
|
971
|
+
const T = seo.schema
|
|
972
|
+
const canon = /<link[^>]*rel=["']canonical["'][^>]*href="([^"]*)"/i.exec(h)
|
|
973
|
+
const node = { '@context': 'https://schema.org', '@type': T }
|
|
974
|
+
if (seo.title != null) node[/^(Article|BlogPosting|NewsArticle|TechArticle|Report)$/.test(T) ? 'headline' : 'name'] = String(seo.title).slice(0, 110)
|
|
975
|
+
if (seo.description != null) node.description = String(seo.description)
|
|
976
|
+
if (canon) node.url = canon[1].replace(/&/g, '&')
|
|
977
|
+
if (seo['og:image'] != null) node.image = String(seo['og:image']).replace(/^\/(?!\/)/, () => (canon && /^https?:\/\/[^/]+/.exec(node.url) || [''])[0] + '/')
|
|
978
|
+
if (seo.lastmod != null) node.dateModified = String(seo.lastmod)
|
|
979
|
+
const own = new RegExp(`<script[^>]*type=["']application/ld\\+json["'][^>]*>\\s*\\{"@context":"https://schema\\.org","@type":"${T}"[\\s\\S]*?</script>`, 'i')
|
|
980
|
+
const tag = `<script type="application/ld+json">${jsonInHtml(node)}</script>`
|
|
981
|
+
h = own.test(h) ? h.replace(own, () => tag) : h.replace('</head>', () => tag + '</head>')
|
|
982
|
+
}
|
|
983
|
+
if (seo.$head) {
|
|
984
|
+
for (const [, attr, key] of seo.$head.matchAll(/<meta[^>]*\b(property|name)=["']([^"']+)["'][^>]*>/gi))
|
|
985
|
+
h = h.replace(new RegExp(`<meta[^>]*\\b${attr}=["']${key.replace(/[.*+?^${}()|[\]\\:]/g, '\\$&')}["'][^>]*>`, 'gi'), '')
|
|
986
|
+
if (/<link[^>]*rel=["']canonical["']/i.test(seo.$head)) h = h.replace(/<link[^>]*rel=["']canonical["'][^>]*>/gi, '')
|
|
987
|
+
h = h.replace('</head>', () => seo.$head + '</head>')
|
|
988
|
+
}
|
|
825
989
|
return h
|
|
826
990
|
}
|
|
827
991
|
// `/_b/<hash>.js` imports inside the chunks resolve to files in dist/ (same hook as the build's prerender)
|