gitoriaLog in with ident

tracker

All repositories: gitoria

ReadmeCodePull requestsReleasesTicketsSettings
Commit7565a8637565a863tracker: LOG timemre7565a863/plugins/web/README.md

15.9 KB

  1. # hl:web — the web framework
  2. `hl:web` serves an app's pages from the server and keeps them live in the browser. It is written
  3. in Hybriel: `WebFramework.hl` is the server half, `client.hl` the browser half, and `compile.hl`
  4. turns each component's View into that component's own code. The model behind it (components,
  5. Views, realms, the boundary) is in [COMPONENTS.md](../../COMPONENTS.md). This page lists the rules
  6. an app is held to. Each rule is a located refusal at boot unless it says otherwise.
  7. ## Starting an app
  8. ```hybriel
  9. import WebFramework from 'hl:web'
  10. import Home from './components/home.hl'
  11. import Styles from './styles.hl'
  12. routes = [ { pattern = "/" component = Home } ]
  13. server = new WebFramework(routes = routes, styles = Styles, port = 8080)
  14. ```
  15. Settings may be passed as named arguments. Most of them may instead be members of the file that
  16. runs the `new`:
  17. | setting | default | meaning |
  18. |---|---|---|
  19. | `routes` | `[]` | the route table (below); pass it as an argument |
  20. | `styles` | none | the app's styles file: the imported class, or a path string. It must be in the project graph |
  21. | `port` | 8080 | the port to listen on |
  22. | `host` | `HL_HOST`/`HOST`, else 0.0.0.0 | the interface to bind |
  23. | `audience` | `{}` | per outward event, who receives it (see Events) |
  24. | `minify` | false | one-line HTML and compact modules |
  25. | `watchMode` | true | a saved `.hl` file re-analyses the app and reloads every open tab |
  26. | `sessionDir` | `<app>/.sessions` | where sessions are stored; `false` keeps them in memory |
  27. | `sessionMaxAge`, `sessionIdle`, `sweepInterval` | 14 days, 15 min, 5 min | the session clocks, in seconds |
  28. | `sessionCookie` | `hlsid` | the cookie's name: letters, digits, `-` and `_` |
  29. | `sessionSecure` | false | set the cookie's `Secure` flag (for an app behind https) |
  30. | `appTitle`, `appDescription`, `appImage`, `appFavicon`, `meta` | none | the head every page gets unless it declares its own |
  31. | `links` | none | a list of hybrids, one `<link>` each in every page's head (`{ rel = 'preconnect' href = '…' }`) |
  32. | `appIcons`, `appShortName`, `appThemeColor`, `appBackgroundColor`, `appTouchIcon`, `appManifest` | none | make the app installable: a web app manifest, linked from every head (see Offline and installable) |
  33. | `offline` | none | the pages kept offline: a service worker, their state in IndexedDB, a queue for their emits |
  34. | `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 |
  35. | `uploadMax` | 1 GiB | the largest file an emit may carry, in bytes (see Files in an emit) |
  36. An app runs on one web framework. If its files reach two packages that hold a `WebFramework.hl`
  37. (for example an old vendored copy beside this one), it is refused at boot, naming the import row
  38. of each.
  39. ## Routes
  40. Each entry has a `pattern` (`/posts/:id`, `/assets/*`) and one kind:
  41. | kind | answers with |
  42. |---|---|
  43. | `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 |
  44. | `function = (route, req) => { … }` | what the function returns (a Hybrid becomes JSON); `req.session` is the cookie's session, or null |
  45. | `direct = "text"` | that text |
  46. | `file = "./x"` | one file |
  47. | `directory = "./assets"` | the files under a directory |
  48. A named group (`:id`) reaches the component as a construction argument of the same name: declare
  49. `id = null` at its root. A named wildcard does the same with the rest of the path:
  50. `/code/*path` gives `path = 'src/lib/util.hl'` for `/code/src/lib/util.hl`, on the first load and
  51. on a navigation. A bare `*` has no name a member could take; it is what a `directory` route
  52. serves from.
  53. The request itself reaches a component the same way, and only one that declares the member
  54. (COMPONENTS.md): `session = null` gets the session, `host = null` the host without its port,
  55. `headers = null` the request's headers as a hash with lowercase names —
  56. `headers['accept-language']`. On a client navigation they are the headers of the request the
  57. navigation crossed on, so they are still the browser's own: the socket's upgrade request, or the
  58. `POST /__hl/emit` under the fallback. `cookie`, `authorization` and `proxy-authorization` are left
  59. out, because a member is state and state reaches page script.
  60. ## Components
  61. A component is a `.hl` file with a `View`. Its root members are its state, and are constructed on
  62. the server for each request.
  63. **A View attribute is a literal, a member or a field path.** `href = "/"`, `href = url` and
  64. `href = p.url` are allowed. `href = '/posts/' + id` is refused at its line: declare
  65. `postUrl = '/posts/' + id` at the file's root and write `href = postUrl`. The same holds for a
  66. value bound on a component reference (`Card { title = t }`) and for the condition of an `if` in a
  67. View, which may also be a `for` row variable.
  68. **An attribute name that is not an identifier is quoted.** `"aria-hidden" = "true"` and
  69. `"data-page" = it.target` render as written and bind like any attribute, in a `for` row too. A
  70. camelCase name is not rewritten (`ariaHidden` renders as `ariaHidden`). `data-page = it.target`
  71. unquoted is refused at its line, naming the quoted form.
  72. **A bound attribute in a `for` row follows the row** on every update of the list: a new list, a
  73. `push`, an `upsert` and a write into a row's record (`items[0].title = …`) repaint it, as they
  74. repaint the row's text and class.
  75. **The tree carries the elements the HTML parser implies.** `table { tr { td { … } } }` is rendered
  76. as `table > tbody > tr > td`, because that is the tree the browser builds from the HTML, and the
  77. page is continued from the tree the browser built. The pairs are listed in `view.hl`
  78. (`impliedElements`): `tr`, `td` and `th` under `table` get a `tbody`, `td` and `th` under `tbody`,
  79. `thead` or `tfoot` get a `tr`, and `col` gets a `colgroup`. Consecutive children that need the same
  80. container share one.
  81. The other rules:
  82. - A View reads only names the file declares. A name it declares nowhere is refused.
  83. - `parent './main.hl'` wraps a page in a shell. The shell declares `slot = null` and places
  84. `slot` in its View exactly once. A component that is filled (`Card { p { … } }`) must declare a
  85. slot too.
  86. - `body { … }` is allowed only as the root of a View, and a page wrapped by a parent cannot have
  87. one: the shell owns the body.
  88. - A View cannot render itself, directly or through other components.
  89. - A Style rule cannot be written on a component reference. Write it on an element inside that
  90. component.
  91. - A page's head comes from its members `__title`, `__description`, `__image`, `__favicon` and
  92. `__meta`, and falls back to the app's `appTitle`, `appDescription`, `appImage`, `appFavicon` and
  93. `meta`. A handler that writes one of them updates the document.
  94. ## Events
  95. `on name(…)` handlers and `emit` statements cross between the browser and the server over a
  96. WebSocket on the app's own port. `POST /__hl/emit` is the fallback for a peer without a socket.
  97. A handler that takes `session` as its last parameter gets the connection's session from the
  98. server, never from the peer.
  99. An outward event reaches the tab that caused it. To send it to other connections, give it an
  100. `audience` entry: a function of the event's arguments and the receiving connection's session that
  101. answers true or false.
  102. A value-form emit (`answer = emit server ask()`) waits for one answer, so it has one answerer.
  103. If two files in the target realm answer the event, it is refused.
  104. **A write anywhere repaints what it wrote** (tickets #98, #99), from the compiler's write
  105. sets, never by watching the members:
  106. - A listener repaints what its handler writes when the handler is done. A handler still
  107. waiting after the task that started it (a network answer, `new Promise(…)`) has its
  108. write set painted then too, so what it wrote before the wait shows while it waits.
  109. - A function made inside a component — an `XMLHttpRequest`'s `onload` or `onprogress`, a
  110. timer, a Promise's executor, a `then` — is compiled so that every return of it is
  111. announced with the site it was made at. What it writes (a member, a push into one, a
  112. field inside one, and what the methods it calls and the events it emits write) is
  113. repainted after the task that ran it. A function that writes no member repaints nothing.
  114. - A repaint the listener already made in the same task is not made twice.
  115. **After a face that takes `session`** (tickets #121, #122), the server re-derives the
  116. members that read `session` for the tabs that mounted the component, with the page's own
  117. route params, and ships them in the ack (`sync`); the browser takes them as writes.
  118. - A member that reads `session` and calls an imported class, directly or through a local
  119. method or static, is computed on the server only and never re-derived in the browser.
  120. - Such a member is already up to date when the handler continues, so a client handler that
  121. appends the face's returned row to a list derived from `session` shows it twice. Assign
  122. from what the sync brought, or leave the list to the sync.
  123. - A member derived from `session` that needs a server-only resource (a database) must reach
  124. it through an imported class or a static of one; a plain copy (`me = session.user.id`) is
  125. derived everywhere.
  126. ### Files in an emit
  127. A browser `File` (an input's `ev.target.files[0]`, a dropped file) or a list of them
  128. (`ev.target.files`) may be an argument of an `emit server …`. It never rides the socket:
  129. before the emit goes out, the client half sends each file to `/__hl/upload` as its own HTTP
  130. requests, with the page's session cookie, in parts of 1 MiB. The face gets the file in its
  131. place as `{ bytes, name, type, size }` (`bytes` a `Bytes`), or a list of those for a list:
  132. ```hybriel
  133. input { type = 'file' on change(ev) { emit send(ev) } }
  134. on send(ev) { saved = emit server saveFile(ev.target.files[0]) }
  135. on server saveFile(file) {
  136. writeFile('uploads/' + file.name, file.bytes)
  137. return file.size
  138. }
  139. ```
  140. - **Progress.** While a file is sent, every mounted component that declares
  141. `on client uploadProgress(p)` gets `p = { event, index, name, sent, size }`: the emit's
  142. event, the file's position among the emit's files, its name, the bytes sent so far and
  143. its size.
  144. - **A dropped connection.** A part that did not come back is asked for again: the client
  145. waits (250 ms, doubling up to 4 s), asks the server how much of the file it has, and goes
  146. on from there. After 8 drops in a row it gives up.
  147. - **A failed upload ends the emit.** A file the server refuses (bigger than `uploadMax`, an
  148. upload of another session) or a connection that stays down ends the emit with that error
  149. instead of sending it — except a queueable emit in an offline app, which is queued with its
  150. files when the connection is what failed (COMPONENTS §10): it gets the failed ack a failing face sends
  151. (`{ t: 'ack', i, ok: false, error }`), so a value-form emit answers null and the error is
  152. logged. The face does not run.
  153. - An upload belongs to the session that started it, and one emit takes it. An upload that
  154. nothing takes within an hour is dropped. The parts are held in the server's memory until
  155. the emit takes them.
  156. ## Offline and installable
  157. The full model is [COMPONENTS.md §10](../../COMPONENTS.md). An app writes no JavaScript for any
  158. of it.
  159. ```hybriel
  160. appTitle = "Hybriel Notes"
  161. appIcons = [ { src = "/static/icon-192.png" sizes = "192x192" } { src = "/static/icon-512.png" sizes = "512x512" } ]
  162. offline = [ NoteList, NoteEditor ]
  163. ```
  164. - `appIcons` (or `appManifest`, or `offline`) serves `/__hl/manifest.webmanifest` and links it,
  165. with an apple-touch-icon, from every page's head. `appManifest`'s entries are written over the
  166. generated manifest.
  167. - `offline` lists pages: route components, as classes or path strings. Anything else is refused
  168. at its line. The framework generates the service worker and registers it. The shell and the
  169. listed pages' documents are cached, and a navigation is network-first. What the server last
  170. said about a listed page is kept in IndexedDB and shown over an older cached document. A
  171. statement-form `emit server …` from a listed page that does not reach the server is queued and
  172. replayed in order when the socket reopens. Each queued emit carries a stable id, and the
  173. server runs it once: a replay whose ack was lost is answered again without running the handler
  174. (the last 200 ids per session are kept). A socket that goes silent without closing is noticed
  175. by the ping and reopened. A queued emit keeps its files: IndexedDB holds them with the entry,
  176. and they go up on the replay under the emit's id, once (COMPONENTS §10). A page that
  177. declares `__hlQueued = 0` /
  178. `__hlQueue = []` shows the queue. A page that declares `__hlOnline = true` has it follow the browser's
  179. connection (`navigator.onLine`, and the online/offline events), so a View can say "you are offline".
  180. - `offlineFallback = Start` (a route component, class or path string, answered by a route with no
  181. parameter; needs `offline`) names a DATA-FREE page: the worker keeps its document once, fetched without
  182. credentials, and shows it for any page it has nothing for while the network is down, instead of "Unavailable
  183. offline". Not set: nothing changes. A page (kept or the fallback) that declares `__hlDataFree = true` is kept
  184. the same way — its document is the anonymous one, and the browser keeps neither its state nor a queue for it.
  185. - Kept state is per component for a member no route parameter feeds (the same list at every url of
  186. the page), per url for the rest. A param page that was never loaded as a document is answered offline
  187. with a kept document of the same component, booted with the url's parameters.
  188. - What is kept belongs to the last user who was logged in. An expired or fresh anonymous session
  189. changes nothing: that user's queue is held, and replays when the same user logs in again (the server
  190. refuses a replay made for another user). Another user logging in forgets the copy (state, queue and the
  191. worker's documents); an entry from before users were kept is dropped unsent. A
  192. face that logs the session out forgets the copy by itself (the ack says whose session it is now);
  193. an app with its own logout calls `forgetOffline()` from `hl:web`.
  194. - In any app, an emit in flight when its socket closes is sent again with its id over the POST
  195. fallback or the next socket, and answered there (ticket #107). A page being left
  196. (`beforeunload`, `pagehide`) reopens nothing and posts nothing.
  197. - `import { notify, notifyAt, notifyIn } from 'hl:web'` shows a notification now, or at a time while the page
  198. is open. A closed app cannot be woken at a set time without a server push.
  199. ## What it serves
  200. | url | what |
  201. |---|---|
  202. | `/<file key>` (e.g. `/components/home.hl`) | a component's browser module |
  203. | `/__hl/hl-runtime.js` | the runtime every module imports |
  204. | `/__hl/web/client.js` | this package's browser half (other packages' halves are at `/__hl/<name>/client.js`) |
  205. | `/__hl/app.css` | the one stylesheet, built from the styles file and every component's `Style` |
  206. | `/__hl/emit` | the POST fallback for emits |
  207. | `/__hl/manifest.webmanifest` | the web app manifest, for an installable app |
  208. | `/__hl/sw.js`, `/__hl/web/worker.js` | the service worker's entry script and its compiled module, for an app with `offline` |
  209. | `/__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 }`) |
  210. 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.
  211. 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

Latest commits

  • 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
  • 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
  • 96ba683adeploy.sh: a gate without a 'passed,' line (check-theme) no longer ends the scriptmre
  • eb3b9205tracker: report 031mre
  • 9b5d2e89tracker mission 031: README (What it does, Test: four gates + the #32 checks, Files: theme/, new pages), STATUS (real copy, A/B load, how to repeat, open points), LOGmre
  • 39950e4ctracker#32 (mission 031): the WorldAPI theme (theme/ vendored verbatim from layouts.worldapi.org 85b5654; styles.hl inherits it: accent green-dark, type colours 1-6; own base/header rules, row lines, genre-pill and inverted-button frames removed, the season foldable keeps its line; check-theme 21 -> 0, 4th deploy gate; main actions class primary) and the #32 header (theme AppHeader/MainMenu/UserMenu/Sidebar/ContentFirst: desktop brand, search, Series|Shows|Movies|Genres|People, user icon with Unwatched..Settings, Logout; signed out the ident selector, phone the iD icon dropdown; phone menu in the sidebar overlay; marked entry by :has); /find -> /search/<q>, /genres, /people(/<letter>), /settings; main { ContentFirst { slot } } works around the hl:web one-line slot bug; gates 365/0, 32/0, 52/0, check-theme 0mre
  • a386dc92tracker: reports 029 + 030mre
  • 71e0fd7dtracker missions 029 + 030: README (What it does, Files, gate count), STATUS (real-copy numbers, how to repeat, open points), LOGmre
  • d36ea6eatracker#34 + #35 (mission 030): Follow directly under the poster, as wide as the poster (show.hl, styles.hl); the status pill next to a series' title — TVmaze's status (new tvmazeStatus, stored by the sync's TVmaze merge) else TMDB's, TVmaze Ended + TMDB Canceled = Canceled, inverted (filled, dark text, no border), green running / yellow pending / red canceled / muted ended (shows.hl statusOf); the daily delta asks TVmaze's status of an unfollowed series TVmaze's change list names (dailysync.hl syncRunStep, sync.hl syncTvmazeStatus); the status backfill after the details repair (backfill.hl, jobs.hl statusTick; resumable, 550 ms per TVmaze request); gates 354/0, 32/0, 52/0mre
  • 7d7d4487tracker#33 (mission 029): reduced titles — every title TMDB's details never went through this app (no detailsAt, no tmdbSync) is incomplete (shows.hl isIncomplete; the old tracker's migrated rows passed #26's test: 5,697 non-adult on the live copy, 691 series without seasons); the repair job does the visibly reduced first (shows.hl missingParts), the page completes one on open; a title TMDB has no poster for (The Remaining) shows the placeholder; tools/count-incomplete.hl; gate fixtures stand for synced titles (tmdbSync), tests/seed-reduced.hl + #33 checks; gates 347/0, 32/0, 52/0mre
  • 661c2592tracker: report 028mre
  • 27c916fatracker mission 028: README ("Code order", the new file map), STATUS (counts before/after, tests, how to repeat, open), LOGmre
  • d924f398tracker mission 028: comments name the new files (sync.hl, dailysync.hl, backfill.hl, credits.hl, jobs.hl, images.hl …); tools/ref-params.py + tools/lambda-audit.py also scan lib/ (they globbed the root only), lambda-audit counts a plain `x = p` alias like `let x = p`mre
  • 2e89b968tracker mission 028 (code order) 5/5 let: `let` only where a variable is reassigned — 667 never-reassigned lets became plain declarations (project.hl, lib/, components/, tools/, tests/); kept: 264 in loop bodies (a plain declaration there is 'Cannot reassign' on the 2nd pass), 234 reassigned, 27 whose name is also a member/outer/free name (a plain write would rebind it); tools/let-audit.py decides and fixes (README 'Code order'); tests/realdata-m028.{sh,mjs} = the page-output diff on a real copy; gates 342/0, 32/0, 52/0, real-copy pages identicalmre
  • 54796ff2tracker mission 028 (code order) 4/5 thin faces + last copies: the show page's check/follow faces call lib/watches.hl toggleWatched / toggleSeasonWatched (seasonAllWatched moved there) and lib/follows.hl toggleFollowed; both logins (header selector face, /login/callback) share lib/users.hl userOfCode; todayStr/listOf copies in components and the export readers copied into tools/migrate.hl + tools/old-short-ids.hl now once (lib/util.hl, lib/export.hl); gates 342/0, 32/0, 52/0; old-short-ids output byte-identical, migrate output identicalmre
  • 06b078e3tracker mission 028 (code order) 3/5 project.hl is the map: config, routes, wiring and a feature → file index (914 → 258 lines); the background jobs (daily sync run, backfills, details repair, credits job, merge, short ids, collection seed) moved unchanged into lib/jobs.hl (a class: their state is reassigned every step, a static cannot be; one instance made after the server), the login callback into lib/users.hl, poster/photo serving into lib/images.hl, the /shows/<slug> rule into lib/shows.hl showsMovedPath; route handlers are thin wrappers; gates 342/0, 32/0, 52/0, real-copy pages identicalmre
  • 94716fd2tracker mission 028 (code order) 2/5 util + topics: lib/util.hl holds envOr, storageDir, postersDir, profilesDir, newId, hexDigits, todayStr, dateOr, textOr, hasId, listOr, firstOf, sortDesc once (were copied into up to 5 files); tmdbsync.hl split into tmdb.hl (TMDB/TVmaze requests), sync.hl (one title's sync), sync-helpers.hl, backfill.hl; details.hl split into details.hl, credits.hl, credits-helpers.hl (isIncomplete to shows.hl); search-helpers.hl (words, query, ranking, slugs); collections.hl (the TMDB collection seed, out of franchises.hl); deltasync.hl renamed dailysync.hl; no behaviour change: gates 342/0, 32/0, 52/0, real-copy pages identicalmre