Photo by Sticker it on Unsplash
Are You in the NPM Package Business? Some Facts I Learned on the Road
I love back-end development. I love C#. I would have never guessed, in a million years that I would end up creating not 1, not 2, but soon-to-be 3 NPM packages. C# is the love of my life, not to mention it ranks in the top 10 for performance in the HTTP servers category, putting the very popular NodeJS to shame, in position #212 according to the 2022 benchmarks. So how did I end up creating NPM packages? Good question. The short answer: Necessity.
A Bit of History
Just for the curious, this is the longer answer to the question above. Feel free to skip if you're not interested.
It all started when I saw the mess some contractor workers had done with the React projects' configurations at work. It was supposed to use dotenv
and .env
files, but I never understood why I had to configure the same value in 4 different places. Since I was hired as senior developer, I had the power to change it, so I did.
Did I mention I love C#? Can't remember. 🤔 Anyway, I love C# and I love .Net Configuration. The sight of the mess I found in the React projects made me yearn for .Net Configuration. So I said to myself: "Myself! You can create the JavaScript equivalent of .Net Configuration. Aren't you a senior, after all?". Myself was spot on, so I did. The first version was all in a single file, with basic merging functionality and a cool feature to build URL's out of configuration values found in the JSON. It was so good that I decided the world had to have it. Enter wj-config. The package works for browser and server (NodeJS) projects and everything was OK once more in the React (and NodeJS) world with this.
Time went by and destiny introduced me to my current lover: Svelte. At this point, I have been promoted to Technical Team Leader, so here comes the leader saying "To hell with React! We'll move all UI to Svelte". Being drunk with power is fun. So enter single-spa
in my life. But I didn't want webpack! I wanted Vite, the one thing single-spa
is not set up for. So enter once more a new NPM package: vite-plugin-single-spa. This one is currently ongoing, but the summary is: I already implemented single-spa
in the React projects and started introducing Svelte.
The third package is not yet complete, but it is the extraction of the merge algorithm in wj-config
into a new package, wj-merge. The merging algorithm is something that can prove very useful for a variety of situations. This is why I am refactoring it as a standalone package.
Ok, so that's the story. Let's go to the main course: My learnings.
Learning 1: People Hate Long Names
This is kind of shocking if one thinks about it. Give people an object named $
, or _,
or a function named t()
and they love it! Give them meaningful and significant names and they'll hate it. I don't know why. Is it because people hate typing? Is it because the short/cryptic names are "cool"? I don't know. The learning here is: Try to use short names for things. The crowd will go nuts.
Learning 2: Typing a Package is Difficult
Who loves Intellisense? I do, and I bet most of you, avid readers. So what is an NPM package that doesn't provide Intellisense? A failure, that's what. If you want to be in the business of packaging for JavaScript, you better learn your TypeScript. And no, JsDoc is not enough, no matter how much hype people give it because Rich Harris (the creator of Svelte) dumped TypeScript. JsDoc cannot define complex structures. You'll still need some TypeScript in definition files. Furthermore, packages expect .d.ts
files in them. So let's talk about JsDoc when it no longer piggyback rides on TypeScript's back.
When I created wj-config
, I didn't know TypeScript. It was my first TypeScript-based project. I thought it went well back then. Now I can't believe how awful it is. After I conclude the extraction of the merging algorithm into its own package (wj-merge
), I'll release v3.0 with much better TypeScript. For example, I have reached a point with wj-merge
where the merged object is fully typed.
Learning 3: Learn How Package.json Works
This is the most difficult of them all. One must read countless articles and issue posts in order to gain some understanding. To complicate the process, things change over time: What needed to be done 4 years ago is usually superseded. To make matters even worse, there are features of package.json that had existed for a long time, and yet TypeScript have only recently started to support.
Learning 3.1: The main Field
This is something that theoretically isn't needed anymore if you use a more modern approach like Svelte does using the exports
field. However, at least a year ago, if I didn't specify the main
field, Visual Studio Code would complain. So the learning here is: Try exports
first, but if it misbehaves, see if adding main
solves the issue.
Of course, if all you need is one entry point, you can probably stick to the
main
field and forgetexports
.
Learning 3.2: Name your .d.ts File the Same As the Package
A year or so ago, if I had index.d.ts
in wj-config
, it wouldn't work in Visual Studio Code, but started working when I renamed it to wj-config.d.ts
. Maybe this is no longer relevant, but have it in the back of your head, just in case.
Learning 3.3: The exports Field
If your package is meant to have multiple "entry points" like vite-plugin-single-spa
, the types
field is not enough because it can only hold the name of one definition TS file. Use the verbose version of the exports
field instead. Here's an example from vite-plugin-single-spa
:
"exports": {
".": {
"import": "./index.js",
"types": "./vite-plugin-single-spa.d.ts"
},
"./ex": {
"import": "./ex/index.js",
"types": "./ex/index.d.ts"
}
},
This means that consumers of this package can import from "vite-plugin-single-spa"
or "vite-plugin-single-spa/ex"
. Because each path has its own types
field, you can specify all of your definition TS files. Fun fact: This only works with TypeScript v4.7 or newer.
Important: Specifying the
exports
field limits consumers of the package to import from the routes specified here. No other routes can be used to import.
Learning 4: Respect Semantic Versioning
I guess this one is pretty much given, but just in case: If don't want to be yelled at, respect semantic versioning. Your consumers will be very angry if all of the sudden a patch version increase carries a change in the exposed API. If you don't know semantic versioning, it is time for you to learn.
Also take the time to learn code strategies to create backwards-compatible upgrades, such as adding optional parameters, function overloads or creating new functions or objects but respecting the old ones. For example, you could move the logic of an existing function to a completely new function, and then calling this new function from the old function. This preserves the old signature while providing new functionality with the new function. This way your consumers get the improved logic, but don't have to worry about API changes.
Learning 5: Documentation!
No matter how good your package is: If it isn't well documented, people will complain. Learn to create good README files, and even Wiki's. The wj-config
package has a ton of features. Its README grew up to 50kb in size! It was loooong. I finally reduced it to just some quick examples and moved everything else to the repository's Wiki.
Learning 6: You'll Screw Deployments Up
I went from wj-config
v1.0.0 to v1.1.0 so fast I couldn't believe it. To this day, I continue to screw things up. From commented code you forget to uncomment, to forgetting to update the README, to forgetting to update your .d.ts
files. Anything can happen. The learning here is: Use pre-releases. When I worked on version 2.0 of wj-config
, I started doing them. I published 3 betas and 3 release candidates before publishing v2.0.0. The package's version history clearly tells the tale.
This learning has a corollary: When starting a new NPM package, start with v0.0.1. Versions that start with zero are regarded as "experimental" versions. Interestingly enough, this carries another corollary: The command npm version patch --preid beta
doesn't append "beta"
to an experimental version. So don't try to do pre-releases of experimental versions. Maybe this is a bug in npm
, I don't know.
Learning 7: Don't Do Manual Deployments
I cannot teach you too much about this one as I haven't set up CI/CD for my NPM packages, but even if I haven't taken this learning into practice, I am fully convinced that this is very important. It will help you minimize your deployment issues. At the very minimum, do what I currently do: Script the deployment. I script using PowerShell, but the scripting language is largely unimportant.
Learning 8: Always Do Unit Testing
Never pretend to deploy a package that doesn't have unit testing. I have been coding for 20 years, and after 20 years I still cannot create bug-free code on the first try.
This learning also has a corollary: Always run unit testing before deploying. Then you won't screw a deployment up because you forgot to uncomment some code (true story).
Learning 9: Don't Spam the Console With Messages
It is incredibly rude for NPM packages to log things to the console, even if it is with console.debug()
. The way to work this is to ask for a logger or logger factory function/object. If the consumer of the NPM package wants to see the logging from the NPM package, they can provide the logger or logger factory by following the package's instructions. An example of such package is @azure/msal-browser
, if you are curious.
The debug NPM Package
This is an incredibly popular package found in countless other packages. I don't like it. It forces you to a specific logger. What if I wanted telemetry? No way, no how. Providing a logger object is, in my opinion, the best route.
Learning 10: Not All Packages Require Bundling/Minification
In my tests with vite-plugin-single-spa
, I noticed that rollup would minify already-minified sources. In 100% of the cases I saw, this actually increased the size of the bundle: Minifying a minified source increases its size. The learning here is: At the very least provide a non-minified version of the code if it is expected to be bundled and minified.
Other cases require no bundling or minification at all. vite-plugin-single-spa
is my example. This is code that only runs during the building or serving process in Vite, and the parts that do get into the user's bundle is minified by Vite. This means that providing a minified version of this package is wasted effort.
The opposite case is wj-config
, which could be consumed by means of a script tag in the browser. This is something I did not foresee, and v3.0 will have a bundled + minified version in it. It will continue to provide the unminified source so people can bundle and minify it when installing it as a package in projects.
Learning 11: Forget CommonJS Modules
Write all your JavaScript in ES modules. It works everywhere. Nuff said.
Learning 12: Review Issues Daily
If you decided to go public with your NPM package (or pretty much any open-source software), be kind to your consumers and keep an eye out for reported issues. Ideally, reply within 24 hours. Even if it is your project, you don't get notified unless you start watching it. Watch your own projects to get e-mail notifications of new issues.
Conclusion
There is nothing trivial or simple in the NPM packaging business. It requires effort and a methodical mindset. It is usually never free of errors and is filled with best practices.
Happy coding!