Skip to Content

Build cache

This page is for anyone sharing a cache across Git worktrees, wiring ttsc into CI, or building a container image. 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

ttsx keeps completed project emit documents under <root>/runtime-builds by default. Each document contains an input-content manifest, compiler-produced source ownership, output bytes and a checksum. Completed documents are published atomically. A source-runtime hit normally transfers verified output into one private run-owned bundle, reserving its output directories and reading individual JS/maps only when needed. Native directory aliases resolve through their actual parent identity; a distinct sibling directory cannot supply private output coordinates. Outputs requiring native map lookup or colliding with that artifact retain ordinary file restoration. Default cache collection and ttsc clean include persistent documents; an active run retains its independent bytes. Explicit cache roots retain their existing caller-owned lifetime. Execute describes validation, source-path behavior, TTSC_EXPLAIN_CACHE=1 and --no-cache.

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 compiled plugin binaries under <root>/plugins and Go objects under <root>/go-build. An opted-in publisher package cache uses <root>/package-artifacts.

The same root also keeps plugin descriptor evaluation results under <root>/descriptors. An uncached CommonJS descriptor runs in a separate runtime process. Its answer can be kept together with the content and physical path of every module it loaded and every candidate it probed.

A later launch reuses the answer only when the descriptor, its factory context, the complete environment, the runtime executable and the ttsc version match and the recorded inputs still match their observed states. An evaluation that printed anything is not kept, because a reused answer prints nothing.

Only a descriptor that declares the files it reads outside its module graph has its answer kept. It declares them by returning hostInputHashes, with a fingerprint for each file it read, or {} when it read none. A descriptor without that declaration may have read anything, and Node 22 has no way to report such a read to ttsc, so it is evaluated again by every launch on every Node release. The same holds for a descriptor that lists a hostInputs entry without a fingerprint, which is how the protocol reports an input whose state it could not prove. The capability answers under <root>/capabilities follow the same rule. The declaration is described in the plugin protocol.

The environment in a descriptor answer’s key is complete, because no supported Node release can report which variables an evaluation read. A ttsc launched from a program running under ttsx inherits that run’s own variables, such as the path of its runtime manifest, so its answers are not shared with other runs.

Capability answers are not reusable when NODE_OPTIONS selects additional runtime authority. This rule also applies to asynchronous discovery: Windows environment names are case-insensitive, including a lowercase node_options, while POSIX names remain case-sensitive. Each worker request adopts the caller’s current environment and removes variables absent from that request.

A launch whose plugin binary is already cached still has to prove that its inputs did not change. It keeps what it learned under <root>/identities and <root>/answers, so the next launch does not read every byte of the plugin’s Go source, the installed ttsc overlay, the Go toolchain and the JavaScript runtime again, or run go list to rediscover which Go package each plugin entry is and go mod edit to parse an unchanged go.mod. An identity records the digest of one of those file populations with the metadata it was read under. An answer, such as a Go package selection, a go.mod parse or a runtime’s measured capabilities, is stored under the identities of everything it depends on. The same records serve every later process that proves a plugin source state the load reported: @ttsc/unplugin workers, capability hosts such as @ttsc/lint and @ttsc/graph, and ttscserver confirm the sources and the Go toolchain from metadata instead of reading them again. ttsx proves the TypeScript compiler that keys its runtime builds the same way, and a ttsc --watch session takes the starting state of each plugin source it watches from the same records.

A recorded digest is reused only while every file in its population still has the recorded identity, size and timestamps, and each modification time is older than a reference the launch writes on the same drive. An edit changes the metadata, and an edit inside the same clock tick as the recorded one fails the reference check, so in both cases the files are read again. The launch writes its references into the cache itself and into a private ttsc-clock-references directory of the current user below the system temporary directory, so files on any other drive are always read.

ttsx and ttsc/register also keep the TypeScript they lower for a package that no tsconfig owns, such as one shipping raw .ts under node_modules, under <root>/ttsx-orphan, keyed by the source bytes and the compiler that lowered them.

Publisher package artifacts

The optional publisher package cache supplies exact Go package actions to a cold source-plugin build. It is off by default. Set TTSC_PACKAGE_ARTIFACTS=1 to try it when a release provides a pinned artifact manifest. The current release does not ship a manifest, so the ordinary Go cache remains the default and an opt-in without an operator manifest reports manifest-unavailable.

Unset TTSC_PACKAGE_ARTIFACTS or set it to 0 to disable preparation on the next cold build. An already validated executable remains eligible for its normal cache hit. Release maintainers can revoke a bad artifact generation by omitting its manifest pin from the next platform package, or by issuing a new pinned manifest after rebuilding and validating its packs. Consumers can disable the backend immediately and use ttsc clean after active native tasks settle to reclaim its local files.

For a separately published manifest, set TTSC_PACKAGE_ARTIFACT_MANIFEST to its absolute local path and TTSC_PACKAGE_ARTIFACT_SHA256 to a digest obtained from a trusted release record. A digest copied from the downloaded manifest does not establish who published it. TTSC_PACKAGE_ARTIFACT_HELPER can name an absolute prebuilt helper when testing a separate installation. The platform package supplies the helper in normal installations; no package-install compile is required.

