From f133dd49f9ac1be9089a94e8ab26827ce121e397 Mon Sep 17 00:00:00 2001 From: robertsLando Date: Mon, 27 Apr 2026 17:20:22 +0200 Subject: [PATCH 1/3] feat: build hooks (preBuild, postBuild, transform) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add three first-class build hooks to replace the shell-script wrappers that previously had to surround pkg invocations: - preBuild — shell command or JS function run once before the walker - postBuild — shell command or JS function run once per produced binary; shell form receives the output path via PKG_OUTPUT - transform — JS-only per-file content rewrite, applied between the walker and bytecode/compression. Enables minify/obfuscate recipes without bundling them into pkg's runtime deps. Lifecycle: preBuild → walk → transform (per file) → bytecode/compression → write → postBuild (per binary). Hooks run identically in traditional and enhanced SEA pipelines; simple SEA mode supports preBuild/postBuild but skips transform (no walker output). Configurable via the typed Node.js API (all three hooks, function form included), package.json#pkg / .pkgrc (shell form for preBuild/postBuild) and pkg.config.{js,cjs,mjs} (function form for any hook). Closes #252. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs-site/guide/api.md | 130 +++++++++++++++--- docs-site/guide/configuration.md | 45 ++++--- docs/ARCHITECTURE.md | 30 +++-- lib/config.ts | 62 +++++++++ lib/hooks.ts | 144 ++++++++++++++++++++ lib/index.ts | 16 +++ lib/sea.ts | 30 +++++ lib/types.ts | 73 ++++++++++ test/test-46-hooks/index.js | 3 + test/test-46-hooks/main.js | 98 ++++++++++++++ test/unit/config-parse.test.ts | 101 ++++++++++++++ test/unit/hooks.test.ts | 221 +++++++++++++++++++++++++++++++ 12 files changed, 904 insertions(+), 49 deletions(-) create mode 100644 lib/hooks.ts create mode 100644 test/test-46-hooks/index.js create mode 100644 test/test-46-hooks/main.js create mode 100644 test/unit/hooks.test.ts diff --git a/docs-site/guide/api.md b/docs-site/guide/api.md index b1fa8c1b..dafe1659 100644 --- a/docs-site/guide/api.md +++ b/docs-site/guide/api.md @@ -58,25 +58,28 @@ The strings are exactly what you'd pass on the command line — see [Getting sta ### `PkgExecOptions` fields -| Field | Type | CLI equivalent | Notes | -| ------------------ | ---------------------------------------- | ---------------------- | ----------------------------------------------------------- | -| `input` | `string` | positional `` | **Required.** Entry file or directory. | -| `targets` | `string[]` | `--targets` | e.g. `['host']` or `['node22-linux-x64', ...]`. | -| `config` | `string` | `--config` | Path to `package.json` or standalone config JSON. | -| `output` | `string` | `--output` | Output file name or template. | -| `outputPath` | `string` | `--out-path` | Output directory (mutually exclusive with `output`). | -| `compress` | `'None' \| 'Brotli' \| 'GZip' \| 'Zstd'` | `--compress` | Default `'None'`. | -| `sea` | `boolean` | `--sea` | Use Single Executable Application mode. | -| `bakeOptions` | `string \| string[]` | `--options` | Node/V8 flags baked into the binary (e.g. `['expose-gc']`). | -| `debug` | `boolean` | `--debug` | Verbose packaging logs. | -| `build` | `boolean` | `--build` | Build base binaries from source. | -| `bytecode` | `boolean` | `--no-bytecode` | Default `true`. Set `false` to ship plain JS. | -| `nativeBuild` | `boolean` | `--no-native-build` | Default `true`. | -| `fallbackToSource` | `boolean` | `--fallback-to-source` | Ship source when bytecode compile fails. | -| `public` | `boolean` | `--public` | Top-level project is public. | -| `publicPackages` | `string[]` | `--public-packages` | Use `['*']` for all. | -| `noDictionary` | `string[]` | `--no-dict` | Use `['*']` to disable all dictionaries. | -| `signature` | `boolean` | `--no-signature` | Default `true` (macOS signing when applicable). | +| Field | Type | CLI equivalent | Notes | +| ------------------ | ------------------------------------------------------------------------ | ---------------------- | --------------------------------------------------------------------------------- | +| `input` | `string` | positional `` | **Required.** Entry file or directory. | +| `targets` | `string[]` | `--targets` | e.g. `['host']` or `['node22-linux-x64', ...]`. | +| `config` | `string` | `--config` | Path to `package.json` or standalone config JSON. | +| `output` | `string` | `--output` | Output file name or template. | +| `outputPath` | `string` | `--out-path` | Output directory (mutually exclusive with `output`). | +| `compress` | `'None' \| 'Brotli' \| 'GZip' \| 'Zstd'` | `--compress` | Default `'None'`. | +| `sea` | `boolean` | `--sea` | Use Single Executable Application mode. | +| `bakeOptions` | `string \| string[]` | `--options` | Node/V8 flags baked into the binary (e.g. `['expose-gc']`). | +| `debug` | `boolean` | `--debug` | Verbose packaging logs. | +| `build` | `boolean` | `--build` | Build base binaries from source. | +| `bytecode` | `boolean` | `--no-bytecode` | Default `true`. Set `false` to ship plain JS. | +| `nativeBuild` | `boolean` | `--no-native-build` | Default `true`. | +| `fallbackToSource` | `boolean` | `--fallback-to-source` | Ship source when bytecode compile fails. | +| `public` | `boolean` | `--public` | Top-level project is public. | +| `publicPackages` | `string[]` | `--public-packages` | Use `['*']` for all. | +| `noDictionary` | `string[]` | `--no-dict` | Use `['*']` to disable all dictionaries. | +| `signature` | `boolean` | `--no-signature` | Default `true` (macOS signing when applicable). | +| `preBuild` | `string \| () => void \| Promise` | _(none — API/config)_ | Shell command or function run before the walker. See [Build hooks](#build-hooks). | +| `postBuild` | `string \| (output: string) => void \| Promise` | _(none — API/config)_ | Run once per produced binary. Shell form receives `PKG_OUTPUT` env. | +| `transform` | `(file: string, contents: Buffer \| string) => Buffer \| string \| void` | _(none — API only)_ | Per-file content transform (minify, obfuscate, etc.). | ## Build a full release pipeline @@ -126,6 +129,95 @@ try { } ``` +## Build hooks + +`pkg` exposes three hooks that run at well-defined points in the build pipeline. They turn shell scripts that previously had to wrap `pkg` (pre-bundle, smoke-test, minify, etc.) into first-class config. + +### Lifecycle order + +``` +preBuild → walk → transform (per file) → bytecode/compression → write → postBuild (per binary) +``` + +### `preBuild` + +Runs once before the walker collects files. Use it for setup work — pre-bundling with esbuild/webpack, codegen, fetching assets. Throw or exit non-zero to abort the build. + +::: code-group + +```js [Function] +await exec({ + input: 'src/index.js', + preBuild: async () => { + await build({ entryPoints: ['src/index.js'], outfile: 'dist/bundle.js' }); + }, +}); +``` + +```json [package.json#pkg] +{ + "pkg": { + "preBuild": "esbuild src/index.js --bundle --outfile=dist/bundle.js" + } +} +``` + +::: + +### `postBuild` + +Runs once per produced binary, after the file has been written and (where applicable) codesigned. Use it for smoke tests, signing, notarization, upload. The shell form receives the absolute output path via the `PKG_OUTPUT` environment variable; the function form receives it as the first argument. + +::: code-group + +```js [Function] +await exec({ + input: 'src/index.js', + postBuild: async (output) => { + await execFileAsync(output, ['--version']); + }, +}); +``` + +```json [package.json#pkg] +{ + "pkg": { + "postBuild": "\"$PKG_OUTPUT\" --version" + } +} +``` + +::: + +### `transform` + +JS-function-only — applied to each file the walker collected, after refinement and before bytecode/compression. Receives the absolute on-disk path and current contents, returns the replacement (a `Buffer` or `string`) or `undefined`/`void` to leave the file unchanged. + +`transform` is the hook for **minification and obfuscation** — `pkg` deliberately ships no minifier of its own so the runtime dependency footprint stays small. Drop in your tool of choice: + +```js +import { exec } from '@yao-pkg/pkg'; +import { minify } from 'terser'; + +await exec({ + input: 'src/index.js', + output: 'dist/app', + transform: async (file, contents) => { + if (!file.endsWith('.js')) return; // leave non-JS untouched + const { code } = await minify(contents.toString()); + return code; + }, +}); +``` + +The transform sees the **exact** set of files `pkg` is embedding (walker output, post-refine), never the user's source tree on disk — so the original repo is left intact. + +### Notes + +- Shell hooks are spawned with `shell: true` and inherit stdio, so the user sees their tool's live output. Non-zero exit fails the build. +- Function-form hooks are reachable from the Node.js API and from `pkg.config.{js,cjs,mjs}` (which can export a function value); JSON-format config (`package.json#pkg`, `.pkgrc`, `.pkgrc.json`) can only carry the shell-string form. +- In simple SEA mode (`--sea` without a `package.json`), `transform` is a no-op — there's no walker output to apply per-file rewrites to. `preBuild` and `postBuild` still run. + ## See also - [CLI options](/guide/options) diff --git a/docs-site/guide/configuration.md b/docs-site/guide/configuration.md index b4cd4c22..ca5dfed9 100644 --- a/docs-site/guide/configuration.md +++ b/docs-site/guide/configuration.md @@ -89,27 +89,30 @@ When both a pkgrc and a `pkg` field in `package.json` are present, the pkgrc win ## Full schema -| Key | Type | Description | -| ------------------ | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `scripts` | glob \| string[] | JS files compiled to V8 bytecode and embedded without source — see [Scripts](#scripts) | -| `assets` | glob \| string[] | Files embedded as raw content, accessible under `/snapshot/` — see [Assets](#assets) | -| `ignore` | string[] | Globs excluded from the final executable — see [Ignore files](#ignore-files) | -| `targets` | string \| string[] | Target triples, e.g. `node22-linux-x64`; accepts a single target, an array, or a comma-separated string — see [Targets](/guide/targets) | -| `outputPath` | string | Directory for output binaries (equivalent to CLI `--out-path`) | -| `patches` | object | Patch modules that can't be packaged as-is — see [pkg source](https://github.com/yao-pkg/pkg/blob/main/dictionary/) for examples | -| `sea` | boolean | Opt into [SEA mode](/guide/sea-mode) without passing `--sea` | -| `seaConfig` | object | Forwarded to Node.js SEA config (`useCodeCache`, `disableExperimentalSEAWarning`, etc.) | -| `deployFiles` | tuple[] | Files that cannot be bundled; each entry is `[from, to]` or `[from, to, "directory"]`. pkg logs a reminder to ship each one next to the output at runtime | -| `compress` | string | VFS compression algorithm — `None` (default), `Brotli`, `GZip`, or `Zstd`. Equivalent to CLI `--compress` | -| `fallbackToSource` | boolean | Ship source when bytecode generation fails for a file. Equivalent to CLI `--fallback-to-source` | -| `public` | boolean | Speed up packaging and disclose top-level project sources. Equivalent to CLI `--public` | -| `publicPackages` | string \| string[] | Package names treated as public. Use `"*"` for all. Equivalent to CLI `--public-packages` | -| `options` | string \| string[] | V8 / Node options baked into the executable, e.g. `["expose-gc"]`. Equivalent to CLI `--options` | -| `bytecode` | boolean | Compile bytecode (default `true`). Set to `false` for source-only builds. Equivalent to CLI `--no-bytecode` | -| `nativeBuild` | boolean | Build native addons (default `true`). Equivalent to CLI `--no-native-build` (set `false`) | -| `noDictionary` | string \| string[] | Package names whose dictionary handling is skipped. Use `"*"` for all. Equivalent to CLI `--no-dict` | -| `debug` | boolean | Verbose packaging logs. Equivalent to CLI `--debug` | -| `signature` | boolean | Sign macOS binaries when applicable (default `true`). Equivalent to CLI `--signature` / `--no-signature` | +| Key | Type | Description | +| ------------------ | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `scripts` | glob \| string[] | JS files compiled to V8 bytecode and embedded without source — see [Scripts](#scripts) | +| `assets` | glob \| string[] | Files embedded as raw content, accessible under `/snapshot/` — see [Assets](#assets) | +| `ignore` | string[] | Globs excluded from the final executable — see [Ignore files](#ignore-files) | +| `targets` | string \| string[] | Target triples, e.g. `node22-linux-x64`; accepts a single target, an array, or a comma-separated string — see [Targets](/guide/targets) | +| `outputPath` | string | Directory for output binaries (equivalent to CLI `--out-path`) | +| `patches` | object | Patch modules that can't be packaged as-is — see [pkg source](https://github.com/yao-pkg/pkg/blob/main/dictionary/) for examples | +| `sea` | boolean | Opt into [SEA mode](/guide/sea-mode) without passing `--sea` | +| `seaConfig` | object | Forwarded to Node.js SEA config (`useCodeCache`, `disableExperimentalSEAWarning`, etc.) | +| `deployFiles` | tuple[] | Files that cannot be bundled; each entry is `[from, to]` or `[from, to, "directory"]`. pkg logs a reminder to ship each one next to the output at runtime | +| `compress` | string | VFS compression algorithm — `None` (default), `Brotli`, `GZip`, or `Zstd`. Equivalent to CLI `--compress` | +| `fallbackToSource` | boolean | Ship source when bytecode generation fails for a file. Equivalent to CLI `--fallback-to-source` | +| `public` | boolean | Speed up packaging and disclose top-level project sources. Equivalent to CLI `--public` | +| `publicPackages` | string \| string[] | Package names treated as public. Use `"*"` for all. Equivalent to CLI `--public-packages` | +| `options` | string \| string[] | V8 / Node options baked into the executable, e.g. `["expose-gc"]`. Equivalent to CLI `--options` | +| `bytecode` | boolean | Compile bytecode (default `true`). Set to `false` for source-only builds. Equivalent to CLI `--no-bytecode` | +| `nativeBuild` | boolean | Build native addons (default `true`). Equivalent to CLI `--no-native-build` (set `false`) | +| `noDictionary` | string \| string[] | Package names whose dictionary handling is skipped. Use `"*"` for all. Equivalent to CLI `--no-dict` | +| `debug` | boolean | Verbose packaging logs. Equivalent to CLI `--debug` | +| `signature` | boolean | Sign macOS binaries when applicable (default `true`). Equivalent to CLI `--signature` / `--no-signature` | +| `preBuild` | string \| function | Shell command or JS function run once before the walker — see [Build hooks](/guide/api#build-hooks) | +| `postBuild` | string \| function | Shell command or JS function run once per produced binary; shell form receives the path via `PKG_OUTPUT` | +| `transform` | function | Per-file content transform applied between the walker and bytecode/compression — function-only, reachable from the Node.js API or `pkg.config.{js,cjs,mjs}` | CLI flags always override config values. Unknown keys under `pkg` produce a warning. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7f697731..cdacdda6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -104,6 +104,8 @@ CLI (lib/index.ts) ├─ Parse targets (node22-linux-x64, etc.) ├─ Fetch pre-compiled Node.js binaries (via @yao-pkg/pkg-fetch) │ + ├─ runPreBuild() — lib/hooks.ts (shell or fn, see "Build Hooks") + │ ├─ Walker (lib/walker.ts) │ ├─ Parse entry file with Babel → find require/import calls │ ├─ Recursively resolve dependencies (lib/follow.ts, lib/resolver.ts) @@ -115,6 +117,8 @@ CLI (lib/index.ts) │ ├─ Purge empty top-level directories │ └─ Denominate paths (strip common prefix) │ + ├─ runTransform() — lib/hooks.ts (per-file content rewrite) + │ ├─ Packer (lib/packer.ts) │ ├─ Serialize file records into "stripes" (snap path + store + data) │ ├─ Wrap bootstrap.js with injected parameters: @@ -122,14 +126,16 @@ CLI (lib/index.ts) │ │ DEFAULT_ENTRYPOINT, SYMLINKS, DICT, DOCOMPRESS │ └─ Return { prelude, entrypoint, stripes } │ - └─ Producer (lib/producer.ts) - ├─ Open Node.js binary - ├─ Find placeholders (PAYLOAD_POSITION, PAYLOAD_SIZE, BAKERY, etc.) - ├─ Stream stripes into payload section - ├─ Apply compression (Brotli/GZip) per stripe - ├─ Build VFS dictionary for path compression - ├─ Inject byte offsets into placeholders - └─ Write final executable + ├─ Producer (lib/producer.ts) + │ ├─ Open Node.js binary + │ ├─ Find placeholders (PAYLOAD_POSITION, PAYLOAD_SIZE, BAKERY, etc.) + │ ├─ Stream stripes into payload section + │ ├─ Apply compression (Brotli/GZip) per stripe + │ ├─ Build VFS dictionary for path compression + │ ├─ Inject byte offsets into placeholders + │ └─ Write final executable + │ + └─ runPostBuild() — lib/hooks.ts (per-binary, sets PKG_OUTPUT) ``` ### Binary Format @@ -227,6 +233,8 @@ CLI (lib/index.ts) │ ├─ Detect: has package.json + target Node >= 22 → enhanced mode │ + ├─ runPreBuild() — lib/hooks.ts (shared with traditional mode) + │ ├─ Walker (lib/walker.ts, seaMode: true) │ ├─ Parse entry file with Babel → find require/import calls │ ├─ Recursively resolve dependencies @@ -237,6 +245,8 @@ CLI (lib/index.ts) ├─ Refiner (lib/refiner.ts) │ └─ Same as traditional (path compression, empty dir pruning) │ + ├─ runTransform() — lib/hooks.ts (per-file content rewrite) + │ ├─ SEA Asset Generator (lib/sea-assets.ts) │ ├─ Concatenate all STORE_CONTENT files into a single __pkg_archive__ blob │ ├─ Build __pkg_manifest__.json: @@ -265,7 +275,8 @@ CLI (lib/index.ts) │ 1. Download Node.js binary (getNodejsExecutable) │ 2. Inject blob via postject (bake) │ 3. Sign macOS if needed (signMacOSIfNeeded) - └─ Cleanup tmpDir + ├─ Cleanup tmpDir + └─ runPostBuild() — lib/hooks.ts (per-binary, sets PKG_OUTPUT) ``` ### SEA Binary Format @@ -625,3 +636,4 @@ With `node:vfs` and `"useVfs": true` in the SEA config, assets will be auto-moun | `lib/esm-transformer.ts` | ~434 | ESM to CJS transformation (traditional mode only) | | `lib/refiner.ts` | ~110 | Path compression, empty directory pruning | | `lib/common.ts` | ~375 | Path normalization, snapshot helpers, store constants | +| `lib/hooks.ts` | ~150 | Build hooks: preBuild, postBuild, transform (shell + function forms) | diff --git a/lib/config.ts b/lib/config.ts index 716febe0..ab18ba7f 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -271,6 +271,12 @@ export interface ParsedInput { build?: boolean; /** Pre-merge flag values keyed by CLI name (see `RawFlags`). */ flags: RawFlags; + /** + * Programmatic-API-only `pkg` overrides that don't have a flag/config-file + * equivalent (currently: function-typed build hooks). Merged into the + * resolved `pkg` config in `resolveConfig`. The CLI never populates this. + */ + apiPkg?: Partial; } /** @@ -392,6 +398,35 @@ function parseOptionsInput(options: PkgExecOptions): ParsedInput { } } + const apiPkg: Partial = {}; + const validateShellOrFn = ( + key: 'preBuild' | 'postBuild', + v: unknown, + ): void => { + if (typeof v !== 'string' && typeof v !== 'function') { + throw wasReported( + `exec() option "${key}" must be a shell command (string) or a function`, + ); + } + if (typeof v === 'string' && v.trim() === '') { + throw wasReported(`exec() option "${key}" must not be an empty string`); + } + }; + if (options.preBuild !== undefined) { + validateShellOrFn('preBuild', options.preBuild); + apiPkg.preBuild = options.preBuild; + } + if (options.postBuild !== undefined) { + validateShellOrFn('postBuild', options.postBuild); + apiPkg.postBuild = options.postBuild; + } + if (options.transform !== undefined) { + if (typeof options.transform !== 'function') { + throw wasReported(`exec() option "transform" must be a function`); + } + apiPkg.transform = options.transform; + } + return { entry: options.input, config: options.config, @@ -400,6 +435,7 @@ function parseOptionsInput(options: PkgExecOptions): ParsedInput { targets: joinList(options.targets), build: options.build, flags, + apiPkg: Object.keys(apiPkg).length ? apiPkg : undefined, }; } @@ -431,6 +467,9 @@ const NON_FLAG_PKG_KEYS = [ 'targets', 'outputPath', 'seaConfig', + 'preBuild', + 'postBuild', + 'transform', ] as const; /** Union of flag-driven and static keys — anything outside this set warns. */ @@ -468,6 +507,24 @@ export function validatePkgConfig(cfg: unknown): void { throw wasReported(`pkg config: "${s.cfg}" must be a string or string[]`); } } + // Hooks: shell-string or function for pre/postBuild, function-only for + // transform. Functions are unreachable from JSON config files but valid + // when loaded from `pkg.config.{js,cjs,mjs}` or passed via the Node API. + for (const key of ['preBuild', 'postBuild'] as const) { + const v = rec[key]; + if (v === undefined) continue; + if (typeof v !== 'string' && typeof v !== 'function') { + throw wasReported( + `pkg config: "${key}" must be a shell command (string) or a function`, + ); + } + if (typeof v === 'string' && v.trim() === '') { + throw wasReported(`pkg config: "${key}" must not be an empty string`); + } + } + if (rec.transform !== undefined && typeof rec.transform !== 'function') { + throw wasReported(`pkg config: "transform" must be a function`); + } } // --------------------------------------------------------------------------- @@ -1004,6 +1061,11 @@ export async function resolveConfig( if (typeof rawPkg !== 'object' || rawPkg === null || Array.isArray(rawPkg)) { throw wasReported('pkg config: "pkg" must be an object'); } + // Programmatic-API hook fields layered on top of any config-file hooks. + // Defined last so they win — the API call site is the most explicit. + if (parsed.apiPkg) { + Object.assign(rawPkg, parsed.apiPkg); + } validatePkgConfig(rawPkg); const flags = resolveFlags(parsed.flags, rawPkg as PkgOptions); const pkg = applyResolvedFlags(rawPkg as PkgOptions, flags); diff --git a/lib/hooks.ts b/lib/hooks.ts new file mode 100644 index 00000000..2e5c5158 --- /dev/null +++ b/lib/hooks.ts @@ -0,0 +1,144 @@ +import { spawn } from 'child_process'; +import { readFile } from 'fs/promises'; + +import { log, wasReported } from './log'; +import { STORE_BLOB, STORE_CONTENT } from './common'; +import type { + FileRecords, + PkgOptions, + PostBuildHook, + PreBuildHook, + TransformHook, +} from './types'; + +/** + * Run a shell command synchronously to-completion. stdio is inherited so the + * user sees their tool's output live; a non-zero exit (or spawn error) throws + * a `wasReported` error so `exec()` aborts with the standard pkg error path. + * + * `extraEnv` is layered on top of `process.env` — used to expose `PKG_OUTPUT` + * to `postBuild` shell hooks. + */ +async function runShell( + command: string, + extraEnv: NodeJS.ProcessEnv, + hookName: string, +): Promise { + await new Promise((resolvePromise, rejectPromise) => { + const child = spawn(command, { + shell: true, + stdio: 'inherit', + env: { ...process.env, ...extraEnv }, + }); + child.on('error', (err) => { + rejectPromise( + wasReported(`${hookName} hook failed to spawn: ${err.message}`), + ); + }); + child.on('exit', (code, signal) => { + if (code === 0) return resolvePromise(); + const reason = signal != null ? `signal ${signal}` : `exit code ${code}`; + rejectPromise( + wasReported(`${hookName} hook failed (${reason}): ${command}`), + ); + }); + }); +} + +/** + * Invoke `preBuild` if configured. Runs once before the walker, regardless + * of pipeline (traditional, simple SEA, enhanced SEA). + */ +export async function runPreBuild(pkg: PkgOptions): Promise { + const hook = pkg.preBuild; + if (hook === undefined) return; + log.info('Running preBuild hook...'); + if (typeof hook === 'string') { + await runShell(hook, {}, 'preBuild'); + return; + } + await (hook as PreBuildHook)(); +} + +/** + * Invoke `postBuild` if configured. Called once per produced binary, after + * it has been written and (where applicable) codesigned. + * + * Shell form receives the absolute output path via `PKG_OUTPUT`; function + * form receives it as the first argument. + */ +export async function runPostBuild( + pkg: PkgOptions, + output: string, +): Promise { + const hook = pkg.postBuild; + if (hook === undefined) return; + log.info(`Running postBuild hook for ${output}`); + if (typeof hook === 'string') { + await runShell(hook, { PKG_OUTPUT: output }, 'postBuild'); + return; + } + await (hook as PostBuildHook)(output); +} + +/** + * Apply the `transform` hook to every record that ships file contents + * (STORE_BLOB or STORE_CONTENT). Must run after the refiner (so paths are + * final) and before bytecode generation / compression (so the transformed + * source feeds those steps). + * + * Bodies are loaded eagerly when the user opts into transform — without + * loading, packer/sea-assets would re-read disk and bypass the transform. + * This trades memory for correctness; the cost only applies to builds that + * configure a transform. + */ +export async function runTransform( + pkg: PkgOptions, + records: FileRecords, +): Promise { + const fn = pkg.transform; + if (fn === undefined) return; + log.info('Running transform hook...'); + + for (const snap of Object.keys(records)) { + const record = records[snap]; + if (!record) continue; + if (!record[STORE_BLOB] && !record[STORE_CONTENT]) continue; + + let body: Buffer | string; + if (record.body !== undefined) { + body = record.body; + } else { + try { + body = await readFile(record.file); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw wasReported( + `transform hook: failed to read "${record.file}": ${reason}`, + ); + } + } + + let result: string | Buffer | void | undefined; + try { + result = await (fn as TransformHook)(record.file, body); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + throw wasReported(`transform hook threw for "${record.file}": ${reason}`); + } + + if (result === undefined) { + // User opted not to change this file. Cache the body we just loaded + // so packer/sea-assets don't re-read the same bytes from disk. + record.body = body; + continue; + } + if (typeof result !== 'string' && !Buffer.isBuffer(result)) { + throw wasReported( + `transform hook for "${record.file}" returned ${typeof result}; ` + + `expected string, Buffer, or undefined`, + ); + } + record.body = result; + } +} diff --git a/lib/index.ts b/lib/index.ts index 4b224def..b278e914 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -6,6 +6,7 @@ import path from 'path'; import { log, wasReported } from './log'; import help from './help'; +import { runPostBuild, runPreBuild, runTransform } from './hooks'; import packer from './packer'; import { plusx } from './chmod'; import producer from './producer'; @@ -170,6 +171,12 @@ export async function exec( // marker + options (shared between SEA and traditional pipelines) pkgOptions.set(pkg); + + // preBuild runs once, before any walking / Node binary fetch / SEA work. + // Placed here (not inside each pipeline) so a single hook covers + // traditional, simple SEA, and enhanced SEA without duplication. + await runPreBuild(pkg); + const marker = buildMarker(configJson, config, inputJson, input); // public / no-dict flags (shared between SEA and traditional pipelines) @@ -293,6 +300,11 @@ export async function exec( records = refineResult.records; symLinks = refineResult.symLinks; + // Transform runs after refinement so hooks see the final paths and the + // final set of records, but before packer hands STORE_BLOB sources to + // the bytecode fabricator and before STORE_CONTENT bodies are compressed. + await runTransform(pkg, records); + const backpack = packer({ records, entrypoint, bytecode, symLinks }); log.debug('Targets:', JSON.stringify(targets, null, 2)); @@ -325,6 +337,10 @@ export async function exec( await signMacOSIfNeeded(target.output, target, flags.signature); await plusx(target.output); } + + if (target.output) { + await runPostBuild(pkg, target.output); + } } shutdown(); diff --git a/lib/sea.ts b/lib/sea.ts index a2e976b2..2e203b65 100644 --- a/lib/sea.ts +++ b/lib/sea.ts @@ -33,6 +33,8 @@ import { patchMachOExecutable, signMachOExecutable } from './mach-o'; import walk from './walker'; import refine from './refiner'; import { generateSeaAssets } from './sea-assets'; +import { runPostBuild, runTransform } from './hooks'; +import pkgOptions from './options'; import { inject as postjectInject } from 'postject'; import { system } from '@yao-pkg/pkg-fetch'; @@ -797,6 +799,10 @@ export async function seaEnhanced( symLinks, } = refine(walkResult.records, walkResult.entrypoint, walkResult.symLinks); + // Apply user transform hook before assets are generated, so any + // minification/obfuscation flows into the SEA archive bytes. + await runTransform(pkgOptions.get(), records); + // Resolve target outputs to absolute paths before chdir to tmpDir for (const target of opts.targets) { if (target.output) { @@ -877,6 +883,18 @@ export async function seaEnhanced( }), ); }); + + // postBuild runs once per produced binary, after baking + signing have + // completed. Sequential so hook stdout/stderr lines from different + // targets don't interleave on the user's terminal. + const pkg = pkgOptions.get(); + if (pkg.postBuild !== undefined) { + for (const target of opts.targets) { + if (target.output) { + await runPostBuild(pkg, target.output); + } + } + } } /** Create NodeJS executable using sea */ @@ -925,4 +943,16 @@ export default async function sea(entryPoint: string, opts: SeaOptions) { }), ); }); + + // Simple SEA mode supports postBuild but not transform — there's no + // walker output to apply per-file rewrites to. preBuild already ran in + // index.ts before this function was called. + const pkg = pkgOptions.get(); + if (pkg.postBuild !== undefined) { + for (const target of opts.targets) { + if (target.output) { + await runPostBuild(pkg, target.output); + } + } + } } diff --git a/lib/types.ts b/lib/types.ts index 8f1121f6..cedbdb35 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -39,6 +39,45 @@ export type ConfigDictionary = Record< // named variants (`'None' | 'Brotli' | ...`) are accepted. export type PkgCompressType = Exclude; +/** + * Build hook called once before the walker collects files. Use for setup + * work like pre-bundling with esbuild/webpack, codegen, or fetching assets. + * + * Function form takes no arguments; throw or return a rejected promise to + * abort the build. + */ +export type PreBuildHook = () => void | Promise; + +/** + * Build hook called once per produced binary, after it has been written + * (and codesigned/chmodded on macOS/Linux). Use for smoke tests, signing, + * notarization, upload, etc. + * + * Function form receives the absolute output path. Shell form receives it + * via the `PKG_OUTPUT` env var. Throw / non-zero exit to fail the build. + */ +export type PostBuildHook = (output: string) => void | Promise; + +/** + * Per-file transform applied after the walker collects files and after + * refinement, but before bytecode compilation and compression. Use for + * minification, obfuscation, or any other content rewrite. + * + * Receives the absolute on-disk file path and the current contents (a + * Buffer when loaded from disk, a string when an earlier step rewrote it). + * Return the replacement bytes/string to apply, or `undefined`/`void` to + * leave the file unchanged. + */ +export type TransformHook = ( + filePath: string, + contents: Buffer | string, +) => + | string + | Buffer + | void + | undefined + | Promise; + export interface PkgOptions { scripts?: string[]; log?: (logger: typeof log, context: Record) => void; @@ -66,6 +105,23 @@ export interface PkgOptions { debug?: boolean; signature?: boolean; sea?: boolean; + /** + * Shell command (string) or JS function run once before the walker. + * Function form is only reachable via the Node.js API or a `pkg.config.js` + * file — JSON config files can only carry the shell form. + */ + preBuild?: string | PreBuildHook; + /** + * Shell command (string) or JS function run once per produced binary. + * Shell form receives the output path via `PKG_OUTPUT`. + */ + postBuild?: string | PostBuildHook; + /** + * Per-file content transform. Function form only — shell-string transforms + * are not supported because piping every file through a child process + * would be prohibitively slow. + */ + transform?: TransformHook; } export interface PackageJson { @@ -200,4 +256,21 @@ export interface PkgExecOptions { noDictionary?: string[]; /** Sign macOS binaries when applicable. Default `true`. */ signature?: boolean; + /** + * Shell command (string) or JS function run once before the walker + * collects files. Throw or reject to abort the build. + */ + preBuild?: string | PreBuildHook; + /** + * Shell command (string) or JS function run once per produced binary, + * after it has been written. Function form receives the output path; + * shell form receives it via `PKG_OUTPUT`. + */ + postBuild?: string | PostBuildHook; + /** + * Per-file content transform applied after walking and refinement, before + * bytecode and compression. Use for minify/obfuscate; receives + * `(filePath, contents)` and returns the replacement (or void to keep). + */ + transform?: TransformHook; } diff --git a/test/test-46-hooks/index.js b/test/test-46-hooks/index.js new file mode 100644 index 00000000..4dba0f1e --- /dev/null +++ b/test/test-46-hooks/index.js @@ -0,0 +1,3 @@ +'use strict'; + +console.log('PKG_HOOKS_MARKER'); diff --git a/test/test-46-hooks/main.js b/test/test-46-hooks/main.js new file mode 100644 index 00000000..aef9a9ca --- /dev/null +++ b/test/test-46-hooks/main.js @@ -0,0 +1,98 @@ +#!/usr/bin/env node + +'use strict'; + +const assert = require('assert'); +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const utils = require('../utils.js'); + +assert(__dirname === process.cwd()); + +// Hooks are most thoroughly exercised through the programmatic Node.js API +// (function-form preBuild/postBuild/transform aren't reachable from the CLI). +// We require the built lib-es5 entry point directly — same entry point +// `utils.pkg.sync` shells out to. +const es5 = path.resolve(__dirname, '../../lib-es5/index.js'); +assert(fs.existsSync(es5), 'Run `yarn build` first!'); +const pkg = require(es5); + +const target = process.argv[2] || 'host'; +const ext = process.platform === 'win32' ? '.exe' : ''; +const output = `test-46-hooks-out${ext}`; +const preMarker = path.resolve(__dirname, 'pre-marker.txt'); +const newcomers = [output, 'pre-marker.txt']; + +const before = utils.filesBefore(newcomers); + +let preCalls = 0; +let postCalls = 0; +let postOutput = null; +let transformedFiles = []; + +(async () => { + await pkg.exec({ + input: path.resolve(__dirname, 'index.js'), + targets: [target], + output: path.resolve(__dirname, output), + debug: false, + // Shell preBuild: writes a sentinel file we can detect afterwards. + preBuild: + process.platform === 'win32' + ? `cmd /c "echo ran > pre-marker.txt"` + : `echo ran > pre-marker.txt`, + // Function preBuild would also work; we want to cover both forms in one + // run, so wrap the shell hook with a function that asserts ordering. + // Here we keep preBuild as the shell form and use postBuild as fn. + postBuild: (out) => { + postCalls++; + postOutput = out; + }, + transform: (file, contents) => { + transformedFiles.push(file); + const text = contents.toString(); + if (text.includes('PKG_HOOKS_MARKER')) { + return text.replace(/PKG_HOOKS_MARKER/g, 'PKG_HOOKS_MUTATED'); + } + return undefined; // leave non-target files untouched + }, + }); + preCalls = fs.existsSync(preMarker) ? 1 : 0; + + // 1) preBuild ran + assert.equal(preCalls, 1, 'preBuild shell hook did not run'); + + // 2) postBuild ran exactly once with the target output path + assert.equal(postCalls, 1, 'postBuild ran ' + postCalls + ' times'); + assert.equal( + postOutput, + path.resolve(__dirname, output), + 'postBuild output path mismatch: ' + postOutput, + ); + + // 3) transform was invoked for the entrypoint at least + assert.ok( + transformedFiles.some((f) => f.endsWith('index.js')), + 'transform never saw index.js: ' + transformedFiles.join(','), + ); + + // 4) the produced binary actually prints the transformed marker — proves + // the transform mutation flowed all the way into the bundle. + const stdout = execFileSync(path.resolve(__dirname, output), { + encoding: 'utf8', + }); + assert.ok( + stdout.includes('PKG_HOOKS_MUTATED'), + 'binary did not print the transformed marker; stdout=' + stdout, + ); + assert.ok( + !stdout.includes('PKG_HOOKS_MARKER'), + 'original marker leaked through transform; stdout=' + stdout, + ); + + utils.filesAfter(before, newcomers); +})().catch((err) => { + console.error(err.stack || err.message); + process.exit(1); +}); diff --git a/test/unit/config-parse.test.ts b/test/unit/config-parse.test.ts index 49b02293..7433f27f 100644 --- a/test/unit/config-parse.test.ts +++ b/test/unit/config-parse.test.ts @@ -355,6 +355,60 @@ describe('parseInput — PkgExecOptions', () => { assert.equal(p.flags.bytecode, undefined); assert.equal(p.flags.compress, undefined); }); + + describe('hooks via PkgExecOptions', () => { + it('preBuild string lands in apiPkg', () => { + const p = parseInput({ input: 'a.js', preBuild: 'esbuild ...' }); + assert.equal(p.apiPkg?.preBuild, 'esbuild ...'); + }); + it('preBuild function lands in apiPkg', () => { + const fn = () => undefined; + const p = parseInput({ input: 'a.js', preBuild: fn }); + assert.equal(p.apiPkg?.preBuild, fn); + }); + it('postBuild + transform together', () => { + const post = (out: string) => { + void out; + }; + const xform = () => undefined; + const p = parseInput({ + input: 'a.js', + postBuild: post, + transform: xform, + }); + assert.equal(p.apiPkg?.postBuild, post); + assert.equal(p.apiPkg?.transform, xform); + }); + it('apiPkg absent when no hook fields set', () => { + assert.equal(parseInput({ input: 'a.js' }).apiPkg, undefined); + }); + it('preBuild as number throws', () => { + assert.throws( + () => + parseInput({ + input: 'a.js', + preBuild: 42, + } as unknown as Parameters[0]), + /preBuild.*must be a shell command \(string\) or a function/, + ); + }); + it('preBuild empty string throws', () => { + assert.throws( + () => parseInput({ input: 'a.js', preBuild: '' }), + /preBuild.*must not be an empty string/, + ); + }); + it('transform as string throws', () => { + assert.throws( + () => + parseInput({ + input: 'a.js', + transform: 'minify', + } as unknown as Parameters[0]), + /transform.*must be a function/, + ); + }); + }); }); describe('resolveFlags — CLI > config > default', () => { @@ -510,6 +564,9 @@ describe('validatePkgConfig', () => { targets: [], outputPath: '', seaConfig: {}, + preBuild: 'echo pre', + postBuild: 'echo post', + transform: () => undefined, }); assert.equal(warned.length, 0, `unexpected warns: ${warned.join('|')}`); }); @@ -573,6 +630,50 @@ describe('validatePkgConfig', () => { it('list with string[] OK', () => { validatePkgConfig({ publicPackages: ['a', 'b'] }); }); + + describe('hooks', () => { + it('preBuild as string OK', () => { + validatePkgConfig({ preBuild: 'echo hi' }); + }); + it('preBuild as function OK', () => { + validatePkgConfig({ preBuild: () => undefined }); + }); + it('preBuild as number throws', () => { + assert.throws( + () => validatePkgConfig({ preBuild: 42 }), + /"preBuild" must be a shell command \(string\) or a function/, + ); + }); + it('preBuild empty string throws', () => { + assert.throws( + () => validatePkgConfig({ preBuild: ' ' }), + /"preBuild" must not be an empty string/, + ); + }); + + it('postBuild as string OK', () => { + validatePkgConfig({ postBuild: './smoke.sh' }); + }); + it('postBuild as function OK', () => { + validatePkgConfig({ postBuild: () => undefined }); + }); + it('postBuild as object throws', () => { + assert.throws( + () => validatePkgConfig({ postBuild: { cmd: 'x' } }), + /"postBuild" must be a shell command \(string\) or a function/, + ); + }); + + it('transform as function OK', () => { + validatePkgConfig({ transform: () => undefined }); + }); + it('transform as string throws', () => { + assert.throws( + () => validatePkgConfig({ transform: 'minify' }), + /"transform" must be a function/, + ); + }); + }); }); describe('isConfiguration', () => { diff --git a/test/unit/hooks.test.ts b/test/unit/hooks.test.ts new file mode 100644 index 00000000..56ecd756 --- /dev/null +++ b/test/unit/hooks.test.ts @@ -0,0 +1,221 @@ +import assert from 'node:assert/strict'; +import { afterEach, beforeEach, describe, it } from 'node:test'; + +import { log } from '../../lib/log'; +import { runPostBuild, runPreBuild, runTransform } from '../../lib/hooks'; +import { STORE_BLOB, STORE_CONTENT, STORE_LINKS } from '../../lib/common'; +import type { FileRecords, PkgOptions } from '../../lib/types'; + +type LogFn = (..._a: unknown[]) => void; +let originals: { info: LogFn; warn: LogFn; error: LogFn }; + +beforeEach(() => { + originals = { + info: log.info as LogFn, + warn: log.warn as LogFn, + error: log.error as LogFn, + }; + log.info = (() => {}) as typeof log.info; + log.warn = (() => {}) as typeof log.warn; + log.error = (() => {}) as typeof log.error; +}); + +afterEach(() => { + log.info = originals.info as typeof log.info; + log.warn = originals.warn as typeof log.warn; + log.error = originals.error as typeof log.error; +}); + +describe('runPreBuild', () => { + it('no-op when not set', async () => { + await runPreBuild({} as PkgOptions); + }); + + it('invokes function form', async () => { + let called = 0; + await runPreBuild({ preBuild: () => void called++ } as PkgOptions); + assert.equal(called, 1); + }); + + it('awaits async function form', async () => { + let resolved = false; + await runPreBuild({ + preBuild: async () => { + await new Promise((r) => setTimeout(r, 5)); + resolved = true; + }, + } as PkgOptions); + assert.equal(resolved, true); + }); + + it('rethrows function-form errors', async () => { + await assert.rejects( + runPreBuild({ + preBuild: () => { + throw new Error('boom'); + }, + } as PkgOptions), + /boom/, + ); + }); + + it('shell form: success exits 0', async () => { + await runPreBuild({ preBuild: 'true' } as PkgOptions); + }); + + it('shell form: non-zero exit throws with hook name', async () => { + await assert.rejects( + runPreBuild({ preBuild: 'exit 7' } as PkgOptions), + /preBuild hook failed.*exit code 7/, + ); + }); +}); + +describe('runPostBuild', () => { + it('no-op when not set', async () => { + await runPostBuild({} as PkgOptions, '/tmp/bin'); + }); + + it('function form receives output path', async () => { + let received: string | undefined; + await runPostBuild( + { + postBuild: (out: string) => { + received = out; + }, + } as PkgOptions, + '/tmp/my-bin', + ); + assert.equal(received, '/tmp/my-bin'); + }); + + it('shell form sees PKG_OUTPUT env', async () => { + // Smoke test using a portable shell-ism that succeeds only when + // PKG_OUTPUT is set. printenv emits the value or returns 1 on miss. + await runPostBuild( + { + postBuild: + process.platform === 'win32' + ? 'if "%PKG_OUTPUT%"=="" (exit 1)' + : 'test -n "$PKG_OUTPUT"', + } as PkgOptions, + '/tmp/seen', + ); + }); +}); + +describe('runTransform', () => { + function makeRecords(): FileRecords { + return { + '/snap/a.js': { + file: '/abs/a.js', + body: Buffer.from('original'), + [STORE_BLOB]: true, + }, + '/snap/b.json': { + file: '/abs/b.json', + body: '{"k":1}', + [STORE_CONTENT]: true, + }, + // No body and no STORE_BLOB/STORE_CONTENT — should be skipped. + '/snap/dir': { + file: '/abs/dir', + [STORE_LINKS]: ['a.js'], + }, + }; + } + + it('no-op when transform not set', async () => { + const records = makeRecords(); + await runTransform({} as PkgOptions, records); + assert.equal(records['/snap/a.js'].body!.toString(), 'original'); + }); + + it('skips records without a content store', async () => { + const records = makeRecords(); + const seen: string[] = []; + await runTransform( + { + transform: (file: string) => { + seen.push(file); + }, + } as PkgOptions, + records, + ); + assert.deepEqual(seen.sort(), ['/abs/a.js', '/abs/b.json']); + }); + + it('replaces body when transform returns a string', async () => { + const records = makeRecords(); + await runTransform( + { + transform: (_file: string, contents: Buffer | string) => { + return contents.toString().toUpperCase(); + }, + } as PkgOptions, + records, + ); + assert.equal(records['/snap/a.js'].body, 'ORIGINAL'); + assert.equal(records['/snap/b.json'].body, '{"K":1}'); + }); + + it('replaces body when transform returns a Buffer', async () => { + const records = makeRecords(); + await runTransform( + { + transform: () => Buffer.from([1, 2, 3]), + } as PkgOptions, + records, + ); + const out = records['/snap/a.js'].body as Buffer; + assert.ok(Buffer.isBuffer(out)); + assert.deepEqual([...out], [1, 2, 3]); + }); + + it('keeps original body when transform returns undefined', async () => { + const records = makeRecords(); + await runTransform({ transform: () => undefined } as PkgOptions, records); + assert.equal(records['/snap/a.js'].body!.toString(), 'original'); + }); + + it('rejects non-Buffer/non-string return', async () => { + const records = makeRecords(); + await assert.rejects( + runTransform( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { transform: () => 42 as any } as PkgOptions, + records, + ), + /transform hook for "\/abs\/a\.js" returned number/, + ); + }); + + it('async transform is awaited', async () => { + const records = makeRecords(); + await runTransform( + { + transform: async (_file: string, contents: Buffer | string) => { + await new Promise((r) => setTimeout(r, 1)); + return `[${contents.toString()}]`; + }, + } as PkgOptions, + records, + ); + assert.equal(records['/snap/a.js'].body, '[original]'); + }); + + it('user errors surface with the file path', async () => { + const records = makeRecords(); + await assert.rejects( + runTransform( + { + transform: () => { + throw new Error('user died'); + }, + } as PkgOptions, + records, + ), + /transform hook threw for "\/abs\/a\.js": user died/, + ); + }); +}); From 8dc7ac2a7ec047c268274fc19a5a329e36c7c510 Mon Sep 17 00:00:00 2001 From: robertsLando Date: Mon, 27 Apr 2026 17:36:50 +0200 Subject: [PATCH 2/3] refactor(types): extract PkgBaseOptions shared by config + API shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PkgOptions and PkgExecOptions had drifted into a 12-field overlap that duplicated identical names, types, and JSDoc — most visibly across the new build hooks. Pull those shared fields into a `PkgBaseOptions` base interface and have both shapes extend it. Listy fields where typing intentionally differs (`targets`, `publicPackages`, `noDictionary` are lenient `string | string[]` in config files and strict `string[]` at the API boundary) and the `options`/`bakeOptions` rename stay on the leaf interfaces — pulling those up would need generic gymnastics for no net win. Public surface: PkgBaseOptions is exported from the package entry so downstream tooling can type-derive shared build-shaping options without enumerating fields by hand. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/index.ts | 3 +- lib/types.ts | 120 ++++++++++++++++++++++++--------------------------- 2 files changed, 59 insertions(+), 64 deletions(-) diff --git a/lib/index.ts b/lib/index.ts index b278e914..ca91d639 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -17,6 +17,7 @@ import { Target, NodeTarget, SymLinks, + PkgBaseOptions, PkgExecOptions, PkgCompressType, } from './types'; @@ -31,7 +32,7 @@ import { stringifyTarget, } from './config'; -export type { PkgExecOptions, PkgCompressType }; +export type { PkgBaseOptions, PkgExecOptions, PkgCompressType }; const { version } = JSON.parse( readFileSync(path.join(__dirname, '../package.json'), 'utf-8'), diff --git a/lib/types.ts b/lib/types.ts index cedbdb35..cc9baf83 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -78,7 +78,62 @@ export type TransformHook = ( | undefined | Promise; -export interface PkgOptions { +/** + * Build-shaping fields shared verbatim between the config-file shape + * (`PkgOptions`) and the programmatic API shape (`PkgExecOptions`): the + * boolean toggles, `compress`, `outputPath`, and the build hooks. + * + * Listy fields with deliberate typing differences (`targets`, + * `publicPackages`, `noDictionary` are lenient `string | string[]` in the + * config and strict `string[]` in the API) and per-shape-only fields + * (e.g. `options` vs `bakeOptions`) stay on the leaf interfaces — pulling + * them up here would require generic gymnastics for no net win. + */ +export interface PkgBaseOptions { + /** Directory to save the output executable(s). */ + outputPath?: string; + /** VFS compression algorithm. Default `'None'`. */ + compress?: PkgCompressType; + /** Use Node.js Single Executable Application mode. */ + sea?: boolean; + /** Verbose packaging logs. */ + debug?: boolean; + /** Compile bytecode. Default `true`. Set to `false` to ship plain JS. */ + bytecode?: boolean; + /** Build native addons. Default `true`. */ + nativeBuild?: boolean; + /** If bytecode compilation fails for a file, ship it as plain source. */ + fallbackToSource?: boolean; + /** Treat the top-level project as public (faster, discloses sources). */ + public?: boolean; + /** Sign macOS binaries when applicable. Default `true`. */ + signature?: boolean; + /** + * Shell command (string) or JS function run once before the walker + * collects files. Throw or reject to abort the build. + * + * Function form is reachable from the Node.js API and from + * `pkg.config.{js,cjs,mjs}`; JSON config files can only carry the shell + * form. + */ + preBuild?: string | PreBuildHook; + /** + * Shell command (string) or JS function run once per produced binary, + * after it has been written. Function form receives the output path; + * shell form receives it via `PKG_OUTPUT`. + */ + postBuild?: string | PostBuildHook; + /** + * Per-file content transform applied after walking and refinement, before + * bytecode and compression. Function-only — shell-string transforms are + * not supported because piping every file through a child process would + * be prohibitively slow. Receives `(filePath, contents)` and returns the + * replacement (or void to keep). + */ + transform?: TransformHook; +} + +export interface PkgOptions extends PkgBaseOptions { scripts?: string[]; log?: (logger: typeof log, context: Record) => void; assets?: string[]; @@ -93,35 +148,9 @@ export interface PkgOptions { patches?: Patches; dictionary?: ConfigDictionary; targets?: string | string[]; - outputPath?: string; - compress?: PkgCompressType; - fallbackToSource?: boolean; - public?: boolean; publicPackages?: string | string[]; options?: string | string[]; - bytecode?: boolean; - nativeBuild?: boolean; noDictionary?: string | string[]; - debug?: boolean; - signature?: boolean; - sea?: boolean; - /** - * Shell command (string) or JS function run once before the walker. - * Function form is only reachable via the Node.js API or a `pkg.config.js` - * file — JSON config files can only carry the shell form. - */ - preBuild?: string | PreBuildHook; - /** - * Shell command (string) or JS function run once per produced binary. - * Shell form receives the output path via `PKG_OUTPUT`. - */ - postBuild?: string | PostBuildHook; - /** - * Per-file content transform. Function form only — shell-string transforms - * are not supported because piping every file through a child process - * would be prohibitively slow. - */ - transform?: TransformHook; } export interface PackageJson { @@ -221,7 +250,7 @@ export interface SeaEnhancedOptions { export type SymLinks = Record; -export interface PkgExecOptions { +export interface PkgExecOptions extends PkgBaseOptions { /** Entry file or directory (required). */ input: string; /** Target specs, e.g. `['node22-linux-x64']` or `['host']`. */ @@ -230,47 +259,12 @@ export interface PkgExecOptions { config?: string; /** Output file name or template for multiple targets. */ output?: string; - /** Directory to save the output executable(s). Mutually exclusive with `output`. */ - outputPath?: string; - /** VFS compression algorithm. Default `'None'`. */ - compress?: PkgCompressType; - /** Use Node.js Single Executable Application mode. */ - sea?: boolean; /** Bake Node/V8 CLI options into the executable (e.g. `['expose-gc']`). */ bakeOptions?: string | string[]; - /** Enable verbose packaging logs. */ - debug?: boolean; /** Build base binaries from source instead of downloading prebuilt ones. */ build?: boolean; - /** Compile bytecode. Default `true`. Set to `false` to ship plain JS. */ - bytecode?: boolean; - /** Build native addons. Default `true`. */ - nativeBuild?: boolean; - /** If bytecode compilation fails for a file, ship it as plain source. */ - fallbackToSource?: boolean; - /** Treat the top-level project as public (faster, discloses sources). */ - public?: boolean; /** Package names to treat as public. `['*']` for all packages. */ publicPackages?: string[]; /** Package names to ignore dictionaries for. `['*']` to disable all. */ noDictionary?: string[]; - /** Sign macOS binaries when applicable. Default `true`. */ - signature?: boolean; - /** - * Shell command (string) or JS function run once before the walker - * collects files. Throw or reject to abort the build. - */ - preBuild?: string | PreBuildHook; - /** - * Shell command (string) or JS function run once per produced binary, - * after it has been written. Function form receives the output path; - * shell form receives it via `PKG_OUTPUT`. - */ - postBuild?: string | PostBuildHook; - /** - * Per-file content transform applied after walking and refinement, before - * bytecode and compression. Use for minify/obfuscate; receives - * `(filePath, contents)` and returns the replacement (or void to keep). - */ - transform?: TransformHook; } From f52b1a99df2d9bf0516b3d6743d83b4fc4b747a8 Mon Sep 17 00:00:00 2001 From: robertsLando Date: Mon, 27 Apr 2026 17:56:46 +0200 Subject: [PATCH 3/3] fix: address review feedback on build hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review: - docs-site/guide/api.md: tighten the type strings for preBuild/postBuild (parens around the function arms so `string | (() => ...)` parses as intended), and reflect that `transform` returns may be Promise-wrapped. - test/test-46-hooks/main.js: add `assert(!module.parent)` to match the e2e convention used by sibling tests. - test/unit/hooks.test.ts: replace `'true'` / `'exit 7'` with portable `node -e` invocations — `true` is a POSIX shell builtin and Windows cmd.exe doesn't recognize it, so the unit suite would fail on win32 CI. Self-review: - lib/config.ts: switch from `Object.assign(rawPkg, parsed.apiPkg)` to a spread, so the source `configJson.pkg` / `inputJson.pkg` objects are not mutated by API-injected hooks bleeding back into the parsed config. - lib/hooks.ts: drop the redundant `as PreBuildHook` / `PostBuildHook` / `TransformHook` casts — TypeScript already narrows the union after the `typeof === 'string'` checks. Trim `void | undefined` on the transform result to plain `void` (`void` covers `undefined` for return types). - lib/hooks.ts + lib/sea.ts: extract a `runPostBuildForTargets` helper to dedupe the per-target loop the two SEA paths shared, and to give the loop direct unit-test coverage (closes the lib/sea.ts coverage gap flagged by codecov on the previous push). - lib/sea.ts: cache `pkgOptions.get()` once in `seaEnhanced` instead of fetching it twice. - docs-site/guide/api.md: warn that `transform` receives every embedded file (including binaries / `.node` addons) — users must filter by extension before rewriting, since returning a string for binary content would corrupt it. Also document the SEA-vs-traditional postBuild timing difference (parallel-bake-then-sequential-postBuild in SEA vs. interleaved per-target in the traditional pipeline). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs-site/guide/api.md | 46 +++++++++++++++-------------- lib/config.ts | 19 +++++++----- lib/hooks.ts | 35 +++++++++++++++------- lib/sea.ts | 34 +++++++++------------- test/test-46-hooks/main.js | 1 + test/unit/hooks.test.ts | 59 ++++++++++++++++++++++++++++++++++++-- 6 files changed, 131 insertions(+), 63 deletions(-) diff --git a/docs-site/guide/api.md b/docs-site/guide/api.md index dafe1659..ae501f11 100644 --- a/docs-site/guide/api.md +++ b/docs-site/guide/api.md @@ -58,28 +58,28 @@ The strings are exactly what you'd pass on the command line — see [Getting sta ### `PkgExecOptions` fields -| Field | Type | CLI equivalent | Notes | -| ------------------ | ------------------------------------------------------------------------ | ---------------------- | --------------------------------------------------------------------------------- | -| `input` | `string` | positional `` | **Required.** Entry file or directory. | -| `targets` | `string[]` | `--targets` | e.g. `['host']` or `['node22-linux-x64', ...]`. | -| `config` | `string` | `--config` | Path to `package.json` or standalone config JSON. | -| `output` | `string` | `--output` | Output file name or template. | -| `outputPath` | `string` | `--out-path` | Output directory (mutually exclusive with `output`). | -| `compress` | `'None' \| 'Brotli' \| 'GZip' \| 'Zstd'` | `--compress` | Default `'None'`. | -| `sea` | `boolean` | `--sea` | Use Single Executable Application mode. | -| `bakeOptions` | `string \| string[]` | `--options` | Node/V8 flags baked into the binary (e.g. `['expose-gc']`). | -| `debug` | `boolean` | `--debug` | Verbose packaging logs. | -| `build` | `boolean` | `--build` | Build base binaries from source. | -| `bytecode` | `boolean` | `--no-bytecode` | Default `true`. Set `false` to ship plain JS. | -| `nativeBuild` | `boolean` | `--no-native-build` | Default `true`. | -| `fallbackToSource` | `boolean` | `--fallback-to-source` | Ship source when bytecode compile fails. | -| `public` | `boolean` | `--public` | Top-level project is public. | -| `publicPackages` | `string[]` | `--public-packages` | Use `['*']` for all. | -| `noDictionary` | `string[]` | `--no-dict` | Use `['*']` to disable all dictionaries. | -| `signature` | `boolean` | `--no-signature` | Default `true` (macOS signing when applicable). | -| `preBuild` | `string \| () => void \| Promise` | _(none — API/config)_ | Shell command or function run before the walker. See [Build hooks](#build-hooks). | -| `postBuild` | `string \| (output: string) => void \| Promise` | _(none — API/config)_ | Run once per produced binary. Shell form receives `PKG_OUTPUT` env. | -| `transform` | `(file: string, contents: Buffer \| string) => Buffer \| string \| void` | _(none — API only)_ | Per-file content transform (minify, obfuscate, etc.). | +| Field | Type | CLI equivalent | Notes | +| ------------------ | ------------------------------------------------------------------------------------------------------------- | ---------------------- | --------------------------------------------------------------------------------- | +| `input` | `string` | positional `` | **Required.** Entry file or directory. | +| `targets` | `string[]` | `--targets` | e.g. `['host']` or `['node22-linux-x64', ...]`. | +| `config` | `string` | `--config` | Path to `package.json` or standalone config JSON. | +| `output` | `string` | `--output` | Output file name or template. | +| `outputPath` | `string` | `--out-path` | Output directory (mutually exclusive with `output`). | +| `compress` | `'None' \| 'Brotli' \| 'GZip' \| 'Zstd'` | `--compress` | Default `'None'`. | +| `sea` | `boolean` | `--sea` | Use Single Executable Application mode. | +| `bakeOptions` | `string \| string[]` | `--options` | Node/V8 flags baked into the binary (e.g. `['expose-gc']`). | +| `debug` | `boolean` | `--debug` | Verbose packaging logs. | +| `build` | `boolean` | `--build` | Build base binaries from source. | +| `bytecode` | `boolean` | `--no-bytecode` | Default `true`. Set `false` to ship plain JS. | +| `nativeBuild` | `boolean` | `--no-native-build` | Default `true`. | +| `fallbackToSource` | `boolean` | `--fallback-to-source` | Ship source when bytecode compile fails. | +| `public` | `boolean` | `--public` | Top-level project is public. | +| `publicPackages` | `string[]` | `--public-packages` | Use `['*']` for all. | +| `noDictionary` | `string[]` | `--no-dict` | Use `['*']` to disable all dictionaries. | +| `signature` | `boolean` | `--no-signature` | Default `true` (macOS signing when applicable). | +| `preBuild` | `string \| (() => void \| Promise)` | _(none — API/config)_ | Shell command or function run before the walker. See [Build hooks](#build-hooks). | +| `postBuild` | `string \| ((output: string) => void \| Promise)` | _(none — API/config)_ | Run once per produced binary. Shell form receives `PKG_OUTPUT` env. | +| `transform` | `(file: string, contents: Buffer \| string) => Buffer \| string \| void \| Promise` | _(none — API only)_ | Per-file content transform (minify, obfuscate, etc.). Async returns are awaited. | ## Build a full release pipeline @@ -216,7 +216,9 @@ The transform sees the **exact** set of files `pkg` is embedding (walker output, - Shell hooks are spawned with `shell: true` and inherit stdio, so the user sees their tool's live output. Non-zero exit fails the build. - Function-form hooks are reachable from the Node.js API and from `pkg.config.{js,cjs,mjs}` (which can export a function value); JSON-format config (`package.json#pkg`, `.pkgrc`, `.pkgrc.json`) can only carry the shell-string form. +- `transform` receives **every** embedded file — JS, JSON, assets, native `.node` addons, anything the walker collected. Filter by `path.extname(file)` (or your matcher of choice) before rewriting; returning a string for binary content will corrupt it. - In simple SEA mode (`--sea` without a `package.json`), `transform` is a no-op — there's no walker output to apply per-file rewrites to. `preBuild` and `postBuild` still run. +- In enhanced SEA mode, all targets are baked in parallel and `postBuild` only fires once **all** binaries are baked (then runs sequentially per target). The traditional pipeline produces targets serially, so `postBuild` for each target fires before the next one starts. Both modes call `postBuild` exactly once per produced binary; only the relative timing differs. ## See also diff --git a/lib/config.ts b/lib/config.ts index ab18ba7f..748efaa8 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -1057,15 +1057,20 @@ export async function resolveConfig( inputJson, ); - const rawPkg = configJson?.pkg ?? inputJson?.pkg ?? {}; - if (typeof rawPkg !== 'object' || rawPkg === null || Array.isArray(rawPkg)) { + const sourcePkg = configJson?.pkg ?? inputJson?.pkg ?? {}; + if ( + typeof sourcePkg !== 'object' || + sourcePkg === null || + Array.isArray(sourcePkg) + ) { throw wasReported('pkg config: "pkg" must be an object'); } - // Programmatic-API hook fields layered on top of any config-file hooks. - // Defined last so they win — the API call site is the most explicit. - if (parsed.apiPkg) { - Object.assign(rawPkg, parsed.apiPkg); - } + // Spread (not Object.assign) so configJson/inputJson stay untouched — + // they're returned to the caller and other readers shouldn't observe + // API-injected hooks bleeding back into the source `pkg` field. + // Programmatic-API hook fields are layered on top of any config-file + // hooks: the API call site is the most explicit. + const rawPkg = { ...sourcePkg, ...(parsed.apiPkg ?? {}) }; validatePkgConfig(rawPkg); const flags = resolveFlags(parsed.flags, rawPkg as PkgOptions); const pkg = applyResolvedFlags(rawPkg as PkgOptions, flags); diff --git a/lib/hooks.ts b/lib/hooks.ts index 2e5c5158..d1fee8ea 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -3,13 +3,7 @@ import { readFile } from 'fs/promises'; import { log, wasReported } from './log'; import { STORE_BLOB, STORE_CONTENT } from './common'; -import type { - FileRecords, - PkgOptions, - PostBuildHook, - PreBuildHook, - TransformHook, -} from './types'; +import type { FileRecords, PkgOptions } from './types'; /** * Run a shell command synchronously to-completion. stdio is inherited so the @@ -57,7 +51,7 @@ export async function runPreBuild(pkg: PkgOptions): Promise { await runShell(hook, {}, 'preBuild'); return; } - await (hook as PreBuildHook)(); + await hook(); } /** @@ -78,7 +72,26 @@ export async function runPostBuild( await runShell(hook, { PKG_OUTPUT: output }, 'postBuild'); return; } - await (hook as PostBuildHook)(output); + await hook(output); +} + +/** + * Run `postBuild` once per produced binary, sequentially. Sequential + * ordering keeps stdout/stderr from overlapping targets cleanly separated + * on the user's terminal. Used by both SEA paths after baking; the + * traditional pipeline calls `runPostBuild` directly inside its own + * per-target loop because each binary is produced sequentially anyway. + */ +export async function runPostBuildForTargets( + pkg: PkgOptions, + targets: ReadonlyArray<{ output?: string }>, +): Promise { + if (pkg.postBuild === undefined) return; + for (const target of targets) { + if (target.output) { + await runPostBuild(pkg, target.output); + } + } } /** @@ -119,9 +132,9 @@ export async function runTransform( } } - let result: string | Buffer | void | undefined; + let result: string | Buffer | void; try { - result = await (fn as TransformHook)(record.file, body); + result = await fn(record.file, body); } catch (err) { const reason = err instanceof Error ? err.message : String(err); throw wasReported(`transform hook threw for "${record.file}": ${reason}`); diff --git a/lib/sea.ts b/lib/sea.ts index 2e203b65..ed60fd2f 100644 --- a/lib/sea.ts +++ b/lib/sea.ts @@ -33,7 +33,7 @@ import { patchMachOExecutable, signMachOExecutable } from './mach-o'; import walk from './walker'; import refine from './refiner'; import { generateSeaAssets } from './sea-assets'; -import { runPostBuild, runTransform } from './hooks'; +import { runPostBuildForTargets, runTransform } from './hooks'; import pkgOptions from './options'; import { inject as postjectInject } from 'postject'; import { system } from '@yao-pkg/pkg-fetch'; @@ -799,9 +799,13 @@ export async function seaEnhanced( symLinks, } = refine(walkResult.records, walkResult.entrypoint, walkResult.symLinks); + // Resolved pkg config — captured once and reused for both transform + // (before asset generation) and postBuild (after baking, below). + const pkg = pkgOptions.get(); + // Apply user transform hook before assets are generated, so any // minification/obfuscation flows into the SEA archive bytes. - await runTransform(pkgOptions.get(), records); + await runTransform(pkg, records); // Resolve target outputs to absolute paths before chdir to tmpDir for (const target of opts.targets) { @@ -885,16 +889,13 @@ export async function seaEnhanced( }); // postBuild runs once per produced binary, after baking + signing have - // completed. Sequential so hook stdout/stderr lines from different - // targets don't interleave on the user's terminal. - const pkg = pkgOptions.get(); - if (pkg.postBuild !== undefined) { - for (const target of opts.targets) { - if (target.output) { - await runPostBuild(pkg, target.output); - } - } - } + // completed. Note: SEA bakes targets in parallel (Promise.all above), + // so postBuild for the first target only fires once *all* binaries are + // baked — this differs from the traditional pipeline, where targets + // are produced sequentially and postBuild fires before the next target + // even starts. The user-visible contract ("postBuild runs per produced + // binary") holds in both modes. + await runPostBuildForTargets(pkg, opts.targets); } /** Create NodeJS executable using sea */ @@ -947,12 +948,5 @@ export default async function sea(entryPoint: string, opts: SeaOptions) { // Simple SEA mode supports postBuild but not transform — there's no // walker output to apply per-file rewrites to. preBuild already ran in // index.ts before this function was called. - const pkg = pkgOptions.get(); - if (pkg.postBuild !== undefined) { - for (const target of opts.targets) { - if (target.output) { - await runPostBuild(pkg, target.output); - } - } - } + await runPostBuildForTargets(pkgOptions.get(), opts.targets); } diff --git a/test/test-46-hooks/main.js b/test/test-46-hooks/main.js index aef9a9ca..e867a88e 100644 --- a/test/test-46-hooks/main.js +++ b/test/test-46-hooks/main.js @@ -8,6 +8,7 @@ const fs = require('fs'); const path = require('path'); const utils = require('../utils.js'); +assert(!module.parent); assert(__dirname === process.cwd()); // Hooks are most thoroughly exercised through the programmatic Node.js API diff --git a/test/unit/hooks.test.ts b/test/unit/hooks.test.ts index 56ecd756..5d112119 100644 --- a/test/unit/hooks.test.ts +++ b/test/unit/hooks.test.ts @@ -2,7 +2,12 @@ import assert from 'node:assert/strict'; import { afterEach, beforeEach, describe, it } from 'node:test'; import { log } from '../../lib/log'; -import { runPostBuild, runPreBuild, runTransform } from '../../lib/hooks'; +import { + runPostBuild, + runPostBuildForTargets, + runPreBuild, + runTransform, +} from '../../lib/hooks'; import { STORE_BLOB, STORE_CONTENT, STORE_LINKS } from '../../lib/common'; import type { FileRecords, PkgOptions } from '../../lib/types'; @@ -60,12 +65,16 @@ describe('runPreBuild', () => { }); it('shell form: success exits 0', async () => { - await runPreBuild({ preBuild: 'true' } as PkgOptions); + // `node -e ""` is portable across cmd.exe and POSIX shells; `true` is + // a Unix shell builtin and isn't recognized by Windows cmd.exe. + await runPreBuild({ preBuild: 'node -e ""' } as PkgOptions); }); it('shell form: non-zero exit throws with hook name', async () => { await assert.rejects( - runPreBuild({ preBuild: 'exit 7' } as PkgOptions), + runPreBuild({ + preBuild: 'node -e "process.exit(7)"', + } as PkgOptions), /preBuild hook failed.*exit code 7/, ); }); @@ -104,6 +113,50 @@ describe('runPostBuild', () => { }); }); +describe('runPostBuildForTargets', () => { + it('no-op when postBuild not set (does not iterate targets)', async () => { + const targets = [{ output: '/tmp/a' }, { output: '/tmp/b' }]; + // Throws if the helper iterates anyway: postBuild is undefined, so the + // function-form path inside runPostBuild would crash. Reaching the end + // proves the early-return short-circuit fires. + await runPostBuildForTargets({} as PkgOptions, targets); + }); + + it('runs sequentially in target order (no overlap)', async () => { + const events: string[] = []; + const targets = [{ output: '/tmp/a' }, { output: '/tmp/b' }]; + await runPostBuildForTargets( + { + postBuild: async (out: string) => { + events.push(`start:${out}`); + await new Promise((r) => setTimeout(r, 5)); + events.push(`end:${out}`); + }, + } as PkgOptions, + targets, + ); + assert.deepEqual(events, [ + 'start:/tmp/a', + 'end:/tmp/a', + 'start:/tmp/b', + 'end:/tmp/b', + ]); + }); + + it('skips targets without an output path', async () => { + const seen: (string | undefined)[] = []; + await runPostBuildForTargets( + { + postBuild: (out: string) => { + seen.push(out); + }, + } as PkgOptions, + [{ output: '/tmp/a' }, {}, { output: '/tmp/b' }], + ); + assert.deepEqual(seen, ['/tmp/a', '/tmp/b']); + }); +}); + describe('runTransform', () => { function makeRecords(): FileRecords { return {