npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

deploy-recover

v0.1.0

Published

Recover tabs left open across a deploy: detect new versions, recover from missing chunks, and never loop reloading.

Readme

deploy-recover

Your users never see a broken page after a deploy.

You deploy. Someone who had your app open since this morning clicks a link, and the page goes blank. The console says:

TypeError: Failed to fetch dynamically imported module: https://app.com/assets/Settings-3f2a9c.js

Their tab still runs the old version, and the old version's lazily loaded files were deleted by the deploy. Every app that uses code splitting (React.lazy, lazy routes, dynamic import()) hits this, on every deploy.

deploy-recover fixes it:

  • Recovers missing chunks. When a lazy file fails to load, it checks whether a new version is live. If so, it reloads into it before the user sees an error. If not (the user is offline, or the server is down), it leaves the error alone.
  • Never loops. A cache that keeps serving old files can't trap the tab in endless reloads. It gives up after 2 tries and tells you.
  • Recovers blank pages too. If the browser restores old HTML whose main script is gone (the back button, a cached page), a tiny inline guard reloads it into the new version.
  • Announces updates. It tells you when a new version is live, so you can show a "New version available" banner.
  • Small. About 2.3 KB gzipped at runtime, plus 0.4 KB for the React helpers and a 0.7 KB inline guard. No dependencies.

Setup (Vite + React)

npm install deploy-recover

1. Stamp each build with a version:

// vite.config.js
import react from "@vitejs/plugin-react";
import deployRecover from "deploy-recover/vite";

export default {
  plugins: [react(), deployRecover()],
};

This writes version.json into your build and adds a version tag to index.html. It does nothing in development.

The version is a hash of your build output, not your commit. Redeploying the same commit with a changed environment variable still changes your chunks (Vite inlines env values), so it counts as a new version. To choose your own, pass deployRecover({ version }) or set DEPLOY_RECOVER_VERSION. The commit SHA from your CI is recorded in version.json for reference.

deploy-recover is for single-page apps (a build with an index.html). Server-rendered frameworks (Next.js, Remix, React Router framework mode) have no index.html to stamp; there the runtime finds no version and stays inactive.

2. Start the guard once, when your app starts:

// main.jsx
import { createDeployRecovery } from "deploy-recover";

createDeployRecovery();

3. Use lazy from deploy-recover instead of React's:

import { lazy } from "deploy-recover/react";

const Settings = lazy(() => import("./pages/Settings"));

It's a drop-in replacement for React.lazy. When a deploy removed the chunk, the page reloads into the new version and your error boundary never flashes. Any other failure reaches your error boundary as usual.

That's all you need. Without step 3, deploy-recover still catches failures through Vite's vite:preloadError event and failed <script>/<link> loads. The difference is that your error boundary may briefly show before the reload.

Show an "update available" banner (optional)

import { useDeployRecovery } from "deploy-recover/react";

function UpdateBanner() {
  const { status, reload } = useDeployRecovery();
  if (status === "update-available") {
    return <div role="status">A new version is available. <button onClick={reload}>Refresh</button></div>;
  }
  if (status === "recovery-failed") {
    return <div role="alert">This page is out of date. Please refresh your browser.</div>;
  }
  return null;
}

Options

createDeployRecovery({
  pollInterval: 5 * 60 * 1000, // check for new versions every 5 min (0 = off)
  checkOnFocus: true,          // also check when the tab becomes visible / comes back online
  reloadWhenHidden: false,     // auto-reload into updates while the tab is in the background
  isSafeToReload: () => !formIsDirty, // never auto-reload over unsaved work
  maxReloads: 2,               // automatic attempts per new version before giving up
  reloadTimeout: 10000,        // if a reload is cancelled ("Leave site?" → Stay), resume after this
  onUpdateAvailable: (state) => {},
  onRecoveryFailed: (state) => {},    // show a "please refresh" message
  onReload: (reason, state) => {},    // "chunk-error" | "update", e.g. for analytics
});

| Status | Meaning | |---|---| | current | Running the latest version, as far as we know | | update-available | A new version is live; the tab still works | | reloading | Reloading into the new version | | recovery-failed | Reloading didn't reach the new version (see Caching) |

When does it reload by itself?

  • After a chunk fails and a new version is live. The page is already broken, so reloading is the fix.
  • When an update is available but nothing is broken: only if you turn on reloadWhenHidden, and then only while the tab is in the background and isSafeToReload() returns true. Otherwise you get onUpdateAvailable, and the choice is yours.