The supported tuple is a stock Go 1.26 or 1.27 patch release with effective CGO_ENABLED=0, empty GOFLAGS, matching target architecture and matching tool bytes. ttsc preserves your Go environment. A different tuple, an existing GOCACHEPROG, missing packs or failed optional preparation uses ordinary Go compilation before the build starts. Once Go starts with the helper, a helper failure fails that build; retrying the same invocation through another cache would hide the error. A cache hit still requires Go’s full ActionID, and the final native link still runs.

The platform package inventory is Windows x64/arm64, Linux x64/arm64/armv6 and macOS x64/arm64. Each non-graph package builds a host cache helper. Artifact coverage is declared per manifest tuple, independently of helper availability. The only verified experimental pack targets Windows x64, Go 1.26.0 and CGO_ENABLED=0; the other six targets and Go 1.27 have no verified packs yet. A build with CGO enabled uses the ordinary cache because Go’s action identity does not include every external C library or header input. A project on TypeScript 7.1 also always uses the ordinary cache. Its plugins build with the ttsc_typescript_7_1 tag in GOFLAGS, which is outside the supported tuple, so a 7.1 cold build compiles without packs.

Selected packs are downloaded before Go starts and held under <root>/package-artifacts; Go cache gets use local files without network requests. An already cached plugin executable never prepares packages. The opt-in build prints a short preparation or fallback reason. Each missing pack starts one HTTP GET with a two-minute timeout and no automatic retry; at most four redirects to HTTPS or loopback destinations are allowed. Preparation reports transfer, extraction and admission time plus logical base, overlay, staging and pinned bytes. One selection permits at most 512 MiB compressed and 4 GiB decoded; new base admission stops above 8 GiB, and staging is limited to 5 GiB. These snapshots do not impose a hard disk ceiling during concurrent writes or active pins. A file that another preparation of the same root removes while a snapshot is taken, such as its finished staging lock, is left out of that snapshot instead of failing the preparation.

For the default workspace cache, a later cold build attempts whole-generation artifact cleanup when retained files exceed 16 GiB or no successful preparation has used the root for 30 days. Active Go leases and unresolved native guards defer cleanup, and Windows locked files may defer it again. Explicit cache roots receive no automatic artifact eviction. ttsc clean removes this directory when it owns a default or dedicated ttsc root; an explicitly shared root remains caller-owned. A user-provided GOCACHE and TTSC_GO_CACHE_DIR retain their existing ownership.

Downloaded bodies are checked against their recorded lengths and SHA-256 before their pack is admitted. Each returned body is checked again and held open through the helper’s Go build. This protects against incomplete downloads and stale cache mappings, but a same-user process that mutates or replaces an already returned file can still violate local cache integrity; use an artifact root writable only by trusted local processes. A crashed preparation can leave a staging lock: another preparation waits up to two minutes and then uses ordinary Go compilation. Reclaim such files with ttsc clean after native closure is confirmed, or let the guarded default-root collector remove an expired generation.

Release builders can seal separate recorded source builds with ttsc-cache -seal, then run ttsc-cache -merge -plan <absolute-plan.json> to deduplicate equal Go actions and bodies across approved compositions. The merge plan names each input manifest, its independently approved SHA-256, an output directory and an immutable HTTPS pack URL base. The merger reports the final manifest SHA-256; the release operator must approve that digest before packaging. To include it in one platform package build, set TTSC_RELEASE_ARTIFACT_MANIFEST to the final manifest’s absolute path and TTSC_RELEASE_ARTIFACT_SHA256 to the approved digest. Also supply an audited aggregate of the Go toolchain, ttsc and each included dependency’s required license and attribution as TTSC_RELEASE_ARTIFACT_NOTICES, with its independently approved digest as TTSC_RELEASE_ARTIFACT_NOTICES_SHA256. The build validates these inputs, release version, source commit, target and transport URLs, then copies the manifest and notices into bin/artifacts and pins both digests in that platform package’s package.json. All four variables are optional as a group; omitting them publishes no artifact claim. The remote ZIP packs must be uploaded at their manifest URLs before consumers opt in. This path does not schedule an npm release.

These entries are generated outputs that a later build can recreate. Active ttsx processes still need their runtime output. Use ttsc clean so its ownership checks preserve runs that may be alive; an explicit --cache-dir instead selects that whole directory and requires the caller to stop any runs using it.

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 the inputs 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:

PackageInvalidates the plugin binaryWhy
ttscYesExplicit version input, and its shim overlay source is hashed.
typescriptYesIt is the resolved tsgo version input.
A plugin such as typiaYesIts Go source directory is hashed byte for byte.
@ttsc/unpluginNoA 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.

Git worktrees

Set TTSC_CACHE_DIR to the same absolute directory in every worktree. A relative value resolves from each worktree’s project root and creates a separate cache there.

