Skip to content
Closed
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@
/.nrepl-port
.envrc
/.lsp/
.idea
hyper.iml
.qwen
62 changes: 56 additions & 6 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ automatically. No client-side framework, no JSON APIs, no JavaScript to write.
(let [count* (h/tab-cursor :count 0)]
[:div
[:h1 "Count: " @count*]
[:button {:data-on:click (h/action (swap! (h/tab-cursor :count) inc))}
[:button {:data-on:click (h/action (swap! count* inc))}
"Increment"]]))

(def routes
Expand Down Expand Up @@ -133,8 +133,8 @@ bound to any event attribute.
(let [count* (h/tab-cursor :count 0)]
[:div
[:p "Count: " @count*]
[:button {:data-on:click (h/action (swap! (h/tab-cursor :count) inc))} "+1"]
[:button {:data-on:click (h/action (swap! (h/tab-cursor :count) dec))} "-1"]]))
[:button {:data-on:click (h/action (swap! count* inc))} "+1"]
[:button {:data-on:click (h/action (swap! count* dec))} "-1"]]))
```

When the button is clicked, Datastar POSTs to the server, Hyper executes the
Expand Down Expand Up @@ -617,6 +617,42 @@ redefine it at the REPL, all connected tabs automatically update their `<head>`.

This is typically how you’d include your compiled Tailwind stylesheet.

## Reverse proxy: subfolder deployments

If your app is served under a subfolder (e.g. `/my-app`) by a reverse proxy
such as nginx or Caddy, pass `:base-path` to `create-handler`. Hyper will
mount its internal endpoints (`/hyper/events`, `/hyper/actions`,
`/hyper/navigate`) under that prefix and generate all client-side URLs
accordingly — no manual path editing required.

```clojure
(def handler
(h/create-handler
#'routes
:base-path "/my-app"))
```

The value must start with `"/"` and have no trailing slash. With the above
example, Hyper mounts its endpoints at:

- `GET /my-app/hyper/events` — SSE stream
- `POST /my-app/hyper/actions` — action handler
- `POST /my-app/hyper/navigate` — SPA back/forward navigation

Your own application routes (e.g. `"/"`, `"/about"`) are unaffected — prefix
those in your reverse proxy config as you normally would.

A minimal nginx snippet for the above:

```nginx
location /my-app/ {
proxy_pass http://localhost:3000/;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
}
```

## SSE connection behavior

By default, Hyper keeps the SSE connection open even when the browser tab is
Expand All @@ -633,6 +669,10 @@ tab is hidden and reopening it when visible — pass `:open-when-hidden? false`:
:open-when-hidden? false))
```

Hyper's built-in `/hyper/events` endpoint automatically sends SSE-friendly
headers, including `Cache-Control: no-cache, no-transform` and
`X-Accel-Buffering: no`, to improve compatibility with reverse proxies.

## Brotli compression

Hyper uses [brotli4j](https://github.com/hyperxpro/Brotli4j) to compress both
Expand Down Expand Up @@ -669,10 +709,10 @@ happened:
[:div
[:h1 "Count: " @count*]
[:button {:data-on:click (h/action {:as "increment"}
(swap! (h/tab-cursor :count) inc))}
(swap! count* inc))}
"+1"]
[:button {:data-on:click (h/action {:as "decrement"}
(swap! (h/tab-cursor :count) dec))}
(swap! count* dec))}
"-1"]]))

(ht/test-page counter-page)
Expand Down Expand Up @@ -815,4 +855,14 @@ The E2E suite covers:

## Contributing

PRs and ideas welcome! Please follow the [angular commit guidelines](https://github.com/angular/angular/blob/main/contributing-docs/commit-message-guidelines.md) with your messages.
PRs and ideas welcome! Please follow the [angular commit
guidelines](https://github.com/angular/angular/blob/main/contributing-docs/commit-message-guidelines.md)
with your messages. All subject lines should be less than 80 characters long and
avoid needless language.

eg:

``` bash
fix: header template rendering w/ nil data
```

4 changes: 3 additions & 1 deletion deps.edn
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
org.clojure/core.memoize {:mvn/version "1.2.281"}
camel-snake-kebab/camel-snake-kebab {:mvn/version "0.4.3"}
ring/ring-defaults {:mvn/version "0.7.0"}
compact-uuids/compact-uuids {:mvn/version "0.2.1"}

;; JSON
cheshire/cheshire {:mvn/version "6.1.0"}
Expand All @@ -41,4 +42,5 @@
:exec-fn kaocha.runner/exec-fn
:exec-args {:randomize? false
:fail-fast? true}}
:dev {:extra-deps {io.github.tonsky/clj-reload {:mvn/version "1.0.0"}}}}}
:dev {:extra-deps {io.github.tonsky/clj-reload {:mvn/version "1.0.0"}
io.github.tonsky/clojure-plus {:mvn/version "1.7.2"}}}}}
39 changes: 23 additions & 16 deletions src/hyper/actions.clj
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
"Action handling for hyper applications.

