Skip to content

Commit 3667383

Browse files
robertsLandoclaude
andauthored
feat: build hooks (preBuild, postBuild, per-file transform) (#273)
* feat: build hooks (preBuild, postBuild, transform) 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) <noreply@anthropic.com> * refactor(types): extract PkgBaseOptions shared by config + API shapes 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) <noreply@anthropic.com> * fix: address review feedback on build hooks 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) <noreply@anthropic.com> * refactor(config): extract shared hook validator; test transform disk path - Deduplicate the preBuild/postBuild shell-or-fn validation shared by parseOptionsInput and validatePkgConfig into validateShellOrFnHook, so the non-empty-string rule lives in one place and can't drift. - Add unit coverage for runTransform's disk-read fallback (record.body undefined -> readFile, then cache) and its read-failure error path, which underpin the eager-load correctness rationale. * test(hooks): add enhanced-SEA e2e coverage for the transform hook Closes #287. The transform build hook ran through the traditional pipeline e2e (test-46-hooks) but had no end-to-end coverage in the SEA pipeline, even though seaEnhanced() calls runTransform() before generating the SEA archive. Add test-88-sea-hooks: it drives the programmatic API (transform is function-only, not reachable from the CLI) with `sea: true` and a package.json input, builds the host SEA binary, and asserts the mutated marker is printed by the produced executable — proving the transform flows into the SEA archive bytes. Also fix an api.md inconsistency: `transform` is reachable from pkg.config.{js,cjs,mjs}, not "API only" (it's just function-only, so unavailable from JSON config) — matching configuration.md and the Build hooks note already in api.md. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3520d26 commit 3667383

16 files changed

Lines changed: 1126 additions & 81 deletions

File tree

docs-site/guide/api.md

Lines changed: 113 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -58,25 +58,28 @@ The strings are exactly what you'd pass on the command line — see [Getting sta
5858

5959
### `PkgExecOptions` fields
6060

