javascript
-
pnpm 11 Made the Safe Thing the Default
Protecting against supply chain attacks requires vigilance. You have to audit your dependencies. You have to pin your versions. You have to review your install scripts. All of these are great things to do, but they require sustained effort.
pnpm 11 took the obvious thing and made it the default. They changed the waiting period.
minimumReleaseAgedefines the minimum number of minutes that must pass after a version is published before pnpm will install it.Before version 11 the default was 0. In version 11 the default is 1440 minutes, which is 24 hours, and it applies to everything.
Most malicious packages get discovered and pulled from the registry within minutes, or at most an hour. So what a day of patience buys you is that you’ve eliminated the potential for dependencies sneaking in that haven’t been fully vetted.
npm already followed suit, and so did everyone else. This is now table stakes across the ecosystem:
- pnpm got there first with
minimumReleaseAge, measured in minutes, back in 10.16 in September 2025. - Yarn shipped
npmMinimalAgeGate, also minutes, in 4.10.0 that same month. - Bun added
minimumReleaseAgein 1.3 in October 2025, measured in seconds, plus aminimumReleaseAgeExcludeslist for packages you trust. - npm landed
min-release-agein 11.10.0 in February 2026, measured in days.
I guess they couldn’t agree on the unit of time for their release age setting.
As far as I know, in all of them besides pnpm, the cooldown is opt-in. It’s not the default. So you have to know the setting exists and you have to go turn it on.
Here are some other things that changed in pnpm 11:
allowBuildsreplacesonlyBuiltDependencies, which was removed in v11. It’s a map of which packages may run build scripts. Anything not listed is disallowed and treated as unreviewed.strictDepBuildsdefaults totrue. Installation exits with a non-zero code if any dependency has unreviewed build scripts, so this fails your CI rather than printing a warning nobody reads.verifyDepsBeforeRundefaults toinstall. Beforepnpm runorpnpm exec, it checks whether your dependency state matches the lockfile. Other options arewarn,error,prompt, andfalse.dangerouslyAllowAllBuildsdefaults tofalse, and the name is doing exactly the work it should. Setting it true lets every dependency, transitive ones included, run install scripts now and in the future.
The clear pattern here is that an automated or unintentional action should be blocked, not permitted with a warning.
Sure, there is somewhat of a cost here. The delay means you can’t immediately install a new version that was just published unless you flip the flag. I can see the
allowBuildsmigration being somewhat of a hassle, because the first time you install after upgrading you’re going to get a list of packages that want to run build scripts. It’s easy to be lazy and approve all of them without thinking.With the tools we have available to us these days, we can ask an agent to review the build scripts. This is the right thing to do. Find a way to pin the dependency to fix transitive version issues. The inner engineer in all of us needs to understand why build scripts are dangerous, and what to be careful of, so that you can ask your subagent to go and see if that’s a problem, or if that problem has been fixed with the new version. It’s up to the human in the loop to ensure that the agents are doing their due diligence.
When building software, we should also look for other paths to optimize, and make the lazy path the safest one, because chances are that’s going to become the default.
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].
- pnpm got there first with
-
Strapi 5 Docs | Strapi 5 Documentation
Get set up in minutes to build any project in hours instead of weeks.
-
npmx - Package Browser for the npm Registry
a fast, modern browser for the npm registry. Search, browse, and explore packages with a modern interface.
-
SvelteKit vs Astro: How I Choose in 2026
If you’ve followed my blog over the past few months, you know I’ve spent a lot of time digging into Svelte 5. Runes completely changed the way I think about reactivity, and SvelteKit is still one of the most elegant frameworks I’ve used for building genuinely interactive web apps.
So lately I’ve been thinking about: when does it make sense to use Astro instead of SvelteKit?
The honest answer is that they’re not really competing, or at least they didn’t used to be. Both frameworks are excellent, and historically they solved two pretty different problems. That line has gotten blurrier lately, but the old split is still the best starting point I’ve got. After building so many things with Astro’s recent updates, here’s how I think about picking one in 2026.
SvelteKit Is for Apps
When you’re building a dashboard, a complex SaaS tool, or anything where state needs to persist across a lot of dynamic route changes, SvelteKit is hard to beat.
SvelteKit assumes your app is going to do a lot of client-side work, and it leans into that. Form actions, nested layouts, complex data loading, it makes all of it straightforward. Combine the routing with Svelte 5’s runes and you get a developer experience that feels like writing plain JavaScript, except the reactivity just happens.
My rule of thumb: if the user is going to be clicking around, dragging things, and holding a long-lived session, I reach for SvelteKit.
Astro Is for Content
Astro comes at the web from the opposite direction. The core philosophy is “zero JS by default,” and it means it.
If you’re building a blog, a marketing site, documentation, or a storefront where the whole point is getting content in front of the reader as fast as possible, Astro wins. Its Islands Architecture ships raw, fast HTML and only hydrates the specific interactive pieces that actually need JavaScript. The result is a site that’s quick out of the box without you having to fight for it.
I should be fair, though: Astro hasn’t stayed in its lane. Recent releases have pushed it toward more dynamic, app-like territory with Server Islands, type-safe Actions, and a sessions API. The “content only” box doesn’t hold the way it did a year ago, and you can build genuinely interactive things in Astro now if you want to.
So I’d put it this way instead: as a rule of thumb, if it feels more like a website than a piece of software, that’s still where Astro shines.
You Don’t Actually Have to Choose
Here’s the the fun part, Astro has first-class support for Svelte.
You don’t give up Svelte to use Astro. Say I’m building a content-heavy site but I need one genuinely complex interactive piece, like a calendar component. I can write that calendar in Svelte, drop it into an Astro page, and tell Astro to hydrate only that component with a
client:loaddirective.You get the performance of a static or server-rendered Astro page, plus the developer experience of writing Svelte for the bits that need to be interactive. That’s a really good deal.
So Which One?
There’s real overlap now, so treat this as a default rather than a law. But it mostly still comes down to one question: are you building software or a website?
- Reach for SvelteKit when you’re building an application. If it feels like software, this is your tool.
- Reach for Astro when you’re building a content site. If SEO, initial load speed, and reading experience are the priorities, let Astro do the heavy lifting and sprinkle in Svelte components where you need them.
Pick the framework that matches the kind of thing you’re building, lean on the overlap when it helps, and don’t agonize over it. Either way you’re starting from a good place.
What are you reaching for first when you spin up a new project these days?
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].
Sources
- Islands Architecture — Astro Docs — the “zero JS by default” philosophy and hydration control via client directives like
client:load. - Erika & Matthew Phillips, “Astro 4.12: Server Islands” (Astro Blog, July 18, 2024) — introduction of Server Islands to combine static caching and dynamic server-rendered blocks.
- Actions — Astro Docs — official documentation for Astro’s type-safe Actions API.
- Sessions — Astro Docs — official documentation for Astro’s sessions API.
- Introducing runes — Svelte Blog — Svelte 5 runes announcement introducing compiler-directive reactivity.
Astro javascript svelte Web development Sveltekit Frameworks
-
Declutter your JavaScript & TypeScript projects
Project linter to find unused dependencies, exports and files
Programming Tools Dev links code javascript software engineering
-
Your Software Is Mostly Strangers' Code
Modern applications aren’t really written anymore. They’re assembled. Seventy to ninety percent of a typical proprietary codebase is open-source code pulled from public registries, NPM, PyPI, crates.io, maintained by thousands of people you’ve never met. Every
npm installis an act of implicit trust extended to strangers, and that trust model has quietly become the weakest link in most security architectures.Attackers figured this out a long time ago. Compromising one popular package gives you a blast radius that phishing campaigns can only dream about: CI pipelines, developer laptops, production workloads, client devices, all simultaneously. SolarWinds. The XZ Utils backdoor. The Shai-Hulud worm, which self-propagated through 170+ npm and PyPI packages by hijacking GitHub Actions OIDC tokens and quietly minted new publish credentials as it spread. The ByBit developer compromise. These aren’t outliers anymore. They’re the shape of the threat.
I want to dig into the mechanics of how this actually happens, and then look at the ecosystem most developers touch every day: NPM.
How Supply Chain Attacks Actually Work
The first thing to understand is that supply chain attacks aren’t really “vulnerabilities” in the classic sense. A buffer overflow is an accidental weakness. A malicious package is intentional code, written to steal credentials, drop a reverse shell, or exfiltrate environment variables the moment it lands on your machine. Traditional appsec tools were built to find the former. They are largely blind to the latter.
The attack patterns cluster into a few categories.
Typosquatting. Publish
axoisand wait for someone to fat-fingeraxios. Sounds trivial, but it works constantly because developers install packages at high velocity and rarely double-check spelling.Dependency confusion. If your company has an internal package called
corp-auth, an attacker publishes a public package with the same name and a higher version number. Many package managers default to “highest version wins,” and your build pulls the public one instead of your internal one.Maintainer hijacking. Compromise a real maintainer through phishing, credential stuffing, or a missing 2FA setup, and push a poisoned update to a package that already has millions of weekly downloads. The Axios compromise in March 2026 followed exactly this pattern. The XZ Utils backdoor was a slower variant. The attacker spent months building trust as a “helpful” co-maintainer before slipping a backdoor into the build.
The thing that makes all of this so effective is the automation downstream. Unpinned versions, auto-merging update bots, transitive dependencies five layers deep. Once a malicious version hits the registry, it propagates fast.
Why NPM Is the Highest-Stakes Ecosystem
NPM serves tens of billions of downloads a week. A typical JavaScript project today pulls in well over a thousand transitive dependencies. Ten years ago that number was in the dozens. The dependency graph is just structurally enormous, and it’s getting worse.
The specific architectural problem in NPM is the lifecycle script, specifically
postinstall. NPM lets package authors define scripts inpackage.jsonthat run automatically when the package is installed. This was designed for legitimate reasons: compiling native bindings, configuring environments. But it also means arbitrary shell commands execute on your machine the moment you typenpm install. No code review. No second thought. Just immediate execution as your user.There are a few practical mitigations, and they’re worth knowing whether you’re a solo developer or running platform security at a large org.
Disable lifecycle scripts. Either pass
--ignore-scriptsad hoc, or set it globally:npm config set ignore-scripts trueThis breaks some legitimate packages (esbuild, bcrypt, anything compiling native code). To manage that, tools like
can-i-ignore-scriptsscan yournode_modulesand generate an allowlist of packages that genuinely need scripts to run. Frameworks like@lavamoat/allow-scriptsformalize this with a deterministic config you can check into the repo.Use
npm ciin CI, notnpm install. This is non-negotiable for production builds.npm installwill happily resolve newer minor versions inside your semver ranges and rewritepackage-lock.json.npm cirefuses to do that. If the lockfile doesn’t match exactly, the install fails. That’s the behavior you want when the question is “did anything change that I didn’t approve.”Consider switching to pnpm. pnpm 10+ has been quietly building some of the best structural defenses in the ecosystem. Postinstall scripts are off by default and require an explicit
allowBuildslist.blockExoticSubdepsprevents transitive deps from resolving via random Git URLs or tarballs.The killer feature, though, is
minimumReleaseAge. As of pnpm v11 (May 2026), the default is 1440 minutes, so pnpm simply refuses to resolve any package version less than 24 hours old. Most malicious packages get pulled from the registry within hours of being detected. A 24-hour cooldown turns the community into your early warning system, with no behavioral analysis or commercial tooling needed.That last one is the single highest-leverage change you can make as an individual developer. It costs nothing, it doesn’t break your workflow, and it neutralizes most day-zero registry malware before it ever reaches you.
Next post I’ll dig into the Python and Rust sides of this. pip’s
setup.pyexecution problem, Rust’sbuild.rsissue, and the surprisingly mature auditing toolchain the Rust community has built aroundcargo-audit,cargo-deny, andcargo-vet.Sources
- Securing Package Managers: Why NPM, PyPI, and Cargo Are High-Value Targets
- Defending Against NPM Supply Chain Attacks: A Practical Guide
- NPM Ignore Scripts Best Practices
- Mitigating supply chain attacks
- Get safe and remain productive with can-i-ignore-scripts
- The Landscape of Malicious Open Source Packages: 2025 Mid-Year Threat Report
- The Evolving Software Supply Chain Attack Surface
- Introducing OpenSSF’s Malicious Packages Repository
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].
-
Five Modern JavaScript Features That Make the Old Patterns Look Silly
I’ve been doing some reading on what JavaScript has been picking up over the last few releases, and the current batch is unusually good. Cleaner resource management, real Set math, lazy iterators, and a couple of small ergonomic wins that retire some genuinely tedious patterns. So this post is my attempt at summarizing five of the more interesting ones, what they replace, and where each one stands on browser support. I hope it helps if you’re trying to figure out what’s actually shipping versus what’s still a proposal.
1. Explicit Resource Management with
usingIf you’ve written C# or Python, this will feel familiar. The
usingkeyword (and its async siblingawait using) ensures a resource is cleaned up the moment the variable goes out of scope, even if your code throws. Under the hood it looks forSymbol.disposeorSymbol.asyncDisposeon the object.The old way meant remembering to wrap everything in
try/finally:async function fetchUser() { const db = new DatabaseConnection(); await db.connect(); try { return await db.query('SELECT * FROM users WHERE id = 1'); } finally { await db.close(); } }The new way:
async function fetchUser() { await using db = new DatabaseConnection(); await db.connect(); return await db.query('SELECT * FROM users WHERE id = 1'); }No
finally, no forgetting to close the connection on the error path. The cleanup is guaranteed.Browser/runtime support: Chrome 123+, Firefox 119+, Node 20.9+. Safari is still pending.
2. New Set Methods
For years, JavaScript’s
Setwas basically a deduplicated array with a fancy name. If you wanted actual set math, you were converting to arrays and looping. Now the operations are built in and run at engine speed.const userRoles = new Set(['read', 'write', 'comment']); const adminRoles = new Set(['read', 'write', 'delete', 'ban', 'comment']); userRoles.intersection(adminRoles); // shared roles adminRoles.difference(userRoles); // what admin has that user doesn't userRoles.union(adminRoles); // everything, deduped userRoles.isSubsetOf(adminRoles); // trueThat’s it. That’s the whole job. No more
new Set([...a].filter(x => b.has(x)))incantations. The full method set also includessymmetricDifference,isSupersetOf, andisDisjointFrom.These shipped as part of ES2024 and have reached Baseline. Available in Chrome 122+, Safari 17+, and recent Firefox.
3. Iterator Helpers
Until now,
.map()and.filter()only worked on arrays, and arrays load everything into memory. If you’re streaming a 50GB log file through a generator, callingArray.from()on it will introduce you to your operating system’s OOM killer.Iterator helpers bring those same methods to iterators, operating lazily, one item at a time.
The old way:
function* infiniteNumbers() { let i = 1; while (true) yield i++; } const evens = []; for (const num of infiniteNumbers()) { if (num % 2 === 0) { evens.push(num); if (evens.length === 3) break; } }The new way:
const result = infiniteNumbers() .filter(n => n % 2 === 0) .take(3) .toArray(); // [2, 4, 6]It only computes what
take(3)needs. You can chain on an infinite sequence and it just works.These are part of ES2025. Firefox has shipped them, Chrome is in the process of shipping in V8, and Safari’s implementation is roughly half done.
4. Map Upsert
The naming bounced around (early proposals called it
emplace, thenupsert), but the final landing isgetOrInsert(key, default)andgetOrInsertComputed(key, callback). The idea is simple: stop doing the three-step “check, default, fetch” dance every time you group data.The old way:
const wordMap = new Map(); for (const word of words) { const key = word[0]; if (!wordMap.has(key)) { wordMap.set(key, []); } wordMap.get(key).push(word); }The new way:
const wordMap = new Map(); for (const word of words) { wordMap.getOrInsert(word[0], []).push(word); }This is the kind of thing every codebase has a
groupByhelper for. The proposal reached Stage 4 in January 2026, so it’s officially in the spec, but engine implementations are still in progress as of this writing. Worth knowing about, not yet safe to ship without a polyfill.5. Import Attributes
As ES Modules took over, importing JSON natively became a real need. The catch is that just letting
importpull in a.jsonfile is a security problem. If the server quietly serves JavaScript instead of JSON, the engine would happily execute it as code.Import attributes fix that by making you declare the type explicitly. If the file isn’t what you said it was, the engine refuses.
import config from './config.json' with { type: 'json' }; console.log(config.databaseHost);No more
fs.readFileSyncfor config, no morerequirehacks in otherwise-modern codebases. Just an import that’s safe by default.If you’ve seen the
assert { type: 'json' }form in older articles, that was an earlier syntax that got renamed before shipping. The current keyword iswith. Available in Chrome, Edge, Firefox, and Safari since April 2025, plus Node and Deno.The Through-Line
What stands out across these five is that each one retires a pattern that’s been written into JavaScript codebases millions of times. The
try/finallycleanup. The customgroupByhelper. The Lodash imports for set operations. Theforloop with a manual counter because there was no.take()on generators. Thefs.readFileSyncfor loading a config file in an otherwise-modern ESM project.The language is quietly absorbing the utility belt, and the code that’s left looks a lot more like what we meant to write in the first place. Sign me up.
Sources
- Explicit Resource Management — V8
- JavaScript Set methods reach Baseline — web.dev
- Iterator helpers — V8
- Map.prototype.getOrInsert — MDN
- Import attributes — MDN
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].
-
JavaScript Finally Gets a Real Date API
When has working with dates ever been easy? Every language has its own version of the same headaches: time zones, parsing, leap years, arithmetic that does weird things at month boundaries. JavaScript just had some quirks layered on top, extra cruft left over from when the language was first created. Third party libraries like Moment.js or date-fns had to fill in the gaps.
Those days are over. Now we have the Temporal API.
Why Date Was Broken
Let me give you the short version of why
Dateis the way it is. It was inspired by Java’sjava.util.Datefrom the 90s, which Java itself eventually deprecated. JavaScript inherited the design and never let it go.The problems are well-known at this point:
Mutability. Pass a
Dateto a function and that function can change it underneath you.const myDate = new Date('2023-01-01'); function addDays(date, days) { date.setDate(date.getDate() + days); return date; } addDays(myDate, 5); console.log(myDate.toISOString().slice(0, 10)); // '2023-01-06'. Surprise, your original is gone.Time zone confusion.
Datestores milliseconds since the Unix epoch but formats itself in the user’s local time zone. Working in any other zone means reaching formoment-timezoneordate-fns-tz.Parsing roulette.
new Date("2023-01-01")andnew Date("Jan 1, 2023")can return different things depending on the browser and the assumed time zone.Math that lies. Adding a month to January 31st?
Daterolls it forward to March 3rd because February doesn’t have 31 days. That’s not a bug exactly. It’s justDatebeing honest that it doesn’t really understand calendars.What Temporal Actually Fixes
Temporal is a new global object designed from the ground up to address all of this. The design choices are worth walking through because they’re opinionated in the right ways.
Everything is immutable
Every operation returns a new object. Your original data stays put.
const start = Temporal.PlainDate.from('2023-01-01'); const end = start.add({ days: 5 }); console.log(start.toString()); // '2023-01-01' console.log(end.toString()); // '2023-01-06'Different types for different concepts
This is the part I find most interesting.
Datetries to be everything, a timestamp, a calendar date, a wall clock time, all at once. Temporal splits these into distinct types and forces you to pick:Temporal.PlainDate: a calendar date, no time, no zone. Birthdays, anniversaries.Temporal.PlainTime: a wall-clock time, no date.Temporal.PlainDateTime: date and time, no zone.Temporal.ZonedDateTime: fully zone-aware and calendar-aware. The one for global apps.Temporal.Instant: an exact point in time, like epoch milliseconds.Temporal.Duration: a length of time.
Making you pick the right type up front is the whole game. Half the bugs in date code come from pretending a
Dateis one thing when it’s actually another.Time zones and calendars built in
Temporal natively understands IANA time zones (
America/New_York,Europe/Paris) and non-Gregorian calendars (Hebrew, Islamic, Japanese). No external library needed.Math that respects the calendar
const t = Temporal.PlainDate.from('2023-01-31'); const nextMonth = t.add({ months: 1 }); console.log(nextMonth.toString()); // '2023-02-28'It clamps to the end of the month instead of rolling over. That’s almost always what you actually wanted.
Comparisons and Diffs
A couple of quick ones, because these are the operations you do constantly.
Comparing two dates:
const t1 = Temporal.PlainDate.from('2023-01-01'); const t2 = Temporal.PlainDate.from('2023-01-01'); console.log(Temporal.PlainDate.compare(t1, t2) === 0); // trueNo more
getTime()dance to compare primitives. There’s an actual comparison function.Finding the difference:
const start = Temporal.PlainDate.from('2023-01-01'); const end = Temporal.PlainDate.from('2023-12-31'); const diff = start.until(end, { largestUnit: 'days' }); console.log(diff.days); // 364No more dividing milliseconds by
1000 * 60 * 60 * 24and hoping DST doesn’t mess you up.Should You Use It Yet?
Check your runtime. Browser and Node support has been landing, but you’ll want to verify Temporal is available where you’re shipping, or use the official polyfill while you wait.
For most date and time work, this replaces Moment.js and date-fns entirely. Moment has been in maintenance mode for years. Temporal gives you the good parts of those libraries as a standard, immutable, well-typed API.
Datewill stick around forever for backwards compatibility. But for new code, use Temporal. The API is better, the semantics are saner, and less bug-prone.I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].
-
npmx.dev Is the NPM Frontend We've Been Asking For
If you’ve spent any time on npmjs.com, you know the drill. You land on a package page, eyeball the tarball size, squint at the dependency list, then bounce out to bundlephobia, then to Are The Types Wrong, then to Socket.dev, just to figure out if this thing is safe to install. That’s not a workflow. That’s a scavenger hunt.
For years, the JavaScript community has been filing requests on the official npm tracker asking for this stuff to live in one place. As Andrew Nesbitt notes, native dark mode was the single most upvoted request on the tracker for something like five years before it shipped. Real install sizes (transitive, not just the tarball). UI warnings about compromised packages and hidden postinstall hooks. Concrete version resolution instead of squinting at semver ranges. The list is long, and it mostly went nowhere.
So the community built npmx.dev instead.
What It Actually Is
It’s important to note: npmx.dev is not a separate registry. It doesn’t mirror packages, and
npm installstill pulls from the official Microsoft/GitHub-owned registry. What npmx is, strictly, is an alternative frontend. It hits the official npm APIs in real time, caches the results at the edge, and renders a much better page.The stack is Nuxt on top of the full VoidZero toolchain with Vite, Vitest, Rolldown, oxlint, oxfmt, and the other usual packages you’d expect. It also leans heavily on SSR and ISR for near-instant page loads. VoidZero (the company behind Vite) sponsors the project, which makes sense, since it’s open-source MIT and the operational costs are basically web traffic and compute, not package storage.
What You Actually Get
I really like the dependency view. Instead of a flat list of what the package declares, you get an expandable tree with recursive vulnerability tracking via OSV. You can drill into transitive dependencies and see which ones are flagged. That’s the thing every security-conscious developer has been doing manually for years.
A few other wins worth calling out:
- Real install size. Not the tarball. The actual disk footprint with all dependencies resolved.
- postinstall scripts surfaced. If a package is going to run arbitrary code on your machine at install time, npmx puts that on the page where you can see it. Not buried in the manifest.
- Concrete resolved versions displayed alongside the semver range, so you know what you’re actually getting.
- Banners suggesting lighter alternatives for bloated legacy packages. Opinionated, and I’m here for it.
A Quick Note on OSV
The vulnerability data comes from the OSV ecosystem, and it’s worth understanding what that is, because it’s one of the more important pieces of open-source infrastructure right now.
OSV is a centralized aggregator that pulls from GitHub Security Advisories, PyPA, RustSec, the Global Security Database, and many ecosystem-specific feeds. The schema is standardized JSON, machine-readable, and maps vulnerabilities to exact versions or commit hashes. That matters because it’s what lets automated scanners avoid the false-positive flood that makes most security tooling annoying to use.
It’s an OpenSSF project under the Linux Foundation. Google maintains the infrastructure; GitHub, the Rust Foundation, the PyPA, Red Hat, and a long list of ecosystem maintainers contribute data. Vendor-neutral, free, and the boring kind of governance you want for security data.
Making It Your Default
Here is how you can make it the actual default npm frontend. Since npmx.dev maintains URL parity with npmjs.com, you can redirect every npm link you click without thinking about it. A couple of options depending on your setup.
A few options:
Kagi users can add a global URL redirect rule:
^https://www.npmjs.com|https://npmx.devClick an npm link in your search results, land on npmx instead.
Browser extensions work for everyone else. There’s a dedicated
npmx-redirectextension for Chrome and Firefox that does only this one thing. Or use Redirector with a regex rule:- Include:
^(?:https?://)?(?:www\.)?npmjs\.com/(.*) - Redirect to:
https://npmx.dev/$1
Custom site search. In Chrome, Brave, Arc, or Safari, add a new site search with shortcut
nx(or@npm) pointing at:https://npmx.dev/search?q=%sNow you type
nx, space,zod, enter. Done. You never see npmjs.com again unless you want to.Why This Matters
The npm registry is critical infrastructure for, conservatively, a huge chunk of the software running on the internet. The fact that the community had to build its own frontend to surface basic security and observability data tells you something about where the priorities have been.
I’ll be using npmx as my default going forward. I’m not going back.
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].
Sources
-
What I Learned Building My First Chrome Extension
I built a Chrome extension to navigate Letterboxd movie lists with keyboard shortcuts. Rate, like, watch, next. Here’s what I learned.
The Idea
I going through the Letterboxd lists, but wanted a better way. “Top 250 Films,” curated genre lists, friends’ recommendations. The flow becomes tedious: click a movie, rate it, go back to the list, find where you were, click the next one. I wanted to load a list into a queue and step through movies one by one with keyboard shortcuts.
So I did what any reasonable person would do and built a Chrome extension for it.
The Framework Graveyard
The Chrome extension ecosystem has a framework problem. CRXJS, the most popular Vite plugin for extensions, was being archived. Its successor, vite-plugin-web-extension, was deprecated in favor of WXT. WXT is solid but it’s another abstraction layer that could go the same way.
I went with plain Vite and manual configuration. Four separate Vite configs, one per entry point (content script, background worker, popup, manager page). A simple build script that runs them sequentially and copies the manifest. No framework dependency that could die on me.
For the UI I used React and TypeScript. Not because the extension needed React, most of the work is content scripts and background messaging, but the popup and settings page benefit from component structure.
Four Separate Worlds
One thing I learned was a Chrome extension isn’t one app. It’s four separate JavaScript contexts that can’t directly share state:
- Content scripts run on the webpage (letterboxd.com). They can read and modify the DOM but can’t access chrome.tabs or other extension APIs.
- Background service worker runs independently. It handles messaging, storage, and tab navigation. It can die at any time and restart.
- Popup is a tiny React app that opens and closes with the extension icon. It loses all state when closed.
- Extension page (the manager) is a full tab running your own HTML. It persists as long as the tab is open.
They communicate through
chrome.runtime.sendMessageandchrome.storage.local. This is an important architectural challenge you need to be aware of. If you’ve never built an extension before, it could trip you up.Letterboxd’s DOM Is a Moving Target
The existing open-source Letterboxd Shortcuts extension uses selectors like
.ajax-click-action.-liketo click the like button. Those selectors don’t exist anymore. Letterboxd has migrated to React components, and the sidebar buttons (watch, like, watchlist, rate) are loaded asynchronously via CSI (Client Side Includes). They’re not in the initial HTML at all.I had to inspect the actual loaded DOM to find the current selectors:
.watch-link,.like-link,a.action.-watchlist. The rating widget still uses the old.rateitpattern with adata-rate-actionattribute and CSRF token POST.If you’re building an extension that interacts with a third-party site’s DOM, expect the selectors to break. Build your DOM interaction layer as a thin, isolated module so you can update selectors without touching the rest of the codebase.
Service Workers Can’t Use DOMParser
My list scraper used
DOMParserto parse HTML responses. Works fine in tests (jsdom), works fine in content scripts (browser context), fails completely in the background service worker. Service workers don’t have access to DOM APIs.I rewrote the parser to use regex. Less elegant but it works everywhere. If I were doing it again, I’d run the parsing in a content script and message the results back to the background worker.
The Build System Is Simpler Than You Think
I expected the multi-entry-point build to be painful. It wasn’t. Each Vite config is about 20 lines. Content script and background worker build as IIFE (single file, no imports). Popup and manager build as standard React apps. The build script is 30 lines of
execFileSynccalls.One gotcha: asset paths. Vite defaults to absolute paths (
/assets/index.js), but extension popups and pages need relative paths (./assets/index.js). Addingbase: './'to the popup and manager configs fixed it.TDD Was Worth It (For the Right Parts)
The extension has four pure logic modules: rating double-tap behavior, auto-advance detection, queue state operations, and keyboard shortcut matching. These are the core of the extension and they’re completely testable without a browser.
Writing tests first caught edge cases I wouldn’t have thought of. What happens when you press the same rating key on a movie that was already rated in a previous session? What if the queue is empty and someone hits “next”? The tests document these decisions.
For DOM interaction code, the Letterboxd API layer, overlays, CSI-loaded content, unit testing isn’t practical. I tested those manually.
What I’d Do Differently … or might change
Start with the DOM. I built the pure logic first and the DOM interaction last. This meant I didn’t discover the CSI loading issue, the changed selectors, or the DOMParser problem until the end. Next time I’d build a minimal content script first, verify it can interact with the target site, then build the logic on top.
Use fewer Vite configs. Four config files with duplicated path aliases is annoying. A single config with a build mode flag, or a shared config factory function, would be cleaner.
Consider the popup lifecycle earlier. Popups close when you click outside them. Any state they hold is gone. I designed around this (the popup is stateless, it queries the background on every open), but it’s easy to get wrong if you don’t plan for it.
The Result
The extension loads any Letterboxd list into a queue, navigates through movies one by one, and lets me rate/like/watch/watchlist with single keystrokes. Auto-advance moves to the next movie when I’ve completed my actions. A dark-themed manager page shows the full queue and lets me customize every shortcut.
It’s a personal tool right now, so not published to the Chrome Web Store. But it’s made going through movie lists is pretty cool. Sometimes the best software is the kind you build for yourself!
If you’re a developer, I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week. Or you can find me on Mastodon at @[email protected].