Skip to content

Packaging with npm

Exercise packaging-npm-annex.pdf · PDF

Run npm install in your project and read the last lines: hundreds of packages. How many of those did you write? None. Each one is something somebody packed and published, and your app stands on all of them.

On 22 March 2016, a developer called Azer Koçulu removed his packages from npm after a dispute over a name. One of them was left-pad: 11 lines that add spaces to the left of a string. Within hours, thousands of projects around the world could not be installed or built, among them Babel and React, because something they depended on depended on those 11 lines. npm restored the package and changed its rules. Publishing is a promise to strangers. On this page you make your first box, and learn to read what is inside before sealing it.

What you will learn

  • Describe the route of a package: src/lib, dist, tarball, registry, node_modules
  • Build a package with svelte-package and npm pack
  • Explain the package.json fields a library needs: name, version, files, exports, peerDependencies
  • Read a tarball listing, and keep tests, stories and leftovers out of the box
  • Install a library from a tarball in another project
  • Choose the next version number with semantic versioning
  • The trap: a Svelte package is not compiled, and npm pack must never be trusted blindly

The route of a brick

A package is a folder of files plus a package.json, compressed so other projects can install it. The compressed file is called a tarball (.tgz). The registry is the shop where packages are published: registry.npmjs.org.

flowchart LR
    S["<b>src/lib/</b><br>the workshop"] -->|"svelte-package"| D["<b>dist/</b><br>a clean copy"]
    D -->|"npm pack"| T["<b>.tgz</b><br>the sealed box"]
    T -->|"npm publish"| R["<b>registry</b><br>the shop"]
    R -->|"npm install"| N["<b>node_modules/</b><br>another project"]
From, to Command What happens
src/lib/ to dist/ svelte-package A clean copy of the library, plus generated .d.ts type files so that the buyer's editor can autocomplete
dist/ to .tgz npm pack The sealed box: dist/, package.json and the README in one compressed file
.tgz to registry npm publish The box goes to the shop. Public, for everyone
Registry to node_modules/ npm install Another project buys it

We stop at the box. npm install ./notebox-ui-0.1.0.tgz installs straight from the file: the same result for the buyer, no account needed, and nothing public that you cannot take back.

Careful: a package is not a .jar

A Svelte library ships its .svelte source files, and the buyer's project compiles them together with its own. That is why Svelte itself is a peer dependency: the buyer brings the compiler.

Pack it

1. Install

npm install -D @sveltejs/package publint

publint checks your package for packaging mistakes. Check too that src/lib/index.js exports your components, as you did in Methods, snippets and events.

2. Write the label

Edit package.json. Remove "private": true, because npm refuses to publish a private package. Then set a real name and version, and add these fields to what you already have:

package.json (the fields to add or change)
{
    "name": "notebox-ui",
    "version": "0.1.0",
    "description": "NoteBox UI: reusable Svelte 5 components",
    "license": "MIT",
    "scripts": {
        "prepack": "svelte-kit sync && svelte-package && publint"
    },
    "files": ["dist", "!dist/**/*.test.*", "!dist/**/*.stories.*", "!dist/**/*.mdx"],
    "sideEffects": ["**/*.css"],
    "svelte": "./dist/index.js",
    "types": "./dist/index.d.ts",
    "exports": {
        ".": {
            "types": "./dist/index.d.ts",
            "svelte": "./dist/index.js"
        },
        "./tokens.css": "./dist/tokens.css"
    },
    "peerDependencies": {
        "svelte": "^5.0.0"
    }
}
Field What it says
name, version What the box is called, and which edition it is
files What goes in the box: dist, minus tests, stories and MDX pages. The ! means "except"
exports The doors of the box. "." is import { Button } from 'notebox-ui', and "./tokens.css" is import 'notebox-ui/tokens.css'. No door, no import
peerDependencies "You must already have Svelte 5." The ^ means any 5.x
sideEffects Tells bundlers not to throw away CSS files, even if nothing seems to use them
prepack npm runs this by itself before npm pack and npm publish. It rebuilds dist and runs publint

A peer dependency is a package your library needs but does not bring. It says: use the one in the buyer's project. If Svelte were a normal dependency, the app could end up with two copies of it.

3. Seal it

npm pack

Read the output, line by line. This is the moment of truth:

src/lib -> dist
Running publint v0.3.25 for notebox-ui...
Packing files with `npm pack`...
Linting...
All good!
npm notice Tarball Contents
npm notice 1.1kB README.md
npm notice 1.6kB dist/assets/favicon.svg
npm notice 1.3kB dist/components/Button.svelte
npm notice 1.1kB dist/components/Button.svelte.d.ts
npm notice 1.9kB dist/components/NoteRow.svelte
npm notice 1.5kB dist/components/UndoBar.svelte
...
npm notice 268B dist/index.js
npm notice 312B dist/tokens.css
npm notice 1.5kB package.json

Is there anything in this box that should not be there? Look again: dist/assets/favicon.svg. Nobody put it there on purpose. The project template left a favicon in src/lib/assets/, and everything in src/lib is copied.

Move the file out of the library, to src/assets/favicon.svg, and change the import in src/routes/+layout.svelte:

src/routes/+layout.svelte (the import)
import favicon from '../assets/favicon.svg';

Run npm pack again, and the leftover is gone. Is anything missing? No stories, no tests: files did its job.

The trap: \"npm pack ships my whole project\"

It ships what files says, plus package.json and the README. Never node_modules, never src/routes. And never trust that: read the listing.

4. Be the customer

Use a second, empty project (npx sv create consumer, minimal template) in a folder next to notebox:

npm install ../notebox/notebox-ui-0.1.0.tgz
consumer/src/routes/+page.svelte
<script>
    import { Button, NoteRow } from 'notebox-ui';
    import 'notebox-ui/tokens.css';

    let count = $state(0);
