gitoriaLog in with ident

gitoria

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit4a2d71254a2d7125initial commitmre4a2d7125/plugins/web/WebFramework.hl

117.8 KB

  1. // The framework's server half — the SERVER REALM's entrypoint, and the EDGE
  2. // MODULE for the carriers the manifest declares.
  3. // Its jobs: answer a URL with the HTML of a constructed component (SSR), ship
  4. // the browser the client, the View tree and the state to continue from, carry
  5. // the boundary in both directions (a websocket on the same port, a POST for a
  6. // peer that has none), and run the server faces an inbound emit names.
  7. import { NativeWebSocketServer, Response, randomToken } from 'hl:http1'
  8. import View from './view.hl'
  9. import Router from './router.hl'
  10. import Sessions from './sessions.hl'
  11. import Session from './session.hl'
  12. // the Style walk — named Css here because `Style` is a well-known MEMBER name
  13. import Css from './css.hl'
  14. import Compile from './compile.hl'
  15. import { readFile, exists, watch } from 'hl:fs'
  16. import { file, env } from 'hl:proc'
  17. import { now } from 'hl:time'
  18. import { newline, tab } from './view.hl'
  19. Hybrid routes = [] // bound by `new WebFrameworkServer(routes = …)`
  20. // THE PROJECT'S STYLES FILE, declared by the app as a property of the framework:
  21. // `new WebFrameworkServer(…, styles = './styles.hl')`. A file of statics and one
  22. // `Style { }` root member (COMPONENTS §4) — the design tokens and the global
  23. // rules, under every component's own Style in the one stylesheet. null: none.
  24. styles = null // the app's styles file: the class it imported, or a path
  25. // WHO AN OUTWARD EVENT IS FOR (creator, 2026-09-12): per event, a function the app
  26. // declares — `audience = { postCreated = (p, session) => … }` — called for every
  27. // connection that has a listener for the event mounted, with the event's
  28. // arguments and that connection's session; a true answer sends the frame. An
  29. // event with no entry reaches the emitting connection only. Only the app knows
  30. // who is entitled; the face stays pure; the framework applies the answer.
  31. Hybrid audience = {}
  32. // THE SESSION STORE (the archive's manifest members, mission 252/255): nothing said →
  33. // `<project>/.sessions`; a path → that path; `false` → memory only. The three clocks
  34. // are seconds; null takes the layer's defaults (14 days valid, 15 min resident, sweep
  35. // every 5 min).
  36. // THE DEV WATCHER (the archive's `watch`): every save under the project arrives as an
  37. // event; a save that changes the analysis re-reads the graph, drops every cache and tells
  38. // every open tab to reload. Off by default — a deploy never re-analyses itself.
  39. Boolean minify = false // production: one-line HTML, compact JavaScript modules
  40. Boolean watchMode = true
  41. lastChange = 0 // epoch ms of the last save acted on (the debounce)
  42. String | Boolean sessionDir = null
  43. Number sessionMaxAge = null
  44. Number sessionIdle = null
  45. Number sweepInterval = null
  46. // THE SESSION COOKIE'S NAME (ticket #10). Cookies ignore the port, so two apps on one
  47. // host that both answered `hlsid` overwrote each other's sessions; an app names its own.
  48. // null: `hlsid`.
  49. String sessionCookie = null
  50. // THE COOKIE'S `Secure` FLAG (ticket #27): true when the app is reached over https —
  51. // behind a TLS proxy the server itself speaks plain http and cannot tell, so the app
  52. // says so. The browser then never sends the session over plain http. false: not set.
  53. Boolean sessionSecure = false
  54. // THE COOKIE'S `Domain` (ticket #44): 'example.org' shares one session with every
  55. // <sub>.example.org, which a host-only cookie cannot. null: host-only, no attribute.
  56. String sessionDomain = null
  57. Number port = 8080
  58. String host = null // the interface to bind; null → HL_HOST/HOST, the manifest's `host`, 0.0.0.0
  59. // THE ONE RUNTIME URL, and the reason it is a member and not a literal in two
  60. // places: a module's `import … from "./hl-runtime.js"` resolves against the
  61. // MODULE's own url, so a server answering modules at two directory depths hands
  62. // the browser two runtime urls — two ES-module instances, two `globalEvents`
  63. // buses, two `__hlRealm` variables, and a crossing emit announced on a bus the
  64. // edge module never taps. Every module this server compiles is handed this one
  65. // specifier (hlJs's third argument).
  66. // THE DIRECTORY THE FRAMEWORK ANSWERS FROM, and the reason it is its own member:
  67. // a package's browser half is served BESIDE THE RUNTIME, and the compiler derives
  68. // that url from the runtime's own (`js_module.zig browserHalfUrl`:
  69. // `<dirname(runtime)>/<pkg>/client.js`). Two sides deriving one url from one
  70. // directory is what keeps the module's `import` and this server's route table
  71. // from drifting apart.
  72. static halfDir = '/__hl'
  73. static runtimeUrl = halfDir + '/hl-runtime.js'
  74. clientUrl = halfUrl('web')
  75. styleUrl = halfDir + '/app.css'
  76. // THE FRAMEWORK'S OWN URLS ARE ROUTES (creator: "just use routes"), appended to
  77. // the app's table with `routes[] = …` — the append that takes on a caller's
  78. // pinned argument during construction, where `routes = routes + […]` is dropped.
  79. // Three of them here — the runtime, the REST carrier and the app's one
  80. // stylesheet — and then one per PACKAGE BROWSER HALF the app's graph holds,
  81. // appended below where the graph is known. There is no catch-all that compiles
  82. // any graph file a url names.
  83. routes[] = { pattern = runtimeUrl framework = true function = (route, req) => {
  84. return new Response(runtime, { headers = { 'Content-Type' = 'text/javascript; charset=utf-8' } })
  85. } }
  86. // THE REST CARRIER, the same emit/ack pair for a peer that cannot hold a socket
  87. // (SPEC: `POST /__hl/emit`).
  88. // ITS SESSION IS THE COOKIE'S (creator, 2026-09-14): this is an HTTP request like
  89. // the page request, so it resolves the SAME session the page route would for that
  90. // cookie — a `session.user` a face writes here is what the next document reads.
  91. // Before this the face was handed null and a login over the fallback was lost.
  92. // AN OUTWARD EMIT RAISED UNDER IT RIDES BACK IN THE ANSWER: there is no
  93. // connection to push to, so the frames a face raised for the CALLER are
  94. // collected while it runs and shipped in the ack as `emits`, which the client
  95. // half delivers before it resolves the emit. The audience fan-out to the other
  96. // connections of that user is the same walk it always was.
  97. routes[] = { pattern = '/__hl/emit' framework = true function = (route, req) => {
  98. if (req.method != 'POST') { return notFound(req.path) }
  99. let s = sessions.resolve(req.headers['cookie'] != null ? req.headers['cookie'] : req.headers['Cookie'])
  100. let fresh = s == null
  101. if (fresh) { s = sessions.mint() }
  102. // the session travels in a MEMBER, not an argument: a call argument is a copy,
  103. // an instance inside it included, and a copied session loses the face's write
  104. postSession = s
  105. postHost = hostOf(req.headers['host'])
  106. let res = new Response(inbound(null, JSON.parse(req.body)), { headers = { 'Content-Type' = 'application/json; charset=utf-8' } })
  107. if (fresh) { res.headers['Set-Cookie'] = sessions.cookieHeader(s.id) }
  108. return res
  109. } }
  110. // THE APP'S ONE STYLESHEET — the `styles` file's rules and every mounted
  111. // component's `Style { }`, walked in style.hl and cached after the first ask.
  112. routes[] = { pattern = styleUrl framework = true function = (route, req) => {
  113. return new Response(stylesheet(), { headers = { 'Content-Type' = 'text/css; charset=utf-8' } })
  114. } }
  115. // THE COMPONENT MODULES ARE ROUTES TOO — the framework's, not the app's: one per
  116. // file a page route mounts and per wrapper above it (the graph's `parent`
  117. // chain), served at the file's own `.hl` url as the browser's projection. A
  118. // browser executes a module by its MIME type, so `text/javascript` at
  119. // `/components/home.hl` is a module, and the url is the path the author wrote.
  120. // Nothing else is served as a module: a file no page route reaches has no url.
  121. // THE LANGUAGE GRAPH, AND THE REALMS ARE THIS PACKAGE'S (creator, 2026-09-12: "the project.hl
  122. // defines no realms so the framework just fills them itself"). This file is the server
  123. // realm's entrypoint, client.hl beside it the browser's; the app reaches both by
  124. // importing hl:web, and the graph follows that reference into the package.
  125. graph = hlProject(
  126. realms = {
  127. server = { entrypoint = './WebFramework.hl' }
  128. client = { entrypoint = './client.hl' }
  129. }
  130. transports = [
  131. ['websocket' 'client' 'server']
  132. ['websocket' 'server' 'client']
  133. ['rest' 'client' 'server']
  134. ]
  135. )
  136. gen = graph.analysisGen // the syntax reflection's handle: the routes below read Views
  137. // TWO WEB FRAMEWORKS IN ONE APP IS NOT AN APP (creator, 15 Sep: project.hl switched
  138. // to the other framework the repo had then while styles.hl still said 'hl:web/css').
  139. // Both packages load, both `on server page` faces answer the same navigation, and
  140. // the second answers on a blank instance — a NotCallable deep inside the other framework's dispatch, which
  141. // says nothing about the two import rows that caused it. So it is refused HERE,
  142. // before anything is served, naming both rows.
  143. oneFrameworkOnly()
  144. // A VALUE-FORM EMIT HAS ONE ANSWERER (SPEC "An emit ANSWERS", ticket #20): checked here,
  145. // before anything is served, because the wire does not say which form an emit was
  146. oneAnswererEach()
  147. // THE APP'S SETTINGS come from the file that constructed this one when they were not
  148. // passed (the archive's `configure()`): `styles`, `audience`, `sessionDir`, the clocks,
  149. // `watchMode`, `minify`, `port`
  150. cfg = hlConstructor()
  151. if (cfg != null) {
  152. if (styles == null && cfg.styles != null) { styles = cfg.styles }
  153. if (cfg.audience != null && audience.keys().length == 0) { audience = cfg.audience }
  154. if (sessionDir == null && cfg.sessionDir != null) { sessionDir = cfg.sessionDir }
  155. if (sessionMaxAge == null && cfg.sessionMaxAge != null) { sessionMaxAge = cfg.sessionMaxAge }
  156. if (sessionIdle == null && cfg.sessionIdle != null) { sessionIdle = cfg.sessionIdle }
  157. if (sweepInterval == null && cfg.sweepInterval != null) { sweepInterval = cfg.sweepInterval }
  158. if (sessionCookie == null && cfg.sessionCookie != null) { sessionCookie = cfg.sessionCookie }
  159. if (cfg.sessionSecure != null && sessionSecure == false) { sessionSecure = cfg.sessionSecure }
  160. if (sessionDomain == null && cfg.sessionDomain != null) { sessionDomain = cfg.sessionDomain }
  161. if (cfg.watchMode != null) { watchMode = cfg.watchMode }
  162. if (cfg.minify != null) { minify = cfg.minify }
  163. if (cfg.port != null) { port = cfg.port }
  164. }
  165. served = {}
  166. mounted = []
  167. for (r of routes) { if (r.component != null) { mounted[] = keyOf(r.component) } }
  168. for (k of mounted) {
  169. let key = k
  170. // a file the graph does not hold is refused at its route row, below (servedKeys
  171. // → routeComponentKey: this walk reads the evaluated table, which has no lines)
  172. if (graph.files[key] == null) { key = null }
  173. while (key != null) {
  174. serveComponent(key)
  175. let child = key
  176. key = graph.files[key].parent
  177. // A PARENT IS A COMPONENT (COMPONENTS §5): one without a View wraps nothing, and
  178. // every page under it answered a 500 while the boot said nothing (322 row 13)
  179. if (key != null && viewHandle(key) == 0) {
  180. hlError("hl:web: the `parent` clause of " + child + " names " + key + ", which declares no View: a parent is a component whose View places `slot` where the page renders")
  181. }
  182. }
  183. }
  184. // EVERY PACKAGE'S BROWSER HALF IS SERVED, BY ONE RULE. A package whose browser
  185. // side is written in this language ships it as `client.hl` beside its
  186. // plugin.json; the compiler compiles it like any other file and writes the
  187. // importing module `import { … } from "<halfDir>/<pkg>/client.js"`
  188. // (js_module.zig browserHalf / browserHalfUrl). Something has to answer that url,
  189. // and this is it — for EVERY such package the graph holds, because the rule
  190. // belongs to the language and not to one package. hl:web's own half is the first
  191. // case of it and no longer a hard-coded route of its own; hl:dnd is the second.
  192. //
  193. // The graph is the whole guard: a key is here only because the entry's closure
  194. // REACHED that package's half through an import, which is the same reason a
  195. // component has a module url. A package nothing imports is served nothing.
  196. for (gk of graph.files.keys()) {
  197. if (gk.startsWith('hl:') && gk.endsWith('/client.hl')) { serveHalf(gk) }
  198. }
  199. // ---- WHAT THIS APP SERVES, OFF THE ANALYSIS ALONE (mission 315) ----------------
  200. // These four are STATIC, so they answer with no instance and without constructing
  201. // anything: the compiler evaluates `servedModulesOf(graph)` at BUILD time to bake
  202. // each module's text, and this file's own root uses the very same functions at
  203. // boot. One definition, two callers — a second derivation would drift, and a
  204. // compiled binary would then serve text generated against different urls.
  205. //
  206. // The seed is the entry's route table AS WRITTEN, read through `hlSyntax`, not
  207. // the evaluated `routes` member: evaluating it means constructing the app, and
  208. // constructing this class binds a socket. The graph carries the entry's record
  209. // since 2026-09-16, which is what makes that readable at all.
  210. // A ROUTE'S COMPONENT IS A PAGE: a file without a View answered every request with a 500
  211. // ("declares no View", raised without a span) while the boot said nothing (mission 322
  212. // row 13). Refused at the row instead.
  213. static routePage = (gen, entryKey, valueNode, key) => {
  214. if (!hasViewMember(gen, key)) { hlSourceError(gen, entryKey, valueNode, key + " declares no View, so it is no page: a route's component is a file whose View renders") }
  215. return key
  216. }
  217. // The key a route row's `component` names: an imported class, or a path string.
  218. // Anything else is a located refusal AT THE ROW — never silently unserved.
  219. static routeComponentKey = (graph, gen, entryKey, imports, valueNode) => {
  220. let v = hlSyntax(gen, entryKey, valueNode)
  221. if (v == null) { return null }
  222. if (v.tag == 'literal' && v.kind == 'string') {
  223. let t = v.text
  224. let k = t.startsWith('./') ? t.slice(2) : t
  225. // A PATH NO FILE OF THE APP IMPORTS IS NOT IN THE GRAPH: the analysis never read
  226. // it, so nothing can be served for it — refused here, at the row, instead of a
  227. // null deep in the framework once the server is already up (STATUS §2 item 0)
  228. if (graph.files[k] == null) { hlSourceError(gen, entryKey, valueNode, "the app never imports " + t + ", so the analysis never read it and the app would serve no module for it: import it in this file (`import Page from '" + t + "'`) and write `component = Page`") }
  229. return routePage(gen, entryKey, valueNode, k)
  230. }
  231. if (v.tag == 'identifier') {
  232. for (imp of imports) {
  233. if (imp.default == v.name && imp.key != null) { return routePage(gen, entryKey, valueNode, imp.key) }
  234. }
  235. }
  236. hlSourceError(gen, entryKey, valueNode, "a route's component must be an imported component class or a path string — this one the analysis cannot resolve to a file, so the app would serve no module for it")
  237. return null
  238. }
  239. // does the file at `key` declare a View? (a component, as opposed to a plain class)
  240. static hasViewMember = (gen, key) => {
  241. for (m of hlMembers(gen, key)) {
  242. if (m.name == 'View') { return true }
  243. }
  244. return false
  245. }
  246. // `key` and every component it composes, once each — the same walk
  247. // `serveComponent` performs, as a function of the graph.
  248. static collectServed = (graph, gen, out, key) => {
  249. if (key == null || graph.files[key] == null) { return null }
  250. for (k of out) { if (k == key) { return null } }
  251. out[] = key
  252. for (imp of graph.files[key].imports) {
  253. if (imp.default != null && imp.key != null && hasViewMember(gen, imp.key)) {
  254. collectServed(graph, gen, out, imp.key)
  255. }
  256. }
  257. return null
  258. }
  259. // Every key this app answers a module for: each route's component and the wrapper
  260. // chain above it, the components those compose, and every package's browser half.
  261. static servedKeys = (graph) => {
  262. let gen = graph.analysisGen
  263. let entryKey = graph.manifest.file
  264. let out = []
  265. if (entryKey != null && graph.files[entryKey] != null) {
  266. let imports = graph.files[entryKey].imports
  267. let routesNode = 0
  268. for (m of hlMembers(gen, entryKey)) {
  269. if (m.name == 'routes') { routesNode = m.node }
  270. }
  271. if (routesNode != 0) {
  272. let arr = hlSyntax(gen, entryKey, routesNode)
  273. if (arr != null && arr.tag == 'array_expression') {
  274. for (e of arr.elements) {
  275. let row = hlSyntax(gen, entryKey, e)
  276. if (row != null && row.tag == 'object_expression') {
  277. for (pe of row.entries) {
  278. let prop = hlSyntax(gen, entryKey, pe)
  279. if (prop != null && prop.tag == 'object_property' && prop.key == 'component') {
  280. let key = routeComponentKey(graph, gen, entryKey, imports, prop.value)
  281. // the wrapper chain above it, deepest last — a page is
  282. // served inside whatever wraps it
  283. let k = key
  284. while (k != null) {
  285. collectServed(graph, gen, out, k)
  286. k = graph.files[k] != null ? graph.files[k].parent : null
  287. }
  288. }
  289. }
  290. }
  291. }
  292. }
  293. }
  294. }
  295. return out
  296. }
  297. // EVERY PACKAGE'S BROWSER HALF, BY ONE RULE — a package whose browser side is
  298. // written in this language ships it as `client.hl`, the compiler writes
  299. // `<halfDir>/<pkg>/client.js` into every importing module, and something has to
  300. // answer that url. The graph is the whole guard: a key is here only because the
  301. // entry's closure reached that package.
  302. //
  303. // SEPARATE FROM `servedKeys` ON PURPOSE: a half gets a ROUTE but is not a module
  304. // url of this app, so it is not in the map `hlJs` is handed. That asymmetry is
  305. // the existing `serveHalf`/`serveComponent` split, stated rather than inherited
  306. // by accident — measured 2026-09-16, when folding the two together made the
  307. // analysis set one key wider than the route walk's.
  308. static packageHalfKeys = (graph) => {
  309. let out = []
  310. for (gk of graph.files.keys()) {
  311. if (gk.startsWith('hl:') && gk.endsWith('/client.hl')) { out[] = gk }
  312. }
  313. return out
  314. }
  315. // The browser realm's NAME, off the graph — the declared realm whose entrypoint
  316. // is this framework's client half. Static, so the compiler can ask it too.
  317. static clientRealmOf = (graph) => {
  318. for (name of graph.realms.keys()) {
  319. if (graph.realms[name].entrypoint == 'hl:web/client.hl') { return name }
  320. }
  321. return null
  322. }
  323. // EVERY MODULE THIS APP SERVES, COMPILED — what a native binary answers `hlJs`
  324. // with (mission 315). The emitter needs the runtime specifier, the browser
  325. // realm and the served-url map; all three are functions of the analysis, so the
  326. // whole map is, and the COMPILER can build it without an instance.
  327. //
  328. // `minify` is NOT: it is an app setting bound at construction, and the analysis
  329. // cannot evaluate a construction. The value used is recorded beside each
  330. // module so a binary asked for a different one refuses at the call instead of
  331. // answering text generated the other way.
  332. // HOW THE APP ASKED FOR ITS MODULES. A build bakes ONE form of every module and
  333. // a binary answering the other form would be different bytes, so the build must
  334. // read the app's own answer — and the app writes it where it configures the
  335. // framework: `new WebFramework(… minify = true …)` in the manifest. The analysis
  336. // carries a construction's named arguments, so this is a READ of the same syntax
  337. // `servedKeys` reads for the routes, not a second declaration.
  338. //
  339. // A COMPUTED `minify` IS NOT READ HERE, and nothing is guessed from it: a build
  340. // cannot evaluate an expression the running app has not reached yet. Such an app
  341. // bakes the unminified form, exactly as every app did before this, and if it
  342. // then asks for the other form at run time the binary says so at the request —
  343. // "this binary carries the module compiled the other way", located, which is
  344. // the refusal that already exists and the one measured on the creator's social
  345. // app (2026-09-17). Writing the literal in the manifest is what removes it.
  346. static manifestMinify = (graph) => {
  347. let gen = graph.analysisGen
  348. let key = graph.manifest.file
  349. if (key == null || graph.files[key] == null) { return false }
  350. for (m of hlMembers(gen, key)) {
  351. let s = hlSyntax(gen, key, m.node)
  352. if (s != null && s.tag == 'new_expression' && s.named != null) {
  353. for (h of s.named) {
  354. let p = hlSyntax(gen, key, h)
  355. if (p != null && p.tag == 'object_property' && p.key == 'minify') {
  356. let v = hlSyntax(gen, key, p.value)
  357. if (v != null && v.tag == 'literal' && v.kind == 'boolean') { return v.text == 'true' }
  358. }
  359. }
  360. }
  361. }
  362. return false
  363. }
  364. static bakeModules = (graph) => {
  365. let realm = clientRealmOf(graph)
  366. let served = servedModulesOf(graph)
  367. let min = manifestMinify(graph)
  368. let out = {}
  369. for (k of servedKeys(graph)) {
  370. if (graph.files[k] != null) {
  371. let p = graph.files[k].path
  372. out[p] = {
  373. text = hlJs(p, min, runtimeUrl, realm, served)
  374. minify = min
  375. runtime = runtimeUrl
  376. realm = realm
  377. }
  378. }
  379. }
  380. // EVERY PACKAGE HALF TOO. The root walk above serves `<halfDir>/<pkg>/client.js`
  381. // for every `hl:<pkg>/client.hl` the graph holds, and a compiled binary answers
  382. // from BAKED text alone — so a bake that skipped them served the page and then
  383. // 404'd on the module the page imports, leaving the client half of the app
  384. // dead with nothing reported (measured on projects/framework, 2026-09-16).
  385. // Same rule, same list, one loop later: what the server routes, the build bakes.
  386. for (gk of graph.files.keys()) {
  387. if (gk.startsWith('hl:') && gk.endsWith('/client.hl') && graph.files[gk] != null) {
  388. let hp = graph.files[gk].path
  389. if (out[hp] == null) {
  390. out[hp] = {
  391. text = hlJs(hp, min, runtimeUrl, realm, served)
  392. minify = min
  393. runtime = runtimeUrl
  394. realm = realm
  395. }
  396. }
  397. }
  398. }
  399. return out
  400. }
  401. // file path → the url this server answers its module at. What `hlJs` is handed,
  402. // and what the compiler bakes against.
  403. static servedModulesOf = (graph) => {
  404. let out = {}
  405. for (k of servedKeys(graph)) {
  406. if (graph.files[k] != null) { out[graph.files[k].path] = '/' + k }
  407. }
  408. return out
  409. }
  410. // ONE PACKAGE HALF'S ROUTE. It is a FUNCTION and not the body of the loop above
  411. // because the url's route closure must hold THIS key: a `let` in a loop body is
  412. // one binding the closures share, so every route built that way would serve the
  413. // last package's half (measured 2026-09-14 — both /__hl/web/client.js and
  414. // /__hl/dnd/client.js answered with hl:web's module). A parameter is a fresh
  415. // binding per call, which is the same reason serveComponent below is a function.
  416. serveHalf(key) {
  417. routes[] = { pattern = halfUrl(key.slice(3, key.length - 10)) framework = true function = (route, req) => { return serveHl('/' + key) } }
  418. return null
  419. }
  420. // a mounted file's module route, and the routes of the components it composes
  421. // (an import with a View), recursively — off the graph, once each
  422. serveComponent(key) {
  423. if (served[key] != null) { return null }
  424. served[key] = true
  425. routes[] = { pattern = moduleUrl(key) framework = true function = (route, req) => { return serveHl(req.path) } }
  426. for (imp of graph.files[key].imports) {
  427. if (imp.default != null && imp.key != null && viewHandle(imp.key) != 0) { serveComponent(imp.key) }
  428. }
  429. return null
  430. }
  431. // THE PROJECT DIRECTORY: this file lives in a package, so the app's directory is read
  432. // off a file that is the app's — the first route component's key against its path
  433. root = projectRoot()
  434. view = new View
  435. css = new Css(minify = minify, view = view)
  436. compiler = new Compile
  437. sessions = new Sessions(dir = sessionDirPath(), maxAge = sessionMaxAge != null ? sessionMaxAge : 1209600, idle = sessionIdle != null ? sessionIdle : 900, sweepEvery = sweepInterval != null ? sweepInterval : 300, cookie = cookieName(), secure = sessionSecure, domain = sessionDomain != null ? sessionDomain : '')
  438. sessions.open()
  439. // THE STYLES FILE IS NAMED AT BOOT, not at the first request for the stylesheet: a value
  440. // that names no file, or a file the graph does not hold, is the app's mistake and the
  441. // app does not start with it (ticket #12)
  442. if (styles != null) {
  443. if (hlTypeName(styles) != 'String' && hlTypeName(styles) != 'Class') { refuseAtSetting('styles', "hl:web: styles is the styles file — the class the app imported (`styles = Styles`) or a path string ('./styles.hl') — and this one is a " + hlTypeName(styles)) }
  444. if (graph.files[keyOf(styles)] == null) { refuseAtSetting('styles', "hl:web: styles names " + keyOf(styles) + ", which is not in the project graph — import it from the app's entry (`import Styles from './styles.hl'`) and write `styles = Styles`") }
  445. }
  446. // the whole tree, nothing excluded: dot directories (the session store) are skipped by
  447. // the walker's own rule, and a custom store inside the tree is the app's choice to pay for
  448. if (watchMode) { __native("eventloop.register", watch(root), "hlFileChanged") }
  449. sheetText = null // the stylesheet, walked once at the first request for it
  450. router = new Router(routes = routes)
  451. trees = {} // key → the View tree, built once
  452. slotAt = {} // key → where its View places its one `slot`
  453. styleNames = {} // key → the rule names of the file's Style, off the syntax
  454. styleRefs = {} // key → [{ tag rule }] the View wrote
  455. styleTags = {} // key → the element names this file's View renders
  456. styleIds = {} // key → the literal `id` values this file's View writes (R3)
  457. staticHolders = {} // key → an instance of a file whose statics another file imports
  458. modules = {} // key → the browser module (JavaScript text), compiled once at boot
  459. servedUrls = null // file path → the url this server answers its module at, for hlJs
  460. runtime = hlJsRuntime() // the runtime library every module imports as "./hl-runtime.js"
  461. peers = {} // connection key → the WsClient, while it is open
  462. peerSession = {} // connection key → its session, from the tab's `hello` (null = anonymous)
  463. peerHost = {} // connection key → the host its upgrade request named (null = none sent)
  464. peerMounts = {} // connection key → the component keys the tab has mounted
  465. // SESSIONS (web/sessions.hl): named by the cookie, at the page request and at the
  466. // socket's handshake alike. Nothing about a session ever reaches page script.
  467. sending = null // the WsClient whose inbound emit is being handled — the peer
  468. // an outward emit reaches, and null under the POST fallback
  469. // THE POST FALLBACK'S PEER IS ITS OWN ANSWER. While a face dispatched over
  470. // `POST /__hl/emit` runs, `posting` is true and every outward emit meant for the
  471. // CALLER is appended here instead of pushed; `inbound` ships the list in the ack.
  472. Boolean posting = false
  473. postEmits = []
  474. postSession = null // the cookie's session of the POST being dispatched
  475. postHost = null // the host of the POST being dispatched
  476. faceHost = null // the host of the carrier whose inbound emit is being dispatched
  477. // ONE PORT, TWO CARRIERS. hl:http1's WebSocket engine answers the Upgrade
  478. // inline on the port this server already bound, so `websocket` and `rest` are
  479. // the same listener — which is why the manifest can declare both for the same
  480. // direction and this file needs no second server.
  481. // EVERY VIEW IS BUILT AT BOOT, not at the first request for its route: a View that
  482. // reads a name its file declares nowhere (checkRead) is the app's mistake, and the
  483. // app does not start with it — the port is bound on the line below this pass.
  484. // THE TWO DERIVATIONS MUST AGREE (mission 315). `served` is built above from the
  485. // EVALUATED route table; `servedKeys(graph)` derives the same set from the
  486. // analysis alone, and the compiler bakes each module's text against the second.
  487. // If they ever disagree the binary would serve modules generated against urls
  488. // this server does not answer, so the disagreement is a refusal, here, at boot.
  489. for (k of servedKeys(graph)) {
  490. if (served[k] == null) {
  491. hlError('hl:web: the analysis says this app serves ' + k + ', the route walk did not — the two derivations of the served set disagree')
  492. }
  493. }
  494. for (k of served.keys()) {
  495. let seen = false
  496. for (a of servedKeys(graph)) { if (a == k) { seen = true } }
  497. if (seen == false) {
  498. let why = ''
  499. for (imp of graph.files[k] != null ? [] : []) { why = why }
  500. hlError('hl:web: the route walk serves ' + k + ', the analysis did not — the two derivations of the served set disagree')
  501. }
  502. }
  503. for (k of served.keys()) { if (viewHandle(k) != 0) { tree(k) } }
  504. // A COMPONENT-REFERENCE CYCLE IS REFUSED HERE (COMPONENTS §12): a View that renders
  505. // itself, directly or through another, is a mount that never ends — it overflowed the
  506. // stack at the first request and the server died of a segfault with nothing said
  507. // (mission 322 row 1).
  508. acyclic = {}
  509. for (k of served.keys()) { if (viewHandle(k) != 0) { referenceCycle(k, []) } }
  510. // …and a page a shell wraps is placed at the shell's slot, inside its body: a page whose
  511. // root is `body` there emitted a second, nested <body> (mission 322 row 5)
  512. for (k of mounted) {
  513. if (graph.files[k] != null && graph.files[k].parent != null && viewHandle(k) != 0 && bodyRooted(k)) {
  514. hlError("hl:web: " + k + " is wrapped by " + graph.files[k].parent + " (its `parent` clause), but its View's root is `body { … }`, the page's body: the shell owns the body, so write the page's root as an element (`main { … }`, `section { … }`)")
  515. }
  516. }
  517. // THE SHEET IS WALKED AT BOOT, so the rules it drops are listed at boot (COMPONENTS §4,
  518. // css.hl's own header): walked at the first request for it, a dead rule was listed only
  519. // once some browser had asked, and a boot log said nothing (mission 322 row 11)
  520. stylesheet()
  521. // EVERY SERVED MODULE IS COMPILED AT BOOT: a free name in a View handler is a located
  522. // compile error (COMPONENTS §6b), and compiled at the first request for the module it
  523. // was a 500 behind a page that had already answered 200 (mission 322 row 14)
  524. for (k of served.keys()) { module(k) }
  525. // THE INTERFACE IS THE OPERATOR'S (ticket #24), on the ladder graph.zig's OperatorBind
  526. // states: the constructor's `host` > HL_HOST (the binary fills it from HOST) > the
  527. // manifest's > 0.0.0.0. Binding the default regardless put an app meant to sit behind
  528. // a proxy on every interface of the machine.
  529. if (host == null) { host = env('HL_HOST') }
  530. if (host == null && cfg != null && cfg.host != null) { host = cfg.host }
  531. if (host == null) { host = '0.0.0.0' }
  532. http = new NativeWebSocketServer(port = port, host = host)
  533. echo 'framework listening on ' + port
  534. module('web/client.hl')
  535. // ---- the browser's modules: compiled IN PROCESS, served from memory --------------
  536. // The JavaScript target is this binary's own function (hlJs), the same one `--js`
  537. // runs from the command line. No child process, no build directory.
  538. //
  539. // THE CLIENT is compiled at boot — every page needs it, and it carries the View
  540. // and the Router with it (one module, three classes). A COMPONENT is compiled ON
  541. // DEMAND, at the request for its own `.hl` url, and kept: a route nobody visits
  542. // costs nothing, and the browser asks for a component's module the first time it
  543. // mounts one.
  544. //
  545. // THE TABLE THE EMITTER WRITES IS DROPPED. `hlJs` puts each file's members,
  546. // handlers and methods — every node of every initializer, with the names it reads —
  547. // into the module, because the old hl:web's browser half reduces that table on every boot.
  548. // This framework asked the same questions at COMPILE time and wrote the answers into
  549. // the component's own statements, so the table is dead weight in the page: 58 KB of
  550. // syntax on the reference app's home page alone. What the framework SERVES is its own
  551. // text, so it leaves that statement out; the language is not asked to change, and
  552. // The old hl:web kept its table untouched.
  553. withoutTables(text) {
  554. // one per FILE the module carries — the client half bundles three classes
  555. let out = text
  556. let at = out.indexOf('hlTablesDef(')
  557. while (at >= 0) {
  558. let end = out.indexOf(');', at)
  559. if (end < 0) { return out }
  560. out = out.slice(0, at) + out.slice(end + 2)
  561. at = out.indexOf('hlTablesDef(')
  562. }
  563. return out
  564. }
  565. // THE APP RUNS ON ONE WEB FRAMEWORK. Which packages are frameworks is READ OFF THE
  566. // GRAPH and not off a list this file keeps: a package the app's entry reached that
  567. // holds a `WebFramework.hl` is one. hl:dnd, hl:http1 and every other package an app
  568. // imports hold none and are untouched by this.
  569. //
  570. // The refusal names the ROW of each import, because the mistake IS an import line —
  571. // a sub-file import (`'hl:web/css'`) counts as much as the package itself, which is
  572. // exactly the case that made it: a lone braced import of a stylesheet helper puts the
  573. // whole package into the graph, and with it a second navigation face.
  574. oneFrameworkOnly() {
  575. let frameworks = {}
  576. for (gk of graph.files.keys()) {
  577. if (gk.startsWith('hl:') && gk.endsWith('/WebFramework.hl')) { frameworks[gk.slice(0, gk.length - 16)] = true }
  578. }
  579. if (frameworks.keys().length < 2) { return null }
  580. // the first row of the app's OWN files that reached each package
  581. let sites = {}
  582. for (k of graph.files.keys()) {
  583. if (!k.startsWith('hl:')) {
  584. for (imp of graph.files[k].imports) {
  585. for (pkg of frameworks.keys()) {
  586. if (sites[pkg] == null && (imp.source == pkg || imp.source.startsWith(pkg + '/'))) {
  587. sites[pkg] = { key = k; node = imp.node; where = k + ':' + imp.line + ':' + imp.col + " imports '" + imp.source + "'" }
  588. }
  589. }
  590. }
  591. }
  592. }
  593. let said = ''
  594. for (pkg of frameworks.keys()) {
  595. if (said != '') { said = said + ', and ' }
  596. if (sites[pkg] == null) { said = said + "'" + pkg + "' (reached through a package, not from a file of the app)" }
  597. else { said = said + sites[pkg].where }
  598. }
  599. let msg = 'this app imports TWO web frameworks: ' + said + '. Both packages load, both answer a page navigation, and the second one answers on a blank instance. An app runs on ONE of them — switch EVERY hl: import in EVERY file of the app to the same package, a sub-file import like \'/css\' included. Switching one line is not switching the app.'
  600. // THE CARET GOES ON ONE OF THE ROWS, and the sentence names them all: two
  601. // import lines are equally the mistake, and a diagnostic can only underline one.
  602. for (pkg of frameworks.keys()) {
  603. if (sites[pkg] != null) { hlSourceError(gen, sites[pkg].key, sites[pkg].node, msg) }
  604. }
  605. hlError(msg)
  606. return null
  607. }
  608. // ONE ANSWERER PER VALUE-FORM EMIT. SPEC: "if more than one class in the destination
  609. // realm declares a non-tap handler for the event, the VALUE form is refused at the emit
  610. // site naming both. As a statement it stays a broadcast and every handler still fires."
  611. // The statement form is what `inbound` does — every face runs. The value form cannot be
  612. // told apart on the wire, so every `x = emit server ev()` the app's files hold is read
  613. // off the analysis here, and one whose event two files answer is refused at its site,
  614. // naming both, before anything is served. Until ticket #20 both faces ran and the first
  615. // answer was taken in silence.
  616. oneAnswererEach() {
  617. let realm = serverRealm()
  618. for (k of graph.files.keys()) {
  619. if (!k.startsWith('hl:')) {
  620. for (e of hlEvents(gen, k).emits) {
  621. if (e.value == true && e.realm == realm) {
  622. let owners = facesOf(e.event)
  623. if (owners.length > 1) {
  624. let named = ''
  625. for (o of owners) { named = named == '' ? o : named + ' and ' + o }
  626. let msg = "hl:web: " + k + ':' + e.line + ':' + e.col + " — this emit waits for the answer of '" + e.event + "', and " + owners.length + " files answer it in the '" + realm + "' realm: " + named + ". A value-form emit has ONE answerer — rename the event in one of them, or emit it as a statement (a broadcast every handler receives)"
  627. hlSourceError(gen, k, e.node, msg)
  628. hlError(msg)
  629. }
  630. }
  631. }
  632. }
  633. }
  634. return null
  635. }
  636. // WHAT THIS SERVER SERVES AS A MODULE OF ITS OWN, by the file's path (mission 314
  637. // rule 0). Handed to `hlJs`, it is what turns `import PostCard from './post_card.hl'`
  638. // into an ES import of `/components/post_card.hl` instead of PostCard's whole class
  639. // text copied into the importing module — EditHistory's class shipped three times on
  640. // the social demo's home page before this. The urls are THIS file's (`moduleUrl`);
  641. // the compiler invents none, and a file this server does not answer for is inlined
  642. // exactly as it always was.
  643. servedModules() {
  644. if (servedUrls == null) {
  645. let out = {}
  646. for (k of served.keys()) { out[pathOf(k)] = moduleUrl(k) }
  647. servedUrls = out
  648. }
  649. return servedUrls
  650. }
  651. // A key the graph holds no file for is answered `null`, and the caller makes
  652. // that a 404: a file the entry's closure never referenced is not part of this
  653. // app, which is what keeps this from being a read of any path a url asks for.
  654. module(key) {
  655. if (modules[key] == null) {
  656. let f = graph.files[key]
  657. if (f == null) { return null }
  658. // THE BROWSER GETS THE BROWSER'S PROJECTION, and nothing else. hlJs's
  659. // fourth argument is the realm the module is produced for: a handler
  660. // written `on server …` is not emitted at all, and a member whose
  661. // initializer reaches a name the browser does not have is declared
  662. // without one, for this server to lay over the instance with the state
  663. // it already ships. The realm NAME comes off the manifest (clientRealm()
  664. // above), never a literal — a project names its own realms.
  665. let text = hlJs(f.path, minify, runtimeUrl, clientRealm(), servedModules())
  666. // AND THE COMPILE STEP'S OWN OUTPUT BESIDE IT (mission 313): a component with a
  667. // View carries, after its class, the paint statements this framework compiled
  668. // from that View — one per bound spot, with its read written in (compile.hl).
  669. // The browser calls them instead of walking the tree at paint time. Text
  670. // produced here and served from memory like the module itself; nothing is
  671. // written to disk and no JavaScript file lives under plugins/.
  672. text = withoutTables(text)
  673. if (viewHandle(key) != 0) { text = text + newline + compiler.module(gen, key, tree(key)) }
  674. modules[key] = text
  675. }
  676. return modules[key]
  677. }
  678. // the file the graph knows under this key — the graph carries every file's own path
  679. pathOf(key) {
  680. let f = graph.files[key]
  681. if (f == null) { hlError('the graph holds no file ' + key + ' — nothing referenced it from the entry') }
  682. return f.path
  683. }
  684. // THE BROWSER'S REALM, off the manifest: the realm whose declared entrypoint is
  685. // this framework's client half. The framework's own two files ARE the two
  686. // realms' entrypoints, so this is a lookup and never a guess.
  687. clientRealm() {
  688. for (name of graph.realms.keys()) {
  689. if (graph.realms[name].entrypoint == 'hl:web/client.hl') { return name }
  690. }
  691. hlError('the manifest declares no realm whose entrypoint is ./client.hl — the browser half of this framework IS a realm, and a page cannot say which realm it is running in until the manifest names it')
  692. }
  693. // THIS realm, off the graph: the declared realm whose entrypoint REACHES this
  694. // file (the app's entry imports it). A face names it, and so does the refusal
  695. // when nothing answers.
  696. serverRealm() {
  697. let mine = graph.files['hl:web/WebFramework.hl'].realms
  698. if (mine.length == 1) { return mine[0] }
  699. hlError('the manifest declares no realm whose entrypoint reaches hl:web/WebFramework.hl — this file cannot dispatch a face without knowing which realm it is')
  700. }
  701. // the app's directory: a route component is the app's file, its key project-relative
  702. projectRoot() {
  703. for (r of routes) {
  704. if (r.component != null) { return rootOf(keyOf(r.component)) }
  705. }
  706. // AN APP OF FUNCTION ROUTES ONLY (ticket #19) has no component to read it off, and
  707. // needs no other: the manifest is the app's file too, and the graph knows its key
  708. if (graph.manifest.file != null && graph.files[graph.manifest.file] != null) { return rootOf(graph.manifest.file) }
  709. hlError('hl:web: the route table names no component and the graph names no manifest file, so the app\'s directory cannot be read off either')
  710. }
  711. // a project-relative key against its absolute path: what is left is the project's directory
  712. rootOf(key) {
  713. let path = pathOf(key)
  714. // A COMPILED BINARY'S PATH IS ITS KEY (mission 318): it carries no
  715. // build directory, so the file's path and its key are the same
  716. // string and the root is the process's own directory. The
  717. // interpreter's path is the script's absolute one, and the key is
  718. // its tail — the root is what stands before it.
  719. if (path == key) { return '.' }
  720. return path.slice(0, path.length - key.length - 1)
  721. }
  722. // THE KEY OF A FILE THE APP NAMED — as the class it imported (`component = SortList`,
  723. // `styles = Styles`: the import is the guarantee the graph reached it) or as a path
  724. // ('./components/home.hl'). A class knows its file (`file(cls)`), and the graph knows
  725. // the file's key.
  726. keyOf(named) {
  727. if (hlTypeName(named) == 'Class') {
  728. let path = file(named)
  729. for (k of graph.files.keys()) {
  730. if (graph.files[k].path == path) { return k }
  731. }
  732. hlError('hl:web: the class at ' + path + ' is not in the project graph — import it from the app\'s entry')
  733. }
  734. // ANYTHING ELSE NAMES NO FILE (ticket #12): `styles = {}` booted and every request
  735. // for the stylesheet was then a NotCallable on this line, with no line of the app's
  736. if (hlTypeName(named) != 'String') { hlError("hl:web: a file the app names — a route's component, or `styles` — is the class it imported or a path string ('./styles.hl'), and this one is a " + hlTypeName(named)) }
  737. if (named.startsWith('./')) { return named.slice(2) }
  738. return named
  739. }
  740. // ---- the View tree: the member's syntax, reflected into plain hybrids ----------
  741. viewHandle(key) {
  742. for (m of hlMembers(gen, key)) {
  743. if (m.name == 'View') { return m.node }
  744. }
  745. return 0
  746. }
  747. tree(key) {
  748. if (trees[key] != null) { return trees[key] }
  749. let handle = viewHandle(key)
  750. if (handle == 0) { hlError('component ' + key + ' declares no View') }
  751. let v = hlSyntax(gen, key, handle)
  752. let nodes = []
  753. for (h of v.entries) {
  754. let n = node(key, h, [], false, [])
  755. if (n != null) { nodes.push(n) }
  756. }
  757. trees[key] = nodes
  758. return nodes
  759. }
  760. // one entry of a hybrid literal → a tree node (null for an attribute: the
  761. // element that owns it reads it)
  762. node(key, handle, path, inFor, rows) {
  763. let n = hlSyntax(gen, key, handle)
  764. if (n == null) { return null }
  765. if (n.tag == 'object_property') {
  766. let v = hlSyntax(gen, key, n.value)
  767. if (v.tag == 'object_expression') {
  768. let childKey = componentKeyOf(key, n.key)
  769. if (childKey != null) { return component(key, n.key, childKey, v, v, path, inFor, rows) }
  770. return element(key, n.key, v, path, inFor, rows)
  771. }
  772. return null
  773. }
  774. if (n.tag == 'literal') { return { k = 'text'; text = n.text; kind = n.kind; } }
  775. if (n.tag == 'identifier') {
  776. // A BARE CAPITALISED REFERENCE IS A COMPOSITION WITH AN EMPTY FILL (§12:
  777. // "`Name { }` is the same reference as bare `Name`"). It is not a read, so it
  778. // is answered before the read rules below refuse the name.
  779. let bareKey = componentKeyOf(key, n.name)
  780. if (bareKey != null) { return component(key, n.name, bareKey, null, n, path, inFor, rows) }
  781. if (n.name == 'slot' || n.name == '$slot') { oneSlot(key, n) }
  782. // AN IMPORTED STATIC IN A VIEW IS FOLDED (the archive did the same at build):
  783. // `h1 { siteTitle }` with `import { siteTitle } from '../store.hl'` is not a
  784. // member of this class, it is a constant of another file — read off that
  785. // file's class once, here, and written into the tree as text
  786. return nameRead(key, n.name, rows, n)
  787. }
  788. if (n.tag == 'view_for') {
  789. // `for (row of list) { … }` — the list is a member (or a row field), the
  790. // body's entries are rendered once per entry with `row` bound to it
  791. let body = hlSyntax(gen, key, n.body)
  792. let children = []
  793. // THE ROW VARIABLE IS IN SCOPE INSIDE THE BODY, and nowhere else: the list is
  794. // read in the scope that stands around the `for`
  795. let list = ref(key, n.list, rows)
  796. let inner = rows.slice(0)
  797. inner.push(n.varName)
  798. for (h of body.entries) {
  799. let c = node(key, h, path, true, inner)
  800. if (c != null) { children.push(c) }
  801. }
  802. // THE SITE, as every element node carries one: the id the module's tables
  803. // file this `for`'s row under, so the browser reads what the list depends
  804. // on from the compiler instead of guessing it off `list.name`.
  805. return { k = 'for'; row = n.varName; list = list; body = children; site = baseName(pathOf(key)) + ':' + n.line + ':' + n.col; }
  806. }
  807. if (n.tag == 'view_if') {
  808. let then = []
  809. let cons = hlSyntax(gen, key, n.consequent)
  810. for (h of cons.entries) {
  811. let c = node(key, h, path, inFor, rows)
  812. if (c != null) { then.push(c) }
  813. }
  814. let other = []
  815. let alt = hlSyntax(gen, key, n.alternate) // null when there is no else
  816. if (alt != null) {
  817. if (alt.tag == 'view_if') {
  818. other.push(node(key, n.alternate, path, inFor, rows))
  819. } else {
  820. for (h of alt.entries) {
  821. let c = node(key, h, path, inFor, rows)
  822. if (c != null) { other.push(c) }
  823. }
  824. }
  825. }
  826. // A BRANCH CONDITION IS A READ (COMPONENTS: a member, a $hole or a loop path).
  827. // Anything else came back null here and the branch rendered nothing, on the
  828. // server and after hydration, with no word said (ticket #17: `if (!flag)`).
  829. let cond = ref(key, n.condition, rows)
  830. if (cond == null) {
  831. let c = hlSyntax(gen, key, n.condition)
  832. hlError("hl:web: " + baseName(pathOf(key)) + ':' + c.line + ':' + c.col + " — a View `if` takes a member, a field path or a `for` row variable as its condition, and this one is an expression: declare it at the root of " + baseName(pathOf(key)) + " (`showIt = !flag`) and write `if (showIt)`")
  833. }
  834. return { k = 'if'; cond = cond; then = then; other = other; site = baseName(pathOf(key)) + ':' + n.line + ':' + n.col; }
  835. }
  836. if (n.tag == 'member_expression') {
  837. // `p.title` — a field path off a member or a row variable
  838. let r = ref(key, handle, rows)
  839. if (r != null) { return r }
  840. }
  841. if (n.tag == 'on_statement') {
  842. // a DOM event handled by the literal that carries it: the browser finds the
  843. // literal in the instance's View by the path of keys and fires the event at it
  844. // THE SITE IS THE HANDLER'S ID: the tables file this handler's write set
  845. // under it, and the browser repaints exactly those members when it runs.
  846. return { k = 'on'; event = n.event; site = baseName(pathOf(key)) + ':' + n.line + ':' + n.col; }
  847. }
  848. return { k = 'other'; tag = n.tag; }
  849. }
  850. // A COMPONENT HAS EXACTLY ONE SLOT (COMPONENTS §2 table, §12): a second one rendered the
  851. // child a second time, with no word said (mission 322 row 4)
  852. oneSlot(key, n) {
  853. let here = baseName(pathOf(key)) + ':' + n.line + ':' + n.col
  854. if (slotAt[key] != null) {
  855. hlError("hl:web: " + here + " — a second `slot` in this View (the first is at " + slotAt[key] + "): a component has exactly one slot, where the page it wraps or the fill it is given renders")
  856. }
  857. slotAt[key] = here
  858. return null
  859. }
  860. isMember(key, name) {
  861. for (m of hlMembers(gen, key)) { if (m.name == name) { return true } }
  862. return false
  863. }
  864. // THE READ OF A BARE NAME IN A VIEW — wherever the name stands. A text child, an
  865. // attribute's value and a reference-site binding are ONE lookup with ONE refusal:
  866. //
  867. // a `for` row variable standing around the read → read at render, from the row
  868. // a member of this file → read at render, from the instance
  869. // a name this file imports by BRACE → a STATIC of another file:
  870. // constant-folded here, at build
  871. // anything else → the located refusal (checkRead)
  872. //
  873. // An ATTRIBUTE used to skip the middle two and record every name as a member read,
  874. // so `a { href = brandHref }` on a braced import asked the instance for a member it
  875. // does not have and the renderer raised error.UndefinedProperty with view.hl's line
  876. // and nothing of the app's — while the same name as a TEXT CHILD rendered fine
  877. // (creator, routger migration W8). A read is a read; where it stands decides
  878. // nothing about what it names.
  879. nameRead(key, name, rows, at) {
  880. if (rows != null && rows.includes(name)) { return { k = 'member'; name = name; } }
  881. if (isMember(key, name)) { return { k = 'member'; name = name; } }
  882. // A BRACED IMPORT FOLDS EVEN WHEN ITS VALUE IS NULL: the name resolved, so the
  883. // read is answered here and never reaches the instance (a null static used to
  884. // fall through to a member read and raise the same UndefinedProperty).
  885. if (importsName(key, name)) {
  886. let folded = importedStatic(key, name)
  887. // THE VALUE AS WELL AS THE TEXT: an attribute and a text child want the text,
  888. // and a reference binding wants the value the author's file declared (§12)
  889. return { k = 'text'; text = folded == null ? '' : '' + folded; kind = 'folded'; value = folded; }
  890. }
  891. checkRead(key, name, rows, at)
  892. return { k = 'member'; name = name; }
  893. }
  894. // A VIEW READS ONLY WHAT ITS FILE DECLARES. A name that is neither a member of
  895. // this class, nor a static it imports by brace, nor a `for` row variable standing
  896. // around this read, is a typo or a missing declaration — refused HERE, at the
  897. // build of the tree (boot), naming the file and the line, instead of a 500 out of
  898. // the renderer with the language's UndefinedProperty and no line of the app's.
  899. checkRead(key, name, rows, n) {
  900. if (rows != null && rows.includes(name)) { return null }
  901. if (isMember(key, name)) { return null }
  902. if (importsName(key, name)) { return null }
  903. let hint = name == 'session' ? "`session = null` to receive the connection's session" : name == 'host' ? "`host = null` to receive the request's host" : "`" + name + " = null`"
  904. hlError("hl:web: " + baseName(pathOf(key)) + ':' + n.line + ':' + n.col + " — the View reads '" + name + "', which this file declares nowhere: declare it at the file's root (" + hint + "), or write the name it was meant to be")
  905. }
  906. // does this file import that name by brace from another file?
  907. importsName(key, name) {
  908. for (imp of graph.files[key].imports) {
  909. if (imp.key != null && imp.names.includes(name)) { return true }
  910. }
  911. return false
  912. }
  913. // the value of a static this file imports by brace from another .hl file, or null —
  914. // read off an instance of that file (a class value takes no computed index, an
  915. // instance does; the file is constructed once, its statics were evaluated at load)
  916. importedStatic(key, name) {
  917. for (imp of graph.files[key].imports) {
  918. if (imp.key != null && imp.names.includes(name)) {
  919. if (staticHolders[imp.key] == null) { staticHolders[imp.key] = construct(imp.key, constructionArgs(imp.key, {}, anonSession(), null)) }
  920. return staticHolders[imp.key][name]
  921. }
  922. }
  923. return null
  924. }
  925. // a READ in the View: a member (`title`), or a field path (`p.title`) whose root
  926. // is a member or a `for` row variable — the client decides which at render time
  927. ref(key, handle, rows) {
  928. let n = hlSyntax(gen, key, handle)
  929. if (n.tag == 'identifier') { checkRead(key, n.name, rows, n) return { k = 'member'; name = n.name; } }
  930. if (n.tag == 'member_expression' && !n.computed) {
  931. let obj = hlSyntax(gen, key, n.object)
  932. let prop = hlSyntax(gen, key, n.property)
  933. if (obj.tag == 'identifier') { checkRead(key, obj.name, rows, obj) return { k = 'field'; name = obj.name; path = [prop.name]; } }
  934. let inner = ref(key, n.object, rows)
  935. if (inner != null && inner.k == 'field') {
  936. let path = inner.path.slice(0)
  937. path.push(prop.name)
  938. return { k = 'field'; name = inner.name; path = path; }
  939. }
  940. }
  941. return null
  942. }
  943. // THE COMPOSED CHILD: an entry named after a class the file imports, whose file
  944. // has a View. `Child { count = count on done(v) { … } }` — the entries are the
  945. // BINDINGS (host values handed to the child's construction as named arguments)
  946. // and the reference-site handlers. The graph says which name is which file.
  947. componentKeyOf(key, name) {
  948. for (imp of graph.files[key].imports) {
  949. if (imp.default == name && imp.key != null && viewHandle(imp.key) != 0) { return imp.key }
  950. }
  951. return null
  952. }
  953. // THE REFERENCE'S SITE IS IN ITS PATH. A path of class names alone made two
  954. // references of one class under one parent (`body/Card` twice) the SAME node key,
  955. // so both collapsed onto one mount and one of the two fills was lost. A reference
  956. // is a construction: each one is its own mount, so each one is its own path.
  957. componentStep(cls, at) {
  958. return cls + '@' + at.line + ':' + at.col
  959. }
  960. // is this entry a `Style.rule` reference? (the `Style` of THIS file, by name)
  961. styleRef(key, e) {
  962. let obj = hlSyntax(gen, key, e.object)
  963. return obj.tag == 'identifier' && obj.name == 'Style'
  964. }
  965. // THE REFERENCE'S POSITIONAL SIGNATURE (§12: "a reference is a construction, so its
  966. // POSITIONAL entries bind to the class's declared properties in declaration order —
  967. // the same rule `new X(a, b)` follows").
  968. //
  969. // MEASURED, not guessed (2026-09-13, this worker): `new X(a, b, …)` fills the class's
  970. // root PROPERTY DECLARATIONS in SOURCE ORDER, one slot each — `on` handlers and
  971. // methods take no slot, and `hlMembers` returns exactly that list in exactly that
  972. // order, so the tree builder reads the signature straight off it. Three of those
  973. // declarations are not properties of the COMPOSITION and are left out here:
  974. //
  975. // `View` and `Style` — what the component renders and paints WITH, not what it is
  976. // constructed from. The language does give them a slot (a
  977. // bare `new` binds them), but §12 says `Card { "text" }` with
  978. // no declared property takes its text as FILL, and a
  979. // component always declares a View: counting it would make
  980. // the positional-literal fill unreachable in every component
  981. // there is.
  982. // `slot` / `$slot` — the late-bound child, not state (§12); and with the fill
  983. // rendered where the child places it the two readings emit
  984. // the same HTML anyway.
  985. // every `static` — a static belongs to the CLASS (§12 build-time constants), so
  986. // a reference cannot seed one for every other reference.
  987. //
  988. // A NAMED assignment does NOT free its slot — measured: `new Q('n1', beta = 'B', 'n2')`
  989. // puts 'n2' in the SECOND declaration and 'B' in beta. It is applied AFTER the
  990. // positional ones and wins, which is why the positional bindings are emitted first
  991. // below. (COMPONENTS.md:711 reads "a property the reference also assigns by name is
  992. // not in the signature"; the interpreter says otherwise and the parenthetical in the
  993. // same line — "named is applied after positional; the name wins, as in `new`" — is
  994. // what this follows.)
  995. positionalSignature(key) {
  996. let out = []
  997. for (m of hlMembers(gen, key)) {
  998. if (!m.isStatic && m.name != 'View' && m.name != 'Style' && m.name != 'slot' && m.name != '$slot') { out.push(m.name) }
  999. }
  1000. return out
  1001. }
  1002. component(key, cls, childKey, v, at, path, inFor, rows) {
  1003. let bindings = []
  1004. let positional = []
  1005. let ons = []
  1006. let fill = []
  1007. let here = path.slice(0)
  1008. here.push(componentStep(cls, at))
  1009. let sig = positionalSignature(childKey)
  1010. let taken = 0
  1011. if (v != null) {
  1012. for (h of v.entries) {
  1013. let e = hlSyntax(gen, key, h)
  1014. if (e.tag == 'object_property') {
  1015. let val = hlSyntax(gen, key, e.value)
  1016. // A BINDING NAMES A DECLARED MEMBER OF THE CHILD (COMPONENTS §12): the value
  1017. // otherwise went into the construction and nowhere, with no word said
  1018. // (mission 322 row 12). An element entry is the fill, not a binding.
  1019. if (val.tag != 'object_expression' && !isMember(childKey, e.key)) {
  1020. hlError("hl:web: " + baseName(pathOf(key)) + ':' + e.line + ':' + e.col + " — " + cls + " has no member '" + e.key + "' to bind: " + baseName(pathOf(childKey)) + " declares " + positionalSignature(childKey).join(', '))
  1021. }
  1022. // A LITERAL ON A REFERENCE KEEPS ITS TYPE (§12 "typed, not stringified"):
  1023. // the binding carries the literal's KIND, and `view.refValue` turns the
  1024. // pair into the value the child's member is given. Written as text alone,
  1025. // `Level2 { n = 1 }` handed the child the String "1" and `n * 97` refused
  1026. // (measured 2026-09-14, demo-nested wall 3).
  1027. if (val.tag == 'literal') { bindings.push({ name = e.key; text = val.text; kind = val.kind; }) }
  1028. else if (val.tag == 'identifier') {
  1029. // a binding is a read at the reference site, so it is the same lookup
  1030. let r = nameRead(key, val.name, rows, val)
  1031. // …and a folded static crosses as its VALUE, for the same reason
  1032. if (r.k == 'text') { bindings.push({ name = e.key; text = r.text; kind = 'folded'; value = r.value; }) }
  1033. else { bindings.push({ name = e.key; member = val.name; }) }
  1034. }
  1035. else if (val.tag == 'member_expression') { bindings.push({ name = e.key; ref = ref(key, e.value, rows); }) }
  1036. else {
  1037. // `Card { span { … } }` — an element entry is not a binding, it is the FILL
  1038. let c = node(key, h, here, inFor, rows)
  1039. // …and an EXPRESSION is neither, so it is refused rather than dropped:
  1040. // the same rule an attribute follows (mission 312 wall 4).
  1041. if (c == null) {
  1042. hlError("hl:web: " + baseName(pathOf(key)) + ':' + val.line + ':' + val.col + " — '" + e.key + "' is bound to an expression on the reference " + cls + ", and a binding is a literal, a member or a field path: declare the expression at the root of " + baseName(pathOf(key)) + " (`" + e.key + "Value = …`) and write `" + e.key + " = " + e.key + "Value`")
  1043. }
  1044. fill.push(c)
  1045. }
  1046. } else if (e.tag == 'on_statement') {
  1047. // A HANDLER WRITTEN ON A REFERENCE IS THE HOST'S, bound on the child's ROOT
  1048. // element. The reference is a literal of THIS file's View, so its behaviour
  1049. // is minted from THIS file's site and its body reaches this file through the
  1050. // owner rule — the same path an element's own handler takes. The child's own
  1051. // handler on that element is not replaced: both are listeners on one element
  1052. // and both run, the child's first, because it was bound when the child's tree
  1053. // was claimed. (Until 2026-09-13 the event was collected here and dropped: a
  1054. // reference with `on submit` never bound, and a form submitted natively.)
  1055. // …AND ITS SITE, because the tables file this handler's write set
  1056. // under it: the browser repaints exactly what it wrote (mission 309).
  1057. ons.push({ event = e.event; site = baseName(pathOf(key)) + ':' + e.line + ':' + e.col; })
  1058. } else if (e.tag == 'member_expression' && !e.computed && styleRef(key, e)) {
  1059. // a `Style.rule` written on a reference: the reference renders no element
  1060. // of its own, so there is nothing for the class to land on (§12)
  1061. hlError("hl:web: " + baseName(pathOf(key)) + ':' + at.line + ':' + at.col + " — a Style rule cannot be written on the component reference '" + cls + "': a reference renders no element of its own. Write the rule on an element inside " + baseName(pathOf(childKey)) + ", or name a '#" + cls + "' local rule in this file")
  1062. } else {
  1063. // AN ORDERED ENTRY. A literal or a read is the next declared property's
  1064. // value while the signature has room — `Button { "SD" }` is `Button
  1065. // { label = "SD" }` — and everything else, plus everything that EXCEEDS
  1066. // the signature, is the FILL: a fragment of THIS file's View, with its
  1067. // reads, its handlers and its `for` rows (§12 "host content").
  1068. let c = node(key, h, here, inFor, rows)
  1069. if (c != null) {
  1070. if (taken < sig.length && (c.k == 'text' || c.k == 'member' || c.k == 'field')) {
  1071. // the same rule as the named form above: a literal keeps its kind and a
  1072. // folded static crosses as its value
  1073. if (c.k == 'text') { positional.push({ name = sig[taken]; text = c.text; kind = c.kind; value = c.value; }) }
  1074. else if (c.k == 'member') { positional.push({ name = sig[taken]; member = c.name; }) }
  1075. else { positional.push({ name = sig[taken]; ref = c; }) }
  1076. taken = taken + 1
  1077. } else { fill.push(c) }
  1078. }
  1079. }
  1080. }
  1081. }
  1082. // THE NAMED ASSIGNMENTS COME LAST, so the name wins over the positional entry that
  1083. // landed on the same property (measured: that is what `new` does)
  1084. let allBindings = []
  1085. for (b of positional) { allBindings.push(b) }
  1086. for (b of bindings) { allBindings.push(b) }
  1087. bindings = allBindings
  1088. // FILLING WHAT PLACES NO SLOT IS A LOCATED ERROR (§12): the child would drop the
  1089. // content silently, which is exactly the bug this rule was written for.
  1090. if (fill.length > 0 && !isMember(childKey, 'slot')) {
  1091. hlError("hl:web: " + baseName(pathOf(key)) + ':' + at.line + ':' + at.col + " — " + cls + " is filled here, but " + baseName(pathOf(childKey)) + " declares no 'slot' to fill: declare `slot = null` there and place `slot` in its View, or write the content outside the reference")
  1092. }
  1093. let classes = []
  1094. if (styleRules(key).includes('#' + cls) && !viewIds(key).includes(cls)) {
  1095. classes.push(view.localClass(key))
  1096. for (t of rootTags(childKey)) { noteRef(key, t, '#' + cls) }
  1097. }
  1098. // THE NODE NAMES ITS CHILD, it does not carry it. What the browser needs to build
  1099. // a child the server never mounted — the module url and the View tree — stands
  1100. // ONCE in the seed's blueprint table, keyed by this `key` (blueprintsIn below).
  1101. // The node used to carry that pair itself, and only when it stood inside a `for`:
  1102. // a child deeper in a minted child's own tree therefore had nothing to build from
  1103. // and was silently missing after a push (a comment card's like button and its
  1104. // edit history, creator's social app, 2026-09-13).
  1105. // THE REFERENCE'S OWN SITE: `file:line:col` of the reference literal, the id the
  1106. // JavaScript target mints ITS behaviour class under — what a handler written on
  1107. // the reference is built from, in the host's frame (client.hl bindRefOns)
  1108. let site = baseName(pathOf(key)) + ':' + at.line + ':' + at.col
  1109. return { k = 'component'; cls = cls; key = childKey; path = here; bindings = bindings; ons = ons; classes = classes; row = inFor; fill = fill; site = site; }
  1110. }
  1111. // ---- Style, the View side (§4, as the archive did it) --------------------------
  1112. // The tree builder writes the class on the element and REPORTS what it wrote
  1113. // (`styleRefs[key]` = [{ tag rule }]) and the element names it rendered
  1114. // (`styleTags[key]`); web/style.hl spells the selectors from those. Three ways
  1115. // a rule reaches an element: `Style.rule` written on it (class = the tag-prefix
  1116. // strip of the rule name), a `#rule` local of this file matching the element by
  1117. // NAME (class = this file's four-character class; `#Child` matches a composed
  1118. // child's root elements), and — decided in style.hl, no class — a rule named
  1119. // like an element of this file's own View.
  1120. styleRules(key) {
  1121. if (styleNames[key] != null) { return styleNames[key] }
  1122. let names = []
  1123. for (m of hlMembers(gen, key)) {
  1124. if (m.name == 'Style') {
  1125. let v = hlSyntax(gen, key, m.node)
  1126. for (h of v.entries) {
  1127. let e = hlSyntax(gen, key, h)
  1128. if (e.tag == 'object_property' && !names.includes(e.key)) { names.push(e.key) }
  1129. }
  1130. }
  1131. }
  1132. styleNames[key] = names
  1133. return names
  1134. }
  1135. noteRef(key, tag, rule) {
  1136. if (styleRefs[key] == null) { styleRefs[key] = [] }
  1137. for (r of styleRefs[key]) { if (r.tag == tag && r.rule == rule) { return null } }
  1138. styleRefs[key].push({ tag = tag; rule = rule; })
  1139. return null
  1140. }
  1141. noteTag(key, tag) {
  1142. if (styleTags[key] == null) { styleTags[key] = [] }
  1143. if (!styleTags[key].includes(tag)) { styleTags[key].push(tag) }
  1144. return null
  1145. }
  1146. // `#name` IS AN ID WHEN THE VIEW HAS ONE (creator's ruling, ticket #51 / R3): a
  1147. // `#name` rule targets the element with `id = 'name'` if this file's View writes
  1148. // that id, and is the file-local class of the elements named `name` otherwise. The
  1149. // ids are read off the View's SYNTAX before the tree is built, because an element
  1150. // named `name` may stand before the element that carries the id. Only a literal id
  1151. // counts — a member-bound one has no value until render. A component reference's
  1152. // own `id = …` is a binding for the child, not an element of this View; its fill is.
  1153. viewIds(key) {
  1154. if (styleIds[key] != null) { return styleIds[key] }
  1155. let out = []
  1156. let handle = viewHandle(key)
  1157. if (handle != 0) { idsIn(key, hlSyntax(gen, key, handle), false, &out) }
  1158. styleIds[key] = out
  1159. return out
  1160. }
  1161. idsIn(key, block, isRef, &out) {
  1162. if (block == null || block.entries == null) { return null }
  1163. for (h of block.entries) {
  1164. let n = hlSyntax(gen, key, h)
  1165. if (n != null && n.tag == 'object_property') {
  1166. let v = hlSyntax(gen, key, n.value)
  1167. if (v.tag == 'object_expression') { idsIn(key, v, componentKeyOf(key, n.key) != null, &out) }
  1168. else if (!isRef && n.key == 'id' && v.tag == 'literal' && !out.includes(v.text)) { out.push(v.text) }
  1169. } else if (n != null && n.tag == 'view_for') {
  1170. idsIn(key, hlSyntax(gen, key, n.body), false, &out)
  1171. } else if (n != null && n.tag == 'view_if') {
  1172. idsIn(key, hlSyntax(gen, key, n.consequent), false, &out)
  1173. let alt = hlSyntax(gen, key, n.alternate)
  1174. if (alt != null && alt.tag == 'view_if') { idsIn(key, { entries = [n.alternate] }, false, &out) }
  1175. else { idsIn(key, alt, false, &out) }
  1176. }
  1177. }
  1178. return null
  1179. }
  1180. // the root element tags of a component's tree (a composed child under `#Child`)
  1181. rootTags(key) {
  1182. let out = []
  1183. for (n of tree(key)) { if (n.k == 'el' && !out.includes(n.tag)) { out.push(n.tag) } }
  1184. return out
  1185. }
  1186. baseName(p) {
  1187. let i = p.lastIndexOf('/')
  1188. return i < 0 ? p : p.slice(i + 1)
  1189. }
  1190. element(key, tag, v, path, inFor, rows) {
  1191. let attrs = []
  1192. let children = []
  1193. let here = path.slice(0)
  1194. here.push(tag)
  1195. let domTag = view.isTag(tag) ? tag : view.kebab(tag)
  1196. // `body { }` IS THE PAGE'S BODY, and only as the View's root (COMPONENTS §6): below
  1197. // the root it rendered a second, nested <body> no browser keeps (mission 322 row 5)
  1198. if (domTag == 'body' && path.length > 0) {
  1199. hlError("hl:web: " + baseName(pathOf(key)) + ':' + v.line + ':' + v.col + " — a `body` element below the View's root: `body { … }` is the page's body and stands only as a View's root")
  1200. }
  1201. noteTag(key, tag)
  1202. let classes = []
  1203. for (h of v.entries) {
  1204. let e = hlSyntax(gen, key, h)
  1205. let styled = false
  1206. if (e.tag == 'member_expression' && !e.computed) {
  1207. let obj = hlSyntax(gen, key, e.object)
  1208. let prop = hlSyntax(gen, key, e.property)
  1209. if (obj.tag == 'identifier' && obj.name == 'Style') {
  1210. if (!styleRules(key).includes(prop.name)) { hlError("no Style rule named '" + prop.name + "' in " + key) }
  1211. if (prop.name.startsWith('#')) { hlError("'" + prop.name + "' in " + key + " is a LOCAL rule and applies by NAMING the element, not by being referenced — every '" + prop.name.slice(1) + "' this file's View renders already carries it; delete the reference") }
  1212. let cls = view.kebab(view.className(prop.name, tag)) // the class is kebab-case (creator, 2026-09-12)
  1213. if (!classes.includes(cls)) { classes.push(cls) }
  1214. noteRef(key, tag, prop.name)
  1215. styled = true
  1216. }
  1217. }
  1218. if (styled) {
  1219. // a Style reference is not a child
  1220. } else if (e.tag == 'object_property') {
  1221. let val = hlSyntax(gen, key, e.value)
  1222. if (val.tag == 'literal' && view.isBoolAttr(e.key) && val.kind == 'boolean') {
  1223. // A BOOLEAN ATTRIBUTE IS ITS PRESENCE (ticket #33): `disabled = true` is the
  1224. // attribute, `disabled = false` its absence — decided here, once, for every
  1225. // writer of the markup
  1226. if (val.text == 'true') { attrs.push({ name = e.key; text = ''; }) }
  1227. } else if (val.tag == 'literal') {
  1228. attrs.push({ name = e.key; text = val.text; })
  1229. } else if (val.tag == 'identifier') {
  1230. // THE SAME LOOKUP A TEXT CHILD USES: a folded static becomes the
  1231. // attribute's literal text, a member or a row stays a read
  1232. let r = nameRead(key, val.name, rows, val)
  1233. if (r.k == 'text' && view.isBoolAttr(e.key)) { if (view.boolOn(r.value)) { attrs.push({ name = e.key; text = ''; }) } }
  1234. else if (r.k == 'text') { attrs.push({ name = e.key; text = r.text; }) }
  1235. else { attrs.push({ name = e.key; member = val.name; }) }
  1236. } else if (val.tag == 'member_expression') {
  1237. attrs.push({ name = e.key; ref = ref(key, e.value, rows); })
  1238. } else {
  1239. let c = node(key, h, here, inFor, rows)
  1240. // AN EXPRESSION AS AN ATTRIBUTE VALUE IS REFUSED HERE, AND WAS DROPPED IN
  1241. // SILENCE (mission 312 wall 4). `span { id = 'L3t' + n }` rendered no `id`
  1242. // at all and said nothing — the element came back with empty attributes and
  1243. // the author had no line to look at. A View attribute takes a literal, a
  1244. // member or a field path; anything else is a member of this file, declared
  1245. // at its root. The nested-element form (`div { span { … } }`) is the entry
  1246. // whose value is a hybrid, and it is what `node` answers for above.
  1247. if (c == null) {
  1248. hlError("hl:web: " + baseName(pathOf(key)) + ':' + val.line + ':' + val.col + " — the attribute '" + e.key + "' is given an expression, and a View attribute is a literal, a member or a field path: declare the expression at the root of " + baseName(pathOf(key)) + " (`" + e.key + "Value = …`) and write `" + e.key + " = " + e.key + "Value`")
  1249. }
  1250. children.push(c)
  1251. }
  1252. } else {
  1253. let c = node(key, h, here, inFor, rows)
  1254. if (c != null) { children.push(c) }
  1255. }
  1256. }
  1257. // a `#tag` LOCAL rule of this file matches this element by name: the file's class
  1258. // — unless the View writes `id = 'tag'` somewhere, then the rule is that id's (R3)
  1259. if (styleRules(key).includes('#' + tag) && !viewIds(key).includes(tag)) {
  1260. let cls = view.localClass(key)
  1261. if (!classes.includes(cls)) { classes.push(cls) }
  1262. noteRef(key, domTag, '#' + tag)
  1263. }
  1264. if (classes.length > 0) {
  1265. let joined = ''
  1266. for (c of classes) { joined = joined == '' ? c : joined + ' ' + c }
  1267. let merged = false
  1268. for (a of attrs) {
  1269. if (a.name == 'class' && a.text != null) { a.text = a.text + ' ' + joined merged = true }
  1270. }
  1271. if (!merged) { attrs.push({ name = 'class'; text = joined; }) }
  1272. }
  1273. // (§6 sibling entries: inside the literal `tag` would name the entry just written,
  1274. // so the source key is taken before the literal is built)
  1275. let srcKey = tag
  1276. // THE SITE: `file:line:col` of the literal, the id the JavaScript target mints the
  1277. // literal's class under — the browser builds this element's literal from it
  1278. // (hlLiteralNew), so a row created after a list changed has a literal too
  1279. let site = baseName(pathOf(key)) + ':' + v.line + ':' + v.col
  1280. return { k = 'el'; tag = domTag; key = srcKey; path = here; attrs = attrs; children = view.implied(domTag, children, here, site); site = site; }
  1281. }
  1282. // ---- an instance and its state -------------------------------------------------
  1283. // THE ROUTE'S GROUPS ARE THE CONSTRUCTION'S NAMED ARGUMENTS (creator ruling
  1284. // 2026-09-12): `:id` is a parameter of the class like any other, set and pinned
  1285. // before its root runs, so `_post = db.fetch(id)` on line 1 sees it. Nothing is
  1286. // laid over an instance afterwards, and nothing is spelled with a sigil.
  1287. construct(key, params) {
  1288. return hlLoad(pathOf(key), params)
  1289. }
  1290. // THE VIEW IS COMPILER OUTPUT, NOT A VALUE — and so, for a render, is the Style.
  1291. // This half reads the View's SYNTAX (`tree()` below, through hlSyntax) and never its
  1292. // value; `state()` ships neither to the browser; the stylesheet is folded into class
  1293. // names at build, by a walk that constructs its OWN instance and reads `Style` there.
  1294. // Evaluating them for a render therefore built a behaviour literal per element per
  1295. // `for` row, per request, for nobody: half the construction time of a 2000-row
  1296. // component, measured 2026-09-14.
  1297. //
  1298. // The framework says so in the one way the language already offers: it PINS them.
  1299. // An explicitly passed construction value outranks every computed default (the
  1300. // creator's rule of 2026-07-26), and a pinned member does not compute its default at
  1301. // all. No language rule about a View was needed and none was added.
  1302. renderArgs(key, args) {
  1303. let out = args
  1304. if (declares(key, 'View')) { out.View = null }
  1305. if (declares(key, 'Style')) { out.Style = null }
  1306. return out
  1307. }
  1308. // does the file-class declare a member of this name?
  1309. declares(key, name) {
  1310. for (m of hlMembers(gen, key)) { if (m.name == name && !m.isStatic) { return true } }
  1311. return false
  1312. }
  1313. // THE FRAMEWORK'S OWN CONSTRUCTIONS — the sheet walk (a component is constructed to
  1314. // read its `Style`), an imported static's holder — happen outside any request, and a
  1315. // class's root runs at construction: `me = session.user` there would meet a null,
  1316. // which a real render never has (every request carries a session, minted if the
  1317. // browser brought none). So they are handed an ANONYMOUS session — nobody logged in,
  1318. // never saved — and the root takes its logged-out branch (the creator, 2026-09-13:
  1319. // /__hl/app.css was a 500 while the page it styles was 200).
  1320. anon = null
  1321. anonSession() {
  1322. if (anon == null) { anon = new Session(created = now(), seen = now()) }
  1323. return anon
  1324. }
  1325. // THE SESSION AT CONSTRUCTION. A component that declares a member `session` is
  1326. // constructed with the connection's session as that named argument — the same
  1327. // instance a server face gets as its trailing argument, and nothing ambient: a
  1328. // shell reads `me = session.user` at its root on the server, and a reload renders
  1329. // logged in (measured by the creator 2026-09-13: the shell rendered logged-out
  1330. // after a reload while the faces posted as alice). The browser gets only what a
  1331. // page may see of it (`state()` below), never the id the cookie carries.
  1332. constructionArgs(key, params, s, host) {
  1333. let wantsSession = declares(key, 'session')
  1334. let wantsHost = declares(key, 'host')
  1335. if (!wantsSession && !wantsHost) { return params }
  1336. let args = {}
  1337. for (k of params.keys()) { args[k] = params[k] }
  1338. if (wantsSession) { args.session = s }
  1339. if (wantsHost) { args.host = host }
  1340. return args
  1341. }
  1342. // THE HOST AT CONSTRUCTION (ticket #74), by the same rule: a component that declares
  1343. // a member `host` is constructed with the host the request named — the `Host`
  1344. // header without its port — so an app answering one address per subdomain renders
  1345. // the right page on the server. A navigation takes it from ITS carrier: the socket's
  1346. // upgrade request, or the POST's own. Null where the request named none, and for the
  1347. // framework's own constructions above, which answer no request.
  1348. hostOf(header) {
  1349. if (header == null || header == '') { return null }
  1350. let h = header.trim()
  1351. // an IPv6 literal keeps its brackets: `[::1]:8080` is `[::1]`
  1352. if (h.startsWith('[')) {
  1353. let close = h.indexOf(']')
  1354. return close < 0 ? h : h.slice(0, close + 1)
  1355. }
  1356. let colon = h.indexOf(':')
  1357. return colon < 0 ? h : h.slice(0, colon)
  1358. }
  1359. state(key, page) {
  1360. let out = {}
  1361. for (m of hlMembers(gen, key)) {
  1362. if (m.name != 'View' && m.name != 'Style' && !m.isStatic) {
  1363. // A FUNCTION MEMBER IS NOT STATE. JSON has no form for it, and shipping
  1364. // it as null would PIN null over the browser's own declaration (a
  1365. // construction argument outranks the default) — so it is left out, and
  1366. // the browser's instance evaluates `hideBadge = () => { … }` itself.
  1367. if (m.name == 'session') {
  1368. // the session's PUBLIC part: who is logged in — the id stays on the server
  1369. out.session = page.session != null ? { user = page.session.user } : null
  1370. } else if (hlTypeName(page[m.name]) != 'Function') { out[m.name] = page[m.name] }
  1371. }
  1372. }
  1373. return out
  1374. }
  1375. // everything the browser needs about one mounted component — and its children:
  1376. // every component node of its tree is a mount of its own, constructed with the
  1377. // bindings evaluated against THIS instance, keyed by the node's path
  1378. mount(key, params, s, host) {
  1379. let page = construct(key, renderArgs(key, constructionArgs(key, params, s, host)))
  1380. let m = { key = key; params = params; view = tree(key); state = state(key, page); page = page; kids = {}; session = s; host = host; }
  1381. mountKids(&m, m.view, {}, '')
  1382. return m
  1383. }
  1384. // `rowKey` is what tells one row's child from the next one's: the index of every
  1385. // enclosing `for` iteration, appended to the component node's path. A component
  1386. // outside every `for` has the empty rowKey and keeps the key it always had.
  1387. mountKids(&m, nodes, rows, rowKey) {
  1388. for (n of nodes) {
  1389. if (n.k == 'component') {
  1390. let args = {}
  1391. for (b of n.bindings) {
  1392. if (b.text != null) { args[b.name] = view.refValue(b) }
  1393. else if (b.member != null) { args[b.name] = view.value({ k = 'member'; name = b.member; }, m.page, rows) }
  1394. else if (b.ref != null) { args[b.name] = view.value(b.ref, m.page, rows) }
  1395. }
  1396. m.kids[view.kidKey(n.path) + rowKey] = mount(n.key, args, m.session, m.host)
  1397. // THE FILL IS THIS FILE'S FRAGMENT, not the child's: a component standing
  1398. // inside it is a child of THIS mount, evaluated in THIS scope
  1399. if (n.fill != null) { mountKids(&m, n.fill, rows, rowKey) }
  1400. } else if (n.k == 'el') {
  1401. mountKids(&m, n.children, rows, rowKey)
  1402. } else if (n.k == 'if') {
  1403. mountKids(&m, n.then, rows, rowKey)
  1404. mountKids(&m, n.other, rows, rowKey)
  1405. } else if (n.k == 'for') {
  1406. // ONE MOUNT PER ROW: the row is the scope the bindings are evaluated in,
  1407. // so the child is constructed once per entry, with that entry's values
  1408. let entries = view.value(n.list, m.page, rows)
  1409. if (entries != null) {
  1410. let ri = 0
  1411. for (entry of entries) {
  1412. let inner = rows + {}
  1413. inner[n.row] = entry
  1414. // THE ROW'S KEY, not its index: a child of a row travels with the
  1415. // record it was mounted for (view.rowKey, the one rule the server's
  1416. // HTML walk and the browser's region read too)
  1417. mountKids(&m, n.body, inner, rowKey + '#' + view.rowKey(entry, ri))
  1418. ri = ri + 1
  1419. }
  1420. }
  1421. }
  1422. }
  1423. return null
  1424. }
  1425. // the route component and, if it declares one, its wrapping shell
  1426. mounts(m, s, host) {
  1427. let key = keyOf(m.route.component)
  1428. let out = { page = mount(key, m.params, s, host); shell = null; }
  1429. let w = hlFile(gen, key).wrapper
  1430. if (w != null) { out.shell = mount(w, m.params, s, host) }
  1431. return out
  1432. }
  1433. // A MOUNT IS NOT ITS TREE. What the browser needs per mount is what makes THIS one
  1434. // this one — its key, the route's params, the state the server evaluated, its
  1435. // children — while the View tree belongs to the COMPONENT and stands once in the
  1436. // seed's blueprint table under the same key. Shipping it per mount put the same tree
  1437. // in the payload as many times as the page mounted that component.
  1438. ship(mount) {
  1439. let kids = {}
  1440. for (k of mount.kids.keys()) { kids[k] = ship(mount.kids[k]) }
  1441. return { key = mount.key; params = shipParams(mount.params); state = mount.state; module = moduleUrl(mount.key); kids = kids; }
  1442. }
  1443. \* A FUNCTION-VALUED BINDING IS NOT SHIPPED — the same rule `state` keeps for a
  1444. function MEMBER, for the same reason. A routine a host hands a child
  1445. (`PostForm { onPosted = ... }`) has no JSON form, and shipping it as null
  1446. would PIN null over the browser's own binding: a construction argument
  1447. outranks what the client evaluates. It is left out, and the browser binds it
  1448. from the host instance when it builds the mirror, exactly as it does for a
  1449. member. Until mission 320 it rode the wire as null because `JSON.stringify`
  1450. invented that null; the language refuses to now, which is what made the lie
  1451. visible. *\
  1452. shipParams(params) {
  1453. let out = {}
  1454. for (k of params.keys()) {
  1455. if (hlTypeName(params[k]) != 'Function') { out[k] = params[k] }
  1456. }
  1457. return out
  1458. }
  1459. // A COMPONENT IS SERVED AT ITS OWN `.hl` URL. A browser executes a module by
  1460. // its MIME type, never by its extension, so `text/javascript` on
  1461. // `/components/home.hl` is a module — and the url the page loads is then the
  1462. // path the author wrote in the route table, with nothing renamed in between.
  1463. // The route that answers it is the APP's (a `function` route in the manifest),
  1464. // because where an app serves its modules from is the app's to say.
  1465. moduleUrl(key) {
  1466. return '/' + key
  1467. }
  1468. // A PACKAGE'S BROWSER HALF IS SERVED BESIDE THE RUNTIME. Not a convention this
  1469. // file invents: the compiler derives the specifier it writes into every importing
  1470. // module from the runtime url it is handed, and for the same reason that url is
  1471. // one member here — one url means ONE ES-module instance, and two would be two
  1472. // copies of the package's browser state. This is that derivation, on this side.
  1473. halfUrl(pkg) {
  1474. return halfDir + '/' + pkg + '/client.js'
  1475. }
  1476. // The module of the `.hl` url a request names — the on-demand half of the
  1477. // transpile. The app's function route hands the path here.
  1478. serveHl(urlPath) {
  1479. let js = module(urlPath.slice(1))
  1480. if (js == null) { return notFound(urlPath) }
  1481. return new Response(js, { headers = { 'Content-Type' = 'text/javascript; charset=utf-8' } })
  1482. }
  1483. // ---- the stylesheet -------------------------------------------------------------
  1484. // A `Style { }` member is an ORDINARY member of the file's class, so its value
  1485. // is what a construction produces — the statics it reads already folded, the
  1486. // `+` rule merge already the language's. That is the whole input; style.hl does
  1487. // the walk. The project's styles file comes first so its global rules stand
  1488. // under every component's, then every component this server can mount, in the
  1489. // order `served` holds them (the pages and the wrapper chain above them — the
  1490. // same set that has a browser module).
  1491. stylesheet() {
  1492. if (sheetText == null) {
  1493. css.gen = gen
  1494. css.view = view
  1495. let files = []
  1496. if (styles != null) {
  1497. // THE STYLES FILE: root members, its inherits first. It has to be in the
  1498. // graph (the app imports it) — its rules are read in authored order off
  1499. // the syntax and their values off the constructed instance.
  1500. let tokenKeys = [] // the token files already walked
  1501. for (sk of inheritChain(keyOf(styles))) {
  1502. if (graph.files[sk] == null) { hlError("the styles file " + sk + " is not in the project graph — import it from the app's entry so its rules can be read in authored order") }
  1503. // A TOKEN FILE the styles file member-imports (`import { dark } from './tokens.hl'`,
  1504. // there `static dark = var('#123')`) is walked BEFORE it, so its var() tokens are
  1505. // named and written into :root first. Before ticket #39 part 2 only the chain's own
  1506. // non-static members were named, and every imported token was an unknown id: a 500
  1507. // "a css variable is used before any styles file declared it". (The architect's
  1508. // patch from mission 021 of the tickets app, taken upstream.)
  1509. for (tk of tokenFilesOf(sk)) {
  1510. if (!tokenKeys.includes(tk)) {
  1511. tokenKeys.push(tk)
  1512. files.push({ key = tk; cls = graph.files[tk].class; rules = tokenRules(tk); aliases = []; refs = []; tags = []; hasView = false; isStyles = true; })
  1513. }
  1514. }
  1515. let inst = hlLoad(pathOf(sk), {})
  1516. let rules = []
  1517. let own = {}
  1518. for (m of hlMembers(gen, sk)) {
  1519. if (!m.isStatic && m.node != 0) {
  1520. let v = hlSyntax(gen, sk, m.node)
  1521. // a merged rule (`a = b + { … }`) is a block too (css.entriesOf)
  1522. rules.push({ key = m.name; node = m.node; value = inst[m.name]; isBlock = v != null && (v.tag == 'object_expression' || (v.tag == 'binary_expression' && css.isHybridValue(inst[m.name]))); })
  1523. own[m.name] = true
  1524. }
  1525. }
  1526. // EACH FILE OF THE CHAIN IS ITS OWN INSTANCE, so a token it INHERITS is a second
  1527. // CssVar with its own id: `body { color = dark }` in the child held an id the
  1528. // walker never named (ticket #39 part 1). The inherited tokens are registered
  1529. // under their names as ALIASES — named, never written into :root a second time.
  1530. let aliases = []
  1531. for (pk of inheritChain(sk)) {
  1532. if (pk != sk) {
  1533. for (m of hlMembers(gen, pk)) {
  1534. if (!m.isStatic && own[m.name] == null && css.isVar(inst[m.name])) { aliases.push({ key = m.name; value = inst[m.name]; }) }
  1535. }
  1536. }
  1537. }
  1538. // the styles file's ROOT is collected here and not by css.entriesOf, so the
  1539. // same refusal has to be raised here: two `@media` root members are one
  1540. // name with one value and would emit the first block empty
  1541. css.guardMedia(sk, rules)
  1542. files.push({ key = sk; cls = graph.files[sk].class; rules = rules; aliases = aliases; refs = []; tags = []; hasView = false; isStyles = true; })
  1543. }
  1544. }
  1545. for (k of served.keys()) {
  1546. if (hasStyle(k)) {
  1547. tree(k) // the refs and tags are collected while the tree is built
  1548. let inst = construct(k, constructionArgs(k, {}, anonSession(), null))
  1549. files.push({ key = k; cls = graph.files[k].class; rules = css.entriesOf(k, styleNode(k), inst.Style); refs = styleRefs[k] != null ? styleRefs[k] : []; tags = styleTags[k] != null ? styleTags[k] : []; ids = viewIds(k); hasView = viewHandle(k) != 0; isStyles = false; })
  1550. }
  1551. }
  1552. sheetText = css.sheet(files)
  1553. }
  1554. return sheetText
  1555. }
  1556. // the .hl files a styles file member-imports that hold var() tokens (a package like
  1557. // hl:web/css is skipped), in import order
  1558. tokenFilesOf(key) {
  1559. let out = []
  1560. for (imp of graph.files[key].imports) {
  1561. if (imp.key != null && !imp.key.startsWith('hl:') && imp.names != null && imp.names.length > 0 && graph.files[imp.key] != null) {
  1562. if (!out.includes(imp.key) && tokenRules(imp.key).length > 0) { out.push(imp.key) }
  1563. }
  1564. }
  1565. return out
  1566. }
  1567. // a token file's var() tokens as styles-file root rules, in authored order — EVERY static
  1568. // token of the file, not only the imported names (a token may name another). The values
  1569. // are the file's statics, evaluated once per process: the same CssVar instances, with the
  1570. // same ids, the importer holds.
  1571. tokenRules(key) {
  1572. let rules = []
  1573. let inst = null
  1574. for (m of hlMembers(gen, key)) {
  1575. if (m.isStatic && m.node != 0) {
  1576. if (inst == null) { inst = hlLoad(pathOf(key), {}) }
  1577. let v = inst[m.name]
  1578. if (css.isVar(v)) { rules.push({ key = m.name; node = m.node; value = v; isBlock = false; }) }
  1579. }
  1580. }
  1581. return rules
  1582. }
  1583. // a file and the files it inherits, parents first (the cascade order of a styles chain)
  1584. inheritChain(key) {
  1585. let out = []
  1586. let f = graph.files[key]
  1587. if (f != null && f.inherits != null) {
  1588. for (p of f.inherits) { for (k of inheritChain(p)) { if (!out.includes(k)) { out.push(k) } } }
  1589. }
  1590. out.push(key)
  1591. return out
  1592. }
  1593. styleNode(key) {
  1594. for (m of hlMembers(gen, key)) { if (m.name == 'Style') { return m.node } }
  1595. return 0
  1596. }
  1597. hasStyle(key) {
  1598. for (m of hlMembers(gen, key)) {
  1599. if (m.name == 'Style') { return true }
  1600. }
  1601. return false
  1602. }
  1603. // ---- rendering ------------------------------------------------------------------
  1604. // ONE MOUNT'S HTML, FROM ITS COMPILED RENDER. The old hl:web walked the View tree per request —
  1605. // a branch per node, an attribute string built again, the indentation of every line
  1606. // decided again. Every one of those is a fact about the View, so this framework
  1607. // compiles the component once into a list of steps (constant text, and a read with its
  1608. // own closure) and a request concatenates them (compile.hl `serverSteps`). The bytes
  1609. // are the same bytes: the structure the walk would have produced is the structure the
  1610. // steps carry.
  1611. renderOf(mount, nodes, indent, slot) {
  1612. let inst = mount.page
  1613. let rows = {}
  1614. let ctx = { slot = slot; kids = mount.kids; }
  1615. return compiler.runSteps(compiler.serverSteps(mount.key, nodes, indent, null, slot != null), &inst, &rows, &ctx)
  1616. }
  1617. document(ms, s) {
  1618. // the page inside the shell: readable by default (elements one per line, the
  1619. // child page at the slot's own depth), one line when `minify` is set
  1620. let nl = minify ? '' : newline
  1621. let indent = minify ? null : tab
  1622. let inner = null
  1623. let body = null
  1624. if (ms.shell != null) {
  1625. // `body { … }` as the shell's View root IS the page body (COMPONENTS §6):
  1626. // render its children into the document's body instead of nesting one
  1627. let root = ms.shell.view
  1628. if (root.length == 1 && root[0].k == 'el' && root[0].tag == 'body') { root = root[0].children }
  1629. // the child page is rendered at the depth the slot stands: the shell is
  1630. // rendered first with a marker, then the marker's own indentation is read
  1631. let marker = '<!--hl:slot-->'
  1632. let shellHtml = renderOf(ms.shell, root, indent, marker)
  1633. let slotIndent = null
  1634. if (!minify) {
  1635. let at = shellHtml.indexOf(marker)
  1636. let lineStart = shellHtml.lastIndexOf(newline, at) + 1
  1637. slotIndent = shellHtml.slice(lineStart, at)
  1638. inner = renderOf(ms.page, ms.page.view, slotIndent, null)
  1639. // block() begins each element with a newline at its indent; the marker
  1640. // already sits on such a line, so the first newline and indent are dropped
  1641. body = shellHtml.replace(newline + slotIndent + marker, inner)
  1642. } else {
  1643. inner = renderOf(ms.page, ms.page.view, null, null)
  1644. body = shellHtml.replace(marker, inner)
  1645. }
  1646. } else {
  1647. body = renderOf(ms.page, ms.page.view, indent, null)
  1648. }
  1649. let seed = seedFor(ms)
  1650. // THE PAGE'S MODULE SCRIPT: two imports and one construction. No component
  1651. // is imported here — the seed carries each mount's module url and the Client
  1652. // loads it (hlLoad → dynamic import) when it mounts it, so a page ships the
  1653. // code for the route it IS and not for every route the app has.
  1654. //
  1655. // AND IT SAYS WHICH REALM THIS IS. A compiled module never sets its own
  1656. // realm (the language has no manifest inside a module compile), and the
  1657. // runtime's default is the neutral 'server' — under which a browser's
  1658. // `emit server x()` would dispatch LOCALLY instead of crossing and an
  1659. // `on client y()` would be skipped. The name is not invented here: it is the
  1660. // realm the manifest declares web/client.hl as the entrypoint OF.
  1661. let t = minify ? '' : tab
  1662. let head = nl + t + '<meta charset="utf-8">'
  1663. head = head + nl + t + '<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">'
  1664. // THE HEAD METADATA (creator, 2026-09-13). One rule for all of it: the PAGE's
  1665. // member wins, the APP's manifest setting is the default, and nothing is
  1666. // invented — a value is written out as the app wrote it, escaped.
  1667. // `__title` / `appTitle` → <title> and og:title
  1668. // `__description` / `appDescription` → meta description and og:description
  1669. // `__image` / `appImage` → og:image (the url AS GIVEN — a relative
  1670. // one stays relative; the host is not guessed)
  1671. // `__favicon` / `appFavicon` → <link rel="icon" href="…">
  1672. // `__meta` / `meta` → a list of hybrids, one <meta> each with
  1673. // exactly the attributes named:
  1674. // `{ name = 'robots' content = 'noindex' }`,
  1675. // `{ property = 'og:type' content = 'article' }`.
  1676. // The app's list first, the page's appended after;
  1677. // the attributes of one entry stand in the
  1678. // hybrid's key order.
  1679. // ALL FIVE ARE PAGE MEMBERS, so they are also SITES of the page mount in the
  1680. // browser (client.hl): a handler's write, an inbound push or a navigation
  1681. // repaints the head element the same way a member repaints an element. What
  1682. // this writes is the first answer; the browser keeps it in step from there.
  1683. let h = headOf(ms)
  1684. if (h.title != null) {
  1685. head = head + nl + t + '<title>' + view.escape(h.title) + '</title>'
  1686. head = head + nl + t + '<meta property="og:title" content="' + view.escape(h.title) + '">'
  1687. }
  1688. if (h.description != null) {
  1689. head = head + nl + t + '<meta name="description" content="' + view.escape(h.description) + '">'
  1690. head = head + nl + t + '<meta property="og:description" content="' + view.escape(h.description) + '">'
  1691. }
  1692. if (h.image != null) { head = head + nl + t + '<meta property="og:image" content="' + view.escape(h.image) + '">' }
  1693. if (h.favicon != null) { head = head + nl + t + '<link rel="icon" href="' + view.escape(h.favicon) + '">' }
  1694. for (m of h.meta) {
  1695. let attrs = ''
  1696. for (k of m.keys()) { attrs = attrs + ' ' + k + '="' + view.escape(m[k]) + '"' }
  1697. head = head + nl + t + '<meta' + attrs + '>'
  1698. }
  1699. // THE PAGE'S OWN META TAGS ARE MARKED. They are the page mount's state in the
  1700. // browser (client.hl paints `__meta` as a group), so the client has to be able
  1701. // to replace exactly these and leave the app's manifest tags above them alone.
  1702. for (m of h.pageMeta) {
  1703. let attrs = ''
  1704. for (k of m.keys()) { attrs = attrs + ' ' + k + '="' + view.escape(m[k]) + '"' }
  1705. head = head + nl + t + '<meta' + attrs + ' data-hl-page-meta>'
  1706. }
  1707. head = head + nl + t + '<link rel="stylesheet" href="' + styleUrl + '">'
  1708. head = head + nl + t + '<script type="module">'
  1709. head = head + 'import { hlNew, hlSetRealm } from "' + runtimeUrl + '";'
  1710. head = head + 'import { Client } from "' + clientUrl + '";'
  1711. head = head + 'hlSetRealm(' + scriptLiteral(clientRealm()) + ');'
  1712. head = head + 'window.__hl = await hlNew(Client, [' + scriptLiteral(JSON.stringify(seed)) + ']);'
  1713. head = head + '</script>' + nl
  1714. return '<!doctype html>' + nl + '<html>' + nl + '<head>' + head + '</head>' + nl + '<body>' + body + nl + '</body>' + nl + '</html>' + nl
  1715. }
  1716. // A VALUE WRITTEN INTO THE INLINE <script> (ticket #34). The seed carries the app's
  1717. // members, user text among them, and JSON.stringify leaves `<` alone: a text holding
  1718. // `</script>` ended the script early and the rest of it was parsed as HTML (stored XSS
  1719. // in every app). `\u003c` is the same character to the JavaScript string literal and
  1720. // nothing to the HTML parser; U+2028/2029 are escaped the same way for older engines.
  1721. // A `<` is never part of a JSON escape, so the replacement cannot split one.
  1722. scriptLiteral(v) {
  1723. let text = JSON.stringify(v).replaceAll('<', '\u003c')
  1724. text = text.replaceAll(JSON.parse('"\u2028"'), '\u2028')
  1725. return text.replaceAll(JSON.parse('"\u2029"'), '\u2029')
  1726. }
  1727. // ---- THE BOUNDARY ---------------------------------------------------------------
  1728. // The frames are the LANGUAGE's (SPEC "The boundary wire protocol"), not this
  1729. // framework's: every edge module of every realm speaks exactly these, and what
  1730. // differs per edge is only the carrier. This one carries two.
  1731. //
  1732. // peer → realm { t:"emit", i, event, payload } { t:"ping" }
  1733. // realm → peer { t:"ack", i, ok, value } { t:"emit", event, payload } { t:"pong" }
  1734. //
  1735. // A crossing emit does NOT come in through here on its way out: the language
  1736. // announces it on the realm broadcast bus and the tap below turns it into the
  1737. // outward frame. That is what makes this file an edge module rather than a
  1738. // place the language knows about.
  1739. // A SAVE. Re-analyse; if the graph reads the same (a store write, an asset, a
  1740. // comment) nothing happens; otherwise the graph, the trees, the modules and the
  1741. // sheet are dropped and every open tab is told to reload — the page it fetches is
  1742. // rendered from the new analysis.
  1743. on hlFileChanged(ev) {
  1744. if (!watchMode) { return null }
  1745. // only a source file: a store write, an asset or an editor's temp file is not a
  1746. // change to the program (the graph's file listing cannot tell a View's text
  1747. // changed, so the event's name decides, not a comparison of analyses)
  1748. if (!ev.name.endsWith('.hl')) { return null }
  1749. // one save arrives as a burst of events (measured: three to four); one re-analysis
  1750. // and one reload per burst
  1751. if (now() - lastChange < 500) { return null }
  1752. lastChange = now()
  1753. let fresh = hlProject()
  1754. graph = fresh
  1755. gen = graph.analysisGen
  1756. trees = {}
  1757. slotAt = {}
  1758. modules = {}
  1759. sheetText = null
  1760. styleNames = {}
  1761. styleRefs = {}
  1762. styleTags = {}
  1763. styleIds = {}
  1764. console.log('framework: ' + ev.name + ' changed — re-analysed (generation ' + gen + '), tabs reloading')
  1765. for (k of peers.keys()) {
  1766. let c = peers[k]
  1767. if (c != null) { c.send(JSON.stringify({ t = 'build' })) }
  1768. }
  1769. return null
  1770. }
  1771. on http.connect(client) {
  1772. peers['c' + client.id] = client
  1773. // THE SESSION IS NAMED AT THE HANDSHAKE (the archive's design, hybrilior's
  1774. // before it): the upgrade is an HTTP request and carries the cookie, the
  1775. // connection carries it here, and page script never sees the id. An id nobody
  1776. // knows, or none, is an anonymous connection.
  1777. peerSession['c' + client.id] = sessions.resolve(client.cookie)
  1778. // and the host it was dialled at, which a navigation over it renders for (ticket #74)
  1779. peerHost['c' + client.id] = hostOf(client.host)
  1780. }
  1781. on http.close(client) {
  1782. peers['c' + client.id] = null
  1783. peerSession['c' + client.id] = null
  1784. peerHost['c' + client.id] = null
  1785. peerMounts['c' + client.id] = null
  1786. }
  1787. on http.message(client, text) {
  1788. let frame = JSON.parse(text)
  1789. if (frame.t == 'ping') { client.send(JSON.stringify({ t = 'pong' })) return null }
  1790. if (frame.t == 'emit') { client.send(inbound(client, frame)) return null }
  1791. // `sub` / `unsub` are the subscription set, and subscriptions belong to the
  1792. // next framework (SPEC says the peer owns the set and resends it on every
  1793. // open). Acked so a peer that sends one is not left waiting.
  1794. if (frame.t == 'sub' || frame.t == 'unsub') { client.send(JSON.stringify({ t = 'ack'; i = frame.i; ok = true; })) return null }
  1795. // `hello`: what the tab has mounted; `mounts`: the same after a navigation.
  1796. // That is what fan-out reads beside the session the handshake named.
  1797. if (frame.t == 'hello' || frame.t == 'mounts') {
  1798. peerMounts['c' + client.id] = frame.mounts
  1799. client.send(JSON.stringify({ t = 'ack'; i = frame.i; ok = true; }))
  1800. return null
  1801. }
  1802. console.warn('framework: unknown frame kind', frame.t)
  1803. return null
  1804. }
  1805. // ONE INBOUND EMIT, dispatched and acked. `ok` is the whole truth for a durable
  1806. // queue — an entry leaves it on the ack, never on a value — so a face that
  1807. // raised nothing acks true with no `value` key, byte-identical to the ack a
  1808. // statement-form emit always got.
  1809. inbound(client, frame) {
  1810. let faces = facesOf(frame.event)
  1811. let ack = { t = 'ack'; i = frame.i; ok = true; }
  1812. if (faces.length == 0) {
  1813. // The far realm cannot see what this one declares, so this is the ONLY
  1814. // place the absence is visible. A value-form emit with no answerer is
  1815. // the located error SPEC describes, and it is located HERE, at the
  1816. // handler that does not exist.
  1817. ack.ok = false
  1818. ack.error = "no class in the '" + serverRealm() + "' realm declares a handler for '" + frame.event + "' — a value-form emit needs an ANSWERER, and this event has none"
  1819. return JSON.stringify(ack)
  1820. }
  1821. // A PEER NEVER FILLS THE SESSION (ticket #16, SPEC "A face's `session` is the
  1822. // server's"). A face whose last parameter is `session` gets the connection's session
  1823. // in that slot; the peer's arguments fill the slots before it. A frame with more
  1824. // arguments than those slots would have put the peer's own object where the session
  1825. // goes, one extra argument and the face trusted it, so it is refused, located at
  1826. // the face, before anything runs.
  1827. for (key of faces) {
  1828. let h = faceOf(key, frame.event)
  1829. if (takesSession(h) && frame.payload.length > h.params.length - 1) {
  1830. ack.ok = false
  1831. ack.error = "hl:web: " + key + ':' + h.line + ':' + h.col + " — '" + frame.event + "' takes " + (h.params.length - 1) + " argument(s) and the session, and this emit sent " + frame.payload.length + ": the `session` parameter is filled by the server, never by the peer"
  1832. postSession = null
  1833. postHost = null
  1834. return JSON.stringify(ack)
  1835. }
  1836. }
  1837. // THE EMITTING CONNECTION is what an outward emit raised inside a face
  1838. // reaches (SPEC: scoped to the emitting connection, sent BEFORE the ack).
  1839. sending = client
  1840. posting = client == null
  1841. postEmits = []
  1842. let value = null
  1843. // THE SESSION IS A FACE'S TRAILING ARGUMENT: `on server create(text, session)`.
  1844. // A face on a blank instance has its arguments and its imports; the session is
  1845. // handed to it like the rest, never read from an ambient name. Where it comes
  1846. // from is the carrier's: the handshake's session over the socket, the cookie's
  1847. // of the request over the POST (the route above put it in `postSession`).
  1848. let s = client != null ? peerSession['c' + client.id] : postSession
  1849. faceHost = client != null ? peerHost['c' + client.id] : postHost
  1850. if (s != null) { sessions.touch(s) }
  1851. // `&args`: a call argument is a copy, an INSTANCE inside it included (measured
  1852. // 2026-09-12: a session handed through a plain argument was a copy, and the
  1853. // face's login wrote the copy); the reference form hands the list itself.
  1854. // ONE LIST PER FACE: the session lands in each face's own `session` slot, the
  1855. // arguments the peer left out read null (a face without one: appended, as ever).
  1856. for (key of faces) {
  1857. let h = faceOf(key, frame.event)
  1858. let args = frame.payload.slice(0)
  1859. if (takesSession(h)) {
  1860. while (args.length < h.params.length - 1) { args.push(null) }
  1861. }
  1862. args.push(s)
  1863. let answer = dispatch(key, frame.event, &args)
  1864. if (value == null) { value = answer }
  1865. }
  1866. sessions.save(s) // whatever a face wrote on it is on disk now
  1867. sessions.maybeSweep(liveSessionIds())
  1868. sending = null
  1869. if (value != null) { ack.value = value }
  1870. // the frames this face raised for a caller that has no connection (above)
  1871. if (posting && postEmits.length > 0) { ack.emits = postEmits }
  1872. posting = false
  1873. postEmits = []
  1874. postSession = null
  1875. postHost = null
  1876. faceHost = null
  1877. return JSON.stringify(ack)
  1878. }
  1879. // THE SERVER FACES of an event: every file the graph holds that declares
  1880. // `on <server realm> <event>()` unscoped. The graph is the only thing that
  1881. // knows — a framework never scans a directory and never reads a file to find
  1882. // out what it declares.
  1883. facesOf(event) {
  1884. let out = []
  1885. for (key of graph.files.keys()) {
  1886. for (h of graph.files[key].handlers) {
  1887. if (h.event == event && h.realm == serverRealm() && h.scope == null) {
  1888. if (!out.includes(key)) { out.push(key) }
  1889. }
  1890. }
  1891. }
  1892. return out
  1893. }
  1894. // the server face of `event` in this file, as the analysis reads it (`params` is on
  1895. // this entry; the graph's handler rows do not carry it)
  1896. faceOf(key, event) {
  1897. for (h of hlEvents(gen, key).handles) {
  1898. if (h.event == event && h.realm == serverRealm() && h.scope == null) { return h }
  1899. }
  1900. return null
  1901. }
  1902. // does this face take the session? Its LAST parameter is named `session`.
  1903. takesSession(h) {
  1904. return h != null && h.params != null && h.params.length > 0 && h.params[h.params.length - 1] == 'session'
  1905. }
  1906. // ONE FACE. A face is a FUNCTION over its arguments, its imports, its statics
  1907. // and the session — so the target is the CLASS and the language dispatches on a
  1908. // blank instance of it: no construction, no member initializer, nothing of the
  1909. // file evaluated. This framework's OWN faces are the exception, and not an
  1910. // exception to that rule: `web/server.hl` is not a component, it is the running
  1911. // edge module, and its face reads the routes and the graph it was constructed

Only the first lines are shown.

Branches

Latest commits

  • 4a2d7125initial commitmre