ADR 0112 — Автономный бинарник @brabo/runner через bun build --compile, со встроенным нативным аддоном .node через реальное извлечение во временный каталог
- Статус: Принят
- Дата: 2026-08-23
- Контекст: пункт backlog из ADR 0106
(«автономный бинарник (
pkg/bun build --compile), отдельный пункт backlog, более высокая стоимость, без определённого триггера») — спутник ADR 0106, который поставил путьnpm install -g @brabo/runnerи явно отложил этот.
Контекст
npm install -g @brabo/runner (ADR 0106) по-прежнему требует, чтобы у пользователя был
установлен Node ≥ 22.6 и чтобы он доверял npm компиляцию нативного аддона
node-pty на своей машине. Владелец продукта запросил более
сложную и более полную альтернативу — настоящий однофайловый бинарник, со
всей матрицей платформ, включая Windows с v1, — после того как ему была
явно предложена и им отклонена более
дешёвая схема «бинарник + папка node-pty-native/ рядом».
Решение
Почему bun build --compile, а не pkg
pkg (Vercel) не поддерживается с 2023 года (внутренне заменён
на @yao-pkg/pkg, форк сообщества), а его модель загрузки
«базового бинарника», привязанного к версии Node, — это ровно тот тип внешней
зависимости, который этот репозиторий избегает добавлять без вынуждающей причины (ADR
0033/0041 уже отвергают магию сборки, которая не доказывается). bun build --compile поддерживается, активно развивается и — что решающе — Bun
1.1+ документирует первоклассную поддержку встраивания нативных аддонов .node
через with { type: 'file' }, а это именно тот механизм, который этому ADR
так или иначе в итоге нужен.
Реальная проблема: node-pty разрешает собственный нативный аддон по ДИНАМИЧЕСКОМУ пути
lib/utils.js#loadNativeModule в node-pty (подтверждено чтением
установленного пакета, node_modules/node-pty/lib/utils.js) делает:
function loadNativeModule(name) {
var dirs = ['build/Release', 'build/Debug', `prebuilds/${process.platform}-${process.arch}`];
var relative = ['..', '.'];
for (var d of dirs) for (var r of relative) {
try { return { dir, module: require(`${r}/${d}/${name}.node`) }; } catch (e) {}
}
throw new Error(...);
}
Этот вызов require() имеет вычисляемый путь — бандлер Bun может
встроить только то, что разрешается статически, во время сборки (доказано
эмпирически: наивный bun build --compile src/index.ts, с
node-pty, упакованным обычным образом, компилируется без ошибок, а затем падает в
runtime с Cannot find module './prebuilds/linux-x64//pty.node' — нативный
файл так и не был встроен, молча). Вот и вся причина, по которой
этот пункт рискованнее пути npm: дело не в «встроить файл», а в
«заставить динамическое разрешение путей стороннего пакета работать
с файлами, которые существуют только внутри скомпилированного бандла».
Работающий механизм, доказанный эмпирически до написания production-кода
- Во время СБОРКИ (
apps/runner/scripts/build-bin.mjs, один запуск на нативную платформу — см. матрицу ниже) обойти установленный пакетnode-ptyи собрать каждый файл, который ему реально нужен в runtime: весьlib/**/*.js(исключая*.test.js— 13 файлов:index.js,unixTerminal.js,windowsTerminal.js,windowsPtyAgent.js,windowsConoutConnection.js,terminal.js,utils.js,types.js,interfaces.js,eventEmitter2.js,conpty_console_list_agent.js,shared/conout.js,worker/conoutSocketWorker.js) плюс нативный(е) файл(ы).nodeТЕКУЩЕЙ платформы —build/Release/*.node, когдаnode-gyp rebuildскомпилировал локально (Linux:node-ptyне поставляет никаких prebuild для Linux, что подтверждено перечислениемprebuilds/в установленном пакете — есть толькоdarwin-x64,darwin-arm64,win32-x64,win32-arm64), илиprebuilds/<platform>-<arch>/*.node, когда пакет его поставляет (macOS: 1 файл; Windows: 3 —pty.node/conpty.node/conpty_console_list.node, все подхватываются обобщённо glob-ом*.nodeв выбранном каталоге, без особого случая для отдельного файла). - Сгенерировать
apps/runner/src/native-pty-embed.generated.tsсо статическимimport fN from '<caminho-real>' with { type: 'file' }на каждый файл —with { type: 'file' }— единственный механизм Bun, требующий литерального спецификатора, и именно поэтому манифест должен быть сгенерирован (список файлов известен только послеpnpm installна этой конкретной платформе), а не написан вручную. - В RUNTIME (
apps/runner/src/native-pty-loader.ts), только внутри скомпилированного бинарника: записать каждый встроенный файл в РЕАЛЬНЫЙ временный каталог (fs.mkdtempSync), сохраняя точную исходную относительную структуру (lib/utils.js,build/Release/pty.nodeи т. д.). Это сохранение — весь фокус: вызовыrequire()самогоnode-ptyвсе относительные, поэтому, как только его файлы лежат в реальном каталоге в реальной форме, которую он ожидает, его динамическое разрешение работает без каких-либо изменений, ровно так, как если бы пакет был установлен обычнымnpm install. - Импортировать
<tempdir>/lib/index.jsпо абсолютному пути к файлу (await import(pathToFileURL(...).href)) — никогдаrequire('node-pty')по имени пакета, для чего понадобилась бы папкаnode_modules, которой нет рядом с автономным бинарником.
Два свойства этого механизма проверены эмпирически, а не
предположены: аддоны .node, встроенные через with { type: 'file' }, корректно
загружаются через require(), даже если переданный путь — виртуальный
/$bunfs/root/... (Bun прозрачно делает нативный аддон загружаемым через dlopen,
несмотря на фальшивый путь); и реальный ненативный
файл, прочитанный через with { type: 'file' } — fs.readFileSync(embeddedPath)
— корректно возвращает реальные байты, в том числе изнутри скомпилированного
бинарника (поэтому побайтовое повторное извлечение в реальный временный каталог
надёжно).
Два реальных бага, найденных только при запуске скомпилированного бинарника, а не при чтении поверхности API
1. --external node-pty обязателен в самом вызове bun build, даже если ничто в pty.ts больше не импортирует node-pty по
голому спецификатору. Некомпилированный резервный путь
native-pty-loader.ts (await import('node-pty'), используемый только вне
скомпилированного бинарника) — это литеральный динамический импорт: бандлер Bun его
обнаруживает и пытается разрешить во время --compile, какая бы ветвь
runtime на самом деле до него ни дошла, и поскольку он МОЖЕТ частично разрешить
node-pty из node_modules (в отличие от полностью
отсутствующего модуля), сборка молча проходит и подставляет на его место
бросающую заглушку. Симптом выглядел идентично самому первому сбою (ошибки
Cannot find module/is not a function появлялись только внутри цепочки
require самого lib/utils.js), что стоило реального времени отладки,
пока настоящая причина (упаковка резервного пути по голому спецификатору) не была
изолирована. --external node-pty в команде bun build --compile заставляет Bun
полностью оставить этот спецификатор в покое, в соответствии с external: ['node-pty'], который tsup.config.ts уже имел по той же причине.
2. Охрана самозапуска (ADR 0106) ломается по НОВОЙ причине внутри
скомпилированного бинарника. process.argv[1] внутри исполняемого файла
bun build --compile — это /$bunfs/root/<name> — виртуальный путь, — и
realpathSync() на нём бросает ENOENT (подтверждено на реальном скомпилированном
бинарнике до того, как трогать production-код), вне какого-либо
try/catch, ещё до попытки main(). У существующей охраны уже было
исправление на основе realpathSync (для случая symlink от npm install -g); этот ADR добавляет вторую, ортогональную ветвь:
import.meta.url.includes('/$bunfs/') равно true для каждого модуля внутри
скомпилированного бинарника (проверено как для входного модуля, так и для
импортированного модуля, не являющегося входом) и проверяется ПЕРВЫМ — у точки
входа автономного бинарника нет неоднозначности «импортирован
тестом», которую нужно разрешать, поэтому обнаружение скомпилированного случая запускает main()
безусловно, обходя вызов realpathSync, который иначе
обрушил бы процесс.
Тестирование интерактивной оболочки внутри PTY под Bun в этой песочнице оказалось ненадёжным — cat, а не bash, служит заменой оболочки в smoke
Путь самотестирования (--self-test-pty, внутренний недокументированный флаг
— см. ниже) изначально запускал реальную оболочку пользователя (bash) и набирал
echo MARKER, повторяя production-использование. Под чистым Node это завершалось значительно
меньше чем за секунду. Под Bun (bun run и внутри скомпилированного бинарника) тот
же самый вызов — запустить bash в PTY через node-pty, записать
команду, дождаться перерисовки приглашения — так и не завершился в пределах таймаута
20 с в этом контейнере песочницы, хотя первое эхо введённого ввода на уровне
ядра приходило немедленно (то есть PTY и нативный
аддон однозначно работали — лишь вывод перерисовки
приглашения самой оболочки так и не появился вовремя). Самотест стал запускать
/bin/cat вместо неё (GerenciadorDePty/shellPadrao() читает запускаемую оболочку только из $SHELL,
без параметра для её переопределения — smoke временно задаёт
process.env.SHELL = '/bin/cat' перед созданием PTY,
восстанавливая в finally). cat детерминирован (выдаёт ровно то, что
читает, без приглашения, без rc-файлов) и при этом доказывает реальную вещь:
GerenciadorDePty.abrir() запускает РЕАЛЬНЫЙ процесс ОС через нативный аддон,
escrever() пишет в его stdin через PTY, а onData доказывает, что
процесс прочитал и записал обратно, — строка-маркер, появляющаяся дважды
(один раз из-за эха ввода самого PTY на уровне ядра, другой — из-за
cat, действительно читающего и повторно выводящего), — это то, что отличает подлинный круг
через процесс от простого локального эха терминала. Это расхождение с
bash — и является ли оно специфичным для runtime Bun или артефактом этого
конкретного контейнера песочницы (без реального TTY, с ограниченным планированием
процессов/потоков) — НЕ было исследовано до первопричины дальше этого; оно
не затрагивает собственно продукт (вкладка Terminal всегда запускает реальную оболочку
пользователя на его реальной машине, вне какого-либо контейнера), и дальнейшее его
преследование было бы решением артефакта песочницы, а не дефекта продукта.
Это зафиксировано здесь на случай, если оно снова появится во время реальной валидации на
Windows/macOS.
Также найдено и исправлено при построении самотеста: process.exit(),
вызванный изнутри асинхронного callback нативного аддона (onData),
не завершал процесс Bun немедленно, пока запущенный дочерний процесс
(cat) оставался жив, а файловый дескриптор его PTY оставался
открытым, — процесс жил дольше намеренно короткого таймаута
ad hoc воспроизведения, пока дочерний процесс не был убит ПЕРВЫМ
(GerenciadorDePty.fechar(), вызывающий processo.kill()) до
разрешения. Production-код уже так делает (fechar() вызывается до
resolvePromise()), поэтому это не отдельный баг в поставленном механизме — это
зафиксировано, потому что это была бы лёгкая ловушка для того, кто будет трогать этот код
позже.
--self-test-pty — внутренний недокументированный флаг вне публичной поверхности CLI
scripts/smoke-bin.mjs должен доказать, что встроенный нативный аддон
работает внутри реально опубликованного бинарника, запуская реальный PTY и
читая реальный вывод — «без mock», по просьбе владельца продукта. Иного
способа достичь этого пути кода извне процесса нет (это
автономный исполняемый файл, а не библиотека), поэтому был
добавлен --self-test-pty: не задокументирован в uso(), разрешает node-pty ровно
как production, запускает PTY ровно как production (GerenciadorDePty)
и выходит, не трогая сеть (--project/--dir/--token на этом пути никогда
не читаются). Это та же категория решения, что и скрытый диагностический флаг
любого CLI (--version, --diag), — не второй путь кода с
собственным риском дрейфа, потому что он вызывает те же самые публичные методы, которые
вызывает production.
Матрица целей и что реально было проверено где
| Платформа | Runner | Источник нативного .node | Проверено в этой песочнице |
|---|---|---|---|
linux-x64 | ubuntu-latest | node-gyp rebuild (prebuild для Linux нет) | Да, от начала до конца — pnpm --filter runner build:bin + smoke:bin зелёные, многократно, включая два бага выше, найденных и исправленных при запуске реального скомпилированного бинарника |
linux-arm64 | ubuntu-24.04-arm | node-gyp rebuild | Нет — здесь нет машины ARM64. Механизм не зависит от архитектуры в коде этого репозитория (здесь ничто не ветвится по arch, только по обобщённой паре ${platform}-${arch}, которую уже использует сам node-pty); риск — кросс-тулчейн самого node-gyp на этом runner, вне контроля этого репозитория и не связанный с Bun |
darwin-x64 | macos-13 | prebuilds/darwin-x64/pty.node | Нет — здесь нет машины macOS. Подтверждено oven-sh/setup-bun и собственной документацией Bun, что bun build --compile нативно поддерживает macOS x64/arm64 (без кросс-компиляции; этот репозиторий собирает только на нативных runner) |
darwin-arm64 | macos-14 | prebuilds/darwin-arm64/pty.node | Нет — то же, что выше |
win32-x64 | windows-latest | prebuilds/win32-x64/{pty.node,conpty.node,conpty_console_list.node} | Нет — пробел наибольшего риска, сказано прямым текстом. Три нативных файла вместо одного, worker_threads.Worker из windowsConoutConnection.js, загружающий worker/conoutSocketWorker.js по пути, вычисляемому относительно извлечённого каталога, и скрипты сборки/тестов этого репозитория, работающие под bash через Git Bash вместо PowerShell (defaults: run: shell: bash в новом workflow), — всё это продумано чтением кода node-pty, но ни разу не выполнялось на реальной машине Windows. Этот ADR всё равно поставляет НАСТОЯЩИЙ однофайловый подход для Windows, согласно явному указанию владельца продукта сначала попробовать более сложный вариант, — но честный статус таков: ПЕРВЫЙ реальный запуск этой платформы — это следующий push тега версии (или ручной workflow_dispatch), а не этот PR |
Никакой резервный вариант по платформам (более дешёвая схема «бинарник + папка
node-pty-native/», явно предложенная и отклонённая) не был реализован
ни для одной платформы — ничто из наблюдаемого в этом исследовании не ОПРОВЕРГЛО
настоящий однофайловый подход ни для одной из пяти целей. Если
Windows действительно не сможет так работать, это проявится как реальный
сбой CI на первой сборке тега, и тогда резервный вариант станет точечным
исключением для одной платформы, ровно как разрешала просьба этого ADR, —
а не решением, принятым превентивно без доказательств.
Что было построено
apps/runner/src/native-pty-loader.ts— разрешаетnode-ptyдля обеих моделей выполнения (см. механизм выше).apps/runner/src/native-pty-embed.generated.ts— версионируется в git как ПУСТОЙ маркер (NATIVE_PTY_FILES: []);scripts/build-bin.mjsперезаписывает его реальным манифестом платформы непосредственно передbun build --compileи восстанавливает маркер вfinally— та же философия «никогда не коммитится, генерируется на одноразовом шаге», которую ADR 0106 уже установил для внедрения версии npm.apps/runner/src/pty.ts—GerenciadorDePtyтеперь получает разрешённый модульnode-ptyвнедрением через конструктор вместо статическогоimportверхнего уровня;import type * as NodePtyNamespace from 'node-pty', только типовой, ничего не стоит в runtime (удаляетсяverbatimModuleSyntax).apps/runner/src/index.ts— разрешаетnode-ptyодин раз вmain()(RN-452), исправляет охрану самозапуска для случая/$bunfs/и добавляет внутренний путь--self-test-pty.apps/runner/scripts/build-bin.mjs— генерирует манифест, вызываетbun build --compile --external node-pty,chmod 755вне Windows.apps/runner/scripts/smoke-bin.mjs— повторяет шаблонsmoke-dist.mjs, но запускает СКОМПИЛИРОВАННЫЙ БИНАРНИК как реальный подпроцесс (RN-452)..github/workflows/build-runner-binaries.yml— тот же триггер по тегу, что и уpublish-runner.yml/release.yml, матрица из 5 целей, каждый job собирает на СОБСТВЕННОМ нативном runner (никогда не кросс-компилирует), запускает реальный smoke и делаетgh release uploadв Release, которуюrelease.ymlуже создал, — идемпотентно (проверяетgh release view --json assetsперед загрузкой), никогда не создаёт Release самостоятельно, повторяет ограничение запуска только владельцем и шаблон разрешения тега, уже установленныеpublish-runner.yml.
Последствия
- Открывает ТРЕТИЙ путь распространения
@brabo/runner, наряду с клонированием monorepo (dev) иnpm install -g(ADR 0106): скачать один файл, без какого-либо тулчейна Node/npm/node-gyp на машине пользователя. - Существующий путь
npm install -g(ADR 0106) никак не меняется в поведении — рефакторингpty.tsпод внедрение через конструктор меняет лишь внутреннее устройство;pnpm --filter runner build/smoke/testвсе проходят без изменений. РЕАЛЬНАЯ регрессия, которую этот рефакторинг внёс и закрыл в том же изменении: перенос разрешенияnode-ptyиз побочного эффекта уровня модуля (поднятого до разбора аргументов, «бесплатное» smoke-покрытие самого ADR 0106) в ленивый вызов внутриmain()привёл к тому, что СУЩЕСТВУЮЩИЙsmoke-dist.mjs(который лишь проверяет путьuso(), без аргументов) перестал доказывать, чтоnode-ptyзагружается в артефакте npm/tsup.smoke-dist.mjsполучил новую проверку — реальные аргументы, недостижимый--api-url, ожидание строкиnode-pty carregado com sucesso, — закрывающую пробел в том же коммите, который его открыл, вместо того чтобы оставить его как молчаливую потерю покрытия. - Эксплуатация: ПЕРВЫЙ реальный запуск матрицы
build-runner-binaries.yml— это следующий push тега версии. Ничто в PR, вводящем этот workflow, его не задействует (запускается по тегу, какpublish-runner.yml), — четыре из пяти платформ проверены РАССУЖДЕНИЕМ о кодеnode-ptyи документированном поведении Bun, а не выполнением, и этот пробел точно назван, по платформам, в таблице выше, а не выдан за закрытый. dist-bin/находится в gitignore, та же трактовка, что и уdist/(ADR 0106).- Вне объёма, без изменений относительно ADR 0106: нюансы отзыва
PAT, эксклюзивность runner по
{project_id, machine_id}; а теперь также: подпись кода бинарников (Gatekeeper/нотаризация на macOS, Authenticode на Windows) — неподписанный бинарник, скачанный из GitHub Release, вызовет предупреждения ОС при первом запуске, реальная UX-стоимость, принятая для этой первой версии и оставленная для будущего пункта, поскольку требует, чтобы владелец продукта получил и оплатил идентичность подписи, — та же категория действия оператора, которую ADR 0106 уже объявил вне досягаемости дляNPM_TOKEN.