node-makefile · git:20260823.d083e84 · 2026-08-23 · sha256 d90d1d778914295b
node-makefile git:20260823.d083e84A
Immutable. This exact content is served forever at /api/v1/blob/d90d1d778914295b.
---
name: node-makefile
description: The Makefile for a Node Web Only project - vendoring pinned frontend assets into public/, compiling a native addon from source, verifying it loads, and the semver version calculation. Use when creating or changing the Makefile, adding a vendored asset, or wiring the native addon build. Triggers on make vendor, make verify, make setup, npm ci, npm_config_build_from_source, node-gyp, fnm, uv tool run, and fonttools woff2 compression.
user-invocable: false
---
# Node Makefile
**Vendor the frontend, compile any native addon from source, prove it loads, then hand off to the release step.**
The native-addon targets exist only when the project has one. A pure-JS app drops `verify` and the `npm_config_build_from_source` prefix and uses a plain `npm ci`.
## Assets Are Never Committed
`make vendor` produces everything under `public/fonts/`, `public/css/`, and `public/vendor/`, and the release workflow calls that same target rather than reimplementing the copies. Those directories are listed in `.gitignore`, so the tree in git holds only what a person wrote.
Every version is pinned to an exact release. A floating range makes two builds of one commit differ, and moving a pin is an edit to this file, which is the intended maintenance cost.
A stamp file guards the whole target and depends on the Makefile and the lockfile. `make bundle` on an unchanged checkout then vendors nothing, and a moved pin re-vendors everything.
## Fonts
Three families are downloaded by default and all three come from Google Fonts as woff2. Nothing is converted, since the css2 endpoint already serves woff2 to a browser-shaped User-Agent.
| Family | Role |
|---|---|
| Inter | body and UI text |
| Google Sans | display headings and branding |
| JetBrains Mono | code and monospace |
Only the `latin` and `latin-ext` blocks of each stylesheet are kept. Google Fonts declares every subset it has, which for Google Sans is twenty-five files covering scripts the page never renders, and the release bundle carries all of them. Filtering leaves six woff2 files across the three families.
The Nerd Font variant is off by default. It exists only for a page that renders Nerd Font glyphs, and it costs two megabytes and roughly ten seconds of woff2 compression against sixty kilobytes and half a second for the plain family. Set `NERDFONT := 1` in the Makefile when a page needs the glyphs; the target then fills the same `css/jetbrains-mono.css` under the same `JetBrains Mono` family name, so no page changes either way.
## Toolchain
`fnm` pins Node to `.node-version`, so `make` is run from a shell where it has activated.
`uv` supplies both the Python `node-gyp` needs and the `fonttools` used for woff2 compression, neither of which is installed globally or into the system interpreter.
## Template
```makefile
.PHONY: help setup vendor font nerdfont verify clean bundle binary version
# =============================================================================
# Variables
# =============================================================================
APP_NAME := [APP_NAME]
VERSION ?= dev-build
NODE_VERSION := 24.19.0
PUBLIC_DIR := public
VENDOR_DIR := $(PUBLIC_DIR)/vendor
CSS_DIR := $(PUBLIC_DIR)/css
FONTS_DIR := $(PUBLIC_DIR)/fonts
STAMP := $(PUBLIC_DIR)/.vendor-stamp
NERDFONT_VERSION := 3.5.0
# Set to 1 only when the page renders Nerd Font glyphs. The plain family covers
# every other case at a fortieth of the bytes.
NERDFONT := 0
MONO := $(if $(filter 1,$(NERDFONT)),nerdfont,font FAMILY="JetBrains+Mono" SLUG=jetbrains-mono WEIGHTS="400;700")
# Google Fonts serves woff2 only to a browser-shaped User-Agent; an unrecognized
# one gets ttf, which is roughly twice the bytes.
UA := Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36
# `uv tool run` is the always-present equivalent of uvx.
UVX := uv tool run
CYAN := \033[0;36m
GREEN := \033[0;32m
NC := \033[0m
# =============================================================================
# Help
# =============================================================================
help: ## Show this help
@echo "$(CYAN)Available targets:$(NC)"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " $(GREEN)%-20s$(NC) %s\n", $$1, $$2}'
.DEFAULT_GOAL := help
# =============================================================================
# Setup and the native addon
# =============================================================================
setup: node_modules vendor verify ## Install deps, vendor assets, verify the addon
# Compiling the addon from source is required on Linux, where no prebuilt exists,
# and avoids the broken prebuilt spawn-helper on macOS. PYTHON points node-gyp at
# uv's managed interpreter so nothing depends on a system Python.
node_modules: package-lock.json
@uv python install
npm_config_build_from_source=true PYTHON="$$(uv python find)" npm ci
@touch node_modules
# =============================================================================
# Vendor the frontend
# =============================================================================
vendor: $(STAMP) ## Copy JS deps and download fonts into public/
@:
# The stamp depends on this file and the lockfile, so a moved pin re-vendors and
# an untouched tree leaves `make bundle` offline.
$(STAMP): Makefile package-lock.json | node_modules
@mkdir -p $(VENDOR_DIR) $(CSS_DIR) $(FONTS_DIR)
@cp node_modules/@tailwindcss/browser/dist/index.global.js $(VENDOR_DIR)/tailwind.js
@cp node_modules/lucide/dist/umd/lucide.min.js $(VENDOR_DIR)/lucide.min.js
@cp node_modules/@xterm/xterm/lib/xterm.js $(VENDOR_DIR)/xterm.js
@cp node_modules/@xterm/xterm/css/xterm.css $(VENDOR_DIR)/xterm.css
@$(MAKE) --no-print-directory font FAMILY="Inter" SLUG=inter WEIGHTS="400;500;600;700"
@$(MAKE) --no-print-directory font FAMILY="Google+Sans" SLUG=google-sans WEIGHTS="400;500;700"
@$(MAKE) --no-print-directory $(MONO)
@touch $(STAMP)
@echo "$(GREEN)Assets vendored$(NC)"
# One Google Fonts family: fetch the stylesheet, keep the latin blocks, pull the
# woff2 files those name, and repoint the URLs at the local copies so nothing is
# fetched at run time. The other twenty-odd subsets are bytes the page never
# serves and the release bundle carries all of them.
font:
@curl -sfL -H "User-Agent: $(UA)" \
"https://fonts.googleapis.com/css2?family=$(FAMILY):wght@$(WEIGHTS)&display=swap" \
-o "$(CSS_DIR)/$(SLUG).raw"
@awk '/^\/\* /{keep = ($$0 ~ /^\/\* latin(-ext)? \*\/$$/)} keep' \
"$(CSS_DIR)/$(SLUG).raw" > "$(CSS_DIR)/$(SLUG).css"
@rm -f "$(CSS_DIR)/$(SLUG).raw"
@grep -o 'https://fonts.gstatic.com/[^)]*' "$(CSS_DIR)/$(SLUG).css" | sort -u \
| xargs -P 8 -I{} sh -c 'curl -sfL "$$1" -o "$(FONTS_DIR)/$$(basename "$$1")"' _ {}
@sed -i.bak -E 's|https://fonts\.gstatic\.com/[^)]*/([^/)]+)|/fonts/\1|g' "$(CSS_DIR)/$(SLUG).css"
@rm -f "$(CSS_DIR)/$(SLUG).css.bak"
# The Nerd Font variant carries the extra glyphs and is not on Google Fonts, so it
# comes from the nerd-fonts release as ttf and is compressed to woff2 here. The
# tar.xz holds the same fonts as the zip in a twentieth of the bytes.
nerdfont:
@set -e; tmp="$$(mktemp -d)"; trap 'rm -rf "$$tmp"' EXIT; \
curl -sfL -o "$$tmp/JetBrainsMono.tar.xz" \
"https://github.com/ryanoasis/nerd-fonts/releases/download/v$(NERDFONT_VERSION)/JetBrainsMono.tar.xz"; \
tar -xJf "$$tmp/JetBrainsMono.tar.xz" -C "$$tmp" \
JetBrainsMonoNerdFontMono-Regular.ttf JetBrainsMonoNerdFontMono-Bold.ttf; \
for w in Regular Bold; do \
$(UVX) -q --from "fonttools[woff]" fonttools ttLib.woff2 compress \
-o "$(FONTS_DIR)/JetBrainsMonoNerdFontMono-$$w.woff2" \
"$$tmp/JetBrainsMonoNerdFontMono-$$w.ttf" >/dev/null 2>&1; \
done
@{ \
for pair in 400:Regular 700:Bold; do \
printf '@font-face{font-family:"JetBrains Mono";font-style:normal;font-weight:%s;font-display:swap;src:url("/fonts/JetBrainsMonoNerdFontMono-%s.woff2") format("woff2");}\n' \
"$${pair%%:*}" "$${pair##*:}"; \
done; \
} > "$(CSS_DIR)/jetbrains-mono.css"
# =============================================================================
# Verify the native addon
# =============================================================================
verify: ## Prove the compiled addon loads and runs
@node --input-type=module -e "import pty from 'node-pty'; const t=pty.spawn('/usr/bin/env',['sh','-c','echo pty-ok'],{name:'xterm-256color',cols:120,rows:36,env:process.env}); let o=''; t.onData(d=>{o+=d;}); t.onExit(e=>{const ok=o.includes('pty-ok')&&e.exitCode===0; console.log(ok?'verify: node-pty OK':'verify: node-pty FAILED'); process.exit(ok?0:1);});"
@echo "$(GREEN)Native addon verified$(NC)"
# =============================================================================
# Release artifacts
# =============================================================================
bundle: vendor ## Assemble the runtime-bundled tarball for this platform
@bash scripts/bundle.sh "$$(node -p 'process.platform')" "$$(node -p 'process.arch')"
@echo "$(GREEN)Bundle assembled in dist/$(NC)"
binary: vendor ## Compile a single self-contained binary (pure-JS apps only)
@bun build ./bin/$(APP_NAME).js --compile --minify --outfile dist/$(APP_NAME)
@echo "$(GREEN)Built: dist/$(APP_NAME)$(NC)"
clean: ## Remove node_modules, vendored assets, and build output
@rm -rf node_modules $(VENDOR_DIR) $(CSS_DIR)/inter.css $(CSS_DIR)/google-sans.css $(CSS_DIR)/jetbrains-mono.css $(FONTS_DIR) $(STAMP) dist
@echo "$(GREEN)Cleaned$(NC)"
# =============================================================================
# Version
# =============================================================================
version: ## Print the next version, derived from the last commit message
@LATEST_TAG=$$(git tag --sort=-v:refname | head -n1 || echo "0.0.0"); \
LATEST_TAG=$${LATEST_TAG#v}; \
MAJOR=$$(echo "$$LATEST_TAG" | cut -d. -f1); \
MINOR=$$(echo "$$LATEST_TAG" | cut -d. -f2); \
PATCH=$$(echo "$$LATEST_TAG" | cut -d. -f3); \
MAJOR=$${MAJOR:-0}; MINOR=$${MINOR:-0}; PATCH=$${PATCH:-0}; \
COMMIT_MSG="$$(git log -1 --pretty=%B)"; \
if echo "$$COMMIT_MSG" | grep -q "\[major-release\]"; then \
MAJOR=$$((MAJOR + 1)); MINOR=0; PATCH=0; \
elif echo "$$COMMIT_MSG" | grep -q "\[minor-release\]"; then \
MINOR=$$((MINOR + 1)); PATCH=0; \
else \
PATCH=$$((PATCH + 1)); \
fi; \
echo "v$${MAJOR}.$${MINOR}.$${PATCH}"
```
## Notes
`node_modules` is a file target gated on `package-lock.json` and touched afterwards, so `make vendor` twice in a row reinstalls nothing. Without the `touch`, make compares against a directory timestamp that npm updates unpredictably.
`npm_config_build_from_source=true` forces `node-gyp` to compile rather than download a prebuilt binary. A prebuilt addon is compiled against a different Node ABI and a different libc than the release targets, which surfaces as a load failure at run time rather than at build time.
`verify` spawns the addon and asserts on its actual output rather than checking that a file exists. A `.node` file that is present but built for the wrong architecture passes a file check and fails on the first request.
`vendor` copies JS from `node_modules` rather than downloading it, because the version is already pinned in `package-lock.json` and a second source of truth would drift from it. Fonts are downloaded, since they are not npm packages.
Tailwind and Lucide are vendored the same way, from `@tailwindcss/browser` and `lucide` in `package.json`, so the Node frontend styles itself with the same utilities and icons as the Go one.
Every target is silent on success apart from the one line that says what it produced. A tool that narrates its own progress buries the one line a failed build needs, which is why `fonttools` has both its streams redirected and `uv` runs under `-q`.
The `version` target uses the same commit-marker convention and the same calculation as the Go Makefile. The release workflow calls `make -s version` rather than reimplementing it.
| Placeholder | Replace with |
|---|---|
| `[APP_NAME]` | the application name |
| `@xterm/xterm` copies | whatever JS the frontend vendors beyond Tailwind and Lucide |
| `node-pty` in `verify` | the project's real native addon, or delete the target |
| `NODE_VERSION` | the pinned version, matching `.node-version` |