</script>

<NoteRow title="Installed from a package" preview="Clicked {count} times" />
<Button onclick={() => count++}>Click me</Button>

Run npm run dev: a NoteBox button, in a project that has never seen Button.svelte. Type <Button variant=" and the editor offers the three variants. That is the .d.ts file, generated from your JSDoc.

A package that only works on its author's machine is not a package. So give your .tgz to a classmate, and ask them to render two of your components using only your Storybook Docs.

When it goes wrong

What you see Why
publint errors about exports The path in exports does not exist in dist. Usually src/lib/index.js is missing or exports nothing
Components with no style The tokens were not imported (import '<name>/tokens.css'), or tokens.css is not in exports
Failed to resolve import "<name>" The tarball was installed into the wrong folder
Stories or tests in the listing The ! lines are missing from files
A component imports from src/routes That folder does not exist in the box. Inside a library, use relative imports: ./Button.svelte

Both dist/ and *.tgz are build outputs: add them to .gitignore.

Which number moves?

Semantic versioning (semver) writes a version as MAJOR.MINOR.PATCH. The number is a message to the people who use your library: it tells them whether updating is safe.

Number You changed They must
PATCH A bug fix. 1.4.2 to 1.4.3 Do nothing. Safe to update
MINOR Something new; old code still works. 1.4.2 to 1.5.0 Do nothing. New things are available
MAJOR Something old changed or disappeared. 1.4.2 to 2.0.0 Read the notes and change their code

Button gets a new optional prop, size="lg"? Minor. What if the default of size changes from md to sm? Major: every <Button> without size now looks different, and nobody's code changed. A default is part of the contract.

Publishing for real

Nobody in the class publishes to the public registry: names are first come, first served, and accounts need personal e-mail addresses. But this is the rest of the route:

Step Note
An account at npmjs.com, then npm login Needs an e-mail address and two-factor authentication
A free name npm view <name> answers 404 if nobody has it. Or use a scoped name, @yourname/notebox-ui, published with npm publish --access public
npm publish --dry-run The full rehearsal, which uploads nothing. Read the listing one last time
npm publish Once package@version has been used, it can never be used again. To fix it, you publish 0.1.1

That last rule, and the limits on unpublishing, exist because of those 11 lines. Today a package can only be removed freely in its first 72 hours, and only if nothing else in the registry depends on it. After that, stricter conditions apply.

Key takeaways

  • The route: src/lib, svelte-package, dist, npm pack, tarball, npm publish, registry, npm install, node_modules.
  • files decides what goes in the box and exports decides what can be imported. Read the listing every time.
  • A Svelte package ships source files. Svelte is a peer dependency: the buyer brings the compiler.
  • prepack rebuilds and checks the package automatically before every npm pack.
  • Semantic versioning: patch for a fix, minor for something new, major for something that breaks. Changing a default is a major change.
  • A published version can never be overwritten. Publish the next one.

Quick check

1. What does npm pack produce?
  • A public page for your library on the npm website
  • A folder called dist with the compiled library
  • A compressed .tgz file with what files lists
  • A new node_modules folder for your project

npm pack seals the box, called a tarball, without uploading anything. svelte-package is what makes dist, and npm publish is what sends it to the registry.

2. What goes in the files field of package.json?
  • What is included in the package when it is packed
  • Which paths other projects can import from it
  • Which packages must be installed in the project
  • Which files the editor must show autocomplete for

files decides what is in the box, and the ! prefix means "except". The paths other projects can import are decided by exports.

3. Why is Svelte a peer dependency of a component library?
  • npm does not allow Svelte as a normal dependency
  • A library only needs Svelte during its own tests
  • It makes the library work in projects with no Svelte
  • The buyer's project compiles the components with its own Svelte

The package ships .svelte source files, so the app's compiler handles them. A peer dependency says "use the one you already have", which avoids two copies of Svelte in one app.

4. A package has tokens.css in dist, but import 'my-ui/tokens.css' fails. What is most likely missing?
  • A sideEffects line that mentions the CSS file
  • An entry for ./tokens.css in the exports field
  • A peerDependencies entry for the CSS file
  • A prepack script that copies the CSS file

exports lists the doors of the box. With an exports map, a file can be inside the package and still not be importable: no door, no import.

5. Your tarball listing shows dist/components/Card.test.js. What do you change?
  • Delete the test from src/lib before each pack
  • Add "!dist/**/*.test.*" to the files field
  • Move the test to the buyer's project instead
  • Add the test file to .gitignore in the project

svelte-package copies everything in src/lib, so the exclusion goes in files. Your tests stay where they are, and they stay out of the box.

6. Your library is at 2.3.1. You fix a bug and change nothing else. What is the next version?
  • 2.3.2
  • 2.4.0
  • 3.0.0
  • 2.3.1

A bug fix moves the patch number. A version that has been published can never be reused, so 2.3.1 is not an option either.

7. A library at 1.2.0 renames the event onclose to ondismiss. Which version should come next?
  • 1.2.1, because only one name has changed
  • 1.3.0, because the new name is a new feature
  • 2.0.0, because existing code that uses it breaks
  • 1.2.0 again, because the behaviour is the same

Anyone using onclose has to change their code, so this is a major change. The version number warns them before they update, however small the edit looks.

8. You published 0.1.0 with a typo in a component's label, and people have installed it. What do you do?
  • Publish the corrected code again as 0.1.0
  • Delete node_modules in your own project and pack again
  • Send the corrected .tgz file to the registry by hand
  • Fix the typo and publish it as 0.1.1

Once package@version has been used, it can never be used again. Publish the next patch, and users get the fix when they update.

The bricks are in the box. Now you have to build something with them: Workshop and revision.