การเผยแพร่ Package
การเผยแพร่ package ทำให้มันพร้อมใช้งานบน npm registry เพื่อให้ใครก็ตาม (หรือสมาชิกในองค์กรของคุณ) ติดตั้งได้ด้วย npm install กระบวนการนี้มีสามขั้นตอน: เตรียม package.json ด้วย metadata ที่ถูกต้อง, bump เวอร์ชัน, และรัน npm publish แต่ละขั้นตอนมี guardrail ที่ช่วยให้คุณ ship release ที่สะอาดและคาดเดาได้
ฟิลด์ package.json สำคัญสำหรับการ publish
หัวข้อที่มีชื่อว่า “ฟิลด์ package.json สำคัญสำหรับการ publish”package ที่พร้อม publish มักมีฟิลด์เหล่านี้ นอกเหนือจาก name และ version:
{ "name": "@myorg/my-util-lib", "version": "1.0.0", "description": "A small utility library", "license": "MIT", "main": "./dist/index.cjs", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", "files": ["dist", "README.md"], "bin": { "my-util": "./dist/cli.js" }, "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.cjs", "default": "./dist/index.cjs" }, "./helpers": { "types": "./dist/helpers.d.ts", "import": "./dist/helpers.mjs", "require": "./dist/helpers.cjs" } }, "engines": { "node": ">=18.0.0" }}license— SPDX identifier ที่ผู้ใช้และ registry scanner ตรวจก่อนจะ depend on package ของคุณmain/module— entry point แบบ CJS เดิม และ convention เฉพาะ bundler ที่ tool เก่าบางตัวยังมองหา ทั้งคู่ถูกแทนที่ด้วยexportsแต่ยังเก็บไว้เพื่อ compat กับ toolingtypes— ไฟล์ TypeScript declaration หลัก ใช้โดย editor และtscเมื่อ package ไม่มีexportsmap หรือใช้เป็น fallbackfiles— allowlist ของ path ที่รวมไว้ใน published tarball ทุกอย่างนอกเหนือจากนี้ (source file, test fixture, config) ถูก exclude ถ้าไม่ระบุ npm ยังใช้ชุด exclusion เริ่มต้น (node_modules,.git, และไฟล์ที่ตรงกับ.npmignoreหรือถ้าไม่มีก็.gitignore)bin— map ชื่อคำสั่ง CLI ไปยัง entry-point script npm สร้าง symlink ใน.bin/ทำให้คำสั่งพร้อมใช้งานใน PATH เมื่อติดตั้ง package และผ่านnpxexports— ประกาศ public API surface path ใดที่ไม่ได้ระบุไว้ที่นี่จะเข้าถึงไม่ได้จากภายนอก ภายใน conditions object แต่ละอัน"types"ต้องมาก่อนเสมอ — ดู Module Resolution ว่าทำไมลำดับ key ถึงสำคัญ
ฟิลด์ files
หัวข้อที่มีชื่อว่า “ฟิลด์ files”{ "files": ["dist", "README.md", "LICENSE"]}"files" เป็น allowlist ไม่ใช่ denylist: มีเพียง path ที่ระบุไว้ที่นี่ (บวกกับไฟล์บางอย่างที่ npm รวมให้เสมอ เช่น package.json และ README) เท่านั้นที่จะอยู่ใน tarball วิธีนี้ทำให้ source map, test fixture, และ config เฉพาะเครื่องไม่หลุดไปกับสิ่งที่ผู้ใช้ดาวน์โหลด
Demo ด้านล่าง reimplement logic ของ allowlist นั้นตรงๆ ด้วย fs กับ fake package tree ที่มีไฟล์ .env และโฟลเดอร์ test/source ที่ไม่เคยอยู่ใน "files" เลย เพื่อให้กลไกการกรองเป็นรูปธรรม แทนที่จะแค่กล่าวอ้าง:
Output (verified on Node 24)
Would ship in the tarball:
README.md
dist/index.js
dist/nested/helper.js
package.json
Excluded (not in "files"):
.env
src/index.ts
test/index.test.js.env, source TypeScript, และไฟล์ test ไม่ไปแตะเครื่องของผู้ใช้เลย npm pack --dry-run จริงบน layout เดียวกันเป๊ะนี้ยืนยันว่าไฟล์สามอย่างเดียวกันนี้ ship ออกไป — demo นี้ทำให้ กลไก ชัดเจนแทนที่จะขอให้คุณเชื่อ CLI output เฉยๆ
ฟิลด์ bin
หัวข้อที่มีชื่อว่า “ฟิลด์ bin”{ "bin": { "my-util": "./dist/cli.js" }}เมื่อ package ถูกติดตั้ง (แบบ local หรือ global) npm จะสร้าง executable ชื่อ my-util ใน node_modules/.bin/ (หรือ global bin directory) ที่ชี้ไปยัง ./dist/cli.js นี่คือสิ่งที่ทำให้คำสั่งนี้ใช้งานได้ผ่าน npx my-util และผ่าน script ใน npm run ของโปรเจกต์ที่ใช้งาน โดยไม่ต้องติดตั้งแบบ global
npx เทียบกับ npm exec เทียบกับ node --run
หัวข้อที่มีชื่อว่า “npx เทียบกับ npm exec เทียบกับ node --run”ทั้งสามอย่างจบลงที่การ “รันโค้ดบางอย่าง” ซึ่งเป็นเหตุผลว่าทำไม snippet ที่ AI generate ถึงสลับใช้กันโดยไม่สังเกตว่าพวกมันไม่เท่ากัน:
| คำสั่ง | รันอะไร | Fallback ไปติดตั้งจาก registry ไหม? | รัน lifecycle script pre/post ไหม? |
|---|---|---|---|
npx <bin-name> | bin entry — local (node_modules/.bin) ถ้ามี ไม่งั้นติดตั้งชั่วคราวจาก registry | ใช่ (prompt เว้นแต่มี --yes/-y) | N/A — ไม่ใช่ script ใน package.json |
npm exec -- <bin-name> | bin lookup แบบเดียวกับ npx (npm 7+ implement npx บนฐาน exec) | ใช่ พฤติกรรมเดียวกับ npx | N/A — ไม่ใช่ script ใน package.json |
node --run <script-name> | script ที่ตั้งชื่อไว้ในฟิลด์ "scripts" ของ package.json โดยตรง — ไม่ spawn shell | ไม่ — มันรันแค่สิ่งที่อยู่ใน "scripts" แล้ว | ไม่ — นี่คือความต่างที่ชัดที่สุดจาก npm run |
Transcript จริง ทั้งหมด local และ offline (bin entry ชื่อ say-hi กับ script greet ที่นิยามไว้ใน package เดียวกัน):
npx say-hihi from local binnpm exec -- say-hihi from local binnode --run greethi from local binทั้งสามพิมพ์เหมือนกันตรงนี้ — แต่เพิ่ม lifecycle script pregreet เข้าไปแล้วความต่างจะเห็นชัด:
npm run greet> [email protected] pregreet> echo PRE-HOOK-RAN
PRE-HOOK-RAN
> [email protected] greet> node cli.js
hi from local binnode --run greethi from local binnode --run ข้าม pregreet ไปเลย — มันรันแค่ script ที่ระบุชื่อ ไม่มีการ resolve lifecycle hook และไม่มี shell คั่นกลาง ซึ่งเป็นเหตุผลที่มันเริ่มเร็วกว่า npm run ด้วย มันเป็นตัวแทนของ npm run <script> ไม่ใช่ของ npx/npm exec: มันติดตั้งอะไรไม่ได้เลย และรัน binary ที่ไม่ใช่ script ใน package.json อยู่แล้วไม่ได้
การกำหนดเวอร์ชันด้วย Semantic Versioning
หัวข้อที่มีชื่อว่า “การกำหนดเวอร์ชันด้วย Semantic Versioning”npm ใช้ semantic versioning (semver): MAJOR.MINOR.PATCH
- PATCH — bug fix ที่ backwards-compatible
- MINOR — feature ใหม่ที่ backwards-compatible
- MAJOR — breaking change
ใช้ npm version เพื่อ bump และสร้าง tag ในขั้นตอนเดียว:
# Patch bump: 1.0.0 → 1.0.1 (bug fixes)npm version patch
# Minor bump: 1.0.1 → 1.1.0 (backwards-compatible new features)npm version minor
# Major bump: 1.1.0 → 2.0.0 (breaking changes)npm version majorแต่ละคำสั่งอัปเดตฟิลด์ "version" ใน package.json, commit การเปลี่ยนแปลง, และสร้าง signed git tag (เช่น v1.0.1) push tag ด้วย git push --follow-tags
ทดสอบก่อน publish ด้วย npm pack
หัวข้อที่มีชื่อว่า “ทดสอบก่อน publish ด้วย npm pack”npm pack รวม package ของคุณเป็น tarball .tgz เหมือนกับที่ npm จะทำถ้าคุณรัน npm publish — แต่มันอยู่แค่ local เท่านั้น วิธีนี้ให้คุณตรวจสอบ artifact สุดท้ายก่อน ship จริง --dry-run ไปไกลกว่านั้นอีกขั้น: มันแสดงรายการเนื้อหา tarball โดยไม่เขียนอะไรลง disk เลย และไม่ต้อง login registry เลยด้วย
# Create a local tarball (e.g. myorg-my-util-lib-1.0.0.tgz)npm pack
# Inspect its contents without extractingtar -tzf myorg-my-util-lib-1.0.0.tgz
# Install it locally in another project to smoke-testnpm install ../my-util-lib/myorg-my-util-lib-1.0.0.tgzผลลัพธ์จริงของ npm pack --dry-run บน package ที่มี "files": ["dist", "README.md"], ไฟล์ .env, และไฟล์ secret ที่ไม่เกี่ยวข้องอยู่ข้างๆ กัน:
npm pack --dry-runnpm noticenpm notice 📦 @demo/[email protected]npm notice Tarball Contentsnpm notice 14B README.mdnpm notice 21B dist/index.jsnpm notice 139B package.jsonnpm notice Tarball Detailsnpm notice name: @demo/my-util-libnpm notice version: 1.0.0npm notice filename: demo-my-util-lib-1.0.0.tgznpm notice package size: 284 Bnpm notice unpacked size: 174 Bnpm notice shasum: 59bc21d933556de397e4fa5056ea5a999750a8d3npm notice integrity: sha512-FBadVz2MkDnJl[...]jO8KfPJW6WRuw==npm notice total files: 3.env และไฟล์ secret หายไปจากรายการเลย — เป็นการกรองแบบเดียวกันเป๊ะกับที่ demo แบบ fs ด้านบน reimplement
การ publish ด้วย npm publish
หัวข้อที่มีชื่อว่า “การ publish ด้วย npm publish”คุณต้อง authenticate (npm login) ก่อน publish npm อ่าน package.json เพื่อกำหนด registry ปลายทาง (ค่าเริ่มต้นคือ https://registry.npmjs.org)
# Log in to the registrynpm login
# Publish a public (unscoped) packagenpm publish
# Publish a scoped package as public (scoped packages default to restricted)npm publish --access public
# Publish under a dist-tag other than "latest" (e.g. a beta)npm publish --tag betaflowchart TD
A["npm version patch/minor/major"] --> B["prepublishOnly script runs (build, test, lint)"]
B --> C["npm pack --dry-run — inspect the tarball contents"]
C --> D{"Looks right?"}
D -->|"no"| E["Fix files/exports/scripts, repeat from npm pack"]
E --> C
D -->|"yes"| F["npm publish"]
F --> G{"2FA required for this account/package?"}
G -->|"yes"| H["Approve via authenticator or security key"]
G -->|"no"| I["Tarball uploaded to the registry"]
H --> I
I --> J["Provenance attestation attached (if --provenance)"] Provenance, 2FA, และ pre-publish checklist
หัวข้อที่มีชื่อว่า “Provenance, 2FA, และ pre-publish checklist”Version ที่ publish แล้วยกเลิกไม่ได้หลังผ่านไป 72 ชั่วโมง (หน้าต่างป้องกันการละเมิดของ npm) ดังนั้น guardrail ต้องอยู่ก่อน npm publish รัน ไม่ใช่หลังจากนั้น:
prepublishOnly— script ในpackage.jsonที่ npm รันอัตโนมัติก่อน pack เลย และรันเฉพาะก่อนnpm publishจริง (ต่างจากprepublishที่ในอดีตยังรันตอนnpm installธรรมดาด้วย) ใส่ build และ test step ไว้ตรงนี้เพื่อไม่ให้ build ที่พังหลุดไป ship ได้:{"scripts": {"prepublishOnly": "npm test && npm run build"}}npm pack --dry-run— การตรวจฟรี local ไม่ต้อง login จากหัวข้อก่อนหน้า รันมันแล้วอ่านรายการไฟล์จริงๆ ก่อน publish ทุกครั้ง- Two-factor authentication (2FA) —
npm profile enable-2fa auth-and-writesต้องการ one-time code (หรือ hardware security key) ทั้งตอน login และตอน publish ดังนั้น npm token ที่หลุดไปอย่างเดียวไม่พอที่จะ publish version อันตรายภายใต้ชื่อคุณ - Provenance (
npm publish --provenance) — จาก CI environment ที่รองรับ (GitHub Actions, GitLab CI) มันแนบ commit และ build workflow ที่แน่นอนที่สร้าง tarball นั้นแบบ cryptographic เข้าไปด้วย มองเห็นได้บนหน้า npm registry สาธารณะ มันตอบคำถาม “สิ่งนี้ถูก build จาก source repo สาธารณะจริงไหม” สำหรับใครก็ตามที่ตรวจสอบ supply-chain incident
Scoped packages
หัวข้อที่มีชื่อว่า “Scoped packages”scoped package ใช้รูปแบบ @scope/name และมักใช้สำหรับ namespace ขององค์กร หรือจัดกลุ่ม package ที่เกี่ยวข้องกัน:
{ "name": "@myorg/my-util-lib", "version": "1.0.0"}Scoped packages เป็น private โดยค่าเริ่มต้น บน npm registry เพื่อ publish แบบ public ต้องใส่ --access public เสมอ:
# Scaffold a scoped packagenpm init --scope=@myorg
# Publish it publiclynpm publish --access publicPrivate scoped packages ต้องใช้ npm account แบบเสียเงิน หรือ private registry
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”ทุกอย่างในบทนี้มีอยู่เพราะ npm publish ใกล้เคียงกับ door ทางเดียวมากกว่าปุ่ม save byte ที่แน่นอนของ tarball ที่ publish แล้วคือถาวร — คุณแก้หรือแทนที่ 1.0.0 แบบเงียบๆ ทีหลังไม่ได้ ดังนั้น guardrail ทุกตัวในบทนี้ ("files", prepublishOnly, npm pack --dry-run, 2FA, provenance) มีอยู่เพื่อจับความผิดพลาดในไม่กี่วินาทีก่อนที่ door นั้นจะปิด เพราะไม่มีอะไรจับมันได้หลังจากนั้น
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”| ตัวเลือก | ประโยชน์ | ต้นทุน |
|---|---|---|
prepublishOnly รัน test/build | build ที่พัง publish ไม่ได้ — check รันอัตโนมัติ ไม่ต้องมีใครจำ | เพิ่มเวลาจริงให้ทุก publish; test ที่ flaky อาจ block release ที่ถูกต้อง |
| 2FA ตอน publish | npm token ที่ถูกขโมยอย่างเดียว publish version อันตรายไม่ได้ | ทุก publish ต้องมี second factor พร้อมใช้ — เพิ่ม friction ให้ automated release pipeline เว้นแต่ตั้ง granular token ไว้ |
--provenance | ลิงก์สาธารณะที่ verify ได้ระหว่าง tarball ที่ publish กับ source commit/workflow ที่แน่นอนที่ build มัน | ใช้ได้เฉพาะจาก CI provider ที่รองรับ — เพิ่มย้อนหลังให้ version ที่ publish ไปแล้วไม่ได้ |
"files" allowlist ที่เข้มงวด | Tarball เล็กและคาดเดาได้ ไม่มีอะไรหลุดไปโดยไม่ตั้งใจ | ลืมเพิ่มไฟล์ที่จำเป็นจริงๆ (wasm binary, font) ทำให้ package พังสำหรับผู้ใช้จนกว่าจะมี version ถัดไป |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “ฉัน publish patch แก้ความผิดพลาดที่ ship ไปได้” คุณ publish version ใหม่ ได้ แต่ version ที่พังยังอยู่บน registry ตลอดไป (หน้าต่าง unpublish ของ npm คือ 72 ชั่วโมงและมีข้อจำกัดแม้ในช่วงนั้น) — ใครก็ตามที่ resolve version นั้นไปแล้วยังคงเก็บความผิดพลาดนั้นไว้
- “
npm packกับnpm publishอาจ package ไฟล์ต่างกัน” ทั้งคู่ใช้ logic"files"/.npmignoreเดียวกันเป๊ะ —npm pack --dry-runไม่ใช่การประมาณ มันคือขั้นตอน build-tarball จริงๆ ลบแค่การอัปโหลดออก - “2FA ป้องกันแค่ login npm account ของฉัน”
enable-2fa auth-and-writesต้องการ second factor เพิ่มเติมสำหรับการ publish เองด้วย — auth token ที่ถูกขโมยหรือรั่วไหลอย่างเดียวไม่พอที่จะ push version อันตราย
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”Publish script ของ CI/CD ที่ AI generate มักละ prepublishOnly ไปเลย แล้วไปตรงจากการ bump version สู่ npm publish เพราะตัวอย่างในรูปแบบ tutorial ที่ training data ส่วนใหญ่มาจากนั้นสาธิตแค่ “วิธี publish” ไม่ใช่ “วิธี publish อย่างปลอดภัย”
🐛 หา bug ในโค้ดนี้
{"name": "my-cli-tool","version": "1.0.0","bin": { "my-cli": "./dist/cli.js" },"devDependencies": { "commander": "^12.0.0"}}เฉลย
commander คือ library parse argument ที่ CLI เรียกใช้จริงตอน runtime — ทุกครั้งที่ my-cli รัน บนเครื่องของผู้ใช้ทุกคน — มันจึงควรอยู่ใน "dependencies" ไม่ใช่ "devDependencies" เพราะ npm ข้าม devDependencies ตอน production install (npm install --omit=dev หรือเมื่อผู้ใช้ install นี้เป็น dependency จริงแทนที่จะ clone repo) ผู้ใช้ที่ install CLI นี้ตามปกติจะเจอ Cannot find module 'commander' ทันทีที่รัน วิธีแก้คือย้ายมัน: "dependencies": { "commander": "^12.0.0" }