In the previous lesson we turned the scripts field into the project's single interface: anyone arriving at Escena Viva knows that npm start boots the server and npm run check validates the code, without needing to know any internal paths. Until now we have always been on the same side of the counter: we were package consumers. In this lesson we switch sides and publish one.
The specific case is real and modest, which is exactly how these things should start. src/utils/format.js contains three functions —formatPrice, formatDate and generateTicketCode— that we are already copying and pasting into two other internal projects: the box-office dashboard and the accounting report generator. That is the symptom that justifies extracting a package. We are going to turn that file into @escena-viva/format, deciding precisely what can be imported from outside, what gets uploaded to the registry and what happens when you publish something you later want to withdraw.
Contents
- When to extract a package and when not to
- The Escena Viva case:
@escena-viva/format - The structure of a publishable package
- The public API:
main,exportsandtypes - What gets uploaded:
filesversus.npmignore - Scoped packages and
publishConfig - Testing before publishing:
npm packandnpm link - Publishing: login,
--dry-run, 2FA and tokens - New versions and distribution tags
- Unpublishing is almost never possible
- Common Mistakes and Tips
- Exercises
- Conclusion
- When to extract a package and when not to
Extracting code into a package has a permanent cost: one more repository, one more version cycle, and the obligation not to break whoever consumes it. That cost is only worth paying when there is real reuse.
| Signal | Extract? | Reason |
|---|---|---|
| The same file copied into two or more projects | Yes | Copies drift apart and bugs get fixed only once |
| Stable code, with little functional drift | Yes | A package that changes every week is a burden on its consumers |
| A small, well-defined API (3-6 functions) | Yes | The surface you have to keep compatible is manageable |
| Used only in this project | No | A well-placed internal module is enough |
| It depends on the application's global state | No | A package should be a function of its inputs |
| It is extracted "just in case" or because it looks professional | No | Cost with no benefit |
The practical rule is the three-copies rule: when you are about to make the third manual copy of a fragment, that fragment is asking to be a package.
Our format.js qualifies: it is copied into two projects, it has three pure functions, it touches neither disk nor network, and its behavior has not changed since module 1.
- The Escena Viva case:
@escena-viva/format
@escena-viva/formatThis is the code we are going to extract, exactly as it stood in the project:
// src/utils/format.js (the version that lived in Escena Viva)
function formatPrice(priceCents) {
const euros = (priceCents / 100).toFixed(2);
return `${euros} EUR`;
}
function formatDate(isoDate) {
const date = new Date(isoDate);
const day = String(date.getUTCDate()).padStart(2, '0');
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
return `${day}/${month}/${date.getUTCFullYear()}`;
}
function generateTicketCode(year, sequence) {
const number = String(sequence).padStart(6, '0');
return `EV-${year}-${number}`;
}
module.exports = { formatPrice, formatDate, generateTicketCode };We create a directory that is a sibling of the project (not inside it) for the package:
The --scope flag makes the initial name @escena-viva/escena-viva-format; we adjust it by hand in package.json. The package's final manifest ends up like this:
{
"name": "@escena-viva/format",
"version": "0.1.0",
"description": "Price, date and ticket code formatting for Escena Viva",
"license": "MIT",
"type": "commonjs",
"main": "./index.js",
"exports": {
".": "./index.js",
"./price": "./lib/price.js",
"./package.json": "./package.json"
},
"files": ["index.js", "lib/", "README.md", "LICENSE"],
"engines": { "node": ">=20" },
"publishConfig": { "access": "public" },
"scripts": {
"test": "node --test",
"prepublishOnly": "npm test"
}
}Notice three things that are no longer there: private: true (a private package cannot be published), a main pointing at a server file, and dependencies. This package has no dependencies at all, and that is a virtue, not a shortcoming.
The code is reorganized into small modules inside lib/ plus an index.js that re-exports:
// lib/price.js — turns whole cents into a readable euro string.
function formatPrice(priceCents) {
if (!Number.isInteger(priceCents)) {
throw new TypeError('The price must be an integer number of cents');
}
const euros = (priceCents / 100).toFixed(2);
return `${euros} EUR`;
}
module.exports = { formatPrice };// index.js — the package's single entry point: it re-exports the public API.
const { formatPrice } = require('./lib/price.js');
const { formatDate } = require('./lib/date.js');
const { generateTicketCode } = require('./lib/code.js');
module.exports = { formatPrice, formatDate, generateTicketCode };In Escena Viva, the change on the consumer side is minimal. You delete src/utils/format.js, replace the require in the files that used it and declare the dependency:
// Before, in src/reports/occupancy.js
const { formatPrice, formatDate } = require('../utils/format.js');
// After
const { formatPrice, formatDate } = require('@escena-viva/format');We start at 0.1.0 on purpose. As we saw in 05-03, in the ^0.x.y range the caret only allows patch changes, so a 0.x leaves us room to reorganize the API without breaking anyone by accident. When it settles down, we will jump to 1.0.0 as a deliberate act.
- The structure of a publishable package
A small, responsible package fits in very few files:
escena-viva-format/ ├── package.json Manifest: name, version, exports, files ├── index.js Entry point of the public API ├── lib/ price.js, date.js, code.js ├── test/format.test.js ├── README.md What it does, how to install it, examples ├── CHANGELOG.md What changed in each version └── LICENSE Full text of the license
The README.md is not decorative documentation: it is the page people see on the registry and, in practice, the first thing that decides whether your package gets used. It must contain the minimal example that works when copied and pasted: a one-line install and three lines of usage with their output (formatPrice(2450) // '24.50 EUR').
The LICENSE file with the full text matters more than it seems: without it, legally your code is "all rights reserved" and a company with lawyers will not be able to use it even though it is published in the open. The license field in package.json must match that file.
- The public API:
main, exports and types
main, exports and typesHistorically, main was the only control: it named the file loaded by require('package'). Its problem is that it prevented nothing. Anyone could write require('@escena-viva/format/lib/price.js') and couple themselves to your internal structure; the day you renamed lib/ you would have broken a consumer without having changed a single public function.
The exports field solves that. It is now the correct way to declare the public API because it does two things at once: it maps public names to real files and it blocks everything not listed.
With that map, the behavior from outside the package is:
| Import | Result | Reason |
|---|---|---|
require('@escena-viva/format') |
Works | The . key points to index.js |
require('@escena-viva/format/price') |
Works | Subpath declared explicitly |
require('@escena-viva/format/lib/price.js') |
ERR_PACKAGE_PATH_NOT_EXPORTED |
The internal path is not in the map |
require('@escena-viva/format/lib/date.js') |
ERR_PACKAGE_PATH_NOT_EXPORTED |
Same, even though the file exists |
require('@escena-viva/format/package.json') |
Works | Declared on purpose |
That blocking is freedom for you: as long as index.js and ./price keep returning the same thing, you can reorganize lib/ without publishing a major version. Note that ./package.json is declared explicitly because several analysis tools read it; if you leave it out, it is blocked too.
exports also accepts conditions, useful when a package offers both CommonJS and ESM. Node picks the branch according to how the package is loaded, and default must always come last because they are evaluated in order:
About types: even if you do not use TypeScript, many of your consumers do. Declaring "types": "./index.d.ts" and writing a twenty-line file with the signatures makes your package autocomplete in their editors. It is optional, but it has one of the best effort-to-gratitude ratios out there.
- What gets uploaded:
files versus .npmignore
files versus .npmignoreWhen you publish, npm packs an entire directory into a tarball. Deciding what goes in has two possible mechanisms and they are not equivalent.
| Mechanism | How it works | Risk |
|---|---|---|
.npmignore |
Denylist: everything is uploaded except what is listed | A new file is uploaded by default |
files in package.json |
Allowlist: only what is listed is uploaded | A new file is left out by default |
files is safer for the same reason a firewall that denies by default is safer than one that allows by default. With .npmignore, the day someone adds internal-notes.md or a .env.production to the repository, that file will travel to the public registry unless somebody remembers to update the denylist. With files, that same file simply does not exist as far as npm is concerned.
There is a confusing detail: if there is neither files nor .npmignore, npm uses .gitignore as the denylist; and if there is an .npmignore, .gitignore stops applying entirely, which produces unpleasant surprises.
Some files are always included (package.json, README, LICENSE and the main file) and others are always excluded (node_modules/, .git/, package-lock.json, .npmrc). The fact that .npmrc is always excluded is an important safeguard, because that is where tokens live. But do not rely on safeguards: use files.
- Scoped packages and
publishConfig
publishConfig@escena-viva/format is a scoped package: the @escena-viva is a namespace associated with a user or organization on the registry. It avoids name collisions —plain format has been taken for years—, it groups the packages of a single organization and it allows shared access policies.
The detail that surprises everyone the first time is that scoped packages are private by default, and publishing privately requires a paid plan. If your package is open you have to say so with npm publish --access public, but typing that flag every time is an invitation to forget it. Better to leave it in the manifest:
publishConfig also covers the opposite case, very common in companies: an internal package that must go to the company's private registry and never to the public one. Pinning the registry there prevents a code leak from an absent-minded npm publish.
- Testing before publishing:
npm pack and npm link
npm pack and npm linkPublishing is irreversible in practice, so checking beforehand is not optional.
npm pack builds exactly the same tarball that would be uploaded, but leaves it on your disk:
npm notice 📦 @escena-viva/[email protected] npm notice Tarball Contents npm notice 1.1kB LICENSE npm notice 842B README.md npm notice 318B index.js npm notice 402B lib/code.js npm notice 380B lib/date.js npm notice 455B lib/price.js npm notice 621B package.json npm notice total files: 7 npm notice filename: escena-viva-format-0.1.0.tgz
That list is the moment of truth. What you should actively look for:
- Is there any
.env? That would be a credential leak published in the open. - Is
data/there with the test sales? It could contain real e-mail addresses. - Is
test/there? Not dangerous, but it inflates the download for every consumer. - Is
index.jsor anylib/file missing? The package would install broken.
npm pack --dry-run shows the same list without writing anything, and tar -tzf file.tgz inspects an already-created tarball. The definitive test is installing that tarball in the real project:
If the occupancy report still prints 24.50 EUR, the package works exactly as anyone else will receive it.
The alternative for day-to-day development is npm link, which creates symbolic links:
The first command links the package into npm's global folder; the second creates, inside node_modules/@escena-viva/format, a symbolic link to your working folder. Every change you save in the package is seen by the project instantly, with no reinstall. Its quirks are worth knowing before you lose an afternoon:
- An
npm installin the project can undo the link and pull the registry version back down. - The link does not honor
filesorexportsas faithfully as a tarball: you may be using a file that will not be published. - If the package and the project depend on the same module, two different copies may be loaded (the classic problem with libraries that keep internal state).
A practical rule: npm link for fast iteration, npm pack plus installing the tarball for the final check. To undo it, npm unlink @escena-viva/format and npm install.
- Publishing: login,
--dry-run, 2FA and tokens
--dry-run, 2FA and tokensThe first step is authenticating. npm login opens the browser and stores the resulting token in your user .npmrc (~/.npmrc), never in the project's. Then a rehearsal that does the whole process —building the tarball, validating the manifest, checking permissions— except the upload, and only then the real publication:
The last command first runs the prepublishOnly hook we defined, which in turn launches npm test. It is the practical application of what we saw in 05-04: a hook that prevents publishing a version with red tests.
On account security, two measures that are not negotiable if anyone else uses your package. The first, 2FA (two-factor): you enable it with npm profile enable-2fa auth-and-writes and it makes every publish ask for a temporary code; most of the known package hijackings have been hijackings of the maintainer's account, not registry failures. The second, granular access tokens for CI: a continuous integration server cannot type a 2FA code, so it needs a token, and it should not be a classic one with full permissions but a granular one, limited to the packages it must publish and with an expiry date, stored as a secret of the CI system and never in the repository. We will see the full setup for secrets and automated deployment in module 11.
- New versions and distribution tags
Never edit the version field by hand. npm version does it, and leaves the repository coherent as well:
npm version patch # 0.1.0 -> 0.1.1 (compatible fix)
npm version minor # 0.1.1 -> 0.2.0 (new functionality)
npm version major # 0.2.0 -> 1.0.0 (breaking change)Each of those commands, in a directory with git:
- Checks that the working tree is clean (if not, it aborts).
- Updates
versioninpackage.jsonand inpackage-lock.json. - Creates a commit with the version number as the message.
- Creates a git tag
v0.1.1pointing at that commit.
That tag is what will let you, two years from now, recover the exact code of a published version. Remember to push it: git push --follow-tags. For pre-releases, npm version prerelease --preid=beta takes you from 1.0.0 to 1.0.1-beta.0.
And this is where distribution tags come in: movable aliases pointing at specific versions. When someone types npm install @escena-viva/format, npm resolves the latest tag. If you publish a beta with nothing else, it becomes latest and everyone gets it without asking. The correct way is npm publish --tag beta: that way latest keeps pointing at the last stable version and whoever wants the beta must ask for it with npm install @escena-viva/format@beta.
Tags are managed after publishing, with no need to republish anything:
npm dist-tag ls @escena-viva/format
npm dist-tag add @escena-viva/[email protected] next
npm dist-tag add @escena-viva/[email protected] latest
npm dist-tag rm @escena-viva/format betaMoving latest is, in fact, the only clean way to "withdraw" a bad version: you do not delete it, but you stop serving it by default.
- Unpublishing is almost never possible
This is a good point to slow down, because intuition misleads. Publishing is not like uploading a file to a server of your own: it is a public commitment. The registry's policy is roughly this:
| Situation | Can it be unpublished? |
|---|---|
| Less than 72 hours since publication | Yes, with npm unpublish |
| More than 72 hours, no dependents and a single version | Only the whole package, under strict conditions |
| More than 72 hours and with other packages depending on it | No |
A specific version someone has in their package-lock.json |
No |
The reason for this rigidity has a name. In 2016, the author of the left-pad package —eleven lines of code that padded a string on the left— unpublished it after a dispute over another package's name. left-pad was a transitive dependency of tools used by half the world, so for hours builds failed in thousands of organizations. The consequence was a tightening of the unpublish policy: the ecosystem's stability weighs more than the right to withdraw your own code.
The correct alternative when a version is bad or a package becomes obsolete is npm deprecate, which deletes nothing but warns on every install:
npm deprecate @escena-viva/[email protected] "Rounding error; use 0.1.1"
npm deprecate @escena-viva/format@"<0.2.0" "Unmaintained; migrate to 0.2.x"
npm deprecate @escena-viva/format "Replaced by @escena-viva/utils"
npm deprecate @escena-viva/[email protected] "" # withdraw the noticeThe message appears as a warning at install time, breaks nothing and gives people time to migrate. It is what a responsible maintainer does.
A special and urgent case: if you have published credentials by accident, unpublishing is not the solution. Even if you manage to withdraw it, the tarball has already been replicated in caches and mirrors. What you have to do is rotate those credentials immediately, and then publish a clean version and mark the bad one as deprecated.
Hence the correct order of work is always: npm pack → review the list → npm publish --dry-run → npm publish.
- Good practices of a responsible package
- A README with runnable examples. A block you can copy that works first time is worth more than three paragraphs of description.
- CHANGELOG.md. One entry per version, with breaking changes highlighted. It is the first thing anyone about to upgrade reads.
- Honest SemVer. The version is not decided by your perception of the change's size, but by its effect on the consumer. If you rename a parameter, it is major even if it is two lines.
- Zero dependencies if possible. Every dependency of yours becomes a transitive one for all your consumers, with its own risk surface.
@escena-viva/formatneeds none. enginesdeclared and tests before publishing withprepublishOnly.- An accessible repository. The
repository,bugsandhomepagefields let people find the code and open issues.
Common Mistakes and Tips
Publishing with private: true in the manifest. npm refuses with This package has been marked as private. It is a deliberate protection: remove it only when the package really is publishable.
Forgetting --access public in a scoped package. The 402 Payment Required error does not mean npm wants to charge you for publishing open source; it means it is trying to publish it as private. Add publishConfig.access.
Using .npmignore and finding a .env in the tarball. The denylist fails silently in the face of new files. Switch to files and always verify with npm pack.
Publishing the wrong folder. npm publish packs the current directory. Check pwd first; --dry-run confirms it by showing the package name.
Editing version by hand and forgetting the git tag. A few months later you will have published versions with no identifiable commit behind them. Always use npm version.
Publishing a beta without --tag. It becomes latest and your whole user base installs it. It is one of the most expensive and easiest mistakes to make.
Trusting npm link as the final test. The symbolic link does not exercise files or exports the way a tarball does. Test the .tgz before publishing, and always work as if publication were final, because for practical purposes it is.
Exercises
Exercise 1: block an internal path
A teammate, in another project, has written require('@escena-viva/format/lib/code.js') to use generateTicketCode without loading the rest. Explain what error they will get with the exports we have defined, and write the exports that would give them access to that function through the public subpath @escena-viva/format/code without exposing the structure of lib/.
Exercise 2: review the tarball
In the package's directory you have accidentally added a .env file with NPM_TOKEN=... and a data/sales-teatro-almendra.json folder with test e-mail addresses. The package.json has files: ["index.js", "lib/", "README.md", "LICENSE"]. Answer: would those files be uploaded? Which command checks it? Would the answer change if instead of files there were an .npmignore with the line data/?
Exercise 3: a bad version published
You have published @escena-viva/[email protected] and four days later you notice that formatPrice(2450) returns '24.5 EUR', dropping the trailing zero of the cents. Two internal projects already have it in their package-lock.json. Write the complete sequence of commands to resolve it correctly and explain why you do not start with npm unpublish.
Solutions
Solution 1. They will get ERR_PACKAGE_PATH_NOT_EXPORTED, because the exports map denies by default any path not declared, even though the file physically exists inside the package. The corrected exports adds a public subpath pointing at the internal file:
"exports": {
".": "./index.js",
"./price": "./lib/price.js",
"./code": "./lib/code.js",
"./package.json": "./package.json"
}Now require('@escena-viva/format/code') works, and the path with lib/ stays blocked. The advantage is that the public name ./code and the real file ./lib/code.js are decoupled: if tomorrow you move the file, changing the map is enough, with no major version.
Solution 2. No, they would not be uploaded. files is an allowlist: only what is enumerated goes into the tarball, and neither .env nor data/ is there. On top of that, npm always excludes .npmrc, though that does not cover a .env.
You check it with npm pack --dry-run (or npm pack and then tar -tzf), reading the tarball's file list.
With .npmignore the answer changes dangerously: data/ would indeed be excluded because it appears in the list, but .env would be uploaded, since it is not listed and .npmignore disables .gitignore entirely. This is exactly why files is safer: it fails on the side of not publishing.
Solution 3. You do not start with npm unpublish because more than 72 hours have passed and, on top of that, there are projects already depending on that version; the registry will reject it, and even if it allowed it, you would break those installs. The correct sequence is to publish the fix and flag the bad version:
npm test # 1. Fix the code and cover it with a test
npm version patch # 2. 0.2.0 -> 0.2.1
git push --follow-tags
npm pack --dry-run # 3. Review the contents and rehearse
npm publish --dry-run
npm publish # 4. Publish the fix
npm deprecate @escena-viva/[email protected] "Incorrect decimal padding; upgrade to 0.2.1"
npm dist-tag ls @escena-viva/format # 5. Confirm that latest is the good versionThe affected projects will get the deprecation warning on their next install and, since their range is ^0.2.0, an npm update will bring them 0.2.1 with no further intervention. It is also worth adding the corresponding entry to CHANGELOG.md.
Conclusion
Publishing a package is above all an exercise in deciding boundaries. We have seen that extraction is justified by real reuse —the three-copies rule— and not by elegance, and we have turned src/utils/format.js into @escena-viva/format, a scoped package with no dependencies and a three-function API.
The important decisions concentrate in four manifest fields: main as the classic entry point, exports as the strict declaration of what can be imported and a wall against internal paths, files as the allowlist of what travels to the registry, and publishConfig.access so a scoped package gets published in the open. Around them, a verification flow that is best not skipped: npm pack to read the tarball with your eyes open, installing the .tgz for the real test, npm publish --dry-run as a rehearsal and only then npm publish, with 2FA on the account and granular tokens in continuous integration.
And a lesson worth internalizing before you need it: what is published is practically final. Outside the 72-hour window there is no going back, as the left-pad case taught; what there is, instead, is npm deprecate, new versions and distribution tags to steer people towards the good code.
In the next lesson, Dependency Security and Maintenance, we turn the argument around. If publishing means other people will run your code, installing means you run strangers' code: we will look at npm audit and how to really read it, npm ls to locate who drags in a vulnerable dependency, overrides to force a patched version, the attacks you need to recognize —typosquatting, hijacked accounts, abandoned packages— and the maintenance routine we will write down in Escena Viva's README.
Node.js Course: From Beginner to Advanced
Module 1: Introduction to Node.js
- What Is Node.js?
- Installing and Setting Up the Environment
- Your First Node.js Program
- The Node.js REPL
- Modern JavaScript for Node.js
- The Course Project: the Escena Viva Platform
Module 2: Core Concepts
- Node.js Architecture
- The Event Loop
- Callbacks and Asynchronous Programming
- Promises and async/await
- Events and EventEmitter
- CommonJS Modules and require()
- ES Modules and Interoperability
Module 3: File System and I/O
- Reading and Writing Files
- The fs Module in Depth
- Cross-Platform Paths with the path Module
- Working with Streams
- Transform Streams and pipeline
- Buffers and Binary Data
Module 4: HTTP and Web Servers
- Creating a Simple HTTP Server
- Handling Requests and Responses
- Manual Routing
- Serving Static Files
- Receiving Data: Request Bodies and JSON
- Consuming External APIs from Node.js
Module 5: NPM and Package Management
- Introduction to NPM and package.json
- Installing and Using Packages
- Semantic Versioning and package-lock
- npm Scripts and Project Automation
- Creating and Publishing Packages
- Dependency Security and Maintenance
Module 6: The Express.js Framework
- Introduction to Express.js
- Setting Up an Express Application
- Routing in Express
- Middleware
- Essential Third-Party Middleware
- Input Data Validation
- Error Handling
Module 7: Databases and ORMs
- Introduction to Databases
- Using MongoDB with Mongoose
- CRUD Operations
- Relationships, Population and Advanced Queries
- Using SQL Databases with Sequelize
- Migrations, Transactions and Seed Data
Module 8: Authentication and Authorization
- Introduction to Authentication
- User Registration and Password Hashing
- Sessions and Cookies with Passport.js
- Authentication with JWT
- Role-Based Access Control
- API Security Best Practices
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with Mocha and Chai
- Test Doubles with Sinon
- Integration Testing
- Coverage and Test Automation
- Debugging Node.js Applications
Module 10: Advanced Topics
- The Cluster Module
- Worker Threads
- Caching and Job Queues with Redis
- Performance Optimization
- Building RESTful APIs
- GraphQL with Node.js
Module 11: Deployment and DevOps
- Configuration and Environment Variables
- Logging and Monitoring in Production
- Using PM2 for Process Management
- Packaging with Docker
- Deploying to Heroku and Other PaaS
- Continuous Integration and Deployment
