tracker
All repositories: gitoria
15.9 KB
# hl:web — the web framework`hl:web` serves an app's pages from the server and keeps them live in the browser. It is writtenin Hybriel: `WebFramework.hl` is the server half, `client.hl` the browser half, and `compile.hl`turns each component's View into that component's own code. The model behind it (components,Views, realms, the boundary) is in [COMPONENTS.md](../../COMPONENTS.md). This page lists the rulesan app is held to. Each rule is a located refusal at boot unless it says otherwise.## Starting an app```hybrielimport WebFramework from 'hl:web'import Home from './components/home.hl'import Styles from './styles.hl'routes = [ { pattern = "/" component = Home } ]server = new WebFramework(routes = routes, styles = Styles, port = 8080)```Settings may be passed as named arguments. Most of them may instead be members of the file thatruns the `new`:| setting | default | meaning ||---|---|---|| `routes` | `[]` | the route table (below); pass it as an argument || `styles` | none | the app's styles file: the imported class, or a path string. It must be in the project graph || `port` | 8080 | the port to listen on || `host` | `HL_HOST`/`HOST`, else 0.0.0.0 | the interface to bind || `audience` | `{}` | per outward event, who receives it (see Events) || `minify` | false | one-line HTML and compact modules || `watchMode` | true | a saved `.hl` file re-analyses the app and reloads every open tab || `sessionDir` | `<app>/.sessions` | where sessions are stored; `false` keeps them in memory || `sessionMaxAge`, `sessionIdle`, `sweepInterval` | 14 days, 15 min, 5 min | the session clocks, in seconds || `sessionCookie` | `hlsid` | the cookie's name: letters, digits, `-` and `_` || `sessionSecure` | false | set the cookie's `Secure` flag (for an app behind https) || `appTitle`, `appDescription`, `appImage`, `appFavicon`, `meta` | none | the head every page gets unless it declares its own || `links` | none | a list of hybrids, one `<link>` each in every page's head (`{ rel = 'preconnect' href = '…' }`) || `appIcons`, `appShortName`, `appThemeColor`, `appBackgroundColor`, `appTouchIcon`, `appManifest` | none | make the app installable: a web app manifest, linked from every head (see Offline and installable) || `offline` | none | the pages kept offline: a service worker, their state in IndexedDB, a queue for their emits || `pingEvery`, `pongWithin` | 25, 10 | the dead-socket clock, in seconds: the browser pings every `pingEvery`, and a pong missing after `pongWithin` closes the socket and reconnects || `uploadMax` | 1 GiB | the largest file an emit may carry, in bytes (see Files in an emit) |An app runs on one web framework. If its files reach two packages that hold a `WebFramework.hl`(for example an old vendored copy beside this one), it is refused at boot, naming the import rowof each.## RoutesEach entry has a `pattern` (`/posts/:id`, `/assets/*`) and one kind:| kind | answers with ||---|---|| `component = Home` or `"./components/home.hl"` | the page, rendered on the server; the component must be imported by the app, and must declare a View || `function = (route, req) => { … }` | what the function returns (a Hybrid becomes JSON); `req.session` is the cookie's session, or null || `direct = "text"` | that text || `file = "./x"` | one file || `directory = "./assets"` | the files under a directory |A named group (`:id`) reaches the component as a construction argument of the same name: declare`id = null` at its root. A named wildcard does the same with the rest of the path:`/code/*path` gives `path = 'src/lib/util.hl'` for `/code/src/lib/util.hl`, on the first load andon a navigation. A bare `*` has no name a member could take; it is what a `directory` routeserves from.The request itself reaches a component the same way, and only one that declares the member(COMPONENTS.md): `session = null` gets the session, `host = null` the host without its port,`headers = null` the request's headers as a hash with lowercase names —`headers['accept-language']`. On a client navigation they are the headers of the request thenavigation crossed on, so they are still the browser's own: the socket's upgrade request, or the`POST /__hl/emit` under the fallback. `cookie`, `authorization` and `proxy-authorization` are leftout, because a member is state and state reaches page script.## ComponentsA component is a `.hl` file with a `View`. Its root members are its state, and are constructed onthe server for each request.**A View attribute is a literal, a member or a field path.** `href = "/"`, `href = url` and`href = p.url` are allowed. `href = '/posts/' + id` is refused at its line: declare`postUrl = '/posts/' + id` at the file's root and write `href = postUrl`. The same holds for avalue bound on a component reference (`Card { title = t }`) and for the condition of an `if` in aView, which may also be a `for` row variable.**An attribute name that is not an identifier is quoted.** `"aria-hidden" = "true"` and`"data-page" = it.target` render as written and bind like any attribute, in a `for` row too. AcamelCase name is not rewritten (`ariaHidden` renders as `ariaHidden`). `data-page = it.target`unquoted is refused at its line, naming the quoted form.**A bound attribute in a `for` row follows the row** on every update of the list: a new list, a`push`, an `upsert` and a write into a row's record (`items[0].title = …`) repaint it, as theyrepaint the row's text and class.**The tree carries the elements the HTML parser implies.** `table { tr { td { … } } }` is renderedas `table > tbody > tr > td`, because that is the tree the browser builds from the HTML, and thepage is continued from the tree the browser built. The pairs are listed in `view.hl`(`impliedElements`): `tr`, `td` and `th` under `table` get a `tbody`, `td` and `th` under `tbody`,`thead` or `tfoot` get a `tr`, and `col` gets a `colgroup`. Consecutive children that need the samecontainer share one.The other rules:- A View reads only names the file declares. A name it declares nowhere is refused.- `parent './main.hl'` wraps a page in a shell. The shell declares `slot = null` and places`slot` in its View exactly once. A component that is filled (`Card { p { … } }`) must declare aslot too.- `body { … }` is allowed only as the root of a View, and a page wrapped by a parent cannot haveone: the shell owns the body.- A View cannot render itself, directly or through other components.- A Style rule cannot be written on a component reference. Write it on an element inside thatcomponent.- A page's head comes from its members `__title`, `__description`, `__image`, `__favicon` and`__meta`, and falls back to the app's `appTitle`, `appDescription`, `appImage`, `appFavicon` and`meta`. A handler that writes one of them updates the document.## Events`on name(…)` handlers and `emit` statements cross between the browser and the server over aWebSocket on the app's own port. `POST /__hl/emit` is the fallback for a peer without a socket.A handler that takes `session` as its last parameter gets the connection's session from theserver, never from the peer.An outward event reaches the tab that caused it. To send it to other connections, give it an`audience` entry: a function of the event's arguments and the receiving connection's session thatanswers true or false.A value-form emit (`answer = emit server ask()`) waits for one answer, so it has one answerer.If two files in the target realm answer the event, it is refused.**A write anywhere repaints what it wrote** (tickets #98, #99), from the compiler's writesets, never by watching the members:- A listener repaints what its handler writes when the handler is done. A handler stillwaiting after the task that started it (a network answer, `new Promise(…)`) has itswrite set painted then too, so what it wrote before the wait shows while it waits.- A function made inside a component — an `XMLHttpRequest`'s `onload` or `onprogress`, atimer, a Promise's executor, a `then` — is compiled so that every return of it isannounced with the site it was made at. What it writes (a member, a push into one, afield inside one, and what the methods it calls and the events it emits write) isrepainted after the task that ran it. A function that writes no member repaints nothing.- A repaint the listener already made in the same task is not made twice.**After a face that takes `session`** (tickets #121, #122), the server re-derives themembers that read `session` for the tabs that mounted the component, with the page's ownroute params, and ships them in the ack (`sync`); the browser takes them as writes.- A member that reads `session` and calls an imported class, directly or through a localmethod or static, is computed on the server only and never re-derived in the browser.- Such a member is already up to date when the handler continues, so a client handler thatappends the face's returned row to a list derived from `session` shows it twice. Assignfrom what the sync brought, or leave the list to the sync.- A member derived from `session` that needs a server-only resource (a database) must reachit through an imported class or a static of one; a plain copy (`me = session.user.id`) isderived everywhere.### Files in an emitA browser `File` (an input's `ev.target.files[0]`, a dropped file) or a list of them(`ev.target.files`) may be an argument of an `emit server …`. It never rides the socket:before the emit goes out, the client half sends each file to `/__hl/upload` as its own HTTPrequests, with the page's session cookie, in parts of 1 MiB. The face gets the file in itsplace as `{ bytes, name, type, size }` (`bytes` a `Bytes`), or a list of those for a list:```hybrielinput { type = 'file' on change(ev) { emit send(ev) } }on send(ev) { saved = emit server saveFile(ev.target.files[0]) }on server saveFile(file) {writeFile('uploads/' + file.name, file.bytes)return file.size}```- **Progress.** While a file is sent, every mounted component that declares`on client uploadProgress(p)` gets `p = { event, index, name, sent, size }`: the emit'sevent, the file's position among the emit's files, its name, the bytes sent so far andits size.- **A dropped connection.** A part that did not come back is asked for again: the clientwaits (250 ms, doubling up to 4 s), asks the server how much of the file it has, and goeson from there. After 8 drops in a row it gives up.- **A failed upload ends the emit.** A file the server refuses (bigger than `uploadMax`, anupload of another session) or a connection that stays down ends the emit with that errorinstead of sending it — except a queueable emit in an offline app, which is queued with itsfiles when the connection is what failed (COMPONENTS §10): it gets the failed ack a failing face sends(`{ t: 'ack', i, ok: false, error }`), so a value-form emit answers null and the error islogged. The face does not run.- An upload belongs to the session that started it, and one emit takes it. An upload thatnothing takes within an hour is dropped. The parts are held in the server's memory untilthe emit takes them.## Offline and installableThe full model is [COMPONENTS.md §10](../../COMPONENTS.md). An app writes no JavaScript for anyof it.```hybrielappTitle = "Hybriel Notes"appIcons = [ { src = "/static/icon-192.png" sizes = "192x192" } { src = "/static/icon-512.png" sizes = "512x512" } ]offline = [ NoteList, NoteEditor ]```- `appIcons` (or `appManifest`, or `offline`) serves `/__hl/manifest.webmanifest` and links it,with an apple-touch-icon, from every page's head. `appManifest`'s entries are written over thegenerated manifest.- `offline` lists pages: route components, as classes or path strings. Anything else is refusedat its line. The framework generates the service worker and registers it. The shell and thelisted pages' documents are cached, and a navigation is network-first. What the server lastsaid about a listed page is kept in IndexedDB and shown over an older cached document. Astatement-form `emit server …` from a listed page that does not reach the server is queued andreplayed in order when the socket reopens. Each queued emit carries a stable id, and theserver runs it once: a replay whose ack was lost is answered again without running the handler(the last 200 ids per session are kept). A socket that goes silent without closing is noticedby the ping and reopened. A queued emit keeps its files: IndexedDB holds them with the entry,and they go up on the replay under the emit's id, once (COMPONENTS §10). A page thatdeclares `__hlQueued = 0` /`__hlQueue = []` shows the queue. A page that declares `__hlOnline = true` has it follow the browser'sconnection (`navigator.onLine`, and the online/offline events), so a View can say "you are offline".- `offlineFallback = Start` (a route component, class or path string, answered by a route with noparameter; needs `offline`) names a DATA-FREE page: the worker keeps its document once, fetched withoutcredentials, and shows it for any page it has nothing for while the network is down, instead of "Unavailableoffline". Not set: nothing changes. A page (kept or the fallback) that declares `__hlDataFree = true` is keptthe same way — its document is the anonymous one, and the browser keeps neither its state nor a queue for it.- Kept state is per component for a member no route parameter feeds (the same list at every url ofthe page), per url for the rest. A param page that was never loaded as a document is answered offlinewith a kept document of the same component, booted with the url's parameters.- What is kept belongs to the last user who was logged in. An expired or fresh anonymous sessionchanges nothing: that user's queue is held, and replays when the same user logs in again (the serverrefuses a replay made for another user). Another user logging in forgets the copy (state, queue and theworker's documents); an entry from before users were kept is dropped unsent. Aface that logs the session out forgets the copy by itself (the ack says whose session it is now);an app with its own logout calls `forgetOffline()` from `hl:web`.- In any app, an emit in flight when its socket closes is sent again with its id over the POSTfallback or the next socket, and answered there (ticket #107). A page being left(`beforeunload`, `pagehide`) reopens nothing and posts nothing.- `import { notify, notifyAt, notifyIn } from 'hl:web'` shows a notification now, or at a time while the pageis open. A closed app cannot be woken at a set time without a server push.## What it serves| url | what ||---|---|| `/<file key>` (e.g. `/components/home.hl`) | a component's browser module || `/__hl/hl-runtime.js` | the runtime every module imports || `/__hl/web/client.js` | this package's browser half (other packages' halves are at `/__hl/<name>/client.js`) || `/__hl/app.css` | the one stylesheet, built from the styles file and every component's `Style` || `/__hl/emit` | the POST fallback for emits || `/__hl/manifest.webmanifest` | the web app manifest, for an installable app || `/__hl/sw.js`, `/__hl/web/worker.js` | the service worker's entry script and its compiled module, for an app with `offline` || `/__hl/upload` | a file of an emit, in parts (`POST` `{ name, type, size, q, n }` → `{ id, have }`, or `{ handled }` when the session handled emit `q` already; `POST ?id=&at=` one part → `{ have }`; `GET ?id=` → `{ have }`) |The page loads the modules and the stylesheet as `<url>?v=<hash>`, where the hash covers everything the build serves. A url with the current hash is sent with `Cache-Control: public, max-age=31536000, immutable`, and any other url with `no-cache`. A deploy therefore changes every url, and a tab that navigates into a newer build loads that page as a full document.An app's own `file` and `directory` routes keep the same url across deploys, so they answer with an `ETag` of the content and `Cache-Control: no-cache` (a route's own `Cache-Control` header replaces that). A request whose `If-None-Match` names the current tag gets a `304` with no body.
Branches
- mainmain branch
Latest commits
- 5cb85d75deploy.sh: a backup taken while a background job writes (tar exit 1) is a warning; archive checked with gzip -tmre
- 9abda75dtracker: report 035mre
- 12595e47mission 035: theme re-vendored from layouts.worldapi.org 0222f67 (two corner radii: radiusSmall 5px, radiusLarge 10px); the tracker's 18 own radii -> radiusSmall/radiusLarge (--layout-radius is gone); check-theme 0; gates 379/0, 32/0, 53/0, 229/0, 26/0; real copy: every computed radius in {0, 5px, 10px, 50%}mre
- e7305014tracker: report 032 (art + photos)mre
- fbb903cctracker#33/#37 (mission 032): a title without a TMDB poster gets its backdrop (w780, posterFromBackdrop, shown 2:3 centre-cropped); movies store runtime, the page shows release date + runtime; art backfill (public titles without poster file / movies without runtime) and person photo backfill (tmdbProfile / photoCheck) as the last start jobs (TRACKER_ART, TRACKER_PHOTOS; off in every gate start); gates 379/0, 32/0, 53/0, 229/0, 26/0, check-theme 0; live copy: art 4977 titles in 42 min (115 posters, 15 backdrops, 4609 runtimes), The Remaining shows its backdrop + 7 minmre
- 4ecc67b2tracker: STATUS/LOG for the t38 + t40 merge (gates 374/0, 32/0, 53/0, 229/0, 26/0, check-theme 0; live copy checks)mre
- e5945d2fMerge t40 (tracker#40 curated franchises, Franchises menu, superseded collections) into main: deploy.sh lists all six gates (browser, kinds, franchises, pager, franchiseseed, check-theme); search.hl keeps #37's personPhotoOf + #40's importWithCredits; pager gate runs with TRACKER_FRANCHISE_SEED=0; gates 374/0, 32/0, 53/0, 229/0, 26/0, check-theme 0mre
- b638e99dMerge t38 (tracker#38 pagination, #35 Returning/Airing label) into main: LOG/STATUS keep both sides; pager gate follows #37's /people (everyone, last updated first: seed updatedAt); gates pager 229/0, browser 374/0mre
- f8ffa4dbtracker: report 032 + The Remaining + #39mre
- 7565a863tracker: LOG timemre
- b10f00c8tracker#39: double episodes — migrated episodes whose TMDB id TMDB replaced are adopted by their number in the sync (old id -> migratedTmdbId); merge.hl step 3 merges each season's doubles at start (keeper: most watches > synced > first; watches moved/parked; tombstones into mergedEpisodes, nothing deleted); tools/count-duplicate-episodes.hl; gate fixture + paths-m039; live copy 850 -> 0 in 64 s; gates 373/0, 32/0, 52/0mre
- 8f1d4542tracker#40: superseded collections — a TMDB collection timeline whose titles are all in one curated timeline is hidden (supersededBy; kept: own page + editor finder), set by the collection seed when it makes one and by the curated build (lifted when the cover is gone); partly covered ones join that franchise; no second widget (First Contact: only Star Trek — Prime); gate franchiseseed 26/0, browser 365/0, kinds 32/0, franchises 53/0, check-theme 0; real copy 24 supersededmre
- 9448d643tracker#35 follow-up (mission 033): TVmaze 'Running' is labelled 'Returning', or 'Airing' while a non-special episode of the two newest seasons is released within today +-7 days (data unchanged); gate fixtures Running/Airing/Aired + a special; browser 366/0, kinds 32/0, franchises 52/0, pager 229/0, check-theme 0mre
- 7d4b293dtracker#40: "Franchises" in the main menu (desktop header after People, phone sidebar) → /franchises, marked on franchise and timeline pages; gates 365/0, 32/0, 53/0, 24/0, check-theme 0mre
- 8751adb8tracker: report 032mre
- 9bce1f65tracker mission 032: STATUS gate files + the hour-boundary flakemre
- 718bfb89tracker#37 (mission 032): /people = everyone, last updated first (updatedAt stamped by the person fill; view built at boot, touched people first at once), photo + name tiles (person colour) with the /movies pagination, /people/<letter> removed; photo = our file, tmdbProfile, a cast/crew entry's profile (in-memory map at boot), else the new 'no photo' placeholder; new cast/crew/created_by people keep tmdbProfile; search people rows with the photo; /settings = the heading only; util.hl sortDesc starts from sorted runs (same result, 105k: 1.6 s -> 0.15 s); gates 369/0, 32/0, 52/0, check-theme 0; README/STATUS/LOGmre
- 93dfb0batracker#40 (mission 034): the curated franchises — data/franchises.json (17 franchises, 31 timelines, 285 TMDB titles, movies + series, in-universe/release order, 12 TMDB collections attached); lib/franchiseseed.hl + jobs.hl franchiseSeedTick (last start job, imports missing titles via details.hl importWithCredits = the search's Add, one per step paced, then one build; franchiseseed.db: editor changes win, the creator's same-name franchise adopted / timeline left alone, 404 remembered, resumable, idempotent); timeline heads 'N titles · in-universe order' (orderKind) and wrap on a phone; series pages show the widget; new gate tests/franchiseseed.mjs (5th in deploy.sh), the others run with TRACKER_FRANCHISE_SEED=0; gates 365/0, 32/0, 52/0, 24/0, check-theme 0; real copy 196 imported, 0 failed, 7 min, restart unchanged=31mre
- 03ec792ftracker#38 (mission 033): pagination goes exactly to the clicked page — tilelist read the clicked button's text after pagination.hl's own handler had rebuilt the buttons (real clicks only); now li.current, else the button's own text; new gate tests/pager.mjs (5 lists x 11 pages, 390/1280, real + script clicks, Back/Forward) 229/0; browser 365/0, kinds 32/0, franchises 52/0, check-theme 0mre
- 96ba683adeploy.sh: a gate without a 'passed,' line (check-theme) no longer ends the scriptmre