diff --git a/.gitignore b/.gitignore index fe5eb23..34a7282 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ /.nrepl-port .envrc /.lsp/ +.idea +hyper.iml +.qwen diff --git a/Readme.md b/Readme.md index d2bc08b..168d1a3 100644 --- a/Readme.md +++ b/Readme.md @@ -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 @@ -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 @@ -617,6 +617,42 @@ redefine it at the REPL, all connected tabs automatically update their ``. 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 @@ -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 @@ -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) @@ -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 +``` + diff --git a/deps.edn b/deps.edn index 7a12ce7..8ccc134 100644 --- a/deps.edn +++ b/deps.edn @@ -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"} @@ -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"}}}}} diff --git a/src/hyper/actions.clj b/src/hyper/actions.clj index 5e34cf4..6e27754 100644 --- a/src/hyper/actions.clj +++ b/src/hyper/actions.clj @@ -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. @@ -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)) @@ -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." diff --git a/src/hyper/core.clj b/src/hyper/core.clj index bde75af..d9ec6d6 100644 --- a/src/hyper/core.clj +++ b/src/hyper/core.clj @@ -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 @@ -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. @@ -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 @@ -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 "'")))}))))) @@ -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 , 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. @@ -399,7 +407,7 @@ (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 @@ -407,7 +415,8 @@ :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. diff --git a/src/hyper/render.clj b/src/hyper/render.clj index 2848e77..d4b0712 100644 --- a/src/hyper/render.clj +++ b/src/hyper/render.clj @@ -75,7 +75,7 @@ the document title and swaps user-provided elements. Why not morph? Morphing inner content via idiomorph can - disconnect