Коли один бінарний файл замінював цілий Node.js runtime — розбираємо внутрішню кухню pkg та причини його “пенсії”
13 січня 2024 року в світі Node.js сталася тиха революція навпаки — команда Vercel заархівувала репозиторій pkg. Інструмент, який дозволяв упакувати весь Node.js проект в один виконуваний файл, офіційно пішов на пенсію. Але перед тим як попрощатися з pkg, давайте розберемо його внутрішню магію та зрозуміємо, чому він став жертвою власного успіху.
Що таке pkg: магія в одному файлі
Уявіть: ваш Node.js проект з усіма залежностями, runtime та асетами — все в одному виконуваному файлі. Не потрібно встановлювати Node.js на цільовій машині, не потрібно npm install — просто запускаєте .exe і все працює.
|
1 2 3 4 5 6 |
# Було node app.js # потребує Node.js + node_modules # Стало ./app.exe # працює на "голій" системі |
Саме цю магію і творив pkg — упаковував JavaScript-код, всі node_modules, нативні addon’и та навіть сам Node.js runtime у єдиний standalone executable.
Анатомія pkg: як це працювало всередині
Віртуальна файлова система
Серце pkg — це snapshot filesystem. Під час збірки pkg створював повний знімок вашого проекту та “вшивав” його прямо в executable:
|
1 2 3 4 5 6 |
// У звичайному Node.js console.log(__filename); // /home/user/project/app.js // У pkg-executable console.log(__filename); // /snapshot/project/app.js |
Усі файли з вашого проекту потрапляли у віртуальний шлях /snapshot/ (або C:\snapshot\ на Windows). Це була ізольована файлова система всередині бінарного файла.
Двоїстість файлових систем
Pkg створював цікаву дуальність:
Snapshot FS — для файлів проекту:
|
1 2 3 4 |
// Доступ до упакованих файлів path.join(__dirname, '../config.json') // /snapshot/project/config.json require('./module.js') // працює автоматично |
Real FS — для зовнішніх файлів:
|
1 2 3 4 |
// Доступ до реальної файлової системи process.cwd() // /home/user/run-directory path.dirname(process.execPath) // каталог де лежить executable |
Bytecode компіляція та захист коду
Pkg не просто копіював JavaScript файли — він компілював їх у V8 bytecode:
|
1 2 3 4 5 6 7 8 |
// Вхідний JS код function hello(name) { return `Hello, ${name}!`; } // ↓ Перетворювався в бінарний bytecode // [0x42, 0x1F, 0x8A, 0x93, ...] // захищений від декомпіляції |
Це давало два преимущества:
- Захист коду — вихідники стали недоступні
- Швидший старт — bytecode завантажувався швидше за JS
Cross-compilation магія
Pkg дозволяв збирати executable для різних платформ з однієї машини:
|
1 2 3 |
# З Linux збираємо для всіх платформ pkg app.js -t node16-win-x64,node16-macos-arm64,node16-linux-arm64 |
Як це працювало? Pkg завантажував готові Node.js бінарники для кожної target-платформи і “вшивав” ваш код у них.
Внутрішня архітектура: від коду до executable
Крок 1: Dependency Traversal
|
1 2 3 4 5 |
// Pkg аналізував усі require() та import const fs = require('fs'); // ✓ built-in module const express = require('express'); // ✓ знаходить в node_modules const config = require('./config'); // ✓ локальний файл |
Крок 2: Asset Detection
|
1 2 3 4 5 6 7 |
// Автоматично виявляв ассети path.join(__dirname, '../static/logo.png') // ↓ // pkg config auto-generated: { "assets": ["static/**/*"] } |
Крок 3: Virtual FS Creation
|
1 2 3 4 5 6 7 8 9 |
Executable Structure: ├── Node.js Runtime (преcompiled) ├── V8 Bytecode (ваш код) ├── Virtual FS Image │ ├── /snapshot/project/ │ ├── /snapshot/node_modules/ │ └── /snapshot/assets/ └── Runtime Bootstrap |
Крок 4: Runtime Injection
При запуску executable pkg “підмінював” стандартні Node.js API:
|
1 2 3 4 5 6 7 8 9 |
// Оригінальний require патчився const originalRequire = require; require = function(path) { if (isInSnapshot(path)) { return loadFromSnapshot(path); } return originalRequire(path); }; |
Проблеми, які наростали
Native Modules — головний біль
|
1 2 3 |
// Цей код ломав pkg const sharp = require('sharp'); // .node файл не упаковувався |
Нативні addon’и (.node файли) не могли бути упаковані всередину executable. Їх доводилося розміщувати поряд з бінарником — що ломало ідею “одного файла”.
Dynamic Requires
|
1 2 3 4 |
// Pkg не міг відстежити динамічні imports const moduleName = getUserInput(); require(moduleName); // ❌ Broken at runtime |
Memory Overhead
Вся віртуальна файлова система завантажувалася в пам’ять при старті. Великі проекти могли “з’їдати” сотні мегабайт RAM.
Debugging Nightmare
|
1 2 3 4 |
// Stack traces показували віртуальні шляхи Error: Cannot find module 'missing' at /snapshot/project/app.js:15:3 // 🤔 Де це насправді? |
Чому pkg заархівували: ідеальний шторм
1. Node.js 21 та Single Executable Applications
Найголовніша причина — Node.js Core Team випустила власне рішення:
|
1 2 3 |
// Офіційний Node.js SEA (Single Executable Application) node --experimental-sea-config sea-config.json |
Це було рішення на рівні самого runtime, без сторонніх залежностей.
2. Maintenance Burden
Кожна нова версія Node.js вимагала оновлення pkg:
- Патчі для підтримки нових API
- Збірка нових базових бінарників
- Тестування сумісності
3. Serverless Revolution
Команда Vercel зосередилась на serverless-рішеннях, де pkg був не потрібен:
“pkg був створений для контейнерів і не призначений для serverless середовищ”
4. Ecosystem Fragmentation
З’явилися численні форки та альтернативи:
@yao-pkg/pkg— найактивніший форкncc— для bundling без runtimeesbuild+ Node.js SEA — сучасний стек
Спадщина pkg: що залишилося
Community Forks
Спільнота не дала pkg померти:
|
1 2 3 4 5 6 |
{ "devDependencies": { "@yao-pkg/pkg": "^5.11.0" // Продовжує життя } } |
Архітектурні рішення
Багато ідей pkg перекочували в Node.js SEA:
- Віртуальна файлова система
- Asset bundling
- Cross-platform builds
Уроки для індустрії
1. Тимчасовість інструментів Навіть популярні інструменти можуть стати legacy за одну ніч.
2. Community vs Corporate Open source проекти можуть “вижити” завдяки форкам та спільноті.
3. Официал wins Коли офіційний runtime додає функціонал, сторонні рішення втрачають сенс.
Альтернативи у 2024: що замість pkg?
Node.js SEA (Recommended)
|
1 2 3 4 5 |
# Сучасний підхід echo '{"main": "app.js", "output": "sea-prep.blob"}' > sea-config.json node --experimental-sea-config sea-config.json npx postject node SEA_BLOB sea-prep.blob |
esbuild + SEA
|
1 2 3 4 5 6 7 8 |
// Bundling з esbuild + упаковка з Node.js SEA await esbuild.build({ entryPoints: ['app.js'], bundle: true, platform: 'node', outfile: 'dist/bundle.js' }); |
Community Forks
|
1 2 3 4 |
# Якщо потрібна стара добра функціональність npm install -g @yao-pkg/pkg yao-pkg app.js |
Висновок: кінець ери, початок нової
Pkg був піонером, який показав можливість “standalone Node.js apps”. Його архівування — не поразка, а природна еволюція. Коли платформа достатньо розвивається, сторонні інструменти або інтегруються в core, або відходять в історію.
Урок для розробників: не бійтеся експериментувати з інструментами, але будьте готові до змін. Те, що працює сьогодні, може стати legacy завтра.
P.S. Якщо у вас є проекти на pkg — час планувати міграцію на Node.js SEA або community forks. Магія упаковки нікуди не поділася, просто змінилася форма заклинання.
Pkg мертвий. Хай живе pkg! 🎭