61-
| Field | Type | CLI equivalent | Notes |
62-
| ------------------ | ---------------------------------------- | ---------------------- | ----------------------------------------------------------- |
63-
| `input` | `string` | positional `<input>` | **Required.** Entry file or directory. |
64-
| `targets` | `string[]` | `--targets` | e.g. `['host']` or `['node22-linux-x64', ...]`. |
65-
| `config` | `string` | `--config` | Path to `package.json` or standalone config JSON. |
66-
| `output` | `string` | `--output` | Output file name or template. |
67-
| `outputPath` | `string` | `--out-path` | Output directory (mutually exclusive with `output`). |
68-
| `compress` | `'None' \| 'Brotli' \| 'GZip' \| 'Zstd'` | `--compress` | Default `'None'`. |
69-
| `sea` | `boolean` | `--sea` | Use Single Executable Application mode. |
70-
| `bakeOptions` | `string \| string[]` | `--options` | Node/V8 flags baked into the binary (e.g. `['expose-gc']`). |
71-
| `debug` | `boolean` | `--debug` | Verbose packaging logs. |
72-
| `build` | `boolean` | `--build` | Build base binaries from source. |
73-
| `bytecode` | `boolean` | `--no-bytecode` | Default `true`. Set `false` to ship plain JS. |
74-
| `nativeBuild` | `boolean` | `--no-native-build` | Default `true`. |
75-
| `fallbackToSource` | `boolean` | `--fallback-to-source` | Ship source when bytecode compile fails. |
76-
| `public` | `boolean` | `--public` | Top-level project is public. |
77-
| `publicPackages` | `string[]` | `--public-packages` | Use `['*']` for all. |
78-
| `noDictionary` | `string[]` | `--no-dict` | Use `['*']` to disable all dictionaries. |
79-
| `signature` | `boolean` | `--no-signature` | Default `true` (macOS signing when applicable). |
61+
| Field | Type | CLI equivalent | Notes |
62+
| ------------------ | ------------------------------------------------------------------------------------------------------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
63+
| `input` | `string` | positional `<input>` | **Required.** Entry file or directory. |
64+
| `targets` | `string[]` | `--targets` | e.g. `['host']` or `['node22-linux-x64', ...]`. |
65+
| `config` | `string` | `--config` | Path to `package.json` or standalone config JSON. |
66+
| `output` | `string` | `--output` | Output file name or template. |
67+
| `outputPath` | `string` | `--out-path` | Output directory (mutually exclusive with `output`). |
68+
| `compress` | `'None' \| 'Brotli' \| 'GZip' \| 'Zstd'` | `--compress` | Default `'None'`. |
69+
| `sea` | `boolean` | `--sea` | Use Single Executable Application mode. |
70+
| `bakeOptions` | `string \| string[]` | `--options` | Node/V8 flags baked into the binary (e.g. `['expose-gc']`). |
71+
| `debug` | `boolean` | `--debug` | Verbose packaging logs. |
72+
| `build` | `boolean` | `--build` | Build base binaries from source. |
73+
| `bytecode` | `boolean` | `--no-bytecode` | Default `true`. Set `false` to ship plain JS. |
74+
| `nativeBuild` | `boolean` | `--no-native-build` | Default `true`. |
75+
| `fallbackToSource` | `boolean` | `--fallback-to-source` | Ship source when bytecode compile fails. |
76+
| `public` | `boolean` | `--public` | Top-level project is public. |
77+
| `publicPackages` | `string[]` | `--public-packages` | Use `['*']` for all. |
78+
| `noDictionary` | `string[]` | `--no-dict` | Use `['*']` to disable all dictionaries. |
79+
| `signature` | `boolean` | `--no-signature` | Default `true` (macOS signing when applicable). |
80+
| `preBuild` | `string \| (() => void \| Promise<void>)` | _(none — API/config)_ | Shell command or function run before the walker. See [Build hooks](#build-hooks). |
81+
| `postBuild` | `string \| ((output: string) => void \| Promise<void>)` | _(none — API/config)_ | Run once per produced binary. Shell form receives `PKG_OUTPUT` env. |
82+
| `transform` | `(file: string, contents: Buffer \| string) => Buffer \| string \| void \| Promise<Buffer \| string \| void>` | _(none — API/JS config)_ | Per-file content transform (minify, obfuscate, etc.). Async returns are awaited. Function-only, so not available from JSON config. |
8083

8184
## Build a full release pipeline
8285

@@ -126,6 +129,97 @@ try {
126129
}
127130
```
128131

132+
## Build hooks
133+
134+
`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.
135+
136+
### Lifecycle order
137+
138+
```
139+
preBuild → walk → transform (per file) → bytecode/compression → write → postBuild (per binary)
140+
```
141+
142+
### `preBuild`
143+
144+
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.
145+
146+
::: code-group
147+
148+
```js [Function]
149+
await exec({
150+
input: 'src/index.js',
151+
preBuild: async () => {
152+
await build({ entryPoints: ['src/index.js'], outfile: 'dist/bundle.js' });
153+
},
154+
});
155+
```
156+
157+
```json [package.json#pkg]
158+
{
159+
"pkg": {
160+
"preBuild": "esbuild src/index.js --bundle --outfile=dist/bundle.js"
161+
}
162+
}
163+
```
164+
165+
:::
166+
167+
### `postBuild`
168+
169+
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.
170+
171+
::: code-group
172+
173+
```js [Function]
174+
await exec({
175+
input: 'src/index.js',
176+
postBuild: async (output) => {
177+
await execFileAsync(output, ['--version']);
178+
},
179+
});
180+
```
181+
182+
```json [package.json#pkg]
183+
{
184+
"pkg": {
185+
"postBuild": "\"$PKG_OUTPUT\" --version"
186+
}
187+
}
188+
```
189+
190+
:::
191+
192+
### `transform`
193+
194+
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.
195+
196+
`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:
197+
198+
```js
199+
import { exec } from '@yao-pkg/pkg';
200+
import { minify } from 'terser';
201+
202+
await exec({
203+
input: 'src/index.js',
204+
output: 'dist/app',
205+
transform: async (file, contents) => {
206+
if (!file.endsWith('.js')) return; // leave non-JS untouched
207+
const { code } = await minify(contents.toString());
208+
return code;
209+
},
210+
});
211+
```
212+
213+
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.
214+
215+
### Notes
216+
217+
- 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.
218+
- 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.
219+
- `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.
220+
- 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.
221+
- 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.
222+
129223
## See also
130224

131225
- [CLI options](/guide/options)

0 commit comments

Comments
 (0)