DEV Community

Vladimir Klepov
Vladimir Klepov

Posted on • Originally published at thoughtspile.github.io on

Open source starter pack for JS devs

So you’ve decided to open-source your project. Amazing! Bad news first: writing code is only the beginning. The information for library authors on the web is surprisingly fragmented, so I’ve decided to put together a list of things to keep in mind when open-sourcing a JS library:

  • Decent docs, an OSS license, TypeScript definitions and a changelog are a must if you want anyone to use your library.
  • Build setup of a library is different from that of an app — but even simpler, if you know what to do.
  • peerDependenies are a thing (also, npm / yarn version resolution is a nightmare).

I’ll give you enough info on each point to get started without going too deep.

I assume you’re comfortable with babel and webpack (or any other build toolchain), and have followed some basic tutorial on publishing an npm package. I also suggest you host your code on GitHub — it has a ton of features useful for OSS, and any other choice is just bizarre in 2021. Let’s pick up where you’ve written some code, pushed it to a public github repo, set up your package.json with name, version and main, and got npm publish to pass. There’s still a bumpy ride ahead.

Documentation

Your library will be used by people who have no idea what it’s supposed to do, and how it works. For a small project, don’t block yourself into building a fancy docs website — a readme.md file in the repo root is enough. Writing the actual docs is a better way to spend your time. Make sure to include:

  • What problem does your project solve? Does your library do something I need?
  • How to install it? Even if it’s npm i my-project, don’t make me guess the package name.
  • A basic usage example. Just something to copy-paste and get started with it.
  • Full API docs. What can the library do? Does it cover all my use-cases? What arguments do I pass where? I shouldn’t have to read the source to answer these questions.

See markdown cheatsheet if you’re not fluent yet.

License

(I’m not a lawyer, but this is my understanding). A project without an explicit license is closed source by default — the users can look at the code, but can’t legally use it. Choose an open-source license, copy the text into a LICENSE file to your repo, put the name in the license field of package.json, and probably mention it in the readme. MIT license is a good choice if you just want everyone to use your code and don’t have a strong opinion on everything must be open-source.

Package structure

When building an app, you have some transpiler + bundler (probably babel + webpack, but anything goes) setup to turn your source into someting that runs in a browser. If you don’t want to make your users jump around patching webpack config, you need some of that, too. Exactly how you should package your code is a complex topic, but let me scratch the surface for you. TLDR:

  • If you use extended JS (JSX / TS / whatever), or want to support older runtimes with zero setup, do a babel (or friends) pass.
  • Have a ES-module build for tree-shaking, and a legacy CommonJS build.
  • Be clear about supported browsers / node versions. Too much = bloat, too little = broken apps for users who don’t do extra setup (they won’t).
  • Never use global polyfills, and prefer well-supported APIs when possible.
  • Don’t bother bundling.

Just a touch deeper:

Transpiling

The code you ship must be standard ES to “just work” for your users. Convert JSX / TypeScript / Vue SFC / other fancy syntax down to JS with babel, tsc, or whatever esbuild you enjoy, and point main to the built version instead of your raw source.

The exact ES target (ES2020 / 6 / 5) is up to you — pick a browser / node target and stick to it. Your users can transpile further down, if they’re determined, but undoing an unnecessary transform is next to impossible — it becomes bloat in the final app. Also, some babel transforms (like unicode regex) are very verbose — have a look at the generated code once in a while.

The most important question is what to do with import / export. Read on.

Tree-shaking

If your library runs in a browser, and it does more than one thing, you’d better support tree shaking — otherwise, every app using your library will ship useless dead code to the end users’ browsers. To get started, create a modular build:

Now a module-capable bundler can pick it up and remove unused code from the final bundle. Cool.

However, node <= 12 does not support import / export syntax without a flag. I’m lost in all the new "type": "module" / ".mjs", but shipping a fallback CommonJS build works fine in all node versions and older bundlers. For a library that works both client- and server-side, keep a CommonJS (exports / require) version (generated with a pass of babel with modules: 'commonjs'), referenced in main field of package.json.

There’s more to tree shaking — some patterns are not tree-shakable, it affects your API choices, complex topic. We’ll save for it later — supporting basic tree-shaking with ES modules is always a good start.

Polyfills

