Oleg Kuibar
Back to Blog
DevOps 9 min read

Why I Built plunk

yalc stopped being maintained and broke with pnpm. npm link still breaks React hooks. So I built a tool that copies files straight into node_modules.

By Oleg Kuibar |

Why I Built plunk

yalc hasn’t seen a meaningful update in years. The last release was 2022, the issue tracker is full of unanswered pnpm bugs, and yalc-watch (the companion tool for automatic re-pushing) is completely abandoned. For a while I worked around the rough edges. Then pnpm v7.10 shipped, yalc’s file: protocol injection stopped working with pnpm’s content-addressable store, and I had no fallback left.

I maintain a shared UI kit consumed by three apps. Every change follows the same loop: edit the library, rebuild, test in a consumer app. The tooling for that last step has been broken for years.

npm link creates a symlink from node_modules/my-lib to your local library checkout. Module resolution follows the real path (the symlink target), not the logical one inside the consumer.

When your library does import React from 'react', Node resolves it relative to the library’s directory on disk. The consumer has its own React. Now you have two React instances loaded in one app.

Consumer app
├── node_modules/
│   ├── react/              ← consumer's React (v18.3.1)
│   └── my-lib/ → symlink → /home/dev/my-lib/
│                            └── node_modules/
│                                └── react/  ← library's React (v18.3.1)

Same version, different module instances. React hooks depend on a shared internal state object. Two instances means two states. useState returns undefined. useContext can’t find its provider. Nothing in the error output points to the symlink as the cause.

Bundlers add another layer of pain. Vite and Turbopack won’t watch files outside the project root by default, so changes to your symlinked library don’t trigger HMR.

yalc: the right idea, abandoned

yalc’s approach was actually sound: copy files instead of symlinking. But the implementation had two problems that only got worse as it stopped being maintained.

First, it rewrites your package.json:

{
  "dependencies": {
    "my-lib": "file:.yalc/my-lib"
  }
}

That rewritten entry shows up in git diffs. The .yalc/ directory needs a gitignore rule. Forget to undo it before pushing and your CI pulls from a path that doesn’t exist on the build server.

Second, it broke with pnpm. Starting around pnpm v7.10, the content-addressable store doesn’t handle file: protocol dependencies pointing to mutable local directories. The lockfile gets confused, integrity checks fail, pnpm install clobbers whatever yalc injected. The GitHub issues piled up. Nobody was fixing them.

There’s no built-in watch mode. The external yalc-watch package hasn’t been updated since 2021. Every push copies the entire package from scratch. No diffing, no skipping unchanged files.

I used yalc for over a year. When pnpm support broke and it became clear the project wasn’t going to get fixed, I decided to build something that solves the same problem without the baggage.

plunk: just copy the files

pnpm’s own injected dependencies feature already proved that copying works. The package lands in node_modules as regular files, module resolution is correct, no symlink confusion.

plunk does this across all package managers. It copies your built library files directly into the consumer’s node_modules/. No symlinks, no package.json rewrites. Everything plunk tracks lives in a gitignored .plunk/ folder.

plunknpm linkyalc
MechanismCopy to node_modulesSymlinkCopy + rewrite package.json
Module resolutionCorrectBroken (dual instances)Correct
Git contaminationNoneNone.yalc/ + rewritten package.json
pnpm supportFull (follows .pnpm/ symlinks)FragileBroken since ~v7.10
Incremental syncxxHash per-file diffN/AFull copy every time
Watch modeBuilt-inNoneExternal (abandoned)
Bundler integrationVite plugin, Next.js cache clearManual reloadNone
Survives npm installplunk restore via postinstallNoNo

How it works

Internally, plunk splits the work into publishing (library to store) and injecting (store to consumer’s node_modules).

plunk publish reads the library’s package.json, figures out which files to include (same rules as npm pack), and copies them into ~/.plunk/store/<name>@<version>/. A SHA-256 content hash prevents redundant writes. Files go to a temp directory first, then get renamed into place, so the operation is atomic.

plunk add copies from the store into the consumer’s node_modules/. It backs up whatever npm/pnpm originally installed so you can roll back later. For pnpm, it follows the symlink chain into .pnpm/ to write files at the real location rather than clobbering the symlink.

plunk dev combines both steps and adds a file watcher. Change a file, it rebuilds, publishes, and pushes to every registered consumer.

Dealing with pnpm

This was the part that made yalc give up. pnpm doesn’t store packages directly in node_modules/:

node_modules/
├── my-lib → .pnpm/my-lib@1.0.0/node_modules/my-lib  (symlink)
└── .pnpm/
    └── my-lib@1.0.0/
        └── node_modules/
            └── my-lib/   ← actual files live here

Writing to node_modules/my-lib would overwrite the symlink itself. plunk resolves through the chain and writes to the real directory inside .pnpm/:

async function resolveTargetDir(
  consumerPath: string,
  packageName: string,
  pm: PackageManager
): Promise<string> {
  const directPath = getNodeModulesPackagePath(consumerPath, packageName);

  if (pm === "pnpm") {
    try {
      const realPath = await resolveRealPath(directPath);
      if (realPath !== resolve(directPath)) {
        return realPath;
      }
    } catch {
      const pnpmDir = join(consumerPath, "node_modules", ".pnpm");
      const entries = await readdir(pnpmDir);
      const encodedName = packageName.replace("/", "+");
      for (const entry of entries) {
        if (entry.startsWith(encodedName + "@")) {
          const candidate = join(pnpmDir, entry, "node_modules", packageName);
          if (await exists(candidate)) return candidate;
        }
      }
    }
  }

  return directPath;
}

The fallback scan handles the edge case where the .pnpm/ directory exists from a previous install but the top-level symlink is gone. This is the kind of thing you can’t fix in yalc without an active maintainer. The pnpm internals keep evolving.

On APFS (macOS), btrfs (Linux), and ReFS (Windows), plunk uses reflinks. The filesystem shares the underlying data blocks, so the copy is nearly instant and takes zero extra disk space until one side is modified.

const reflinkSupported = new Map<string, boolean>();

export async function copyWithCoW(src: string, dest: string): Promise<void> {
  await mkdir(dirname(dest), { recursive: true });

  const root = volumeRoot(dest);
  const supportsReflink = reflinkSupported.get(root);

  if (supportsReflink === false) {
    await copyFile(src, dest);
    return;
  }

  if (supportsReflink === true) {
    await copyFile(src, dest, constants.COPYFILE_FICLONE);
    return;
  }

  // First copy on this volume: probe
  try {
    await copyFile(src, dest, constants.COPYFILE_FICLONE_FORCE);
    reflinkSupported.set(root, true);
  } catch {
    reflinkSupported.set(root, false);
    await copyFile(src, dest);
  }
}

It probes once per filesystem volume and caches the result. Mixed setups (external drives, containers with different mounts) work without re-probing.

Incremental sync

yalc copies everything on every push. plunk diffs first.

It starts with file sizes. Different size means the file changed, copy it, move on. If the sizes match, it checks the modification time. plunk preserves source timestamps after each copy, so if the mtime hasn’t changed, the file is almost certainly the same and gets skipped without hashing. The hash (xxHash64, about 5-10x faster than SHA-256) is only computed when the size matches but the mtime differs.

Files removed from the source get cleaned up from the destination. All comparisons run in parallel, throttled to the number of CPU cores.

Watch mode

The file watcher follows what I call “debounce effects, not detection.” Changes are picked up immediately, but the expensive part (build + push) is coalesced:

const scheduleFlush = () => {
  if (coalesceTimer) return;

  coalesceTimer = setTimeout(async () => {
    coalesceTimer = null;

    if (running) {
      pendingWhileRunning = true;
      return;
    }

    running = true;
    try {
      if (options.buildCmd) {
        const success = await runBuildCommand(options.buildCmd, watchDir);
        if (!success) return;
      }
      await onChange();
    } finally {
      running = false;
      if (pendingWhileRunning) {
        pendingWhileRunning = false;
        scheduleFlush();
      }
    }
  }, debounceMs);
};

Rapid filesystem events collapse into a single push. If changes arrive while a push is in-flight, they queue up and trigger another round when the current one finishes. If the build fails, the push is skipped. No point injecting stale output.

Vite integration

plunk has a Vite plugin that watches .plunk/state.json (updated on every push) and triggers a full page reload:

export default function plunkPlugin(): Plugin {
  return {
    name: "vite-plugin-plunk",
    apply: "serve",

    configResolved(config) {
      plunkStateFile = normalize(join(config.root, ".plunk", "state.json"));
      cacheDir = config.cacheDir;
    },

    configureServer(server) {
      server.watcher.add(plunkStateFile);
      server.watcher.on("change", async (changedPath: string) => {
        if (normalize(changedPath) !== plunkStateFile) return;
        try {
          await rm(cacheDir, { recursive: true, force: true });
        } catch {}
        server.ws.send({ type: "full-reload", path: "*" });
      });
    },
  };
}

It sends full-reload over WebSocket rather than calling server.restart(). Restart re-bundles vite.config.ts, which can fail with CJS/ESM interop issues. A full-reload just makes the browser refetch everything, and Vite re-optimizes dependencies on its own. plunk also clears Next.js and Webpack caches after injection.

The daily workflow

The whole point is that there’s no ceremony.

plunk add my-lib --from ../my-lib publishes the library and injects it in one command. It detects the package manager, backs up what’s installed, checks for missing transitive deps.

plunk dev figures out the build command from package.json, watches for changes, rebuilds, pushes to all consumers. No config file.

plunk restore hooks into postinstall. Run pnpm install and all linked packages get re-injected automatically. Teammates who don’t use plunk never see anything different.

plunk migrate converts an existing yalc setup. It removes .yalc/, undoes the package.json rewrites, and sets up plunk state.

GitHub / npm / browser playground

npx @olegkuibar/plunk init