Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/checks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,9 @@ jobs:
run: nix-shell --run "cljfmt check"
- name: Run Tests
run: nix-shell --run "clojure -M:main:test"
- name: Run Tree-Shaking Tests
run: >-
nix-shell --run "clojure -J-Dhyper.tree-shake=true -M:main:test:tree-shake e2e
--focus hyper.e2e-test/expr-nested-test
--focus hyper.e2e-test/squint-core-version-reload-test
--focus hyper.component-e2e-test"
7 changes: 4 additions & 3 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -2041,13 +2041,14 @@ Hyper ships clj-kondo config (see [clj-kondo](#clj-kondo)) that validates
`defc` structure at lint time and makes attrs, `emit`, and `ctx` resolve
inside segments.

For self-hosted/air-gapped deploys, override the Squint runtime CDN URL
with the `:squint-core-url` option on `create-handler`.
Set `:squint-core-url` on `create-handler` to load the Squint core from
another URL, such as a CDN. By default hyper serves it at
`/hyper/squint-core.js`, for `h/expr` and components.

### Tree shaking

Pass `:tree-shake? true` to `create-handler` to serve only the Squint core
functions that your `h/expr` expressions use, instead of all of
functions that your `h/expr` expressions and `defc` components use, instead of all of
`/hyper/squint-core.js` (28 KB brotli-compressed):

```clojure
Expand Down
68 changes: 37 additions & 31 deletions src/hyper/component/bundle.clj
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,16 @@
"Assembly and serving support for the client-components JS bundle.

Components registered in hyper.component/registry* are assembled into a
single ES module — squint core import, the hyper component runtime
(resources/hyper/component-runtime.js), then each compiled component —
served at /hyper/components.js (see hyper.server) and injected into the
page head with a content-hashed URL (see hyper.render), which makes the
response immutable-cacheable and lets REPL redefinition hot-swap the
bundle over SSE."
single ES module with a binding to the page's squint core, the hyper
component runtime (resources/hyper/component-runtime.js) and each compiled
component. The module is served at /hyper/components.js (see
hyper.server) and injected into the page head with a content-hashed URL
(see hyper.render), which makes the response immutable-cacheable and lets
REPL redefinition hot-swap the bundle over SSE."
(:require [clojure.java.io :as io]
[clojure.string :as str]
[hyper.component :as component]))

(def default-squint-core-url
"CDN URL for squint's core.js runtime, version-matched to the compiler
dependency in deps.edn. Override via the :squint-core-url option on
create-handler to self-host."
"https://cdn.jsdelivr.net/npm/squint-cljs@0.9.184/src/squint/core.js")
[hyper.component :as component]
[hyper.expr :as expr]))

(defn- runtime-js []
(slurp (io/resource "hyper/component-runtime.js")))
Expand Down Expand Up @@ -53,8 +48,8 @@
pairs))))

(defn- assemble-bundle
[registry squint-core-url]
(let [js (str "import * as $sc from '" (or squint-core-url default-squint-core-url) "';\n"
[registry]
(let [js (str "const $sc = window.hyper_sc;\n"
(import-lines registry)
(runtime-js)
"\n"
Expand All @@ -69,28 +64,39 @@
"Assemble (or return cached) the components JS bundle.

Returns {:js \"...\" :hash \"...\"} or nil when no components are
registered. The bundle is a single ES module: squint core import,
the hyper component runtime, then each registered component sorted
by name. Cached against the registry snapshot + core URL."
([] (bundle nil))
([{:keys [squint-core-url]}]
(let [registry @component/registry*]
(when (seq registry)
(let [cache-key [registry squint-core-url]
cached @bundle-cache*]
(if (= cache-key (:key cached))
(:bundle cached)
(let [b (assemble-bundle registry squint-core-url)]
(reset! bundle-cache* {:key cache-key :bundle b})
b)))))))
registered. The bundle is a single ES module: a binding to the page's
squint core (window.hyper_sc), the hyper component runtime, then each
registered component sorted by name. Cached against the registry snapshot."
[]
(let [registry @component/registry*]
(when (seq registry)
(let [cached @bundle-cache*]
(if (= registry (:key cached))
(:bundle cached)
(let [b (assemble-bundle registry)]
(reset! bundle-cache* {:key registry :bundle b})
b))))))

(defonce ^:private core-vars-cache* (atom nil))

(defn core-vars
"Returns the munged squint core names that the registered components use."
[]
(let [registry @component/registry*
[cached-registry names] @core-vars-cache*]
(if (identical? registry cached-registry)
names
(let [names (into #{} (mapcat #(expr/core-vars-of "$sc" (:js %))) (vals registry))]
(reset! core-vars-cache* [registry names])
names))))

(defn head-script-tag
"Hiccup script tag for the components bundle, or nil when no components
are registered. The content hash is carried in the query string so the
endpoint can serve immutable cache headers and a registry change rotates
the URL (which, combined with head-element fingerprint diffing, makes
REPL redefinition hot-swap the bundle over SSE)."
[base-path opts]
(when-let [{:keys [hash]} (bundle opts)]
[base-path]
(when-let [{:keys [hash]} (bundle)]
[:script {:type "module"
:src (str base-path "/hyper/components.js?v=" hash)}]))
7 changes: 3 additions & 4 deletions src/hyper/core.clj
Original file line number Diff line number Diff line change
Expand Up @@ -926,11 +926,10 @@
Options (keyword arguments):
- :app-state — Atom for application state (default: fresh atom)
- :datastar-script - Override of the default datastar script tag (as Hiccup) or nil to suppress
- :squint-core-url — Override of the squint core.js URL used by the client
components bundle (default: version-matched jsDelivr CDN).
Point at a self-hosted copy for offline/air-gapped deploys.
- :squint-core-url - URL the page imports squint core from, for h/expr and
components (default: /hyper/squint-core.js, served by hyper).
- :tree-shake? - Serve only the squint core functions that h/expr output
uses. Needs babashka.esbuild on the classpath.
and defc components use. Needs babashka.esbuild on the classpath.
- :head — Hiccup nodes appended to the HTML <head>, or (fn [req] ...) -> hiccup
- :webkit-sse-shim? — Inject a small client shim (into <head>, only for WebKit/Safari
user agents) that routes the GET render stream through a native
Expand Down
29 changes: 21 additions & 8 deletions src/hyper/expr.clj
Original file line number Diff line number Diff line change
Expand Up @@ -238,8 +238,18 @@
(atom (sorted-set)))

;; A future squint release adds the used core names to the compiler output, which replaces this regex.
(defn- record-core-vars! [js]
(when-let [names (seq (map second (re-seq #"hyper_sc\.([A-Za-z_$][\w$]*)" js)))]
(defn core-vars-of
"Returns the squint core names that js references through alias."
[alias js]
(into (sorted-set) (map second)
(re-seq (re-pattern (str (java.util.regex.Pattern/quote alias) "\\.([A-Za-z_$][\\w$]*)")) js)))

(defn use-core-vars!
"Adds names to core-vars*.
names is a set of munged squint core names.
Returns js."
[names js]
(when-not (every? @core-vars* names)
(swap! core-vars* into names))
js)

Expand All @@ -258,7 +268,6 @@
:core-alias "hyper_sc"
:macros compiler-macros})
replace-deref
record-core-vars!
(restore-signal-casing processed)
(str/replace #"\n" " ")
str/trim
Expand Down Expand Up @@ -327,8 +336,12 @@
pairs* (atom [])
forms* (mapv #(infer-boundary % env-locals pairs*) forms)
template (join-statements (map compile-form forms*))
pairs @pairs*]
(if (empty? pairs)
template
`(substitute ~template
[~@(map (fn [[ph expr mode]] [ph expr mode]) pairs)]))))
names (core-vars-of "hyper_sc" template)
pairs @pairs*
js (if (empty? pairs)
template
`(substitute ~template
[~@(map (fn [[ph expr mode]] [ph expr mode]) pairs)]))]
(if (seq names)
`(use-core-vars! ~(set names) ~js)
js)))
3 changes: 3 additions & 0 deletions src/hyper/expr/bundle.clj
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
(def ^:private core-dir
(delay
(let [dir (fs/create-temp-dir {:prefix "hyper-squint-core"})]
(fs/delete-on-exit dir)
(fs/delete-on-exit (fs/file dir "core.js"))
(fs/delete-on-exit (fs/file dir "entry.js"))
(with-open [in (io/input-stream (io/resource "squint/core.js"))]
(io/copy in (fs/file dir "core.js")))
dir)))
Expand Down
4 changes: 1 addition & 3 deletions src/hyper/render.clj
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,7 @@
hiccup or nil."
[app-state* req]
(let [user-head (routes/resolve-head (get @app-state* :head) req)
comp-tag (component.bundle/head-script-tag
(get @app-state* :base-path "")
{:squint-core-url (get @app-state* :squint-core-url)})
comp-tag (component.bundle/head-script-tag (get @app-state* :base-path ""))
els (cond-> (head-elements user-head)
comp-tag (conj comp-tag))]
(when (seq els)
Expand Down
21 changes: 12 additions & 9 deletions src/hyper/server.clj
Original file line number Diff line number Diff line change
Expand Up @@ -152,12 +152,17 @@
(.digest (java.security.MessageDigest/getInstance "SHA-256")
(.getBytes ^String (slurp (io/resource "squint/core.js")) "UTF-8")))))

