Engineering

The Multicall Pattern: Curing Toolchain Sprawl with a Single TypeScript Binary

Why Do We Celebrate Fragmentation?

Why did we collectively decide that splitting a lightweight 5MB application into fifty 100MB micro-tools was a victory for engineering efficiency?

I genuinely don't know when the industry lost the plot on system design. You write a few utility scripts to manage your database and sync some assets before flushing a cache. Next thing you know, your CI/CD pipeline is churning out twelve different Docker images just to run basic cron jobs. You manage multiple repositories with redundant package.json files. Updating a shared dependency becomes a massive headache.

I know the exact sinking feeling when your runner chokes because it ran out of disk space caching node modules for the tenth identical deployment pipeline. It is exhausting. Worse, it makes your architecture fragile.

Early in my solopreneur journey, I fell into this exact trap. I built a suite of internal tooling for my infrastructure. I had a CLI for database migrations and another for background job processing. I also built a tool for log ingestion. I bundled them individually. Deploying meant pushing gigabytes of bloated files to my server. Every tool required its own configuration step and build matrix, along with a dedicated execution environment. I was spending more time managing the delivery of my tools than actually using them to build my product.

Then, I remembered a brilliant architectural trick used in resource-constrained environments.

The Omnitool Epiphany

If you poke around the internals of minimal operating systems, you notice something strange. The standard utilities you rely on daily aren't separate programs. They are symlinks pointing back to a single, highly optimized executable.

The concept is elegant: you compile one massive binary containing the logic for dozens of distinct tools. When you execute a command, the binary wakes up and checks the name you used to invoke it (by reading the initial argument from the operating system). It then instantly routes execution to the correct internal module.

It is a monolith disguised as a micro-toolchain.

We can steal this exact pattern for modern TypeScript development. Instead of shipping deploy-cli and migrate-db alongside sync-assets as completely isolated packages, we can ship one executable that shape-shifts based on how it is called. You get the developer experience of focused, single-purpose tools with the deployment simplicity of a single artifact.

Let's walk through how to build a Multicall CLI in TypeScript.

Phase 1: The Dispatcher

The magic relies entirely on parsing how the script was invoked. In C, this is done by reading argv[0]. In the Node.js ecosystem, process.argv behaves slightly differently. process.argv[0] is usually the path to the Node executable itself, while process.argv[1] is the path to the executed script.

Here is how we construct the central nervous system of our omnitool.

// src/dispatcher.ts
import path from 'path';
import { migrateDatabase } from './applets/migrate';
import { flushCache } from './applets/cache';
import { generateReports } from './applets/reports';
 
// 1. Define our registry of applets
type AppletHandler = (args: string[]) => Promise<void>;
 
const applets: Record<string, AppletHandler> = {
  'db-migrate': migrateDatabase,
  'cache-flush': flushCache,
  'gen-reports': generateReports,
};
 
async function main() {
  // 2. Determine how we were invoked
  const rawInvokedPath = process.argv[1];
  const invokedAs = rawInvokedPath ? path.basename(rawInvokedPath) : '';
 
  // 3. Fallback routing
  // If called directly via `node dispatcher.js db-migrate`,
  // the command is in argv[2]. Otherwise, it's the symlink name.
  const commandName = applets[invokedAs] 
    ? invokedAs 
    : (process.argv[2] || 'omnitool');
 
  // 4. Handle the default/help case
  if (commandName === 'omnitool') {
    console.log('\nAvailable applets:');
    Object.keys(applets).forEach(name => console.log(`  - ${name}`));
    console.log('\nUsage: symlink the binary to an applet name, or run `omnitool <applet>`');
    process.exit(0);
  }
 
  const handler = applets[commandName];
  
  if (!handler) {
    console.error(`Fatal: Applet '${commandName}' not found.`);
    process.exit(1);
  }
 
  // 5. Dispatch execution, passing down remaining arguments
  // If invoked via symlink, args start at index 2. 
  // If invoked via manual fallback, args start at index 3.
  const argsOffset = applets[invokedAs] ? 2 : 3;
  const appletArgs = process.argv.slice(argsOffset);
 
  try {
    await handler(appletArgs);
  } catch (error) {
    console.error(`[${commandName}] crashed:`, error);
    process.exit(1);
  }
}
 
main();

Notice the fallback logic. If you are developing locally, setting up symlinks every time you recompile is irritating. You can just run node dist/dispatcher.js cache-flush. But in production, the symlink approach takes over automatically.

How do we get the symlinks installed without writing custom bash scripts? We abuse the bin field in package.json. NPM and Yarn natively support mapping multiple command names to the same file. When the package is installed globally (or locally in node_modules/.bin), the package manager creates the symlinks for us.

{
  "name": "@myorg/infra-omnitool",
  "version": "1.0.0",
  "description": "A single binary for all infrastructure operations",
  "main": "dist/dispatcher.js",
  "bin": {
    "omnitool": "dist/dispatcher.js",
    "db-migrate": "dist/dispatcher.js",
    "cache-flush": "dist/dispatcher.js",
    "gen-reports": "dist/dispatcher.js"
  },
  "scripts": {
    "build": "tsc"
  },
  "dependencies": {
    "pg": "^8.11.0",
    "redis": "^4.6.0"
  }
}

