🎉 Add the renderer facade and wire both pools in main

The malli-validated facade dispatches each render to the browser
or the headless driver by its wasm flag, sharing one leased
worker across the headless renders of a scope. The production
wiring declares both pools sized to the worker concurrency; the
wiring itself is asserted as data because booting it would spawn
a real render worker off the test bundle.

AI-assisted-by: muse-spark-1.3-contributor
This commit is contained in:
Andrey Antukh 2026-10-08 11:41:29 +00:00
parent 77ed919997
commit 373116fe33
6 changed files with 332 additions and 18 deletions

View File

@ -44,6 +44,8 @@ on this side: the row of the job, its cancel and its result are the backend's.
- `exporter.wasm.pool` mirrors the browser service for `worker_threads`: the running instance is `{:pool :timeout-ms}` (the pool plus the silence timeout its renders await), `with-worker`/`render-on`/`terminate` take the pool first, the worker factory is injected through config, and the creation handshake (`await-ready`) is its own unit with `:worker-not-ready`/`boot-error` paths.
- `exporter.renderer.browser` merges the bitmap/pdf/svg flows into one `render [ctx params on-object]` (branching by type, `on-object` always awaited, unknown types raise); `ctx` carries what the job params do not freeze (`:browser-pool`, `:base-uri`, `:public-uri`, `:svgo?`). Dead xml predicates from the old tree did not survive the merge.
- `exporter.renderer.wasm` drives headless renders off the wasm pool instance (`{:pool :timeout-ms}`): `with-scope` leases one worker and serializes its renders, and the cancel source arrives injected (`:cancel-signal`/`:cancelled?`/`:on-cancel` fns) instead of reaching the jobs registry.
- `exporter.renderer` is the malli-validated facade over both drivers: `render [env params on-object]` dispatches by `:is-wasm`, `with-scope [env exports f]` leases one headless worker for the whole call. `env` is flat (`:browser-pool :base-uri :public-uri :svgo? :wasm-pool :cancel-source`); the browser ctx is derived by `select-keys`. Params validation raises `:data-validation`.
- `exporter.main` wires both pools (`:exporter.browser/pool` and `:exporter.wasm.pool/pool`, each sized to the worker concurrency). No test boots the production wiring: the wasm pool warms one worker eagerly and the worker script in a test process would be the test bundle itself; the wiring is asserted as data and the lifecycle rides fake configs.
- Async test vars prove their chain only when really awaited: a test passing with 0 assertions means the chain broke upstream.
## Render details

View File

@ -18,17 +18,19 @@
[app.common.logging :as l]
[app.config :as cf]
[app.consumer.config :as ccfg]
;; Loaded for its init-key/halt-key methods, which is what makes the
;; browser pool part of the system below.
;; Loaded for their init-key/halt-key methods, which is what makes
;; the pools part of the system below.
[exporter.browser]
[exporter.utils.system :as system]))
[exporter.utils.system :as system]
[exporter.wasm.pool]))
(l/setup! {:exporter :info})
(def system-config
"The production wiring. Env-derived values are read here, at the
wiring layer; the components themselves take plain data."
{:exporter.browser/pool {:max (ccfg/concurrency)}})
{:exporter.browser/pool {:max (ccfg/concurrency)}
:exporter.wasm.pool/pool {:max (ccfg/concurrency)}})
;; The running system map, or nil when nothing is started.
;; Counterpart of the backend's `app.system/system` var.

View File