export TTSC_CACHE_DIR="$HOME/.cache/my-project/ttsc"

Use a directory dedicated to ttsc whose basename is ttsc. That basename lets ttsc clean remove both plugins/ and the nested go-build/.

The compiled plugin cache is content-addressed, so worktrees with identical build inputs reuse one binary, while a branch that changes an input receives another entry. Builds of the same key are coordinated across processes.

TTSC_GO_CACHE_DIR redirects only the Go object cache. TTSC_CACHE_DIR redirects the compiled plugin cache, the descriptor, capability, identity, answer, and orphan-lowering caches, temporary ttsx output, and, unless TTSC_GO_CACHE_DIR or GOCACHE overrides it, the Go object cache under go-build/ as well.

An explicit TTSC_CACHE_DIR is caller-owned and is not pruned automatically. Run ttsc clean while no worktree is building, or manage retention separately.

Run npx ttsc cache paths --json in each worktree to confirm that they resolve to the same root.

GitHub Actions

The default cache root is node_modules/.cache/ttsc, and you can persist it directly. Source-plugin binaries and Go objects use its plugins and go-build directories. Temporary ttsx output uses ttsx below the same resolved workspace root, but each runtime generation is removed on exit and must not be persisted. A run terminated outright cannot remove its own, so the next ttsx or ttsc/register run removes it once none of that run’s processes is alive. 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 build

Which 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/ttsc

Then cache .ttsc-cache/ttsc/plugins as the required artifact. Add .ttsc-cache/ttsc/go-build only as the optional accelerator. The same variable places temporary ttsx output under .ttsc-cache/ttsc/ttsx, so a nested tsconfig cannot create a second project-local cache root.

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.

Keep the root, and TTSC_GO_CACHE_DIR, outside every plugin’s Go sources as well: a plugin binary is keyed on its sources, so a build refuses a cache inside them instead of changing the sources it was keyed on while it builds. A project whose root is itself a plugin’s Go module therefore needs a root elsewhere, or below its node_modules, which the sources never include.

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. The same policy covers descriptors, capabilities, identities, answers, and ttsx-orphan, where each reuse records a use. It checks at most daily while idle and again after every cold binary publication. The binary returned by that cold build and entries with active build generations are excluded; if those exclusions keep the root above the ceiling, the marker schedules another pass.

After a cold source-plugin build attempt, including an ordinary failed build, ttsc prunes its Go object cache from an 8 GiB ceiling toward 6 GiB when native ownership permits it. The newest target-sized cohort remains protected for the requested hour plus Go’s one-hour access-mtime coalescing tolerance. Active builds and maintenance publish heartbeat records. An unguarded stopped build heartbeat receives a one-hour grace; completed maintenance is retired immediately, and an expired maintenance heartbeat receives one minute. These time limits rely on heartbeat freshness and do not prove that a suspended process has ended. A record dated implausibly far in the future is rebased and receives one ordinary grace period.

Owned native builds also protect their exact scratch inputs, plugin key and shared Go cache until process-tree closure is confirmed. An unknown outcome retains these paths and refuses same-key reuse, GC and clean regardless of PID absence or age; diagnostics identify the retained paths. Independent plugin keys can still build against the shared Go cache. Caller-selected Go caches receive lifetime guard metadata for these owned tasks without becoming subject to ttsc’s size-based eviction. Normal joined or never-started commands release their guards. Recovery requires the original command owner’s confirmed closure, or independent external closure confirmation before manual reclamation; losing that authority leaves the inputs quarantined. Older ttsc versions do not implement these guards and must not share quarantined roots.

A root named through TTSC_CACHE_DIR or --cache-dir is not garbage-collected automatically because its caller owns its lifetime. For a TTSC_CACHE_DIR root, ttsc clean removes plugins, descriptors, capabilities, identities, answers, ttsx-orphan, runtime directories whose recorded owners are provably gone, and the nested go-build when the root’s basename is ttsc. A runtime directory stays when its process is alive, its owner is on another host, or its owner record is absent or unreadable.

ttsc clean --cache-dir <dir> selects the named directory as a whole, including runtime output still in use. TTSC_GO_CACHE_DIR follows the same caller-owned lifetime. A caller-provided GOCACHE stays protected in both cleanup modes: a deletion target that contains it, lies inside it, or has uncertain physical overlap is preserved.

An owner record that cannot be read or parsed, or a run with no owner record from an older release, leaves ownership unknown, so default clean keeps that run. If the run index itself cannot be listed, clean fails without removing the runtime directory. A new run’s directory and owner record are published under the same lock that default clean holds while it chooses and removes old runs. The run index is pinned to its physical directory before ownership is inspected, so changing a cache link later cannot redirect removal to another run. A dangling link at the runtime root is removed as a link. Each Node child that inherits the runtime manifest records its own ownership before running user code; a live descendant keeps the run’s output after its launcher exits. A child without that manifest uses its own dependency cache and does not claim the parent run.

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 build

In 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 cache

ttsc 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=max

Confirm 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 ... (a cold Go cache can take several minutes)

On 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

Last updated on