When you run npm install -g . on your server, your system pathway gets populated with db-migrate and cache-flush. It also includes gen-reports. They all point to the exact same compiled JavaScript file. You just solved CLI sprawl without introducing a complex monorepo toolchain.

Phase 3: Mitigating Memory Bloat with Dynamic Imports

You are probably glaring at that dispatcher.ts file and thinking about memory consumption. If I import every single tool at the top of the file, V8 will parse and load all the dependencies for the reporting engine just to run a simple cache flush. That defeats the entire purpose of being lightweight.

You are entirely right. Static imports in a multicall architecture create massive overhead. We need to delay module evaluation until the specific applet is triggered.

TypeScript supports dynamic import() out of the box. Let's refactor the dispatcher to be lazily evaluated.

// src/dispatcher.ts
import path from 'path';
 
// We only store the module path or a dynamic import function, not the loaded code.
type AppletLoader = () => Promise<(args: string[]) => Promise<void>>;
 
const applets: Record<string, AppletLoader> = {
  'db-migrate': async () => {
    const { migrateDatabase } = await import('./applets/migrate');
    return migrateDatabase;
  },
  'cache-flush': async () => {
    const { flushCache } = await import('./applets/cache');
    return flushCache;
  },
  'gen-reports': async () => {
    const { generateReports } = await import('./applets/reports');
    return generateReports;
  },
};
 
async function main() {
  const rawInvokedPath = process.argv[1];
  const invokedAs = rawInvokedPath ? path.basename(rawInvokedPath) : '';
  const commandName = applets[invokedAs] ? invokedAs : (process.argv[2] || 'omnitool');
 
  if (commandName === 'omnitool') {
    console.log('\nAvailable applets:');
    Object.keys(applets).forEach(name => console.log(`  - ${name}`));
    process.exit(0);
  }
 
  const loader = applets[commandName];
  if (!loader) {
    console.error(`Fatal: Applet '${commandName}' not found.`);
    process.exit(1);
  }
 
  // Lazy load the exact dependencies we need right now
  const handler = await loader();
  const argsOffset = applets[invokedAs] ? 2 : 3;
  
  try {
    await handler(process.argv.slice(argsOffset));
  } catch (error) {
    console.error(`[${commandName}] crashed:`, error);
    process.exit(1);
  }
}
 
main();

Now, if you call cache-flush, the Node process entirely ignores the pg driver and the heavy PDF-generation libraries needed by gen-reports. You retain the monolithic deployment artifact, but achieve the fast boot times and low memory footprints of isolated micro-scripts.

Phase 4: The Ultimate Artifact (Compiling to a True Binary)

Relying on npm install in production is still a liability. You are depending on the server having the correct Node version and a working package manager. It also needs network access to pull down node_modules.

To achieve true deployment nirvana, we bundle the entire TypeScript project, dependencies included, into a single executable file.

I prefer using esbuild paired with Node's relatively new Single Executable Applications (SEA) feature, or tools like pkg. Let's look at how to strip this down with esbuild. We will compile the entire app into a single JavaScript file, and then inject it into a Node binary.

First, bundle the code:

# Install esbuild
npm i -D esbuild
 
# Bundle everything into one minified file, ignoring external native modules if necessary
npx esbuild src/dispatcher.ts \
  --bundle \
  --platform=node \
  --target=node20 \
  --outfile=dist/bundled.js \
  --minify

Once bundled, you have a singular dist/bundled.js file. Using Node 20+ SEA, you create a configuration file (sea-config.json):

{
  "main": "dist/bundled.js",
  "output": "dist/sea-prep.blob"
}

Generate the blob:

node --experimental-sea-config sea-config.json

Copy the Node executable and inject the blob (this example is for macOS/Linux):

cp $(command -v node) dist/omnitool
 
# Inject using postject (comes with Node)
npx postject dist/omnitool NODE_SEA_BLOB dist/sea-prep.blob \
    --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2
 
chmod +x dist/omnitool

You now possess a single, statically linked binary file named omnitool. You can drop this 50MB file onto any server. You avoid running npm install and managing a node_modules folder. Version conflicts disappear completely.

To deploy the separate tools on the server, you just copy the binary and create symlinks:

scp dist/omnitool user@server:/usr/local/bin/omnitool
 
ssh user@server << 'EOF'
  cd /usr/local/bin
  ln -sf omnitool db-migrate
  ln -sf omnitool cache-flush
EOF

Embracing Constraints

Constraints breed elegance. The moment I stopped treating every utility script as a sacred, isolated project, my infrastructure became radically simpler to maintain.

Shrinking your deployment footprint saves a few gigabytes on a cloud bill. It also reduces the cognitive load required to understand your system. When a pipeline fails, I don't have to trace through twelve different repositories to find out which dependency bump broke the build. I check the omnitool.

Stop building endless pipelines for endless scripts. Centralize the delivery and compartmentalize the execution. Let the command line context figure out the rest.