(defn- used-core-vars
"Returns the munged squint core names that h/expr output and defc components use."
[]
(into (set @expr/core-vars*) (component.bundle/core-vars)))

(defn- squint-core-version
"Returns a version string for the squint core that /hyper/squint-core.js serves.
The version changes if the served JS changes."
[app-state*]
(if (:tree-shake? @app-state*)
(Integer/toHexString (hash [@squint-core-sha @expr/core-vars*]))
(Integer/toHexString (hash [@squint-core-sha (used-core-vars)]))
(subs @squint-core-sha 0 12)))

(defn- handle-full-render
Expand Down Expand Up @@ -899,7 +904,7 @@
Shared by page-handler and not-found-handler so both emit identical document
scaffolding, differing only in `status` and `fallback-title` (the <title>
used when the result has no :title)."
[result {:keys [datastar-script open-when-hidden? base-path webkit-sse-shim? core-version tree-shake?]
[result {:keys [datastar-script open-when-hidden? base-path webkit-sse-shim? core-version tree-shake? squint-core-url]
:or {open-when-hidden? true
base-path ""
webkit-sse-shim? true}}
Expand All @@ -926,7 +931,7 @@
[:title title]
webkit-shim
[:script {:type "module"}
(c/raw (str "import * as sc from '" base-path "/hyper/squint-core.js?v=" core-version "';\n"
(c/raw (str "import * as sc from '" (or squint-core-url (str base-path "/hyper/squint-core.js?v=" core-version)) "';\n"
"window.hyper_sc = sc;"
(when tree-shake? (str "\nwindow.hyper_squint_version = '" core-version "';"))))]
datastar-script
Expand Down Expand Up @@ -1011,10 +1016,9 @@
URL forever, so a later URL reuse (e.g. reverting an edit) would serve
that stale entry until a hard refresh. A mismatched (or missing) `?v=`
is therefore served `no-cache` so the browser always revalidates."
[app-state*]
[]
(fn [req]
(if-let [{:keys [js hash]} (component.bundle/bundle
{:squint-core-url (get @app-state* :squint-core-url)})]
(if-let [{:keys [js hash]} (component.bundle/bundle)]
(let [requested (get-in req [:query-params "v"])
matched? (= requested hash)]
{:status 200
Expand All @@ -1039,7 +1043,7 @@
(fn [req]
(let [br? (br/accepts-br? req)
body (cond
core-js (let [{:keys [js br]} (core-js (set @expr/core-vars*))]
core-js (let [{:keys [js br]} (core-js (used-core-vars))]
(if br? br js))
br? @js-br
:else js)]
Expand Down Expand Up @@ -1325,7 +1329,7 @@
[(str base-path "/hyper/actions") {:post (action-handler app-state*)}]
[(str base-path "/hyper/upload") {:post upload-route}]
[(str base-path "/hyper/navigate") {:post (navigate-handler app-state*)}]
[(str base-path "/hyper/components.js") {:get (components-js-handler app-state*)}]
[(str base-path "/hyper/components.js") {:get (components-js-handler)}]
[(str base-path "/hyper/squint-core.js") {:get (squint-core-js-handler app-state* (:tree-shake? opts))}]]
;; Store the routes source (Var or value) so title resolution can
;; always read the latest route metadata, even between router rebuilds.
Expand All @@ -1346,7 +1350,6 @@
:disconnect-grace-ms disconnect-grace-ms
:heartbeat-ms heartbeat-ms
:open-when-hidden? (get opts :open-when-hidden? true)
:squint-core-url (:squint-core-url opts)
:tree-shake? (:tree-shake? opts))
initial-routes (if (var? routes) @routes routes)
initial-handler (build-ring-handler initial-routes app-state* page-wrapper system-routes default-handler)
Expand Down
3 changes: 2 additions & 1 deletion test/hyper/component_e2e_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,8 @@
(use-fixtures :once
(fn [f]
(let [app-state (atom (state/init-state))
handler (h/create-handler #'routes :app-state app-state)]
handler (h/create-handler #'routes :app-state app-state
:tree-shake? (= "true" (System/getProperty "hyper.tree-shake")))]
(reset! test-server (h/start! handler {:port test-port}))
(try
(f)
Expand Down
17 changes: 8 additions & 9 deletions test/hyper/component_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -136,32 +136,31 @@
(with-fresh-registry
(testing "nil when no components are registered"
(is (nil? (hcb/bundle)))
(is (nil? (hcb/head-script-tag "" nil))))
(is (nil? (hcb/head-script-tag ""))))

(hc/register-component! "a-widget" {:attrs [:v] :render "(fn [{:keys [v]} _] [:em v])"})

(testing "bundle is a single ES module: prelude, runtime, components"
(let [{:keys [js hash]} (hcb/bundle)]
(is (str/starts-with? js "import * as $sc from"))
(is (str/includes? js hcb/default-squint-core-url))
(is (str/starts-with? js "const $sc = window.hyper_sc;"))
(is (str/includes? js "function $define(") "runtime shim included")
(is (str/includes? js "$define(\"a-widget\"") "component included")
(is (= 16 (count hash)))))

(testing "bundle is cached against the registry snapshot"
(is (identical? (hcb/bundle) (hcb/bundle))))

(testing "core url override lands in the prelude"
(is (str/includes? (:js (hcb/bundle {:squint-core-url "/vendor/squint-core.js"}))
"from '/vendor/squint-core.js'")))

(testing "registering another component changes the hash and sorts by name"
(let [h1 (:hash (hcb/bundle))]
(hc/register-component! "b-widget" {:attrs [] :render "(fn [_ _] [:i])"})
(let [{:keys [js hash]} (hcb/bundle)]
(is (not= h1 hash))
(is (< (str/index-of js "$define(\"a-widget\"")
(str/index-of js "$define(\"b-widget\""))))))))
(str/index-of js "$define(\"b-widget\""))))))

(testing "core-vars returns the $sc names of registered components"
(hc/register-component! "c-widget" {:attrs [:v] :render "(fn [{:keys [v]} _] [:em (count v)])"})
(is (contains? (hcb/core-vars) "count")))))

;; ---------------------------------------------------------------------------
;; Macro-time hiccup compilation
Expand Down Expand Up @@ -455,7 +454,7 @@
(with-fresh-registry
(hc/register-component! "a-widget" {:attrs [] :render "(fn [_ _] [:i])"})
(testing "script tag carries base-path and content hash"
(let [[tag attrs] (hcb/head-script-tag "/my-app" nil)]
(let [[tag attrs] (hcb/head-script-tag "/my-app")]
(is (= :script tag))
(is (= "module" (:type attrs)))
(is (str/starts-with? (:src attrs) "/my-app/hyper/components.js?v="))
Expand Down
8 changes: 7 additions & 1 deletion test/hyper/expr_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,13 @@
(let [expansion (macroexpand-1 '(hyper.expr/->expr (set! $x ~(compute))))]
(is (seq? expansion))
(is (= 'hyper.expr/substitute (first expansion)))
(is (string? (second expansion)) "template compiled at expansion, not per call"))))
(is (string? (second expansion)) "template compiled at expansion, not per call")))

(testing "evaluating an expansion records its squint core names in core-vars*"
(let [expansion (macroexpand-1 '(hyper.expr/->expr (or $a $b)))]
(with-redefs [expr/core-vars* (atom (sorted-set))]
(eval expansion)
(is (contains? @expr/core-vars* "truth_"))))))

;; ---------------------------------------------------------------------------
;; Deref of static signal vars (e.g. the connection signals)
Expand Down
17 changes: 11 additions & 6 deletions test/hyper/server_test.clj
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,13 @@
(is (bytes? (:body response)))
(is (identical? (:body response) (:body (handler request)))))))

(deftest test-squint-core-url
(testing ":squint-core-url replaces the squint core URL of a page"
(let [handler (server/create-handler [["/" {:get (fn [_] [:div])}]] (atom (state/init-state))
{:squint-core-url "/vendor/core.js"})]
(is (string/includes? (:body (handler {:uri "/" :request-method :get}))
"import * as sc from '/vendor/core.js'")))))

(deftest test-squint-core-version
(testing "the versioned squint core URL of a page is cached as immutable"
(let [handler (server/create-handler [["/" {:get (fn [_] [:div])}]] (atom (state/init-state)))
Expand Down Expand Up @@ -1096,9 +1103,8 @@
(try
(reset! component/registry* {})
(component/register-component! "x-widget" {:attrs [] :render "(fn [_ _] [:i])"})
(let [app-state* (atom {})
handler (#'server/components-js-handler app-state*)
hash (:hash (bundle/bundle))]
(let [handler (#'server/components-js-handler)
hash (:hash (bundle/bundle))]
(testing "matching ?v -> immutable (true content-addressed cache)"
(let [resp (handler {:query-params {"v" hash}})]
(is (= 200 (:status resp)))
Expand All @@ -1124,9 +1130,8 @@
(let [saved @component/registry*]
(try
(reset! component/registry* {})
(let [app-state* (atom {})
handler (#'server/components-js-handler app-state*)
resp (handler {:query-params {"v" "anything"}})]
(let [handler (#'server/components-js-handler)
resp (handler {:query-params {"v" "anything"}})]
(is (= 404 (:status resp))))
(finally
(reset! component/registry* saved))))))
Loading