Actions are server-side functions triggered by client interactions."
(:require [taoensso.telemere :as t]))
(:require [compact-uuids.core :as uuid]
[taoensso.telemere :as t]))

(defn register-action!
"Register an action function and return its ID.
Expand All @@ -15,7 +16,7 @@
- :as — a human-readable name for the action, useful for testing"
([app-state* session-id tab-id action-fn]
(register-action! app-state* session-id tab-id action-fn
(str "action-" (java.util.UUID/randomUUID))
(str "action-" (uuid/str (java.util.UUID/randomUUID)))
nil))
([app-state* session-id tab-id action-fn action-id]
(register-action! app-state* session-id tab-id action-fn action-id nil))
Expand All @@ -34,20 +35,26 @@
(defn execute-action!
"Execute an action by ID with error handling.
When client-params are provided they are passed to the action fn."
([app-state* action-id]
(execute-action! app-state* action-id nil))
([app-state* action-id client-params]
(if-let [action-data (get-in @app-state* [:actions action-id])]
(let [{:keys [fn]} action-data]
(t/catch->error! :hyper.error/execute-action
(fn client-params))
true)
(do
(t/log! {:level :warn
:id :hyper.error/action-not-found
:data {:hyper/action-id action-id}
:msg "Action not found"})
(throw (ex-info "Action not found" {:hyper/action-id action-id}))))))
([app-state* tab-id action-id]
(execute-action! app-state* tab-id action-id nil))
([app-state* tab-id action-id client-params]
(let [tab-read-lock (.readLock (get-in @app-state* [:tabs tab-id :renderer :rw-lock]))]
(.lock tab-read-lock)
(try
(if-let [action-data (get-in @app-state* [:actions action-id])]
(let [{:keys [fn]} action-data]
(t/catch->error! :hyper.error/execute-action
(fn client-params))
true)
(do
(t/log! {:level :warn
:id :hyper.error/action-not-found
:data {:hyper/action-id action-id}
:msg "Action not found"})
(throw (ex-info "Action not found" {:hyper/action-id action-id
:hyper/action-ids (keys (get-in @app-state* [:actions]))}))))
(finally
(.unlock tab-read-lock))))))

(defn cleanup-tab-actions!
"Remove all actions for a tab."
Expand Down
31 changes: 20 additions & 11 deletions src/hyper/core.clj
Original file line number Diff line number Diff line change
Expand Up @@ -199,18 +199,19 @@
automatically sent in the request body. When client params are present,
they are URL-encoded into the query string via the hyper.encodeClientParams helper
so the server can read them from query-params.
Optionally injects a custom Datastar expression to conditionally prevent the post."
[action-id used-params js]
Optionally injects a custom Datastar expression to conditionally prevent the post.
base-path is prepended to the /hyper/actions endpoint (empty string when not set)."
[action-id used-params js base-path]
(let [js-injection (when js (str js " && "))]
(if (empty? used-params)
(str js-injection "@post('/hyper/actions?action-id=" action-id "')")
(str js-injection "@post('" base-path "/hyper/actions?action-id=" action-id "')")
(let [obj-entries (->> used-params
vals
(map (fn [{:keys [js key]}]
(str key ":" js)))
(str/join ","))]
(str js-injection
"@post('/hyper/actions?action-id=" action-id
"@post('" base-path "/hyper/actions?action-id=" action-id
"&' + hyper.encodeClientParams({" obj-entries "}))")))))

