כזה ניסיתי: פיתוח אפליקציית קובץ בודד עם node 25
גרסה 25 של node.js הוסיפה פיצ'ר כיפי לאנשים שכותבים כלי שורת פקודה שנקרא Single Executable Application. בקצרה הוא מאפשר להטמיע תוכנית node בתוך קובץ ההפעלה של node.js עצמו ואז להפיץ את הקובץ הזה בתור אפליקציית קובץ יחיד, כלומר לקוח מקבל קובץ הפעלה אחד שאיך שמפעילים אותו מבצע את הקוד בסקריפט שהוטמע.
בואו נראה איך זה עובד דרך 3 דוגמאות.
1. אפליקציית שלום עולם
אפליקציות קובץ יחיד נכתבות רק ב CommonJS (אני מקווה שלא שכחתם כבר שזה קיים. בכל מקרה זה הכתיב עם ה require האופייני ל node במקום כתיב ה import) ודורשות קובץ קונפיגורציה עם סיומת json שמכיל את הפרטים הבאים:
{
"main": "/path/to/bundled/script.js",
"executable": "/path/to/node/binary", // Optional, if not specified, uses the current Node.js binary
"output": "/path/to/write/the/generated/executable",
"disableExperimentalSEAWarning": true, // Default: false
"useSnapshot": false, // Default: false
"useCodeCache": true, // Default: false
"execArgv": ["--no-warnings", "--max-old-space-size=4096"], // Optional
"execArgvExtension": "env", // Default: "env", options: "none", "env", "cli"
"assets": { // Optional
"a.dat": "/path/to/a.dat",
"b.txt": "/path/to/b.txt"
}
}
בשביל הניסוי אני יוצר קובץ קונפיגורציה בשם sea-config.json עם התוכן המינימלי הבא:
{
"main": "test1.js",
"executable": "/Users/ynonp/.nvm/versions/node/v25.5.0/bin/node",
"disableExperimentalSEAWarning": true,
"output": "sea",
}
וקובץ test1.js עם התוכן הבא:
console.log('hello world');
מפעיל משורת הפקודה את הפקודות הבאות לפי ההוראות בתיעוד:
$ node --build-sea sea-config.json
$ codesign --sign - sea
ויש לי קובץ הפעלה בשם sea שמכיל את node.js ואת הסקריפט. אפשר להפעיל אותו ולראות את התוצאה:
$ ./sea
hello world
זאת כנראה תוכנית ה hello world הכבדה בעולם כי הקובץ שוקל 125 מגה, אבל זה ברור כי הוא מגיע עם כל הקוד של node.js עצמו.
2. טעינת קובץ חיצוני
בדוגמה השניה רציתי לראות איך לעבוד עם קובץ חיצוני. יצרתי קובץ בשם utils.js עם התוכן הבא:
function twice(x) {
return x * 2;
}
module.exports = {
twice
}
וקובץ בשם test2.js עם התוכן הבא:
const process = require('node:process');
const {twice} = require('./utils.js');
const user = process.env["USER"];
console.log(`Hello ${user}. 2 * 2 = ${twice(2)}`);
עדכנתי את שם קובץ המקור בקונפיגורציה ל test2.js ובניתי לפי ההוראות. התוצאה הפעם פחות מוצלחת:
$ ./sea
node:internal/main/embedding:113
throw new ERR_UNKNOWN_BUILTIN_MODULE(id);
^
Error [ERR_UNKNOWN_BUILTIN_MODULE]: No such built-in module: ./utils.js
at embedderRequire (node:internal/main/embedding:113:11)
at test2.js:2:15
at embedderRunCjs (node:internal/main/embedding:89:10) {
code: 'ERR_UNKNOWN_BUILTIN_MODULE'
}
Node.js v25.5.0
פקודות require בתוך single executable application יודעות לעבוד רק עם מודולים מובנים ב node ולא עם קבצים חיצוניים. בשביל לטעון קובץ חיצוני אנחנו צריכים לשים אותו על המכונה בה נפעיל את האפליקציה (כלומר זו כבר תהיה אפליקציית שני קבצים) ולעדכן את הקוד של test2.js:
const process = require('node:process');
const { createRequire } = require('node:module');
require = createRequire(__filename);
const {twice} = require('./utils.js');
const user = process.env["USER"];
console.log(`Hello ${user}. 2 * 2 = ${twice(2)}`);
וזה עובד כל עוד הקובץ utils.js נמצא באותה תיקיה ממנה אני מפעיל את הקוד. אם אני מוחק את המודול או מזיז את sea לתיקיה אחרת אני מקבל את השגיאה:
$ ./sea
node:internal/modules/cjs/loader:1450
throw err;
^
Error: Cannot find module './utils.js'
Require stack:
- /Users/ynonp/tmp/blog/node-sea/sea
at Module._resolveFilename (node:internal/modules/cjs/loader:1447:15)
at defaultResolveImpl (node:internal/modules/cjs/loader:1058:19)
at resolveForCJSWithHooks (node:internal/modules/cjs/loader:1063:22)
at Module._load (node:internal/modules/cjs/loader:1233:25)
at TracingChannel.traceSync (node:diagnostics_channel:328:14)
at wrapModuleLoad (node:internal/modules/cjs/loader:245:24)
at Module.require (node:internal/modules/cjs/loader:1547:12)
at require (node:internal/modules/helpers:152:16)
at test2.js:5:17
at embedderRunCjs (node:internal/main/embedding:89:10) {
code: 'MODULE_NOT_FOUND',
requireStack: [ '/Users/ynonp/tmp/blog/node-sea/sea' ]
}
Node.js v25.5.0
3. שילוב קבצים מוטמעים
דרך נוספת לשלב קבצים חיצוניים היא באמצעות Assets. אסטים יוטמעו בתוך קובץ הבינארי ליד הסקריפט ויהיו זמינים לתוכנית שלנו בזמן ריצה. אפשר להטמיע בינארים אחרים כמו תמונות ואפשר גם להטמיע קבצי קוד. בואו ננסה את זה עם הקובץ utils.js מהדוגמה הקודמת. אני מעדכן את קובץ הקונפיגורציה כדי לכלול את הקובץ בתור קובץ מוטמע:
{
"main": "test3.js",
"executable": "/Users/ynonp/.nvm/versions/node/v25.5.0/bin/node",
"disableExperimentalSEAWarning": true,
"output": "sea",
"assets": {
"utils": "./utils.js"
}
}
אני ניגש לקבצים המוטמעים דרך פונקציה בשם sea.getAsset. הקוד הבא יודע לטעון מודול מתוך קובץ מוטמע באמצעות פונקציית requireFromString שמשתמשת ב API הלא מתועד module.prototype._compile:
const sea = require('node:sea');
const utilsSource = sea.getAsset('utils', 'utf8');
function requireFromString(src, filename) {
var m = new module.constructor();
m.paths = module.paths;
m._compile(src, filename);
return m.exports;
}
const {twice} = requireFromString(utilsSource, 'utils.js');
const user = process.env["USER"];
console.log(`Hello ${user}. 2 * 2 = ${twice(2)}`);
קימפול והפעלה של התוכנית האחרונה למרבה ההפתעה עובד ונותן לי להפעיל את הפונקציה twice גם אם אני מזיז את קובץ הבינארי שנוצר למקום אחר.
סך הכל אפליקציות קובץ יחיד זה רעיון מדליק ויכול לחסוך התקנה של node או של גרסה רלוונטית של node על מכונה של לקוח. לדעתי כל עוד המימוש הוא הטמעה של סקריפט בתוך הבינארי של node כלומר שהגודל של הקובץ יוצא מעל 100 מגה, וכל עוד אין דרך קלה להשתמש במודולים צד-שלישי אנשים לא ימהרו להשתמש בזה.