Fork me on GitHub
#shadow-cljs
<
2019-02-05
>
mhuebert11:02:29

hmm, was :external-config at some point explained in the docs? I recall reading about it at some point, and it saved my bacon yesterday, but I can’t find a good explanation anywhere

mhuebert12:02:59

I’ve posted a gist of one way I have been handling injection of environment variables using a shadow-cljs build hook and juxt/aero: https://gist.github.com/mhuebert/a87795a74bf3f3452e6b032f1c7ee25d

mhuebert12:02:48

It seems like a bit more ceremony than I would like but is the cleanest way I have found (so far) to satisfy all my requirements.. I would be very happy to discover ways to simplify it

thheller12:02:25

I guess it is time I finally write down how to do runtime configuration for CLJS 🙂

10
5
thheller12:02:58

:external-config and the macro approaches break the assumptions made by the caching stuff so it very likely breaks caching in some way

mhuebert12:02:20

yes, whenever that config changes, the whole app recompiles

thheller12:02:42

yeah thats the nuclear option I added for external config

thheller12:02:55

since there is no other way to tell what it may affect 😛

mhuebert12:02:24

I tried another approach where I compared the prev-config to current-config, and conditionally added just the env namespace to :cache-blockers if things had changed, but that never worked for the first compile

mhuebert12:02:47

so changes to the config while the server wasn’t running, or re-running with a different release flag, wouldn’t get picked up

mhuebert12:02:15

that actually looked like it might be a bug or nuance with how :cache-blockers works

thheller12:02:43

you can skip the hook though and just

(defn release
  "Build :browser release, with advanced compilation"
  ([] (release "local"))
  ([release-flag]
   (-> (shadow-config/get-build! :browser)
       ;; note, we add ::release-flag to our build-config, we need this later.
       (assoc-in [:compiler-options :external-config ::env] (read-env :release))
       (shadow/release* {}))))

mhuebert12:02:17

for release that would work, but not for watch

mhuebert12:02:11

for watch we need something to happen to on every recompile, no?

thheller12:02:39

well the problem here is that you are treating runtime configuration like you would in clojure

thheller12:02:56

but what you are actually turning it into is compile time constants

thheller12:02:07

which isn't actually what you want

mhuebert12:02:08

it is both runtime and compile time configuration

mhuebert12:02:18

the stuff is also exposed to macros, for DCE etc

mhuebert12:02:33

the idea is to have a single config map which gives you the same benefits as goog-define-ing things

mhuebert12:02:53

read via aero so you can pull in system environment variables or keep things in the edn directly

thheller12:02:00

you only get goog-define via actual goog-define

mhuebert12:02:20

