diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml index 22228b8..8238c37 100644 --- a/.github/workflows/checks.yaml +++ b/.github/workflows/checks.yaml @@ -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" diff --git a/Readme.md b/Readme.md index 60569e7..40c42c5 100644 --- a/Readme.md +++ b/Readme.md @@ -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 diff --git a/src/hyper/component/bundle.clj b/src/hyper/component/bundle.clj index 9958b54..24177b1 100644 --- a/src/hyper/component/bundle.clj +++ b/src/hyper/component/bundle.clj @@ -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"))) @@ -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" @@ -69,20 +64,31 @@ "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 @@ -90,7 +96,7 @@ 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)}])) diff --git a/src/hyper/core.clj b/src/hyper/core.clj index a77cdfd..bda04f4 100644 --- a/src/hyper/core.clj +++ b/src/hyper/core.clj @@ -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 , or (fn [req] ...) -> hiccup - :webkit-sse-shim? — Inject a small client shim (into , only for WebKit/Safari user agents) that routes the GET render stream through a native diff --git a/src/hyper/expr.clj b/src/hyper/expr.clj index 3864db8..bbc2ff2 100644 --- a/src/hyper/expr.clj +++ b/src/hyper/expr.clj @@ -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) @@ -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 @@ -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))) diff --git a/src/hyper/expr/bundle.clj b/src/hyper/expr/bundle.clj index eeba564..cee0c46 100644 --- a/src/hyper/expr/bundle.clj +++ b/src/hyper/expr/bundle.clj @@ -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))) diff --git a/src/hyper/render.clj b/src/hyper/render.clj index ffd2736..3d56a21 100644 --- a/src/hyper/render.clj +++ b/src/hyper/render.clj @@ -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) diff --git a/src/hyper/server.clj b/src/hyper/server.clj index d113bb9..7769aa2 100644 --- a/src/hyper/server.clj +++ b/src/hyper/server.clj @@ -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 @@ -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 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}} @@ -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 @@ -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 @@ -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)] @@ -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. @@ -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) diff --git a/test/hyper/component_e2e_test.clj b/test/hyper/component_e2e_test.clj index 5622f16..dd770d6 100644 --- a/test/hyper/component_e2e_test.clj +++ b/test/hyper/component_e2e_test.clj @@ -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) diff --git a/test/hyper/component_test.clj b/test/hyper/component_test.clj index 91200ef..9cd7930 100644 --- a/test/hyper/component_test.clj +++ b/test/hyper/component_test.clj @@ -136,14 +136,13 @@ (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))))) @@ -151,17 +150,17 @@ (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 @@ -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=")) diff --git a/test/hyper/expr_test.clj b/test/hyper/expr_test.clj index 9247b46..19af1e0 100644 --- a/test/hyper/expr_test.clj +++ b/test/hyper/expr_test.clj @@ -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) diff --git a/test/hyper/server_test.clj b/test/hyper/server_test.clj index dd7c67e..355bb99 100644 --- a/test/hyper/server_test.clj +++ b/test/hyper/server_test.clj @@ -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))) @@ -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))) @@ -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))))))