Should your library include the polyfills for recent browser APIs you use? It’s a surprisingly debatable issue among OSS authors. Problem, short version: in most app setups, babel doesn’t process node_modules, possibly breaking the final app in older browsers. But if you include a polyfill, removing it is from an app that only targets modern browsers is super hard. Also, the final bundle is likely to contain duplicate polyfills of one API.

I’ll stick with Rich Harris on this one:

  • Never include global polyfills that patch BuiltIn.prototype or window — they can clash with other global polyfills, and are not tree-shakable.
  • Use helper functions (aka ponyfills) like export function startsWith if for code reuse.
  • Prefer well-supported APIs if possible — using str.indexOf(...) === 0 instead of startsWith is not that hard.
  • Clearly say what targets you support in the readme. Don’t pretend to support IE11 if you’re not very serious. Maybe provide instructions on setting up babel-loader to process your library.

Bundling

You could bundle your code, but I think it creates more problems than it solves at the start. How to exclude your dependencies from the bundle? What libraryTarget do you need? Are we sure bundling does not accidentally create non-tree-shakable logic? I’d stick with babel CLI and ship the code as separate JS files: npx babel src --out-dir dist.

Typings

TypeScript is a major player in the JS ecosystem these days. Libraries without TS types explode in TS projects with Could not find a declaration file for module '...', forcing users to either @ts-ignore it or slap together custom ambient declarations. Some lazier developers will probably move to the next library, and I won’t blame them.

Shipping TS types is actually easy: if you write in TypeScript, tsc --declaration (--declarationOnly if you build with babel, see docs) into your build folder. If you write pure JS, it’s even easier — just write a custom index.d.ts file describing your library, and copy it to build folder. Now point types field in package.json to the declaration entry point, and you’re all set! Don’t worry about @types/* pacakges for now. See full TS docs on publishing if you have any trouble.

I don’t know much about Flow, and no one ever asked me to support it, but if you’re a fan, see SO tips on doing that.

Understand dependencies vs dev/peerDependencies

In app development dependencies (npm i) vs devDependenices (npm i --dev) is not a real issue — sure, dev is for your build pipeline, dependencies for real runtime libraries, but it mostly works fine if you mess up. The difference is critical for libraries, though:

  • devDependencies are not installed after npm i your-lib.
  • dependencies are automatically installed along with your package. If the user (or some other package) requests a different version of the same dependency, they may get duplicated, but at least it usually works.
  • peerDependencies allow you to reference a package explicitly installed by your users. This ensures the dependency instance is shared between user code and your library, which is crucial for plugins — react components, express middlewares, etc. In effect, this forces a single dependency version per app — your users can’t upgrade to react 18 until you support it. Assume the users have to install peers manually — sure, npm 7+ installs them automatically, but yarn and pnpm don’t. People hate manually installing stuff they don’t care about to get the project to build, so don’t overuse peers.
  • bundledDependencies and optionalDependencies? You don’t need them.

Basic guideline: all the runtime dependencies go into dependencies. Plugins should put the main library into peerDependencies.

Changelog

I install your library, and I’m happy with it. Some time later, it’s friday evening and I can’t get real job done any more. I decide to update my dependencies, and discover that your library moved from 1.0.3 to 2.3.0.

  • Have you fixed some important bugs, so that I need to update right now?
  • Have you added new features I might enjoy?
  • What’s the breaking change in v2, and how do I update?

To answer these questions, I’d love to see a changelog saying what changed in every version since 1.0.3. Both GH releases and a CHANGELOG.md in the root work fine. Otherwise, I’ll have to read the commit / PR list, which is likely to make me very sad.


So, here’s my list of stuff to keep in mind when publishing a JS library:

Must have:

  1. A readme with a problem statement, installation command, hello world example, and full API docs.
  2. A license: full text in project root, name in the readme and in package.json
  3. TS typings (unless it’s a CLI tool).
  4. Changelog in GH releases or a changelog.md.

Managing dependencies:

  1. Runtime dependencies go into package.json
  2. Plugins make the master library a peerDependency

Build setup:

  1. Transpile the code to standard JS.
  2. Running in browsers? Ship es-modules entrypoint in package.json modules for tree shaking.
  3. Running on node? Ship CommonJS entrypoint in main.
  4. Avoid global polyfills.
  5. Don’t bundle.

Discussion (0)