Перейти к основному содержимому

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-кода​

  1. Во время СБОРКИ (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 в выбранном каталоге, без особого случая для отдельного файла).
  2. Сгенерировать apps/runner/src/native-pty-embed.generated.ts со статическим import fN from '<caminho-real>' with { type: 'file' } на каждый файл — with { type: 'file' } — единственный механизм Bun, требующий литерального спецификатора, и именно поэтому манифест должен быть сгенерирован (список файлов известен только после pnpm install на этой конкретной платформе), а не написан вручную.
  3. В RUNTIME (apps/runner/src/native-pty-loader.ts), только внутри скомпилированного бинарника: записать каждый встроенный файл в РЕАЛЬНЫЙ временный каталог (fs.mkdtempSync), сохраняя точную исходную относительную структуру (lib/utils.js, build/Release/pty.node и т. д.). Это сохранение — весь фокус: вызовы require() самого node-pty все относительные, поэтому, как только его файлы лежат в реальном каталоге в реальной форме, которую он ожидает, его динамическое разрешение работает без каких-либо изменений, ровно так, как если бы пакет был установлен обычным npm install.
  4. Импортировать <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-x64ubuntu-latestnode-gyp rebuild (prebuild для Linux нет)Да, от начала до конца — pnpm --filter runner build:bin + smoke:bin зелёные, многократно, включая два бага выше, найденных и исправленных при запуске реального скомпилированного бинарника
linux-arm64ubuntu-24.04-armnode-gyp rebuildНет — здесь нет машины ARM64. Механизм не зависит от архитектуры в коде этого репозитория (здесь ничто не ветвится по arch, только по обобщённой паре ${platform}-${arch}, которую уже использует сам node-pty); риск — кросс-тулчейн самого node-gyp на этом runner, вне контроля этого репозитория и не связанный с Bun
darwin-x64macos-13prebuilds/darwin-x64/pty.nodeНет — здесь нет машины macOS. Подтверждено oven-sh/setup-bun и собственной документацией Bun, что bun build --compile нативно поддерживает macOS x64/arm64 (без кросс-компиляции; этот репозиторий собирает только на нативных runner)
darwin-arm64macos-14prebuilds/darwin-arm64/pty.nodeНет — то же, что выше
win32-x64windows-latestprebuilds/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.