├── .gitignore ├── Caddyfile ├── LICENSE ├── README.md ├── deps.edn ├── dev └── scratch.clj ├── examples ├── README.md ├── billion_checkboxes │ ├── README.md │ ├── build.clj │ ├── deps.edn │ ├── resources │ │ └── .env.edn │ └── src │ │ └── app │ │ └── main.clj ├── chat_atom │ ├── README.md │ ├── build.clj │ ├── deps.edn │ ├── resources │ │ └── .env.edn │ └── src │ │ └── app │ │ └── main.clj ├── communtative_connected_count │ ├── README.md │ ├── build.clj │ ├── deps.edn │ ├── resources │ │ └── .env.edn │ └── src │ │ └── app │ │ └── main.clj ├── drag_drop │ ├── README.md │ ├── build.clj │ ├── deps.edn │ ├── resources │ │ └── .env.edn │ └── src │ │ └── app │ │ └── main.clj ├── game_of_life │ ├── README.md │ ├── build.clj │ ├── deps.edn │ ├── resources │ │ └── .env.edn │ └── src │ │ └── app │ │ ├── game.clj │ │ └── main.clj ├── one_million_checkboxes │ ├── README.md │ ├── build.clj │ ├── deps.edn │ ├── resources │ │ └── .env.edn │ └── src │ │ └── app │ │ └── main.clj ├── popover │ ├── README.md │ ├── build.clj │ ├── deps.edn │ ├── resources │ │ └── .env.edn │ └── src │ │ └── app │ │ └── main.clj ├── presence_cursors │ ├── README.md │ ├── build.clj │ ├── deps.edn │ ├── resources │ │ └── .env.edn │ └── src │ │ └── app │ │ └── main.clj └── server-setup.sh ├── resources ├── datastar.js └── datastar.js.map └── src └── hyperlith ├── core.clj ├── extras └── sqlite.clj └── impl ├── assets.clj ├── batch.clj ├── blocker.clj ├── brotli.clj ├── cache.clj ├── codec.clj ├── crypto.clj ├── css.clj ├── datastar.clj ├── env.clj ├── error.clj ├── headers.clj ├── html.clj ├── http.clj ├── json.clj ├── load_testing.clj ├── namespaces.clj ├── params.clj ├── router.clj ├── session.clj ├── trace.clj └── util.clj /.gitignore: -------------------------------------------------------------------------------- 1 | **/db/ 2 | **/database.db* 3 | .* 4 | !.github 5 | !.gitignore 6 | .clj-kondo 7 | !.clj-kondo/config.edn 8 | !.clj-kondo/app/* 9 | !.env.edn -------------------------------------------------------------------------------- /Caddyfile: -------------------------------------------------------------------------------- 1 | # Caddy file for testing with https locally 2 | # 3 | # You can install Caddy with: 4 | # 5 | # $ brew install caddy 6 | # 7 | # You can start caddy with in the project root directory: 8 | # 9 | # $ Caddy run 10 | # 11 | # Then start your project at the server at repl on port 8080 12 | # 13 | localhost:3030 { 14 | reverse_proxy localhost:8080 { 15 | # If localhost:8080 is not responding retry every second for 16 | # 30 seconds. This stops deployments from breaking SSE connections. 17 | lb_try_duration 30s 18 | lb_try_interval 1s 19 | } 20 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Anders Murphy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hyperlith: the hypermedia based monolith 2 | 3 | This is a small and very opinionated fullstack [Datastar](https://data-star.dev/) framework mostly 4 | for my personal use. However, I felt there were some ideas in here that were worth sharing. 5 | 6 | Hyperlith only uses a subset of Datastar's feartures. If you want a production ready full featured Datastar Clojure SDK use [the official SDK](https://github.com/starfederation/datastar/tree/main/sdk/clojure). 7 | 8 | >⚠️ **WARNING:** API can change at any time! Use at your own risk. 9 | 10 | ## Goals / Priorities 11 | 12 | - **Opinionated** - does not try to be a general solution. 13 | - **Server driven** - push based multiplayer and streaming updates from day 1. 14 | - **Production REPL first** - a running system should be simple to: introspect, modify and patch. 15 | - **Minimal dependencies** - ensures the project is robust and secure long term. 16 | - **Operationally Simple** - handles all operational tasks in process. 17 | - **Sovereign** - can be deployed on any VPS provider. 18 | 19 | ## F.A.Q 20 | 21 | **Q:** *Why do I get 403 when running the examples locally in Chrome or Safari?* 22 | 23 | **A:** *The session and csrf cookies use `Secure` this means that these cookies won't be set on localhost when using chrome or safari (as they require HTTPS) . If you want to use Chrome or Safari for local development you can run `caddy run` to start a local https proxy on https://localhost:3030. The local `caddyfile` can be [found here](https://github.com/andersmurphy/hyperlith/blob/master/Caddyfile). The goal is for development to be as close to production as possible, and Hyperlith is designed to be run behind a reverse proxy.* 24 | 25 | ## Rational (more like a collection of opinions) 26 | 27 | #### Why large/fat/main morphs (immediate mode)? 28 | 29 | By only using `data: mergeMode morph` and always targeting the `main` element of the document the API can be massively simplified. This avoids having the explosion of endpoints you get with HTMX and makes reasoning about your app much simpler. 30 | 31 | #### Why have single render function per page? 32 | 33 | By having a single render function per page you can simplify the reasoning about your app to `view = f(state)`. You can then reason about your pushed updates as a continuous signal rather than discrete event stream. The benefit of this is you don't have to handle missed events, disconnects and reconnects. When the state changes on the server you push down the latest view, not the delta between views. On the client idiomorph can translate that into fine grained dom updates. 34 | 35 | #### Why re-render on any database change? 36 | 37 | When your events are not homogeneous, you can't miss events, so you cannot throttle your events without losing data. 38 | 39 | But, wait! Won't that mean every change will cause all users to re-render? Yes, but at a maximum rate determined by the throttle. This, might sound scary at first but in practice: 40 | 41 | - The more shared views the users have the more likely most of the connected users will have to re-render when a change happen. 42 | 43 | - The more events that are happening the more likely most users will have to re-render. 44 | 45 | This means you actually end up doing more work with a non homogeneous event system under heavy load than with this simple homogeneous event system that's throttled (especially it there's any sort of common/shared view between users). 46 | 47 | #### Why no diffing? 48 | 49 | In theory you can optimise network and remove the need for idiomorph if you do diffing between the last view and the current view. However, in practice because the SSE stream is being compressed for the duration of a connection and html compresses really well you get amazing compression (reduction in size by 90-100x! Sometimes more) over a series of view re-renders. The compression is so good that in my experience it's more network efficient and more performant that fine grained updates with diffing (without any of the additional complexity). 50 | 51 | This approach avoids the additional challenges of view and session maintenance (increased server load and memory usage). 52 | 53 | My suspicion is websocket approaches in this space like Phoenix Liveview haven't stumbled across this because you don't get compression out of the box with websockets, and idiomorph is a relatively new invention. Intuitively you would think the diffing approach would be more performant so you wouldn't even consider this approach. 54 | 55 | #### Signals are only for ephemeral client side state 56 | 57 | Signals should only be used for ephemeral client side state. Things like: the current value of a text input, whether a popover is visible, current csrf token, input validation errors. Signals can be controlled on the client via expressions, or from the backend via `merge-signals`. 58 | 59 | #### Signals in fragments should be declared __ifmissing 60 | 61 | Because signals are only being used to represent ephemeral client state that means they can only be initialised by fragments and they can only be changed via expressions on the client or from the server via `merge-signals` in an action. Signals in fragments should be declared `__ifmissing` unless they are "view only". 62 | 63 | #### View only signals 64 | 65 | View only signals, are signals that can only be changed by the server. These should not be declared `__ifmissing` instead they should be made "local" by starting their key with an `_` this prevents the client from sending them up to the server. 66 | 67 | #### Actions should not update the view themselves directly 68 | 69 | Actions should not update the view via merge fragments. This is because the changes they make would get overwritten on the next `render-fn` that pushes a new view down the updates SSE connection. However, they can still be used to update signals as those won't be changed by fragment merges. This allows you to do things like validation on the server. 70 | 71 | #### Stateless 72 | 73 | The only way for actions to affect the view returned by the `render-fn` running in a connection is via the database. The ensures CQRS. This means there is no connection state that needs to be persisted or maintained (so missed events and shutdowns/deploys will not lead to lost state). Even when you are running in a single process there is no way for an action (command) to communicate with/affect a view render (query) without going through the database. 74 | 75 | #### CQRS 76 | 77 | - Actions modify the database and return a 204 or a 200 if they `merge-signals`. 78 | - Render functions re-render when the database changes and send an update down the updates SSE connection. 79 | 80 | #### Work sharing (caching) 81 | 82 | Work sharing is the term I'm using for sharing renders between connected users. This can be useful when a lot of connected users share the same view. For example a leader board, game board, presence indicator etc. It ensures the work (eg: query and html generation) for that view is only done once regardless of the number of connected users. 83 | 84 | There's a lot of ways you can do this. I've settled on a simple cache that gets invalidate when a `:refresh-event` is fired. This means the cache is invalidated at most every X msec (determined by `:max-refresh-ms`) and only if the db state has changed. 85 | 86 | To add something to the cache wrap the function in the `cache` higher order function. 87 | 88 | #### Batching 89 | 90 | Batching pairs really well with CQRS as you have a resolution window, this defines the maximum frequency the view can update, or in other terms the granularity/resolution of the view. Batching can generally be used to improve throughput by batching changes. 91 | 92 | However, there is one downsides with batching to keep in mind and that is you don't get atomic transactions. The transaction move to the batch level, not the transact/insert level. Transaction matter when you are dealing with constraints you want to deal with at the database level, classic example is accounting systems or ledgers where you want to be able to fail an atomic transaction that violates a constraint (like user balance going negative). The problem with batching is that that transaction constraint failure, fails the whole batch not only the transact that was culpable. 93 | 94 | #### Use `data-on-pointerdown/mous` over `data-on-click` 95 | 96 | This is a small one but can make even the slowest of networks feel much snappier. 97 | 98 | ## Other Radical choices 99 | 100 | #### No CORS 101 | 102 | By hosting all assets on the same origin we avoid the need for CORS. This avoids additional server round trips and helps reduce latency. 103 | 104 | #### Cookie based sessions 105 | 106 | Hyperlith uses a simple unguessable random uid for managing sessions. This should be used to look up further auth/permission information in the database. 107 | 108 | #### CSRF 109 | 110 | Double submit cookie pattern is used for CSRF. 111 | 112 | #### Rendering an initial shim 113 | 114 | Rather than returning the whole page on initial render and having two render paths, one for initial render and one for subsequent rendering a shell is rendered and then populated when the page connects to the updates endpoint for that page. This has a few advantages: 115 | 116 | - The page will only render dynamic content if the user has javascript and first party cookies enabled. 117 | 118 | - The initial shell page can generated and compressed once. 119 | 120 | - The server only does more work for actual users and less work for link preview crawlers and other bots (that don't support javascript or cookies). 121 | 122 | #### Routing 123 | 124 | Router is a simple map, this means path parameters are not supported use query parameters or body instead. I've found over time that path parameters force you to adopt an arbitrary hierarchy that is often wrong (and place oriented programming). Removing them avoids this and means routing can be simplified to a map and have better performance than a more traditional adaptive radix tree router. 125 | 126 | >📝 Note: The Hyperlith router is completely optional and you can swap it out for reitit if you want to support path params. 127 | 128 | #### Reverse proxy 129 | 130 | Hyperlith is designed to be deployed between a reverse proxy like caddy for handling HTTP2/3 (you want to be using HTTP2/3 with SSE). 131 | 132 | #### Minimal middleware 133 | 134 | Hyperlith doesn't expose middleware and keeps the internal middleware to a minimum. 135 | 136 | #### Minimal dependencies 137 | 138 | Hyperlith tries to keep dependencies to a minimum. 139 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src" "resources"] 2 | :deps 3 | {org.clojure/clojure {:mvn/version "1.12.0"} 4 | org.clojure/data.json {:mvn/version "2.5.1"} 5 | org.clojure/core.async {:mvn/version "1.7.701"} 6 | 7 | ;; HTTP 8 | http-kit/http-kit {:mvn/version "2.9.0-beta1"} 9 | 10 | ;; COMPRESSION 11 | com.aayushatharva.brotli4j/brotli4j {:mvn/version "1.18.0"} 12 | ;; Assumes you deploy uberjar on linux x86_64 13 | com.aayushatharva.brotli4j/native-linux-x86_64 {:mvn/version "1.18.0"} 14 | io.netty/netty-buffer {:mvn/version "4.1.119.Final"} 15 | 16 | ;; TEMPLATING 17 | dev.onionpancakes/chassis {:mvn/version "1.0.365"} 18 | 19 | ;; SQLITE 20 | andersmurphy/sqlite4clj 21 | {:git/url "https://github.com/andersmurphy/sqlite4clj" 22 | :git/sha "2c6173596d2fe47226467a18fb0b863cea289b5e"} 23 | com.github.seancorfield/honeysql {:mvn/version "2.7.1295"}} 24 | :aliases 25 | {:dev 26 | {:extra-paths ["dev"] 27 | :extra-deps 28 | {com.aayushatharva.brotli4j/native-osx-x86_64 {:mvn/version "1.18.0"} 29 | com.aayushatharva.brotli4j/native-osx-aarch64 {:mvn/version "1.18.0"} 30 | com.aayushatharva.brotli4j/native-linux-aarch64 {:mvn/version "1.18.0"}}}}} 31 | -------------------------------------------------------------------------------- /dev/scratch.clj: -------------------------------------------------------------------------------- 1 | (ns scratch 2 | (:require [hyperlith.core :as h])) 3 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Server setup and management 2 | 3 | ## Add a record to DNS 4 | 5 | Add an A record @ which points to the IPV4 address of the VPS. For IPV6 add a AAAA record @ which points to the IPV6 address of the VPS. 6 | 7 | ## Initial server setup 8 | 9 | Move the setup script to the server: 10 | 11 | ```bash 12 | scp server-setup.sh root@example.andersmurphy.com: 13 | ``` 14 | 15 | ssh into server as root: 16 | 17 | ```bash 18 | ssh root@example.andersmurphy.com 19 | ``` 20 | 21 | run bash script: 22 | 23 | ```bash 24 | bash server-setup.sh 25 | ``` 26 | 27 | follow instructions. 28 | 29 | ## Caddy service 30 | 31 | Check status: 32 | 33 | ```bash 34 | systemctl status caddy 35 | ``` 36 | 37 | Reload config without downtime. 38 | 39 | ```bash 40 | systemctl reload caddy 41 | ``` 42 | 43 | Docs: https://caddyserver.com/docs/running#using-the-service 44 | 45 | ## Useful systemd commands 46 | 47 | Check status of service. 48 | 49 | ```bash 50 | systemctl status app.service 51 | ``` 52 | 53 | Restart service manually: 54 | 55 | ```bash 56 | systemctl restart app.service 57 | ``` 58 | -------------------------------------------------------------------------------- /examples/billion_checkboxes/README.md: -------------------------------------------------------------------------------- 1 | ## Build JAR. 2 | 3 | ```bash 4 | clojure -Srepro -T:build uber 5 | ``` 6 | 7 | ## Run jar locally 8 | 9 | ``` 10 | java -Dclojure.server.repl="{:port 5555 :accept clojure.core.server/repl}" -jar target/app.jar -m app.main -Duser.timezone=UTC -XX:+UseZGC -XX:+ZGenerational 11 | ``` 12 | 13 | ## Deploy 14 | 15 | Move JAR to server (this will trigger a service restart). 16 | 17 | ```bash 18 | scp target/app.jar root@checkboxes.andersmurphy.com:/home/app/ 19 | ``` 20 | 21 | ## After deploying first jar 22 | 23 | Optional: the first time you move the jar onto the server you will need to reboot to trigger/test systemd is working correctly. 24 | 25 | ``` 26 | ssh root@checkboxes.andersmurphy.com "reboot" 27 | ``` 28 | 29 | ## SSH into repl 30 | 31 | ```bash 32 | ssh root@checkboxes.andersmurphy.com "nc localhost:5555" 33 | ``` 34 | 35 | -------------------------------------------------------------------------------- /examples/billion_checkboxes/build.clj: -------------------------------------------------------------------------------- 1 | (ns build 2 | (:require [clojure.tools.build.api :as b])) 3 | 4 | (def lib 'app) 5 | (def class-dir "target/classes") 6 | (def basis (delay (b/create-basis {:project "deps.edn"}))) 7 | (def uber-file (format "target/%s.jar" (name lib))) 8 | 9 | (defn clean [_] (b/delete {:path "target"})) 10 | 11 | (defn uber 12 | [_] 13 | (clean nil) 14 | (b/copy-dir {:src-dirs ["src" "resources"] :target-dir class-dir}) 15 | (b/compile-clj {:basis @basis 16 | :ns-compile '[app.main] 17 | :src-dirs ["src"] 18 | :class-dir class-dir 19 | :java-opts ;; needed for coffi 20 | ["--enable-native-access=ALL-UNNAMED"]}) 21 | (b/uber {:class-dir class-dir 22 | :uber-file uber-file 23 | :basis @basis 24 | :main 'app.main 25 | :manifest {"Enable-Native-Access" "ALL-UNNAMED"}})) 26 | -------------------------------------------------------------------------------- /examples/billion_checkboxes/deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src" "resources"] 2 | :deps {org.clojure/clojure {:mvn/version "1.12.0"} 3 | hyperlith/hyperlith {:local/root "../../../hyperlith"}} 4 | :aliases {:dev {:jvm-opts 5 | ["--enable-native-access=ALL-UNNAMED" 6 | "-Duser.timezone=UTC" 7 | "-XX:+UseZGC" 8 | "-XX:+ZGenerational"]} 9 | :build {:deps {io.github.clojure/tools.build 10 | {:git/tag "v0.10.5" :git/sha "2a21b7a"}} 11 | :ns-default build}}} 12 | -------------------------------------------------------------------------------- /examples/billion_checkboxes/resources/.env.edn: -------------------------------------------------------------------------------- 1 | {;; WARNING: .env.edn should not normally be committed to source control 2 | ;; but is here as an example. 3 | :csrf-secret "fb1704df2b3484223cb5d2a79bf06a508311d8d0f03c68e724d555b6b605966d0ebb8dc54615f8d080e5fa062bd3b5bce5b6ba7ded23333bbd55deea3149b9d5"} 4 | -------------------------------------------------------------------------------- /examples/billion_checkboxes/src/app/main.clj: -------------------------------------------------------------------------------- 1 | (ns app.main 2 | (:gen-class) 3 | (:require [hyperlith.core :as h] 4 | [hyperlith.extras.sqlite :as d])) 5 | 6 | ;; (* 198 198 16 16) 10 036 224 7 | ;; (* 625 625 16 16) 100 000 000 8 | ;; (* 1977 1977 16 16) 1 000 583 424 9 | 10 | (def board-size 1977 #_625 #_198) 11 | (def chunk-size 16) 12 | (def board-size-px (* 3 3 120000)) 13 | 14 | (def states 15 | [0 1 2 3 4 5 6]) 16 | 17 | (def state->class 18 | ["none" "r" "b" "g" "o" "f" "p"]) 19 | 20 | (def css 21 | (let [black :black 22 | board-size-px (str board-size-px "px")] 23 | (h/static-css 24 | [["*, *::before, *::after" 25 | {:box-sizing :border-box 26 | :margin 0 27 | :padding 0}] 28 | 29 | [:html 30 | {:font-family "Arial, Helvetica, sans-serif" 31 | :font-size :18px 32 | :color black}] 33 | 34 | [:.main 35 | {:height :100dvh 36 | :margin-inline :auto 37 | :padding-block :2dvh 38 | :display :flex 39 | :width "min(100% - 2rem , 42rem)" 40 | :gap :5px 41 | :flex-direction :column}] 42 | 43 | [:.view 44 | {:overflow :scroll 45 | :overflow-anchor :none 46 | :width "min(100% - 2rem , 42rem)" 47 | :aspect-ratio "1/1"}] 48 | 49 | [:.board 50 | {:background :white 51 | :width board-size-px 52 | :display :grid 53 | :aspect-ratio "1/1" 54 | :gap :10px 55 | :grid-template-rows (str "repeat(" board-size ", 1fr)") 56 | :grid-template-columns (str "repeat(" board-size ", 1fr)")}] 57 | 58 | [:.chunk 59 | {:background :white 60 | :display :grid 61 | :gap :10px 62 | :grid-template-rows (str "repeat(" chunk-size ", 1fr)") 63 | :grid-template-columns (str "repeat(" chunk-size ", 1fr)")}] 64 | 65 | ["input[type=\"checkbox\"]" 66 | {:appearance :none 67 | :margin 0 68 | :font :inherit 69 | :color :currentColor 70 | :border "0.15em solid currentColor" 71 | :border-radius :0.15em 72 | :display :grid 73 | :place-content :center}] 74 | 75 | ["input[type=\"checkbox\"]:checked::before" 76 | {:content "\"\"" 77 | :width "0.80em" 78 | :height "0.80em" 79 | :clip-path "polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%)" 80 | :box-shadow "inset 1em 1em white"}] 81 | 82 | [:.pop 83 | {:transform "scale(0.8)" 84 | :transition "scale 0.6s ease"}] 85 | 86 | [:.r {:background-color :red}] 87 | [:.b {:background-color :blue}] 88 | [:.g {:background-color :green}] 89 | [:.o {:background-color :orange}] 90 | [:.f {:background-color :fuchsia}] 91 | [:.p {:background-color :purple}]]))) 92 | 93 | (defn Checkbox [local-id state] 94 | (let [checked (not= state 0) 95 | color-class (state->class state)] 96 | (h/html 97 | [:input 98 | {:class (when checked color-class) 99 | :type "checkbox" 100 | :checked checked 101 | :data-id local-id}]))) 102 | 103 | (defn chunk-id->xy [chunk-id] 104 | [(rem chunk-id board-size) 105 | (quot chunk-id board-size)]) 106 | 107 | (defn xy->chunk-id [x y] 108 | (+ x (* y board-size))) 109 | 110 | (defn xy->chunk-ids [x y] 111 | (-> (for [x (range x (+ x 3)) 112 | y (range y (+ y 3))] 113 | (xy->chunk-id x y)) 114 | vec)) 115 | 116 | (defn Chunk [chunk-id chunk-cells] 117 | (let [[x y] (chunk-id->xy chunk-id) 118 | x (inc x) 119 | y (inc y)] 120 | (h/html 121 | [:div.chunk 122 | {:id chunk-id 123 | :data-id chunk-id 124 | :style {:grid-column x :grid-row y}} 125 | (into [] 126 | (map-indexed (fn [local-id box] (Checkbox local-id box))) 127 | chunk-cells)]))) 128 | 129 | (defn UserView [{:keys [x y] :or {x 0 y 0}} db] 130 | (->> (d/q db 131 | {:select [:chunk-id [[:json_group_array :state] :chunk-cells]] 132 | :from :cell 133 | :where [:in :chunk-id (xy->chunk-ids x y)] 134 | :group-by [:chunk-id]}) 135 | (mapv (fn [[chunk-id chunk-cells]] 136 | (Chunk chunk-id (h/json->edn chunk-cells)))))) 137 | 138 | (def mouse-down-js 139 | (str 140 | "evt.target.parentElement.dataset.id &&" 141 | "(evt.target.classList.add('pop')," 142 | "@post(`/tap?id=${evt.target.dataset.id}&pid=${evt.target.parentElement.dataset.id}`))")) 143 | 144 | (defn Board [content] 145 | (h/html 146 | [:div#board.board 147 | {:data-on-mousedown mouse-down-js} 148 | content])) 149 | 150 | (defn scroll-offset-js [n] 151 | (str "Math.round((" n "/" board-size-px ")*" board-size "-1)")) 152 | 153 | (def on-scroll-js 154 | (str 155 | "let x = " (scroll-offset-js "el.scrollLeft") ";" 156 | "let y = " (scroll-offset-js "el.scrollTop") ";" 157 | "let change = x !== $x || y !== $y;" 158 | "$x = x; $y = y;" 159 | "change && @post(`/scroll`)")) 160 | 161 | (defn render-home [{:keys [db sid tab tabid first-render] :as _req}] 162 | (let [user (get-in @tab [sid tabid] tab) 163 | board (Board (UserView user db))] 164 | (if first-render 165 | (h/html 166 | [:link#css {:rel "stylesheet" :type "text/css" :href (css :path)}] 167 | [:main#morph.main {:data-signals-x "0" :data-signals-y "0"} 168 | [:div#view.view 169 | {:data-on-scroll__throttle.100ms.trail.noleading on-scroll-js} 170 | board] 171 | [:h1 "One Billion Checkboxes"] 172 | [:p "(actually 1,000,583,424)"] 173 | [:p "Built with ❤️ using " 174 | [:a {:href "https://clojure.org/"} "Clojure"] 175 | " and " 176 | [:a {:href "https://data-star.dev"} "Datastar"] 177 | "🚀"] 178 | [:p "Source code can be found " 179 | [:a {:href "https://github.com/andersmurphy/hyperlith/blob/master/examples/billion_checkboxes/src/app/main.clj" } "here"]]]) 180 | board))) 181 | 182 | (defn action-tap-cell 183 | [{:keys [sid tx-batch!] 184 | {:strs [id pid]} :query-params}] 185 | (when (and id pid) 186 | (let [user-color (h/modulo-pick (subvec states 1) sid) 187 | cell-id (int (parse-long id)) 188 | chunk-id (int (parse-long pid))] 189 | (tx-batch! 190 | (fn action-tap-cell-thunk [db] 191 | (let [[checks] (d/q db {:select [:checks] 192 | :from :session 193 | :where [:= :id sid]})] 194 | (if checks 195 | (d/q db {:update :session 196 | :set {:checks (inc checks)} 197 | :where [:= :id sid]}) 198 | (d/q db {:insert-into :session 199 | :values [{:id sid :checks 1}]}))) 200 | (let [[state] (d/q db {:select [:state] 201 | :from :cell 202 | :where 203 | [:and 204 | [:= :chunk-id chunk-id] 205 | [:= :cell-id cell-id]]}) 206 | new-state (if (= 0 state) user-color 0)] 207 | (d/q db {:update :cell 208 | :set {:state new-state} 209 | :where [:and 210 | [:= :chunk-id chunk-id] 211 | [:= :cell-id cell-id]]}))))))) 212 | 213 | (defn action-scroll [{:keys [sid tabid tab] {:keys [x y]} :body}] 214 | (swap! tab 215 | (fn [snapshot] 216 | (-> snapshot 217 | (assoc-in [sid tabid :x] (max (int x) 0)) 218 | (assoc-in [sid tabid :y] (max (int y) 0)))))) 219 | 220 | (def default-shim-handler 221 | (h/shim-handler 222 | (h/html 223 | [:link#css {:rel "stylesheet" :type "text/css" :href (css :path)}] 224 | [:title nil "One billion checkboxes"] 225 | [:meta {:content "So many checkboxes" :name "description"}]))) 226 | 227 | (def router 228 | (h/router 229 | {[:get (css :path)] (css :handler) 230 | [:get "/"] default-shim-handler 231 | [:post "/"] (h/render-handler #'render-home 232 | {:br-window-size 19}) 233 | [:post "/scroll"] (h/action-handler #'action-scroll) 234 | [:post "/tap"] (h/action-handler #'action-tap-cell)})) 235 | 236 | (defn build-chunk [x y] 237 | (mapv (fn [c] 238 | {:chunk_id (xy->chunk-id x y) 239 | :cell_id c 240 | :state 0}) 241 | (range (* chunk-size chunk-size)))) 242 | 243 | (defn initial-board-db-state! [db] 244 | (let [board-range (range board-size)] 245 | (d/with-write-tx [db db] 246 | (run! 247 | (fn [y] 248 | (run! (fn [x] 249 | (d/q db 250 | {:insert-into :cell 251 | :values (build-chunk x y)})) 252 | board-range) 253 | (print ".") (flush)) 254 | board-range))) 255 | nil) 256 | 257 | (defn migrations [db] 258 | ;; Note: all this code must be idempotent 259 | 260 | ;; Create tables 261 | (println "Running migrations...") 262 | (d/q db 263 | "CREATE TABLE IF NOT EXISTS cell(chunk_id INTEGER, cell_id INTEGER, state INTEGER, PRIMARY KEY (chunk_id, cell_id)) WITHOUT ROWID") 264 | (d/q db 265 | "CREATE TABLE IF NOT EXISTS session(id TEXT PRIMARY KEY, checks INTEGER) WITHOUT ROWID") 266 | ;; Populate checkboxes 267 | (when-not (d/q db {:select [:cell-id] :from :cell :limit 1}) 268 | (initial-board-db-state! db))) 269 | 270 | (defn ctx-start [] 271 | (let [tab-state_ (atom {:users {}}) 272 | {:keys [writer reader]} 273 | (d/init-db! "database.db" 274 | {:pool-size 4 275 | :pragma {:foreign_keys false}})] 276 | ;; Run migrations 277 | (migrations writer) 278 | ;; Watch tab state 279 | (add-watch tab-state_ :refresh-on-change 280 | (fn [_ _ _ _] (h/refresh-all!))) 281 | {:tab tab-state_ 282 | :db reader 283 | :db-read reader 284 | :db-write writer 285 | :tx-batch! (h/batch! 286 | (fn [thunks] 287 | #_{:clj-kondo/ignore [:unresolved-symbol]} 288 | (d/with-write-tx [db writer] 289 | (run! (fn [thunk] (thunk db)) thunks)) 290 | (h/refresh-all!)) 291 | {:run-every-ms 100})})) 292 | 293 | (defn ctx-stop [ctx] 294 | (.close (:db-write ctx)) 295 | (.close (:db-read ctx))) 296 | 297 | (defn -main [& _] 298 | (h/start-app 299 | {:router #'router 300 | :max-refresh-ms 100 301 | :ctx-start ctx-start 302 | :ctx-stop ctx-stop 303 | :csrf-secret (h/env :csrf-secret) 304 | :on-error (fn [_ctx {:keys [_req error]}] 305 | (let [{:keys [cause trace type]} error] 306 | (println "") 307 | (println type) 308 | (println cause) 309 | (println "") 310 | (run! println trace)) 311 | (flush))})) 312 | 313 | ;; Refresh app when you re-eval file 314 | (h/refresh-all!) 315 | 316 | (comment 317 | (do (-main) nil) 318 | ;; (clojure.java.browse/browse-url "http://localhost:8080/") 319 | 320 | ;; stop server 321 | (((h/get-app) :stop)) 322 | 323 | (def db (-> (h/get-app) :ctx :db)) 324 | 325 | ,) 326 | 327 | (comment 328 | (def tx-batch! (-> (h/get-app) :ctx :tx-batch!)) 329 | 330 | (future 331 | (time 332 | (run! 333 | (fn [_] 334 | (run! 335 | (fn [_] 336 | (action-tap-cell 337 | {:sid "test-user" 338 | :tx-batch! tx-batch! 339 | :query-params {"pid" "0" 340 | "id" (str (rand-int 200))}})) 341 | ;; 10000r/s 342 | (range 10)) 343 | (Thread/sleep 1)) 344 | (range 10000)))) 345 | ) 346 | 347 | (comment 348 | (def db (-> (h/get-app) :ctx :db)) 349 | 350 | (UserView {:x 1 :y 1} db) 351 | 352 | ;; Execution time mean : 456.719068 ms 353 | ;; Execution time mean : 218.760262 ms 354 | (user/bench 355 | (->> (mapv 356 | (fn [n] 357 | (future 358 | (let [n (mod n board-size)] 359 | (UserView {:x n :y n} db)))) 360 | (range 0 4000)) 361 | (run! (fn [x] @x)))) 362 | 363 | ;; On server test 364 | (time ;; simulate 1000 concurrent renders 365 | (->> (mapv 366 | (fn [n] 367 | (future (UserView {:x n :y n} db))) 368 | (range 0 1000)) 369 | (run! (fn [x] @x)))) 370 | 371 | ;; (user/bench (do (UserView {:x 1 :y 1} db) nil)) 372 | 373 | (d/pragma-check db) 374 | 375 | (d/q db {:select [[[:count :*]]] :from :session}) 376 | (d/q db {:select [[[:sum :checks]]] :from :session}) 377 | (d/q db {:select [:checks] :from :session 378 | :order-by [[:checks :desc]]}) 379 | 380 | (d/table-info db :cell) 381 | (d/table-list db) 382 | 383 | (user/bench ;; Execution time mean : 455.139383 µs 384 | (d/q db 385 | ["SELECT CAST(chunk_id AS TEXT), CAST(state AS TEXT) FROM cell WHERE chunk_id IN (?, ?, ?, ?, ?, ?, ?, ?, ?)" 386 | 1978 3955 5932 1979 3956 5933 1980 3957 5934])) 387 | 388 | ,) 389 | 390 | (comment 391 | (user/bench 392 | (d/q db 393 | ["SELECT chunk_id, JSON_GROUP_ARRAY(state) AS chunk_cells FROM cell WHERE chunk_id IN (?, ?, ?, ?, ?, ?, ?, ?, ?) GROUP BY chunk_id" 1978 3955 5932 1979 3956 5933 1980 3957 5934])) 394 | 395 | (def tab-state (-> (h/get-app) :ctx :tab)) 396 | 397 | (count @tab-state) 398 | 399 | (def db-write (-> (h/get-app) :ctx :db-write)) 400 | 401 | ;; Free up space (slow) 402 | ;; (time (d/q db-write "VACUUM")) 403 | 404 | ,) 405 | 406 | ;; TODO: make scroll bars always visible 407 | -------------------------------------------------------------------------------- /examples/chat_atom/README.md: -------------------------------------------------------------------------------- 1 | ## Build JAR. 2 | 3 | ```bash 4 | clojure -Srepro -T:build uber 5 | ``` 6 | 7 | ## Run jar locally 8 | 9 | ``` 10 | java -Dclojure.server.repl="{:port 5555 :accept clojure.core.server/repl}" -jar target/app.jar -m app.main -Duser.timezone=UTC -XX:+UseZGC -XX:+ZGenerational 11 | ``` 12 | 13 | ## Deploy 14 | 15 | Move JAR to server (this will trigger a service restart). 16 | 17 | ```bash 18 | scp target/app.jar root@example.andersmurphy.com:/home/app/ 19 | ``` 20 | 21 | ## After deploying first jar 22 | 23 | Optional: the first time you move the jar onto the server you will need to reboot to trigger/test systemd is working correctly. 24 | 25 | ``` 26 | ssh root@example.andersmurphy.com "reboot" 27 | ``` 28 | 29 | ## SSH into repl 30 | 31 | ```bash 32 | ssh root@example.andersmurphy.com "nc localhost:5555" 33 | ``` 34 | 35 | -------------------------------------------------------------------------------- /examples/chat_atom/build.clj: -------------------------------------------------------------------------------- 1 | (ns build 2 | (:require [clojure.tools.build.api :as b])) 3 | 4 | (def lib 'app) 5 | (def class-dir "target/classes") 6 | (def basis (delay (b/create-basis {:project "deps.edn"}))) 7 | (def uber-file (format "target/%s.jar" (name lib))) 8 | 9 | (defn clean [_] (b/delete {:path "target"})) 10 | 11 | (defn uber 12 | [_] 13 | (clean nil) 14 | (b/copy-dir {:src-dirs ["src" "resources"] :target-dir class-dir}) 15 | (b/compile-clj {:basis @basis 16 | :ns-compile '[app.main] 17 | :src-dirs ["src"] 18 | :class-dir class-dir}) 19 | (b/uber {:class-dir class-dir 20 | :uber-file uber-file 21 | :basis @basis 22 | :main 'app.main})) 23 | -------------------------------------------------------------------------------- /examples/chat_atom/deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src" "resources"] 2 | :deps {org.clojure/clojure {:mvn/version "1.12.0"} 3 | hyperlith/hyperlith {:local/root "../../../hyperlith"}} 4 | :aliases {:build {:deps {io.github.clojure/tools.build 5 | {:git/tag "v0.10.5" :git/sha "2a21b7a"}} 6 | :ns-default build}}} 7 | -------------------------------------------------------------------------------- /examples/chat_atom/resources/.env.edn: -------------------------------------------------------------------------------- 1 | {;; WARNING: .env.edn should not normally be committed to source control 2 | ;; but is here as an example. 3 | :csrf-secret "fb1704df2b3484223cb5d2a79bf06a508311d8d0f03c68e724d555b6b605966d0ebb8dc54615f8d080e5fa062bd3b5bce5b6ba7ded23333bbd55deea3149b9d5"} 4 | -------------------------------------------------------------------------------- /examples/chat_atom/src/app/main.clj: -------------------------------------------------------------------------------- 1 | (ns app.main 2 | (:gen-class) 3 | (:require [clojure.pprint :as pprint] 4 | [clojure.string :as str] 5 | [hyperlith.core :as h])) 6 | 7 | (def css 8 | (h/static-css 9 | [["*, *::before, *::after" 10 | {:box-sizing :border-box 11 | :margin 0 12 | :padding 0}] 13 | 14 | [:.main 15 | {:height :100dvh 16 | :width "min(100% - 2rem , 40rem)" 17 | :margin-inline :auto 18 | :padding-block :2dvh 19 | :overflow-y :scroll 20 | :scrollbar-width :none 21 | :display :flex 22 | :gap :3px 23 | :flex-direction :column-reverse}] 24 | 25 | [:.chat 26 | {:display :flex 27 | :flex-direction :column}]])) 28 | 29 | (defn get-messages [db] 30 | (reverse (@db :messages))) 31 | 32 | (def messages 33 | (h/cache 34 | (fn [db] 35 | (for [[id content] (get-messages db)] 36 | [:p {:id id} content])))) 37 | 38 | (defn render-home [{:keys [db] :as _req}] 39 | (h/html 40 | [:link#css {:rel "stylesheet" :type "text/css" :href (css :path)}] 41 | [:main#morph.main 42 | [:div.chat 43 | [:input {:type "text" :data-bind "message"}] 44 | [:button 45 | {:data-on-click "@post('/send')"} "send"]] 46 | (messages db)])) 47 | 48 | (defn action-send-message [{:keys [_sid db] {:keys [message]} :body}] 49 | (when-not (str/blank? message) 50 | (swap! db update :messages conj [(h/new-uid) message]) 51 | (h/signals {:message ""}))) 52 | 53 | ;; Allows for shim handler to be reused across shim routes 54 | (def default-shim-handler 55 | (h/shim-handler 56 | (h/html 57 | [:link#css {:rel "stylesheet" :type "text/css" :href (css :path)}] 58 | [:title nil "Chat"] 59 | [:meta {:content "Chat app" :name "description"}]))) 60 | 61 | (def router 62 | (h/router 63 | {[:get (css :path)] (css :handler) 64 | [:get "/"] default-shim-handler 65 | [:post "/"] (h/render-handler #'render-home) 66 | [:post "/send"] (h/action-handler #'action-send-message)})) 67 | 68 | (defn ctx-start [] 69 | (let [db_ (atom {:messages []})] 70 | (add-watch db_ :refresh-on-change (fn [& _] (h/refresh-all!))) 71 | {:db db_})) 72 | 73 | (defn -main [& _] 74 | (h/start-app 75 | {:router #'router 76 | :max-refresh-ms 100 77 | :ctx-start ctx-start 78 | :ctx-stop (fn [_state] nil) 79 | :csrf-secret (h/env :csrf-secret) 80 | :on-error (fn [_ctx {:keys [req error]}] 81 | (pprint/pprint req) 82 | (pprint/pprint error))})) 83 | 84 | ;; Refresh app when you re-eval file 85 | (h/refresh-all!) 86 | 87 | (comment 88 | (-main) 89 | ;; (clojure.java.browse/browse-url "http://localhost:8080/") 90 | 91 | ;; stop server 92 | (((h/get-app) :stop)) 93 | 94 | ;; query outside of handler 95 | (get-messages (-> (h/get-app) :ctx :db)) 96 | ,) 97 | -------------------------------------------------------------------------------- /examples/communtative_connected_count/README.md: -------------------------------------------------------------------------------- 1 | ## Build JAR. 2 | 3 | ```bash 4 | clojure -Srepro -T:build uber 5 | ``` 6 | 7 | ## Run jar locally 8 | 9 | ``` 10 | java -Dclojure.server.repl="{:port 5555 :accept clojure.core.server/repl}" -jar target/app.jar -m app.main -Duser.timezone=UTC -XX:+UseZGC -XX:+ZGenerational 11 | ``` 12 | 13 | ## Deploy 14 | 15 | Move JAR to server (this will trigger a service restart). 16 | 17 | ```bash 18 | scp target/app.jar root@example.andersmurphy.com:/home/app/ 19 | ``` 20 | 21 | ## After deploying first jar 22 | 23 | Optional: the first time you move the jar onto the server you will need to reboot to trigger/test systemd is working correctly. 24 | 25 | ``` 26 | ssh root@example.andersmurphy.com "reboot" 27 | ``` 28 | 29 | ## SSH into repl 30 | 31 | ```bash 32 | ssh root@example.andersmurphy.com "nc localhost:5555" 33 | ``` 34 | 35 | -------------------------------------------------------------------------------- /examples/communtative_connected_count/build.clj: -------------------------------------------------------------------------------- 1 | (ns build 2 | (:require [clojure.tools.build.api :as b])) 3 | 4 | (def lib 'app) 5 | (def class-dir "target/classes") 6 | (def basis (delay (b/create-basis {:project "deps.edn"}))) 7 | (def uber-file (format "target/%s.jar" (name lib))) 8 | 9 | (defn clean [_] (b/delete {:path "target"})) 10 | 11 | (defn uber 12 | [_] 13 | (clean nil) 14 | (b/copy-dir {:src-dirs ["src" "resources"] :target-dir class-dir}) 15 | (b/compile-clj {:basis @basis 16 | :ns-compile '[app.main] 17 | :src-dirs ["src"] 18 | :class-dir class-dir}) 19 | (b/uber {:class-dir class-dir 20 | :uber-file uber-file 21 | :basis @basis 22 | :main 'app.main})) 23 | -------------------------------------------------------------------------------- /examples/communtative_connected_count/deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src" "resources"] 2 | :deps {org.clojure/clojure {:mvn/version "1.12.0"} 3 | hyperlith/hyperlith {:local/root "../../../hyperlith"}} 4 | :aliases {:build {:deps {io.github.clojure/tools.build 5 | {:git/tag "v0.10.5" :git/sha "2a21b7a"}} 6 | :ns-default build}}} 7 | -------------------------------------------------------------------------------- /examples/communtative_connected_count/resources/.env.edn: -------------------------------------------------------------------------------- 1 | {;; WARNING: .env.edn should not normally be committed to source control 2 | ;; but is here as an example. 3 | :csrf-secret "fb1704df2b3484223cb5d2a79bf06a508311d8d0f03c68e724d555b6b605966d0ebb8dc54615f8d080e5fa062bd3b5bce5b6ba7ded23333bbd55deea3149b9d5"} 4 | -------------------------------------------------------------------------------- /examples/communtative_connected_count/src/app/main.clj: -------------------------------------------------------------------------------- 1 | (ns app.main 2 | (:gen-class) 3 | (:require [hyperlith.core :as h])) 4 | 5 | (def css 6 | (h/static-css 7 | [["*, *::before, *::after" 8 | {:box-sizing :border-box 9 | :margin 0 10 | :padding 0}] 11 | 12 | [:html 13 | {:font-family "Arial, Helvetica, sans-serif"}] 14 | 15 | [:.main 16 | {:height :100dvh 17 | :width "min(100% - 2rem , 40rem)" 18 | :margin-inline :auto 19 | :padding-block :2dvh 20 | :display :grid 21 | :place-items :center}] 22 | 23 | [:.counter 24 | {:text-align :center 25 | :font-size :50px}]])) 26 | 27 | (defn render-home [{:keys [connected-counter] :as _req}] 28 | (h/html 29 | [:link#css {:rel "stylesheet" :type "text/css" :href (css :path)}] 30 | [:main#morph.main 31 | [:div 32 | [:p nil (str "connected users")] 33 | [:p.counter nil @connected-counter]]])) 34 | 35 | (def default-shim-handler 36 | (h/shim-handler 37 | (h/html 38 | [:link#css {:rel "stylesheet" :type "text/css" :href (css :path)}]))) 39 | 40 | (def router 41 | (h/router 42 | {[:get (css :path)] (css :handler) 43 | [:get "/"] default-shim-handler 44 | [:post "/"] (h/render-handler #'render-home 45 | :on-open 46 | (fn [{:keys [connected-counter]}] 47 | (dosync (commute connected-counter inc))) 48 | :on-close 49 | (fn [{:keys [connected-counter]}] 50 | (dosync (commute connected-counter dec))))})) 51 | 52 | (defn ctx-start [] 53 | ;; By using ref and commute to track user count allows for higher 54 | ;; level of concurrency. 55 | (let [connected-counter_ (ref 0)] 56 | (add-watch connected-counter_ :refresh-on-change 57 | (fn [& _] (h/refresh-all!))) 58 | {:connected-counter connected-counter_})) 59 | 60 | (defn -main [& _] 61 | (h/start-app 62 | {:router #'router 63 | :max-refresh-ms 100 64 | :ctx-start ctx-start 65 | :ctx-stop (fn [_db] nil) 66 | :csrf-secret (h/env :csrf-secret)})) 67 | 68 | ;; Refresh app when you re-eval file 69 | (h/refresh-all!) 70 | 71 | (comment 72 | (-main) 73 | ;; (clojure.java.browse/browse-url "http://localhost:8080/") 74 | 75 | ;; stop server 76 | (((h/get-app) :stop)) 77 | 78 | 79 | ,) 80 | -------------------------------------------------------------------------------- /examples/drag_drop/README.md: -------------------------------------------------------------------------------- 1 | ## Build JAR. 2 | 3 | ```bash 4 | clojure -Srepro -T:build uber 5 | ``` 6 | 7 | ## Run jar locally 8 | 9 | ``` 10 | java -Dclojure.server.repl="{:port 5555 :accept clojure.core.server/repl}" -jar target/app.jar -m app.main -Duser.timezone=UTC -XX:+UseZGC -XX:+ZGenerational 11 | ``` 12 | 13 | ## Deploy 14 | 15 | Move JAR to server (this will trigger a service restart). 16 | 17 | ```bash 18 | scp target/app.jar root@example.andersmurphy.com:/home/app/ 19 | ``` 20 | 21 | ## After deploying first jar 22 | 23 | Optional: the first time you move the jar onto the server you will need to reboot to trigger/test systemd is working correctly. 24 | 25 | ``` 26 | ssh root@example.andersmurphy.com "reboot" 27 | ``` 28 | 29 | ## SSH into repl 30 | 31 | ```bash 32 | ssh root@example.andersmurphy.com "nc localhost:5555" 33 | ``` 34 | 35 | -------------------------------------------------------------------------------- /examples/drag_drop/build.clj: -------------------------------------------------------------------------------- 1 | (ns build 2 | (:require [clojure.tools.build.api :as b])) 3 | 4 | (def lib 'app) 5 | (def class-dir "target/classes") 6 | (def basis (delay (b/create-basis {:project "deps.edn"}))) 7 | (def uber-file (format "target/%s.jar" (name lib))) 8 | 9 | (defn clean [_] (b/delete {:path "target"})) 10 | 11 | (defn uber 12 | [_] 13 | (clean nil) 14 | (b/copy-dir {:src-dirs ["src" "resources"] :target-dir class-dir}) 15 | (b/compile-clj {:basis @basis 16 | :ns-compile '[app.main] 17 | :src-dirs ["src"] 18 | :class-dir class-dir}) 19 | (b/uber {:class-dir class-dir 20 | :uber-file uber-file 21 | :basis @basis 22 | :main 'app.main})) 23 | -------------------------------------------------------------------------------- /examples/drag_drop/deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src" "resources"] 2 | :deps {org.clojure/clojure {:mvn/version "1.12.0"} 3 | hyperlith/hyperlith {:local/root "../../../hyperlith"}} 4 | :aliases {:build {:deps {io.github.clojure/tools.build 5 | {:git/tag "v0.10.5" :git/sha "2a21b7a"}} 6 | :ns-default build}}} 7 | -------------------------------------------------------------------------------- /examples/drag_drop/resources/.env.edn: -------------------------------------------------------------------------------- 1 | {;; WARNING: .env.edn should not normally be committed to source control 2 | ;; but is here as an example. 3 | :csrf-secret "fb1704df2b3484223cb5d2a79bf06a508311d8d0f03c68e724d555b6b605966d0ebb8dc54615f8d080e5fa062bd3b5bce5b6ba7ded23333bbd55deea3149b9d5"} 4 | -------------------------------------------------------------------------------- /examples/drag_drop/src/app/main.clj: -------------------------------------------------------------------------------- 1 | (ns app.main 2 | (:gen-class) 3 | (:require [hyperlith.core :as h])) 4 | 5 | (def css 6 | (h/static-css 7 | [["*, *::before, *::after" 8 | {:box-sizing :border-box 9 | :margin 0 10 | :padding 0}] 11 | 12 | [:html 13 | {:font-family "Arial, Helvetica, sans-serif" 14 | :overflow :hidden 15 | :background :#212529 16 | :color :#e9ecef}] 17 | 18 | [:.main 19 | {:margin-top :20px 20 | :display :grid 21 | :place-items :center 22 | :gap :2px}] 23 | 24 | [:.board 25 | {:user-select :none 26 | :-webkit-touch-callout :none 27 | :-webkit-user-select :none 28 | :width :100% 29 | :max-width :500px 30 | :aspect-ratio "1 / 1" 31 | :position :relative}] 32 | 33 | [:.star 34 | {:position :absolute 35 | :touch-action :none 36 | :font-size :30px 37 | :transition "all 0.2s ease-in-out"}] 38 | 39 | [:.dropzone 40 | {:position :absolute 41 | :font-size :30px}] 42 | 43 | [:.counter 44 | {:font-size :16px}] 45 | 46 | [:a {:color :#e9ecef}]])) 47 | 48 | (defn place-stars [db n] 49 | (doseq [_n (range n)] 50 | (let [x (rand-nth (range 0 100 10)) 51 | y (rand-nth (range 0 100 10))] 52 | (swap! db h/assoc-in-if-missing [:stars (str "s" x y)] 53 | {:x x :y y})))) 54 | 55 | (def stars 56 | (h/cache 57 | (fn [db] 58 | (for [[star-id {:keys [x y]}] (:stars @db)] 59 | [:div.star 60 | {:id star-id 61 | :style {:left (str x "%") :top (str y "%")} 62 | :draggable "true" 63 | :data-on-dragstart 64 | "evt.dataTransfer.setData('text/plain', evt.target.id)"} 65 | "⭐"])))) 66 | 67 | (defn render-home [{:keys [db] :as _req}] 68 | (h/html 69 | [:link#css {:rel "stylesheet" :type "text/css" :href (css :path)}] 70 | [:main#morph.main 71 | [:p.counter "DRAG THE STARS TO THE SHIP"] 72 | [:p "(multiplayer co-op)"] 73 | [:div.board nil (stars db) 74 | [:div.dropzone 75 | {:style {:left :55% :top :55%} 76 | :data-on-dragover "evt.preventDefault()" 77 | :data-on-drop 78 | "evt.preventDefault(); @post(`/dropzone?id=${evt.dataTransfer.getData('text/plain')}`)"} 79 | "🚀"]] 80 | [:p.counter nil 81 | (str "STARS COLLECTED: " (@db :stars-collected))] 82 | [:a {:href "https://data-star.dev/"} 83 | "Built with ❤️ using Datastar"] 84 | [:a {:href "https://github.com/andersmurphy/hyperlith/blob/master/examples/drag_drop/src/app/main.clj"} 85 | "show me the code"]])) 86 | 87 | (defn remove-star [db id] 88 | (-> (update db :stars dissoc id) 89 | (update :stars-collected inc))) 90 | 91 | (defn move-star [db id] 92 | (swap! db assoc-in [:stars id] {:x 55 :y 55}) 93 | (Thread/sleep 250) 94 | (swap! db remove-star id)) 95 | 96 | (defn action-user-move-star-to-dropzone 97 | [{:keys [db] {:strs [id]} :query-params}] 98 | (when id 99 | (move-star db id))) 100 | 101 | (def default-shim-handler 102 | (h/shim-handler 103 | (h/html 104 | ;; Setting the colour here prevents flash on remote stylesheet update 105 | [:style "html {background: #212529}"] 106 | [:link#css {:rel "stylesheet" :type "text/css" :href (css :path)}]))) 107 | 108 | (def router 109 | (h/router 110 | {[:get (css :path)] (css :handler) 111 | [:get "/"] default-shim-handler 112 | [:post "/"] (h/render-handler #'render-home 113 | :on-close 114 | (fn [{:keys [sid db]}] 115 | (swap! db update :cursors dissoc sid))) 116 | [:post "/dropzone"] (h/action-handler #'action-user-move-star-to-dropzone)})) 117 | 118 | (defn ctx-start [] 119 | (let [db_ (atom {:stars-collected 0})] 120 | (place-stars db_ 15) 121 | (add-watch db_ :refresh-on-change 122 | (fn [_ ref _old-state new-state] 123 | (when (empty? (:stars new-state)) 124 | (place-stars ref 15)) 125 | (h/refresh-all!))) 126 | {:db db_})) 127 | 128 | (defn -main [& _] 129 | (h/start-app 130 | {:router #'router 131 | :max-refresh-ms 100 132 | :ctx-start ctx-start 133 | :ctx-stop (fn [_] nil) 134 | :csrf-secret (h/env :csrf-secret)})) 135 | 136 | ;; Refresh app when you re-eval file 137 | (h/refresh-all!) 138 | 139 | (comment 140 | (-main) 141 | ;; (clojure.java.browse/browse-url "http://localhost:8080/") 142 | 143 | ;; stop server 144 | (((h/get-app) :stop)) 145 | 146 | (:db ((h/get-app) :ctx)) 147 | 148 | (place-stars (:db ((h/get-app) :ctx)) 10) 149 | 150 | ,) 151 | 152 | (comment 153 | (def db (:db ((h/get-app) :ctx))) 154 | 155 | (place-stars (:db ((h/get-app) :ctx)) 60) 156 | 157 | (do (mapv 158 | (fn [[k _]] 159 | (action-user-move-star-to-dropzone 160 | {:db db 161 | :query-params {"id" k}})) 162 | (:stars @db)) 163 | nil) 164 | ) 165 | -------------------------------------------------------------------------------- /examples/game_of_life/README.md: -------------------------------------------------------------------------------- 1 | ## Build JAR. 2 | 3 | ```bash 4 | clojure -Srepro -T:build uber 5 | ``` 6 | 7 | ## Run jar locally 8 | 9 | ``` 10 | java -Dclojure.server.repl="{:port 5555 :accept clojure.core.server/repl}" -jar target/app.jar -m app.main -Duser.timezone=UTC -XX:+UseZGC -XX:+ZGenerational 11 | ``` 12 | 13 | ## Deploy 14 | 15 | Move JAR to server (this will trigger a service restart). 16 | 17 | ```bash 18 | scp target/app.jar root@example.andersmurphy.com:/home/app/ 19 | ``` 20 | 21 | ## After deploying first jar 22 | 23 | Optional: the first time you move the jar onto the server you will need to reboot to trigger/test systemd is working correctly. 24 | 25 | ``` 26 | ssh root@example.andersmurphy.com "reboot" 27 | ``` 28 | 29 | ## SSH into repl 30 | 31 | ```bash 32 | ssh root@example.andersmurphy.com "nc localhost:5555" 33 | ``` 34 | 35 | -------------------------------------------------------------------------------- /examples/game_of_life/build.clj: -------------------------------------------------------------------------------- 1 | (ns build 2 | (:require [clojure.tools.build.api :as b])) 3 | 4 | (def lib 'app) 5 | (def class-dir "target/classes") 6 | (def basis (delay (b/create-basis {:project "deps.edn"}))) 7 | (def uber-file (format "target/%s.jar" (name lib))) 8 | 9 | (defn clean [_] (b/delete {:path "target"})) 10 | 11 | (defn uber 12 | [_] 13 | (clean nil) 14 | (b/copy-dir {:src-dirs ["src" "resources"] :target-dir class-dir}) 15 | (b/compile-clj {:basis @basis 16 | :ns-compile '[app.main] 17 | :src-dirs ["src"] 18 | :class-dir class-dir}) 19 | (b/uber {:class-dir class-dir 20 | :uber-file uber-file 21 | :basis @basis 22 | :main 'app.main})) 23 | -------------------------------------------------------------------------------- /examples/game_of_life/deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src" "resources"] 2 | :deps {org.clojure/clojure {:mvn/version "1.12.0"} 3 | hyperlith/hyperlith {:local/root "../../../hyperlith"}} 4 | :aliases {:build {:deps {io.github.clojure/tools.build 5 | {:git/tag "v0.10.5" :git/sha "2a21b7a"}} 6 | :ns-default build}}} 7 | -------------------------------------------------------------------------------- /examples/game_of_life/resources/.env.edn: -------------------------------------------------------------------------------- 1 | {;; WARNING: .env.edn should not normally be committed to source control 2 | ;; but is here as an example. 3 | :csrf-secret "fb1704df2b3484223cb5d2a79bf06a508311d8d0f03c68e724d555b6b605966d0ebb8dc54615f8d080e5fa062bd3b5bce5b6ba7ded23333bbd55deea3149b9d5"} 4 | -------------------------------------------------------------------------------- /examples/game_of_life/src/app/game.clj: -------------------------------------------------------------------------------- 1 | ;; This code based of (with some minor modifications): https://github.com/kaepr/game-of-life-cljs/blob/80ff8a16804e03d35d056cc3c64f4d0be9ce301e/src/app/game.cljs#L1C1-L83C6 2 | (ns app.game) 3 | 4 | (def grid-config 5 | {:name "Square" 6 | :neighbors [[-1 -1] [-1 0] [-1 1] 7 | [0 -1] #_cell [0 1] 8 | [1 -1] [1 0] [1 1]]}) 9 | 10 | (defn dead-cell [] :dead) 11 | 12 | (defn alive-cell [living-neighbors] (rand-nth living-neighbors)) 13 | 14 | (defn alive? [cell] (not= cell :dead)) 15 | 16 | (defn coordinates->index [row col max-cols] 17 | (+ col (* row max-cols))) 18 | 19 | (defn index->coordinates [idx max-cols] 20 | [(quot idx max-cols) (rem idx max-cols)]) 21 | 22 | (defn get-cell [board idx] 23 | (get board idx false)) 24 | 25 | (defn update-cell [board [row col] max-cols cell] 26 | (assoc board (coordinates->index row col max-cols) cell)) 27 | 28 | (defn empty-board [max-rows max-cols] 29 | (vec (repeat (* max-rows max-cols) (dead-cell)))) 30 | 31 | (defn get-neighbors 32 | "Returns all neighbors using 1d based vector indexing." 33 | [neighbors [row col] max-rows max-cols] 34 | (let [valid? (fn [r c] (and (>= r 0) (>= c 0) (< r max-rows) (< c max-cols)))] 35 | (->> neighbors 36 | (map (fn [[dr dc]] 37 | (let [r (+ row dr) 38 | c (+ col dc)] 39 | (when (valid? r c) 40 | (coordinates->index r c max-cols))))) 41 | (filter some?)))) 42 | 43 | (defn cell-transition [cell neighbors-count living-neighbors] 44 | (if (or (and (alive? cell) (or (= neighbors-count 2) (= neighbors-count 3))) 45 | (and (not (alive? cell)) (= neighbors-count 3))) 46 | (alive-cell living-neighbors) 47 | (dead-cell))) 48 | 49 | (defn next-gen-board [{:keys [board max-rows max-cols]}] 50 | (let [next-board (transient board) 51 | size (* max-rows max-cols)] 52 | (dotimes [idx size] 53 | (let [coords (index->coordinates idx max-cols) 54 | cell (get-cell board idx) 55 | neighbors (:neighbors grid-config) 56 | neighbor-cells (get-neighbors neighbors coords max-rows max-cols) 57 | living-neighbors (filter alive? (map #(get-cell board %) 58 | neighbor-cells)) 59 | neighbor-count (count living-neighbors)] 60 | (assoc! next-board idx (cell-transition cell neighbor-count 61 | living-neighbors)))) 62 | (persistent! next-board))) 63 | 64 | (comment 65 | 66 | (empty-board 10 10) 67 | 68 | (next-gen-board {:board (empty-board 10 10) 69 | :max-rows 10 70 | :max-cols 10})) 71 | -------------------------------------------------------------------------------- /examples/game_of_life/src/app/main.clj: -------------------------------------------------------------------------------- 1 | (ns app.main 2 | (:gen-class) 3 | (:require [clojure.pprint :as pprint] 4 | [hyperlith.core :as h] 5 | [app.game :as game])) 6 | 7 | (def board-size 50) 8 | 9 | (def colors 10 | [:red :blue :green :orange :fuchsia :purple]) 11 | 12 | (def css 13 | (let [black :black 14 | cell-transition "background 0.6s ease"] 15 | (h/static-css 16 | [["*, *::before, *::after" 17 | {:box-sizing :border-box 18 | :margin 0 19 | :padding 0}] 20 | 21 | [:html 22 | {:font-family "Arial, Helvetica, sans-serif" 23 | :font-size :18px 24 | :color black}] 25 | 26 | [:.main 27 | {:height :100dvh 28 | :width "min(100% - 2rem , 30rem)" 29 | :margin-inline :auto 30 | :padding-block :2dvh 31 | :display :flex 32 | :gap :5px 33 | :flex-direction :column}] 34 | 35 | [:.board 36 | {:background :white 37 | :width "min(100% - 2rem , 30rem)" 38 | :display :grid 39 | :aspect-ratio "1/1" 40 | :grid-template-rows (str "repeat(" board-size ", 1fr)") 41 | :grid-template-columns (str "repeat(" board-size ", 1fr)")}] 42 | 43 | [:.tile 44 | {:border-bottom "1px solid black" 45 | :border-right "1px solid black"}] 46 | 47 | [:.dead 48 | {:background :white}] 49 | 50 | [:.red 51 | {:background :red 52 | :transition cell-transition}] 53 | [:.blue 54 | {:background :blue 55 | :transition cell-transition}] 56 | [:.green 57 | {:background :green 58 | :transition cell-transition}] 59 | [:.orange 60 | {:background :orange 61 | :transition cell-transition}] 62 | [:.fuchsia 63 | {:background :fuchsia 64 | :transition cell-transition}] 65 | [:.purple 66 | {:background :purple 67 | :transition cell-transition}]]))) 68 | 69 | (def board-state 70 | (h/cache 71 | (fn [db] 72 | (into [] 73 | (comp 74 | (map-indexed 75 | (fn [id color-class] 76 | (let [morph-id (when-not (= :dead color-class) id)] 77 | (h/html 78 | [:div.tile 79 | {:class color-class 80 | :data-id id 81 | :id morph-id}]))))) 82 | (:board db))))) 83 | 84 | (defn board [snapshot] 85 | (let [view (board-state snapshot)] 86 | (h/html 87 | [:div.board 88 | {:data-on-pointerdown "@post(`/tap?id=${evt.target.dataset.id}`)"} 89 | view]))) 90 | 91 | (defn render-home [{:keys [db _sid] :as _req}] 92 | (let [snapshot @db] 93 | (h/html 94 | [:link#css {:rel "stylesheet" :type "text/css" :href (css :path)}] 95 | [:main#morph.main 96 | [:h1 "Game of Life (multiplayer)"] 97 | [:p "Built with ❤️ using " 98 | [:a {:href "https://clojure.org/"} "Clojure"] 99 | " and " 100 | [:a {:href "https://data-star.dev"} "Datastar"] 101 | "🚀"] 102 | [:p "Source code can be found " 103 | [:a {:href "https://github.com/andersmurphy/hyperlith/blob/master/examples/game_of_life/src/app/main.clj"} "here"]] 104 | (board snapshot)]))) 105 | 106 | (defn render-home-star [{:keys [db _sid] :as _req}] 107 | (let [snapshot @db] 108 | (h/html 109 | [:link#css {:rel "stylesheet" :type "text/css" :href (css :path)}] 110 | [:main#morph.main nil 111 | (board snapshot)]))) 112 | 113 | (defn fill-cell [board color id] 114 | (if ;; crude overflow check 115 | (<= 0 id (dec (* board-size board-size))) 116 | (assoc board id color) 117 | board)) 118 | 119 | (defn fill-cross [db id sid] 120 | (let[user-color (h/modulo-pick colors sid)] 121 | (-> db 122 | (update :board fill-cell user-color (- id board-size)) 123 | (update :board fill-cell user-color (- id 1)) 124 | (update :board fill-cell user-color id) 125 | (update :board fill-cell user-color (+ id 1)) 126 | (update :board fill-cell user-color (+ id board-size))))) 127 | 128 | (defn action-tap-cell [{:keys [sid db] {:strs [id]} :query-params}] 129 | (when id 130 | (swap! db fill-cross (parse-long id) sid))) 131 | 132 | (def default-shim-handler 133 | (h/shim-handler 134 | (h/html 135 | [:link#css {:rel "stylesheet" :type "text/css" :href (css :path)}] 136 | [:title nil "Game of Life"] 137 | [:meta {:content "Conway's Game of Life" :name "description"}]))) 138 | 139 | (defn next-gen-board [current-board] 140 | (game/next-gen-board 141 | {:board current-board 142 | :max-rows board-size 143 | :max-cols board-size})) 144 | 145 | (defn next-generation! [db] 146 | (swap! db update :board next-gen-board)) 147 | 148 | (defn start-game! [db] 149 | (let [running_ (atom true)] 150 | (h/thread 151 | (while @running_ 152 | (Thread/sleep 200) ;; 5 fps 153 | (next-generation! db))) 154 | (fn stop-game! [] (reset! running_ false)))) 155 | 156 | (def router 157 | (h/router 158 | {[:get (css :path)] (css :handler) 159 | [:get "/"] default-shim-handler 160 | [:post "/"] (h/render-handler #'render-home 161 | {:br-window-size 18}) 162 | [:get "/star"] default-shim-handler 163 | [:post "/star"] (h/render-handler #'render-home-star 164 | {:br-window-size 18}) 165 | [:post "/tap"] (h/action-handler #'action-tap-cell)})) 166 | 167 | (defn ctx-start [] 168 | (let [db_ (atom {:board (game/empty-board board-size board-size) 169 | :users {}})] 170 | (add-watch db_ :refresh-on-change 171 | (fn [_ _ old-state new-state] 172 | ;; Only refresh if state has changed 173 | (when-not (= old-state new-state) 174 | (h/refresh-all!)))) 175 | {:db db_ 176 | :game-stop (start-game! db_)})) 177 | 178 | (defn -main [& _] 179 | (h/start-app 180 | {:router #'router 181 | :max-refresh-ms 200 182 | :ctx-start ctx-start 183 | :ctx-stop (fn [{:keys [game-stop]}] (game-stop)) 184 | :csrf-secret (h/env :csrf-secret) 185 | :on-error (fn [_ctx {:keys [req error]}] 186 | ;; (pprint/pprint req) 187 | (pprint/pprint error) 188 | (flush))})) 189 | 190 | ;; Refresh app when you re-eval file 191 | (h/refresh-all!) 192 | 193 | (comment 194 | (-main) 195 | ;; (clojure.java.browse/browse-url "http://localhost:8080/") 196 | 197 | ;; stop server 198 | (((h/get-app) :stop)) 199 | 200 | (def db (-> (h/get-app) :ctx :db)) 201 | 202 | (reset! db {:board (game/empty-board board-size board-size) 203 | :users {}}) 204 | 205 | (->> @db :users) 206 | 207 | (->> @db :board (remove false?)) 208 | 209 | ,) 210 | -------------------------------------------------------------------------------- /examples/one_million_checkboxes/README.md: -------------------------------------------------------------------------------- 1 | ## Build JAR. 2 | 3 | ```bash 4 | clojure -Srepro -T:build uber 5 | ``` 6 | 7 | ## Run jar locally 8 | 9 | ``` 10 | java -Dclojure.server.repl="{:port 5555 :accept clojure.core.server/repl}" -jar target/app.jar -m app.main -Duser.timezone=UTC -XX:+UseZGC -XX:+ZGenerational 11 | ``` 12 | 13 | ## Deploy 14 | 15 | Move JAR to server (this will trigger a service restart). 16 | 17 | ```bash 18 | scp target/app.jar root@example.andersmurphy.com:/home/app/ 19 | ``` 20 | 21 | ## After deploying first jar 22 | 23 | Optional: the first time you move the jar onto the server you will need to reboot to trigger/test systemd is working correctly. 24 | 25 | ``` 26 | ssh root@example.andersmurphy.com "reboot" 27 | ``` 28 | 29 | ## SSH into repl 30 | 31 | ```bash 32 | ssh root@example.andersmurphy.com "nc localhost:5555" 33 | ``` 34 | 35 | -------------------------------------------------------------------------------- /examples/one_million_checkboxes/build.clj: -------------------------------------------------------------------------------- 1 | (ns build 2 | (:require [clojure.tools.build.api :as b])) 3 | 4 | (def lib 'app) 5 | (def class-dir "target/classes") 6 | (def basis (delay (b/create-basis {:project "deps.edn"}))) 7 | (def uber-file (format "target/%s.jar" (name lib))) 8 | 9 | (defn clean [_] (b/delete {:path "target"})) 10 | 11 | (defn uber 12 | [_] 13 | (clean nil) 14 | (b/copy-dir {:src-dirs ["src" "resources"] :target-dir class-dir}) 15 | (b/compile-clj {:basis @basis 16 | :ns-compile '[app.main] 17 | :src-dirs ["src"] 18 | :class-dir class-dir}) 19 | (b/uber {:class-dir class-dir 20 | :uber-file uber-file 21 | :basis @basis 22 | :main 'app.main})) 23 | -------------------------------------------------------------------------------- /examples/one_million_checkboxes/deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src" "resources"] 2 | :deps {org.clojure/clojure {:mvn/version "1.12.0"} 3 | hyperlith/hyperlith {:local/root "../../../hyperlith"}} 4 | :aliases {:build {:deps {io.github.clojure/tools.build 5 | {:git/tag "v0.10.5" :git/sha "2a21b7a"}} 6 | :ns-default build}}} 7 | -------------------------------------------------------------------------------- /examples/one_million_checkboxes/resources/.env.edn: -------------------------------------------------------------------------------- 1 | {;; WARNING: .env.edn should not normally be committed to source control 2 | ;; but is here as an example. 3 | :csrf-secret "fb1704df2b3484223cb5d2a79bf06a508311d8d0f03c68e724d555b6b605966d0ebb8dc54615f8d080e5fa062bd3b5bce5b6ba7ded23333bbd55deea3149b9d5"} 4 | -------------------------------------------------------------------------------- /examples/one_million_checkboxes/src/app/main.clj: -------------------------------------------------------------------------------- 1 | (ns app.main 2 | (:gen-class) 3 | (:require [clojure.pprint :as pprint] 4 | [hyperlith.core :as h] 5 | [clojure.string :as str])) 6 | 7 | (def board-size 72) 8 | (def chunk-size 14) 9 | (def board-size-px 40000) 10 | (def view-size 3) 11 | 12 | (def colors 13 | [:r :b :g :o :f :p]) 14 | 15 | (def class->color 16 | {:r :red :b :blue :g :green :o :orange :f :fuchsia :p :purple}) 17 | 18 | (def css 19 | (let [black :black 20 | board-size-px (str board-size-px "px")] 21 | (h/static-css 22 | [["*, *::before, *::after" 23 | {:box-sizing :border-box 24 | :margin 0 25 | :padding 0}] 26 | 27 | [:html 28 | {:font-family "Arial, Helvetica, sans-serif" 29 | :font-size :18px 30 | :color black}] 31 | 32 | [:.main 33 | {:height :100dvh 34 | :margin-inline :auto 35 | :padding-block :2dvh 36 | :display :flex 37 | :width "min(100% - 2rem , 40rem)" 38 | :gap :5px 39 | :flex-direction :column}] 40 | 41 | [:.view 42 | {:overflow :scroll 43 | :overflow-anchor :none 44 | :width "min(100% - 2rem , 40rem)" 45 | :aspect-ratio "1/1"}] 46 | 47 | [:.board 48 | {:background :white 49 | :width board-size-px 50 | :display :grid 51 | :aspect-ratio "1/1" 52 | :gap :10px 53 | :grid-template-rows (str "repeat(" board-size ", 1fr)") 54 | :grid-template-columns (str "repeat(" board-size ", 1fr)")}] 55 | 56 | [:.chunk 57 | {:background :white 58 | :display :grid 59 | :gap :10px 60 | :grid-template-rows (str "repeat(" chunk-size ", 1fr)") 61 | :grid-template-columns (str "repeat(" chunk-size ", 1fr)")}] 62 | 63 | [:.r 64 | {:accent-color :red}] 65 | 66 | [:.o 67 | {:accent-color :orange}] 68 | 69 | [:.g 70 | {:accent-color :green}] 71 | 72 | [:.b 73 | {:accent-color :blue}] 74 | 75 | [:.p 76 | {:accent-color :purple}] 77 | 78 | [:.f 79 | {:accent-color :fuchsia}]]))) 80 | 81 | (defn Checkbox [[id color-class]] 82 | (let [checked (boolean color-class)] 83 | (h/html 84 | [:input 85 | {:class color-class 86 | :type "checkbox" 87 | :checked checked 88 | :data-id id}]))) 89 | 90 | (defn Chunk [x y chunk] 91 | (h/html 92 | [:div.chunk 93 | {:style {:grid-row y :grid-column x}} 94 | (into [] 95 | (map (fn [box] (Checkbox box))) 96 | chunk)])) 97 | 98 | (defn UserView [{:keys [x y] :or {x 0 y 0}} board-state] 99 | (second 100 | (reduce 101 | (fn [[dy view] board-row] 102 | [(inc dy) 103 | (into view 104 | (map-indexed (fn [dx chunk] 105 | (Chunk (inc (+ x dx)) (inc (+ y dy)) chunk))) 106 | (subvec board-row x (min (+ x view-size) board-size)))]) 107 | [0 []] 108 | (subvec board-state y (min (+ y view-size) board-size))))) 109 | 110 | (defn Board [sid content] 111 | (h/html 112 | [:div#board.board 113 | {:style 114 | {:accent-color (class->color (h/modulo-pick colors sid))} 115 | :data-on-mousedown "evt.target.dataset.id && 116 | @post(`/tap?id=${evt.target.dataset.id}`)"} 117 | content])) 118 | 119 | (defn scroll-offset-js [n] 120 | (str "Math.round((" n "/" board-size-px ")*" board-size "-1)")) 121 | 122 | (def on-scroll-js 123 | (str 124 | "let x = " (scroll-offset-js "el.scrollLeft") ";" 125 | "let y = " (scroll-offset-js "el.scrollTop") ";" 126 | "let change = x !== $x || y !== $y;" 127 | "$x = x; $y = y;" 128 | "change && @post(`/scroll`)")) 129 | 130 | (defn render-home [{:keys [db sid tabid first-render] :as _req}] 131 | (let [snapshot @db 132 | user (get-in snapshot [:users sid tabid]) 133 | board (Board sid (UserView user (:board snapshot)))] 134 | (if first-render 135 | (h/html 136 | [:link#css {:rel "stylesheet" :type "text/css" :href (css :path)}] 137 | [:main#morph.main {:data-signals-x "0" :data-signals-y "0"} 138 | [:div#view.view 139 | {:data-on-scroll__throttle.100ms.trail.noleading on-scroll-js} 140 | board] 141 | [:h1 "One Million Checkboxes"] 142 | [:p "Built with ❤️ using " 143 | [:a {:href "https://clojure.org/"} "Clojure"] 144 | " and " 145 | [:a {:href "https://data-star.dev"} "Datastar"] 146 | "🚀"] 147 | [:p "Source code can be found " 148 | [:a {:href "https://github.com/andersmurphy/hyperlith/blob/master/examples/one_million_checkboxes/src/app/main.clj" } "here"]]]) 149 | board))) 150 | 151 | (defn action-tap-cell [{:keys [sid db] {:strs [id]} :query-params}] 152 | (when id 153 | (let [color-class (h/modulo-pick colors sid) 154 | [x y c] (mapv parse-long (str/split id #"-"))] 155 | (swap! db update-in [:board y x c 1] 156 | (fn [color] (if (nil? color) color-class nil)))))) 157 | 158 | (defn action-scroll [{:keys [sid tabid db] {:keys [x y]} :body}] 159 | (swap! db 160 | (fn [snapshot] 161 | (-> snapshot 162 | (assoc-in [:users sid tabid :x] (max (int x) 0)) 163 | (assoc-in [:users sid tabid :y] (max (int y) 0)))))) 164 | 165 | (def default-shim-handler 166 | (h/shim-handler 167 | (h/html 168 | [:link#css {:rel "stylesheet" :type "text/css" :href (css :path)}] 169 | [:title nil "One Million checkboxes"] 170 | [:meta {:content "So many checkboxes" :name "description"}]))) 171 | 172 | (def router 173 | (h/router 174 | {[:get (css :path)] (css :handler) 175 | [:get "/"] default-shim-handler 176 | [:post "/"] (h/render-handler #'render-home 177 | {:br-window-size 19}) 178 | [:post "/scroll"] (h/action-handler #'action-scroll) 179 | [:post "/tap"] (h/action-handler #'action-tap-cell)})) 180 | 181 | (defn initial-board-state [] 182 | (mapv 183 | (fn [y] 184 | (mapv 185 | (fn [x] 186 | (mapv (fn [c] 187 | ;; building the id once here leads to a 7x speed up 188 | ;; generating hiccup (building strings is expensive) 189 | [(str x "-" y "-" c) nil]) 190 | (range (* chunk-size chunk-size)))) 191 | (range board-size))) 192 | (range board-size))) 193 | 194 | (defn ctx-start [] 195 | (let [db_ (atom {:board (initial-board-state) 196 | :users {}})] 197 | (add-watch db_ :refresh-on-change 198 | (fn [_ _ old-state new-state] 199 | ;; Only refresh if state has changed 200 | (when-not (= old-state new-state) 201 | (h/refresh-all!)))) 202 | {:db db_})) 203 | 204 | (defn -main [& _] 205 | (h/start-app 206 | {:router #'router 207 | :max-refresh-ms 100 208 | :ctx-start ctx-start 209 | :ctx-stop (fn [{:keys [game-stop]}] (game-stop)) 210 | :csrf-secret (h/env :csrf-secret) 211 | :on-error (fn [_ctx {:keys [_req error]}] 212 | (pprint/pprint error) 213 | (flush))})) 214 | 215 | ;; Refresh app when you re-eval file 216 | (h/refresh-all!) 217 | 218 | (comment 219 | (do (-main) nil) 220 | ;; (clojure.java.browse/browse-url "http://localhost:8080/") 221 | 222 | ;; stop server 223 | (((h/get-app) :stop)) 224 | 225 | (def db (-> (h/get-app) :ctx :db)) 226 | 227 | (@db :users) 228 | 229 | ,) 230 | 231 | (comment 232 | (def db (-> (h/get-app) :ctx :db)) 233 | 234 | (user/bench (do (UserView {:x 10 :y 10} (@db :board)) nil)) 235 | ) 236 | -------------------------------------------------------------------------------- /examples/popover/README.md: -------------------------------------------------------------------------------- 1 | ## Build JAR. 2 | 3 | ```bash 4 | clojure -Srepro -T:build uber 5 | ``` 6 | 7 | ## Run jar locally 8 | 9 | ``` 10 | java -Dclojure.server.repl="{:port 5555 :accept clojure.core.server/repl}" -jar target/app.jar -m app.main -Duser.timezone=UTC -XX:+UseZGC -XX:+ZGenerational 11 | ``` 12 | 13 | ## Deploy 14 | 15 | Move JAR to server (this will trigger a service restart). 16 | 17 | ```bash 18 | scp target/app.jar root@example.andersmurphy.com:/home/app/ 19 | ``` 20 | 21 | ## After deploying first jar 22 | 23 | Optional: the first time you move the jar onto the server you will need to reboot to trigger/test systemd is working correctly. 24 | 25 | ``` 26 | ssh root@example.andersmurphy.com "reboot" 27 | ``` 28 | 29 | ## SSH into repl 30 | 31 | ```bash 32 | ssh root@example.andersmurphy.com "nc localhost:5555" 33 | ``` 34 | 35 | -------------------------------------------------------------------------------- /examples/popover/build.clj: -------------------------------------------------------------------------------- 1 | (ns build 2 | (:require [clojure.tools.build.api :as b])) 3 | 4 | (def lib 'app) 5 | (def class-dir "target/classes") 6 | (def basis (delay (b/create-basis {:project "deps.edn"}))) 7 | (def uber-file (format "target/%s.jar" (name lib))) 8 | 9 | (defn clean [_] (b/delete {:path "target"})) 10 | 11 | (defn uber 12 | [_] 13 | (clean nil) 14 | (b/copy-dir {:src-dirs ["src" "resources"] :target-dir class-dir}) 15 | (b/compile-clj {:basis @basis 16 | :ns-compile '[app.main] 17 | :src-dirs ["src"] 18 | :class-dir class-dir}) 19 | (b/uber {:class-dir class-dir 20 | :uber-file uber-file 21 | :basis @basis 22 | :main 'app.main})) 23 | -------------------------------------------------------------------------------- /examples/popover/deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src" "resources"] 2 | :deps {org.clojure/clojure {:mvn/version "1.12.0"} 3 | hyperlith/hyperlith {:local/root "../../../hyperlith"}} 4 | :aliases {:build {:deps {io.github.clojure/tools.build 5 | {:git/tag "v0.10.5" :git/sha "2a21b7a"}} 6 | :ns-default build}}} 7 | -------------------------------------------------------------------------------- /examples/popover/resources/.env.edn: -------------------------------------------------------------------------------- 1 | {;; WARNING: .env.edn should not normally be committed to source control 2 | ;; but is here as an example. 3 | :csrf-secret "fb1704df2b3484223cb5d2a79bf06a508311d8d0f03c68e724d555b6b605966d0ebb8dc54615f8d080e5fa062bd3b5bce5b6ba7ded23333bbd55deea3149b9d5"} 4 | -------------------------------------------------------------------------------- /examples/popover/src/app/main.clj: -------------------------------------------------------------------------------- 1 | (ns app.main 2 | (:gen-class) 3 | (:require [hyperlith.core :as h])) 4 | 5 | (def css 6 | (h/static-css 7 | [["*, *::before, *::after" 8 | {:box-sizing :border-box 9 | :margin 0 10 | :padding 0}] 11 | 12 | [:html 13 | {:font-family "Arial, Helvetica, sans-serif"}] 14 | 15 | [:.main 16 | {:height :100dvh 17 | :width "min(100% - 2rem , 40rem)" 18 | :margin-inline :auto 19 | :padding-block :2dvh 20 | :display :grid 21 | :place-items :center}] 22 | 23 | [:.counter 24 | {:text-align :center 25 | :font-size :50px}] 26 | 27 | [:.popover 28 | {:position :absolute 29 | :top :50% 30 | :left :50% 31 | :transform "translate(-50%, -50%)"}]])) 32 | 33 | (defn render-home [{:keys [db] :as _req}] 34 | (h/html 35 | [:link#css {:rel "stylesheet" :type "text/css" :href (css :path)}] 36 | [:main#morph.main 37 | ;; We track connected users as this will cause updates out of bounds 38 | ;; and will show that the popover state is not affected by other users 39 | [:div 40 | [:p nil "connected users"] 41 | [:p.counter nil (@db :connected-users)]] 42 | [:button {:popovertarget "my-popover"} "Open Popover"] 43 | [:div#my-popover.popover {:popover true} "Greetings, one and all!"]])) 44 | 45 | (def default-shim-handler 46 | (h/shim-handler 47 | (h/html 48 | [:link#css {:rel "stylesheet" :type "text/css" :href (css :path)}]))) 49 | 50 | (def router 51 | (h/router 52 | {[:get (css :path)] (css :handler) 53 | [:get "/"] default-shim-handler 54 | [:post "/"] (h/render-handler #'render-home 55 | ;; Example of tracking connected users 56 | ;; This could use a separate atom or a 57 | ;; commute and ref 58 | :on-open 59 | (fn [{:keys [_ db]}] 60 | (swap! db update :connected-users inc)) 61 | :on-close 62 | (fn [{:keys [_ db]}] 63 | (swap! db update :connected-users dec)))})) 64 | 65 | (defn ctx-start [] 66 | (let [db_ (atom {:connected-users 0})] 67 | (add-watch db_ :refresh-on-change (fn [& _] (h/refresh-all!))) 68 | {:db db_})) 69 | 70 | (defn -main [& _] 71 | (h/start-app 72 | {:router #'router 73 | :max-refresh-ms 100 74 | :ctx-start ctx-start 75 | :ctx-stop (fn [_state] nil) 76 | :csrf-secret (h/env :csrf-secret)})) 77 | 78 | ;; Refresh app when you re-eval file 79 | (h/refresh-all!) 80 | 81 | (comment 82 | 83 | (-main) 84 | ;; (clojure.java.browse/browse-url "http://localhost:8080/") 85 | 86 | ;; stop server 87 | (((h/get-app) :stop)) 88 | 89 | ,) 90 | -------------------------------------------------------------------------------- /examples/presence_cursors/README.md: -------------------------------------------------------------------------------- 1 | ## Build JAR. 2 | 3 | ```bash 4 | clojure -Srepro -T:build uber 5 | ``` 6 | 7 | ## Run jar locally 8 | 9 | ``` 10 | java -Dclojure.server.repl="{:port 5555 :accept clojure.core.server/repl}" -jar target/app.jar -m app.main -Duser.timezone=UTC -XX:+UseZGC -XX:+ZGenerational 11 | ``` 12 | 13 | ## Deploy 14 | 15 | Move JAR to server (this will trigger a service restart). 16 | 17 | ```bash 18 | scp target/app.jar root@example.andersmurphy.com:/home/app/ 19 | ``` 20 | 21 | ## After deploying first jar 22 | 23 | Optional: the first time you move the jar onto the server you will need to reboot to trigger/test systemd is working correctly. 24 | 25 | ``` 26 | ssh root@example.andersmurphy.com "reboot" 27 | ``` 28 | 29 | ## SSH into repl 30 | 31 | ```bash 32 | ssh root@example.andersmurphy.com "nc localhost:5555" 33 | ``` 34 | 35 | -------------------------------------------------------------------------------- /examples/presence_cursors/build.clj: -------------------------------------------------------------------------------- 1 | (ns build 2 | (:require [clojure.tools.build.api :as b])) 3 | 4 | (def lib 'app) 5 | (def class-dir "target/classes") 6 | (def basis (delay (b/create-basis {:project "deps.edn"}))) 7 | (def uber-file (format "target/%s.jar" (name lib))) 8 | 9 | (defn clean [_] (b/delete {:path "target"})) 10 | 11 | (defn uber 12 | [_] 13 | (clean nil) 14 | (b/copy-dir {:src-dirs ["src" "resources"] :target-dir class-dir}) 15 | (b/compile-clj {:basis @basis 16 | :ns-compile '[app.main] 17 | :src-dirs ["src"] 18 | :class-dir class-dir}) 19 | (b/uber {:class-dir class-dir 20 | :uber-file uber-file 21 | :basis @basis 22 | :main 'app.main})) 23 | -------------------------------------------------------------------------------- /examples/presence_cursors/deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src" "resources"] 2 | :deps {org.clojure/clojure {:mvn/version "1.12.0"} 3 | hyperlith/hyperlith {:local/root "../../../hyperlith"}} 4 | :aliases {:build {:deps {io.github.clojure/tools.build 5 | {:git/tag "v0.10.5" :git/sha "2a21b7a"}} 6 | :ns-default build}}} 7 | -------------------------------------------------------------------------------- /examples/presence_cursors/resources/.env.edn: -------------------------------------------------------------------------------- 1 | {;; WARNING: .env.edn should not normally be committed to source control 2 | ;; but is here as an example. 3 | :csrf-secret "fb1704df2b3484223cb5d2a79bf06a508311d8d0f03c68e724d555b6b605966d0ebb8dc54615f8d080e5fa062bd3b5bce5b6ba7ded23333bbd55deea3149b9d5"} 4 | -------------------------------------------------------------------------------- /examples/presence_cursors/src/app/main.clj: -------------------------------------------------------------------------------- 1 | (ns app.main 2 | (:gen-class) 3 | (:require [hyperlith.core :as h])) 4 | 5 | (def css 6 | (h/static-css 7 | [["*, *::before, *::after" 8 | {:box-sizing :border-box 9 | :margin 0 10 | :padding 0}] 11 | 12 | [:.cursor-area 13 | {:user-select :none 14 | :height :100dvh 15 | :width "100%"}] 16 | 17 | [:.cursor 18 | {:position :absolute 19 | :transition "all 0.2s ease-in-out"}]])) 20 | 21 | (def cursors 22 | (h/cache 23 | (fn [db] 24 | (for [[sid [x y]] @db] 25 | [:div.cursor 26 | {:id (h/digest sid) 27 | :style {:left (str x "px") :top (str y "px")}} 28 | "🚀"])))) 29 | 30 | (defn render-home [{:keys [db] :as _req}] 31 | (h/html 32 | [:link#css {:rel "stylesheet" :type "text/css" :href (css :path)}] 33 | [:main#morph.main {:data-signals-x__ifmissing 0 34 | :data-signals-y__ifmissing 0} 35 | [:div.cursor-area 36 | {:data-on-mousemove__debounce.100ms 37 | "$x = evt.clientX; $y = evt.clientY; @post('/position')"} 38 | (cursors db)]])) 39 | 40 | (defn action-user-cursor-position [{:keys [sid db] {:keys [x y]} :body}] 41 | (when (and x y) 42 | (swap! db assoc sid [x y]))) 43 | 44 | (def default-shim-handler 45 | (h/shim-handler 46 | (h/html 47 | [:link#css {:rel "stylesheet" :type "text/css" :href (css :path)}]))) 48 | 49 | (def router 50 | (h/router 51 | {[:get (css :path)] (css :handler) 52 | [:get "/"] default-shim-handler 53 | [:post "/"] (h/render-handler #'render-home 54 | :on-close 55 | (fn [{:keys [sid db]}] (swap! db dissoc sid))) 56 | [:post "/position"] (h/action-handler action-user-cursor-position)})) 57 | 58 | (defn ctx-start [] 59 | (let [db_ (atom {})] 60 | (add-watch db_ :refresh-on-change (fn [& _] (h/refresh-all!))) 61 | {:db db_})) 62 | 63 | (defn -main [& _] 64 | (h/start-app 65 | {:router #'router 66 | :max-refresh-ms 100 67 | :ctx-start ctx-start 68 | :ctx-stop (fn [_db] nil) 69 | :csrf-secret (h/env :csrf-secret)})) 70 | 71 | ;; Refresh app when you re-eval file 72 | (h/refresh-all!) 73 | 74 | (comment 75 | (-main) 76 | ;; (clojure.java.browse/browse-url "http://localhost:8080/") 77 | 78 | ;; stop server 79 | (((h/get-app) :stop)) 80 | 81 | (-> (h/get-app) :ctx :db) 82 | 83 | (reset! (-> (h/get-app) :ctx :db) {}) 84 | 85 | ;; Example backend driven cursor test 86 | (doseq [_x (range 10000)] 87 | (Thread/sleep 1) 88 | (action-user-cursor-position 89 | {:db (-> (h/get-app) :ctx :db) 90 | :sid (rand-nth (range 1000)) 91 | :body {:x (rand-nth (range 1 400 20)) 92 | :y (rand-nth (range 1 400 20))}})) 93 | 94 | ,) 95 | -------------------------------------------------------------------------------- /examples/server-setup.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -x 3 | set -e 4 | 5 | # Dependencies 6 | apt-get update 7 | apt-get upgrade 8 | apt-get -y install openjdk-21-jre-headless ufw caddy 9 | 10 | # App user (you cannot login as this user) 11 | useradd -rms /usr/sbin/nologin app 12 | 13 | # Systemd service 14 | cat > /etc/systemd/system/app.service << EOD 15 | [Unit] 16 | Description=app 17 | StartLimitIntervalSec=500 18 | StartLimitBurst=5 19 | ConditionPathExists=/home/app/app.jar 20 | 21 | [Service] 22 | User=app 23 | Restart=on-failure 24 | RestartSec=5s 25 | WorkingDirectory=/home/app 26 | ExecStart=/usr/bin/java -Dclojure.server.repl="{:port 5555 :accept clojure.core.server/repl}" -jar app.jar -m app.main -Duser.timezone=UTC -XX:+UseZGC -XX:+ZGenerational -XX:InitialRAMPercentage 75.0 -XX:MaxRAMPercentage 75.0 -XX:MinRAMPercentage 75.0 27 | 28 | [Install] 29 | WantedBy=multi-user.target 30 | EOD 31 | systemctl enable app.service 32 | 33 | cat > /etc/systemd/system/app-watcher.service << EOD 34 | [Unit] 35 | Description=Restarts app on jar upload 36 | After=network.target 37 | 38 | [Service] 39 | ExecStart=/usr/bin/env systemctl restart app.service 40 | 41 | [Install] 42 | WantedBy=multi-user.target 43 | EOD 44 | systemctl enable app-watcher.service 45 | 46 | cat > /etc/systemd/system/app-watcher.path << EOD 47 | [Unit] 48 | Wants=app-watcher.service 49 | 50 | [Path] 51 | PathChanged=/home/app/app.jar 52 | 53 | [Install] 54 | WantedBy=multi-user.target 55 | EOD 56 | systemctl enable app-watcher.path 57 | 58 | # Firewall 59 | ufw default deny incoming 60 | ufw default allow outgoing 61 | ufw allow OpenSSH 62 | ufw allow 80 63 | ufw allow 443 64 | ufw --force enable 65 | 66 | # Reverse proxy 67 | rm /etc/caddy/Caddyfile 68 | cat > /etc/caddy/Caddyfile << EOD 69 | example.andersmurphy.com { 70 | header -Server 71 | reverse_proxy localhost:8080 { 72 | lb_try_duration 30s 73 | lb_try_interval 1s 74 | } 75 | } 76 | EOD 77 | 78 | # Let's encrypt 79 | systemctl daemon-reload 80 | systemctl enable --now caddy 81 | 82 | # ssh config 83 | cat >> /etc/ssh/sshd_config << EOD 84 | # Setup script changes 85 | PasswordAuthentication no 86 | PubkeyAuthentication yes 87 | AuthorizedKeysFile .ssh/authorized_keys 88 | EOD 89 | systemctl restart ssh 90 | 91 | -------------------------------------------------------------------------------- /resources/datastar.js: -------------------------------------------------------------------------------- 1 | // Datastar v1.0.0-RC.8 2 | var gt=/🖕JS_DS🚀/.source,De=gt.slice(0,5),re=gt.slice(4),V="datastar",bt="Datastar-Request",yt=1e3,ht="type module",vt=!1,St=!1,Et=!0,Ue="morph",Tt="inner",xt="outer",At="prepend",wt="append",Mt="before",Rt="after",Dt="upsertAttributes",Ct=Ue,ge="datastar-merge-fragments",be="datastar-merge-signals",Ke="datastar-remove-fragments",Be="datastar-remove-signals",ye="datastar-execute-script";var X=e=>e.trim()==="true",G=e=>e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").replace(/([a-z])([0-9]+)/gi,"$1-$2").replace(/([0-9]+)([a-z])/gi,"$1-$2").toLowerCase(),he=e=>G(e).replace(/-./g,t=>t[1].toUpperCase()),ve=e=>G(e).replace(/-/g,"_"),qn=e=>he(e).replace(/^./,t=>t[0].toUpperCase()),Lt=e=>Function(`return Object.assign({}, ${e})`)(),J=e=>e.startsWith("$")?e.slice(1):e,_n={kebab:G,snake:ve,pascal:qn};function L(e,t){for(let n of t.get("case")||[]){let r=_n[n];r&&(e=r(e))}return e}var jn="computed",Ft={type:"attribute",name:jn,keyReq:"must",valReq:"must",isExpr:!0,onLoad:({key:e,mods:t,genRX:n,computed:r,batch:s})=>{e=L(e,t);let{deps:a,rxFn:o}=n();s(()=>{r(a,o,e)})}};var _=`${V}-signal-change`;var Fe=new Set,Pe=new Set,ke=new Set;function Ne(e){Fe.clear(),Pe.clear(),ke.clear();let t=e();return(Fe.size||Pe.size||ke.size)&&document.dispatchEvent(new CustomEvent(_,{detail:{added:[...Fe],removed:[...Pe],updated:[...ke]}})),t}var I=[],Q=new Map;function ie(){return I}function Ze(e){return Q.get(e)}function Un(e){let t=[],n=0,r=I.length-1,s=a=>a!==e&&!a.startsWith(`${e}.`);for(;n<=r;){let a=Math.floor((n+r)/2),o=I[a];if(oe)r=a-1;else{let i=a;do{let c=I[i];if(s(c))break;i--}while(i>=0);let l=a;do{let c=I[l];if(s(c))break;l++}while(lr.length-n.length),Fe.add(e)}function W(e,t){let n=Q.get(e);if(n){if(!(n instanceof se))return;n.value=t}else Te(t,e)}function oe(e){return Q.get(e)?.value}function ae(e,t){let n=!1,r=Q.get(e);return r||(n=!0,r=Te(t,e)),{dep:r,inserted:n}}function Ve(e,t=!1){let n=kt(e);for(let[r,s]of Object.entries(n))Q.has(r)&&t||W(r,s)}function et(...e){for(let t of e){let n=Un(t);for(let r of n){Q.delete(r);let s=I.indexOf(r);s>-1&&I.splice(s,1),Pe?.add(r)}}}function Oe(e=!0,t=!1){return JSON.stringify(Kn(t),null,e?2:0)}function Kn(e,...t){let n=new Map;for(let s of I)if(!(e&&s.match(/^_|\._/))){if(t.length>0){let a=!1;for(let o of t)if(s.startsWith(o)){a=!0;break}if(!a)continue}n.set(s,Q.get(s)?.value)}return zn(n)}function Bn(e){return Object.prototype.toString.call(e)==="[object Object]"}function kt(e,t=[],n="."){return Object.keys(e).reduce((r,s)=>Object.assign({},r,Bn(e[s])?kt(e[s],t.concat([s]),n):{[t.concat([s]).join(n)]:e[s]}),{})}function zn(e,t="."){let n={};for(let[r,s]of e.entries()){let a=r.split(t),o=n;for(let i=0;ie(...r,...n.map((s,a)=>t[a]?s:s.value)))}var Ce=[],Se=[],Le=0,Ee=0;function tt(e,t){switch(e.length){case 0:return t;case 1:return()=>t(e[0].value);case 2:return()=>t(e[0].value,e[1].value);case 3:return()=>t(e[0].value,e[1].value,e[2].value);case 4:return()=>t(e[0].value,e[1].value,e[2].value,e[3].value)}let n=e.length;return Ce[n]||(Ce[n]=Array(n)),()=>{for(let r=0;rn.dispose()}var Je=class{constructor(t,n){this.flags=2;for(let r of t)Vt(r,this);this.run=tt(t,n),this.run()}dispose(){if(this.depsTail=void 0,this.flags&=-249,this.deps){let t=this.deps;do{let n=t.dep,r=t.nextDep,s=t.nextSub,a=t.prevSub;if(s?s.prevSub=a:n.subsTail=a,a?a.nextSub=s:n.subs=s,!n.subs&&"deps"in n){let o=n.flags;o&32||(n.flags=o|32);let i=n.deps;if(i){t=i,n.depsTail.nextDep=r,n.deps=void 0,n.depsTail=void 0;continue}}t=r}while(t);this.deps=void 0}}};function Vt(e,t){let n=t.depsTail;if(n&&n.dep===e)return;let r=n?n.nextDep:t.deps;if(r&&r.dep===e){t.depsTail=r;return}let s=e.subsTail;if(s&&s.sub===t&&Ye(s,t))return;let a={dep:e,sub:t,nextDep:r,prevSub:void 0,nextSub:void 0};if(n?n.nextDep=a:t.deps=a,!e.subs)e.subs=a;else{let o=e.subsTail;a.prevSub=o,o.nextSub=a}return t.depsTail=a,e.subsTail=a,a}function Ot(e){let t,n=0,r;e:do{r=!1;let s=e.dep;if(e.sub.flags&32)r=!0;else if("flags"in s){let a=s.flags;if((a&33)===33){if(ze(s)){let o=s.subs;o.nextSub&&Qe(o),r=!0}}else if((a&65)===65){(e.nextSub||e.prevSub)&&(t={target:e,linked:t}),e=s.deps,++n;continue}}if(!r&&e.nextDep){e=e.nextDep;continue}for(;n;){--n;let a=e.sub,o=a.subs;if(r){if(ze(a)){o.nextSub?(e=t.target,t=t.linked,Qe(o)):e=o;continue}}else a.flags&=-65;if(o.nextSub?(e=t.target,t=t.linked):e=o,e.nextDep){e=e.nextDep;continue e}r=!1}return r}while(!0)}function Qe(e){do{let t=e.sub,n=t.flags;(n&96)===64&&(t.flags=n|32|8,(n&10)===2&&(Se[Ee++]=t)),e=e.nextSub}while(e)}function Ye(e,t){let n=t.depsTail;if(n){let r=t.deps;do{if(r===e)return!0;if(r===n)break;r=r.nextDep}while(r)}return!1}var It={type:"attribute",name:"signals",isExpr:!0,onLoad:e=>{let{key:t,mods:n,value:r,genRX:s,evalRX:a,batch:o}=e,{deps:i,dm:l,rxFn:c}=s(),u=n.has("ifmissing");if(t!==""){let p=L(t,n),m=r===""?r:a(c,l,i);o(()=>{u?ae(p,m):W(p,m)})}else{let p=a(c,l,i);o(()=>{Ve(p,u)})}}};function Ie(e){return e instanceof HTMLElement||e instanceof SVGElement}function le(e,t){let n=Y?`data-${Y}-ignore`:"data-ignore",r=`${n}__self`;if(!Ie(e)||e.closest(`[${n}]`))return;let s=document.createTreeWalker(e,1);for(;e;){if(Ie(e)){if(e.hasAttribute(n)){e=s.nextSibling();continue}e.hasAttribute(r)||t(e)}e=s.nextNode()}}var Qn="https://data-star.dev/errors";function Ht(e,t,n={}){let r=new Error;r.name=`${V} ${e} error`;let s=ve(t),a=new URLSearchParams({metadata:JSON.stringify(n)}).toString(),o=JSON.stringify(n,null,2);return r.message=`${t} 3 | More info: ${Qn}/${e}/${s}?${a} 4 | Context: ${o}`,r}function H(e,t,n={}){let r={plugin:{name:t.plugin.name,type:t.plugin.type}};return Ht("init",e,Object.assign(r,n))}function v(e,t,n={}){let r={plugin:{name:t.plugin.name,type:t.plugin.type},element:{id:t.el.id,tag:t.el.tagName},expression:{rawKey:t.rawKey,key:t.key,value:t.value,validSignals:ie(),fnContent:t.fnContent}};return Ht("runtime",e,Object.assign(r,n))}var xe={},it=[],ce=new Map,st=null,Y="";function $t(e){Y=e}function He(...e){for(let t of e){let n={plugin:t,actions:xe,removals:ce,applyToElement:$e,batch:Ne,signal:Te,computed:nt,effect:rt},r=t.type;if(r==="action")xe[t.name]=t;else if(r==="attribute")it.push(t),t.onGlobalInit?.(n);else if(r==="watcher")t.onGlobalInit?.(n);else throw H("InvalidPluginType",n)}it.sort((t,n)=>{let r=n.name.length-t.name.length;return r!==0?r:t.name.localeCompare(n.name)})}function ot(){queueMicrotask(()=>{le(document.documentElement,$e),st||(st=new MutationObserver(Yn),st.observe(document.body,{subtree:!0,childList:!0,attributes:!0}))})}function Yn(e){let t=new Set;for(let{target:r,type:s,addedNodes:a,removedNodes:o}of e)switch(s){case"childList":{for(let i of o)le(i,l=>{let c=ce.get(l);if(ce.delete(l)){for(let u of c.values())u();c.clear()}});for(let i of a)le(i,l=>t.add(l))}break;case"attributes":{if(!Ie(r)||r.closest(`[${Y?`data-${Y}-ignore`:"data-ignore"}]`))continue;t.add(r);break}}let n=Array.from(t);n.sort((r,s)=>r.compareDocumentPosition(s)&Node.DOCUMENT_POSITION_FOLLOWING?-1:1);for(let r of n)$e(r)}function Gt(e){let t=5831,n=e.length;for(;n--;)t+=(t<<5)+e.charCodeAt(n);return(t>>>0).toString(36)}function $e(e){let t=[],n=ce.get(e)||new Map,r=new Map(n);for(let[s,a]of Object.entries(e.dataset)){if(!s.startsWith(Y))continue;let o=Gt(`${s}${a}`);r.delete(o)||t.push({key:s,value:a,hash:o})}for(let[s,a]of r)a(),n.delete(s);for(let{key:s,value:a,hash:o}of t){let i=Zn(e,s,a);i&&n.set(o,i)}n.size&&ce.set(e,n)}function Zn(e,t,n){let r=he(t.slice(Y.length)),s=it.find(m=>RegExp(`^${m.name}([A-Z]|_|$)`).test(r));if(!s)return;let[a,...o]=r.slice(s.name.length).split(/\_\_+/),i=!!a;i&&(a=he(a));let l=!!n,c={applyToElement:$e,actions:xe,removals:ce,genRX:()=>er(c,...s.argNames||[]),plugin:s,el:e,rawKey:r,key:a,value:n,mods:new Map,batch:Ne,signal:Te,computed:nt,effect:rt,evalRX:Nt},u=s.keyReq||"allowed";if(i){if(u==="denied")throw v(`${s.name}KeyNotAllowed`,c)}else if(u==="must")throw v(`${s.name}KeyRequired`,c);let p=s.valReq||"allowed";if(l){if(p==="denied")throw v(`${s.name}ValueNotAllowed`,c)}else if(p==="must")throw v(`${s.name}ValueRequired`,c);if(u==="exclusive"||p==="exclusive"){if(i&&l)throw v(`${s.name}KeyAndValueProvided`,c);if(!i&&!l)throw v(`${s.name}KeyOrValueRequired`,c)}for(let m of o){let[h,...f]=m.split(".");c.mods.set(he(h),new Set(f.map(b=>b.toLowerCase())))}return s.onLoad(c)||(()=>{})}function er(e,...t){let n=[],r=new Set,s="";if(e.plugin.isExpr){let f=/(\/(\\\/|[^\/])*\/|"(\\"|[^\"])*"|'(\\'|[^'])*'|`(\\`|[^`])*`|\(\s*((function)\s*\(\s*\)|(\(\s*\))\s*=>)\s*(?:\{[\s\S]*?\}|[^;)\{]*)\s*\)\s*\(\s*\)|[^;])+/gm,b=e.value.trim().match(f);if(b){let S=b.length-1,R=b[S].trim();R.startsWith("return")||(b[S]=`return (${R});`),s=b.join(`; 5 | `)}}else s=e.value.trim();let a=new Map,o=RegExp(`(?:${De})(.*?)(?:${re})`,"gm");for(let f of s.matchAll(o)){let b=f[1],S=`dsEscaped${Gt(b)}`;a.set(S,b),s=s.replace(De+b+re,S)}let i=(f,b)=>`${f}${ve(b).replaceAll(/\./g,"_")}`,l=new Set(t),c=ie();if(c.length){let f=c.join("|"),b=RegExp(`\\$(${f})(\\s*[+&^\\/*|-]?=[^=]|\\+\\+|--)`,"gm"),S=[...s.matchAll(b)],R=(k,T,x="")=>{let F=RegExp(`\\$${k[1]}(?!\\w)`,"gm");s=s.replaceAll(F,T+x)};if(S.length){let k=`${V}Mut_`,T=new Set;for(let x of S){let F=x[1],d=Ze(F),A=i(k,F);d&&!T.has(d)&&(r.add(d),T.add(d),n.push(!0),l.add(A)),R(x,A,".value")}}let D=RegExp(`\\$(${f})(\\W|$)`,"gm"),C=[...s.matchAll(D)];if(C.length){let k=`${V}Pure_`,T=new Set;for(let x of C){let F=x[1],d=Ze(F),A=i(k,F);d&&!T.has(d)&&(r.add(d),T.add(d),n.push(!1),l.add(A)),R(x,A)}}}let u=new Set,p=RegExp(`@(${Object.keys(xe).join("|")})\\(`,"gm"),m=[...s.matchAll(p)],h=new Set;if(m.length){let f=`${V}Act_`;for(let b of m){let S=b[1],R=xe[S];if(!R)continue;u.add(S);let D=i(f,S);l.add(D),s=s.replace(`@${S}(`,`${D}(`),h.add((...C)=>R.fn(e,...C))}}for(let[f,b]of a)s=s.replace(f,b);e.fnContent=s;try{let f=Function("el",...l,s);return{dm:n,deps:[...r],rxFn:(...b)=>{try{return f(e.el,...b,...h)}catch(S){throw v("ExecuteExpression",e,{error:S.message})}}}}catch(f){throw v("GenerateExpression",e,{error:f.message})}}He(It,Ft);var Z=`${V}-sse`,Ge="started",We="finished",Wt="error",qt="retrying",_t="retrying";function j(e,t){document.addEventListener(Z,n=>{if(n.detail.type!==e)return;let{argsRaw:r}=n.detail;t(r)})}function te(e,t,n){document.dispatchEvent(new CustomEvent(Z,{detail:{type:e,el:t,argsRaw:n}}))}var jt=e=>`${e}`.includes("text/event-stream"),Ut=e=>e==="GET",zt="text/event-stream",Xt="text/html",Jt="application/json",Qt="application/javascript",tr=[zt,Xt,Jt,Qt],U=async(e,t,n,r)=>{let{el:s,evt:a}=e,{headers:o,contentType:i,includeLocal:l,excludeSignals:c,selector:u,openWhenHidden:p,retryInterval:m,retryScaler:h,retryMaxWaitMs:f,retryMaxCount:b,abort:S}=Object.assign({headers:{},contentType:"json",includeLocal:!1,excludeSignals:!1,selector:null,openWhenHidden:!1,retryInterval:yt,retryScaler:2,retryMaxWaitMs:3e4,retryMaxCount:10,abort:void 0},r),R=t.toLowerCase(),D=()=>{};try{if(!n?.length)throw v("SseNoUrlProvided",e,{action:R});let C={Accept:tr.join(", ")};C[bt]=!0,i==="json"&&(C["Content-Type"]="application/json");let k=Object.assign({},C,o),T={method:t,headers:k,openWhenHidden:p,retryInterval:m,retryScaler:h,retryMaxWaitMs:f,retryMaxCount:b,signal:S,onopen:async d=>{if(d.status>=400){let A=d.status.toString();te(Wt,s,{status:A})}},onmessage:d=>{if(!d.event.startsWith(V))return;let A=d.event,P={},q=d.data.split(` 6 | `);for(let y of q){let w=y.indexOf(" "),E=y.slice(0,w),M=P[E];M||(M=[],P[E]=M);let O=y.slice(w+1);M.push(O)}let g={};for(let[y,w]of Object.entries(P))g[y]=w.join(` 7 | `);te(A,s,g)},onerror:d=>{if(jt(d))throw v("InvalidContentType",e,{url:n});d&&(console.error(d.message),te(qt,s,{message:d.message}))}},x=new URL(n,window.location.href),F=new URLSearchParams(x.search);if(i==="json"){if(!c){let d=Oe(!1,!l);Ut(t)?F.set(V,d):T.body=d}}else if(i==="form"){let d=u?document.querySelector(u):s.closest("form");if(d===null)throw u?v("SseFormNotFound",e,{action:R,selector:u}):v("SseClosestFormNotFound",e,{action:R});if(!d.checkValidity()){d.reportValidity(),D();return}let A=new FormData(d),P=s;if(s===d)a instanceof SubmitEvent&&(P=a.submitter);else{let y=w=>w.preventDefault();d.addEventListener("submit",y),D=()=>d.removeEventListener("submit",y)}if(P instanceof HTMLButtonElement){let y=P.getAttribute("name");y&&A.append(y,P.value)}let q=d.getAttribute("enctype")==="multipart/form-data";q||(k["Content-Type"]="application/x-www-form-urlencoded");let g=new URLSearchParams(A);if(Ut(t))for(let[y,w]of g)F.append(y,w);else q?T.body=A:T.body=g}else throw v("SseInvalidContentType",e,{action:R,contentType:i});te(Ge,s,{}),x.search=F.toString();try{await or(x.toString(),s,T)}catch(d){if(!jt(d))throw v("SseFetchFailed",e,{method:t,url:n,error:d})}}finally{te(We,s,{}),D()}};async function nr(e,t){let n=e.getReader(),r;for(r=await n.read();!r.done;)t(r.value),r=await n.read()}function rr(e){let t,n,r,s=!1;return function(o){t===void 0?(t=o,n=0,r=-1):t=ir(t,o);let i=t.length,l=0;for(;n0){let l=s.decode(o.subarray(0,i)),c=i+(o[i+1]===32?2:1),u=s.decode(o.subarray(c));switch(l){case"data":r.data=r.data?`${r.data} 8 | ${u}`:u;break;case"event":r.event=u;break;case"id":e(r.id=u);break;case"retry":{let p=Number.parseInt(u,10);Number.isNaN(p)||t(r.retry=p);break}}}}}function ir(e,t){let n=new Uint8Array(e.length+t.length);return n.set(e),n.set(t,e.length),n}function Kt(){return{data:"",event:"",id:"",retry:void 0}}var Bt="last-event-id";function or(e,t,{signal:n,headers:r,onopen:s,onmessage:a,onclose:o,onerror:i,openWhenHidden:l,fetch:c,retryInterval:u=1e3,retryScaler:p=2,retryMaxWaitMs:m=3e4,retryMaxCount:h=10,overrides:f,...b}){return new Promise((S,R)=>{let D={...r};D.accept||(D.accept=zt);let C;function k(){C.abort(),document.hidden||q()}l||document.addEventListener("visibilitychange",k);let T=0;function x(){document.removeEventListener("visibilitychange",k),window.clearTimeout(T),C.abort()}n?.addEventListener("abort",()=>{x(),S()});let F=c??window.fetch,d=s??function(){},A=0,P=u;async function q(){C=new AbortController;try{let g=await F(e,{...b,headers:D,signal:C.signal});A=0,u=P,await d(g);let y=async(E,M,O,N,...Me)=>{let de={[O]:await M.text()};for(let me of Me){let B=`datastar-${G(me)}`,z=M.headers.get(B);if(N){let Re=N[me];Re&&(typeof Re=="string"?z=Re:z=JSON.stringify(Re))}z&&(de[me]=z)}te(E,t,de)},w=g.headers.get("Content-Type");if(w?.includes(Xt))return await y(ge,g,"fragments",f,"selector","mergeMode","useViewTransition");if(w?.includes(Jt))return await y(be,g,"signals",f,"onlyIfMissing","mergeMode","useViewTransition");if(w?.includes(Qt))return await y(ye,g,"script",f,"autoRemove","attributes");await nr(g.body,rr(sr(E=>{E?D[Bt]=E:delete D[Bt]},E=>{P=E,u=E},a))),o?.(),x(),S()}catch(g){if(!C.signal.aborted)try{let y=i?.(g)??u;window.clearTimeout(T),T=window.setTimeout(q,y),u*=p,u=Math.min(u,m),A++,A>=h?(te(_t,t,{}),x(),R("Max retries reached.")):console.error(`Datastar failed to reach ${e.toString()} retrying in ${y}ms.`)}catch(y){x(),R(y)}}}q()})}var Yt={type:"action",name:"delete",fn:async(e,t,n)=>U(e,"DELETE",t,{...n})};var Zt={type:"action",name:"get",fn:async(e,t,n)=>U(e,"GET",t,{...n})};var en={type:"action",name:"patch",fn:async(e,t,n)=>U(e,"PATCH",t,{...n})};var tn={type:"action",name:"post",fn:async(e,t,n)=>U(e,"POST",t,{...n})};var nn={type:"action",name:"put",fn:async(e,t,n)=>U(e,"PUT",t,{...n})};var rn={type:"watcher",name:ye,onGlobalInit:async e=>{j(ye,({autoRemove:t=`${Et}`,attributes:n=ht,script:r})=>{let s=X(t);if(!r?.length)throw H("NoScriptProvided",e);let a=document.createElement("script");for(let o of n.split(` 9 | `)){let i=o.indexOf(" "),l=i?o.slice(0,i):o,c=i?o.slice(i):"";a.setAttribute(l.trim(),c.trim())}a.text=r,document.head.appendChild(a),s&&a.remove()})}};var Ae=document,we=!!Ae.startViewTransition;function K(e,t){if(t.has("viewtransition")&&we){let n=e;e=(...r)=>document.startViewTransition(()=>n(...r))}return e}var mn="div",at="value",ut=e=>document.createElement(e),gn=()=>ut(mn),ar=e=>`<${e}>`,sn=e=>[...e.querySelectorAll("[id]")],bn=e=>e instanceof Element,on=e=>e instanceof HTMLTemplateElement,an=e=>e instanceof HTMLInputElement,ln=e=>e instanceof HTMLTextAreaElement,cn=e=>e instanceof HTMLOptionElement,yn={type:"watcher",name:ge,onGlobalInit:async e=>{let t=ut("template");j(ge,({fragments:n=ar(mn),selector:r="",mergeMode:s=Ct,useViewTransition:a="false"})=>{let o=X(a);t.innerHTML=n.trim();for(let i of[...t.content.children]){if(!bn(i))throw H("NoFragmentsFound",e);let l=r||`#${i.getAttribute("id")}`,c=document.querySelectorAll(l);if(!c.length)throw H("NoTargetsFound",e,{selectorOrID:l});o&&we?Ae.startViewTransition(()=>un(e,s,i,c)):un(e,s,i,c)}})}};function un(e,t,n,r){for(let s of r){let a=n.cloneNode(!0);switch(t){case Ue:{cr(s,a),le(s,o=>{let i=e.removals.get(o);if(e.removals.delete(o)){for(let l of i.values())l();i.clear()}e.applyToElement(o)});break}case Tt:s.innerHTML=a.outerHTML;break;case xt:s.replaceWith(a);break;case At:s.prepend(a);break;case wt:s.append(a);break;case Mt:s.before(a);break;case Rt:s.after(a);break;case Dt:for(let o of a.getAttributeNames()){let i=a.getAttribute(o);s.setAttribute(o,i)}break;default:throw H("InvalidMergeMode",e,{mergeMode:t})}}}var pe=gn();pe.hidden=!0;var qe,$=new Map,ue=new Set,lr=pe.moveBefore!==void 0;function cr(e,t){let n=gn();n.append(t);let r=sn(n),s=sn(e);e.id&&s.push(e);let a=new Set,o=new Map;for(let{id:m,tagName:h}of s)o.has(m)?a.add(m):o.set(m,h);ue.clear();for(let{id:m,tagName:h}of r)ue.has(m)?a.add(m):o.get(m)===h&&ue.add(m);for(let m of a)ue.delete(m);$.clear(),dn(e.parentElement,s),dn(n,r),document.body.insertAdjacentElement("afterend",pe),qe=e;let i=e.parentNode,l=e.previousSibling,c=e.nextSibling;hn(i,n,e,c);let u=[],p=l?.nextSibling||i.firstChild;for(;p&&p!==c;)u.push(p),p=p.nextSibling;return pe.remove(),u}function hn(e,t,n=null,r=null){on(e)&&on(t)&&(e=e.content,t=t.content),n??=e.firstChild;for(let s of t.childNodes){if(n&&n!==r){let o=ur(s,n,r);if(o){if(o!==n){let i=n;for(;i&&i!==o;){let l=i;i=i.nextSibling,fn(l)}}lt(o,s),n=o.nextSibling;continue}}if(bn(s)&&ue.has(s.id)){let o=s.id,i=`[id="${o}"]`,l=p=>p.querySelector(i),c=qe.id===o&&qe||l(qe)||l(pe),u=c;for(;u=u.parentNode;){let p=$.get(u);p&&(p.delete(c.id),p.size||$.delete(u))}vn(e,c,n),lt(c,s),n=c.nextSibling;continue}if($.has(s)){let o=ut(s.tagName);return e.insertBefore(o,n),lt(o,s),o}let a=document.importNode(s,!0);e.insertBefore(a,n)}for(;n&&n!==r;){let s=n;n=n.nextSibling,fn(s)}}function ur(e,t,n){let r=null,s=e.nextSibling,a=0,o=0,i=$.get(e)?.size||0,l=t;for(;l&&l!==n;){if(pn(l,e)){let c=!1,u=$.get(l),p=$.get(e);if(p&&u){for(let m of u)if(p.has(m)){c=!0;break}}if(c)return l;if(!r&&!$.has(l)){if(!i)return l;r=l}}if(o+=$.get(l)?.size||0,o>i||(r===null&&s&&pn(l,s)&&(a++,s=s.nextSibling,a>=2&&(r=void 0)),l.contains(document.activeElement)))break;l=l.nextSibling}return r||null}function pn(e,t){let n=e,r=t;return n.nodeType===r.nodeType&&n.tagName===r.tagName&&(!n.id||n.id===r.id)}function fn(e){$.has(e)?vn(pe,e,null):e.parentNode?.removeChild(e)}function vn(e,t,n=null){lr?e.moveBefore(t,n):e.insertBefore(t,n)}function lt(e,t){let n=t.nodeType,r=e,s=t;if(n===1){let a=r.attributes,o=s.attributes;for(let i of o)r.getAttribute(i.name)!==i.value&&r.setAttribute(i.name,i.value);for(let i=a.length-1;0<=i;i--){let l=a[i];l&&(s.hasAttribute(l.name)||r.removeAttribute(l.name))}if(an(r)&&an(s)&&s.type!=="file"){let i=s.value,l=r.value;ct(r,s,"checked"),ct(r,s,"disabled"),Object.keys(s.dataset||{}).some(u=>u.startsWith("bind"))||(s.hasAttribute(at)?l!==i&&(r.setAttribute(at,i),r.value=i):(r.value="",r.removeAttribute(at)))}else if(cn(r)&&cn(s))ct(r,s,"selected");else if(ln(r)&&ln(s)){let i=s.value,l=r.value;i!==l&&(r.value=i),r.firstChild&&r.firstChild.nodeValue!==i&&(r.firstChild.nodeValue=i)}}return(n===8||n===3)&&e.nodeValue!==t.nodeValue&&(e.nodeValue=t.nodeValue),r.isEqualNode(s)||hn(r,s),e}function ct(e,t,n){let r=t,s=e,a=r[n],o=s[n];a!==o&&(s[n]=r[n],a?e.setAttribute(n,""):e.removeAttribute(n))}function dn(e,t){for(let n of t)if(ue.has(n.id)){let r=n;for(;r&&r!==e;){let s=$.get(r);s||(s=new Set,$.set(r,s)),s.add(n.id),r=r.parentElement}}}var Sn={type:"watcher",name:be,onGlobalInit:async({batch:e})=>{j(be,({signals:t="{}",onlyIfMissing:n=`${St}`})=>{let r=X(n),s=Lt(t);e(()=>Ve(s,r))})}};var En={type:"watcher",name:Ke,onGlobalInit:async e=>{j(Ke,({selector:t,useViewTransition:n=`${vt}`})=>{if(!t.length)throw H("NoSelectorProvided",e);let r=X(n),s=document.querySelectorAll(t),a=()=>{for(let o of s)o.remove()};r&&we?Ae.startViewTransition(()=>a()):a()})}};var Tn={type:"watcher",name:Be,onGlobalInit:async e=>{let{batch:t}=e;j(Be,({paths:n=""})=>{let r=n.split(` 10 | `).map(s=>s.trim());if(!r?.length)throw H("NoPathsProvided",e);t(()=>{et(...r)})})}};var xn={type:"attribute",name:"attr",valReq:"must",isExpr:!0,onLoad:({el:e,key:t,genRX:n,computed:r,effect:s})=>{let{deps:a,rxFn:o}=n(),i=(c,u)=>{u===""||u===!0?e.setAttribute(c,""):u===!1||u===null||u===void 0?e.removeAttribute(c):e.setAttribute(c,u)};if(t===""){let c=r(a,o);return s([c],u=>{for(let[p,m]of Object.entries(u))i(p,m)})}t=G(t);let l=r(a,o);return s([l],c=>{i(t,c)})}};var pr=/^data:(?[^;]+);base64,(?.*)$/,An=["change","input","keydown"],wn={type:"attribute",name:"bind",keyReq:"exclusive",valReq:"exclusive",onLoad:e=>{let{el:t,key:n,mods:r,value:s,effect:a,batch:o}=e,i=t,l=n?L(n,r):J(s),c=t.tagName.toLowerCase(),u=c.includes("input"),p=c.includes("select"),m=t.getAttribute("type"),h=t.hasAttribute("value"),f="",b=u&&m==="checkbox";b&&(i.hasAttribute("checked")?f=h?i.value:!0:f=h?"":!1);let S=u&&m==="number";S&&(f=0);let R=u&&m==="radio";R&&(t.getAttribute("name")?.length||t.setAttribute("name",l));let D=u&&m==="file",{dep:C,inserted:k}=o(()=>ae(l,f)),T=-1;Array.isArray(C.value)&&(t.getAttribute("name")===null&&t.setAttribute("name",l),T=[...document.querySelectorAll(`[name="${l}"]`)].findIndex(g=>g===e.el));let x=T>=0,F=()=>[...oe(l)],d=()=>{let g=oe(l);x&&!p&&(g=g[T]||f);let y=`${g}`;if(b||R)typeof g=="boolean"?i.checked=g:i.checked=y===i.value;else if(p){let w=t;if(w.multiple){if(!x)throw v("BindSelectMultiple",e);for(let E of w.options){if(E?.disabled)continue;let M=S?Number(E.value):E.value;E.selected=g.includes(M)}}else w.value=y}else D||("value"in t?t.value=y:t.setAttribute("value",y))},A=async()=>{let g=oe(l);if(x){let M=g;for(;T>=M.length;)M.push(f);g=M[T]||f}let y=(M,O)=>{let N=O;x&&!p&&!D&&(N=F(),N[T]=O),W(M,N)};if(D){let M=[...i?.files||[]],O=[],N=[],Me=[];await Promise.all(M.map(de=>new Promise(me=>{let B=new FileReader;B.onload=()=>{if(typeof B.result!="string")throw v("InvalidFileResultType",e,{resultType:typeof B.result});let z=B.result.match(pr);if(!z?.groups)throw v("InvalidDataUri",e,{result:B.result});O.push(z.groups.contents),N.push(z.groups.mime),Me.push(de.name)},B.onloadend=()=>me(void 0),B.readAsDataURL(de)}))),o(()=>{y(l,O),y(`${l}Mimes`,N),y(`${l}Names`,Me)});return}let w=i.value||"",E;if(b){let M=i.checked;h?E=M?w:"":E=M}else if(p){let O=[...t.selectedOptions];x?E=O.filter(N=>N.selected).map(N=>N.value):E=O[0]?.value||f}else typeof g=="boolean"?E=!!w:typeof g=="number"?E=Number(w):E=w||"";o(()=>{y(l,E)})};k&&A();for(let g of An)t.addEventListener(g,A);let P=g=>{g.persisted&&A()};window.addEventListener("pageshow",P);let q=a([C],()=>d());return()=>{q();for(let g of An)t.removeEventListener(g,A);window.removeEventListener("pageshow",P)}}};var Mn={type:"attribute",name:"class",valReq:"must",isExpr:!0,onLoad:({el:e,key:t,mods:n,genRX:r,computed:s,effect:a})=>{let o=e.classList,{deps:i,rxFn:l}=r(),c=s(i,l);return a([c],u=>{if(t===""){let p=u;for(let[m,h]of Object.entries(p)){let f=m.split(/\s+/);h?o.add(...f):o.remove(...f)}}else{let p=G(t);p=L(p,n),u?o.add(p):o.remove(p)}})}};var Rn={type:"attribute",name:"indicator",keyReq:"exclusive",valReq:"exclusive",onLoad:e=>{let{el:t,key:n,mods:r,value:s,batch:a}=e,o=n?L(n,r):J(s),{dep:i}=a(()=>ae(o,!1));if(!(i instanceof se))throw v("not_signal",e,{signalName:o});let l=c=>{let{type:u,el:p}=c.detail;if(p===t)switch(u){case Ge:i.value=!0;break;case We:i.value=!1;break}};return document.addEventListener(Z,l),()=>{i.value=!1,document.removeEventListener(Z,l)}}};var Dn={type:"attribute",name:"jsonSignals",keyReq:"denied",valReq:"denied",onLoad:e=>{let{el:t}=e;t instanceof HTMLElement||v("JsonSignalsInvalidElement",e);let n=()=>{t.textContent=Oe(!0)};return n(),document.addEventListener(_,n),()=>{document.removeEventListener(_,n)}}};function ee(e){if(!e||e.size<=0)return 0;for(let t of e){if(t.endsWith("ms"))return Number(t.replace("ms",""));if(t.endsWith("s"))return Number(t.replace("s",""))*1e3;try{return Number.parseFloat(t)}catch{}}return 0}function ne(e,t,n=!1){return e?e.has(t.toLowerCase()):n}function pt(e,t){return(...n)=>{setTimeout(()=>{e(...n)},t)}}function fr(e,t,n=!1,r=!0){let s=0,a=()=>s&&clearTimeout(s);return(...o)=>{a(),n&&!s&&e(...o),s=setTimeout(()=>{r&&e(...o),a()},t)}}function dr(e,t,n=!0,r=!1){let s=!1;return(...a)=>{s||(n&&e(...a),s=!0,setTimeout(()=>{s=!1,r&&e(...a)},t))}}function fe(e,t){let n=t.get("delay");if(n){let a=ee(n);e=pt(e,a)}let r=t.get("debounce");if(r){let a=ee(r),o=ne(r,"leading",!1),i=!ne(r,"notrail",!1);e=fr(e,a,o,i)}let s=t.get("throttle");if(s){let a=ee(s),o=!ne(s,"noleading",!1),i=ne(s,"trail",!1);e=dr(e,a,o,i)}return e}var Cn={type:"attribute",name:"on",keyReq:"must",valReq:"must",argNames:["evt"],onLoad:e=>{let{el:t,key:n,mods:r,genRX:s,evalRX:a}=e,o=t;r.has("window")&&(o=window);let{dm:i,deps:l,rxFn:c}=s(),u=f=>{if(f){if(r.has("prevent")&&f.preventDefault(),r.has("stop")&&f.stopPropagation(),!(f.isTrusted||f instanceof CustomEvent||r.has("trust")))return;e.evt=f}a(c,i,l,f)};u=fe(u,r),u=K(u,r);let p={capture:!1,passive:!1,once:!1};if(r.has("capture")&&(p.capture=!0),r.has("passive")&&(p.passive=!0),r.has("once")&&(p.once=!0),r.has("outside")){o=document;let f=u;u=b=>{let S=b?.target;t.contains(S)||f(b)}}let h=G(n);if(h=L(h,r),(h===Z||h===_)&&(o=document),t instanceof HTMLFormElement&&h==="submit"){let f=u;u=b=>{b?.preventDefault(),f(b)}}return o.addEventListener(h,u,p),()=>{o.removeEventListener(h,u)}}};var ft=new WeakSet,Ln={type:"attribute",name:"onIntersect",keyReq:"denied",onLoad:({el:e,mods:t,genRX:n,evalRX:r})=>{let{dm:s,deps:a,rxFn:o}=n(),i=()=>r(o,s,a);i=fe(i,t),i=K(i,t);let l={threshold:0};t.has("full")?l.threshold=1:t.has("half")&&(l.threshold=.5);let c=new IntersectionObserver(u=>{for(let p of u)p.isIntersecting&&(i(),c&&ft.has(e)&&c.disconnect())},l);return c.observe(e),t.has("once")&&ft.add(e),()=>{t.has("once")||ft.delete(e),c&&(c.disconnect(),c=null)}}};var Fn={type:"attribute",name:"onInterval",keyReq:"denied",valReq:"must",onLoad:({mods:e,genRX:t,evalRX:n})=>{let{dm:r,deps:s,rxFn:a}=t(),o=()=>n(a,r,s);o=K(o,e);let i=1e3,l=e.get("duration");l&&(i=ee(l),ne(l,"leading",!1)&&o());let c=setInterval(o,i);return()=>{clearInterval(c)}}};var dt=new WeakSet,Pn={type:"attribute",name:"onLoad",keyReq:"denied",valReq:"must",onLoad:({el:e,mods:t,genRX:n,evalRX:r})=>{let{dm:s,deps:a,rxFn:o}=n(),i=()=>r(o,s,a);i=K(i,t);let l=0,c=t.get("delay");return c&&(l=ee(c)),i=pt(i,l),dt.has(e)||i(),t.has("once")&&dt.add(e),()=>{t.has("once")||dt.delete(e)}}};function mt(e,t){return t=t.replaceAll(".","\\.").replaceAll("**",re).replaceAll("*","[^\\.]*").replaceAll(re,".*"),RegExp(`^${t}$`).test(e)}function _e(e){let t=[],n=e.split(/\s+/).filter(r=>r!=="");n=n.map(r=>J(r));for(let r of n)for(let s of ie())mt(s,r)&&t.push(s);return t}var kn={type:"attribute",name:"onSignalChange",valReq:"must",argNames:["evt"],onLoad:({key:e,mods:t,genRX:n,evalRX:r})=>{let{dm:s,deps:a,rxFn:o}=n(),i=c=>r(o,s,a,c);i=fe(i,t),i=K(i,t);let l=c=>{if(e!==""){let u=L(e,t),{added:p,removed:m,updated:h}=c.detail;if(![...p,...m,...h].some(f=>mt(f,u)))return}i(c)};return document.addEventListener(_,l),()=>{document.removeEventListener(_,l)}}};var Nn={type:"attribute",name:"ref",keyReq:"exclusive",valReq:"exclusive",onLoad:({el:e,key:t,mods:n,value:r,batch:s})=>{let a=t?L(t,n):J(r);s(()=>{W(a,e)})}};var Vn="none",On="display",In={type:"attribute",name:"show",keyReq:"denied",valReq:"must",isExpr:!0,onLoad:({el:{style:e},genRX:t,computed:n,effect:r})=>{let{deps:s,rxFn:a}=t(),o=n(s,a);return r([o],async i=>{i?e.display===Vn&&e.removeProperty(On):e.setProperty(On,Vn)})}};var Hn={type:"attribute",name:"text",keyReq:"denied",valReq:"must",isExpr:!0,onLoad:e=>{let{el:t,genRX:n,computed:r,effect:s}=e,{deps:a,rxFn:o}=n();t instanceof HTMLElement||v("TextInvalidElement",e);let i=r(a,o);return s([i],l=>{t.textContent=`${l}`})}};var $n={type:"action",name:"setAll",fn:({batch:e},t,n)=>{e(()=>{let r=_e(t);for(let s of r)W(s,n)})}};var Gn={type:"action",name:"toggleAll",fn:({batch:e},t)=>{e(()=>{let n=_e(t);for(let r of n)W(r,!oe(r))})}};var je=new WeakMap,Wn={type:"attribute",name:"preserveAttr",valReq:"exclusive",keyReq:"exclusive",onLoad:({el:e,key:t,value:n})=>{let r=t?[t]:n.trim().split(" ");if(je.has(e)){let a=je.get(e);for(let o of r){let i=a[o];i!==void 0?e.setAttribute(o,i):e.removeAttribute(o)}}else{let a={};for(let o of r){let i=e.getAttribute(o);i!==null&&(a[o]=i)}je.set(e,a)}let s=new MutationObserver(a=>{for(let{attributeName:o,target:i}of a){let l=i,c=je.get(l);if(c){let u=o,p=l.getAttribute(u);p!==null?c[u]=p:delete c[u]}}});return s.observe(e,{attributes:!0,attributeFilter:r}),()=>{s&&(s.disconnect(),s=null)}}};He(Zt,tn,nn,en,Yt,yn,Sn,En,Tn,rn,xn,wn,Mn,Rn,Dn,Cn,Ln,Fn,Pn,kn,Wn,Nn,In,Hn,$n,Gn);ot();export{ot as apply,He as load,$t as setAlias}; 11 | //# sourceMappingURL=datastar.js.map 12 | -------------------------------------------------------------------------------- /src/hyperlith/core.clj: -------------------------------------------------------------------------------- 1 | (ns hyperlith.core 2 | (:require [hyperlith.impl.namespaces :refer [import-vars]] 3 | [hyperlith.impl.session :refer [wrap-session]] 4 | [hyperlith.impl.json :refer [wrap-parse-json-body]] 5 | [hyperlith.impl.params :refer [wrap-query-params]] 6 | [hyperlith.impl.blocker :refer [wrap-blocker]] 7 | [hyperlith.impl.datastar :as ds] 8 | [hyperlith.impl.util :as u] 9 | [hyperlith.impl.error :as er] 10 | [hyperlith.impl.crypto] 11 | [hyperlith.impl.css] 12 | [hyperlith.impl.http] 13 | [hyperlith.impl.html] 14 | [hyperlith.impl.router] 15 | [hyperlith.impl.cache :as cache] 16 | [hyperlith.impl.assets] 17 | [hyperlith.impl.trace] 18 | [hyperlith.impl.env] 19 | [hyperlith.impl.batch] 20 | [clojure.core.async :as a] 21 | [clojure.pprint :as pprint] 22 | [org.httpkit.server :as hk] 23 | [hyperlith.impl.codec :as codec]) 24 | (:import (java.util.concurrent Executors))) 25 | 26 | ;; Make futures use virtual threads 27 | (set-agent-send-executor! 28 | (Executors/newVirtualThreadPerTaskExecutor)) 29 | 30 | (set-agent-send-off-executor! 31 | (Executors/newVirtualThreadPerTaskExecutor)) 32 | 33 | (import-vars 34 | ;; ENV 35 | [hyperlith.impl.env 36 | env] 37 | ;; UTIL 38 | [hyperlith.impl.util 39 | load-resource 40 | assoc-if-missing 41 | assoc-in-if-missing 42 | qualify-keys 43 | modulo-pick 44 | thread 45 | circular-subvec] 46 | ;; HTML 47 | [hyperlith.impl.html 48 | html 49 | html-raw-str 50 | html-resolve-alias] 51 | ;; CACHE / WORK SHARING 52 | [hyperlith.impl.cache 53 | cache] 54 | ;; CRYPTO 55 | [hyperlith.impl.crypto 56 | new-uid 57 | digest] 58 | ;; ROUTER 59 | [hyperlith.impl.router 60 | router 61 | wrap-routes] 62 | ;; DATASTAR 63 | [hyperlith.impl.datastar 64 | shim-handler 65 | signals 66 | action-handler 67 | render-handler 68 | debug-signals-el] 69 | ;; HTTP 70 | [hyperlith.impl.http 71 | get! 72 | post! 73 | throw-if-status-not!] 74 | ;; CSS 75 | [hyperlith.impl.css 76 | static-css 77 | --] 78 | ;; ASSETS 79 | [hyperlith.impl.assets 80 | static-asset] 81 | ;; TRACE 82 | [hyperlith.impl.trace 83 | traces 84 | trace> 85 | traces-reset!] 86 | ;; ERROR 87 | [hyperlith.impl.error 88 | try-log] 89 | ;; CODEC 90 | [hyperlith.impl.codec 91 | url-query-string 92 | url-encode] 93 | ;; JSON 94 | [hyperlith.impl.json 95 | json->edn] 96 | ;; BATCH 97 | [hyperlith.impl.batch 98 | batch!]) 99 | 100 | (defonce ^:private refresh-ch_ (atom nil)) 101 | (defonce ^:private app_ (atom nil)) 102 | 103 | (defn get-app 104 | "Return app for debugging at the repl." 105 | [] 106 | @app_) 107 | 108 | (defn refresh-all! [& {:keys [keep-cache?] :as _opts}] 109 | (when-let [!! (ds/throttle (assoc req 137 | :hyperlith.core/refresh-mult refresh-mult) 138 | (u/merge ctx))))) 139 | ;; Middleware make for messy error stacks. 140 | middleware (-> router 141 | wrap-ctx 142 | ;; Wrap error here because req params/body/session 143 | ;; have been handled (and provide useful context). 144 | er/wrap-error 145 | ;; The handlers after this point do not throw errors 146 | ;; are robust/lenient. 147 | wrap-query-params 148 | (wrap-session csrf-secret) 149 | wrap-parse-json-body 150 | wrap-blocker) 151 | stop-server (hk/run-server middleware {:port port}) 152 | app {:ctx ctx 153 | :stop (fn stop [& [opts]] 154 | (stop-server opts) 155 | (ctx-stop ctx) 156 | (a/close! table-name name) ");"))) 28 | 29 | (defn table-list [db] 30 | (q db "PRAGMA table_list;")) 31 | -------------------------------------------------------------------------------- /src/hyperlith/impl/assets.clj: -------------------------------------------------------------------------------- 1 | (ns hyperlith.impl.assets 2 | (:require [hyperlith.impl.headers :refer [default-headers]] 3 | [hyperlith.impl.crypto :as crypto] 4 | [hyperlith.impl.brotli :as br])) 5 | 6 | (defn static-asset 7 | [{:keys [body content-type compress?]}] 8 | (let [resp (cond-> {:status 200 9 | :headers 10 | (assoc default-headers 11 | "Cache-Control" "max-age=31536000, immutable" 12 | "Content-Type" content-type) 13 | :body body} 14 | compress? (update :body br/compress :quality 11) 15 | compress? (assoc-in [:headers "Content-Encoding"] "br"))] 16 | {:handler (fn [_] resp) 17 | :path (str "/" (crypto/digest body))})) 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/hyperlith/impl/batch.clj: -------------------------------------------------------------------------------- 1 | (ns hyperlith.impl.batch 2 | (:require [clojure.core.async :as a] 3 | [hyperlith.impl.util :as util] 4 | [hyperlith.impl.error :as er])) 5 | 6 | (defn batch! 7 | "Wraps side-effecting function in a queue and batch mechanism. The batch is run every X ms when not empty and/or if it reaches it's max size. Function must take a vector of items." 8 | [effect-fn & {:keys [run-every-ms max-size] 9 | :or {run-every-ms 100 10 | max-size 1000}}] 11 | (let [= (count batch) max-size))) 24 | (do (er/try-log {} (effect-fn batch)) 25 | (recur (a/timeout run-every-ms) [])) 26 | 27 | ;; Add to batch 28 | (= p !! > (get-in req [:headers "accept-encoding"]) 10 | (re-find #"(?:^| )br(?:$|,)"))) 11 | {:status 406} 12 | 13 | :else (handler req)))) 14 | -------------------------------------------------------------------------------- /src/hyperlith/impl/brotli.clj: -------------------------------------------------------------------------------- 1 | (ns hyperlith.impl.brotli 2 | (:require 3 | [clojure.java.io :as io] 4 | [clojure.math :as m]) 5 | (:import (com.aayushatharva.brotli4j Brotli4jLoader ) 6 | (com.aayushatharva.brotli4j.encoder Encoder Encoder$Parameters 7 | Encoder$Mode BrotliOutputStream) 8 | (com.aayushatharva.brotli4j.decoder Decoder BrotliInputStream) 9 | (java.io ByteArrayOutputStream IOException))) 10 | 11 | #_:clj-kondo/ignore 12 | (defonce ensure-br 13 | (Brotli4jLoader/ensureAvailability)) 14 | 15 | (defn window-size->kb [window-size] 16 | (/ (- (m/pow 2 window-size) 16) 1000)) 17 | 18 | (defn encoder-params [{:keys [quality window-size]}] 19 | (doto (Encoder$Parameters/new) 20 | (.setMode Encoder$Mode/TEXT) 21 | ;; LZ77 window size (0, 10-24) (default: 24) 22 | ;; window size is (pow(2, NUM) - 16) 23 | (.setWindow (or window-size 24)) 24 | (.setQuality (or quality 5)))) 25 | 26 | (defn compress [data & {:as opts}] 27 | (-> (if (string? data) (String/.getBytes data) ^byte/1 data) 28 | (Encoder/compress (encoder-params opts)))) 29 | 30 | (defn byte-array-out-stream ^ByteArrayOutputStream [] 31 | (ByteArrayOutputStream/new)) 32 | 33 | (defn compress-out-stream ^BrotliOutputStream 34 | [^ByteArrayOutputStream out-stream & {:as opts}] 35 | (BrotliOutputStream/new out-stream (encoder-params opts) 36 | ;; TODO: Default buffer size for brotli library, needs to be tuned. 37 | 16384)) 38 | 39 | (defn compress-stream [^ByteArrayOutputStream out ^BrotliOutputStream br chunk] 40 | (doto br 41 | (.write (String/.getBytes chunk)) 42 | (.flush)) 43 | (let [result (.toByteArray out)] 44 | (.reset out) 45 | result)) 46 | 47 | (defn decompress [data] 48 | (let [decompressed (Decoder/decompress data)] 49 | (String/new (.getDecompressedData decompressed)))) 50 | 51 | (defn decompress-stream [data] 52 | (with-open [in (-> (if (string? data) (String/.getBytes data) data) 53 | io/input-stream 54 | (BrotliInputStream/new)) 55 | out (ByteArrayOutputStream/new)] 56 | (.enableEagerOutput in) 57 | (try ;; Allows decompressing of incomplete streams 58 | (loop [read (.read in)] 59 | (when (> read -1) 60 | (.write out read) 61 | (recur (.read in)))) 62 | (catch IOException _)) 63 | (str out))) 64 | 65 | (comment 66 | (decompress (compress "hellohellohello"))) 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /src/hyperlith/impl/cache.clj: -------------------------------------------------------------------------------- 1 | (ns hyperlith.impl.cache 2 | (:require [hyperlith.impl.util :as util])) 3 | 4 | (defonce ^:private cache_ (atom {})) 5 | 6 | (defn cache 7 | "Wraps function in a caching mechanism. This is a global cache." 8 | [f] 9 | ;; Note: cache has no upper bound and is only cleared when a refresh 10 | ;; event is fire. 11 | (fn [& args] 12 | (let [k [f args] 13 | ;; By delaying the value we make it lazy 14 | ;; then it gets evaluated on first read. 15 | ;; This prevents stampedes. 16 | new-value (delay (apply f args))] 17 | @((swap! cache_ util/assoc-if-missing k new-value) k)))) 18 | 19 | (defn invalidate-cache! 20 | "Invalidates global cache." 21 | [] 22 | (reset! cache_ {})) 23 | -------------------------------------------------------------------------------- /src/hyperlith/impl/codec.clj: -------------------------------------------------------------------------------- 1 | (ns hyperlith.impl.codec 2 | (:require [hyperlith.impl.util :as u]) 3 | (:import clojure.lang.MapEntry 4 | [java.net URLDecoder] 5 | [java.util StringTokenizer] 6 | [java.net URLEncoder])) 7 | 8 | (defn url-encode [s] (URLEncoder/encode (str s) "UTF-8")) 9 | 10 | (defn url-query-string [m] 11 | (->> (for [[k v] m] 12 | (str (url-encode (name k)) "=" (url-encode v))) 13 | (interpose "&") 14 | (apply str "?"))) 15 | 16 | (defn form-decode-str 17 | "Decode the supplied www-form-urlencoded string using UTF-8." 18 | [^String encoded] 19 | (try 20 | (URLDecoder/decode encoded "UTF-8") 21 | (catch Exception _ nil))) 22 | 23 | (defn- tokenized [s delim] 24 | (reify clojure.lang.IReduceInit 25 | (reduce [_ f init] 26 | (let [tokenizer (StringTokenizer. s delim)] 27 | (loop [result init] 28 | (if (.hasMoreTokens tokenizer) 29 | (recur (f result (.nextToken tokenizer))) 30 | result)))))) 31 | 32 | (defn- split-key-value-pair [^String s] 33 | (let [i (.indexOf s #=(int \=))] 34 | (cond 35 | (pos? i) (MapEntry. (.substring s 0 i) (.substring s (inc i))) 36 | (zero? i) (MapEntry. "" (.substring s (inc i))) 37 | :else (MapEntry. s "")))) 38 | 39 | (defn form-decode 40 | "Decode the supplied www-form-urlencoded string using UTF-8. If the encoded 41 | value is a string, a string is returned. If the encoded value is a map of 42 | parameters, a map is returned." 43 | [^String encoded] 44 | (if-not (.contains encoded "=") 45 | (form-decode-str encoded) 46 | (reduce 47 | (fn [m param] 48 | (let [kv (split-key-value-pair param) 49 | k (form-decode-str (key kv)) 50 | v (form-decode-str (val kv))] 51 | (if (and k v) 52 | (u/assoc-conj m k v) 53 | m))) 54 | {} 55 | (tokenized encoded "&")))) 56 | -------------------------------------------------------------------------------- /src/hyperlith/impl/crypto.clj: -------------------------------------------------------------------------------- 1 | (ns hyperlith.impl.crypto 2 | (:import [javax.crypto Mac] 3 | [javax.crypto.spec SecretKeySpec] 4 | [java.security SecureRandom] 5 | [java.util Base64 Base64$Encoder])) 6 | 7 | (def ^SecureRandom secure-random 8 | (SecureRandom/new)) 9 | 10 | (def ^Base64$Encoder base64-encoder 11 | (.withoutPadding (Base64/getUrlEncoder))) 12 | 13 | (defn bytes->base64 [^byte/1 b] 14 | (.encodeToString base64-encoder b)) 15 | 16 | (defn random-unguessable-uid 17 | "URL-safe base64-encoded 160-bit (20 byte) random value. Speed 18 | is similar random-uuid. 19 | See: https://neilmadden.blog/2018/08/30/moving-away-from-uuids/" 20 | [] 21 | (let [buffer (byte-array 20)] 22 | (.nextBytes secure-random buffer) 23 | (bytes->base64 buffer))) 24 | 25 | (def new-uid 26 | "Allows uid implementation to be changed if need be." 27 | random-unguessable-uid) 28 | 29 | (defn secret-key->hmac-md5-keyspec [secret-key] 30 | (SecretKeySpec/new (String/.getBytes secret-key) "HmacMD5")) 31 | 32 | (defn hmac-md5 33 | "Used for quick stateless csrf token generation." 34 | [key-spec data] 35 | (-> (doto (Mac/getInstance "HmacMD5") 36 | (.init key-spec)) 37 | (.doFinal (String/.getBytes data)) 38 | bytes->base64)) 39 | 40 | (defn digest 41 | "Digest function based on Clojure's hash." 42 | [data] 43 | ;; Note: hashCode is not guaranteed consistent between JVM 44 | ;; executions except in the case for strings. This is why we 45 | ;; convert to a string first. 46 | (Integer/toHexString (hash (str data)))) 47 | 48 | 49 | -------------------------------------------------------------------------------- /src/hyperlith/impl/css.clj: -------------------------------------------------------------------------------- 1 | (ns hyperlith.impl.css 2 | (:require [hyperlith.impl.assets :refer [static-asset]])) 3 | 4 | (defn to-str [s] 5 | (cond (keyword? s) (name s) 6 | (vector? s) (->> (map to-str s) 7 | (interpose " ") 8 | (apply str)) 9 | :else (str s))) 10 | 11 | (defn format-rule [[k v]] 12 | (str 13 | (to-str k) 14 | "{" 15 | (reduce (fn [acc [k v]] 16 | (str acc (to-str k) ":" (to-str v) ";")) 17 | "" 18 | (sort-by (comp to-str key) v)) 19 | "}")) 20 | 21 | (defn static-css [css-rules] 22 | (static-asset 23 | {:body (if (vector? css-rules) 24 | (->> (map format-rule css-rules) (reduce str "")) 25 | css-rules) 26 | :content-type "text/css" 27 | :compress? true})) 28 | 29 | (defn -- [css-var-name] 30 | (str "var(--" (to-str css-var-name) ")")) 31 | -------------------------------------------------------------------------------- /src/hyperlith/impl/datastar.clj: -------------------------------------------------------------------------------- 1 | (ns hyperlith.impl.datastar 2 | (:require [hyperlith.impl.assets :refer [static-asset]] 3 | [hyperlith.impl.session :refer [csrf-cookie-js]] 4 | [hyperlith.impl.json :as j] 5 | [hyperlith.impl.headers 6 | :refer [default-headers strict-transport]] 7 | [hyperlith.impl.util :as util] 8 | [hyperlith.impl.brotli :as br] 9 | [hyperlith.impl.crypto :as crypto] 10 | [hyperlith.impl.html :as h] 11 | [hyperlith.impl.error :as er] 12 | [org.httpkit.server :as hk] 13 | [clojure.string :as str] 14 | [clojure.core.async :as a])) 15 | 16 | (def datastar-source-map 17 | (static-asset 18 | {:body (util/load-resource "datastar.js.map") 19 | :content-type "text/javascript" 20 | :compress? true})) 21 | 22 | (def datastar 23 | (static-asset 24 | {:body 25 | (-> (util/load-resource "datastar.js") slurp 26 | ;; Make sure we point to the right source map 27 | (str/replace "datastar.js.map" (:path datastar-source-map))) 28 | :content-type "text/javascript" 29 | :compress? true})) 30 | 31 | (defn merge-fragments [event-id fragments] 32 | (str "event: datastar-merge-fragments" 33 | "\nid: " event-id 34 | "\ndata: fragments " (str/replace fragments "\n" "\ndata: fragments ") 35 | "\n\n\n")) 36 | 37 | (defn merge-signals [signals] 38 | (str "event: datastar-merge-signals" 39 | "\ndata: onlyIfMissing false" 40 | "\ndata: signals " (j/edn->json signals) 41 | "\n\n\n")) 42 | 43 | (defn throttle [!! (h/html 72 | [h/doctype-html5 73 | [:html {:lang "en"} 74 | [:head 75 | [:meta {:charset "UTF-8"}] 76 | (when head-hiccup head-hiccup) 77 | ;; Scripts 78 | [:script#js {:defer true :type "module" 79 | :src (datastar :path)}] 80 | ;; Enables responsiveness on mobile devices 81 | [:meta {:name "viewport" 82 | :content "width=device-width, initial-scale=1.0"}]] 83 | [:body 84 | [:div {:data-signals-csrf csrf-cookie-js 85 | :data-signals-tabid tabid-js}] 86 | [:div {:data-on-load on-load-js}] 87 | [:noscript "Your browser does not support JavaScript!"] 88 | [:main {:id "morph"}]]]]) 89 | h/html->str)] 90 | (-> {:status 200 91 | :headers (assoc default-headers "Content-Encoding" "br") 92 | :body (-> body (br/compress :quality 11))} 93 | ;; Etags ensure the shim is only sent again if it's contents have changed 94 | (assoc-in [:headers "ETag"] (crypto/digest body))))) 95 | 96 | (def routes 97 | {[:get (datastar :path)] (datastar :handler) 98 | [:get (datastar-source-map :path)] (datastar-source-map :handler)}) 99 | 100 | (defn shim-handler [head-hiccup] 101 | (let [resp (build-shim-page-resp head-hiccup) 102 | etag (get-in resp [:headers "ETag"])] 103 | (fn handler [req] 104 | (if (= (get-in req [:headers "if-none-match"]) etag) 105 | {:status 304} 106 | resp)))) 107 | 108 | (defn signals [signals] 109 | {:hyperlith.core/signals signals}) 110 | 111 | (defn action-handler [thunk] 112 | (fn handler [req] 113 | (if-let [signals (:hyperlith.core/signals (thunk req))] 114 | {:status 200 115 | ;; 200 signal responses have reduced headers 116 | :headers {"Content-Type" "text/event-stream" 117 | "Cache-Control" "no-store" 118 | "Content-Encoding" "br" 119 | "Strict-Transport-Security" strict-transport} 120 | :body (br/compress (merge-signals signals))} 121 | ;; 204 needs even less 122 | {:headers {"Strict-Transport-Security" strict-transport 123 | "Cache-Control" "no-store"} 124 | :status 204}))) 125 | 126 | (defn render-handler 127 | [render-fn & {:keys [on-close on-open br-window-size] :as _opts 128 | :or {;; Window size can be tuned to trade memory 129 | ;; for reduced bandwidth and compute. 130 | ;; The right window size can significantly improve 131 | ;; compression of highly variable streams of data. 132 | ;; (br/window-size->kb 18) => 262KB 133 | br-window-size 18}}] 134 | (fn handler [req] 135 | (let [;; Dropping buffer is used here as we don't want a slow handler 136 | ;; blocking other handlers. Mult distributes each event to all 137 | ;; taps in parallel and synchronously, i.e. each tap must 138 | ;; accept before the next item is distributed. 139 | !! str new-view) 169 | new-view-hash (crypto/digest new-view-str)] 170 | ;; only send an event if the view has changed 171 | (when (not= last-view-hash new-view-hash) 172 | (->> (merge-fragments 173 | new-view-hash new-view-str) 174 | (br/compress-stream out br) 175 | (send! ch))) 176 | (recur new-view-hash)))) 177 | ;; we want work cancelling to have higher priority 178 | :priority true)) 179 | ;; Close channel on error or when thread stops 180 | (hk/close ch))) 181 | (when on-open (on-open req))) 182 | :on-close (fn hk-on-close [_ _] 183 | (a/>!! env-file slurp edn/read-string))) 8 | 9 | (defmacro env 10 | "Read env from .env.edn. If env is missing fails at compile time." 11 | [k] 12 | (if (k env-data) 13 | ;; We could just inline the value, but that makes it trickier 14 | ;; to patch env values on a running server from the REPL. 15 | `(env-data ~k) 16 | (throw (ex-info (str "Missing env in .env.edn: " k) 17 | {:missing-env k})))) 18 | 19 | -------------------------------------------------------------------------------- /src/hyperlith/impl/error.clj: -------------------------------------------------------------------------------- 1 | (ns hyperlith.impl.error 2 | (:require 3 | [hyperlith.impl.util :as u] 4 | [clojure.main :as main] 5 | [clojure.string :as str])) 6 | 7 | (defonce on-error_ (atom nil)) 8 | 9 | (def demunge-csl-xf 10 | (map (fn [stack-element-data] 11 | (update stack-element-data 0 (comp main/demunge str))))) 12 | 13 | (def demunge-anonymous-functions-xf 14 | (map (fn [stack-element-data] 15 | (update stack-element-data 0 str/replace #"(/[^/]+)--\d+" "$1")))) 16 | 17 | (def ignored-cls-re 18 | (re-pattern 19 | (str "^(" 20 | (str/join "|" 21 | [;; A lot of this stuff is filler 22 | "clojure.lang" 23 | "clojure.main" 24 | "clojure.core.server" 25 | "clojure.core/eval" 26 | "clojure.core/binding-conveyor-fn" 27 | "java.util.concurrent.FutureTask" 28 | "java.util.concurrent.ThreadPoolExecutor" 29 | "java.util.concurrent.ThreadPoolExecutor/Worker" 30 | "java.lang.Thread"]) 31 | ").*"))) 32 | 33 | (def remove-ignored-cls-xf 34 | ;; We don't care about var indirection 35 | (remove (fn [[cls _ _ _]] (re-find ignored-cls-re cls)))) 36 | 37 | (def not-hyperlith-cls-xf 38 | ;; trim error trace to users space helps keep trace short 39 | (take-while (fn [[cls _ _ _]] (not (str/starts-with? cls "hyperlith"))))) 40 | 41 | (defn log-error [req t] 42 | (@on-error_ 43 | {;; req is under own key as it can contain data you don't want to log. 44 | :req (dissoc req :async-channel :websocket?) 45 | :error (let [m (Throwable->map t)] 46 | (-> m 47 | (update :cause str/replace #"\"" "'") 48 | (update :trace (fn [trace] 49 | (into [] 50 | (comp demunge-csl-xf 51 | not-hyperlith-cls-xf 52 | remove-ignored-cls-xf 53 | demunge-anonymous-functions-xf 54 | ;; This shrinks the trace to the most 55 | ;; relevant line 56 | (u/dedupe-with first) 57 | ;; max number of lines 58 | (take 15)) 59 | trace))) 60 | (assoc :type (-> m :via peek :type str))))})) 61 | 62 | (defmacro try-log [data & body] 63 | `(try 64 | ~@body 65 | (catch Throwable ~'t 66 | (log-error ~data ~'t) 67 | ;; Return nil when there is an error 68 | nil))) 69 | 70 | (defn wrap-error [handler] 71 | (fn [req] 72 | (or (try-log req (handler req)) {:status 400}))) 73 | -------------------------------------------------------------------------------- /src/hyperlith/impl/headers.clj: -------------------------------------------------------------------------------- 1 | (ns hyperlith.impl.headers 2 | (:require [clojure.string :as str])) 3 | 4 | (def self "'self'") 5 | (def none "'none'") 6 | (def unsafe-inline "'unsafe-inline'") 7 | (def unsafe-eval "'unsafe-eval'") 8 | 9 | (def csp-data 10 | {:base-uri [self] 11 | ;; Chrome and Safari do not allow redirects after submitting a form 12 | ;; unless the destination URL is listed in the form-action CSP rule, 13 | ;; even if it is a GET redirect that does not contain the original 14 | ;; form data. 15 | :form-action [self] 16 | :default-src [none] 17 | :media-src [self "https: data:"] 18 | :script-src [self unsafe-eval] 19 | :img-src [self "https: data:"] 20 | :font-src [self] 21 | :connect-src [self] 22 | :style-src [self unsafe-inline] 23 | :style-src-elem [self unsafe-inline] 24 | :frame-ancestors [none]}) 25 | 26 | (defn csp-data->str [csp-data] 27 | (reduce 28 | (fn [acc [k v]] (str acc (name k) " " (str/join " " v) ";")) 29 | "" 30 | csp-data)) 31 | 32 | (def strict-transport 33 | "Forces https, including on subdomains. Prevents attacker from using 34 | compromised subdomain." 35 | "max-age=63072000;includeSubDomains;preload") 36 | 37 | (def default-headers 38 | {"Content-Type" "text/html" 39 | "Strict-Transport-Security" strict-transport 40 | "Content-Security-Policy" (csp-data->str csp-data) 41 | "Referrer-Policy" "no-referrer" 42 | "X-Content-Type-Options" "nosniff" 43 | "X-Frame-Options" "deny" 44 | "Cache-Control" "no-cache, must-revalidate"}) 45 | -------------------------------------------------------------------------------- /src/hyperlith/impl/html.clj: -------------------------------------------------------------------------------- 1 | (ns hyperlith.impl.html 2 | (:require [dev.onionpancakes.chassis.core :as h] 3 | [dev.onionpancakes.chassis.compiler :as cc])) 4 | 5 | ;; Warn on ambiguous attributes 6 | (cc/set-warn-on-ambig-attrs!) 7 | 8 | (def doctype-html5 h/doctype-html5) 9 | 10 | (def html->str h/html) 11 | 12 | (def html-raw-str h/raw-string) 13 | 14 | (defmacro html 15 | "Compiles html." 16 | [& hiccups] 17 | (let [node (vec hiccups)] 18 | `(cc/compile ~node))) 19 | 20 | (def html-resolve-alias h/resolve-alias) 21 | -------------------------------------------------------------------------------- /src/hyperlith/impl/http.clj: -------------------------------------------------------------------------------- 1 | (ns hyperlith.impl.http 2 | (:require [org.httpkit.client :as http] 3 | [hyperlith.impl.json :as json] 4 | [clojure.string :as str])) 5 | 6 | (defn has-json-body? [resp] 7 | (and (:body resp) 8 | (-> resp :headers :content-type (str/starts-with? "application/json")))) 9 | 10 | (defn wrap-json-response [method] 11 | (fn [url request] 12 | (let [req @(method url request)] 13 | (cond-> req 14 | (has-json-body? req) (update :body json/json->edn))))) 15 | 16 | (def get! (wrap-json-response #_:clj-kondo/ignore http/get)) 17 | 18 | (def post! (wrap-json-response #_:clj-kondo/ignore http/post)) 19 | 20 | (defn throw-if-status-not! 21 | "Convert response status that is not in the status-set into an ex-info and 22 | then throw." 23 | [status-set message {:keys [body status]}] 24 | (if ((complement status-set) status) body 25 | (throw (ex-info (str message ": " status) 26 | (assoc body :status status))))) 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/hyperlith/impl/json.clj: -------------------------------------------------------------------------------- 1 | (ns hyperlith.impl.json 2 | (:require [clojure.data.json :as json] 3 | [clojure.java.io :as io])) 4 | 5 | (defn json-stream->edn [json] 6 | (try 7 | (-> json io/reader (json/read {:key-fn keyword})) 8 | (catch Throwable _))) 9 | 10 | (defn json->edn [json] 11 | (try (json/read-str json {:key-fn keyword}) 12 | (catch Throwable _))) 13 | 14 | (defn edn->json [edn] 15 | (json/write-str edn)) 16 | 17 | (defn parse-json-body? [req] 18 | (and (= (:request-method req) :post) 19 | (= (:content-type req) "application/json") 20 | (:body req))) 21 | 22 | (defn wrap-parse-json-body 23 | [handler] 24 | (fn [req] 25 | (cond-> req 26 | (parse-json-body? req) (update :body json-stream->edn) 27 | true handler))) 28 | -------------------------------------------------------------------------------- /src/hyperlith/impl/load_testing.clj: -------------------------------------------------------------------------------- 1 | (ns hyperlith.impl.load-testing 2 | (:require [clojure.string :as str] 3 | [hyperlith.impl.brotli :as br] 4 | [org.httpkit.client :as http]) 5 | (:import [java.util Arrays] 6 | [org.httpkit DynamicBytes] 7 | [org.httpkit.client IFilter])) 8 | 9 | (defn parse-sse-events [events] 10 | (mapv 11 | (fn [event] 12 | (reduce (fn [acc line] 13 | (let [[k v] (update (str/split line #": " 2) 0 keyword)] 14 | (if (= :data k) 15 | (update acc :data conj v) 16 | (assoc acc k v)))) 17 | {:data []} 18 | (str/split-lines event))) 19 | (str/split events #"\n\n\n"))) 20 | 21 | (defn byte-capture-filter 22 | "Captures bytes and returns the last capture of the whole buffer." 23 | [capture] 24 | (reify IFilter 25 | (^boolean accept [_this ^DynamicBytes bytes] 26 | ;; As DynamicBytes accumulates we only care about the final result 27 | ;; before close or timeout. 28 | (let [prev-length (:length @capture) 29 | next-length (int (.length bytes))] 30 | (swap! capture 31 | #(-> (assoc % :length next-length) 32 | ;; Currently we only decompress these values at the end 33 | ;; in theory this could be replaced with a streaming 34 | ;; decompression. 35 | (update :events into (Arrays/copyOfRange 36 | ^byte/1 (.get bytes) 37 | ^int prev-length 38 | ^int next-length))))) 39 | true) 40 | (^boolean accept [_this ^java.util.Map m] 41 | (swap! capture assoc :headers m) 42 | true))) 43 | 44 | (defn wrap-capture-sse-response [method] 45 | (fn [url request] 46 | (let [capture (atom {:length 0 47 | :events []})] 48 | #_:clj-kondo/ignore 49 | (method 50 | url 51 | (assoc request 52 | ;; We have to do decompress ourselves otherwise the filter will wait 53 | ;; for the stream to complete which could be never. 54 | :as :none 55 | :filter (byte-capture-filter capture))) 56 | capture))) 57 | 58 | (def capture-sse-get! 59 | (wrap-capture-sse-response #_:clj-kondo/ignore http/get)) 60 | 61 | (def capture-sse-post! 62 | (wrap-capture-sse-response #_:clj-kondo/ignore http/post)) 63 | 64 | (defn parse-captured-response [capture] 65 | (cond-> capture 66 | (= (get-in capture [:headers "content-encoding"]) "br") 67 | (update :events (comp br/decompress-stream byte-array)) 68 | :always (update :events parse-sse-events))) 69 | 70 | (comment 71 | (def capture 72 | (capture-sse-post! 73 | "http://localhost:8080/" 74 | {:timeout 1 75 | :headers 76 | {"accept-encoding" "br" 77 | "cookie" "__Host-sid=5SNfeDa90PhXl0expOLFGdjtrpY; __Host-csrf=3UsG62ic9wLsg9EVQhGupw" 78 | "content-type" "application/json"} 79 | :body "{\"csrf\":\"3UsG62ic9wLsg9EVQhGupw\"}"})) 80 | 81 | (-> @capture parse-captured-response) 82 | 83 | ) 84 | -------------------------------------------------------------------------------- /src/hyperlith/impl/namespaces.clj: -------------------------------------------------------------------------------- 1 | (ns hyperlith.impl.namespaces) 2 | 3 | (defn link-vars 4 | "Makes sure that all changes to `src` are reflected in `dst`." 5 | [src dst] 6 | (add-watch src dst 7 | (fn [_ src old new] 8 | (alter-var-root dst (constantly @src)) 9 | (alter-meta! dst merge (dissoc (meta src) :name))))) 10 | 11 | (defmacro import-fn 12 | "Given a function in another namespace, defines a function with the 13 | same name in the current namespace. Argument lists, doc-strings, 14 | and original line-numbers are preserved." 15 | ([sym] 16 | `(import-fn ~sym nil)) 17 | ([sym name] 18 | (let [vr (resolve sym) 19 | m (meta vr) 20 | n (or name (:name m)) 21 | arglists (:arglists m) 22 | protocol (:protocol m)] 23 | (when-not vr 24 | (throw (IllegalArgumentException. (str "Don't recognize " sym)))) 25 | (when (:macro m) 26 | (throw (IllegalArgumentException. 27 | (str "Calling import-fn on a macro: " sym)))) 28 | 29 | `(do 30 | (def ~(with-meta n {:protocol protocol}) (deref ~vr)) 31 | (alter-meta! (var ~n) merge (dissoc (meta ~vr) :name)) 32 | (link-vars ~vr (var ~n)) 33 | ~vr)))) 34 | 35 | (defmacro import-macro 36 | "Given a macro in another namespace, defines a macro with the same 37 | name in the current namespace. Argument lists, doc-strings, and 38 | original line-numbers are preserved." 39 | 40 | ([sym] 41 | `(import-macro ~sym nil)) 42 | ([sym name] 43 | (let [vr (resolve sym) 44 | m (meta vr) 45 | n (or name (with-meta (:name m) {})) 46 | arglists (:arglists m)] 47 | (when-not vr 48 | (throw (IllegalArgumentException. (str "Don't recognize " sym)))) 49 | (when-not (:macro m) 50 | (throw (IllegalArgumentException. 51 | (str "Calling import-macro on a non-macro: " sym)))) 52 | `(do 53 | (def ~n ~(resolve sym)) 54 | (alter-meta! (var ~n) merge (dissoc (meta ~vr) :name)) 55 | (.setMacro (var ~n)) 56 | (link-vars ~vr (var ~n)) 57 | ~vr)))) 58 | 59 | (defmacro import-def 60 | "Given a regular def'd var from another namespace, defined a new var with the 61 | same name in the current namespace." 62 | ([sym] 63 | `(import-def ~sym nil)) 64 | ([sym name] 65 | (let [vr (resolve sym) 66 | m (meta vr) 67 | n (or name (:name m)) 68 | n (with-meta n (if (:dynamic m) {:dynamic true} {})) 69 | nspace (:ns m)] 70 | (when-not vr 71 | (throw (IllegalArgumentException. (str "Don't recognize " sym)))) 72 | `(do 73 | (def ~n @~vr) 74 | (alter-meta! (var ~n) merge (dissoc (meta ~vr) :name)) 75 | (link-vars ~vr (var ~n)) 76 | ~vr)))) 77 | 78 | (defmacro import-vars 79 | "Imports a list of vars from other namespaces." 80 | {:clj-kondo/lint-as 'potemkin/import-vars} 81 | [& syms] 82 | (let [unravel (fn unravel [x] 83 | (if (sequential? x) 84 | (->> x 85 | rest 86 | (mapcat unravel) 87 | (map 88 | #(symbol 89 | (str (first x) 90 | (when-let [n (namespace %)] 91 | (str "." n))) 92 | (name %)))) 93 | [x])) 94 | syms (mapcat unravel syms)] 95 | `(do 96 | ~@(map 97 | (fn [sym] 98 | (let [vr (resolve sym) 99 | m (meta vr)] 100 | (cond 101 | (nil? vr) `(throw (ex-info (format "`%s` does not exist" '~sym) {})) 102 | (:macro m) `(import-macro ~sym) 103 | (:arglists m) `(import-fn ~sym) 104 | :else `(import-def ~sym)))) 105 | syms)))) 106 | -------------------------------------------------------------------------------- /src/hyperlith/impl/params.clj: -------------------------------------------------------------------------------- 1 | (ns hyperlith.impl.params 2 | (:require [hyperlith.impl.codec :as codec])) 3 | 4 | ;; TODO: might want to parse datastar query param (json) to edn 5 | ;; TODO: might want to keyword query params 6 | 7 | (defn parse-query-string [query-string] 8 | (try 9 | (codec/form-decode query-string) 10 | (catch Throwable _))) 11 | 12 | (defn wrap-query-params 13 | [handler] 14 | (fn [req] 15 | (-> (if-let [query-string (:query-string req)] 16 | (assoc req :query-params (parse-query-string query-string)) 17 | req) 18 | handler))) 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/hyperlith/impl/router.clj: -------------------------------------------------------------------------------- 1 | (ns hyperlith.impl.router 2 | (:require [hyperlith.impl.datastar :as ds])) 3 | 4 | (defn router 5 | "Convert route map into router." 6 | ([routes] (router routes (fn [_] {:status 404}))) 7 | ([routes not-found-handler] 8 | (let [routes (merge ds/routes routes)] 9 | (fn [req] 10 | ((routes [(:request-method req) (:uri req)] not-found-handler) req))))) 11 | 12 | (defn wrap-routes 13 | "Wrap a route map in a collection of middleware. Middlewares are applied 14 | left to right (top to bottom)." 15 | [middlewares routes] 16 | (update-vals routes (apply comp middlewares))) 17 | -------------------------------------------------------------------------------- /src/hyperlith/impl/session.clj: -------------------------------------------------------------------------------- 1 | (ns hyperlith.impl.session 2 | (:require [hyperlith.impl.crypto :as crypto])) 3 | 4 | (defn get-sid [req] 5 | (try ;; In case we get garbage 6 | (some->> (get-in req [:headers "cookie"]) 7 | (re-find #"__Host-sid=([^;^ ]+)") 8 | second) 9 | (catch Throwable _))) 10 | 11 | (defn session-cookie [sid] 12 | (str "__Host-sid=" sid "; Path=/; Secure; HttpOnly; SameSite=Lax")) 13 | 14 | (defn csrf-cookie [csrf] 15 | (str "__Host-csrf=" csrf "; Path=/; Secure; SameSite=Lax")) 16 | 17 | (defn wrap-session 18 | [handler csrf-secret] 19 | (let [;; Only create the spec once. 20 | csrf-keyspec (crypto/secret-key->hmac-md5-keyspec csrf-secret) 21 | sid->csrf (fn sid->csrf [sid] (crypto/hmac-md5 csrf-keyspec sid))] 22 | (fn [req] 23 | (let [body (:body req) 24 | sid (get-sid req)] 25 | (cond 26 | ;; If user has sid and csrf handle request 27 | (and sid (= (:csrf body) (sid->csrf sid))) 28 | (handler (assoc req :sid sid :csrf (:csrf body) :tabid (:tabid body))) 29 | 30 | ;; :get request and user does not have session we create one 31 | ;; if they do not have a csrf cookie we give them one 32 | (= (:request-method req) :get) 33 | (let [new-sid (or sid (crypto/random-unguessable-uid)) 34 | csrf (sid->csrf new-sid)] 35 | (-> (handler (assoc req :sid new-sid :csrf csrf 36 | :tabid (:tabid body))) 37 | (assoc-in [:headers "Set-Cookie"] 38 | ;; These cookies won't be set on local host on chrome/safari 39 | ;; as it's using secure needs to be true and local host 40 | ;; does not have HTTPS. SameSite is set to lax as it 41 | ;; allows the same cookie session to be used following a 42 | ;; link from another site. 43 | [(session-cookie new-sid) 44 | (csrf-cookie csrf)]))) 45 | 46 | ;; Not a :get request and user does not have session we 403 47 | ;; Note: If the updates SSE connection is a not a :get then this 48 | ;; will close the connection until the user reloads the page. 49 | :else 50 | {:status 403}))))) 51 | 52 | (def csrf-cookie-js 53 | "document.cookie.match(/(^| )__Host-csrf=([^;]+)/)?.[2]") 54 | -------------------------------------------------------------------------------- /src/hyperlith/impl/trace.clj: -------------------------------------------------------------------------------- 1 | (ns hyperlith.impl.trace) 2 | 3 | (def initial-value {:already-seen #{} :events []}) 4 | 5 | (def traces_ (atom initial-value)) 6 | 7 | (defn traces-reset! 8 | "Clear previous logged traces." 9 | [] 10 | (reset! traces_ initial-value)) 11 | 12 | (defn traces 13 | "Logged traces." 14 | [] 15 | (:events @traces_)) 16 | 17 | (def trace-tap 18 | (do (remove-tap trace-tap) ;; Remove old tap 19 | (let [f (fn [{:keys [exp] :as trace}] 20 | (swap! traces_ 21 | (fn [{:keys [already-seen] :as traces}] 22 | (if (already-seen (hash exp)) 23 | traces 24 | (-> (update traces :already-seen conj (hash exp)) 25 | (update :events conj trace))))))] 26 | (add-tap f) 27 | f))) 28 | 29 | (defmacro trace> 30 | "Trace macro that logs expression and return value to traces atom via 31 | tap>. Only logs a given expression once." 32 | [args] 33 | `(let [result# ~args] 34 | (tap> (sorted-map :exp (quote ~args) :ret result# :meta ~(meta args))) 35 | result#)) 36 | -------------------------------------------------------------------------------- /src/hyperlith/impl/util.clj: -------------------------------------------------------------------------------- 1 | (ns hyperlith.impl.util 2 | (:refer-clojure :exclude [merge]) 3 | (:require [clojure.java.io :as io]) 4 | (:import (java.io InputStream))) 5 | 6 | (defn assoc-conj 7 | "Associate a key with a value in a map. If the key already exists in the map, 8 | a vector of values is associated with the key." 9 | [map key val] 10 | (assoc map key 11 | (if-let [cur (get map key)] 12 | (if (vector? cur) 13 | (conj cur val) 14 | [cur val]) 15 | val))) 16 | 17 | (defn merge 18 | "Faster merge." 19 | [m1 m2] 20 | (persistent! (reduce-kv assoc! (transient (or m1 {})) m2))) 21 | 22 | (defmacro thread 23 | "Starts a virtual thread. Conveys bindings." 24 | [& body] 25 | `(Thread/startVirtualThread 26 | (bound-fn* ;; binding conveyance 27 | (fn [] ~@body)))) 28 | 29 | (defmacro while-some 30 | {:clj-kondo/lint-as 'clojure.core/let} 31 | [bindings & body] 32 | `(loop [] 33 | (when-some ~bindings 34 | ~@body 35 | (recur)))) 36 | 37 | (defn assoc-if-missing [m k v] 38 | (if-not (m k) (assoc m k v) m)) 39 | 40 | (defn assoc-in-if-missing [m ks v] 41 | (if-not (get-in m ks) (assoc-in m ks v) m)) 42 | 43 | (defn resource->bytes [resource] 44 | (-> resource io/input-stream InputStream/.readAllBytes)) 45 | 46 | (defmacro load-resource 47 | "Fails at compile time if resource doesn't exists." 48 | [path] 49 | (let [res (io/resource path)] 50 | (assert res (str path " not found.")) 51 | `(resource->bytes (io/resource ~path)))) 52 | 53 | (defn qualify-keys 54 | "Adds qualifier to key. Overwrites existing qualifier. Is idempotent." 55 | [m ns] 56 | (update-keys m (fn [k] (keyword (name ns) (name k))))) 57 | 58 | (defn dedupe-with 59 | ([f] 60 | (fn [rf] 61 | (let [pv (volatile! ::none)] 62 | (fn 63 | ([] (rf)) 64 | ([result] (rf result)) 65 | ([result input] 66 | (let [prior @pv 67 | f-input (f input)] 68 | (vreset! pv f-input) 69 | (if (= prior f-input) 70 | result 71 | (rf result input)))))))) 72 | ([f coll] (sequence (dedupe-with f) coll))) 73 | 74 | (defn modulo-pick 75 | "Given a coll and a value x. Returns a random value from coll. 76 | Always returns the same value for a given coll and value." 77 | [coll x] 78 | (-> (hash x) (mod (count coll)) coll)) 79 | 80 | (defn circular-subvec 81 | "Like subvec but loops round. Result can never be larger than the initial 82 | vector v." 83 | [v start end] 84 | (let [size (count v)] 85 | (if (>= end size) 86 | (let [v1 (subvec v start size)] 87 | (into v1 (subvec v 0 (min (- end size) (- size (count v1)))))) 88 | (subvec v start end)))) 89 | --------------------------------------------------------------------------------