forked from vercel/pkg
-
-
Notifications
You must be signed in to change notification settings - Fork 66
Expand file tree
/
Copy pathsea.ts
More file actions
1128 lines (1010 loc) · 38.9 KB
/
Copy pathsea.ts
File metadata and controls
1128 lines (1010 loc) · 38.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { execFile as cExecFile } from 'child_process';
import util from 'util';
import { basename, dirname, join, resolve } from 'path';
import {
copyFile,
writeFile,
rm,
mkdir,
mkdtemp,
stat,
readFile,
open,
} from 'fs/promises';
import { createWriteStream } from 'fs';
import { pipeline } from 'stream/promises';
import { ReadableStream } from 'stream/web';
import { createHash } from 'crypto';
import { homedir, tmpdir } from 'os';
import unzipper from 'unzipper';
import { extract as tarExtract } from 'tar';
import { log, wasReported } from './log';
import {
NodeTarget,
Target,
SeaEnhancedOptions,
NodeVersion,
NodeRange,
NodeOs,
NodeArch,
NODE_OSES,
NODE_ARCHS,
} from './types';
import { patchMachOExecutable, signMachOExecutable } from './mach-o';
import walk from './walker';
import refine from './refiner';
import { generateSeaAssets } from './sea-assets';
import { inject as postjectInject } from 'postject';
import { system } from '@yao-pkg/pkg-fetch';
const { hostPlatform, hostArch } = system;
const execFileAsync = util.promisify(cExecFile);
/**
* The SEA fuse sentinel that postject uses to activate the binary.
*
* Built by concatenation so the literal never appears as a single string
* in compiled output. When pkg's own code is walked into a SEA archive
* (e.g. user lists @yao-pkg/pkg in dependencies), a verbatim sentinel
* would end up inside the injected blob, causing postject to find
* duplicate occurrences and fail with "Multiple occurences of sentinel".
*/
// prettier-ignore
const SEA_SENTINEL_FUSE =
'NODE_SEA' + '_FUSE_fce680ab2cc467b6e072b8b5df1996b2';
/** Returns stat of path when exits, false otherwise */
const exists = async (path: string) => {
try {
return await stat(path);
} catch {
return false;
}
};
/**
* Benign LIEF messages printed by postject during SEA blob injection.
*
* LIEF re-parses the Node binary after postject expands the section
* table to make room for `NODE_SEA_BLOB`, and the pre-existing
* build-id / `.note.*` section-name offsets no longer resolve cleanly
* through `.shstrtab` — so LIEF falls back to synthetic names like
* `.note.100` and warns. On Mach-O the analogous "signature seems
* corrupted" line is also cosmetic: we re-sign the binary with
* `codesign` after injection (see signMachOExecutable).
*
* The warnings have no bearing on correctness of the produced
* executable but users reasonably assume something is wrong, so we
* filter them out of stderr just for the duration of the inject call.
*/
const BENIGN_POSTJECT_STDERR =
/^warning: (?:The signature seems corrupted!|Can't find string offset for section name '\.note)/;
type StderrWrite = typeof process.stderr.write;
type WriteCallback = (err?: Error | null) => void;
/**
* Run `fn` with `process.stderr.write` wrapped to drop known-benign
* postject/LIEF messages. Anything that doesn't match the allow-list
* pattern passes through unchanged, so real errors are never hidden.
* The original `write` is restored in a `finally` block regardless of
* whether `fn` resolves or rejects.
*/
async function withFilteredPostjectStderr<T>(fn: () => Promise<T>): Promise<T> {
const original: StderrWrite = process.stderr.write;
const bound: StderrWrite = original.bind(process.stderr);
const filtered = ((
chunk: string | Uint8Array,
encodingOrCb?: BufferEncoding | WriteCallback,
cb?: WriteCallback,
): boolean => {
const text =
typeof chunk === 'string'
? chunk
: Buffer.isBuffer(chunk)
? chunk.toString('utf8')
: Buffer.from(chunk).toString('utf8');
if (BENIGN_POSTJECT_STDERR.test(text)) {
const callback = typeof encodingOrCb === 'function' ? encodingOrCb : cb;
if (callback) process.nextTick(callback);
return true;
}
// The write() overloads accept either an encoding or a callback in
// slot 2; disambiguate here so the correct overload is dispatched.
return typeof encodingOrCb === 'function'
? bound(chunk, encodingOrCb)
: bound(chunk, encodingOrCb, cb);
}) as StderrWrite;
process.stderr.write = filtered;
try {
return await fn();
} finally {
process.stderr.write = original;
}
}
export type GetNodejsExecutableOptions = {
useLocalNode?: boolean;
nodePath?: string;
};
export type SeaConfig = {
disableExperimentalSEAWarning: boolean;
useSnapshot: boolean; // must be set to false when cross-compiling
useCodeCache: boolean; // must be set to false when cross-compiling
// TODO: add support for assets: https://nodejs.org/api/single-executable-applications.html#single_executable_applications_assets
assets?: Record<string, string>;
};
export type SeaOptions = {
seaConfig?: SeaConfig;
signature?: boolean;
targets: (NodeTarget & Partial<Target>)[];
} & GetNodejsExecutableOptions;
const defaultSeaConfig: SeaConfig = {
disableExperimentalSEAWarning: true,
useSnapshot: false,
useCodeCache: false,
};
/** Download a file from a given URL and save it to `filePath` */
async function downloadFile(url: string, filePath: string): Promise<void> {
const response = await fetch(url);
if (!response.ok || !response.body) {
throw new Error(`Failed to download file from ${url}`);
}
const fileStream = createWriteStream(filePath);
return pipeline(response.body as unknown as ReadableStream, fileStream);
}
/** Extract node executable from the archive */
async function extract(os: NodeOs, archivePath: string): Promise<string> {
const nodeDir = basename(archivePath, os === 'win' ? '.zip' : '.tar.gz');
const archiveDir = dirname(archivePath);
const nodePath =
os === 'win'
? join(archiveDir, `${nodeDir}.exe`)
: join(archiveDir, nodeDir, 'bin', 'node');
// Skip extraction when a sentinel marks the previous extract as complete.
// Both tar and unzipper write to the final path directly, so a crash
// mid-extract would otherwise leave a truncated binary that silently
// poisons future runs. The sentinel is only written after extraction
// succeeds, so its absence forces a re-extract. We also require the
// binary itself to be present — if a user or cleanup tool removed the
// extracted binary, returning a stale `nodePath` would surface as a
// confusing ENOENT at bake() time.
const sentinel = `${nodePath}.ok`;
if ((await exists(sentinel)) && (await exists(nodePath))) {
return nodePath;
}
// Clear any partial output or stale sentinel from a previously
// interrupted extract or out-of-band cache cleanup.
await rm(nodePath, { force: true });
await rm(sentinel, { force: true });
if (os === 'win') {
const { files } = await unzipper.Open.file(archivePath);
const nodeBinPath = `${nodeDir}/node.exe`;
const nodeBin = files.find((file) => file.path === nodeBinPath);
if (!nodeBin) {
throw new Error('Node executable not found in the archive');
}
await pipeline(nodeBin.stream(), createWriteStream(nodePath));
} else {
await tarExtract({
file: archivePath,
cwd: archiveDir,
filter: (path) => path === `${nodeDir}/bin/node`,
});
}
if (!(await exists(nodePath))) {
throw new Error('Node executable not found in the archive');
}
await writeFile(sentinel, '');
return nodePath;
}
/** Verify the checksum of downloaded NodeJS archive */
async function verifyChecksum(
filePath: string,
checksumUrl: string,
fileName: string,
): Promise<void> {
const response = await fetch(checksumUrl);
if (!response.ok) {
throw new Error(`Failed to download checksum file from ${checksumUrl}`);
}
const checksums = await response.text();
const expectedChecksum = checksums
.split('\n')
.find((line) => line.includes(fileName))
?.split(' ')[0];
if (!expectedChecksum) {
throw new Error(`Checksum for ${fileName} not found`);
}
const fileBuffer = await readFile(filePath);
const hashSum = createHash('sha256');
hashSum.update(fileBuffer);
const actualChecksum = hashSum.digest('hex');
if (actualChecksum !== expectedChecksum) {
throw new Error(`Checksum verification failed for ${fileName}`);
}
}
/** Get the node os based on target platform */
function getNodeOs(platform: string): NodeOs {
const platformsMap: Record<string, string> = {
macos: 'darwin',
};
const validatedPlatform = platformsMap[platform] || platform;
if (!(NODE_OSES as readonly string[]).includes(validatedPlatform)) {
throw new Error(`Unsupported OS: ${platform}`);
}
return validatedPlatform as NodeOs;
}
/** Get the node arch based on target arch */
function getNodeArch(arch: string): NodeArch {
if (!(NODE_ARCHS as readonly string[]).includes(arch)) {
throw new Error(`Unsupported architecture: ${arch}`);
}
return arch as NodeArch;
}
/**
* Get latest Node.js version covering a partial range. Accepts `22`,
* `22.22`, or `22.22.2`; returns the canonical v-prefixed triple the
* rest of the file expects.
*/
async function getNodeVersion(
os: NodeOs,
arch: NodeArch,
nodeVersion: string,
): Promise<NodeVersion> {
// validate nodeVersion using regex. Allowed formats: 16, 16.0, 16.0.0
const regex = /^\d{1,2}(\.\d{1,2}){0,2}$/;
if (!regex.test(nodeVersion)) {
throw new Error('Invalid node version format');
}
const parts = nodeVersion.split('.');
if (parts.length > 3) {
throw new Error('Invalid node version format');
}
if (parts.length === 3) {
return `v${nodeVersion}` as NodeVersion;
}
let url;
switch (arch) {
case 'riscv64':
case 'loong64':
url = 'https://unofficial-builds.nodejs.org/download/release/index.json';
break;
default:
url = 'https://nodejs.org/dist/index.json';
break;
}
const response = await fetch(url);
if (!response.ok) {
throw new Error('Failed to fetch node versions');
}
const versions = (await response.json()) as {
version: string;
files: string[];
}[];
const nodeOS = os === 'darwin' ? 'osx' : os;
const latest = versions.find(
(v) =>
v.version.startsWith(`v${nodeVersion}`) &&
v.files.some((f) => f.startsWith(`${nodeOS}-${arch}`)),
);
if (!latest) {
throw new Error(`Node version ${nodeVersion} not found`);
}
return latest.version as NodeVersion;
}
/**
* The custom base Node binary to embed for SEA, or `undefined` to download one.
*
* Precedence matches standard mode: an explicit `opts.nodePath` (from the
* `--sea-node-path` CLI flag or the `seaNodePath` pkg-config key) wins over the
* `PKG_NODE_PATH` environment variable. `PKG_NODE_PATH` is the same env var
* pkg-fetch honours for the traditional build path (`localPlace()`); folding it
* in here makes it work for SEA too, instead of a separate SEA-only mechanism.
*/
function resolveCustomBaseNode(
opts: GetNodejsExecutableOptions,
): string | undefined {
if (opts.nodePath) return opts.nodePath;
if (process.env.PKG_NODE_PATH) return resolve(process.env.PKG_NODE_PATH);
return undefined;
}
/** Executable container format sniffed from a binary's magic bytes. */
type BinaryFormat = 'elf' | 'macho' | 'pe';
const ELF_MACHINE: Record<number, NodeArch> = {
0x3e: 'x64', // EM_X86_64
0xb7: 'arm64', // EM_AARCH64
};
const MACHO_CPU: Record<number, NodeArch> = {
0x01000007: 'x64', // CPU_TYPE_X86_64
0x0100000c: 'arm64', // CPU_TYPE_ARM64
};
const PE_MACHINE: Record<number, NodeArch> = {
0x8664: 'x64', // IMAGE_FILE_MACHINE_AMD64
0xaa64: 'arm64', // IMAGE_FILE_MACHINE_ARM64
};
/**
* Expected container format for a pkg target platform (the raw suffix string).
* ELF covers the whole Linux/Alpine family (linux / alpine / linuxstatic /
* freebsd) — the glibc/musl/static flavor isn't in the header.
*/
const formatForPlatform = (platform: string): BinaryFormat =>
platform === 'macos' || platform === 'darwin'
? 'macho'
: platform === 'win'
? 'pe'
: 'elf';
const FORMAT_LABEL: Record<BinaryFormat, string> = {
elf: 'ELF (Linux/Alpine)',
macho: 'Mach-O (macOS)',
pe: 'PE (Windows)',
};
/**
* Sniff a binary's container format and CPU arch from its magic bytes, so we can
* tell whether a supplied base Node binary actually matches the requested
* target. Reads only the header. Returns `{}` for an unrecognised file (caller
* skips the format/arch checks rather than guessing). Note: ELF can't reveal the
* glibc/musl/static *flavor*, so `elf` maps to the whole Linux/Alpine family.
*/
export async function sniffBinaryTarget(
file: string,
): Promise<{ format?: BinaryFormat; arch?: NodeArch }> {
let fh;
try {
fh = await open(file, 'r');
} catch {
return {};
}
try {
const buf = Buffer.alloc(4096);
const { bytesRead } = await fh.read(buf, 0, 4096, 0);
const b = buf.subarray(0, bytesRead);
if (b.length < 20) return {};
// ELF: 0x7f 'E' 'L' 'F'; EI_DATA at [5] (1=LE,2=BE); e_machine at [18..20].
if (b[0] === 0x7f && b[1] === 0x45 && b[2] === 0x4c && b[3] === 0x46) {
const machine = b[5] === 2 ? b.readUInt16BE(18) : b.readUInt16LE(18);
return { format: 'elf', arch: ELF_MACHINE[machine] };
}
// Mach-O (thin): magic FEEDFACE/FEEDFACF; byte-swapped CEFAEDFE/CFFAEDFE are
// the little-endian on-disk forms. cputype is the next 4 bytes.
const be = b.readUInt32BE(0);
if (
be === 0xfeedface ||
be === 0xfeedfacf ||
be === 0xcefaedfe ||
be === 0xcffaedfe
) {
const le = be === 0xcefaedfe || be === 0xcffaedfe;
const cpu = le ? b.readUInt32LE(4) : b.readUInt32BE(4);
return { format: 'macho', arch: MACHO_CPU[cpu] };
}
// PE: 'MZ', e_lfanew (uint32 @0x3C) -> 'PE\0\0', COFF machine 2 bytes after.
if (b[0] === 0x4d && b[1] === 0x5a && b.length >= 0x40) {
const lfanew = b.readUInt32LE(0x3c);
if (
lfanew + 6 <= b.length &&
b[lfanew] === 0x50 &&
b[lfanew + 1] === 0x45 &&
b[lfanew + 2] === 0 &&
b[lfanew + 3] === 0
) {
return { format: 'pe', arch: PE_MACHINE[b.readUInt16LE(lfanew + 4)] };
}
return { format: 'pe' };
}
return {};
} finally {
await fh.close();
}
}
/**
* Guard a custom base Node binary against multi-target / wrong-platform /
* version-skew footguns.
*
* A supplied binary (via `--sea-node-path` / `seaNodePath` / `PKG_NODE_PATH`, or
* `useLocalNode`) is returned by {@link getNodejsExecutable} for *every* target,
* so a multi-target run would bake that one binary into outputs for other
* platforms/arches — silently producing broken artifacts. We:
*
* 1. Reject if the requested targets span more than one distinct
* `platform`+`arch` — one binary can't be several mutually-exclusive things
* at once (incl. linux vs alpine vs linuxstatic, which we can't tell apart
* from the binary but the user clearly can't have meant simultaneously).
* 2. Sniff the binary and reject a format/arch mismatch against that single
* target (e.g. a macOS binary for a `linux` target, or x64 for `arm64`).
* The glibc/musl/static flavor isn't in the header, so that sub-distinction
* stays the user's responsibility.
* 3. Verify the binary's major matches the requested `nodeRange`
* (`assertSingleTargetMajor` only compares the targets to each other).
*/
async function assertCustomBaseNodeTarget(
targets: (NodeTarget & Partial<Target>)[],
opts: GetNodejsExecutableOptions,
): Promise<void> {
const customNode = resolveCustomBaseNode(opts);
if (!customNode && !opts.useLocalNode) return;
const binPath = customNode ?? process.execPath;
// 1. A single binary maps to exactly one platform+arch. Key on the raw target
// suffix strings so linux / alpine / linuxstatic stay distinct (they collapse
// under getNodeOs, but they're mutually exclusive runtimes).
const combos = new Map<string, { platform: string; arch: string }>();
for (const t of targets) {
const platform = String(t.platform);
const arch = String(t.arch);
combos.set(`${platform}-${arch}`, { platform, arch });
}
if (combos.size > 1) {
throw wasReported(
`A custom base Node binary applies to a single platform/arch, but the ` +
`requested targets span ${combos.size}: ${[...combos.keys()].join(', ')}. ` +
`One binary can't be all of them — run pkg once per target with a ` +
`matching binary.`,
);
}
const { platform, arch } = [...combos.values()][0];
// 2. Format / arch match against that single target.
const sniff = await sniffBinaryTarget(binPath);
if (sniff.format) {
const expected = formatForPlatform(platform);
if (sniff.format !== expected) {
throw wasReported(
`Custom base Node binary is ${FORMAT_LABEL[sniff.format]}, but target ` +
`"${platform}" needs ${FORMAT_LABEL[expected]}.`,
);
}
if (sniff.arch && sniff.arch !== arch) {
throw wasReported(
`Custom base Node binary is ${sniff.arch}, but target arch is "${arch}". ` +
`The binary must match the target's architecture.`,
);
}
}
// 3. Major version match.
const targetMajor = parseInt(targets[0].nodeRange.replace('node', ''), 10);
if (Number.isNaN(targetMajor)) return; // 'latest' / unparseable: nothing to compare
const version =
binPath === process.execPath
? process.version
: (await execFileAsync(binPath, ['--version'])).stdout.trim();
const binMajor = parseInt(version.replace(/^v/, ''), 10);
if (binMajor !== targetMajor) {
throw wasReported(
`Custom base Node binary is ${version} (major ${binMajor}), but target ` +
`"${targets[0].nodeRange}" requests Node ${targetMajor}. The binary's ` +
`major version must match the target.`,
);
}
}
/**
* Resolve the concrete Node.js version (e.g. `v22.22.2`) pkg will use
* for `target` — mirrors the version selection done inside
* {@link getNodejsExecutable} without performing the download, so
* callers can reason about host/target version skew independently of
* the download itself.
*/
async function resolveTargetNodeVersion(
target: NodeTarget,
opts: GetNodejsExecutableOptions,
): Promise<NodeVersion> {
if (opts.useLocalNode) return process.version as NodeVersion;
const customNode = resolveCustomBaseNode(opts);
if (customNode) {
// A user-supplied binary can be any version — don't assume it
// matches the host. Ask it directly.
const { stdout } = await execFileAsync(customNode, ['--version']);
return stdout.trim() as NodeVersion;
}
const os = getNodeOs(target.platform);
const arch = getNodeArch(target.arch);
return getNodeVersion(os, arch, target.nodeRange.replace('node', ''));
}
/** Fetch, validate and extract nodejs binary. Returns a path to it */
async function getNodejsExecutable(
target: NodeTarget,
opts: GetNodejsExecutableOptions,
): Promise<string> {
const customNode = resolveCustomBaseNode(opts);
if (customNode) {
// check if the custom base binary exists
if (!(await exists(customNode))) {
throw new Error(
`Provided node executable path "${customNode}" does not exist`,
);
}
return customNode;
}
if (opts.useLocalNode) {
return process.execPath;
}
const os = getNodeOs(target.platform);
const arch = getNodeArch(target.arch);
const nodeVersion = await resolveTargetNodeVersion(target, opts);
const fileName = `node-${nodeVersion}-${os}-${arch}.${os === 'win' ? 'zip' : 'tar.gz'}`;
let url;
let checksumUrl;
switch (arch) {
case 'riscv64':
case 'loong64':
url = `https://unofficial-builds.nodejs.org/download/release/${nodeVersion}/${fileName}`;
checksumUrl = `https://unofficial-builds.nodejs.org/download/release/${nodeVersion}/SHASUMS256.txt`;
break;
default:
url = `https://nodejs.org/dist/${nodeVersion}/${fileName}`;
checksumUrl = `https://nodejs.org/dist/${nodeVersion}/SHASUMS256.txt`;
break;
}
const downloadDir = join(homedir(), '.pkg-cache', 'sea');
// Ensure the download directory exists
if (!(await exists(downloadDir))) {
await mkdir(downloadDir, { recursive: true });
}
const filePath = join(downloadDir, fileName);
const archiveSentinel = `${filePath}.ok`;
// Skip download + checksum only when a sentinel marks the previous run as
// verified. downloadFile writes straight to filePath without tmp+rename,
// so an interrupted download would otherwise leave a partial archive that
// later skips checksum verification and fails cryptically at extract time.
if (!((await exists(archiveSentinel)) && (await exists(filePath)))) {
// Clear any partial download or stale sentinel.
await rm(filePath, { force: true });
await rm(archiveSentinel, { force: true });
log.info(`Downloading nodejs executable from ${url}...`);
await downloadFile(url, filePath);
log.info(`Verifying checksum of ${fileName}`);
await verifyChecksum(filePath, checksumUrl, fileName);
await writeFile(archiveSentinel, '');
}
log.info(`Extracting node binary from ${fileName}`);
const nodePath = await extract(os, filePath);
return nodePath;
}
/** Bake the blob into the executable */
async function bake(
nodePath: string,
target: NodeTarget & Partial<Target>,
blobData: Buffer,
): Promise<void> {
const outPath = resolve(process.cwd(), target.output as string);
log.info(
`Creating executable for ${target.nodeRange}-${target.platform}-${target.arch}....`,
);
if (!(await exists(dirname(outPath)))) {
await mkdir(dirname(outPath), { recursive: true });
}
// check if executable_path exists
if (await exists(outPath)) {
log.warn(`Executable ${outPath} already exists, will be overwritten`);
}
// copy the executable as the output executable
await copyFile(nodePath, outPath);
log.info(`Injecting the blob into ${outPath}...`);
// No pre-strip of the downloaded node binary's signature on macOS:
// the final `codesign -f --sign -` in signMacOSIfNeeded force-replaces
// any existing signature after postject injection, so a preliminary
// `codesign --remove-signature` is redundant.
// Use postject JS API directly instead of spawning npx.
// This avoids two CI issues:
// 1. "Text file busy" race condition from concurrent npx invocations
// 2. "Argument is not a constructor" from npx downloading incompatible versions
await withFilteredPostjectStderr(() =>
postjectInject(outPath, 'NODE_SEA_BLOB', blobData, {
sentinelFuse: SEA_SENTINEL_FUSE,
machoSegmentName: target.platform === 'macos' ? 'NODE_SEA' : undefined,
overwrite: true,
}),
);
}
/**
* Patch mach-O __LINKEDIT (non-SEA only) and ad-hoc sign the binary.
*
* The __LINKEDIT patch exists for the classic pkg flow: pkg appends the
* VFS payload to the end of the binary, and codesign only hashes content
* covered by __LINKEDIT — so the segment must be extended to include the
* payload before signing.
*
* Pass `isSea: true` to skip the patch. For SEA binaries postject
* already creates a dedicated NODE_SEA `LC_SEGMENT_64` (per the
* [Node.js SEA docs](https://nodejs.org/api/single-executable-applications.html))
* and __LINKEDIT already sits at the file tail with
* `filesize = file.length - fileoff`, so the patch is a no-op on the
* resulting Mach-O. The docs call for just `codesign --sign -` after
* postject, which is what `signMachOExecutable` does.
*/
export async function signMacOSIfNeeded(
output: string,
target: NodeTarget & Partial<Target>,
signature?: boolean,
isSea?: boolean,
): Promise<void> {
if (!signature || target.platform !== 'macos') return;
if (!isSea) {
const buf = patchMachOExecutable(await readFile(output));
await writeFile(output, buf);
}
try {
signMachOExecutable(output);
} catch {
if (target.arch === 'arm64') {
log.warn('Unable to sign the macOS executable', [
'Due to the mandatory code signing requirement, before the',
'executable is distributed to end users, it must be signed.',
'Otherwise, it will be immediately killed by kernel on launch.',
'An ad-hoc signature is sufficient.',
'To do that, run pkg on a Mac, or transfer the executable to a Mac',
'and run "codesign --sign - <executable>", or (if you use Linux)',
'install "ldid" utility to PATH and then run pkg again',
]);
}
}
}
/** Run a callback inside a temporary directory, cleaning up afterwards */
async function withSeaTmpDir<T>(
fn: (tmpDir: string) => Promise<T>,
): Promise<T> {
const tmpDir = await mkdtemp(join(tmpdir(), 'pkg-sea-'));
const previousDirectory = process.cwd();
try {
process.chdir(tmpDir);
return await fn(tmpDir);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const wrapped = new Error(
`Error while creating the executable: ${message}`,
{ cause: error },
);
// Preserve the original stack if available
if (error instanceof Error && error.stack) {
wrapped.stack = `${wrapped.message}\n [cause]: ${error.stack}`;
}
throw wrapped;
} finally {
process.chdir(previousDirectory);
await rm(tmpDir, { recursive: true }).catch(() => {
log.warn(`Failed to cleanup the temp directory ${tmpDir}`);
});
}
}
/**
* Validate that the host Node.js version running pkg supports SEA.
* Although node:sea is stable from Node 20, pkg requires 22+ to align with
* engines.node and the @roberts_lando/vfs dependency.
*
* Host-only check — target Node majors are validated via
* {@link resolveMinTargetMajor}.
*/
function assertHostSeaNodeVersion(): number {
const nodeMajor = parseInt(process.version.slice(1).split('.')[0], 10);
if (nodeMajor < 22) {
throw new Error(
`SEA support requires at least node v22.0.0, actual node version is ${process.version}`,
);
}
return nodeMajor;
}
/**
* Resolve the smallest target Node.js major version across a target list.
* Unparseable ranges (e.g. "latest") fall back to the host major so pkg on
* Node 25 treats "latest" as 25.
*/
function resolveMinTargetMajor(
targets: (NodeTarget & Partial<Target>)[],
): number {
const hostMajor = parseInt(process.version.slice(1), 10);
if (targets.length === 0) return hostMajor;
return Math.min(
...targets.map((t) => {
const v = parseInt(t.nodeRange.replace('node', ''), 10);
return Number.isNaN(v) ? hostMajor : v;
}),
);
}
/**
* SEA prep blobs are Node-major specific (e.g. Node 25.8 added an
* exec_argv_extension header field), so a single blob cannot be safely
* baked into binaries of different Node majors. Reject mixed-major target
* lists up front instead of silently producing broken executables.
*/
function assertSingleTargetMajor(
targets: (NodeTarget & Partial<Target>)[],
): void {
const hostMajor = parseInt(process.version.slice(1), 10);
const majors = new Set(
targets.map((t) => {
const v = parseInt(t.nodeRange.replace('node', ''), 10);
return Number.isNaN(v) ? hostMajor : v;
}),
);
if (majors.size > 1) {
throw wasReported(
`SEA mode cannot mix Node.js majors in a single run ` +
`(got ${[...majors].sort((a, b) => a - b).join(', ')}). ` +
`Run pkg once per Node major.`,
);
}
}
/**
* Index into `targets` of the first entry whose platform+arch match
* `host`, or -1 when no target is runnable on the host. Exported for
* unit testing step 1 of the SEA blob-generator selection without
* spinning up a full pkg invocation.
*/
export function pickMatchingHostTargetIndex(
host: { platform: string; arch: string },
targets: readonly { platform: string; arch: string }[],
): number {
return targets.findIndex(
(t) => t.platform === host.platform && t.arch === host.arch,
);
}
/**
* Pick the node binary used to generate the SEA prep blob.
*
* The blob layout is node-version specific — not just major-version
* specific. Node occasionally changes the SEA header layout within a
* single major line (Node 22.19/22.20 added fields that break the 22.22
* reader, Node 25.8 added `exec_argv_extension`, etc.), so using a host
* Node whose patch release differs from the downloaded target binary
* crashes `node::sea::FindSingleExecutableResource` at startup with
* `EXC_BAD_ACCESS` inside `BlobDeserializer::ReadArithmetic` — see
* discussion #236.
*
* Strategy (all paths guarantee the generator is the same version as the
* reader, eliminating patch-version skew):
*
* 1. Prefer a downloaded target binary whose platform & arch match the
* host — already downloaded, guaranteed version-matched.
* 2. Otherwise (pure cross-platform build, e.g. Linux host producing
* only a macos-arm64 binary), download a host-platform/arch node
* binary at the same node range as the targets and use it purely
* as the generator.
* 3. If the host-platform download fails (unsupported host such as
* alpine/musl, offline, checksum mismatch, …), fall back to
* `process.execPath` only when its version exactly matches the
* resolved target version. Otherwise hard-fail — silently running
* the generator with a skewed node would reintroduce the same
* EXC_BAD_ACCESS this function exists to prevent.
*
* All targets share a single node major (enforced by
* {@link assertSingleTargetMajor}).
*/
async function pickBlobGeneratorBinary(
targets: (NodeTarget & Partial<Target>)[],
nodePaths: string[],
opts: GetNodejsExecutableOptions,
): Promise<string> {
const matchIdx = pickMatchingHostTargetIndex(
{ platform: hostPlatform, arch: hostArch },
targets,
);
if (matchIdx !== -1) {
log.debug(
`SEA blob generator: host matches ${targets[matchIdx].platform}-${targets[matchIdx].arch} target, reusing its downloaded binary (${nodePaths[matchIdx]}).`,
);
return nodePaths[matchIdx];
}
// No target is runnable on the host. Resolve the target's concrete
// patch version first, then pin a host-platform download to that exact
// version so the blob generator and the SEA reader baked into each
// target share the same patch level — otherwise we regress into the
// discussion #236 crash on any host/target patch skew. Resolving
// against target's platform/arch (not host's) is what pins the
// version: host and target could otherwise land on different latest
// patches (unofficial builds, arch-specific availability).
const targetVersion = await resolveTargetNodeVersion(targets[0], opts);
if (targetVersion === process.version) {
// Host already runs the exact target version; no download needed.
return process.execPath;
}
log.info(
`No target matches host ${hostPlatform}-${hostArch}; downloading a ` +
`host-platform node ${targetVersion} to generate the SEA blob ` +
`(avoids SEA header version skew — see discussion #236).`,
);
try {
// nodeRange must be `node<bare>` so
// getNodejsExecutable → getNodeVersion's `replace('node','')` + regex
// sees a clean `22.22.2` (v-prefix would fail the validator).
// `hostPlatform` from pkg-fetch is wider than NodeTarget.platform
// (e.g. 'alpine', 'linuxstatic'); getNodejsExecutable only reads
// platform/arch to route the download, so the assertion is safe.
const nodeRange: NodeRange = `node${targetVersion.slice(1)}`;
const hostGeneratorTarget = {
platform: hostPlatform,
arch: hostArch,
nodeRange,
} as NodeTarget;
// Drop user-supplied nodePath / useLocalNode: they'd short-circuit
// the download in getNodejsExecutable and reintroduce version skew.
const downloadOpts: GetNodejsExecutableOptions = {
...opts,
nodePath: undefined,
useLocalNode: false,
};
return await getNodejsExecutable(hostGeneratorTarget, downloadOpts);
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
throw wasReported(
`Cannot generate SEA blob: host node ${process.version} differs ` +
`from target ${targetVersion} and the host-platform download ` +
`failed (${reason}). Running the generator with a skewed node ` +
`would crash the final binary at startup with EXC_BAD_ACCESS in ` +
`node::sea::FindSingleExecutableResource (see discussion #236). ` +
`Install node ${targetVersion} locally (e.g. via nvm) or pass ` +
`nodePath pointing to a host-runnable node binary of that version.`,
);
}
}
/**
* Generate the SEA prep blob from a sea-config.json file.
*
* Uses --experimental-sea-config (not --build-sea): --build-sea produces
* a finished executable and bypasses the prep-blob + postject flow that
* pkg relies on for multi-target support and for injecting custom
* bootstraps into downloaded node binaries.
*/
async function generateSeaBlob(
seaConfigFilePath: string,
generatorBinary: string,
): Promise<void> {
log.info('Generating the blob...');
await execFileAsync(generatorBinary, [
'--experimental-sea-config',
seaConfigFilePath,
]);
}
/** Create NodeJS executable using the enhanced SEA pipeline (walker + refiner + assets) */
export async function seaEnhanced(
entryPoint: string,
opts: SeaEnhancedOptions,
) {
assertHostSeaNodeVersion();
// useSnapshot is incompatible with the enhanced VFS bootstrap: SEA's
// snapshot mode runs the main script at build time inside a V8 startup
// snapshot context and expects the runtime entry to be registered via
// v8.startupSnapshot.setDeserializeMainFunction(). Our bootstrap doesn't
// do that, and at build time `sea.getRawAsset('__pkg_archive__')` does
// not exist yet (we're running plain Node to generate the blob, not
// inside a SEA binary), so snapshot construction would fail outright.
//
// useCodeCache, on the other hand, only caches V8 bytecode for the
// bootstrap script — it speeds up bootstrap parsing without affecting
// the runtime VFS path. It is forwarded to sea-config below.
if (opts.seaConfig?.useSnapshot === true) {
throw wasReported(
'Enhanced SEA mode does not support useSnapshot. ' +
'Remove it from seaConfig, or use simple --sea without a package.json.',
);
}
assertSingleTargetMajor(opts.targets);
await assertCustomBaseNodeTarget(opts.targets, opts);
const minTargetMajor = resolveMinTargetMajor(opts.targets);
if (minTargetMajor < 22) {
throw wasReported(
`Enhanced SEA mode requires Node >= 22 targets. ` +
`Minimum target version resolved to Node ${minTargetMajor}.`,
);
}
entryPoint = resolve(process.cwd(), entryPoint);
if (!(await exists(entryPoint))) {
throw new Error(`Entrypoint path "${entryPoint}" does not exist`);
}
const { marker, params = {} } = opts;
// Run walker in SEA mode
log.info('Walking dependencies...');
const walkResult = await walk(marker, entryPoint, opts.addition, {
...params,
seaMode: true,
});
// Refine (path compression, empty dir pruning)
log.info('Refining file records...');
const {
records,
entrypoint: refinedEntry,
symLinks,
} = refine(walkResult.records, walkResult.entrypoint, walkResult.symLinks);
// Resolve target outputs to absolute paths before chdir to tmpDir
for (const target of opts.targets) {