If the user cancels the reload (your app shows a "Leave site?" prompt for unsaved changes and they choose Stay), deploy-recover goes back to update-available after reloadTimeout. The failed lazy import then reaches your error boundary instead of spinning forever.

Plugin options: deployRecover({ version, fileName, bootGuard }).

  • Set bootGuard: false if your Content-Security-Policy blocks inline scripts.
  • If your app already ships a public/version.json, the build stops with a clear message. Pick another name with fileName: "deploy-recover.json".
  • Assets on a CDN (an absolute base like https://cdn.example.com/) are supported. version.json is then fetched from the CDN, so the CDN must send CORS headers for it.

Other build tools

For any static build (webpack, Create React App, Rollup, Parcel, a static-exported site), stamp the output folder after building:

"scripts": {
  "build": "webpack --mode production && deploy-recover stamp dist"
}
deploy-recover stamp <outDir> [--version <id>] [--base shop/] [--file version.json] [--no-boot-guard]

Then use createDeployRecovery() and recoverImport() in your app:

import { createDeployRecovery, recoverImport } from "deploy-recover";

createDeployRecovery();
const loadSettings = recoverImport(() => import("./settings.js"));

The version defaults to a hash of the files in <outDir>. The runtime recognises webpack's ChunkLoadError and the chunk-error messages of Chrome, Firefox and Safari. Only the Vite + Chrome path is covered by the end-to-end suite, so treat other bundlers and browsers as supported but less proven. One known gap: the inline boot guard reacts to failed entry files. A classic (non-module) entry script that receives HTML fails with a syntax error instead of a load error, so that case isn't covered yet.

Git Bash on Windows rewrites arguments that start with /, so write --base shop/ rather than --base /shop/. PowerShell and CMD are unaffected.

Caching

deploy-recover relies on two files never being cached:

| File | Header | |---|---| | index.html (and other HTML) | Cache-Control: no-cache | | version.json | Cache-Control: no-store |

Hashed assets (/assets/*) can and should stay cached forever. Most hosts already treat HTML this way. If yours doesn't:

Netlify / Cloudflare Pages (_headers file in your output folder):

/index.html
  Cache-Control: no-cache
/version.json
  Cache-Control: no-store

Vercel (vercel.json):

{ "headers": [
  { "source": "/index.html", "headers": [{ "key": "Cache-Control", "value": "no-cache" }] },
  { "source": "/version.json", "headers": [{ "key": "Cache-Control", "value": "no-store" }] }
] }

nginx:

location = /index.html   { add_header Cache-Control "no-cache"; }
location = /version.json { add_header Cache-Control "no-store"; }

Service workers / PWAs (e.g. vite-plugin-pwa): a service worker serves cached HTML, so reloading doesn't reach the new version by itself. Use your service worker's own update flow (for vite-plugin-pwa, registerSW({ onNeedRefresh })). If you run deploy-recover alongside it, deploy-recover detects the stuck state and reports recovery-failed rather than reloading in a loop.

How it was tested

Besides unit tests, an end-to-end suite builds a real Vite + React + React Router app as v1 and v2. It serves them from a test server and drives a real Chrome tab that stays open while v2 is deployed. Every scenario checks how many times the page actually loaded:

| Scenario | Result | |---|---| | Missing chunk returns 404 | Reloads once into v2 | | Host serves index.html (200) for missing files (Netlify/Vercel/nginx style) | Reloads once into v2 | | A cache keeps serving the old app | Stops after 2 reloads, reports recovery-failed | | Old HTML whose entry script was deleted (back button) | Inline guard reloads into v2 | | …on a host that serves index.html (200) for missing files | Inline guard reloads into v2 | | …and served forever | Inline guard stops after 2 reloads | | Chunk missing but no new deploy | No reload; your error boundary shows the error | | New version live, nothing failing | Reports update-available, no reload | | App served from a subpath (/app/) | Finds /app/version.json and recovers | | Build stamped by the CLI instead of the Vite plugin | Recovers | | Same commit redeployed with a changed env value (default versioning) | Recovers | | App already has public/version.json | Build fails with a clear message |

Run it with npm run test:e2e. It drives your installed Chrome; the suite runs in Chrome only.

License

MIT