compilecat
v0.0.9
Published
compiler plugin that helps you write high performance javascript.
Downloads
1,472
Maintainers
Readme

npm install compilecatcompilecat
A JavaScript/TypeScript compiler plugin for hot-path optimizations, driven by opt-in annotations. It does function inlining, scalar-replacement of aggregates (SROA), loop unrolling, and a Closure-style simplify tier (constant folding, purity-driven pure-call elimination, dead-store removal).
Built on a Rust/oxc core. Ships as a bundler plugin (via unplugin) that optimizes each source file before bundling while keeping TypeScript. It is cross-module aware: when a file imports an @inline donor, the plugin resolves and reads the donor module, inlines across the module boundary, and drops the now-unused import. addWatchFile is wired in, so editing a donor re-transforms every consumer that inlined it.
Usage
Pick the subpath for your bundler — the options API is identical across all of them:
| Bundler | Import |
|---|---|
| Rollup | compilecat/rollup |
| Vite | compilecat/vite |
| Rolldown | compilecat/rolldown |
| Webpack | compilecat/webpack |
| esbuild | compilecat/esbuild |
| Rspack | compilecat/rspack |
| Rsbuild | compilecat/rsbuild |
| Farm | compilecat/farm |
| Bun | compilecat/bun |
// rollup.config.js
import compilecat from 'compilecat/rollup';
export default {
plugins: [compilecat({ include: [/\/src\//] })],
};// vite.config.js
import compilecat from 'compilecat/vite';
export default {
plugins: [compilecat({ include: [/\/src\//] })],
};// webpack.config.js
const { compilecat } = require('compilecat/webpack');
module.exports = {
plugins: [compilecat({ include: [/\/src\//] })],
};include scopes which module ids compilecat transforms and reads as donors (picomatch globs and/or RegExps). It's required, so node_modules is never trawled unless a package is listed explicitly.
A browser/edge wasm backend is available at compilecat/wasm.
Directives
All optimizations are opt-in via /* @* */ block comments.
@inline: inline at the call site
On a function declaration, every call within the chunk is replaced with the function body. In bundle mode the chunk is the whole tree-shaken program, so callers from other source files reach the annotated function naturally, with no cross-file resolver involved.
/* @inline */
function add(out: Vec3, a: Vec3, b: Vec3): Vec3 {
out[0] = a[0] + b[0];
out[1] = a[1] + b[1];
out[2] = a[2] + b[2];
return out;
}
function step(result: Vec3, v1: Vec3, v2: Vec3) {
add(result, v1, v2);
}function step(result: Vec3, v1: Vec3, v2: Vec3) {
result[0] = v1[0] + v2[0];
result[1] = v1[1] + v2[1];
result[2] = v1[2] + v2[2];
}On a call site, inlines just that call. Useful for forcing a specific call:
import { vec3 } from 'mathcat';
function step(out: Vec3, v: Vec3) {
/* @inline */ vec3.normalize(out, v);
}Library code from node_modules is eligible for inlining whenever it ends up in the chunk and carries (or is targeted by) a directive. The resolver isn't doing anything special for libraries because bundling already pulled the body in.
@flatten: inline every call inside this function
A caller-side bulk directive. Every resolvable call inside the annotated function's body is treated as if its call site had /* @inline */.
/* @flatten */
function step(out: Vec3, v: Vec3) {
vec3.normalize(out, v);
vec3.scale(out, out, 2);
}@sroa: break an array-literal local into scalars
Scalar Replacement of Aggregates. Converts const v = [a, b, c] plus constant-index accesses (v[0], v[1], ...) into scalar locals. Useful for tuple-shaped data (vec3, quat, mat4) in hot loops.
function step(dt: number) {
const v: Vec3 = /* @sroa */ [0, 0, 0];
v[0] = 1 * dt;
v[1] = 2 * dt;
v[2] = 3 * dt;
use(v[0], v[1], v[2]);
}function step(dt: number) {
let v_0 = 1 * dt;
let v_1 = 2 * dt;
let v_2 = 3 * dt;
use(v_0, v_1, v_2);
}Escape analysis bails silently if the array leaks, whether by being passed to a function, spread, indexed non-constantly, aliased, or accessed via .length.
Can also be placed on an enclosing function to opt in every qualifying declaration inside it.
SROA also scalarizes object/record locals (const v = { x, y, z } with static .x/.y/.z accesses) and typed-tuple locals (const v: Vec3 = mk()), and performs module-scratch localization. A module-level scratch buffer reused as per-call temporary storage (the crashcat/mathcat house style const _scratch = /*@__PURE__*/ [0, 0, 0], written and read inside one @optimize function) is proven safe (single-owner, killed-on-entry, non-re-entrant via a CFG must-reaching-definitions analysis) and scalarized into per-call locals, with the module const deleted. This borrows LLVM GlobalOpt's global-localization idea, fused into SROA so the buffer is never materialized as a per-call allocation.
@unroll: unroll a loop with a static trip count
/* @unroll */
for (let i = 0; i < 3; i++) {
process(i);
}process(0);
process(1);
process(2);Supports for (let i = <lit>; i <(=) <lit>; i(++|+= <lit>)) { ... } and for (const x of <array literal>) { ... }. Warns and leaves the loop untouched if the trip count isn't static or the body has loop-crossing break/continue/return.
@optimize
Applies @flatten, @sroa, and @unroll. Does not apply @inline.
/* @optimize */
function step(out: Vec3, v: Vec3, dt: number) {
const scaled: Vec3 = [0, 0, 0];
for (let i = 0; i < 3; i++) scaled[i] = v[i] * dt;
vec3.add(out, out, scaled);
}@pure: assert a function is side-effect-free
compilecat runs a Closure-style purity analysis (a port of PureFunctionIdentifier) that proves most immutable-math helpers pure on its own. A call to a proven-pure function can be dropped when its result is unused, reordered, or de-duplicated, and it's emitted with a /*@__PURE__*/ marker for the downstream bundler. @pure is a developer-assertion override for cases the analysis can't see through (e.g. a helper that bottoms out in an unresolved external call you know is pure):
/* @pure */
function lerp(a: number, b: number, t: number): number {
return externalMathHelper(a, b, t);
}Use it sparingly. Asserting purity on a function that actually has side effects will let those effects be optimized away.
Pipeline
Each chunk that contains a compilecat directive runs through:
parse
→ normalize (MakeDeclaredNamesUnique)
→ stamp-pure-calls (purity analysis marks side-effect-free calls; emits /*@__PURE__*/)
→ inline-functions (DIRECT + BLOCK inlining, FunctionArgumentInjector)
→ block-flatten (lift the scaffolding blocks inlining emits)
→ loop-unroller
→ scalar-replace-aggregates (incl. module-scratch localization — see `@sroa` above)
→ block-flatten (lift the per-iteration blocks the unroller emits)
→ simplify (per-function fixpoint, ×8):
peephole-fold-constants
minimize-exit-points
peephole-minimize-conditions
inline-variables
cleanup-residue
peephole-remove-dead-code
block-flatten (lift bare blocks exposed during simplification)
flow-sensitive-inline-variables (CFG-based)
dead-assignments-elimination (CFG-based)
→ remove-unused-code (drop bindings/imports left unused after inlining)
→ strip-directives (remove the authored /* @* */ markers)
→ regenerateEverything is gated: only constructs carrying a directive (and their subtrees), plus the directive-free consumers an @inline was inlined into, are ever rewritten. The rest of the file is left byte-identical.
Some of the optimization passes (Rust, under rust/crates/compilecat_core/src/passes/) are functional ports of corresponding jscomp/*.java files from Google Closure Compiler. Others, notably @unroll and @sroa, diverge. See NOTICE.
Plugin options
The same options apply to every adapter:
compilecat({
include: string | RegExp | (string | RegExp)[], // REQUIRED. picomatch
// globs and/or RegExps
exclude?: string | RegExp | (string | RegExp)[], // additional skips
sourcemap?: boolean, // emit source maps. default true
debug?: boolean, // per-build timing + counter breakdown. default false
})include is required and has no implicit default. It bounds both the files that get transformed and the donor modules that may be read and inlined, so node_modules is never trawled unless a package is listed explicitly (e.g. ['**/src/**', '**/node_modules/mathcat/**']). Under rolldown the scope test runs in Rust and out-of-scope files never enter the JS plugin handler at all; other bundlers apply the filter in JS. An in-scope file that calls an in-scope @inline function is always processed, even when it carries no directive of its own.
// e.g. only transform engine code; everything else (app code,
// node_modules, etc.) is invisible to compilecat.
compilecat({
include: ['src/engine/**'],
})Acknowledgements
Heavily inspired by unplugin-inline-functions.
Attribution
Some of the optimization passes under rust/crates/compilecat_core/src/passes/
are ports of corresponding files from the Google Closure Compiler,
licensed under the Apache License, Version 2.0. See NOTICE for
required attribution. Compilecat itself is MIT-licensed (see LICENSE).
