Build cache
This page is for anyone wiring ttsc into CI, a container image, or any environment that starts from a clean filesystem. It covers what the build cache holds, what invalidates it, and how to persist it correctly.
If you only want to know where the cache lives locally or how to delete it, Compile → Plugin cache is the short version.
What is cached
ttsc compiles source plugins to native binaries. A plugin such as typia ships Go source, and the first build in a fresh environment compiles it, which is the cost this cache exists to remove. The cache holds two things: the compiled plugin binaries under <root>/plugins, and the Go object cache used while building them under <root>/go-build.
Both are build outputs, not inputs. Deleting the cache is always safe. The next build recompiles.
The cache is content-addressed
A cached binary is reused only when a key computed from the build inputs matches exactly. Those inputs include the ttsc version, the resolved typescript version, platform/arch, the Go toolchain’s content, the effective Go build environment, and the plugin’s own source bytes. Architecture → Cache Key Inputs lists all nine in order.
The key you give actions/cache affects your hit rate, never your correctness. A restored cache that does not match misses on the internal key and rebuilds. A cache keyed too narrowly costs you a rebuild you did not need; one keyed too broadly costs you a rebuild you did not avoid. Neither can hand you a binary built from different inputs than the ones you are building with.
It is specific to OS and architecture
The cached artifact is a native executable, and the key reflects that twice: platform=${process.platform}/${process.arch} is a direct input, and GOOS and GOARCH enter through the Go build environment. A binary built on linux/x64 is never served to a linux/arm64 run.
A CI cache key must therefore separate architectures, not only operating systems. See Key the cache correctly.
What invalidates it
For a typical consumer project, the dependency updates that matter are narrower than they look:
| Package | Invalidates the plugin binary | Why |
|---|---|---|
ttsc | Yes | Explicit version input, and its shim overlay source is hashed. |
typescript | Yes | It is the resolved tsgo version input. |
A plugin such as typia | Yes | Its Go source directory is hashed byte for byte. |
@ttsc/unplugin | No | A JS bundler adapter. It contributes nothing to the Go build. |
Hashing the lockfile in a CI cache key covers all of these without enumerating them. The cost is an occasional miss when an unrelated dependency moves, which is usually the right trade.
Plugin options do not invalidate the binary. Lint rules, banner text, calls, and CLI flags such as --emit or --outDir are read at run time and are not part of the key.
GitHub Actions
The default cache root is node_modules/.cache/ttsc, and you can persist it directly. The ordering is the part that matters.
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- run: npm ci
- uses: actions/cache@v4
with:
path: node_modules/.cache/ttsc/plugins
key: ${{ runner.os }}-${{ runner.arch }}-ttsc-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-ttsc-
- run: npx ttsc prepare
- run: npm run buildWhich paths to persist
Persisting the compiled plugin binaries is sufficient to skip a cold source-plugin build. The Go object cache is a separate optional accelerator: it helps the next time a source change or dependency bump produces a plugin-binary cache miss, but it is not consulted when the compiled binary already matches.
npx ttsc cache paths --json reports requiredRoots for compiled binaries and acceleratorRoots for Go objects. Persist requiredRoots by default. Add acceleratorRoots only when faster cold rebuilds justify its larger cache entry. The older cacheableRoots field remains as a compatibility union for existing scripts; it is not the minimal required set. This works the same for npm, pnpm, yarn, and bun, and for workspaces under any of them.
Why the cache step goes after the install
npm ci removes node_modules before it installs, and .cache lives inside it. The conventional workflow shape puts cache restore above install, which means the restore lands and the install immediately deletes it.
Nothing errors when that happens. ttsc prepare cold-builds, the post step saves an entry identical to the one that was just discarded, and every run pays the full compile while the logs look healthy. Read Confirm the cache is working rather than assuming a cache step is enough.
npm ci is the documented case, and other package managers may reconcile node_modules in place rather than deleting it. Rather than track which does what, put the cache step after the install in every workflow. That ordering is correct under all of them.
Key the cache correctly
runner.os yields only Linux, macOS, or Windows. That no longer separates runners: arm64 Linux runners exist, and macos-14 and later are arm64 while macos-13 is x64. A matrix spanning architectures under an os-only key puts binaries from two targets in one entry.
Nothing breaks, because the internal key still refuses the mismatch. What happens instead is that the first job to finish wins the save, and every job on the other architecture restores binaries it cannot use and rebuilds cold on every run. Include runner.arch:
key: ${{ runner.os }}-${{ runner.arch }}-ttsc-${{ hashFiles('package-lock.json') }}restore-keys is worth keeping. On a lockfile bump the primary key misses, but the previous entry can still contain a compiled binary for an unchanged internal content key. If you also persist acceleratorRoots, the restored Go objects make a real binary miss incremental instead of fully cold.
The cache saves only when the key misses
actions/cache writes in its post step only when the primary key was not found on restore. An exact hit saves nothing.
A lockfile-derived key therefore already gives you the policy most projects want: the cache is written when a dependency actually moved, and not otherwise. There is no need to gate the save step by hand.
When to move the cache root
Once the install and the build live in separate jobs, or the cache step has to sit above the install for reasons of its own, ordering cannot help and the root has to leave node_modules:
env:
TTSC_CACHE_DIR: .ttsc-cache/ttscThen cache .ttsc-cache/ttsc/plugins as the required artifact. Add .ttsc-cache/ttsc/go-build only as the optional accelerator. Point the root at a directory dedicated to ttsc whose basename is ttsc: ttsc clean always removes its plugins/, and that basename is the safety proof that lets it remove the nested go-build/ too. A shared location such as ~/.cache is therefore the wrong target.
Relocating costs you automatic cleanup. In the default project-local cache, ttsc prunes plugin binaries unused for 30 days and runs LRU pruning from a 2 GiB ceiling toward 1.6 GiB. It checks at most daily while idle and again after every cold binary publication, so a fresh daily marker cannot hide the binary just added. The binary returned by that cold build and entries with active build generations are excluded; if those safety exclusions keep the root above the ceiling, the marker schedules another pass instead of deleting an entry that may be about to execute. ttsc applies the corresponding policy to the Go object cache after every cold source-plugin build attempt, including a failed build, pruning from an 8 GiB ceiling toward 6 GiB. The newest target-sized Go-object cohort remains protected for the requested hour plus Go’s one-hour access-mtime coalescing tolerance. Active builds and maintenance publish heartbeat records. A stopped build heartbeat retains a one-hour grace for an orphaned Go child; completed maintenance is retired immediately, and an abandoned maintenance heartbeat expires after one minute. A record dated implausibly far in the future is rebased and receives one ordinary grace period: a live owner refreshes it, while an orphan then expires normally. A root named explicitly through TTSC_CACHE_DIR or --cache-dir is never garbage collected, on the assumption that a caller who named a path owns it. TTSC_GO_CACHE_DIR follows the same caller-owned lifetime, and an ambient user GOCACHE is never modified or removed by ttsc.
pnpm
The same shape, with the lockfile and the install command swapped:
steps:
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- uses: actions/cache@v4
with:
path: node_modules/.cache/ttsc/plugins
key: ${{ runner.os }}-${{ runner.arch }}-ttsc-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-${{ runner.arch }}-ttsc-
- run: pnpm exec ttsc prepare
- run: pnpm run buildIn a pnpm workspace the cache root resolves to the workspace root’s node_modules, so one entry covers every package. Run ttsc prepare from the project directory the build actually uses, so the warmed binary matches the key that build looks up.
Containers
A container image build (Docker, Google Cloud Build, Cloud Run) is not a persistent runner: each build starts from a clean filesystem, so a source plugin recompiles cold on every deploy unless the compiled binary is carried forward. This is a different mechanism from the CI recipes above. There the cache is a directory persisted with actions/cache; here it has to travel inside the image build, as a build layer or a build-scoped cache.
Warm the binary in its own layer, ordered before the app source is copied, and pin TTSC_CACHE_DIR at a stable absolute path whose basename is ttsc, so ttsc clean still owns its plugins/ and go-build/:
FROM node:24
WORKDIR /app
ENV TTSC_CACHE_DIR=/root/.cache/ttsc
COPY package*.json ./
RUN npm ci
COPY tsconfig*.json ./
RUN npx ttsc prepare # the cold Go build happens here, once
COPY . .
RUN npm run build # the bundler build reuses the warm cachettsc prepare builds whatever tsconfig.json declares, so tsconfig.json must be copied before it runs, or the build fails to find a project. A bundler build through @ttsc/unplugin reads the same tsconfig, so the warmed binary is the exact one the later build looks up. If prepare prints no source plugins found, the plugin is declared only in the bundler config; add it to tsconfig.json compilerOptions.plugins so both steps compute the same content key.
An ephemeral builder (Google Cloud Build, most CI Docker builds) discards its local layer and BuildKit cache between builds, so the warm layer is reused only through a cache that outlives the build: a registry-backed layer cache (docker build --cache-from <pushed-image> --build-arg BUILDKIT_INLINE_CACHE=1, then deploy the built image) or a bucket-backed cache. A bare RUN --mount=type=cache or plain layer caching starts empty on every fresh build VM and silently recompiles. gcloud run deploy --source passes no cache flag and, under buildpacks, offers no layer to hold the binary; to reuse it you must take over the build (gcloud builds submit with a registry cache, or a prebuilt base image) rather than deploy from source.
On GitHub Actions, docker/build-push-action reaches the same registry-backed cache through the Actions cache backend. Set cache-to: type=gha,mode=max so the intermediate ttsc prepare layer is exported, not only the final image: the default mode=min caches just the last layer, so the plugin recompiles on every run.
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
cache-from: type=gha
cache-to: type=gha,mode=maxConfirm the cache is working
A cache that never hits is indistinguishable from one that works, unless you look. On a cold build ttsc prints:
ttsc: building source plugin ... this runs once per cache keyOn the second run of an unchanged workflow, or the second build of an unchanged image, that line must be absent. If it appears every time, the cache is restoring nothing useful.
In CI, check that the cache step runs after the install, that the key includes runner.arch, and that the persisted paths match requiredRoots from npx ttsc cache paths --json (plus acceleratorRoots only when chosen). In a container build, check that the builder has a cache that outlives the build at all, and that cache-to is set to mode=max so the ttsc prepare layer is exported rather than only the final one.
See also
- Compile → Plugin cache: cache locations, overrides, and
ttsc clean. - Architecture → Cache Key Inputs: the full key definition, for plugin authors.
- Setup → Bundlers: the bundler transform cache, which is a separate mechanism.