From 6fddb8f7a249f71fe9fc6660c0022215d73974f1 Mon Sep 17 00:00:00 2001 From: Michiel Borkent Date: Wed, 14 Oct 2026 18:45:17 +0200 Subject: [PATCH] feat: serve source maps for defc components behind :source-map? --- deps.edn | 2 +- example/example/app.clj | 2 +- src/hyper/component.clj | 71 +++++++++++++++-------------- src/hyper/component/bundle.clj | 82 +++++++++++++++++++++++----------- src/hyper/core.clj | 8 +++- src/hyper/render.clj | 3 +- src/hyper/server.clj | 22 +++++++-- test/hyper/component_test.clj | 74 +++++++++++++++++++++++++++++- test/hyper/server_test.clj | 27 ++++++++++- 9 files changed, 216 insertions(+), 75 deletions(-) diff --git a/deps.edn b/deps.edn index 28895a5..7986954 100644 --- a/deps.edn +++ b/deps.edn @@ -36,7 +36,7 @@ cheshire/cheshire {:mvn/version "6.1.0"} ;; Client-side component compilation (CLJS-dialect -> JS) - io.github.squint-cljs/squint {:mvn/version "0.14.210"} + io.github.squint-cljs/squint {:mvn/version "0.15.212"} ;; Logging com.taoensso/telemere {:mvn/version "1.2.1"}} diff --git a/example/example/app.clj b/example/example/app.clj index 8c6055a..9dccb0c 100644 --- a/example/example/app.clj +++ b/example/example/app.clj @@ -590,7 +590,7 @@ ;; --------------------------------------------------------------------------- (def app - (h/start! (h/create-handler #'routes :head [styles]) {:port 4000})) + (h/start! (h/create-handler #'routes :head [styles] :source-map? true) {:port 4000})) (comment (h/stop! app)) diff --git a/src/hyper/component.clj b/src/hyper/component.clj index 2f14057..3b0d99e 100644 --- a/src/hyper/component.clj +++ b/src/hyper/component.clj @@ -55,13 +55,14 @@ :context :statement}) (defn- compile-squint* - [source] - (try - (squint/compile* source squint-opts) - (catch Exception e - (throw (ex-info (str "Squint compilation failed: " (ex-message e)) - {:source source} - e))))) + ([source] (compile-squint* source nil)) + ([source opts] + (try + (squint/compile* source (merge squint-opts opts)) + (catch Exception e + (throw (ex-info (str "Squint compilation failed: " (ex-message e)) + {:source (if (string? source) source (str/join "\n" (map pr-str source)))} + e)))))) (defn compile-squint "Compile a string of Squint (CLJS-dialect) source to JavaScript. @@ -74,29 +75,23 @@ [source] (:body (compile-squint* source))) -(defn- ns-form-source +(defn- ns-form "Build a Squint ns form declaring ES module requires, so alias-qualified symbols (e.g. d3/line) resolve during compilation. The import statements themselves are elided from the compiled output (the bundle prelude emits them once, deduplicated across components)." [tag-name requires] (when (seq requires) - (str "(ns hyper.component." - (str/replace tag-name "-" "_") - " (:require " - (str/join " " (map (fn [{:keys [url alias]}] - (str "[\"" url "\" :as " alias "]")) - requires)) - "))\n"))) + (list 'ns (symbol (str "hyper.component." (str/replace tag-name "-" "_"))) + (list* :require (map (fn [{:keys [url alias]}] [url :as alias]) requires))))) (defn- define-call-source "Assemble the full Squint source for a component definition: the optional ns/require form followed by a call to the runtime's `$define` (which the bundle prelude provides) with the given spec-entry strings as the spec - map body. Shared by registration-source (raw Squint strings) and - squint-source (defc)." + map body. Used by registration-source (raw Squint strings)." [tag-name requires spec-entries] - (str (ns-form-source tag-name requires) + (str (some-> (ns-form tag-name requires) pr-str (str "\n")) "($define " (pr-str tag-name) " " "{" (str/join " " spec-entries) "})")) @@ -315,7 +310,8 @@ form)) (defn- squint-source - "Build the complete Squint source for a `defc` component. + "Build the Squint forms for a `defc` component, keeping the source + positions of the user's forms. The generated source is a call to `$define` with: - :attrs — vector of attribute name strings @@ -331,10 +327,7 @@ and `emit`/`ctx` in scope. When `requires` is non-empty, an ns form with the module requires is - prepended so alias-qualified symbols resolve during compilation. - - All forms are emitted as Clojure data (readable by Squint) so no - string-quoting escaping is needed — `pr-str` handles it." + prepended so alias-qualified symbols resolve during compilation." [tag-name attr-keys {:keys [events render mount update unmount]} ns-str requires] (let [attr-strs (mapv clojure.core/name attr-keys) attr-destructure {:keys (mapv symbol (map clojure.core/name attr-keys))} @@ -362,12 +355,14 @@ ~@handler-bindings ~@(when (seq events) ['$handlers handlers-map])] ~render-rewritten)))) - spec-entries (cond-> [(str ":attrs " (pr-str attr-strs))] - render-fn (conj (str ":render " (pr-str render-fn))) - mount (conj (str ":mount " (pr-str (lifecycle-fn mount)))) - update (conj (str ":update " (pr-str (lifecycle-fn update)))) - unmount (conj (str ":unmount " (pr-str (lifecycle-fn unmount)))))] - (define-call-source tag-name requires spec-entries))) + spec (cond-> (array-map :attrs attr-strs) + render-fn (assoc :render render-fn) + mount (assoc :mount (lifecycle-fn mount)) + update (assoc :update (lifecycle-fn update)) + unmount (assoc :unmount (lifecycle-fn unmount)))] + (cond-> [] + (seq requires) (conj (ns-form tag-name requires)) + true (conj (list '$define tag-name spec))))) (defmacro defc "Define a client-side web component. @@ -477,21 +472,25 @@ _ (assert (or (:render parsed) (:mount parsed)) (str "defc " cname ": a (render ...) or (mount ...) segment is required")) ns-str (str *ns*) - src (squint-source tag-name attr-keys parsed ns-str requires) + forms (squint-source tag-name attr-keys parsed ns-str requires) tag-kw (keyword tag-name) ;; Compile at macro-expansion time so Squint errors surface at compile ;; time (with the source attached), but emit the registration into the ;; expansion so it executes at runtime. This keeps defc AOT-safe ;; (expansion-time side effects don't survive AOT compilation) and ;; testable (with-redefs of registry* works). - {compiled-js :body - core-vars :used-core-vars} (compile-squint* src)] + {:as compiled + compiled-js :body + core-vars :used-core-vars} (compile-squint* forms {:source-map true})] `(do (check-alias-conflicts! ~tag-name '~requires) - (swap! registry* assoc ~tag-name {:attrs ~attr-keys - :requires '~requires - :js ~compiled-js - :core-vars ~core-vars}) + (swap! registry* assoc ~tag-name {:attrs ~attr-keys + :requires '~requires + :js ~compiled-js + :core-vars ~core-vars + :source-map ~(when (not= "NO_SOURCE_PATH" *file*) + {:segments (:source-map-segments compiled) + :source *file*})}) (defn ~cname ~@(when docstring [docstring]) [attr-map# & children#] diff --git a/src/hyper/component/bundle.clj b/src/hyper/component/bundle.clj index 21823e7..999cc10 100644 --- a/src/hyper/component/bundle.clj +++ b/src/hyper/component/bundle.clj @@ -10,7 +10,8 @@ REPL redefinition hot-swap the bundle over SSE." (:require [clojure.java.io :as io] [clojure.string :as str] - [hyper.component :as component])) + [hyper.component :as component] + [squint.compiler.source-map :as sm])) (defn- runtime-js [] (slurp (io/resource "hyper/component-runtime.js"))) @@ -46,35 +47,61 @@ (str "import * as " (munge (name alias)) " from '" url "';\n")) pairs)))) +(defn- source-text [path] + (when path + (or (some-> (io/resource path) slurp) + (let [f (io/file path)] (when (.exists f) (slurp f)))))) + +(defn- bundle-source-map + "Returns the source map JSON for components placed after prefix, joined with + newlines, or nil if no component carries segments." + [prefix components] + (let [sources (vec (distinct (keep (comp :source :source-map) components))) + index (zipmap sources (range)) + segments (loop [[c & more] components, line (sm/count-newlines prefix), acc []] + (if-not c + acc + (let [{:keys [segments source]} (:source-map c) + acc (into acc (map (fn [[gl gc sl sc]] [(+ gl line) gc sl sc (index source)])) + (when source segments))] + (recur more (+ line 1 (sm/count-newlines (:js c))) acc))))] + (when (seq segments) + (sm/encode segments {:file "components.js" + :sources sources + :sources-content (mapv source-text sources)})))) + (defn- assemble-bundle - [registry] - (let [js (str "const $sc = window.hyper_sc;\n" - (import-lines registry) - (runtime-js) - "\n" - (->> (sort-by key registry) - (map (comp :js val)) - (str/join "\n")))] - {:js js :hash (sha256-hex js)})) + [registry source-map?] + (let [prefix (str "const $sc = window.hyper_sc;\n" + (import-lines registry) + (runtime-js) + "\n") + components (map val (sort-by key registry)) + source-map (when source-map? (bundle-source-map prefix components)) + js (cond-> (str prefix (str/join "\n" (map :js components))) + source-map (str "\n//# sourceMappingURL=components.js.map\n"))] + {:js js :hash (sha256-hex js) :source-map source-map})) -(defonce ^:private bundle-cache* (atom nil)) +(defonce ^:private bundle-cache* (atom {})) (defn bundle "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: a binding to the page's + Returns {:js \"...\" :hash \"...\" :source-map \"...\"} or nil when no + components are 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)))))) + registered component sorted by name. Cached against the registry snapshot. + :source-map is nil if source-map? is false." + ([] (bundle false)) + ([source-map?] + (let [registry @component/registry*] + (when (seq registry) + (let [cached (get @bundle-cache* source-map?)] + (if (= registry (:key cached)) + (:bundle cached) + (let [b (assemble-bundle registry source-map?)] + (swap! bundle-cache* assoc source-map? {:key registry :bundle b}) + b))))))) (defn core-vars "Returns the munged squint core names that the registered components use." @@ -87,7 +114,8 @@ 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] - (when-let [{:keys [hash]} (bundle)] - [:script {:type "module" - :src (str base-path "/hyper/components.js?v=" hash)}])) + ([base-path] (head-script-tag base-path false)) + ([base-path source-map?] + (when-let [{:keys [hash]} (bundle source-map?)] + [: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 67cdfff..7d1cd2e 100644 --- a/src/hyper/core.clj +++ b/src/hyper/core.clj @@ -940,6 +940,9 @@ components (default: /hyper/squint-core.js, served by hyper). - :tree-shake? - Serve only the squint core functions that h/expr output and defc components use. Needs babashka.esbuild on the classpath. + - :source-map? - Serve a source map for defc components. The map contains + the full source and path of each file that defines one. + Use in development only. Default false. - :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 @@ -1019,7 +1022,7 @@ (stop! app)" [routes & {:keys [app-state head static-resources static-dir watches datastar-script base-path middleware render-middleware - render-error squint-core-url tree-shake? render-guard + render-error squint-core-url tree-shake? source-map? render-guard max-file-size max-file-count upload-expires-in param-transformer] :or {app-state (atom (state/init-state)) datastar-script server/default-datastar-script} @@ -1034,7 +1037,8 @@ :middleware middleware :render-middleware render-middleware :squint-core-url squint-core-url - :tree-shake? tree-shake?} + :tree-shake? tree-shake? + :source-map? source-map?} ;; Only forward when supplied so server defaults apply. render-error (assoc :render-error render-error) render-guard (assoc :render-guard render-guard) diff --git a/src/hyper/render.clj b/src/hyper/render.clj index 8c12d68..342ac78 100644 --- a/src/hyper/render.clj +++ b/src/hyper/render.clj @@ -154,7 +154,8 @@ 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 "")) + comp-tag (component.bundle/head-script-tag (get @app-state* :base-path "") + (:source-map? @app-state*)) 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 66d9c5c..f477965 100644 --- a/src/hyper/server.clj +++ b/src/hyper/server.clj @@ -1016,9 +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)] + (if-let [{:keys [js hash]} (component.bundle/bundle (:source-map? @app-state*))] (let [requested (get-in req [:query-params "v"]) matched? (= requested hash)] {:status 200 @@ -1032,6 +1032,20 @@ :headers {"Content-Type" "text/plain"} :body "No components registered"}))) +(defn- components-js-map-handler + "Serve the source map of the client-components bundle if :source-map? is true." + [app-state*] + (fn [_req] + (if-let [source-map (when (:source-map? @app-state*) + (:source-map (component.bundle/bundle true)))] + {:status 200 + :headers {"Content-Type" "application/json; charset=utf-8" + "Cache-Control" "no-cache"} + :body source-map} + {:status 404 + :headers {"Content-Type" "text/plain"} + :body "No source map"}))) + (defn- squint-core-js-handler "Returns a handler that serves squint's core.js from the classpath. Serves only the functions h/expr output uses if tree-shake? is true, which needs babashka.esbuild. @@ -1328,7 +1342,8 @@ [(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)}] + [(str base-path "/hyper/components.js") {:get (components-js-handler app-state*)}] + [(str base-path "/hyper/components.js.map") {:get (components-js-map-handler app-state*)}] [(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. @@ -1350,6 +1365,7 @@ :heartbeat-ms heartbeat-ms :open-when-hidden? (get opts :open-when-hidden? true) :tree-shake? (:tree-shake? opts) + :source-map? (:source-map? opts) :param-transformer (or (:param-transformer opts) routes/default-malli-transformer)) initial-routes (if (var? routes) @routes routes) diff --git a/test/hyper/component_test.clj b/test/hyper/component_test.clj index 9cd7930..63ef037 100644 --- a/test/hyper/component_test.clj +++ b/test/hyper/component_test.clj @@ -7,13 +7,14 @@ [hyper.component :as hc] [hyper.component.bundle :as hcb] [hyper.component.hiccup :as hch] - [hyper.signal :as signal])) + [hyper.signal :as signal] + [squint.compiler.source-map :as sm])) (defn- with-fresh-registry* "Run f with an isolated component registry and bundle cache." [f] (with-redefs-fn {#'hc/registry* (atom {}) - #'hyper.component.bundle/bundle-cache* (atom nil)} + #'hyper.component.bundle/bundle-cache* (atom {})} f)) (defmacro with-fresh-registry [& body] @@ -302,6 +303,75 @@ (eval '(hyper.component/defc no-render-widget [{:keys [x]}]))))))) +(deftest test-defc-source-map + (with-fresh-registry + #_{:clj-kondo/ignore [:inline-def]} + (hc/defc sm-widget + [{:keys [label]}] + (render + [:span (str label "!")])) + + (let [{:keys [line file]} (meta #'sm-widget) + {:keys [segments source]} (get-in @hc/registry* ["sm-widget" :source-map])] + (testing "defc maps the str call to its line in the source file" + (is (= file source)) + (is (= #{(+ line 2)} (set (map #(nth % 2) segments))))) + + (testing "the bundle links a source map with the file name and content" + (let [{:keys [js source-map]} (hcb/bundle true)] + (is (str/ends-with? js "//# sourceMappingURL=components.js.map\n")) + (is (str/includes? source-map (str "\"sources\":[\"" source "\"]"))) + (is (str/includes? source-map "\"sourcesContent\":[\"(ns hyper.component-test")))) + + (testing "source-map? false omits the map and the sourceMappingURL" + (let [{:keys [js source-map]} (hcb/bundle false)] + (is (nil? source-map)) + (is (not (str/includes? js "sourceMappingURL"))))) + + (testing "bundle caches source-map? true and false separately" + (let [with-map (hcb/bundle true)] + (hcb/bundle false) + (is (identical? with-map (hcb/bundle true)))))))) + +(deftest test-bundle-source-map-lines + (with-fresh-registry + (hc/register-component! "sm-0" {:attrs [] :render "(fn [_ _] [:i])"}) + #_{:clj-kondo/ignore [:inline-def]} + (hc/defc sm-a + [{:keys [label]}] + (render + [:span (str label "!")])) + #_{:clj-kondo/ignore [:inline-def]} + (hc/defc sm-b + [{:keys [label]}] + (render + [:span (str label "?")])) + + (let [segments* (atom nil) + encode sm/encode + js (with-redefs [sm/encode (fn [segments opts] + (reset! segments* segments) + (encode segments opts))] + (:js (hcb/bundle true))) + js-lines (str/split-lines js) + marker {(+ 2 (:line (meta #'sm-a))) "\"!\"" + (+ 2 (:line (meta #'sm-b))) "\"?\""}] + (testing "each segment points at the bundle line with its component's str call" + (is (= (set (keys marker)) (set (map #(nth % 2) @segments*)))) + (doseq [[gl _ sl _ src] @segments*] + (is (= 0 src)) + (is (str/includes? (nth js-lines gl) (marker sl)))))))) + +(deftest test-defc-no-source-path + (with-fresh-registry + (testing "defc with *file* NO_SOURCE_PATH stores no source map" + (binding [*file* "NO_SOURCE_PATH"] + (eval '(hyper.component/defc repl-widget + [{:keys [x]}] + (render [:i x])))) + (is (contains? @hc/registry* "repl-widget")) + (is (nil? (get-in @hc/registry* ["repl-widget" :source-map])))))) + (deftest test-defc-requires (with-fresh-registry (testing ":require compiles alias-qualified symbols and lands in the bundle" diff --git a/test/hyper/server_test.clj b/test/hyper/server_test.clj index 8ed0816..db3166d 100644 --- a/test/hyper/server_test.clj +++ b/test/hyper/server_test.clj @@ -1219,7 +1219,7 @@ (try (reset! component/registry* {}) (component/register-component! "x-widget" {:attrs [] :render "(fn [_ _] [:i])"}) - (let [handler (#'server/components-js-handler) + (let [handler (#'server/components-js-handler (atom {})) hash (:hash (bundle/bundle))] (testing "matching ?v -> immutable (true content-addressed cache)" (let [resp (handler {:query-params {"v" hash}})] @@ -1241,12 +1241,35 @@ (finally (reset! component/registry* saved)))))) +(deftest test-components-js-map-handler + (let [saved @component/registry*] + (try + (reset! component/registry* {}) + (let [handler (#'server/components-js-map-handler (atom {:source-map? true}))] + (testing "404 when no components are registered" + (is (= 404 (:status (handler {}))))) + (component/register-component! "m-widget" {:attrs [] :render "(fn [_ _] [:i])"}) + (testing "404 when no component carries a source map" + (is (= 404 (:status (handler {}))))) + (swap! component/registry* assoc-in ["m-widget" :source-map] + {:segments [[0 0 0 0]] :source "hyper/server_test.clj"}) + (testing "200 with the bundle's source map JSON" + (let [resp (handler {})] + (is (= 200 (:status resp))) + (is (= "application/json; charset=utf-8" (get-in resp [:headers "Content-Type"]))) + (is (= (:source-map (bundle/bundle true)) (:body resp))) + (is (string/starts-with? (:body resp) "{\"version\":3")))) + (testing "404 with :source-map? false" + (is (= 404 (:status ((#'server/components-js-map-handler (atom {})) {})))))) + (finally + (reset! component/registry* saved))))) + (deftest test-components-js-handler-no-components (testing "404 when no components are registered" (let [saved @component/registry*] (try (reset! component/registry* {}) - (let [handler (#'server/components-js-handler) + (let [handler (#'server/components-js-handler (atom {})) resp (handler {:query-params {"v" "anything"}})] (is (= 404 (:status resp)))) (finally