(defmacro action
Expand Down Expand Up @@ -289,10 +290,11 @@
:hyper/router router#}]
~@body)))
idx# (if context/*action-idx* (swap! context/*action-idx* inc) (hash action-fn#))
action-id# (str "a-" tab-id# "-" idx#)
action-id# (str "a_" tab-id# "_" idx#)
_# (actions/register-action! app-state*# session-id# tab-id# action-fn# action-id#
~(when as-name {:as as-name}))]
(build-action-expr action-id# '~used-params ~js))))
~(when as-name {:as as-name}))
base-path# (get @app-state*# :base-path "")]
(build-action-expr action-id# '~used-params ~js base-path#))))

(defn navigate
"Create a navigation link using reitit named routes.
Expand Down Expand Up @@ -327,6 +329,7 @@
tab-id (:hyper/tab-id *request*)]
(when-let [path (:path (reitit/match-by-name router route-name params))]
(let [href (state/build-url path query-params)
base-path (get @app-state* :base-path "")
;; Use live-routes to always get the latest route metadata
route-index (routes/live-route-index app-state*)
;; Resolve title eagerly for the pushState call
Expand All @@ -347,12 +350,12 @@
:query-params (or query-params {})})))
nav-idx (if *action-idx* (swap! *action-idx* inc) (hash nav-fn))
action-id (actions/register-action! app-state* session-id tab-id nav-fn
(str "a-" tab-id "-" nav-idx))
(str "a_" tab-id "_" nav-idx))
escaped-title (or (utils/escape-js-string title) "")
escaped-href (utils/escape-js-string href)]
{:href href
:data-on:click__prevent
(str "@post('/hyper/actions?action-id=" action-id "');"
(str "@post('" base-path "/hyper/actions?action-id=" action-id "');"
" window.history.pushState({title: '" escaped-title "'}, '', '" escaped-href "');"
(when title
(str " document.title = '" escaped-title "'")))})))))
Expand All @@ -368,6 +371,11 @@
- :app-state — Atom for application state (default: fresh atom)
- :datastar-script - Override of the default datastar script tag (as Hiccup) or nil to suppress
- :head — Hiccup nodes appended to the HTML <head>, or (fn [req] ...) -> hiccup
- :base-path — URL path prefix for reverse-proxy deployments where the app is served
under a subfolder (e.g. \"/my-app\"). When set, all internal hyper
endpoints (/hyper/events, /hyper/actions, /hyper/navigate) are mounted
and referenced under this prefix. Must start with \"/\" and have no
trailing slash.
- :static-resources — Classpath resource root(s) to serve as static assets
- :static-dir — Filesystem directory (or directories) to serve as static assets
- :watches — Vector of Watchable sources added to every page route.
Expand Down Expand Up @@ -399,15 +407,16 @@
(def app (start! handler {:port 3000}))
;; Later...
(stop! app)"
[routes & {:keys [app-state head static-resources static-dir watches datastar-script]
[routes & {:keys [app-state head static-resources static-dir watches datastar-script base-path]
:or {app-state (atom (state/init-state))
datastar-script server/default-datastar-script}}]
(server/create-handler routes app-state
{:head head
:datastar-script datastar-script
:static-resources static-resources
:static-dir static-dir
:watches watches}))
:watches watches
:base-path base-path}))

(defn start!
"Start the hyper application server.
Expand Down
30 changes: 17 additions & 13 deletions src/hyper/render.clj
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@
the document title and swaps user-provided <head> elements.

Why not morph? Morphing <head> inner content via idiomorph can
disconnect <style>/<link> elements from the browser's CSSOM the
disconnect <style>/<link> elements from the browser's CSSOM - the
nodes stay in the DOM but styles stop applying. By using JS to
remove-then-append we guarantee the browser re-evaluates them.

Expand Down Expand Up @@ -128,11 +128,11 @@
passed through as-is for redirects, error responses, etc.

- A render result map with pre-serialized HTML strings:
:title resolved page title string, or nil
:head-html HTML string of marked <head> elements, or nil
:body-html HTML string of the rendered page body
:url current route URL string, or nil
:declared-signals vector of signal declaration maps for HTML injection
:title - resolved page title string, or nil
:head-html - HTML string of marked <head> elements, or nil
:body-html - HTML string of the rendered page body
:url - current route URL string, or nil
:declared-signals - vector of signal declaration maps for HTML injection

Binds `context/*request*` and `context/*action-idx*` for the duration
of both rendering and HTML serialization, so lazy hiccup sequences
Expand Down Expand Up @@ -184,21 +184,25 @@
#'context/*declared-signals* (atom [])})
(try
(let [body (safe-render render-fn req)]
;; Ring response passthrough render-fn returned a redirect,
;; Ring response passthrough - render-fn returned a redirect,
;; error, or other non-hiccup response; pass it through as-is.
(if (and (map? body) (:status body))
body
(let [title-spec (when (and (seq route-index) route)
;; Serialize body HTML first - this forces lazy hiccup
;; sequences (for, map, etc.) which may call h/action and
;; register actions during realization.
(let [body-html (c/html body)
title-spec (when (and (seq route-index) route)
(routes/find-route-title route-index (:name route)))
title (routes/resolve-title title-spec req)
head (some-> (routes/resolve-head (get @app-state* :head) req)
mark-head-elements)
declared @context/*declared-signals*]
{:title title
:head-html (some-> head c/html)
:body-html (c/html body)
:url url
:declared-signals declared})))
{:title title
:head-html (some-> head c/html)
:body-html body-html
:url url
:declared-signals declared})))
(finally
(pop-thread-bindings)))))))

Expand Down
Loading