@ -0,0 +1,87 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns exporter.renderer
"Common renderer interface, validated with malli from day one.
Dispatches one render at a time to the browser or the headless
driver by the `:is-wasm` flag of the params. `env` carries
everything the params do not freeze: the browser context
(`:browser-pool`, `:base-uri`, `:public-uri`, `:svgo?`), the wasm
pool instance (`:wasm-pool`, the `{:pool :timeout-ms}` map the pool
service owns) and the injected cancel source (`:cancel-source`,
the `:cancel-signal`/`:cancelled?`/`:on-cancel` fns)."
(:require
[app.common.schema :as sm]
[exporter.renderer.browser :as browser]
[exporter.renderer.wasm :as wasm]))
(def schema:type
[:enum :png :jpeg :webp :pdf :svg])
(def schema:object
[:map
[:id :string]
[:name :string]
[:suffix :string]
[:filename :string]
[:share-id {:optional true} ::sm/uuid]])
(def schema:objects
[:vector {:min 1} schema:object])
(def schema:render-params
[:map
[:file-id ::sm/uuid]
[:page-id ::sm/uuid]
[:scale ::sm/number]
[:token :string]
[:type schema:type]
[:objects schema:objects]
[:is-wasm {:optional true} ::sm/boolean]
[:job-id {:optional true} ::sm/uuid]])
(def ^:private check-params
(sm/check-fn schema:render-params :hint "invalid render params"))
(def ^:private check-callback
(sm/check-fn ::sm/fn :hint "on-object must be a fn"))
(defn- browser-ctx
[env]
(select-keys env [:browser-pool :base-uri :public-uri :svgo?]))
(defn headless?
"Whether `params` renders with render-wasm rather than a browser."
[{:keys [is-wasm]}]
(boolean is-wasm))
(defn ^:async render
[env params on-object]
(check-params params)
(check-callback on-object)
(if (headless? params)
(await (wasm/render (:wasm-pool env) (:cancel-source env) params on-object))
(await (browser/render (browser-ctx env) params on-object))))
(defn ^:async with-scope
"Runs `f`, a fn of a render fn with the same signature as `render`.
Exports that render headless share one worker for the whole call
instead of acquiring one per render; the browser backend keeps
rendering them one checkout at a time."
[env exports f]
(if (some headless? exports)
(await (wasm/with-scope (:wasm-pool env) (:cancel-source env)
(:job-id (first exports))
(fn [leased]
(f (fn [params on-object]
(check-params params)
(check-callback on-object)
(if (headless? params)
(leased params on-object)
(browser/render (browser-ctx env) params on-object)))))))
(await (f (fn [params on-object]
(render env params on-object))))))

View File

