Search code examples
gnu-make

Typescript workflow using GNU Make


I cobbled together a very light-weight process of bundling JS files built from Typescript. TLDR: I run tsc to generate js/foo.js for every src/foo.ts. Then I bundle the JS files using esbuild. Here's the rough dependency graph:

pkg/         js/                             src/
  a.js   ->    a.js   -.               .->     a.ts
  b.js   ->    b.js   --> .compiled   -+->     b.ts
  c.js   ->    c.js   -'               '->     c.ts

Because tsc, like Java, compiles the whole code base at once, unconditionally, rather than individual source files, I've used a .compiled file as an intermediate proxy to indicate tsc compilation.

And here's the watered-down but standalone, functional, Makefile (slightly edited since original post):

.PHONY: bundle
bundle: .bundled

src_stems := a b c
ts_files := $(addsuffix .ts,$(addprefix src/,$(src_stems)))
js_files := $(addsuffix .js,$(addprefix js/,$(src_stems)))
pkg_files := $(addsuffix .js,$(addprefix pkg/,$(src_stems)))

.bundled: $(pkg_files)
    touch $@

pkg:
    mkdir -p $@

pkg/%.js: js/%.js | pkg
    cp $< $@

$(js_files): .compiled

# Simulate running tsc
.compiled: $(ts_files)
    mkdir -p js
    touch $(js_files)
    touch $@

.PHONY:
clean:
    rm -rf pkg js .compiled .bundled

Everything works as expected upon make clean bundle.

$ mkdir -p src
$ touch src/{a,b,c}.ts
$ make

However, I'm surprised at the behaviour when bundling after single file update. For example, running this after a clean build:

$ touch src/b.ts # I modify a single source
$ make
mkdir -p js
touch js/a.js js/b.js js/c.js
touch .compiled
cp js/b.js pkg/b.js    <- Note a.js isn't being bundled!
cp js/c.js pkg/c.js
touch .bundled

And, if I run make again, the missing file is caught up:

$ make
cp js/a.js pkg/a.js
touch .bundled

I tried debugging with make -d but can't figure out why, when I have partial updates, it takes two runs to get a.js bundled. Note also that it's always a.js that's missed out, which I suppose is because it's the first source to be listed.

Some debug lines from make -d:

     Prerequisite 'js/a.js' is older than target 'pkg/a.js'.
...
     Prerequisite 'js/b.js' is newer than target 'pkg/b.js'.
...
     Prerequisite 'js/c.js' is newer than target 'pkg/c.js'.

The diagnostic for pkg/a.js line is at odds with the rest. In fact, all the js/*.js files will have the same timestamp.

What am I doing wrong? Are there other set of rules I should consider?


Solution

  • This line:

    js/a.js js/b.js js/c.js: .compiled
    

    is not sufficient. It tells make there's a prerequisite, but it doesn't provide any recipe. You want to have a recipe so that it's a full rule, not just a prerequisite declaration. Adding a semicolon would be enough (I believe):

    js/a.js js/b.js js/c.js: .compiled ;
    

    alternatively you could add something like this if that's more understandable:

    js/a.js js/b.js js/c.js: .compiled
            # do nothing
    

    Just a note: it would be simpler to read your example (and realize it's correct) if you assigned the source files to a variable and used that variable, instead of rewriting all the files every time, where we have to carefully parse them to be sure they're not mistyped.