From 5a0f9138c4d48726b8ae1e3c2a35823606dcd7f9 Mon Sep 17 00:00:00 2001 From: Alexander van Elsas <58037137+avanelsas@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:52:57 +0200 Subject: [PATCH 1/2] feat(barebuild): read-only server-resource read projection for BareDOM (0.1.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## BareBuild — a server-resource read projection for BareDOM BareBuild attempts to support presentational web components using server state only. A client does not need business logic, store or runtime framework. The PR introduces the entire **read-only** phase as a sub project. ### What's included - A first complete version of BareBuild (`src/barebuild/`, will ship eventually as `@vanelsas/barebuild`). - A complete tasks demo showcasing 4 web component BareBuild consumers for an x-table, x-stat, x-progress and x-search-field. - A Babashka tasks server (an independent JSON implementation of the server-client contract), a live demo page, and an SSR boot page. Writes are deferred to a later stage. --- barebuild/.gitignore | 4 + barebuild/CHANGELOG.md | 19 + barebuild/LICENSE | 21 + barebuild/README.md | 128 +++++ barebuild/bb.edn | 8 + barebuild/docs/architecture-diagram.md | 153 ++++++ barebuild/docs/authoring-a-consumer.md | 98 ++++ barebuild/docs/server-contract.md | 139 ++++++ barebuild/favicon.ico | Bin 0 -> 4286 bytes barebuild/package-lock.json | 208 +++++++++ barebuild/package.json | 25 + barebuild/read_demo/README.md | 76 +++ barebuild/read_demo/dev-server/server.clj | 320 +++++++++++++ .../read_demo/dev-server/server_test.clj | 208 +++++++++ barebuild/read_demo/index.html | 59 +++ barebuild/read_demo/src/read_demo/demo.cljs | 37 ++ .../read_demo/x_progress_consumer/model.cljs | 12 + .../x_progress_consumer.cljs | 23 + .../x_search_field_consumer/model.cljs | 17 + .../x_search_field_consumer.cljs | 36 ++ .../src/read_demo/x_stat_consumer/model.cljs | 13 + .../x_stat_consumer/x_stat_consumer.cljs | 21 + .../src/read_demo/x_table_consumer/model.cljs | 46 ++ .../x_table_consumer/x_table_consumer.cljs | 141 ++++++ .../x_progress_consumer_model_test.cljs | 22 + .../x_search_field_consumer_model_test.cljs | 22 + .../read_demo/x_stat_consumer_model_test.cljs | 11 + .../x_table_consumer_model_test.cljs | 68 +++ barebuild/shadow-cljs.edn | 30 ++ .../src/barebuild/consumer_resource.cljs | 56 +++ barebuild/src/barebuild/core.cljs | 9 + .../elements/server_resource/model.cljs | 11 + .../server_resource/server_resource.cljs | 170 +++++++ barebuild/src/barebuild/resource.cljs | 262 +++++++++++ barebuild/src/barebuild/utils.cljs | 25 + barebuild/src/barebuild/wire.cljs | 83 ++++ barebuild/test/barebuild/resource_test.cljs | 440 ++++++++++++++++++ barebuild/test/barebuild/utils_test.cljs | 32 ++ barebuild/test/barebuild/wire_test.cljs | 77 +++ 39 files changed, 3130 insertions(+) create mode 100644 barebuild/.gitignore create mode 100644 barebuild/CHANGELOG.md create mode 100644 barebuild/LICENSE create mode 100644 barebuild/README.md create mode 100644 barebuild/bb.edn create mode 100644 barebuild/docs/architecture-diagram.md create mode 100644 barebuild/docs/authoring-a-consumer.md create mode 100644 barebuild/docs/server-contract.md create mode 100644 barebuild/favicon.ico create mode 100644 barebuild/package-lock.json create mode 100644 barebuild/package.json create mode 100644 barebuild/read_demo/README.md create mode 100644 barebuild/read_demo/dev-server/server.clj create mode 100644 barebuild/read_demo/dev-server/server_test.clj create mode 100644 barebuild/read_demo/index.html create mode 100644 barebuild/read_demo/src/read_demo/demo.cljs create mode 100644 barebuild/read_demo/src/read_demo/x_progress_consumer/model.cljs create mode 100644 barebuild/read_demo/src/read_demo/x_progress_consumer/x_progress_consumer.cljs create mode 100644 barebuild/read_demo/src/read_demo/x_search_field_consumer/model.cljs create mode 100644 barebuild/read_demo/src/read_demo/x_search_field_consumer/x_search_field_consumer.cljs create mode 100644 barebuild/read_demo/src/read_demo/x_stat_consumer/model.cljs create mode 100644 barebuild/read_demo/src/read_demo/x_stat_consumer/x_stat_consumer.cljs create mode 100644 barebuild/read_demo/src/read_demo/x_table_consumer/model.cljs create mode 100644 barebuild/read_demo/src/read_demo/x_table_consumer/x_table_consumer.cljs create mode 100644 barebuild/read_demo/test/read_demo/x_progress_consumer_model_test.cljs create mode 100644 barebuild/read_demo/test/read_demo/x_search_field_consumer_model_test.cljs create mode 100644 barebuild/read_demo/test/read_demo/x_stat_consumer_model_test.cljs create mode 100644 barebuild/read_demo/test/read_demo/x_table_consumer_model_test.cljs create mode 100644 barebuild/shadow-cljs.edn create mode 100644 barebuild/src/barebuild/consumer_resource.cljs create mode 100644 barebuild/src/barebuild/core.cljs create mode 100644 barebuild/src/barebuild/elements/server_resource/model.cljs create mode 100644 barebuild/src/barebuild/elements/server_resource/server_resource.cljs create mode 100644 barebuild/src/barebuild/resource.cljs create mode 100644 barebuild/src/barebuild/utils.cljs create mode 100644 barebuild/src/barebuild/wire.cljs create mode 100644 barebuild/test/barebuild/resource_test.cljs create mode 100644 barebuild/test/barebuild/utils_test.cljs create mode 100644 barebuild/test/barebuild/wire_test.cljs diff --git a/barebuild/.gitignore b/barebuild/.gitignore new file mode 100644 index 00000000..cf30c16c --- /dev/null +++ b/barebuild/.gitignore @@ -0,0 +1,4 @@ +/dist +/out +/.shadow-cljs +/node_modules diff --git a/barebuild/CHANGELOG.md b/barebuild/CHANGELOG.md new file mode 100644 index 00000000..cdcfe955 --- /dev/null +++ b/barebuild/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +All notable changes to BareBuild are documented here. This project adheres to +[Semantic Versioning](https://semver.org/). + +## 0.1.0 + +First release. BareBuild — a read-only server-resource read projection runtime for +BareDOM — and its showcase demo are implemented. + +- **Runtime (product).** The `` element and the pure `step` lifecycle + (fetch, sort, page, rejection, keep-stale, contract validation, echo-adoption, + trailing-fetch, network/protocol failures, disconnect-abort, SSR boot), plus the shared + `consumer-resource/register!` mechanism for authoring consumers. +- **Demo (showcase).** A Babashka tasks server and a live page where one `` + drives four example consumers — stat, progress, table, and search-field — from server + state. + +Read-only; writes (commands, mutations) are a later phase. diff --git a/barebuild/LICENSE b/barebuild/LICENSE new file mode 100644 index 00000000..14f72e2d --- /dev/null +++ b/barebuild/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Alexander van Elsas + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/barebuild/README.md b/barebuild/README.md new file mode 100644 index 00000000..ed51b6ca --- /dev/null +++ b/barebuild/README.md @@ -0,0 +1,128 @@ +# BareBuild + +**A tool that supports presenting data in a Web Component based UI using only server state [BareDOM](https://github.com/avanelsas/baredom).** + +BareBuild attempts to support presentational web components using server state only. A client +does not need business logic, store or runtime framework. The two main parts of BareBuild are: + +- **``**: a non-visual custom element that holds one immutable resource + value, coordinates network delivery, and projects user intent into the URL. +- **`consumer-resource/register!`**: The mechanism that is used to author *consumers*. consumers +are thin elements that translate/project an accepted server value onto a specific web component. + +The idea behind BareBuild is that you write a pure projection and a render function. BareBuild owns the whole lifecycle. +A server holds all state and is the only source of truth. + +## The loop + +Everything is one server-driven cycle: + +1. **Get Intent**: A gesture (or the page URL on load) says what the user wants to see. +2. **Fetch from the server**: `` asks the server for exactly that. +3. **The server holds state**: The server answers and its accepted value is the only source of truth. +4. **Render the server state**: The consumer projects that value into a web component. +5. **Repeat**: The next gesture becomes new intent, and the loop turns. + +The URL always mirrors the current intent, so every view is a shareable link and the back +button works. State is a succession of immutable values, no atoms, no signals, no +mutable store. + +## How it works + +At the top level, `` sits between the server and web components. +Plug consumers in with `register!`: + +```mermaid +%%{init: {'themeVariables': {'fontSize': '18px'}}}%% +flowchart TB + SERVER["Server
(plain JSON envelope)"] + subgraph SR["<server-resource>"] + direction TB + WIRE["wire · JSON → CLJS"] + STEP["step · pure lifecycle"] + EXEC["executor · decisionless edge"] + end + URL["browser URL + history"] + CONS["Consumers
(consumer-resource/register!)"] + COMP["BareDOM components"] + + SERVER -->|"envelope"| WIRE + WIRE --> STEP + STEP --> EXEC + EXEC -->|"fetch"| SERVER + EXEC -->|"url-write"| URL + URL -->|"intent (connect / back-forward)"| STEP + EXEC -->|"notify"| CONS + CONS -->|"render accepted value"| COMP + COMP -->|"gesture"| CONS + CONS -->|"submit-intent!"| STEP +``` + +The runtime itself is one pure loop. Events go in, a next value plus effects comes out, and an +executor that performs them: + +```mermaid +%%{init: {'themeVariables': {'fontSize': '18px'}}}%% +flowchart LR + EVENTS["Events in (closed set)

:connected (embed)
:intent-patch (query-patch, gesture-class)
:url-changed (query)
:response
:protocol-failed
:network-failed
:disconnected"] + + STEP["step (pure)
resource × event → resource′ + effects

resource value:
:url-intent · :last-accepted · :last-failure
:active-request (request/id, query)
:request-count · :history-policy · :endpoint"] + + EFFECTS["Effects out (data)

:fetch (endpoint, query, request/id)
:url-write (params, mode)
:notify-consumers (resource)
:abort (request/id)
:diagnostic (stale-*)"] + + EXEC["executor (decisionless edge)

fetch · history push/replace
applyResource · AbortController"] + + EVENTS --> STEP --> EFFECTS --> EXEC + EXEC -->|"response · failure · gesture · popstate"| EVENTS +``` + +- **Pure**: Every decision lives in `step` and is visible in the returned effects. + The executor only performs them (fetch, history, notify, abort). `step` is testable + and replayable from an event log. +- **One request in flight**: `start-request` mints a monotonic `:request/id`; `pending?` + and `installable?` derive purely from the value. A response is installed only if its id + matches the live request. A gesture made mid-flight is picked up by a single trailing + fetch once the in-flight request clears. +- **Two conversions only**: JSON↔CLJS at the network edge, CLJS→DOM at the component edge. + CLJS values in between. + +> **Integrating a server?** The endpoint must return a specific JSON envelope — see the +> [server contract](./docs/server-contract.md). For the full data flow with a worked consumer +> example, see [`docs/architecture-diagram.md`](./docs/architecture-diagram.md); to write a +> consumer, see [`docs/authoring-a-consumer.md`](./docs/authoring-a-consumer.md). + +## Status + +**Pre-release, read-only (v1).** Not yet published to npm (`"private": true`). Writes: commands, mutations, optimistic updates — are a separate, later phase. + +## Layout + +| Path | What | +|---|---| +| `src/barebuild/` | **the product** — the pure core (`resource`, `wire`, `utils`), the `register!` mechanism (`consumer_resource`), and the `` element | +| `read_demo/` | **the demo** — example consumers, a Babashka dev-server, and a live page (showcase; never shipped) | +| `docs/` | [`server-contract.md`](./docs/server-contract.md), [`architecture-diagram.md`](./docs/architecture-diagram.md), [`authoring-a-consumer.md`](./docs/authoring-a-consumer.md) | +| `test/barebuild/` | product unit tests | + +## Develop + +```sh +# from this directory: +npm run compile # compile the ESM lib +npm run build # release build (Closure Advanced) +npm test # run the unit tests under Node +``` + +Lint: `clj-kondo --lint src test`. + +BareBuild imports a few BareDOM utilities directly from `../src` (one shared `du`, no fork) +and bundles them into its dist, so the product is self-contained and has no runtime +dependency on BareDOM. (An app whose consumers drive BareDOM components installs +`@vanelsas/baredom` itself — that's the app's dependency, not BareBuild's.) + +Running the showcase demo (a live page driving BareDOM components from a tasks server) has +its own guide, see [`read_demo/README.md`](./read_demo/README.md). + +## License + +[MIT](./LICENSE) — same as BareDOM. diff --git a/barebuild/bb.edn b/barebuild/bb.edn new file mode 100644 index 00000000..da65ede3 --- /dev/null +++ b/barebuild/bb.edn @@ -0,0 +1,8 @@ +{:paths ["read_demo/dev-server"] + :tasks + {server {:doc "Run the BareBuild dev-server (the contract oracle + demo backend)." + :requires ([server]) + :task (server/-main)} + test:server {:doc "Run the dev-server handler tests." + :requires ([server-test]) + :task (server-test/run)}}} diff --git a/barebuild/docs/architecture-diagram.md b/barebuild/docs/architecture-diagram.md new file mode 100644 index 00000000..be4b3c90 --- /dev/null +++ b/barebuild/docs/architecture-diagram.md @@ -0,0 +1,153 @@ +# BareBuild — Architecture Diagram + +A read-only projection of server state into presentational BareDOM components. Two +custom elements (``, ``) sit between an independent +JSON server and the visual ``; the whole lifecycle is one pure `step` function +whose only outputs are a next value and a list of effects executed at a decisionless edge. + +Two figures: **Figure 1** is the whole picture (boxes = structure, arrows = data flow); +**Figure 2** zooms into the pure `step` ↔ effects ↔ executor loop. + +## Figure 1 — Overview (structure + data flow) + +```mermaid +%%{init: {'themeVariables': {'fontSize': '18px'}}}%% +flowchart TB + subgraph SERVER["bb dev-server (oracle · cheshire, independent JSON)"] + API["/api/tasks
accepted | rejected envelope"] + BOOT["/demo/boot
SSR embed (script/json)"] + end + + subgraph BROWSER["Browser"] + URL["URL query (tasks.*)
+ history / popstate"] + end + + subgraph SR["<server-resource>"] + WIRE["wire
Conversion 1: JSON → CLJS"] + STEP["step (pure)
lifecycle over the resource value"] + EXEC["executor (run-effects!)
decisionless edge"] + end + + subgraph CONS["<x-table-consumer>"] + PROJ["project
accepted → view-model"] + C2["Conversion 2: CLJS → DOM"] + end + + TABLE["<x-table> · <x-table-cell>
x-pagination"] + + URL -->|"connect / popstate: canonical url-intent"| STEP + BOOT -->|"embedded envelope on load"| WIRE + EXEC -->|"fetch: GET /api/tasks?requestId=tasks:N + query"| API + EXEC -.->|"abort: cancel in-flight"| API + API -->|"JSON envelope: value · shape · query echo · requestId"| WIRE + WIRE -->|"CLJS response, or protocol-failure marker"| STEP + STEP -->|"effects: fetch · url-write · notify · abort · diagnostic"| EXEC + EXEC -->|"url-write: push/replaceState (scoped)"| URL + EXEC -->|"notify-consumers: applyResource(resource, ctx)"| PROJ + PROJ --> C2 + C2 -->|"x-table-row / x-table-cell children"| TABLE + TABLE -->|"x-table-cell-sort (direction) · page-request"| CONS + CONS -->|"submit-intent! → :intent-patch"| STEP +``` + +**Arrow legend (the data that rides each edge):** + +- **URL → step** — on connect and on back/forward, `construct-url-intent` reads the + `tasks.*` params and `canonicalize-query`s them into the `:url-intent`. +- **executor → server** — a `:fetch` effect becomes `GET /api/tasks?requestId=…&`. + The request id is minted *inside* `step` and carried on the effect. +- **server → wire** — a plain JSON envelope (accepted: `outcome/requestId/revision/query/ + value/shape/pageInfo`; rejected: `outcome/requestId/query/error`). The server emits it + independently (cheshire), never via BareBuild's bijection. +- **wire → step** — Conversion 1 parses it to a CLJS `:response` value, or a + `:protocol-failed` marker for a malformed envelope. +- **step → executor** — the pure result: a next resource value plus effects as data. +- **executor → URL** — a `:url-write` reflects the (adopted, normalized) query back into + the address bar via `build-scoped-url` + `history`. +- **executor → consumer** — a `:notify-consumers` calls `applyResource`, handing over the + whole resource value. +- **consumer → x-table** — `project` builds a `{:columns :rows}` view-model; Conversion 2 + renders it as `x-table-row`/`x-table-cell` children. +- **x-table → consumer → step** — a sort/page gesture becomes an intent patch via + `translate-gesture` and re-enters `step` as `:intent-patch`. + +## Figure 2 — Zoom: the pure loop (`step` ↔ effects ↔ executor) + +```mermaid +%%{init: {'themeVariables': {'fontSize': '18px'}}}%% +flowchart LR + EVENTS["Events in (closed set)

:connected (embed)
:intent-patch (query-patch, gesture-class)
:url-changed (query)
:response
:protocol-failed
:network-failed
:disconnected"] + + STEP["step (pure)
resource × event → resource′ + effects

resource value:
:url-intent · :last-accepted · :last-failure
:active-request (request/id, query)
:request-count · :history-policy · :endpoint"] + + EFFECTS["Effects out (data)

:fetch (endpoint, query, request/id)
:url-write (params, mode)
:notify-consumers (resource)
:abort (request/id)
:diagnostic (stale-*)"] + + EXEC["executor (decisionless edge)

fetch · history push/replace
applyResource · AbortController"] + + EVENTS --> STEP --> EFFECTS --> EXEC + EXEC -->|"response · failure · gesture · popstate"| EVENTS +``` + +**What the loop guarantees:** + +- **Pure vs edge.** Every decision lives in `step` and is visible in the returned + effects; the executor only performs them (fetch, history, notify, abort). `step` is + `=`-testable and replayable from an event log. +- **One request in flight.** `start-request` mints a monotonic `:request/id` into + `:active-request`; `pending?`/`installable?` derive purely from the value. A response is + installed iff its id matches the live request; a gesture made mid-flight is picked up by + the single trailing fetch once the in-flight request clears. +- **Two conversions only.** JSON↔CLJS at the network edge (`wire`, Figure 1 left) and + CLJS→DOM at the component edge (`x-table-consumer`, Figure 1 right). CLJS values in + between, because structural `=` is load-bearing. + +## Figure 3 — The consumer layer (one mechanism, many thin consumers) + +```mermaid +%%{init: {'themeVariables': {'fontSize': '18px'}}}%% +flowchart TB + SR["<server-resource>
notify-consumers → applyResource(resource)"] + + subgraph MECH["consumer-resource/register! — shared mechanism (one file)"] + APPLY["applyResource
child caching · submit-intent! · three change-guards"] + G1["render — on :last-accepted change"] + G2["on-failure — on :last-failure change (nil = recover)"] + G3["on-pending — on pending? change"] + APPLY --> G1 + APPLY --> G2 + APPLY --> G3 + end + + subgraph TABLE["<x-table-consumer>"] + TR["render → rows/cells + pagination"] + TF["on-failure → x-alert"] + TP["on-pending → aria-busy + dim"] + TG["gestures → submit-intent!"] + end + + subgraph STAT["<x-stat-consumer>"] + ST["render → value attr"] + SP["on-pending → loading attr"] + end + + SR --> APPLY + G1 --> TR + G2 --> TF + G3 --> TP + G1 --> ST + G3 --> SP + TG -->|":intent-patch"| SR +``` + +**The de-complect.** Every consumer braids three concerns; the mechanism owns one and each +consumer owns the other two: + +- **Mechanism** (`consumer_resource.cljs`) — *how a consumer is driven*: the `applyResource` + install, the three change-guards, child caching, intent submission. Written once. +- **Calculation** (each `model.cljs`) — *resource → view data*: pure, node-tested projection. +- **Effect** (each element file) — *view data → DOM*: the `render`/`on-failure`/`on-pending` + hooks, all `(child value this)`. + +Adding a component is therefore a projection plus a render fn (plus optional hooks) — the +mechanism is untouched. x-stat (scalar, display-only) and x-table (list, gestures, failure +UI, pagination) are the two ends of that range, driven by the same core. diff --git a/barebuild/docs/authoring-a-consumer.md b/barebuild/docs/authoring-a-consumer.md new file mode 100644 index 00000000..926013b0 --- /dev/null +++ b/barebuild/docs/authoring-a-consumer.md @@ -0,0 +1,98 @@ +# Authoring a resource consumer + +A **consumer** is a thin custom element that renders a ``'s value into a +presentational BareDOM component. You write two small pieces — a pure projection and a +render function — and register with `consumer-resource/register!`. The shared mechanism +supplies everything else: the `applyResource` method, the change-guards, child caching, and +gesture submission. + +The value your projection reads is the accepted server envelope — its exact shape is the +[server contract](./server-contract.md). + +## The two files + +``` +x__consumer/ ; app code — this repo's demo keeps these under read_demo/src/read_demo/ + model.cljs ; pure: tag metadata + projection (resource → view data). Node-tested. + x__consumer.cljs ; DOM: render + optional hooks + init! → register! +``` + +- **`model.cljs`** holds `tag-name`, `observed-attributes` (usually `#js []`), and a pure + projection function (accepted response → whatever the child needs). No DOM. This is where + unit tests live. +- **`x__consumer.cljs`** holds the DOM effects (render, failure UI, loading) and calls + `consumer-resource/register!` from `init!`. + +## `register!` config + +```clojure +(consumer-resource/register! + {:tag "x--consumer" ; the consumer element tag + :child-tag "x-" ; the driven child, cached on connect + :observed-attributes model/observed-attributes + :render render! ; required + :on-failure on-failure! ; optional + :on-pending on-pending! ; optional + :on-connect on-connect!}) ; optional +``` + +**All hooks share one signature: `(child value this)`** — `child` is the cached child +element, `this` is the consumer host. + +| Hook | Signature | Fires when… | +|---|---|---| +| `:render` | `(child accepted this)` | `:last-accepted` changes | +| `:on-failure` | `(child failure this)` | `:last-failure` changes — `failure` is **nil on recovery**, so clear your failure UI | +| `:on-pending` | `(child pending this)` | `pending?` changes — `pending` is a boolean; show/hide loading | + +What you get for free: +- **Keep-stale is automatic.** A failure leaves `:last-accepted` untouched, so `render` + simply no-ops during failures — the last good view stays on screen. +- **One request in flight, stale-drop, echo-adoption, trailing-fetch, revert** — all handled + by the pure `step` upstream; the consumer only ever sees the resulting value. + +## Gestures (interactive consumers) + +In a DOM event handler, translate the gesture into an intent patch (a `model` function) and +submit it: + +```clojure +(defn- on-sort [^js e] + (let [consumer (.closest (.-currentTarget e) "x--consumer") + patch (model/translate-gesture …)] ; {:query-patch {…} :gesture-class :refinement} + (consumer-resource/submit-intent! consumer patch))) +``` + +`:gesture-class` is `:refinement` (→ replace history) or `:navigation` (→ push history); +`step` resolves it to a URL-write mode. Display-only consumers have no gestures. + +## Wiring it in + +1. Register the consumer in **your app's** init — require its ns and call its `init!`, + alongside the driven BareDOM component's `init!`. Then call `barebuild.core/init` to + install `` (the BareBuild runtime). Consumers are app code — they are + **never** added to `barebuild.core`, which registers only ``. In this + repo's demo all of that lives in `read-demo.demo` (`read_demo/src/read_demo/demo.cljs`); in + production the driven BareDOM component is a peer dependency the host page loads. +2. Demo markup — add the consumer as a **direct child** of `` (so + `collect-consumers` finds it); nest the child inside it: + ```html + + + + + + ``` +One `` fans out to any number of consumers. + +## Worked examples + +**Minimal — `x-stat-consumer`** (display-only scalar): `project-stat` (model) + a one-line +`render!` (set the `value` attr) + `on-pending!` (toggle the `loading` attr). ~20 lines. + +**Full — `x-table-consumer`**: `accepted-response->view-model` + gesture translators (model); +`render!` (build `x-table-row`/`x-table-cell` children + pagination), `on-failure!` (an +`x-alert`), `on-pending!` (`aria-busy` + dim), and sort/page gestures via `submit-intent!`. + +Both are driven by the same 49-line `consumer_resource.cljs` — the difference between them is +exactly their projection and their hooks. diff --git a/barebuild/docs/server-contract.md b/barebuild/docs/server-contract.md new file mode 100644 index 00000000..6a0a3447 --- /dev/null +++ b/barebuild/docs/server-contract.md @@ -0,0 +1,139 @@ +# The server contract + +BareBuild is a *read projection*: it never owns state, it renders whatever an HTTP endpoint +returns. That endpoint is the real integration point, the contract a server +must satisfy. It is transport- and language-agnostic (plain JSON); the demo's +[`read_demo/dev-server/server.clj`](../read_demo/dev-server/server.clj) is a complete +reference implementation. + +## The request + +BareBuild fetches with a single GET — read-only, no body: + +``` +GET ?&requestId= +``` + +- **``**: The `src` of the ``. +- **``**: The current user intent as URL query params (sort, page, filter, … + whatever the consumers submit). BareBuild forwards them as-is. The server decides which it + honors. +- **`requestId`**: An opaque id BareBuild mints per request, echo it back unchanged. + +## The response + +Always **HTTP 200** for a business outcome (see [Status codes](#status-codes)). The JSON body +is one of two envelopes. + +### Accepted — the server returns data + +| Field | Type | Notes | +|---|---|---| +| `outcome` | `"accepted"` | | +| `requestId` | string | echo of the request's id | +| `revision` | string | opaque version tag for the resource | +| `query` | object | the server's **normalized** echo of the honored query (see [The query echo](#the-query-echo-is-load-bearing)) | +| `value` | array | the records to render | +| `shape` | object | structural contract for the records (see [The shape](#the-shape)) | +| `pageInfo` | object | `{ page, pageSize, totalPages, totalCount }` — all numbers | + +### Rejected — the server refuses the query + +A *business* verdict (e.g. an unsupported sort field), not a transport error. + +| Field | Type | Notes | +|---|---|---| +| `outcome` | `"rejected"` | | +| `requestId` | string | echo | +| `revision` | string | | +| `query` | object | the rejected query, echoed | +| `error` | object | `{ code, message, details }` — `code` a short slug, `message` human-readable, `details` free-form | + +A rejected envelope carries **no `value` / `shape`** — BareBuild keeps the last good view and +surfaces the error. + +## The shape + +`shape` is a structural description of a record: which field is its identity, and the key and +type of every field it carries. BareBuild validates each response against it, so the runtime +stays domain-agnostic — it never hardcodes field names; the shape tells it what to expect. + +```json +{ + "idKey": "id", + "fields": [ + { "key": "owner", "type": "string" }, + { "key": "start", "type": "date" }, + { "key": "status", "type": "string" } + ] +} +``` + +- **`idKey`**: The field that uniquely identifies a record. Every record must have it, and ids must + be unique. +- **`fields`**: The declared, consumable fields. Each has a `key` and a `type`, one of + **`string` · `number` · `date` · `url`**. `null` is allowed for any field. + +On every accepted response BareBuild **validates the records against this shape** (id present and +unique, each declared field present, each value the declared type). A mismatch is a *contract +failure*: the response is not installed and the last good view stays. + +## Two rules to keep in mind + +### Status codes + +`accepted` and `rejected` are **both HTTP 200**. The `outcome` field carries the verdict, not +the status code. A `4xx` / `5xx` is read as a **transport failure**, not a rejection. So don't +return `400` for an invalid query, return `200` with `outcome: "rejected"`. + +### The query echo is load-bearing + +The server must **echo the query it honored**, normalized, in the accepted `query` +field. BareBuild adopts that echo as the canonical intent and writes it back to the URL. If you +drop a param you honored from the echo, BareBuild reads it as *"the server removed that param"* +and strips it from the URL. The classic symptom is a filter or sort that silently reverts. +Echo every param you honored. Omit only the ones you genuinely ignored. + +## Failure modes + +BareBuild distinguishes four, and **all keep the last good view on screen**: + +| Failure | Trigger | +|---|---| +| **rejected** | `outcome: "rejected"` with an `error` | +| **contract** | an accepted envelope whose records don't match the declared `shape` | +| **protocol** | the body isn't a valid envelope (unparseable JSON, or missing `value` + `shape` / missing `error`) | +| **network** | no response, or a non-2xx status | + +## The round-trip + +```mermaid +%%{init: {'themeVariables': {'fontSize': '16px'}}}%% +sequenceDiagram + participant C as server-resource + participant S as Server + C->>S: GET endpoint?query & requestId + alt accepted (HTTP 200) + S-->>C: outcome:accepted · query echo · value · shape · pageInfo + C->>C: validate records vs shape → render, adopt echo into URL + else rejected (HTTP 200) + S-->>C: outcome:rejected · query · error + C->>C: keep last good view · show error · revert URL + else transport failure + S--xC: non-2xx / no response + C->>C: keep last good view · retry on next intent + end +``` + +## Optional: SSR boot + +To paint on first load with no request, embed the first accepted envelope in the page as a +`\n" + " \n" + " \n" + " \n" + " \n" + " \n" + "\n"))) + +(defn- content-type-for [path] + (cond + (str/ends-with? path ".js") "text/javascript" + (str/ends-with? path ".map") "application/json" + :else "application/octet-stream")) + +(defn- serve-dist + "Serve a compiled module under dist/ so the boot page loads same-origin. Restricted to + the dist/ prefix, no path traversal." + [uri] + (let [rel (subs uri 1)] ; strip leading / + (if (and (str/starts-with? rel "dist/") + (not (str/includes? rel "..")) + (.exists (io/file rel))) + {:status 200 + :headers (merge {"content-type" (content-type-for rel)} cors-headers) + :body (slurp (io/file rel))} + (json-response 404 {:error "not-found" :uri uri})))) + +(defn handler [req] + (let [uri (:uri req)] + (cond + (= :options (:request-method req)) + {:status 204 :headers cors-headers} + + (= "/health" uri) + {:status 200 :headers (merge {"content-type" "text/plain"} cors-headers) :body "ok"} + + (= "/demo/boot" uri) + {:status 200 :headers (merge {"content-type" "text/html"} cors-headers) :body (boot-html)} + + (str/starts-with? uri "/dist/") + (serve-dist uri) + + (= "/api/tasks" uri) + (let [params (parse-query (:query-string req)) + fixture (get params "fixture")] + (cond + ;; ?fixture=… produces a specific failure mode (5a). + (some? fixture) (fixture-response fixture params) + ;; Otherwise: accepted / rejected — both HTTP 200, the outcome carries the + ;; verdict (§3.1); a 4xx would read as a network failure to the client. + (unsupported-sort? params) (json-response 200 (rejected-envelope params)) + :else (json-response 200 (accepted-envelope params)))) + + :else + (json-response 404 {:error "not-found" :uri uri})))) + +(defn -main [& _] + (http/run-server handler {:port port}) + (println (str "BareBuild dev-server listening on http://localhost:" port)) + @(promise)) diff --git a/barebuild/read_demo/dev-server/server_test.clj b/barebuild/read_demo/dev-server/server_test.clj new file mode 100644 index 00000000..d360d2b0 --- /dev/null +++ b/barebuild/read_demo/dev-server/server_test.clj @@ -0,0 +1,208 @@ +;; HTTP-level test for the dev-server: call the handler directly (no port bind) +;; and assert the §6.5 envelope. Run with `bb run test:server`. +(ns server-test + (:require [clojure.test :refer [deftest is run-tests]] + [clojure.string :as str] + [cheshire.core :as json] + [server])) + +(defn- get-raw [uri qs] + (server/handler {:request-method :get :uri uri :query-string qs})) + +(defn- get-json [uri qs] + (let [resp (get-raw uri qs)] + [(:status resp) (json/parse-string (:body resp) true)])) + +(defn- owners [body] (mapv :owner (:value body))) +(defn- starts [body] (mapv :start (:value body))) +(defn- ids [body] (mapv :id (:value body))) + +(defn- non-decreasing? [xs] + (every? #(<= (compare (first %) (second %)) 0) (partition 2 1 xs))) +(defn- non-increasing? [xs] + (every? #(>= (compare (first %) (second %)) 0) (partition 2 1 xs))) + +(deftest health-ok + (let [resp (server/handler {:request-method :get :uri "/health"})] + (is (= 200 (:status resp))) + (is (= "ok" (:body resp))))) + +(deftest get-tasks-returns-accepted-envelope + (let [[status body] (get-json "/api/tasks" "requestId=test-1")] + (is (= 200 status)) + (is (= "accepted" (:outcome body))) + (is (= "test-1" (:requestId body)) "echoes the client request id") + (is (= "tasks:v1" (:revision body))) + (is (contains? body :value) "accepted envelope carries value") + (is (contains? body :shape) "accepted envelope carries shape") + (is (not (contains? body :error)) "accepted envelope has no error") + (is (= "id" (get-in body [:shape :idKey]))) + (is (= ["owner" "start" "status"] + (->> (get-in body [:shape :fields]) (map :key) (filter #{"owner" "start" "status"}))) + "shape declares the display fields") + (is (= 10 (count (:value body))) "a page holds page-size rows") + (is (every? #(contains? % :id) (:value body)) "every row has the id-key") + (is (every? #(#{"todo" "doing" "done"} (:status %)) (:value body)) + "status is constrained to the known set") + (is (= {:page 1 :pageSize 10 :totalPages 4 :totalCount 40} (:pageInfo body)) + "pageInfo carries the server's pagination state"))) + +(deftest empty-query-echoes-empty + (let [[_ body] (get-json "/api/tasks" nil)] + (is (= {} (:query body)) "no owned params (default page 1) -> empty query echo") + (is (= (vec (range 1 11)) (ids body)) "no sort -> natural id order, first page"))) + +;; --- step 2a: sorting + normalized echo ------------------------------------ + +(deftest sort-owner-ascending + (let [[_ body] (get-json "/api/tasks" "sort=owner&direction=asc")] + (is (= {:sort "owner" :direction "asc"} (:query body)) "echoes the normalized query") + (is (= 10 (count (owners body))) "one page") + (is (= "Alice" (first (owners body))) "smallest owner first") + (is (non-decreasing? (owners body)) "page is sorted ascending"))) + +(deftest missing-direction-normalizes-to-asc + (let [[_ body] (get-json "/api/tasks" "sort=owner")] + (is (= "asc" (get-in body [:query :direction])) "server fills in the default direction") + (is (non-decreasing? (owners body)) "sorted ascending"))) + +(deftest sort-descending + (let [[_ body] (get-json "/api/tasks" "sort=start&direction=desc")] + (is (= {:sort "start" :direction "desc"} (:query body))) + (is (non-increasing? (starts body)) "reverse chronological"))) + +(deftest invalid-direction-coerces-to-asc + (let [[_ body] (get-json "/api/tasks" "sort=owner&direction=sideways")] + (is (= "asc" (get-in body [:query :direction])) "unknown direction coerced to asc") + (is (non-decreasing? (owners body))))) + +;; --- step 3a: rejection ---------------------------------------------------- + +(deftest unsupported-sort-field-is-rejected + (let [[status body] (get-json "/api/tasks" "sort=bogus&direction=asc&requestId=r9")] + (is (= 200 status) "a rejection is a protocol response, not an HTTP error") + (is (= "rejected" (:outcome body))) + (is (= "r9" (:requestId body)) "echoes the client request id") + (is (not (contains? body :value)) "rejected: no value") + (is (not (contains? body :shape)) "rejected: no shape") + (is (= "bogus" (get-in body [:query :sort])) "echoes the rejected query") + (is (= "invalid-query" (get-in body [:error :code]))) + (is (string? (get-in body [:error :message]))) + (is (= "bogus" (get-in body [:error :details :field]))))) + +(deftest valid-sort-is-not-rejected + (let [[_ body] (get-json "/api/tasks" "sort=owner")] + (is (= "accepted" (:outcome body)) "a supported field is still accepted"))) + +;; --- step 4a: paging ------------------------------------------------------- + +(deftest second-page-returns-second-slice + (let [[_ body] (get-json "/api/tasks" "page=2")] + (is (= (vec (range 11 21)) (ids body)) "page 2 is the next page-size slice") + (is (= "2" (get-in body [:query :page])) "page is echoed (as a string) when > 1") + (is (= 2 (get-in body [:pageInfo :page]))))) + +(deftest out-of-range-page-clamps-to-last + (let [[_ body] (get-json "/api/tasks" "page=99")] + (is (= (vec (range 31 41)) (ids body)) "clamped to the last page") + (is (= 4 (get-in body [:pageInfo :page]))) + (is (= "4" (get-in body [:query :page])) "echoes the clamped page"))) + +(deftest default-page-omitted-from-echo + (let [[_ body] (get-json "/api/tasks" "page=1")] + (is (nil? (get-in body [:query :page])) "the default page 1 is not echoed") + (is (= 1 (get-in body [:pageInfo :page]))))) + +(deftest sort-and-page-compose + (let [[_ body] (get-json "/api/tasks" "sort=id&direction=desc&page=1")] + (is (= {:sort "id" :direction "desc"} (:query body)) "page 1 omitted; sort echoed") + (is (= (vec (range 40 30 -1)) (ids body)) "sorted by id desc, first page"))) + +;; --- step 7a: search filtering + echo -------------------------------------- + +(deftest search-filters-and-is-echoed + (let [[_ body] (get-json "/api/tasks" "search=alice")] + (is (= {:search "alice"} (:query body)) "the term is echoed so it round-trips") + (is (= [1 11 21 31] (ids body)) "only the Alice rows survive the filter") + (is (every? #(= "Alice" %) (owners body)) "every surviving row matches") + (is (= 4 (get-in body [:pageInfo :totalCount])) "pageInfo counts the filtered set") + (is (= 1 (get-in body [:pageInfo :totalPages])) "four rows fit on one page"))) + +(deftest search-is-case-insensitive-echo-preserves-case + (let [[_ body] (get-json "/api/tasks" "search=aLiCe")] + (is (= {:search "aLiCe"} (:query body)) "echo keeps the term as typed") + (is (= [1 11 21 31] (ids body)) "matching ignores case"))) + +(deftest blank-search-is-ignored + (let [[_ body] (get-json "/api/tasks" "search=%20%20")] + (is (= {} (:query body)) "a whitespace-only term is not a filter and is not echoed") + (is (= 40 (get-in body [:pageInfo :totalCount])) "the full set is served"))) + +(deftest search-composes-with-sort + (let [[_ body] (get-json "/api/tasks" "search=alice&sort=id&direction=desc")] + (is (= {:search "alice" :sort "id" :direction "desc"} (:query body)) "both echoed") + (is (= [31 21 11 1] (ids body)) "filter then sort: Alice rows, id descending"))) + +(deftest search-narrows-pagination-across-pages + (let [[_ p1] (get-json "/api/tasks" "search=done") + [_ p2] (get-json "/api/tasks" "search=done&page=2")] + (is (= 13 (get-in p1 [:pageInfo :totalCount])) "thirteen done rows") + (is (= 2 (get-in p1 [:pageInfo :totalPages])) "spanning two filtered pages") + (is (= 10 (count (ids p1))) "first filtered page is full") + (is (= 3 (count (ids p2))) "second filtered page holds the remainder") + (is (= "2" (get-in p2 [:query :page])) "the filtered second page is echoed"))) + +(deftest search-clamps-out-of-range-page + (let [[_ body] (get-json "/api/tasks" "search=alice&page=3")] + (is (nil? (get-in body [:query :page])) "one filtered page -> clamped to 1, page omitted") + (is (= 1 (get-in body [:pageInfo :page]))) + (is (= [1 11 21 31] (ids body)) "still the four Alice rows"))) + +;; --- step 5a: failure fixtures + SSR boot ----------------------------------- + +(deftest fixture-bad-outcome-is-unknown-outcome + (let [[status body] (get-json "/api/tasks" "fixture=bad-outcome")] + (is (= 200 status)) + (is (= "banana" (:outcome body)) "an unknown outcome -> a protocol failure for the client") + (is (not (contains? body :value))) + (is (not (contains? body :shape))))) + +(deftest fixture-missing-shape-drops-a-required-member + (let [[status body] (get-json "/api/tasks" "fixture=missing-shape")] + (is (= 200 status)) + (is (= "accepted" (:outcome body))) + (is (contains? body :value) "value still present") + (is (not (contains? body :shape)) "shape missing -> protocol failure"))) + +(deftest fixture-unparseable-body-is-not-json + (let [resp (get-raw "/api/tasks" "fixture=unparseable")] + (is (= 200 (:status resp))) + (is (thrown? Exception (json/parse-string (:body resp))) + "the body is deliberately not valid JSON"))) + +(deftest fixture-contract-violates-the-shape + (let [[status body] (get-json "/api/tasks" "fixture=contract")] + (is (= 200 status)) + (is (= "accepted" (:outcome body)) "well-formed envelope...") + (is (not (contains? (first (:value body)) :status)) + "...but the first row is missing the declared 'status' field"))) + +(deftest fixture-500-is-a-transport-error + (is (= 500 (:status (get-raw "/api/tasks" "fixture=500"))) + "a non-2xx status the client reads as a network failure")) + +(deftest boot-page-embeds-the-first-response + (let [resp (get-raw "/demo/boot" nil) + body (:body resp)] + (is (= 200 (:status resp))) + (is (str/includes? (get-in resp [:headers "content-type"]) "text/html")) + (is (str/includes? body "type=\"application/json\"") "carries a JSON embed") + (is (str/includes? body "\"outcome\":\"accepted\"") "the embed is an accepted envelope"))) + +(deftest dist-serving-rejects-traversal-and-missing + (is (= 404 (:status (get-raw "/dist/../server.clj" nil))) "no path traversal") + (is (= 404 (:status (get-raw "/dist/nope.js" nil))) "missing file -> 404")) + +(defn run [] + (let [{:keys [fail error]} (run-tests 'server-test)] + (System/exit (if (pos? (+ (or fail 0) (or error 0))) 1 0)))) diff --git a/barebuild/read_demo/index.html b/barebuild/read_demo/index.html new file mode 100644 index 00000000..05d32040 --- /dev/null +++ b/barebuild/read_demo/index.html @@ -0,0 +1,59 @@ + + + + + + BareBuild — Task demo + + + +
+

BareBuild — Task demo

+

+ A read-only projection of server state: one <server-resource> fetches the + task list and fans out to several consumers — a total-count <x-stat>, a + page-position <x-progress>, a free-text <x-search-field> + filter, and a sortable, paginated <x-table>. Filter, sort a column, or + change page and the query round-trips through the server into the URL; invalid queries and + network failures keep the last good view on screen. +

+
+ +
+ + + + + + + + + + + + + + + +
+ + + + diff --git a/barebuild/read_demo/src/read_demo/demo.cljs b/barebuild/read_demo/src/read_demo/demo.cljs new file mode 100644 index 00000000..3dd03347 --- /dev/null +++ b/barebuild/read_demo/src/read_demo/demo.cljs @@ -0,0 +1,37 @@ +(ns read-demo.demo + (:require + [baredom.components.x-alert.x-alert :as x-alert] + [baredom.components.x-pagination.x-pagination :as x-pagination] + [baredom.components.x-progress.x-progress :as x-progress] + [baredom.components.x-search-field.x-search-field :as x-search-field] + [baredom.components.x-spacer.x-spacer :as x-spacer] + [baredom.components.x-stat.x-stat :as x-stat] + [baredom.components.x-table.x-table :as x-table] + [baredom.components.x-table-row.x-table-row :as x-table-row] + [baredom.components.x-table-cell.x-table-cell :as x-table-cell] + [read-demo.x-progress-consumer.x-progress-consumer :as x-progress-consumer] + [read-demo.x-search-field-consumer.x-search-field-consumer :as x-search-field-consumer] + [read-demo.x-stat-consumer.x-stat-consumer :as x-stat-consumer] + [read-demo.x-table-consumer.x-table-consumer :as x-table-consumer] + [barebuild.core :as core])) + +;; Stands in for a host app: registers the BareDOM components it drives, the demo's own +;; consumers, and then the BareBuild runtime (server-resource) via barebuild.core. +(defn ^:export init [] + ;; driven BareDOM components + (x-alert/init!) + (x-pagination/init!) + (x-progress/init!) + (x-search-field/init!) + (x-spacer/init!) + (x-stat/init!) + (x-table/init!) + (x-table-row/init!) + (x-table-cell/init!) + ;; the demo's consumers (host-app code, not part of the BareBuild product) + (x-progress-consumer/init!) + (x-search-field-consumer/init!) + (x-stat-consumer/init!) + (x-table-consumer/init!) + ;; the BareBuild runtime element + (core/init)) diff --git a/barebuild/read_demo/src/read_demo/x_progress_consumer/model.cljs b/barebuild/read_demo/src/read_demo/x_progress_consumer/model.cljs new file mode 100644 index 00000000..bc6f89cc --- /dev/null +++ b/barebuild/read_demo/src/read_demo/x_progress_consumer/model.cljs @@ -0,0 +1,12 @@ +(ns read-demo.x-progress-consumer.model) + +(def tag-name "x-progress-consumer") + +(def observed-attributes #js []) + +(def event-schema {}) + +(def method-api {}) + +(defn project-progress [{:keys [page total-pages]}] + {:value page :max total-pages}) diff --git a/barebuild/read_demo/src/read_demo/x_progress_consumer/x_progress_consumer.cljs b/barebuild/read_demo/src/read_demo/x_progress_consumer/x_progress_consumer.cljs new file mode 100644 index 00000000..e7de4c96 --- /dev/null +++ b/barebuild/read_demo/src/read_demo/x_progress_consumer/x_progress_consumer.cljs @@ -0,0 +1,23 @@ +(ns read-demo.x-progress-consumer.x-progress-consumer + (:require + [barebuild.consumer-resource :as consumer-resource] + [read-demo.x-progress-consumer.model :as model] + [baredom.utils.dom :as du])) + +(defn- on-pending! [^js x-progress pending _this] + (if pending + (du/set-attr! x-progress "indeterminate" "") + (du/remove-attr! x-progress "indeterminate"))) + +(defn- render! [^js x-progress accepted-response _this] + (let [{:keys [max value]} (model/project-progress (:page-info accepted-response))] + (du/set-attr! x-progress "max" max) + (du/set-attr! x-progress "value" value))) + +(defn init! [] + (consumer-resource/register! + {:tag model/tag-name + :child-tag "x-progress" + :observed-attributes model/observed-attributes + :on-pending on-pending! + :render render!})) diff --git a/barebuild/read_demo/src/read_demo/x_search_field_consumer/model.cljs b/barebuild/read_demo/src/read_demo/x_search_field_consumer/model.cljs new file mode 100644 index 00000000..0ac877dc --- /dev/null +++ b/barebuild/read_demo/src/read_demo/x_search_field_consumer/model.cljs @@ -0,0 +1,17 @@ +(ns read-demo.x-search-field-consumer.model) + +(def tag-name "x-search-field-consumer") + +(def observed-attributes #js []) + +(def event-schema {}) + +(def method-api {}) + +(defn translate-search-gesture + [value] + {:query-patch {:search value} :gesture-class :refinement}) + +(defn project-search-value + [accepted-response] + (get-in accepted-response [:query :search])) diff --git a/barebuild/read_demo/src/read_demo/x_search_field_consumer/x_search_field_consumer.cljs b/barebuild/read_demo/src/read_demo/x_search_field_consumer/x_search_field_consumer.cljs new file mode 100644 index 00000000..451febff --- /dev/null +++ b/barebuild/read_demo/src/read_demo/x_search_field_consumer/x_search_field_consumer.cljs @@ -0,0 +1,36 @@ +(ns read-demo.x-search-field-consumer.x-search-field-consumer + (:require + [barebuild.consumer-resource :as consumer-resource] + [read-demo.x-search-field-consumer.model :as model] + [baredom.utils.dom :as du])) + +(defn- on-pending! [^js x-search-field pending _this] + (if pending + (du/set-attr! x-search-field "aria-busy" "true") + (du/remove-attr! x-search-field "aria-busy"))) + +(defn- on-connect! [^js el] + (let [x-search-field (.querySelector el "x-search-field")] + ;; These event listeners are disposed of automatically + (.addEventListener x-search-field "x-search-field-input" + (fn [e] + (consumer-resource/submit-intent! el (model/translate-search-gesture (.. e -detail -value))))) + (.addEventListener x-search-field "x-search-field-clear" + (fn [_e] + (consumer-resource/submit-intent! el (model/translate-search-gesture "")))))) + +(defn- render! [^js x-search-field accepted-response _this] + (let [term (or (model/project-search-value accepted-response) "") + current (.-value x-search-field)] + (when (and (not= term current) + (not= x-search-field (.-activeElement js/document))) ; user is not typing + (du/set-attr! x-search-field "value" term)))) + +(defn init! [] + (consumer-resource/register! + {:tag model/tag-name + :child-tag "x-search-field" + :observed-attributes model/observed-attributes + :on-pending on-pending! + :render render! + :on-connect on-connect!})) diff --git a/barebuild/read_demo/src/read_demo/x_stat_consumer/model.cljs b/barebuild/read_demo/src/read_demo/x_stat_consumer/model.cljs new file mode 100644 index 00000000..29ac777f --- /dev/null +++ b/barebuild/read_demo/src/read_demo/x_stat_consumer/model.cljs @@ -0,0 +1,13 @@ +(ns read-demo.x-stat-consumer.model) + +(def tag-name "x-stat-consumer") + +(def observed-attributes #js []) + +(def event-schema {}) + +(def method-api {}) + +(defn project-stat + [accepted-response] + (str (get-in accepted-response [:page-info :total-count]))) diff --git a/barebuild/read_demo/src/read_demo/x_stat_consumer/x_stat_consumer.cljs b/barebuild/read_demo/src/read_demo/x_stat_consumer/x_stat_consumer.cljs new file mode 100644 index 00000000..77c694d9 --- /dev/null +++ b/barebuild/read_demo/src/read_demo/x_stat_consumer/x_stat_consumer.cljs @@ -0,0 +1,21 @@ +(ns read-demo.x-stat-consumer.x-stat-consumer + (:require + [barebuild.consumer-resource :as consumer-resource] + [read-demo.x-stat-consumer.model :as model] + [baredom.utils.dom :as du])) + +(defn- on-pending! [^js x-stat pending _this] + (if pending + (du/set-attr! x-stat "loading" "") + (du/remove-attr! x-stat "loading"))) + +(defn- render! [^js x-stat accepted-response _this] + (du/set-attr! x-stat "value" (model/project-stat accepted-response))) + +(defn init! [] + (consumer-resource/register! + {:tag model/tag-name + :child-tag "x-stat" + :observed-attributes model/observed-attributes + :on-pending on-pending! + :render render!})) diff --git a/barebuild/read_demo/src/read_demo/x_table_consumer/model.cljs b/barebuild/read_demo/src/read_demo/x_table_consumer/model.cljs new file mode 100644 index 00000000..c870ada7 --- /dev/null +++ b/barebuild/read_demo/src/read_demo/x_table_consumer/model.cljs @@ -0,0 +1,46 @@ +(ns read-demo.x-table-consumer.model) + +(def tag-name "x-table-consumer") + +(def observed-attributes #js []) + +(def event-schema {}) + +(def method-api {}) + +(defn accepted-response->view-model + [accepted-response] + (let [{:keys [query shape]} accepted-response + sort-column-name (:sort query) + sort-direction (:direction query) + {:keys [id-key label fields]} shape + columns (mapv + (fn [{:keys [key type]}] + {:key key + :label (or label key) + :type type + :sort-direction (if (= sort-column-name key) + sort-direction + "none")}) + fields) + field-keys (map :key fields) + rows (mapv + (fn [row] + {:id (get row id-key) + :cells (select-keys row field-keys)}) + (:value accepted-response))] + {:columns columns + :rows rows})) + +(defn translate-gesture + [field-key direction] + {:query-patch (if (= direction "none") + {:sort nil :direction nil} + {:sort field-key :direction direction}) + :gesture-class :refinement}) + +(defn translate-pagination-gesture + [page] + {:query-patch + {:page (str page)} + :gesture-class :navigation}) diff --git a/barebuild/read_demo/src/read_demo/x_table_consumer/x_table_consumer.cljs b/barebuild/read_demo/src/read_demo/x_table_consumer/x_table_consumer.cljs new file mode 100644 index 00000000..0db9fa81 --- /dev/null +++ b/barebuild/read_demo/src/read_demo/x_table_consumer/x_table_consumer.cljs @@ -0,0 +1,141 @@ +(ns read-demo.x-table-consumer.x-table-consumer + (:require + [barebuild.consumer-resource :as consumer-resource] + [read-demo.x-table-consumer.model :as model] + [baredom.utils.dom :as du])) + +(def ^:private k-data-field-key "data-field-key") + +(declare x-table-cell-sort-event) + +(defn- failure-message [last-failure] + (case (:failure last-failure) + :rejected (get-in last-failure [:response :error :message]) + :network "Couldn't reach the server — please try again." + :protocol "The server sent an unexpected response." + :contract "The server's data didn't match the expected format." + "Something went wrong.")) + +(defn- create-x-alert! + [{:keys [text type dismissible timeout-ms]}] + (let [alert (.createElement js/document "x-alert")] + (du/set-attr! alert "type" type) + (du/set-attr! alert "text" text) + (du/set-attr! alert "dismissible" dismissible) + (when timeout-ms + (du/set-attr! alert "timeout-ms" timeout-ms)) + alert)) + +(defn- delete-x-alert! [^js parent] + (when-let [existing (.querySelector parent "x-alert")] + (.remove existing))) + +(defn- x-pagination-page-request + [^js e] + (let [new-page (.. e -detail -page) + gesture (model/translate-pagination-gesture (str new-page)) + consumer (.closest (.-currentTarget e) "x-table-consumer")] + (consumer-resource/submit-intent! consumer gesture))) + +(defn- create-x-pagination! + [{:keys [page total-pages size]}] + (let [pagination (.createElement js/document "x-pagination")] + (du/set-attr! pagination "page" page) + (du/set-attr! pagination "total-pages" total-pages) + (du/set-attr! pagination "size" size) + (.addEventListener pagination "page-change" x-pagination-page-request) + pagination)) + +(defn- maybe-delete-x-pagination! + [^js parent] + (when-let [existing (.querySelector parent "x-pagination")] + (.remove existing))) + +(defn- maybe-set-x-pagination! + [{:keys [page total-pages]} ^js parent] + (if (or (nil? total-pages) + (<= total-pages 1)) + (maybe-delete-x-pagination! parent) + (let [existing-pagination (.querySelector parent "x-pagination")] + (if existing-pagination + (du/set-attr! existing-pagination "page" page) + (let [new-pagination (create-x-pagination! {:page page + :total-pages total-pages + :size "md"})] + (.appendChild parent new-pagination) + (du/set-attr! new-pagination "page" page)))))) + +(defn- x-table-cell-sort-event + [^js e] + (let [k (du/get-attr (.-currentTarget e) k-data-field-key) + direction (:direction (js->clj (.-detail e) :keywordize-keys true)) + consumer (.closest (.-currentTarget e) "x-table-consumer") + gesture (model/translate-gesture k direction)] + (consumer-resource/submit-intent! consumer gesture))) + +(defn- create-table-cell! + [s is-header sort-direction] + (let [cell (.createElement js/document "x-table-cell")] + (when is-header + (du/set-attr! cell "type" "header") + (du/set-attr! cell "scope" "col") + (du/set-attr! cell "sortable" "") + (du/set-attr! cell "sort-direction" sort-direction) + (.addEventListener cell "x-table-cell-sort" x-table-cell-sort-event)) + (set! (.-textContent cell) (str s)) + cell)) + +(defn- create-header-row! + [columns] + (let [row (.createElement js/document "x-table-row")] + (doseq [{k :key label :label sort-direction :sort-direction} columns] + (let [cell (create-table-cell! label true sort-direction)] + (du/set-attr! cell k-data-field-key k) + (.appendChild row cell))) + row)) + +(defn- create-body-row! + [values] + (let [row (.createElement js/document "x-table-row")] + (doseq [v values] + (let [cell (create-table-cell! v false nil)] + (.appendChild row cell))) + row)) + +(defn- render-table! + [{:keys [columns rows]} table] + (when table + (set! (.-innerHTML table) "") + (du/set-attr! table "columns" (str (count columns))) + (.appendChild table (create-header-row! columns)) + (doseq [r rows] + (let [values (map (fn [col] (get (:cells r) (:key col))) columns) + row (create-body-row! values)] + (.appendChild table row))))) + +(defn- render! [^js table accepted ^js this] + (render-table! (model/accepted-response->view-model accepted) table) + (maybe-set-x-pagination! (:page-info accepted) this)) + +(defn- on-failure! [_child failure ^js this] + (delete-x-alert! this) + (when failure + (.appendChild this (create-x-alert! {:type "error" + :dismissible true + :text (failure-message failure)})))) + +(defn- on-pending! [^js table pending _this] + (if pending + (do (du/set-attr! table "aria-busy" "true") + (set! (.. table -style -opacity) "0.6")) + (do (du/remove-attr! table "aria-busy") + (set! (.. table -style -opacity) "")))) + +(defn init! [] + (consumer-resource/register! + {:tag model/tag-name + :child-tag "x-table" + :observed-attributes model/observed-attributes + :render render! + :on-failure on-failure! + :on-pending on-pending!})) diff --git a/barebuild/read_demo/test/read_demo/x_progress_consumer_model_test.cljs b/barebuild/read_demo/test/read_demo/x_progress_consumer_model_test.cljs new file mode 100644 index 00000000..7703520b --- /dev/null +++ b/barebuild/read_demo/test/read_demo/x_progress_consumer_model_test.cljs @@ -0,0 +1,22 @@ +(ns read-demo.x-progress-consumer-model-test + "project-progress: the bounded-numeric projection — page-info to an x-progress value/max pair." + (:require [cljs.test :refer-macros [deftest is testing]] + [read-demo.x-progress-consumer.model :as model])) + +(deftest project-progress-maps-page-onto-value-and-max + (testing "page becomes the value, total-pages becomes the max" + (is (= {:value 1 :max 4} + (model/project-progress {:page 1 :total-pages 4 :page-size 10 :total-count 40})))) + (testing "a later page moves the value while max stays the page count" + (is (= {:value 3 :max 4} + (model/project-progress {:page 3 :total-pages 4 :page-size 10 :total-count 40}))))) + +(deftest project-progress-reads-only-the-two-bounded-keys + (testing "page-size and total-count are irrelevant to the bar's bounds" + (is (= {:value 2 :max 7} + (model/project-progress {:page 2 :total-pages 7}))))) + +(deftest project-progress-absent-page-info-yields-nil-bounds + (testing "a missing page-info projects nil value/max (x-progress falls back to its defaults)" + (is (= {:value nil :max nil} + (model/project-progress nil))))) diff --git a/barebuild/read_demo/test/read_demo/x_search_field_consumer_model_test.cljs b/barebuild/read_demo/test/read_demo/x_search_field_consumer_model_test.cljs new file mode 100644 index 00000000..6648022e --- /dev/null +++ b/barebuild/read_demo/test/read_demo/x_search_field_consumer_model_test.cljs @@ -0,0 +1,22 @@ +(ns read-demo.x-search-field-consumer-model-test + "translate-search-gesture / project-search-value: the refinement gesture out, and the + echoed term read back. Query keys are keywords — wire/parse-envelope runs the echo through + canonicalize-query, and the merge in resource/step canonicalizes intent-patches too." + (:require [cljs.test :refer-macros [deftest is testing]] + [read-demo.x-search-field-consumer.model :as model])) + +(deftest translate-produces-a-refinement-query-patch + (testing "a term becomes a :search query-patch classed as a refinement" + (is (= {:query-patch {:search "alice"} :gesture-class :refinement} + (model/translate-search-gesture "alice")))) + (testing "an empty term is carried as-is (canonicalize-query drops it downstream, so the + clear gesture removes the key and restores the full set)" + (is (= {:query-patch {:search ""} :gesture-class :refinement} + (model/translate-search-gesture ""))))) + +(deftest project-reads-the-echoed-term + (testing "the term comes from the accepted response's canonicalized (keyword-keyed) :query" + (is (= "alice" + (model/project-search-value {:query {:search "alice"} :value []})))) + (testing "no search echoed -> nil (the field reflects an empty filter)" + (is (nil? (model/project-search-value {:query {} :value []}))))) diff --git a/barebuild/read_demo/test/read_demo/x_stat_consumer_model_test.cljs b/barebuild/read_demo/test/read_demo/x_stat_consumer_model_test.cljs new file mode 100644 index 00000000..6dffd718 --- /dev/null +++ b/barebuild/read_demo/test/read_demo/x_stat_consumer_model_test.cljs @@ -0,0 +1,11 @@ +(ns read-demo.x-stat-consumer-model-test + "project-stat: the non-collection projection — an accepted response to a scalar string." + (:require [cljs.test :refer-macros [deftest is testing]] + [read-demo.x-stat-consumer.model :as model])) + +(deftest project-stat-reads-server-total-count + (testing "the scalar comes from the server's page-info, not from the visible rows" + (is (= "40" (model/project-stat {:page-info {:total-count 40 :page 2 :page-size 10} + :value [{"id" 11} {"id" 12}]})))) + (testing "absent page-info yields an empty string (nothing to show)" + (is (= "" (model/project-stat {:value []}))))) diff --git a/barebuild/read_demo/test/read_demo/x_table_consumer_model_test.cljs b/barebuild/read_demo/test/read_demo/x_table_consumer_model_test.cljs new file mode 100644 index 00000000..f66336d1 --- /dev/null +++ b/barebuild/read_demo/test/read_demo/x_table_consumer_model_test.cljs @@ -0,0 +1,68 @@ +(ns read-demo.x-table-consumer-model-test + (:require [cljs.test :refer-macros [deftest is testing]] + [read-demo.x-table-consumer.model :as model])) + +(def accepted + {:outcome :accepted + :request/id "req-1" + :revision "tasks:v1" + :query {:sort "owner" :direction "asc"} + :value [{"id" 1 "owner" "Alice" "start" "2026-01-05" "status" "doing" "secret" "x"} + {"id" 2 "owner" "Bob" "start" nil "status" "todo" "secret" "y"}] + :shape {:id-key "id" + :fields [{:key "owner" :type :string} + {:key "start" :type :date} + {:key "status" :type :string}]}}) + +(deftest columns-from-shape-in-declared-order + (let [{:keys [columns]} (model/accepted-response->view-model accepted)] + (is (= [{:key "owner" :label "owner" :type :string :sort-direction "asc"} + {:key "start" :label "start" :type :date :sort-direction "none"} + {:key "status" :label "status" :type :string :sort-direction "none"}] + columns) + "columns from shape.fields in order; label falls back to key; the sorted column + carries the echoed direction, others \"none\""))) + +(deftest rows-lift-id-and-project-declared-cells + (let [{:keys [rows]} (model/accepted-response->view-model accepted)] + (testing "id is lifted from each row via shape :id-key" + (is (= [1 2] (mapv :id rows)))) + (testing "cells contain only declared fields, as a map of raw values" + (is (= {"owner" "Alice" "start" "2026-01-05" "status" "doing"} + (:cells (first rows)))) + (is (not (contains? (:cells (first rows)) "secret")) "undeclared key dropped (§5.2)") + (is (not (contains? (:cells (first rows)) "id")) "id is lifted, not a cell")) + (testing "explicit nil cell is preserved (key present, value nil)" + (is (contains? (:cells (second rows)) "start")) + (is (nil? (get-in (second rows) [:cells "start"])))))) + +(deftest empty-shape-and-value + (testing "no fields / no rows -> empty columns and rows, no crash" + (is (= {:columns [] :rows []} + (model/accepted-response->view-model + {:shape {:id-key "id" :fields []} :value []}))))) + +(deftest sort-direction-annotation + (testing "the sorted column gets the echoed direction, all others get \"none\"" + (let [{:keys [columns]} (model/accepted-response->view-model + (assoc accepted :query {:sort "start" :direction "desc"}))] + (is (= ["none" "desc" "none"] (mapv :sort-direction columns))))) + (testing "no sort in the query -> every column \"none\"" + (let [{:keys [columns]} (model/accepted-response->view-model + (assoc accepted :query {}))] + (is (every? #(= "none" %) (map :sort-direction columns)))))) + +(deftest translate-gesture-builds-intent-patch + (is (= {:query-patch {:sort "owner" :direction "asc"} :gesture-class :refinement} + (model/translate-gesture "owner" "asc")) + "sort gesture -> a query-patch classified :refinement (replaces history)")) + +(deftest translate-gesture-none-clears-sort + (is (= {:query-patch {:sort nil :direction nil} :gesture-class :refinement} + (model/translate-gesture "owner" "none")) + "a \"none\" direction nils the sort keys so the canonicalized intent carries no sort")) + +(deftest translate-pagination-gesture-builds-navigation-patch + (is (= {:query-patch {:page "3"} :gesture-class :navigation} + (model/translate-pagination-gesture "3")) + "page gesture -> a :page query-patch classified :navigation (pushes history)")) diff --git a/barebuild/shadow-cljs.edn b/barebuild/shadow-cljs.edn new file mode 100644 index 00000000..3e3d15b6 --- /dev/null +++ b/barebuild/shadow-cljs.edn @@ -0,0 +1,30 @@ +;; BareBuild — fully separate build, run from inside barebuild/. +;; Imports BareDOM utils from source via the "../src" source-path (this repo's +;; shadow-cljs compiles from :source-paths + bundled CLJS, not deps.edn). +{:source-paths ["src" "test" "read_demo/src" "read_demo/test" "../src"] + + :builds + {;; ESM library output (per-component modules added as elements land). + :lib + {:target :esm + :output-dir "dist" + :modules + {:base {:entries []} + ;; Published entry: registers only BareBuild's own elements. The BareDOM + ;; components it drives are the host app's responsibility (peer dep), so they + ;; are NOT bundled here. + :core {:exports {init barebuild.core/init} + :depends-on #{:base}} + ;; Demo/dev entry: stands in for a host app — registers the BareDOM components + + ;; the demo's own consumers (bundled only in this module) and BareBuild via :core. + ;; Source lives under read_demo/ (read-demo.* ns), fully separate from the product. + :demo {:exports {init read-demo.demo/init} + :depends-on #{:core}}}} + + ;; Fast pure-CLJS unit tests under Node (the pure core is DOM-free). + ;; Element/integration tests will use a browser/karma target later. + ;; .cjs so Node runs the CommonJS test bundle despite package.json "type":"module". + :test + {:target :node-test + :output-to "out/node-tests.cjs" + :ns-regexp "-test$"}}} diff --git a/barebuild/src/barebuild/consumer_resource.cljs b/barebuild/src/barebuild/consumer_resource.cljs new file mode 100644 index 00000000..61d0a5e1 --- /dev/null +++ b/barebuild/src/barebuild/consumer_resource.cljs @@ -0,0 +1,56 @@ +(ns barebuild.consumer-resource + (:require + [barebuild.resource :as resource] + [baredom.utils.component :as component] + [baredom.utils.dom :as du])) + +(def ^:private k-child "__xConsumerChild") +(def ^:private k-last-rendered "__xConsumerLastRendered") +(def ^:private k-last-failure "__xConsumerLastFailure") +(def ^:private k-pending "__xConsumerPending") +(def ^:private k-submit-intent "__xConsumerSubmitIntent") + +(defn submit-intent! + "Send an intent patch from a gesture handler back to the server-resource." + [^js consumer patch] + ((du/getv consumer k-submit-intent) patch)) + +(defn- install-apply-resource! [^js proto render on-failure on-pending] + (.defineProperty js/Object proto "applyResource" + #js {:value + (fn apply-resource [resource-value ctx] + (this-as + ^js this + (du/setv! this k-submit-intent (:submit-intent! ctx)) + (let [{:keys [last-accepted last-failure]} resource-value + pending (resource/pending? resource-value)] + (when (and on-failure (not= last-failure (du/getv this k-last-failure))) + (on-failure (du/getv this k-child) last-failure this) + (du/setv! this k-last-failure last-failure)) + (when (and last-accepted (not= last-accepted (du/getv this k-last-rendered))) + (render (du/getv this k-child) last-accepted this) + (du/setv! this k-last-rendered last-accepted)) + (when (and on-pending (not= pending (du/getv this k-pending))) + (on-pending (du/getv this k-child) pending this) + (du/setv! this k-pending pending)) ))) + :writable true :configurable true})) + +(defn register! + "Register a resource-consumer custom element from a config: + :tag element tag name + :child-tag the driven child element, cached on connect + :render (fn [child accepted this]) — called when :last-accepted changes + :on-failure (fn [child failure this]) — optional; called when :last-failure changes, + with failure nil on recovery so the component can clear its UI + :on-pending (fn [child pending this]) — optional; called when :pending? changes + :on-connect (fn [this]) — optional extra wiring + :observed-attributes — optional, defaults to #js []" + [{:keys [tag child-tag render on-failure on-pending on-connect observed-attributes]}] + (component/register! + tag + {:observed-attributes (or observed-attributes #js []) + :connected-fn (fn [^js el] + (du/setv! el k-child (.querySelector el child-tag)) + (when on-connect (on-connect el))) + :attribute-changed-fn (fn [_el _name _old _new] nil) + :setup-prototype-fn (fn [^js proto] (install-apply-resource! proto render on-failure on-pending))})) diff --git a/barebuild/src/barebuild/core.cljs b/barebuild/src/barebuild/core.cljs new file mode 100644 index 00000000..c5ded575 --- /dev/null +++ b/barebuild/src/barebuild/core.cljs @@ -0,0 +1,9 @@ +(ns barebuild.core + (:require + [barebuild.elements.server-resource.server-resource :as server-resource])) + +;; Registers BareBuild's runtime element(s). Consumers are host-app code — the demo +;; registers its own (see read-demo.demo); a real adopter authors theirs with +;; barebuild.consumer-resource/register!. +(defn ^:export init [] + (server-resource/init!)) diff --git a/barebuild/src/barebuild/elements/server_resource/model.cljs b/barebuild/src/barebuild/elements/server_resource/model.cljs new file mode 100644 index 00000000..6b1409a5 --- /dev/null +++ b/barebuild/src/barebuild/elements/server_resource/model.cljs @@ -0,0 +1,11 @@ +(ns barebuild.elements.server-resource.model) + +(def tag-name "server-resource") + +(def attr-src "src") + +(def observed-attributes #js []) + +(def event-schema {}) + +(def method-api {}) diff --git a/barebuild/src/barebuild/elements/server_resource/server_resource.cljs b/barebuild/src/barebuild/elements/server_resource/server_resource.cljs new file mode 100644 index 00000000..277f72fb --- /dev/null +++ b/barebuild/src/barebuild/elements/server_resource/server_resource.cljs @@ -0,0 +1,170 @@ +(ns barebuild.elements.server-resource.server-resource + (:require + [barebuild.wire :as wire] + [barebuild.resource :as resource] + [barebuild.elements.server-resource.model :as model] + [barebuild.utils :as utils] + [baredom.utils.component :as component] + [baredom.utils.dom :as du] + [clojure.string :as str])) + +;; ── Instance-field keys ────────────────────────────────────────────────────── +(def ^:private k-resource "__xServerResource") +(def ^:private k-consumers "__xConsumers") +(def ^:private k-popstate "__xPopstate") +(def ^:private k-abort "__xAbort") +(declare handle-event!) + +(defn- construct-url-intent [resource-id] + (let [current-url-params (js/URLSearchParams. (.-search js/location)) + prefix (str resource-id ".") + resource-id-keys (filterv #(str/starts-with? % prefix) + (js/Array.from (.keys current-url-params)))] + (utils/canonicalize-query + (into {} + (for [k resource-id-keys] + [(keyword (subs k (count prefix))) + (.get current-url-params k)]))))) + +(defn- handle-popstate [^js el resource-id] + (handle-event! el [:url-changed (construct-url-intent resource-id)])) + +;; ── The engine ─────────────────────────────────────────────────────────────── + +(defn- map->query-params + "Take a map of k v and turn it into a url query parameter string + e.g. {tasks.sort \"end\"} -> \"tasks.sort=end\"" + [m] + (let [params (js/URLSearchParams.)] + (doseq [[k v] m] + (.append params (name k) (str v))) + (.toString params))) + +(defn- execute-fetch! [^js el url request-id] + ;; Transient transport handle: the AbortController lives outside every value (§6.3); + ;; the lifecycle it tracks is traced through :active-request + the dispatched events. + (let [controller (js/AbortController.)] + (du/setv-untraced! el k-abort controller) + (-> (js/fetch url #js {:signal (.-signal controller)}) + (.then (fn [^js resp] + (if (.-ok resp) + (.text resp) ; text, not .json + (throw (js/Error. (str "HTTP " (.-status resp))))))) + (.then (fn [^js body] + (let [obj (try (js/JSON.parse body) (catch :default _ nil)) + result (wire/parse-envelope obj)] ; obj nil -> empty-body marker + (if (:protocol-failure result) + (handle-event! el [:protocol-failed (assoc result :request/id request-id)]) + (handle-event! el [:response result]))))) + (.catch (fn [^js e] + ;; an aborted fetch is intentional (disconnect / supersede), not a failure + (when-not (= "AbortError" (.-name e)) + (handle-event! el [:network-failed {:request/id request-id :error {:kind :offline}}]))))))) + +(defn- run-effects! + [^js el effects] + (doseq [[fx m] effects] + (case fx + :fetch + (let [request-id (:request/id m) + query (:query m) + ;; turn the map of k-v pairs into a query param + query-params (map->query-params query) + url (str + (:endpoint m) + "?requestId=" + request-id + (when-not (str/blank? query-params) + (str "&" query-params)))] + (execute-fetch! el url request-id)) + + :notify-consumers + (let [r (:resource m) + consumers (du/getv el k-consumers) + ctx {:submit-intent! (fn [patch] (handle-event! el [:intent-patch patch]))}] + (doseq [^js c consumers] + (.applyResource c r ctx))) + + :url-write + (let [new-url (utils/build-scoped-url (.-search js/location) + (.-pathname js/location) + (:resource/id m) + (:params m))] + (if (= (:mode m) :push) + (.pushState js/history nil "" new-url) + (.replaceState js/history nil "" new-url))) + + :abort + (when-let [^js controller (du/getv el k-abort)] + (.abort controller) + (du/setv-untraced! el k-abort nil)) + + nil))) + +(defn- handle-event! + [^js el event] + (let [r (du/getv el k-resource) + {:keys [resource effects]} (resource/step r event)] + (du/setv! el k-resource resource) + (run-effects! el effects))) + +(defn- read-boot-embed + "Read the