mirror of
https://github.com/penpot/penpot.git
synced 2026-10-31 23:10:31 +00:00
🎉 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:
parent
77ed919997
commit
373116fe33
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
87
exporter/src/exporter/renderer.cljs
Normal file
87
exporter/src/exporter/renderer.cljs
Normal 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))))))
|
||||
@ -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))
|
||||
|
||||
206
exporter/test/exporter_tests/renderer_test.cljs
Normal file
206
exporter/test/exporter_tests/renderer_test.cljs
Normal 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))))))
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user