(defmacro goog-define*
       "Like goog-define, but reads `k` from `env/config` at compile time "
       [name k default]
       `(~'goog-define ~name ~(get config k default)))

mhuebert12:02:41

^works with the above approach

thheller12:02:45

hmm ok so you use them as actual goog-defines just with different defaults

thheller13:02:39

hmm I think your hook solution is the cleanest way

mhuebert13:02:45

so the single config map is exposed as a plain map in cljs-land, and then i only use that goog-define* bit when I want DCE. We also have some clj scripts which rely on the config being available in clojure-land (generating static html from hiccup, some other deploy-related tasks)

thheller13:02:46

you just can skip the entire custom build function

thheller13:02:01

ah nope .. you have three profiles

mhuebert13:02:17

re: the custom build function, i just couldn’t figure out how to pass in my own command line argument

thheller13:02:23

meh ... I hate env variables so much for compiling stuff

thheller13:02:34

alright 2second example: in a browser app I recommend passing runtime config via the HTML

thheller13:02:52

so assuming you generate it on the server

thheller13:02:10

you call <script>your.app.init(some-json-config-data);</script>

thheller13:02:19

or you load it via xhr (depends on the app you have)

thheller13:02:54

things like the url you use in your example should probably come from the html directly since the server probably knows these things

thheller13:02:30

or if just static stuff you just have a index-dev.html index-staging.html index.html for prod

mhuebert13:02:45

yeah, server or compile environment. this is all just deployed to s3 but we generate the index.html from hiccup in a post-compile build hook

mhuebert13:02:16

so it would just be index.html but generated specifically for the current env

thheller13:02:08

this is what I do

mhuebert13:02:25

and currently that build hook can seamlessly read from the same app.env/config var that is exposed to cljs

mhuebert13:02:02

and we can also put things like :devtools false in the same config map, which needs to be a compile-time flag to keep stuff out of prod builds

thheller13:02:16

do you have an example of something that should actually be a compile time constant?

thheller13:02:29

ie. something that affects DCE?

mhuebert13:02:31

well, our devtools flag

mhuebert13:02:07

we have an additional tab that is only visible in dev and sometimes staging, and includes a bunch of extra stuff that we don’t want bundled always

thheller13:02:31

would that not be solved by using :preloads [your.app.dev-stuff]?

mhuebert13:02:09

hmm. not in the way it currently works, it is used as a conditional in a few places

thheller13:02:12

or do you constantly check (when devtools (do-more-stuff))?

mhuebert13:02:40

eg. in routing (cond-> routes env/devtools? (merge {.. dev routes ..}))

thheller13:02:15

hmm that for example probably won't be properly DCE'd by closure

thheller13:02:52

did you verify that is actually removed and not just disabled?

thheller13:02:07

or is that a pure closure-define?

mhuebert13:02:35

that is a closure-define

mhuebert13:02:54

using that goog-define* macro which reads from the env map at compile time

mhuebert13:02:16

so, a “pure” closure-define but derived from our canonical config

thheller13:02:07

sure. you don't actually gain anything by making it a goog-define since you are never redefining it

thheller13:02:44

just something that ends up as (def devtools? false) would have the same effect as far as closure is concerned

thheller13:02:07

eg. (def devtools? (get-config :devtools false))

mhuebert13:02:16

ah. that is good to know

thheller13:02:42

if the get-config is a macro and just emits literals you don't need extra goog-define gimmicks

thheller13:02:59

hmm although ...

thheller13:02:15

hmm I'm not sure I can think of a clean way to do this

thheller13:02:15

do you ever use watch with a non-dev release-flag?

mhuebert13:02:52

yeah, sometimes I use a prod release flag if i want my local build to talk to the prod api server

thheller13:02:06

you could always skip all this entirely

thheller13:02:34

and just generating a static src/app/env.cljs before starting the compile

thheller13:02:53

no macro trickery and compiler magic required 😛

mhuebert13:02:22

hm. if that is done in a build hook, would shadow notice changes to that file “in time” to invalidate its cache if it has changed?

mhuebert13:02:02

if config variables are read from a static/unchanging map, can DCE still happen? eg. (when (:devtools static-config) ...)

thheller13:02:05

hmm good point

thheller13:02:17

no that won't work

thheller13:02:44

but the code you generate can just emit a static variable

thheller13:02:28

generating something like

(ns app.env)

(def devtools? false)

(def config {:url "foo.bar"})
is easy enough

mhuebert13:02:22

yeah.. that code-generation looks to me more complicated than what we are doing, where there is a straightforward config map

thheller13:02:51

with lots of macro trickery 🙂

mhuebert13:02:22

yeah. although, the macro trickery has a straightforward API once it’s set up, and there is no uncertainty about where a given environment variable is coming from. if you know the release flag, there is just one edn file where you can always look things up. and changes are picked up during recompile

thheller13:02:23

hmm can't really think of anything other than what you already do

thheller13:02:32

I could add an option that lets you modify the config before it is used

mhuebert13:02:32

so maybe it is the most minimal thing that solves for the constraints/requirements we’ve accepted

mhuebert13:02:39

is :cache-blockers supposed to work on first compile?

thheller13:02:50

but that still wouldn't solve the extra "release-flag" argument

thheller13:02:25

:cache-blockers complete blocks all cache for the namespaces mentioned. yes it works on the first compile

mhuebert13:02:55

npm run lets you add command line parameters after a --, which are then passed to the underlying script, eg. npm run watch -- staging. that’s actually what we have set up. i could imagine something similar for shadow, allowing parameters at the end to be passed in as command-line-args or something, but I haven’t thought a lot about that.

thheller13:02:41

sure that would be easy but where would you read them?

mhuebert13:02:51

ok. I think there might be a bug there, as my previous attempt was dynamically setting :cache-blockers in :compile-prepare stage, and on the first compile it wasn’t recompiling everything that I added to it

mhuebert13:02:04

I would read the *command-line-args* from build hooks

thheller13:02:07

you can't really modify the config on :compile-prepare

mhuebert13:02:36

ok. in :compile-prepare is where I am setting :external-config

mhuebert13:02:11

that’s where I re-read the environment and update app.env/config

thheller13:02:00

hmm yeah but the watch logic will not trigger recompiles if you modify the config there

mhuebert13:02:43

hm. i guess that could explain why it wasn’t working on the 1st compile, but seemed to work later

thheller13:02:31

you can just set (ns ^:dev/always app.env) to never cache it

mhuebert13:02:39

that slows everything down a lot

thheller13:02:04

cache-blockers does the same no?

mhuebert13:02:21

yeah, but i was only adding it to :cache-blockers when the config changed

mhuebert13:02:32

(-> build-state
        (assoc ::prev-config public-config)
        (update-in [:shadow.build/config :cache-blockers] (if env-changed?
                                                            #(conj % 'web3.env)
                                                            #(remove #{'web3.env} %))))

thheller13:02:47

that has no effect

mhuebert13:02:56

that seemed to work as intended except for the 1st compile

mhuebert13:02:14

but maybe it was always only affecting the next compile?

thheller13:02:12

so assuming the file is not compiled and cached already

thheller13:02:28

in watch the file will be compiled once and after that kept in the compiler state

thheller13:02:47

if the file is touched on the filesystem the version in the compiler state is invalidated

thheller13:02:06

if a file is in the compiler state and not otherwise affected it won't be recompiled

thheller13:02:28

so if you add cache-blockers after it is already compiled nothing happens?

thheller13:02:09

ah no it works but since the first compile was cached and written to disk

thheller13:02:23

the next time you compile if can load that

thheller13:02:41

meh this makes my head hurt even thinking about it 😛

mhuebert13:02:52

i think i need to have a look at the source

thheller13:02:58

hmm no I can't see how changing cache-blockers affects anything once a file is compiled and in memory

thheller13:02:25

config changes typically trigger a fill reconfigure and recompile

thheller13:02:35

source file changes only trigger a partial recompile

mhuebert13:02:59

you mean this part

(let [output (get-in state [:output resource-id])]
    ;; skip compilation if output is already present from previous compile
    ;; always recompile files with warnings
    (if (and output (not (seq (:warnings output))))
      output
      (maybe-compile-cljs state src)
      ))

mhuebert13:02:11

well, now i am wondering how it was working before

mhuebert14:02:15

so seq-compile-cljs-sources uses that code, par-compile-cljs-sources calls maybe-compile-cljs directly without that output check but is not used for partial incremental compiles

mhuebert14:02:18

unless i am misreading

thheller14:02:19

hehe indeed

thheller14:02:03

ah nvm. it does the same logic though

mhuebert14:02:36

well, this isn’t so important anyway, for our purposes :external-config is fine, and we can live with recompiling everything when config changes, as that is not so often. I guess in a hyper-optimised world we would figure out how to add a particular namespace to the list of things-to-recompile from within a :compile-prepare hook.

mhuebert14:02:33

(update state :output dissoc resource-id)?

thheller14:02:44

would something like shadow-cljs release app --config-hook "(your.ns/some-fn 1 2 3)" help?

thheller14:02:09

so (your.ns/some-fn config-from-file 1 2 3) would be called whenever the config is loaded?

thheller14:02:59

yes if you remove the :output it will recompile

thheller14:02:38

(shadow.build.data/get-source-id-by-provide state 'app.env) will you you the proper resource-id

thheller14:02:24

probably should use (shadow.build.data/remove-source-by-id state resource-id) instead of just removing the output

mhuebert14:02:40

if we remove ’app.env, would namespaces that depend on app.env also recompile?

thheller14:02:21

depends on if it was cached before

mhuebert14:02:30

i am thinking about the --config-hook thing, i don’t fully follow how that fits in

thheller14:02:49

since the logic will load a cached file from disk when not in memory

thheller14:02:07

if the cache logic decides that the disk cache was ok then no recompiles happen at all

thheller14:02:20

(not even app.env)

mhuebert14:02:52

hmm. so this wouldn’t really trigger a recompile then, because the file isn’t changing

mhuebert14:02:10

we would need to remove it from the output and also add it to cache-blockers

thheller14:02:03

do you actually need app.env/config on the clojure side?

mhuebert14:02:55

yes - not just the DCE stuff (ie. in macros) but also scripts that generate HTML, interop with Sentry

mhuebert14:02:08

which are all triggered in build hooks

thheller14:02:49

right now it sounds like you are abusing builds hooks a little bit too much 🙂

mhuebert14:02:01

like

:build-hooks [(app.build/load-env)
                                  (app.build/compile-static-assets-hook)
                                  (app.sentry/push-source-maps!)]

mhuebert14:02:02

they are reliable workhorses

thheller14:02:08

why not

(defn release
  "Build :browser release, with advanced compilation"
  ([] (release "local"))
  ([release-flag]
   (-> (shadow/get-build-config! :browser)
       ;; note, we add ::release-flag to our build-config, we need this later.
       (assoc ::release-flag release-flag)
       (shadow/release* {})
       (compile-static-assets)
       (send-source-maps-to-sentry)
       )))

thheller14:02:53

seems overkill to run all the hooks all the time in watch? granted I don't know what you do in them but probably the first thing you do is check if they should actually run?

mhuebert14:02:13

yeah, the sentry fn starts with (when (= :release (:shadow.build/mode build-state)) ...)

mhuebert14:02:38

the static assets are affected by changes to config, so i want those to recompile, and all the heavier bits are memoized so it adds max handful-of-milliseconds

mhuebert14:02:45

i previously had these things in the watch/release functions like you have there

thheller14:02:19

what made you move them into hooks?

thheller14:02:15

I like how far you are pushing things with hooks and stuff though

thheller14:02:26

interesting to see what they are used for 🙂

thheller14:02:05

seems all ok ... just probably want something cleaner for the command line stuff

mhuebert14:02:24

before, there were multiple commands that had to be run / coordinated at the right time, in order to get a correct build

mhuebert14:02:57

now, running the shadow build ensures that everything is created declaratively / at the right time / using a consistent environment

mhuebert14:02:40

it is not really possible to compile the app without simultaneously creating all the appropriate assets

mhuebert14:02:28

it has made it easier for onboarding / booting up in new environments, there is little to remember

mhuebert14:02:42

I think at one point I was hoping to do away with the custom release/watch scripts altogether,

mhuebert14:02:50

the only reason they are there is for the release flag

mhuebert14:02:19

overall the build hooks let me substantially simplify our whole build/deploy process

mhuebert14:02:51

the scripts are hid behind npm scripts, so in practice we run npm run watch -- staging and then it delegates to shadow’s clj-run

thheller14:02:02

I have been thinking about ways to make configs adjustable from the command line

thheller14:02:17

ie. load the config from the file but also merge in extra values

mhuebert14:02:25

that has been helpful as i have been able to evolve how this stuff works without breaking anyone’s habits

thheller14:02:36

I could add shadow-cljs release browser --config-merge '{:some "data"}' for example

thheller14:02:19

cljs.main lets you specify multiple --compile-opts and merges them together

thheller14:02:26

that is a good idea imho

mhuebert14:02:27

hmm that could be useful. would that be added to shadow’s config and validated according to that schema, or could we put arbitrary keys there

thheller14:02:52

you could add anything. config spec validation is open.

thheller14:02:26

I would probably have outsourced the html generation to an external config somewhere but if you like it there that is fine 🙂

mhuebert14:02:15

for any real project i would do that

thheller14:02:18

I've been thinking about this whole static site generation business a lot the past few weeks

👍 5
mhuebert14:02:30

but for just a quick sketch i didn’t want to have to create an extra file

mhuebert14:02:56

and 90% of the time for these cljs projects, all i need in terms of HTML is a title, and a couple style tags and script elements

aisamu15:02:42

For one-off experiments I use the test runner and overwrite its DOM with cljs 😂

mhuebert14:02:26

what kind of use-cases are you focused on?

thheller14:02:16

nothing in particular. just looking at what the rest of the world is doing/using

thheller14:02:30

it all doesn't really fit what I'm doing for work stuff

thheller14:02:12

just interested to see what others are doing really

thheller14:02:55

I have probably never written anything that didn't have a massive backend that was at least as big as the frontend

mhuebert14:02:27

that function in the build hook, write-assets!, makes use of the :output-dir and :asset-path in the shadow build config, and in :release mode it appends content hashes to paths

thheller14:02:53

but the "get something simple out" part for CLJ(S) still sucks compared to something like gatsby or so

gklijs20:02:18

I'm about to transform what's now a tiny cms with nginx-clojure to a static site. First goal is just static html, which should be quite easy, since the back-end is already set up to generate the whole page with just the key of the main content. The cljs part is really simple apart from the cms stuff, just some menu things and image modals.

thosmos22:02:07

My current app is basically a static thing hosted on Firebase hosting and using Firebase firestore. The goal will be to generate static HTML for each route and use code splitting and module loading per route change, basically like https://zeit.co/now, except on firebase. I'm looking into ways to generate the HTML. I'll probably wind up doing it the same way I did last time with a headless browser that spits out the HTML.

thosmos22:02:40

would be cool if someone made a shadow-cljs builder for Now

thosmos22:02:11

I looked into doing it, but seemed like more than a few hours of work.

thheller22:02:20

I did some experiments with zeit but as of now I wouldn't want to build a builder for it

thheller22:02:41

you are only allowed 100mb cache for builds which isn't even enough for the JVM you'd need to download

thheller22:02:08

compiling things locally and just using now tools to push them seems best

thheller14:02:15

(granted that I always have a server that consumes that file at runtime)

mhuebert14:02:20

this project has a big backend for the api server, but it doesn’t generate any html, the web app is all on s3

mhuebert15:02:06

so deployment happens via aws CodeBuild, a remote box performs the shadow build and spits all the html / js to disk

mhuebert15:02:19

all triggered by pushing to specific branches on GitHub

mhuebert15:02:01

I have used manifest.edn as well. i ended up writing something more general because we needed the same cache-busting stuff for generated html and css also

mhuebert15:02:58

our hosting is configured to cache everything except index.html et al very aggressively

thheller15:02:16

yeah makes sense. Just surprised that shadow-cljs is used to drive it all

aisamu15:02:42

For one-off experiments I use the test runner and overwrite its DOM with cljs 😂

souenzzo18:02:59

My JVM returns

(System/getProperty "java.version")
=> "11.0.1"
There is 2 ways to get "11" in my system
{"java.specification.version" "11"
 "java.vm.specification.version" "11"}
https://github.com/thheller/shadow-cljs/blob/1485cbefc62d781ca5bc1ddf3464833c75771f2e/src/main/shadow/cljs/devtools/server/npm_deps.clj#L12