@ -10,6 +10,7 @@
(:require
["node:process" :as proc]
[app.common.logging :as l]
[app.consumer.config :as ccfg]
[cljs.test :as t :include-macros true]
[exporter.browser :as browser]
[exporter.main :as main]))
@ -39,15 +40,24 @@
(defn- fake-config
[calls]
{:exporter.browser/pool {:max 1
:create-browser (fn []
(swap! calls conj :create-browser)
(js/Promise.resolve (stub-browser calls)))}})
{:exporter.browser/pool {:max 1
:create-browser (fn []
(swap! calls conj :create-browser)
(js/Promise.resolve (stub-browser calls)))}
:exporter.wasm.pool/pool {:max 1
:min 0
:create-worker (fn []
(swap! calls conj :create-worker)
(js/Promise.resolve #js {}))}})
(defn- pool-of
[]
(:exporter.browser/pool @main/system))
(defn- wasm-pool-of
[]
(:exporter.wasm.pool/pool @main/system))
(t/deftest ^:async install-process-handlers-registers-handlers
(try
(let [listeners (fn [event] (.listenerCount proc/default event))
@ -74,22 +84,27 @@
(catch :default cause
(t/is false (str "unexpected failure: " (ex-message cause))))))
(t/deftest ^:async hot-reload-start-boots-production-wiring
(try
;; No browser launches: the pool starts empty (min 0) and nothing
;; checks one out, so this boots the real config with no Chromium.
(let [started (js/Promise. (fn [resolve _] (main/on-after-load (fn [] (resolve :done)))))]
(t/is (= :done (await started))))
(t/is (some? (pool-of)))
(t/is (= :stopped (await (main/stop))))
(catch :default cause
(t/is false (str "unexpected failure: " (ex-message cause))))))
(t/deftest production-wiring-declares-both-pools
(t/testing "the wiring names the browser and the wasm pools, sized to the worker concurrency"
(t/is (= {:max (ccfg/concurrency)}
(:exporter.browser/pool main/system-config)))
(t/is (= {:max (ccfg/concurrency)}
(:exporter.wasm.pool/pool main/system-config)))))
;; NOTE: no test boots the production wiring: the wasm pool warms one
;; worker eagerly (min 1), and in this process the worker script would
;; be the test bundle itself. The hook path stays covered through the
;; fake configs below; the production map above is data, asserted
;; without booting it.
(t/deftest ^:async start-boots-and-stop-halts
(try
(let [calls (atom [])]
(t/is (= :started (await (main/start-custom (fake-config calls)))))
(t/is (some? (pool-of)))
(t/testing "the wasm pool rides the same lifecycle"
(t/is (some? (:pool (wasm-pool-of))))
(t/is (= 300000 (:timeout-ms (wasm-pool-of)))))
(await (browser/exec (pool-of) {} (fn [_] :ok)))
(t/is (= :stopped (await (main/stop))))
(t/is (nil? @main/system))

View File

@ -0,0 +1,206 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns exporter-tests.renderer-test
"The renderer facade: malli validation and dispatch to the browser
or headless drivers, against a stub browser pool and a stub wasm
pool."
(:require
["generic-pool" :as gp]
["node:events" :as events]
[app.common.transit :as transit]
[app.common.uuid :as uuid]
[app.config :as cf]
[cljs.test :as t :include-macros true]
[exporter.renderer :as renderer]))
;; --- stub browser side (bitmap only: navigate once, shoot objects) ---
(defn- stub-locator
[calls]
#js {:waitFor (fn [_opts] (js/Promise.resolve nil))
:screenshot (fn [opts]
(swap! calls conj [:screenshot (unchecked-get opts "path")])
(js/Promise.resolve nil))})
(defn- stub-page
[calls]
#js {:goto (fn [url _opts]
(swap! calls conj [:goto (str url)])
(js/Promise.resolve nil))
:waitForTimeout (fn [ms] (swap! calls conj [:sleep ms]) (js/Promise.resolve nil))
:waitForFunction (fn [_src _arg _opts] (js/Promise.resolve nil))
:locator (fn [sel] (swap! calls conj [:select sel]) (stub-locator calls))
:$$ (fn [sel]
(swap! calls conj [:select-all sel])
(js/Promise.resolve #js []))
:evaluate (fn [_f & _args] (js/Promise.resolve nil))})
(defn- stub-context
[page]
#js {:newPage (fn [] (js/Promise.resolve page))
:close (fn [] (js/Promise.resolve nil))})
(defn- stub-browser
[page]
#js {:newContext (fn [_opts] (js/Promise.resolve (stub-context page)))
:isConnected (fn [] true)
:close (fn [] (js/Promise.resolve nil))})
(defn- stub-browser-pool
[calls]
(let [page (stub-page calls)]
(gp/createPool #js {:create (fn [] (js/Promise.resolve (stub-browser page)))
:destroy (fn [browser] (.close ^js browser))
:validate (fn [_] true)}
#js {:max 1 :min 0})))
;; --- stub wasm side (one worker answering object+done) ---
(defn- stub-worker
[calls]
(let [^js emitter (events/EventEmitter.)]
(unchecked-set emitter "postMessage"
(fn [msg]
(swap! calls conj [:post (unchecked-get msg "type")])
(js/setTimeout
(fn []
(.emit emitter "message" #js {:type "object"
:payload (transit/encode-str {:id "w1" :path "/tmp/w1.png"})})
(.emit emitter "message" #js {:type "done"}))
0)))
(unchecked-set emitter "terminate"
(fn []
(swap! calls conj :terminate)
(js/Promise.resolve nil)))
emitter))
(defn- stub-wasm-pool
[calls worker]
#js {:acquire (fn [] (swap! calls conj :acquire) (js/Promise.resolve worker))
:release (fn [_] (swap! calls conj :release) (js/Promise.resolve nil))
:destroy (fn [_] (swap! calls conj :destroy) (js/Promise.resolve nil))})
(defn- stub-cancel-source
[calls]
{:cancel-signal (fn [job-id] (swap! calls conj [:cancel-signal job-id]) nil)
:cancelled? (fn [job-id] (swap! calls conj [:cancelled?-asked job-id]) false)
:on-cancel (fn [job-id f] (swap! calls conj [:on-cancel job-id f]) nil)})
(defn- test-env
[calls]
(let [worker (stub-worker calls)]
{:browser-pool (stub-browser-pool calls)
:base-uri (cf/get-internal-uri)
:public-uri (cf/get :public-uri)
:svgo? false
:wasm-pool {:pool (stub-wasm-pool calls worker) :timeout-ms 5000}
:cancel-source (stub-cancel-source calls)}))
(defn- test-params
[type opts]
(merge {:file-id (uuid/next)
:page-id (uuid/next)
:token "token"
:scale 1
:type type
:objects [{:id "a" :name "a" :suffix ".png" :filename "a.png"}]}
opts))
(t/deftest headless-reads-the-wasm-flag
(t/is (true? (renderer/headless? {:is-wasm true :type :png})))
(t/is (false? (renderer/headless? {:type :png})))
(t/is (false? (renderer/headless? {:is-wasm false :type :png}))))
(t/deftest ^:async render-routes-bitmaps-to-the-browser
(try
(let [calls (atom [])
seen (atom [])]
(await (renderer/render (test-env calls)
(test-params :png nil)
(fn [object] (swap! seen conj object) nil)))
(t/is (= ["a"] (mapv :id @seen)))
(t/is (some #(and (vector? %) (= :goto (first %))) @calls))
(t/is (not-any? #(and (vector? %) (= :post (first %))) @calls)))
(catch :default cause
(t/is false (str "unexpected failure: " (ex-message cause))))))
(t/deftest ^:async render-routes-headless-to-the-worker
(try
(let [calls (atom [])
job-id (uuid/next)
seen (atom [])]
(await (renderer/render (test-env calls)
(test-params :png {:is-wasm true :job-id job-id})
(fn [object] (swap! seen conj object) nil)))
(t/is (= [{:id "w1" :path "/tmp/w1.png"}] @seen))
(t/is (some #{[:post "render"]} @calls))
(t/is (some #{[:cancel-signal job-id]} @calls))
(t/is (not-any? #(and (vector? %) (= :goto (first %))) @calls)))
(catch :default cause
(t/is false (str "unexpected failure: " (ex-message cause))))))
(t/deftest ^:async render-rejects-invalid-params
(try
(let [calls (atom [])]
(try
(await (renderer/render (test-env calls)
(dissoc (test-params :png nil) :objects)
(fn [_] nil)))
(t/is false "render should have rejected params without objects")
(catch :default cause
(t/is (= :data-validation (-> cause ex-data :code))))))
(catch :default cause
(t/is false (str "unexpected failure: " (ex-message cause))))))
(t/deftest ^:async render-rejects-a-non-fn-callback
(try
(let [calls (atom [])]
(try
(await (renderer/render (test-env calls) (test-params :png nil) :not-a-fn))
(t/is false "render should have rejected a non-fn on-object")
(catch :default cause
(t/is (= :data-validation (-> cause ex-data :code))))))
(catch :default cause
(t/is false (str "unexpected failure: " (ex-message cause))))))
(t/deftest ^:async with-scope-shares-one-worker-across-headless-renders
(try
(let [calls (atom [])
job-id (uuid/next)
env (test-env calls)
seen (atom [])]
(await (renderer/with-scope env
[(test-params :png {:is-wasm true :job-id job-id})
(test-params :png {:is-wasm true :job-id job-id})
(test-params :png {:job-id job-id})]
(^:async fn [render*]
(await (render* (test-params :png {:is-wasm true :job-id job-id})
(fn [object] (swap! seen conj object) nil)))
(await (render* (test-params :png {:is-wasm true :job-id job-id})
(fn [object] (swap! seen conj object) nil)))
(await (render* (test-params :png {:job-id job-id})
(fn [object] (swap! seen conj object) nil))))))
(t/is (= 1 (count (filter #{:acquire} @calls))))
(t/is (= 1 (count (filter #{:release} @calls))))
(t/is (= 3 (count @seen))))
(catch :default cause
(t/is false (str "unexpected failure: " (ex-message cause))))))
(t/deftest ^:async with-scope-without-headless-renders-directly
(try
(let [calls (atom [])
env (test-env calls)
seen (atom [])]
(await (renderer/with-scope env
[(test-params :png nil)]
(fn [render*]
(render* (test-params :png nil)
(fn [object] (swap! seen conj object) nil)))))
(t/is (= ["a"] (mapv :id @seen)))
(t/is (not-any? #{:acquire} @calls)))
(catch :default cause
(t/is false (str "unexpected failure: " (ex-message cause))))))

View File

@ -18,6 +18,7 @@
[exporter-tests.main-test]
[exporter-tests.renderer-browser-test]
[exporter-tests.renderer-svg-test]
[exporter-tests.renderer-test]
[exporter-tests.renderer-wasm-test]
[exporter-tests.shell-test]
[exporter-tests.system-test]
@ -45,6 +46,7 @@
'exporter-tests.consumer-config-test
'exporter-tests.renderer-browser-test
'exporter-tests.renderer-svg-test
'exporter-tests.renderer-test
'exporter-tests.renderer-wasm-test
'exporter-tests.shell-test
'exporter-tests.system-test