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