From ce842ef345d1337b6a63c2e46e1857ac9c2a0eb5 Mon Sep 17 00:00:00 2001 From: jdalton Date: Tue, 10 Nov 2026 20:51:43 -0500 Subject: [PATCH] Compress native prebuilds with verified bounded decoding --- .github/workflows/ci.yml | 3 + docs/compressed-prebuilds.md | 84 ++ docs/compression-benchmark.json | 1099 +++++++++++++++++++++ package.json | 5 +- publish.yml | 2 + scripts/benchmark-compressed-prebuilds.js | 91 ++ scripts/chart-compressed-prebuilds.js | 42 + scripts/compress-prebuilds.js | 56 ++ scripts/native-target.js | 21 + src/compressedNative.ts | 217 ++++ src/compression/brotli.ts | 7 + src/compression/zstd.ts | 17 + src/utils.ts | 16 + test/compressed-prebuilds.js | 186 ++++ test/pty-smoke.js | 23 + 15 files changed, 1868 insertions(+), 1 deletion(-) create mode 100644 docs/compressed-prebuilds.md create mode 100644 docs/compression-benchmark.json create mode 100644 scripts/benchmark-compressed-prebuilds.js create mode 100644 scripts/chart-compressed-prebuilds.js create mode 100644 scripts/compress-prebuilds.js create mode 100644 scripts/native-target.js create mode 100644 src/compressedNative.ts create mode 100644 src/compression/brotli.ts create mode 100644 src/compression/zstd.ts create mode 100644 test/compressed-prebuilds.js create mode 100644 test/pty-smoke.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d8625ae4..46e6a25e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,3 +83,6 @@ jobs: - name: Test run: npm test + + - name: Test compressed prebuilds and real PTY loading + run: npm run test:compressed diff --git a/docs/compressed-prebuilds.md b/docs/compressed-prebuilds.md new file mode 100644 index 000000000..541e14b70 --- /dev/null +++ b/docs/compressed-prebuilds.md @@ -0,0 +1,84 @@ +# Compressed native prebuilds + +This proposal reduces the complete package by **2.51% unpacked** and **0.78% +downloaded**, with additional loading time. It compresses only `.node` addons. +Windows DLLs, executables, and debug symbols remain unchanged and account for +**95.1% of unpacked bytes**. It does not remove other platforms' files. + +The comparison uses the same `node-pty@1.2.0-beta.18` release before and after this +proposal. The 61.80% addon saving +applies to just 4.16% of the package. The download is already gzip-compressed, +so the 675,323-byte unpacked reduction saves only 55,517 download bytes. + +| Measure | Published | Proposed | +| --- | ---: | ---: | +| Complete unpacked package | 26,907,504 B | 26,232,181 B | +| npm download | 7,152,900 B | 7,097,383 B | +| `.node` files only | 1,118,952 B | 427,483 B | +| Fresh-process load, cache available | 2.56 ms | 5.67 ms | +| Fresh-process load, new cache | 2.56 ms | 46.55 ms | + +The decoded cache adds **137,384 B** on the measured macOS ARM64 host. Including +that cache, package-file bytes fall by **2.00%**, not 2.51%. These are logical file +sizes, not allocated disk blocks, dependency-tree sizes, or package-manager stores. +Compression provides no measured startup speedup. + +
+Measurement and reproduction + +The rerun uses a freshly downloaded registry archive verified against npm's +SHA-512 integrity value. Repacking the baseline reproduces that archive exactly. +Both package variants use the same npm version and packing options. The runner +checks that unrelated packed files retain identical sizes and hashes. + +Timings are medians of 21 fresh processes per mode on macOS ARM64 with +Node 26.12.0. Raw ordering alternates; each first load uses a new decoded cache. +Every process also spawns a real PTY. Timings exclude Node startup and PTY spawning. +OS/file caches are not flushed. Windows and Linux startup are not measured here. + +After extracting the published archive: + +```sh +npm run build +npm run benchmark:compressed -- /path/to/package > docs/compression-benchmark.json +node scripts/chart-compressed-prebuilds.js /tmp/node-pty-compression.svg +npm run test:compressed +``` + +For registry verification, pass a second argument pointing to a JSON file with +`integrity`, `downloadBytes`, and `unpackedBytes` from the verified download. +The benchmark fails if the repacked baseline differs. The recorded +[report](compression-benchmark.json) includes provenance, file inventories, +codec inputs, cache files, and every timing sample. +Upload the generated chart as a GitHub attachment; do not commit image files. + +
+ +
+Implementation and runtime requirements + +- macOS uses Brotli quality 11; Linux and Windows use Zstd level 19. This is the + chosen size/decode-time tradeoff, not a requirement of those operating systems. +- Build and sign native files first, then run `npm run compress:prebuilds` before + packing. Every payload must shrink and round-trip exactly. +- The loader checks its cache first. On a miss, it verifies compressed bytes, + loads only the selected built-in codec, and verifies decoded size and SHA-512. + Decoding is bounded to 64 MiB per file. Zstd needs Node 22.15+; packing uses 24+. +- Private cache directories are published atomically. macOS helpers and Windows + runtime companions retain their relative paths. Package contents stay read-only. +- Each fresh process verifies cached runtime files. Repeated loads in the same + process reuse the loaded addon. Raw source builds load no decoder. +- The default cache is `~/.node-pty/v1-`. `NODE_PTY_CACHE` selects + an absolute private directory with an existing parent. Unsafe or corrupt entries + fail with removal instructions. Close users of an entry before deleting it. + Old entries are not automatically evicted. +- Hashes check integrity against the installed manifest, not publisher identity. + The OS still checks native architecture and signatures. Use the public JS API, + not internal `.node` paths. + +The verified-decoding design draws on [SWC #12741](https://github.com/swc-project/swc/issues/12741) +and [Aube #1930](https://github.com/aubepkg/aube/pull/1930). Filesystem compression +and [platform-specific distribution](https://github.com/microsoft/node-pty/pull/1016) +are separate changes; their savings are not included here. + +
diff --git a/docs/compression-benchmark.json b/docs/compression-benchmark.json new file mode 100644 index 000000000..b6f3ad0dd --- /dev/null +++ b/docs/compression-benchmark.json @@ -0,0 +1,1099 @@ +{ + "measuredAt": "2026-11-11T01:32:08.639Z", + "node": "v26.12.0", + "npm": "12.2.0", + "platform": "darwin", + "arch": "arm64", + "packageVersion": "1.2.0-beta.18", + "method": "Twenty-one fresh processes per mode; alternating raw ordering, unique first-load cache each round; no page-cache eviction. Each process spawns and verifies a real PTY.", + "provenance": { + "version": "1.2.0-beta.18", + "url": "https://registry.npmjs.org/node-pty/-/node-pty-1.2.0-beta.18.tgz", + "integrity": "sha512-3Rej3788OnasAwoSmTZhlCeUraJ9CQldtL5b571b9+3Nv47+PaxA5VPIGOOs+16OYQePTrC1Lrs8a9/df5WPRg==", + "downloadBytes": 7152900, + "unpackedBytes": 26907504 + }, + "inputs": [ + { + "target": "darwin-arm64", + "codec": "brotli", + "file": "pty.node", + "rawBytes": 86904, + "compressedBytes": 16011, + "sha512": "dfd581efe87c8c30d13d5c1823e725ff5be7ad6a31114a7fcf61d4306eabce9b2af575e25575f25c8b88606b8328d730390c424de1037328cb3812991240b33d" + }, + { + "target": "darwin-x64", + "codec": "brotli", + "file": "pty.node", + "rawBytes": 58656, + "compressedBytes": 17189, + "sha512": "b4cac612e0a6eb244df53811578311c370822fcbcdacb020fb9b0d209db31179aba234a1f82df5b8d16fec02f1187bc3d3051c1644e5be4a88d318a273b56520" + }, + { + "target": "linux-arm64", + "codec": "zstd", + "file": "pty.node", + "rawBytes": 69096, + "compressedBytes": 21258, + "sha512": "eb2a7069c05730c2b9db887b51dc0d81d55ff2f65de1b7bf674f7919c9fd47e69c3b0913f8eed37e3917c7b5afc7b4022da02aa137a76951815b9615299cd2bb" + }, + { + "target": "linux-x64", + "codec": "zstd", + "file": "pty.node", + "rawBytes": 71784, + "compressedBytes": 23070, + "sha512": "1cd8e6c3167d227f95b8031afd4efac96d2038c22dc5586542804e3e98735429e6676c13b366a52ad24dc623544cac9d92d092551d756e84b501298c5ba9ef0b" + }, + { + "target": "win32-arm64", + "codec": "zstd", + "file": "conpty.node", + "rawBytes": 282112, + "compressedBytes": 111111, + "sha512": "4982ca2d28c3a094171bc77edf347888366b36fced39416d7f0f9a0cd64d80b74e6334bae1f67d1c106bdfc5745ce75d3c2fbbc5a39ae2a38db78203babd25cd" + }, + { + "target": "win32-arm64", + "codec": "zstd", + "file": "conpty_console_list.node", + "rawBytes": 122368, + "compressedBytes": 48280, + "sha512": "014187bb1ebdd2ecabf723b91cc4b6e85e203b171804208482b004f715e14e261aa6c89780b97e9032c2826a2d5f65b82fa908fe06543e73ba5faaacad95243d" + }, + { + "target": "win32-x64", + "codec": "zstd", + "file": "conpty.node", + "rawBytes": 293376, + "compressedBytes": 129790, + "sha512": "82f108cc7849f856e49f9d28539db1010514d629aaeead2e0b196709b0e680ef596e9fa6860092a05a417d3cd2c8bbd22f8d21b780dfd12ca409fbad17479a9c" + }, + { + "target": "win32-x64", + "codec": "zstd", + "file": "conpty_console_list.node", + "rawBytes": 134656, + "compressedBytes": 60774, + "sha512": "e52577a1a779988c267084c1c1193b9bb19794ea314bbbc6a308bd3dd7f39c8c146062fa66016f1364a7bf32a2caa6b3d7962c09b2ad173752a6c9d1b777878e" + } + ], + "sizes": { + "baseline": { + "tarballBytes": 7152900, + "unpackedBytes": 26907504, + "integrity": "sha512-3Rej3788OnasAwoSmTZhlCeUraJ9CQldtL5b571b9+3Nv47+PaxA5VPIGOOs+16OYQePTrC1Lrs8a9/df5WPRg==", + "files": [ + { + "path": "LICENSE", + "bytes": 3326, + "sha512": "a202772b8e040488993bf8f9000b4e032a6f8173ce84e1d1a100fabe4377f0465da3b581276f1c8990349d31364234acd07b0fd21949a9890e28ceb42777b1db" + }, + { + "path": "README.md", + "bytes": 8104, + "sha512": "14b242a700bc18ddaa5b335cc94c0e0770b57954cc9c34817241ffe15e7affe4cb4f9edff8e22257c87838820398943e7b31794b652edf68573fac5c09fa2e2b" + }, + { + "path": "binding.gyp", + "bytes": 4657, + "sha512": "b903a90a9a6729905cad06eadebc83e9610578b60c2333848e00cf785bd7b2a4cf7be06e6eba8cc3742d8194c88fab1355ebb2d74b517b31db8882cf21068332" + }, + { + "path": "lib/conpty_console_list_agent.js", + "bytes": 932, + "sha512": "a289bd4bf3fad76382bd947888b560a3d92e76c5b0b04b4875c547e15336cd99d72a480f6fe9e1dda3125406514b4b3308e9ddd898b458b58ab55d45dd965af4" + }, + { + "path": "lib/eventEmitter2.js", + "bytes": 1600, + "sha512": "93529d7d25bffddd1cffbe8060b310470b8dba533aeb3c6edb5ed56eaabe9ed749f29e444f935a1255c4becfb892f49aa0433067d16c6352195ef31b305be904" + }, + { + "path": "lib/index.js", + "bytes": 1942, + "sha512": "253a06102583d11ebd9984175dee78ae508d19ee103fc356e5864a531c133499f5b43eb1206bba8067c4d79edeb2debd6bbf85833492c0ab502b007831c2bdd7" + }, + { + "path": "lib/interfaces.js", + "bytes": 233, + "sha512": "013fba949e8a748cb2ece477c0365fab50dca324e29c433ec3d0e828474d1ad9bc434a6b8286a56e20e6dcdbbc2bd5a3b6fe3631773942db792adb66c8c0f192" + }, + { + "path": "lib/shared/conout.js", + "bytes": 322, + "sha512": "fb37161105a8a12ac19d91f1cb3bdfaf6b17bfd6396ea4bbfba31a9e2d792aba2e5fd35c40403ea9589f99e79e286d870779337a209a1926b4a23ca0bdd47e4b" + }, + { + "path": "lib/terminal.js", + "bytes": 7499, + "sha512": "2ff010886f813af1e0a0ba692ffa1cd5103c5a9db614791526714759ae5d206240e1f6d10a74d88bec1c562133e77e09c540ca28588ed0ccfad8da1c9df95310" + }, + { + "path": "lib/types.js", + "bytes": 228, + "sha512": "bf04a06ce72a5a9462543b43e1265fb37f15fb489a6fafc85e90897f123b40b85bbfbfb739c8af2d2464a6b2dea88bcfcc7eb1b559cef7b3b0a5a80aa6c881fb" + }, + { + "path": "lib/unixTerminal.js", + "bytes": 14039, + "sha512": "197220eb4cc81ad27939b50169ea2fd80fcf91cba485d65390f13fcfaa2d65f9fc8a2ab564026098b60726b58282e162aefb51b6c6093ac9b1796d701331111a" + }, + { + "path": "lib/utils.js", + "bytes": 1548, + "sha512": "391b8f80bbba03266cb152b35b9731bcaed8851b14a62f26cb6dc88c249dde8207b35e3dd0e4526dd98445dbfe1e0ab9b1f473126cc18455f37fad0fb14efe04" + }, + { + "path": "lib/windowsConoutConnection.js", + "bytes": 6443, + "sha512": "e6f594d35db18b6c93c2736a4a747b9822ca7a7b510c3404582618d5929674667f04bfe437ae2dcb5e71c570b0b613469a1fb0b6faa58fa8428d04cd68813b5f" + }, + { + "path": "lib/windowsPtyAgent.js", + "bytes": 14480, + "sha512": "8dd1a77f0f5a00411d829f955152d3d760fb4a201eb5c0eddd5328c9693903debcba74aa4530dc1a49edb45972ccf69be8c814998e868b2d94ee85bc4b3e0027" + }, + { + "path": "lib/windowsTerminal.js", + "bytes": 8716, + "sha512": "bed18fb8ce843b41df6b91a8c985c0fd65e0a845d9f050ce0d07f83400d27b0070f84c4d36fd4a6e210ed5100e3d15f498f2bf06ebca8737a2d267598d043338" + }, + { + "path": "lib/worker/conoutSocketWorker.js", + "bytes": 878, + "sha512": "c9a75fdaff3092c82f1b422a1c3423cc6625b6f4e735db61390b0ca518a033a55f4bd1cdba6ccf0ec3dd763d8d149305a37e9cce654052920225d48e01f96935" + }, + { + "path": "package.json", + "bytes": 1895, + "sha512": "8b2e3ca823481d6eb5e9b62696d9d56fd7bd02b88742cb82c66f2d3f8650f2eb1576ecdff62777c321e1edc84758340581149fa5cb32b15e7c8b686a90b35827" + }, + { + "path": "prebuilds/darwin-arm64/pty.node", + "bytes": 86904, + "sha512": "dfd581efe87c8c30d13d5c1823e725ff5be7ad6a31114a7fcf61d4306eabce9b2af575e25575f25c8b88606b8328d730390c424de1037328cb3812991240b33d" + }, + { + "path": "prebuilds/darwin-arm64/spawn-helper", + "bytes": 50480, + "sha512": "7e716b9fae1a793e9be5ab831040c29f8f43277849ad0399beab98ebeab91c6ff413730d2ba4b743e2ab459cfbb611c2e6bbe5f101242c6955b95bd217bec1df" + }, + { + "path": "prebuilds/darwin-x64/pty.node", + "bytes": 58656, + "sha512": "b4cac612e0a6eb244df53811578311c370822fcbcdacb020fb9b0d209db31179aba234a1f82df5b8d16fec02f1187bc3d3051c1644e5be4a88d318a273b56520" + }, + { + "path": "prebuilds/darwin-x64/spawn-helper", + "bytes": 9248, + "sha512": "12f15bf72dd685c4acc04f3748925011a7b41b9440da779f712a4b05bf2523d3da36656a3c999c32ea9247df16d8401c5385f44e61b09fbe09aec80eb511dabf" + }, + { + "path": "prebuilds/linux-arm64/pty.node", + "bytes": 69096, + "sha512": "eb2a7069c05730c2b9db887b51dc0d81d55ff2f65de1b7bf674f7919c9fd47e69c3b0913f8eed37e3917c7b5afc7b4022da02aa137a76951815b9615299cd2bb" + }, + { + "path": "prebuilds/linux-x64/pty.node", + "bytes": 71784, + "sha512": "1cd8e6c3167d227f95b8031afd4efac96d2038c22dc5586542804e3e98735429e6676c13b366a52ad24dc623544cac9d92d092551d756e84b501298c5ba9ef0b" + }, + { + "path": "prebuilds/win32-arm64/conpty_console_list.node", + "bytes": 122368, + "sha512": "014187bb1ebdd2ecabf723b91cc4b6e85e203b171804208482b004f715e14e261aa6c89780b97e9032c2826a2d5f65b82fa908fe06543e73ba5faaacad95243d" + }, + { + "path": "prebuilds/win32-arm64/conpty_console_list.pdb", + "bytes": 4239360, + "sha512": "f9922548b373f517a70102a44b70e690eb6d972e06d096b830b1d9eee020e72c41ac51335c20a536563973ed2087821781e3a27b5f7ec8ac4154e55244f214e7" + }, + { + "path": "prebuilds/win32-arm64/conpty.node", + "bytes": 282112, + "sha512": "4982ca2d28c3a094171bc77edf347888366b36fced39416d7f0f9a0cd64d80b74e6334bae1f67d1c106bdfc5745ce75d3c2fbbc5a39ae2a38db78203babd25cd" + }, + { + "path": "prebuilds/win32-arm64/conpty.pdb", + "bytes": 5935104, + "sha512": "84c402069897bed97b7755b0e12b7ce169c155274a7d1e7b464bc83578c42102e65fd6763de56df17699fa42ec07b90b0739ae97ca4feee132a98641d83f9be0" + }, + { + "path": "prebuilds/win32-arm64/conpty/conpty.dll", + "bytes": 106528, + "sha512": "14b9b6f55cdb9f968b818fffd1c3700e2b300846c8998468784d0be58f085802b02f8074972e39a712fa5605bbe7c99fd2913cd8800eab19791fbb825daae1b5" + }, + { + "path": "prebuilds/win32-arm64/conpty/OpenConsole.exe", + "bytes": 1110560, + "sha512": "78e3569756b9029b45bd870b0558ecd1e5b184d5d41de00b748e45928961ba29859a0dc5db468a138c68dd3960e3c008291159bf0fedc556edca9efd6d6d14fa" + }, + { + "path": "prebuilds/win32-x64/conpty_console_list.node", + "bytes": 134656, + "sha512": "e52577a1a779988c267084c1c1193b9bb19794ea314bbbc6a308bd3dd7f39c8c146062fa66016f1364a7bf32a2caa6b3d7962c09b2ad173752a6c9d1b777878e" + }, + { + "path": "prebuilds/win32-x64/conpty_console_list.pdb", + "bytes": 4321280, + "sha512": "a8c384826bb45acb5bb1f0a8d5c8d6f15598a4d198d5a6158ceb291fd8299c704aff9158a63636b57ffe240862a5fb55517c01c5d41166edd5bd83f608acfe23" + }, + { + "path": "prebuilds/win32-x64/conpty.node", + "bytes": 293376, + "sha512": "82f108cc7849f856e49f9d28539db1010514d629aaeead2e0b196709b0e680ef596e9fa6860092a05a417d3cd2c8bbd22f8d21b780dfd12ca409fbad17479a9c" + }, + { + "path": "prebuilds/win32-x64/conpty.pdb", + "bytes": 6311936, + "sha512": "01cc0a417a7c0f8882b3cc100edfa6ac705b0dd37a702d71feaf94273a69942c4d5f39187a3aa57e63f2aa08cc2c915d7a31ea4d22c739c42d95ac2ce614a46b" + }, + { + "path": "prebuilds/win32-x64/conpty/conpty.dll", + "bytes": 110152, + "sha512": "f5c36909dca9140ad2135fcf9a7455ad83c3831ef2a428bc3a9545da565891fcc9ff4b9c0660e6f4b15fec122b5b85126868c8739084e54fc27204a754eecaaa" + }, + { + "path": "prebuilds/win32-x64/conpty/OpenConsole.exe", + "bytes": 1062472, + "sha512": "b49451640ed205ce50a5af49628ceaa9815e05f4a635e8404bd5cadb4f70f87a88d4be863731f8533aef979b9bcbac783748bc87c0ea0fb523f28cb020584226" + }, + { + "path": "scripts/post-install.js", + "bytes": 2401, + "sha512": "68a113f65379e402c5987a2570135eaa953c7e5250abf756ae10fba39322f5502195f81c55b16cebd4112db3530fded359d91f1f89225075ca7e443738430da0" + }, + { + "path": "scripts/prebuild.js", + "bytes": 1172, + "sha512": "e4a9cd2c44b5db12846b7829d69a1bebc45ea8b3621b6eb0086a6af9f58d5397898c6f1ba9e0493f0f9cf7092bb76eaccf41d8e7ff60c756b0f696418a8649e7" + }, + { + "path": "src/unix/pty.cc", + "bytes": 23090, + "sha512": "65b6cd4acba63beb31b9254df57e60b78ce60aedd5a85e4a27bf2907bf89d1614128eb1418c56bfe2cf30d5fc87363633395de216dd517ac468d7f2a649976fa" + }, + { + "path": "src/unix/spawn-helper.cc", + "bytes": 494, + "sha512": "293d7ef1ee5e666280a659460bb63e5b08f83cfbbad3acb422ec09b8472b1fd788ca7f0d5153e35caf97d7caf71850dc295cd3672e143b2227d28c956f5d2f65" + }, + { + "path": "src/win/conpty_console_list.cc", + "bytes": 1368, + "sha512": "b748dcf44179e22f1d62f4b04085188bc770f3a680c7195df52c19b3df1566d5097d9de6e454fe52ce4668e66305022a9f2442d595aba2b894c046a69f90ac5c" + }, + { + "path": "src/win/conpty.cc", + "bytes": 23389, + "sha512": "c53738037c6245ed97201c74d4d3937d33d6b79b5dd6fe29cddc23e03aa82c08255c13fdd1c7119fd4d11fb5de18122dd02b3690fdeac6fa202de771d6de364c" + }, + { + "path": "src/win/conpty.h", + "bytes": 1727, + "sha512": "2a1c64ad06ee4c644eeeb21df9bbdd046a8720146058a6f886586c2496675c0424160210c5666da6d6784897e00e09be8eee99620415733236b706258d2abda8" + }, + { + "path": "src/win/path_util.cc", + "bytes": 2607, + "sha512": "896e99c3d5790da1b1d7e42845cb62d3330edb05f166bd313a207df88d10f7d746e94cef49d0772b2f05426f5c1cb071b1fcf8ea90c6c09861acda748abfc84c" + }, + { + "path": "src/win/path_util.h", + "bytes": 695, + "sha512": "7492bc305cf5716454c4d7186aa91447e576c4f171b2cf1d3895dce4e9cc8f8bc4b791a03fa8424c14d726a1e8b9ea1f625eacf6a16f63795278865dd46f7b85" + }, + { + "path": "third_party/conpty/1.25.260303002/win10-arm64/conpty.dll", + "bytes": 106528, + "sha512": "14b9b6f55cdb9f968b818fffd1c3700e2b300846c8998468784d0be58f085802b02f8074972e39a712fa5605bbe7c99fd2913cd8800eab19791fbb825daae1b5" + }, + { + "path": "third_party/conpty/1.25.260303002/win10-arm64/OpenConsole.exe", + "bytes": 1110560, + "sha512": "78e3569756b9029b45bd870b0558ecd1e5b184d5d41de00b748e45928961ba29859a0dc5db468a138c68dd3960e3c008291159bf0fedc556edca9efd6d6d14fa" + }, + { + "path": "third_party/conpty/1.25.260303002/win10-x64/conpty.dll", + "bytes": 110152, + "sha512": "f5c36909dca9140ad2135fcf9a7455ad83c3831ef2a428bc3a9545da565891fcc9ff4b9c0660e6f4b15fec122b5b85126868c8739084e54fc27204a754eecaaa" + }, + { + "path": "third_party/conpty/1.25.260303002/win10-x64/OpenConsole.exe", + "bytes": 1062472, + "sha512": "b49451640ed205ce50a5af49628ceaa9815e05f4a635e8404bd5cadb4f70f87a88d4be863731f8533aef979b9bcbac783748bc87c0ea0fb523f28cb020584226" + }, + { + "path": "typings/node-pty.d.ts", + "bytes": 7935, + "sha512": "b5a0c935ed6b276bfcf97242d5b06d2a27cb3fb04764ddcc77ed61216dad6ced12edda173ba4d5945e21e6ae8ebfd6f0f4746a1f5975636d126dc16473a68a1d" + } + ] + }, + "compressed": { + "tarballBytes": 7097383, + "unpackedBytes": 26232181, + "integrity": "sha512-b+/KO/Vt6RpDcsrX5xDzOxEnuHh/J+Ie/5tQbLWZDQL2Rcc1Hm1uwbpWzt15Lt3nrnKmhTmeGiFX011j/iVkHw==", + "files": [ + { + "path": "LICENSE", + "bytes": 3326, + "sha512": "a202772b8e040488993bf8f9000b4e032a6f8173ce84e1d1a100fabe4377f0465da3b581276f1c8990349d31364234acd07b0fd21949a9890e28ceb42777b1db" + }, + { + "path": "README.md", + "bytes": 8104, + "sha512": "14b242a700bc18ddaa5b335cc94c0e0770b57954cc9c34817241ffe15e7affe4cb4f9edff8e22257c87838820398943e7b31794b652edf68573fac5c09fa2e2b" + }, + { + "path": "binding.gyp", + "bytes": 4657, + "sha512": "b903a90a9a6729905cad06eadebc83e9610578b60c2333848e00cf785bd7b2a4cf7be06e6eba8cc3742d8194c88fab1355ebb2d74b517b31db8882cf21068332" + }, + { + "path": "lib/compressedNative.js", + "bytes": 9767, + "sha512": "a11ef72be372ecdb572f0e21fc5670b638c9a84300792f67fb36300c37c55e71ea3980b12fb035763ceb0551e769fdf163c45cf51cf32c271fc425c1cc38e8bf" + }, + { + "path": "lib/compression/brotli.js", + "bytes": 278, + "sha512": "cd523a0b1eb762e740f692720ccda4423c0154b4714ccd6ab6f5e81671a6bb3766c010441139152c16bf0fdecf6d112d04d6586bdbccdd7608e8586f2a2d8ce0" + }, + { + "path": "lib/compression/zstd.js", + "bytes": 774, + "sha512": "d361cab4f34c8c5e156f6e7dbdc4ee1b2245e3e1559be54357f2e2a59855d4e83e9aa40ea895131a55950143aa932b1566302f94d6fef0e61d8a3e8c2d30d185" + }, + { + "path": "lib/conpty_console_list_agent.js", + "bytes": 932, + "sha512": "a289bd4bf3fad76382bd947888b560a3d92e76c5b0b04b4875c547e15336cd99d72a480f6fe9e1dda3125406514b4b3308e9ddd898b458b58ab55d45dd965af4" + }, + { + "path": "lib/eventEmitter2.js", + "bytes": 1600, + "sha512": "93529d7d25bffddd1cffbe8060b310470b8dba533aeb3c6edb5ed56eaabe9ed749f29e444f935a1255c4becfb892f49aa0433067d16c6352195ef31b305be904" + }, + { + "path": "lib/index.js", + "bytes": 1942, + "sha512": "253a06102583d11ebd9984175dee78ae508d19ee103fc356e5864a531c133499f5b43eb1206bba8067c4d79edeb2debd6bbf85833492c0ab502b007831c2bdd7" + }, + { + "path": "lib/interfaces.js", + "bytes": 233, + "sha512": "013fba949e8a748cb2ece477c0365fab50dca324e29c433ec3d0e828474d1ad9bc434a6b8286a56e20e6dcdbbc2bd5a3b6fe3631773942db792adb66c8c0f192" + }, + { + "path": "lib/shared/conout.js", + "bytes": 322, + "sha512": "fb37161105a8a12ac19d91f1cb3bdfaf6b17bfd6396ea4bbfba31a9e2d792aba2e5fd35c40403ea9589f99e79e286d870779337a209a1926b4a23ca0bdd47e4b" + }, + { + "path": "lib/terminal.js", + "bytes": 7499, + "sha512": "2ff010886f813af1e0a0ba692ffa1cd5103c5a9db614791526714759ae5d206240e1f6d10a74d88bec1c562133e77e09c540ca28588ed0ccfad8da1c9df95310" + }, + { + "path": "lib/types.js", + "bytes": 228, + "sha512": "bf04a06ce72a5a9462543b43e1265fb37f15fb489a6fafc85e90897f123b40b85bbfbfb739c8af2d2464a6b2dea88bcfcc7eb1b559cef7b3b0a5a80aa6c881fb" + }, + { + "path": "lib/unixTerminal.js", + "bytes": 14039, + "sha512": "197220eb4cc81ad27939b50169ea2fd80fcf91cba485d65390f13fcfaa2d65f9fc8a2ab564026098b60726b58282e162aefb51b6c6093ac9b1796d701331111a" + }, + { + "path": "lib/utils.js", + "bytes": 2384, + "sha512": "8ae552a76077365ca534cc7d2305d5d6b800619349996865f6cd1d84705857d94e3c99608c246baa09352eea078e209b2eab098102157fc73d87e30031dc9495" + }, + { + "path": "lib/windowsConoutConnection.js", + "bytes": 6443, + "sha512": "e6f594d35db18b6c93c2736a4a747b9822ca7a7b510c3404582618d5929674667f04bfe437ae2dcb5e71c570b0b613469a1fb0b6faa58fa8428d04cd68813b5f" + }, + { + "path": "lib/windowsPtyAgent.js", + "bytes": 14480, + "sha512": "8dd1a77f0f5a00411d829f955152d3d760fb4a201eb5c0eddd5328c9693903debcba74aa4530dc1a49edb45972ccf69be8c814998e868b2d94ee85bc4b3e0027" + }, + { + "path": "lib/windowsTerminal.js", + "bytes": 8716, + "sha512": "bed18fb8ce843b41df6b91a8c985c0fd65e0a845d9f050ce0d07f83400d27b0070f84c4d36fd4a6e210ed5100e3d15f498f2bf06ebca8737a2d267598d043338" + }, + { + "path": "lib/worker/conoutSocketWorker.js", + "bytes": 878, + "sha512": "c9a75fdaff3092c82f1b422a1c3423cc6625b6f4e735db61390b0ca518a033a55f4bd1cdba6ccf0ec3dd763d8d149305a37e9cce654052920225d48e01f96935" + }, + { + "path": "package.json", + "bytes": 1895, + "sha512": "8b2e3ca823481d6eb5e9b62696d9d56fd7bd02b88742cb82c66f2d3f8650f2eb1576ecdff62777c321e1edc84758340581149fa5cb32b15e7c8b686a90b35827" + }, + { + "path": "prebuilds/darwin-arm64/native-manifest.json", + "bytes": 619, + "sha512": "7c985cc280e0dcebf25073a033b37c81a1685a3b96266160a1bdc7504f5e37156ab9c23f01d30130732411ccf43fd628ed485757749c2dbd7def3fc9c76f5528" + }, + { + "path": "prebuilds/darwin-arm64/pty.node.br", + "bytes": 16011, + "sha512": "08b0bf33ae6a50fffbbffd62c60d29eefb11192439390e283fc9e41aae46e1ad334735047e2df8415922f8a532848f1a2f05e8d6fca4dad83f6a5efabcbfadf3" + }, + { + "path": "prebuilds/darwin-arm64/spawn-helper", + "bytes": 50480, + "sha512": "7e716b9fae1a793e9be5ab831040c29f8f43277849ad0399beab98ebeab91c6ff413730d2ba4b743e2ab459cfbb611c2e6bbe5f101242c6955b95bd217bec1df" + }, + { + "path": "prebuilds/darwin-x64/native-manifest.json", + "bytes": 616, + "sha512": "f5cf59550c53d1b0cd3237e0ddb1df87220b821d3801c2594b97ca04f1d35ffeb5719cf26e0c397974398d22d285f213c1482f9c915638b94ebde2fd92dfbef8" + }, + { + "path": "prebuilds/darwin-x64/pty.node.br", + "bytes": 17189, + "sha512": "ec2a56b689848f751329a7c1257310c8d7c67a2a58fb192f2c1961c4dfaef6e5faaa148aa7ff87310e7673fe25a8e8e5323887e88aa63e85723be604b2770769" + }, + { + "path": "prebuilds/darwin-x64/spawn-helper", + "bytes": 9248, + "sha512": "12f15bf72dd685c4acc04f3748925011a7b41b9440da779f712a4b05bf2523d3da36656a3c999c32ea9247df16d8401c5385f44e61b09fbe09aec80eb511dabf" + }, + { + "path": "prebuilds/linux-arm64/native-manifest.json", + "bytes": 420, + "sha512": "0d2de46b8ba77d43a0b9de4218ade15e61bd4e9c25c468b4bad7e617983adc79da34e8b8a1dc3269b1ff219ab5c2201075de86c716ffb9c4a4c424d1f789b531" + }, + { + "path": "prebuilds/linux-arm64/pty.node.zst", + "bytes": 21258, + "sha512": "242735b75493af0e5886df5bc995251604d6d5a963c50816aa1e23b02379371d60677ff748d1fe5d11f33eecc91c56db0630bc0136bd3c75a4abfc14d5a005ce" + }, + { + "path": "prebuilds/linux-x64/native-manifest.json", + "bytes": 418, + "sha512": "8ca2234853d09a402c9f257f54bc2de8206ea8a1050ffa1cd21c014e30054049f2ac13c58fdd99f9df34575a3268271a2a0acd8cdb0b648559c478ccab8041b9" + }, + { + "path": "prebuilds/linux-x64/pty.node.zst", + "bytes": 23070, + "sha512": "59cd2f02f9feb1926311da7e2a4fe0333fb98b8300b01a061492413eac5dc9829631129b9a3f72c941cef0207fdb3cd330493d6cd08bee06f60f0c16ba137714" + }, + { + "path": "prebuilds/win32-arm64/conpty_console_list.node.zst", + "bytes": 48280, + "sha512": "58ffb3383618d5d032f0384f29c0133ad47e5319991bf0dbb9a9da4d6ac5f828374e73298c592aa7e4995e2af3e4f08ea7e93301f95b130d01be60bb04f6eed4" + }, + { + "path": "prebuilds/win32-arm64/conpty_console_list.pdb", + "bytes": 4239360, + "sha512": "f9922548b373f517a70102a44b70e690eb6d972e06d096b830b1d9eee020e72c41ac51335c20a536563973ed2087821781e3a27b5f7ec8ac4154e55244f214e7" + }, + { + "path": "prebuilds/win32-arm64/conpty.node.zst", + "bytes": 111111, + "sha512": "d929a5ed3bd4a83e7d5014ffb59033bf4454ce8debe75106a10b25be100290124eb2fed22e14e9334742b369203ab761cb961a37c2081d7f1648bbf21828e630" + }, + { + "path": "prebuilds/win32-arm64/conpty.pdb", + "bytes": 5935104, + "sha512": "84c402069897bed97b7755b0e12b7ce169c155274a7d1e7b464bc83578c42102e65fd6763de56df17699fa42ec07b90b0739ae97ca4feee132a98641d83f9be0" + }, + { + "path": "prebuilds/win32-arm64/conpty/conpty.dll", + "bytes": 106528, + "sha512": "14b9b6f55cdb9f968b818fffd1c3700e2b300846c8998468784d0be58f085802b02f8074972e39a712fa5605bbe7c99fd2913cd8800eab19791fbb825daae1b5" + }, + { + "path": "prebuilds/win32-arm64/conpty/OpenConsole.exe", + "bytes": 1110560, + "sha512": "78e3569756b9029b45bd870b0558ecd1e5b184d5d41de00b748e45928961ba29859a0dc5db468a138c68dd3960e3c008291159bf0fedc556edca9efd6d6d14fa" + }, + { + "path": "prebuilds/win32-arm64/native-manifest.json", + "bytes": 1210, + "sha512": "30513a5e7a15505a9dafa3f7af2896efddefa6283a49fdb067f8182dc2f0faf0b6682af51a9ae443d1b0a9e977797a1fc5335926ff004a49b826771efbacd0e9" + }, + { + "path": "prebuilds/win32-x64/conpty_console_list.node.zst", + "bytes": 60774, + "sha512": "b26bab5194ccea10d340c38e2fea8f5ccc46b22e534e139d2e3780c9fbda0350ee6cf2ed3bc8b802ed6f3db77b105bc3d0c58c38b4ed224849d07bb12742e835" + }, + { + "path": "prebuilds/win32-x64/conpty_console_list.pdb", + "bytes": 4321280, + "sha512": "a8c384826bb45acb5bb1f0a8d5c8d6f15598a4d198d5a6158ceb291fd8299c704aff9158a63636b57ffe240862a5fb55517c01c5d41166edd5bd83f608acfe23" + }, + { + "path": "prebuilds/win32-x64/conpty.node.zst", + "bytes": 129790, + "sha512": "8bbbcabba622caec99327c2ea7fa147946cf481a754ac9108e0799c7a25e9d33ce9a42571d4756100a9ab715e2084642bfd503ea96fcafc51d537941fb059047" + }, + { + "path": "prebuilds/win32-x64/conpty.pdb", + "bytes": 6311936, + "sha512": "01cc0a417a7c0f8882b3cc100edfa6ac705b0dd37a702d71feaf94273a69942c4d5f39187a3aa57e63f2aa08cc2c915d7a31ea4d22c739c42d95ac2ce614a46b" + }, + { + "path": "prebuilds/win32-x64/conpty/conpty.dll", + "bytes": 110152, + "sha512": "f5c36909dca9140ad2135fcf9a7455ad83c3831ef2a428bc3a9545da565891fcc9ff4b9c0660e6f4b15fec122b5b85126868c8739084e54fc27204a754eecaaa" + }, + { + "path": "prebuilds/win32-x64/conpty/OpenConsole.exe", + "bytes": 1062472, + "sha512": "b49451640ed205ce50a5af49628ceaa9815e05f4a635e8404bd5cadb4f70f87a88d4be863731f8533aef979b9bcbac783748bc87c0ea0fb523f28cb020584226" + }, + { + "path": "prebuilds/win32-x64/native-manifest.json", + "bytes": 1208, + "sha512": "f4a6974c42b576f5731c2537e7f4860c47d150245bb4da145aae9e26bf443d106fca7aeece2b64bd72a7597b44c8d0f36da17485ddd2d5051e85170aa52f60d1" + }, + { + "path": "scripts/post-install.js", + "bytes": 2401, + "sha512": "68a113f65379e402c5987a2570135eaa953c7e5250abf756ae10fba39322f5502195f81c55b16cebd4112db3530fded359d91f1f89225075ca7e443738430da0" + }, + { + "path": "scripts/prebuild.js", + "bytes": 1172, + "sha512": "e4a9cd2c44b5db12846b7829d69a1bebc45ea8b3621b6eb0086a6af9f58d5397898c6f1ba9e0493f0f9cf7092bb76eaccf41d8e7ff60c756b0f696418a8649e7" + }, + { + "path": "src/unix/pty.cc", + "bytes": 23090, + "sha512": "65b6cd4acba63beb31b9254df57e60b78ce60aedd5a85e4a27bf2907bf89d1614128eb1418c56bfe2cf30d5fc87363633395de216dd517ac468d7f2a649976fa" + }, + { + "path": "src/unix/spawn-helper.cc", + "bytes": 494, + "sha512": "293d7ef1ee5e666280a659460bb63e5b08f83cfbbad3acb422ec09b8472b1fd788ca7f0d5153e35caf97d7caf71850dc295cd3672e143b2227d28c956f5d2f65" + }, + { + "path": "src/win/conpty_console_list.cc", + "bytes": 1368, + "sha512": "b748dcf44179e22f1d62f4b04085188bc770f3a680c7195df52c19b3df1566d5097d9de6e454fe52ce4668e66305022a9f2442d595aba2b894c046a69f90ac5c" + }, + { + "path": "src/win/conpty.cc", + "bytes": 23389, + "sha512": "c53738037c6245ed97201c74d4d3937d33d6b79b5dd6fe29cddc23e03aa82c08255c13fdd1c7119fd4d11fb5de18122dd02b3690fdeac6fa202de771d6de364c" + }, + { + "path": "src/win/conpty.h", + "bytes": 1727, + "sha512": "2a1c64ad06ee4c644eeeb21df9bbdd046a8720146058a6f886586c2496675c0424160210c5666da6d6784897e00e09be8eee99620415733236b706258d2abda8" + }, + { + "path": "src/win/path_util.cc", + "bytes": 2607, + "sha512": "896e99c3d5790da1b1d7e42845cb62d3330edb05f166bd313a207df88d10f7d746e94cef49d0772b2f05426f5c1cb071b1fcf8ea90c6c09861acda748abfc84c" + }, + { + "path": "src/win/path_util.h", + "bytes": 695, + "sha512": "7492bc305cf5716454c4d7186aa91447e576c4f171b2cf1d3895dce4e9cc8f8bc4b791a03fa8424c14d726a1e8b9ea1f625eacf6a16f63795278865dd46f7b85" + }, + { + "path": "third_party/conpty/1.25.260303002/win10-arm64/conpty.dll", + "bytes": 106528, + "sha512": "14b9b6f55cdb9f968b818fffd1c3700e2b300846c8998468784d0be58f085802b02f8074972e39a712fa5605bbe7c99fd2913cd8800eab19791fbb825daae1b5" + }, + { + "path": "third_party/conpty/1.25.260303002/win10-arm64/OpenConsole.exe", + "bytes": 1110560, + "sha512": "78e3569756b9029b45bd870b0558ecd1e5b184d5d41de00b748e45928961ba29859a0dc5db468a138c68dd3960e3c008291159bf0fedc556edca9efd6d6d14fa" + }, + { + "path": "third_party/conpty/1.25.260303002/win10-x64/conpty.dll", + "bytes": 110152, + "sha512": "f5c36909dca9140ad2135fcf9a7455ad83c3831ef2a428bc3a9545da565891fcc9ff4b9c0660e6f4b15fec122b5b85126868c8739084e54fc27204a754eecaaa" + }, + { + "path": "third_party/conpty/1.25.260303002/win10-x64/OpenConsole.exe", + "bytes": 1062472, + "sha512": "b49451640ed205ce50a5af49628ceaa9815e05f4a635e8404bd5cadb4f70f87a88d4be863731f8533aef979b9bcbac783748bc87c0ea0fb523f28cb020584226" + }, + { + "path": "typings/node-pty.d.ts", + "bytes": 7935, + "sha512": "b5a0c935ed6b276bfcf97242d5b06d2a27cb3fb04764ddcc77ed61216dad6ced12edda173ba4d5945e21e6ae8ebfd6f0f4746a1f5975636d126dc16473a68a1d" + } + ] + } + }, + "baselineBreakdown": { + "otherFiles": 201448, + "nativeAddons": 1118952, + "windowsCompanionsAndSymbols": 25587104 + }, + "currentPlatformCache": { + "files": [ + { + "path": "v1-7c985cc280e0dcebf25073a033b37c81a1685a3b96266160a1bdc7504f5e37156ab9c23f01d30130732411ccf43fd628ed485757749c2dbd7def3fc9c76f5528/pty.node", + "bytes": 86904 + }, + { + "path": "v1-7c985cc280e0dcebf25073a033b37c81a1685a3b96266160a1bdc7504f5e37156ab9c23f01d30130732411ccf43fd628ed485757749c2dbd7def3fc9c76f5528/spawn-helper", + "bytes": 50480 + } + ], + "bytes": 137384 + }, + "medianRequireMs": { + "raw": 2.557417, + "first": 46.546, + "cached": 5.674667 + }, + "timings": [ + { + "round": 0, + "kind": "raw", + "requireMs": 29.687375, + "sameProcessRequireMs": 0.005041, + "smoke": "passed" + }, + { + "round": 0, + "kind": "first", + "requireMs": 50.32475, + "sameProcessRequireMs": 0.008958, + "smoke": "passed" + }, + { + "round": 0, + "kind": "cached", + "requireMs": 5.732375, + "sameProcessRequireMs": 0.022875, + "smoke": "passed" + }, + { + "round": 1, + "kind": "first", + "requireMs": 37.205583, + "sameProcessRequireMs": 0.00825, + "smoke": "passed" + }, + { + "round": 1, + "kind": "cached", + "requireMs": 5.4755, + "sameProcessRequireMs": 0.0035, + "smoke": "passed" + }, + { + "round": 1, + "kind": "raw", + "requireMs": 2.355708, + "sameProcessRequireMs": 0.003666, + "smoke": "passed" + }, + { + "round": 2, + "kind": "raw", + "requireMs": 2.192583, + "sameProcessRequireMs": 0.004083, + "smoke": "passed" + }, + { + "round": 2, + "kind": "first", + "requireMs": 46.546, + "sameProcessRequireMs": 0.007625, + "smoke": "passed" + }, + { + "round": 2, + "kind": "cached", + "requireMs": 5.333916, + "sameProcessRequireMs": 0.004208, + "smoke": "passed" + }, + { + "round": 3, + "kind": "first", + "requireMs": 43.751958, + "sameProcessRequireMs": 0.0075, + "smoke": "passed" + }, + { + "round": 3, + "kind": "cached", + "requireMs": 6.081625, + "sameProcessRequireMs": 0.004375, + "smoke": "passed" + }, + { + "round": 3, + "kind": "raw", + "requireMs": 2.718125, + "sameProcessRequireMs": 0.00375, + "smoke": "passed" + }, + { + "round": 4, + "kind": "raw", + "requireMs": 2.557417, + "sameProcessRequireMs": 0.00375, + "smoke": "passed" + }, + { + "round": 4, + "kind": "first", + "requireMs": 105.083416, + "sameProcessRequireMs": 0.011333, + "smoke": "passed" + }, + { + "round": 4, + "kind": "cached", + "requireMs": 5.056833, + "sameProcessRequireMs": 0.003458, + "smoke": "passed" + }, + { + "round": 5, + "kind": "first", + "requireMs": 44.213375, + "sameProcessRequireMs": 0.006417, + "smoke": "passed" + }, + { + "round": 5, + "kind": "cached", + "requireMs": 5.703292, + "sameProcessRequireMs": 0.003916, + "smoke": "passed" + }, + { + "round": 5, + "kind": "raw", + "requireMs": 2.677417, + "sameProcessRequireMs": 0.003834, + "smoke": "passed" + }, + { + "round": 6, + "kind": "raw", + "requireMs": 2.255042, + "sameProcessRequireMs": 0.0035, + "smoke": "passed" + }, + { + "round": 6, + "kind": "first", + "requireMs": 92.709083, + "sameProcessRequireMs": 0.009125, + "smoke": "passed" + }, + { + "round": 6, + "kind": "cached", + "requireMs": 6.048084, + "sameProcessRequireMs": 0.003583, + "smoke": "passed" + }, + { + "round": 7, + "kind": "first", + "requireMs": 37.44325, + "sameProcessRequireMs": 0.008167, + "smoke": "passed" + }, + { + "round": 7, + "kind": "cached", + "requireMs": 5.191625, + "sameProcessRequireMs": 0.004292, + "smoke": "passed" + }, + { + "round": 7, + "kind": "raw", + "requireMs": 2.300625, + "sameProcessRequireMs": 0.003708, + "smoke": "passed" + }, + { + "round": 8, + "kind": "raw", + "requireMs": 2.374084, + "sameProcessRequireMs": 0.0035, + "smoke": "passed" + }, + { + "round": 8, + "kind": "first", + "requireMs": 115.746, + "sameProcessRequireMs": 0.00925, + "smoke": "passed" + }, + { + "round": 8, + "kind": "cached", + "requireMs": 5.691167, + "sameProcessRequireMs": 0.005125, + "smoke": "passed" + }, + { + "round": 9, + "kind": "first", + "requireMs": 35.123541, + "sameProcessRequireMs": 0.009417, + "smoke": "passed" + }, + { + "round": 9, + "kind": "cached", + "requireMs": 5.50575, + "sameProcessRequireMs": 0.003416, + "smoke": "passed" + }, + { + "round": 9, + "kind": "raw", + "requireMs": 2.721292, + "sameProcessRequireMs": 0.003708, + "smoke": "passed" + }, + { + "round": 10, + "kind": "raw", + "requireMs": 2.631291, + "sameProcessRequireMs": 0.004042, + "smoke": "passed" + }, + { + "round": 10, + "kind": "first", + "requireMs": 107.662084, + "sameProcessRequireMs": 0.0195, + "smoke": "passed" + }, + { + "round": 10, + "kind": "cached", + "requireMs": 5.674667, + "sameProcessRequireMs": 0.0055, + "smoke": "passed" + }, + { + "round": 11, + "kind": "first", + "requireMs": 41.060458, + "sameProcessRequireMs": 0.008541, + "smoke": "passed" + }, + { + "round": 11, + "kind": "cached", + "requireMs": 5.76475, + "sameProcessRequireMs": 0.005542, + "smoke": "passed" + }, + { + "round": 11, + "kind": "raw", + "requireMs": 2.5095, + "sameProcessRequireMs": 0.003875, + "smoke": "passed" + }, + { + "round": 12, + "kind": "raw", + "requireMs": 2.61425, + "sameProcessRequireMs": 0.004125, + "smoke": "passed" + }, + { + "round": 12, + "kind": "first", + "requireMs": 81.503958, + "sameProcessRequireMs": 0.008583, + "smoke": "passed" + }, + { + "round": 12, + "kind": "cached", + "requireMs": 5.851208, + "sameProcessRequireMs": 0.006042, + "smoke": "passed" + }, + { + "round": 13, + "kind": "first", + "requireMs": 37.023625, + "sameProcessRequireMs": 0.008625, + "smoke": "passed" + }, + { + "round": 13, + "kind": "cached", + "requireMs": 5.809292, + "sameProcessRequireMs": 0.006875, + "smoke": "passed" + }, + { + "round": 13, + "kind": "raw", + "requireMs": 2.756208, + "sameProcessRequireMs": 0.003917, + "smoke": "passed" + }, + { + "round": 14, + "kind": "raw", + "requireMs": 2.787, + "sameProcessRequireMs": 0.003625, + "smoke": "passed" + }, + { + "round": 14, + "kind": "first", + "requireMs": 84.950833, + "sameProcessRequireMs": 0.009375, + "smoke": "passed" + }, + { + "round": 14, + "kind": "cached", + "requireMs": 5.231333, + "sameProcessRequireMs": 0.00525, + "smoke": "passed" + }, + { + "round": 15, + "kind": "first", + "requireMs": 42.587042, + "sameProcessRequireMs": 0.012291, + "smoke": "passed" + }, + { + "round": 15, + "kind": "cached", + "requireMs": 5.8495, + "sameProcessRequireMs": 0.005458, + "smoke": "passed" + }, + { + "round": 15, + "kind": "raw", + "requireMs": 2.506917, + "sameProcessRequireMs": 0.003875, + "smoke": "passed" + }, + { + "round": 16, + "kind": "raw", + "requireMs": 2.159583, + "sameProcessRequireMs": 0.003833, + "smoke": "passed" + }, + { + "round": 16, + "kind": "first", + "requireMs": 119.486792, + "sameProcessRequireMs": 0.009084, + "smoke": "passed" + }, + { + "round": 16, + "kind": "cached", + "requireMs": 5.270375, + "sameProcessRequireMs": 0.005292, + "smoke": "passed" + }, + { + "round": 17, + "kind": "first", + "requireMs": 41.109959, + "sameProcessRequireMs": 0.005875, + "smoke": "passed" + }, + { + "round": 17, + "kind": "cached", + "requireMs": 5.147458, + "sameProcessRequireMs": 0.005334, + "smoke": "passed" + }, + { + "round": 17, + "kind": "raw", + "requireMs": 2.555334, + "sameProcessRequireMs": 0.004583, + "smoke": "passed" + }, + { + "round": 18, + "kind": "raw", + "requireMs": 2.930625, + "sameProcessRequireMs": 0.004, + "smoke": "passed" + }, + { + "round": 18, + "kind": "first", + "requireMs": 84.54625, + "sameProcessRequireMs": 0.00875, + "smoke": "passed" + }, + { + "round": 18, + "kind": "cached", + "requireMs": 6.232375, + "sameProcessRequireMs": 0.005542, + "smoke": "passed" + }, + { + "round": 19, + "kind": "first", + "requireMs": 42.11925, + "sameProcessRequireMs": 0.007125, + "smoke": "passed" + }, + { + "round": 19, + "kind": "cached", + "requireMs": 5.122583, + "sameProcessRequireMs": 0.006459, + "smoke": "passed" + }, + { + "round": 19, + "kind": "raw", + "requireMs": 2.684125, + "sameProcessRequireMs": 0.004042, + "smoke": "passed" + }, + { + "round": 20, + "kind": "raw", + "requireMs": 2.213875, + "sameProcessRequireMs": 0.003917, + "smoke": "passed" + }, + { + "round": 20, + "kind": "first", + "requireMs": 107.075542, + "sameProcessRequireMs": 0.008875, + "smoke": "passed" + }, + { + "round": 20, + "kind": "cached", + "requireMs": 5.048125, + "sameProcessRequireMs": 0.005791, + "smoke": "passed" + } + ] +} diff --git a/package.json b/package.json index 08e2d0687..d239d62df 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,10 @@ "test": "cross-env NODE_ENV=test mocha -R spec --exit lib/*.test.js", "posttest": "npm run lint", "prepare": "npm run build", - "prepublishOnly": "npm run build" + "prepublishOnly": "npm run build", + "test:compressed": "mocha --exit test/compressed-prebuilds.js", + "compress:prebuilds": "node scripts/compress-prebuilds.js", + "benchmark:compressed": "node scripts/benchmark-compressed-prebuilds.js" }, "dependencies": { "node-addon-api": "^7.1.0" diff --git a/publish.yml b/publish.yml index 7c89a7964..fb0184b1d 100644 --- a/publish.yml +++ b/publish.yml @@ -69,6 +69,8 @@ extends: # stable releases, but increments the version for beta releases. - script: node scripts/increment-version.js displayName: 'Increment version' + - script: npm run compress:prebuilds + displayName: 'Compress and verify native prebuilds' testSteps: - task: DownloadPipelineArtifact@2 diff --git a/scripts/benchmark-compressed-prebuilds.js b/scripts/benchmark-compressed-prebuilds.js new file mode 100644 index 000000000..a05bbc1f4 --- /dev/null +++ b/scripts/benchmark-compressed-prebuilds.js @@ -0,0 +1,91 @@ +// npm run benchmark:compressed -- /path/to/unpacked/published/package [provenance.json] +// Uses the original release's native inputs; never changes that directory. +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const cp = require('child_process'); +const assert = require('assert'); +const crypto = require('crypto'); +const { compressPrebuild } = require('./compress-prebuilds'); +const original = path.resolve(process.argv[2]); +const provenance = process.argv[3] ? JSON.parse(fs.readFileSync(process.argv[3], 'utf8')) : undefined; +const work = fs.mkdtempSync(path.join(os.homedir(), '.node-pty-bench-')); +function run(command, args, options = {}) { + const result = cp.spawnSync(command, args, { encoding: 'utf8', ...options }); + if (result.status !== 0) { throw new Error(result.stderr || result.error || `Exit ${result.status}`); } + return result.stdout; +} +function pack(dir, destination) { + fs.mkdirSync(destination); + const report = JSON.parse(run(process.execPath, [process.env.npm_execpath, 'pack', dir, + '--ignore-scripts', '--json', '--pack-destination', destination])); + const item = Array.isArray(report) ? report[0] : Object.values(report)[0]; + const files = item.files.map(file => ({ path: file.path, bytes: file.size, + sha512: crypto.createHash('sha512').update(fs.readFileSync(path.join(dir, file.path))).digest('hex') })); + assert.strictEqual(files.reduce((sum, file) => sum + file.bytes, 0), item.unpackedSize); + return { tarballBytes: item.size, unpackedBytes: item.unpackedSize, integrity: item.integrity, files }; +} +try { + const raw = path.join(work, 'raw'); + const compressed = path.join(work, 'compressed'); + fs.cpSync(original, raw, { recursive: true }); + fs.cpSync(original, compressed, { recursive: true }); + for (const file of ['utils.js', 'compressedNative.js']) { + fs.copyFileSync(path.join(__dirname, '../lib', file), path.join(compressed, 'lib', file)); + } + fs.cpSync(path.join(__dirname, '../lib/compression'), path.join(compressed, 'lib/compression'), { recursive: true }); + const inputs = []; + for (const target of fs.readdirSync(path.join(compressed, 'prebuilds')).sort()) { + const dir = path.join(compressed, 'prebuilds', target); + const manifest = compressPrebuild(dir, target); + for (const file of manifest.files.filter(file => file.compressed)) { + inputs.push({ target, codec: manifest.codec, file: file.path, rawBytes: file.size, compressedBytes: file.encodedSize, sha512: file.sha512 }); + } + } + const sizes = { baseline: pack(raw, path.join(work, 'pack-raw')), compressed: pack(compressed, path.join(work, 'pack-compressed')) }; + if (provenance) { + assert.strictEqual(sizes.baseline.integrity, provenance.integrity, 'Repacked baseline must match the verified registry archive'); + assert.strictEqual(sizes.baseline.tarballBytes, provenance.downloadBytes); + assert.strictEqual(sizes.baseline.unpackedBytes, provenance.unpackedBytes); + } + const changed = new Set(['lib/utils.js', ...sizes.compressed.files.filter(file => + file.path === 'lib/compressedNative.js' || file.path.startsWith('lib/compression/') || + /\.(node|br|zst)$/.test(file.path) || file.path.endsWith('/native-manifest.json')).map(file => file.path), + ...sizes.baseline.files.filter(file => file.path.endsWith('.node')).map(file => file.path)]); + for (const file of sizes.baseline.files.filter(file => !changed.has(file.path))) { + assert.deepStrictEqual(sizes.compressed.files.find(other => other.path === file.path), file); + } + const breakdown = {}; + for (const file of sizes.baseline.files) { + const category = file.path.endsWith('.node') ? 'nativeAddons' : + /\.(dll|exe|pdb)$/.test(file.path) ? 'windowsCompanionsAndSymbols' : 'otherFiles'; + breakdown[category] = (breakdown[category] || 0) + file.bytes; + } + const timings = []; + for (let round = 0; round < 21; round++) { + const cache = path.join(work, `cache-${round}`); + const load = (kind, dir) => { + const output = run(process.execPath, [path.join(__dirname, '../test/pty-smoke.js'), dir], { + env: { ...process.env, NODE_PTY_CACHE: cache } + }); + timings.push({ round, kind, ...JSON.parse(output) }); + }; + if (round % 2 === 0) { load('raw', raw); } + load('first', compressed); + load('cached', compressed); + if (round % 2 !== 0) { load('raw', raw); } + } + const medians = Object.fromEntries(['raw', 'first', 'cached'].map(kind => { + const values = timings.filter(row => row.kind === kind).map(row => row.requireMs).sort((a, b) => a - b); + return [kind, values[Math.floor(values.length / 2)]]; + })); + const cacheFiles = fs.readdirSync(path.join(work, 'cache-0'), { recursive: true, withFileTypes: true }) + .filter(entry => entry.isFile()).map(entry => ({ path: path.relative(path.join(work, 'cache-0'), path.join(entry.parentPath, entry.name)), + bytes: fs.statSync(path.join(entry.parentPath, entry.name)).size })); + console.log(JSON.stringify({ measuredAt: new Date().toISOString(), node: process.version, npm: run(process.execPath, [process.env.npm_execpath, '--version']).trim(), platform: process.platform, arch: process.arch, + packageVersion: JSON.parse(fs.readFileSync(path.join(raw, 'package.json'))).version, + method: 'Twenty-one fresh processes per mode; alternating raw ordering, unique first-load cache each round; no page-cache eviction. Each process spawns and verifies a real PTY.', + provenance, inputs, sizes, baselineBreakdown: breakdown, + currentPlatformCache: { files: cacheFiles, bytes: cacheFiles.reduce((sum, file) => sum + file.bytes, 0) }, + medianRequireMs: medians, timings }, null, 2)); +} finally { fs.rmSync(work, { recursive: true, force: true }); } diff --git a/scripts/chart-compressed-prebuilds.js b/scripts/chart-compressed-prebuilds.js new file mode 100644 index 000000000..ab4a43d39 --- /dev/null +++ b/scripts/chart-compressed-prebuilds.js @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +// Render measured package sizes. Each pair has its own zero-based byte scale. +const fs = require('fs'); +if (!process.argv[2]) { throw new Error('Usage: node scripts/chart-compressed-prebuilds.js /path/to/chart.svg'); } +const report = require('../docs/compression-benchmark.json'); +const raw = report.sizes.baseline; +const packed = report.sizes.compressed; +const nativeRaw = report.inputs.reduce((sum, file) => sum + file.rawBytes, 0); +const nativePacked = report.inputs.reduce((sum, file) => sum + file.compressedBytes, 0); +const rows = [ + ['Complete package, unpacked', raw.unpackedBytes, packed.unpackedBytes], + ['npm download (.tgz)', raw.tarballBytes, packed.tarballBytes], + ['.node files only (subset)', nativeRaw, nativePacked] +]; +const megabytes = value => `${(value / 1_000_000).toFixed(3)} MB`; +const saving = (before, after) => ((before - after) / before * 100).toFixed(2); +const unchangedShare = (report.baselineBreakdown.windowsCompanionsAndSymbols / raw.unpackedBytes * 100).toFixed(1); +const parts = [ + '', + `Compression saves ${saving(raw.unpackedBytes, packed.unpackedBytes)}% unpacked and ${saving(raw.tarballBytes, packed.tarballBytes)}% downloaded`, + 'Before and after sizes in decimal megabytes for node-pty 1.2.0-beta.18. The native addon reduction is a subset, not the whole package. Each pair starts at zero.', + '', + '', + 'Small whole-package saving; large addon-only reduction', + 'node-pty 1.2.0-beta.18 · Same release files · Lower is smaller', + 'Gray: published Blue: proposed Each pair uses its own zero-based scale. 1 MB = 1,000,000 bytes.' +]; +rows.forEach(([label, before, after], index) => { + const y = 145 + index * 137; + const percent = saving(before, after); + parts.push(`${label}`, + `${percent}% smaller · ${megabytes(before - after)} saved`); + for (const [offset, bytes, color, name] of [[18, before, '#87929e', 'Before'], [52, after, '#4285f4', 'After']]) { + parts.push(`${name}`, + ``, + `${megabytes(bytes)}`); + } +}); +parts.push(`${unchangedShare}% of unpacked bytes are unchanged Windows DLLs, executables, and symbols.`, + `Decoded cache adds ${megabytes(report.currentPlatformCache.bytes)} on this macOS ARM64 host; excluded above.`, + ''); +fs.writeFileSync(process.argv[2], parts.join('\n') + '\n'); diff --git a/scripts/compress-prebuilds.js b/scripts/compress-prebuilds.js new file mode 100644 index 000000000..733107d6c --- /dev/null +++ b/scripts/compress-prebuilds.js @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +// Run after building TypeScript and final native signing/stripping, before pack. +const fs = require('fs'); +const path = require('path'); +const zlib = require('zlib'); +const { verifyNativeTarget } = require('./native-target'); +const { MANIFEST, MAX_FILE_SIZE, digest, validateManifest, verifyBytes, nativeCodecForTarget } = require('../lib/compressedNative'); + +function compressPrebuild(dir, target) { + if (fs.existsSync(path.join(dir, MANIFEST))) { + throw new Error(`Already compressed: ${dir}`); + } + const names = target.startsWith('win32-') + ? ['conpty.node', 'conpty_console_list.node', 'conpty/conpty.dll', 'conpty/OpenConsole.exe'] + : target.startsWith('darwin-') ? ['pty.node', 'spawn-helper'] : ['pty.node']; + const codec = nativeCodecForTarget(target); + const suffix = codec === 'brotli' ? '.br' : '.zst'; + const outputs = []; + const manifest = { version: 2, target, codec, files: names.map(name => { + const filename = path.join(dir, name); + const stat = fs.lstatSync(filename); + if (!stat.isFile() || !stat.size || stat.size > MAX_FILE_SIZE) { throw new Error(`Invalid input: ${filename}`); } + const raw = fs.readFileSync(filename); + const file = { path: name, size: raw.length, sha512: digest(raw), compressed: name.endsWith('.node') }; + if (file.compressed) { + verifyNativeTarget(raw, target); + const encoded = codec === 'brotli' ? zlib.brotliCompressSync(raw, { params: { + [zlib.constants.BROTLI_PARAM_QUALITY]: 11, + [zlib.constants.BROTLI_PARAM_LGWIN]: 20, + [zlib.constants.BROTLI_PARAM_SIZE_HINT]: raw.length + } }) : zlib.zstdCompressSync(raw, { params: { [zlib.constants.ZSTD_c_compressionLevel]: 19 } }); + if (encoded.length >= raw.length) { throw new Error(`Compression does not save space: ${filename}`); } + const decode = codec === 'brotli' ? zlib.brotliDecompressSync : zlib.zstdDecompressSync; + verifyBytes(decode(encoded, { maxOutputLength: raw.length }), file); + file.encodedSize = encoded.length; + file.encodedSha512 = digest(encoded); + outputs.push({ filename, encoded }); + } + return file; + }) }; + validateManifest(manifest, target); + // Complete all round-trip checks before changing any input. Release staging + // is disposable; a failed publication must never be packed or published. + for (const { filename, encoded } of outputs) { fs.writeFileSync(`${filename}${suffix}`, encoded, { flag: 'wx' }); } + fs.writeFileSync(path.join(dir, MANIFEST), JSON.stringify(manifest) + '\n', { flag: 'wx' }); + for (const { filename } of outputs) { fs.unlinkSync(filename); } + return manifest; +} + +if (require.main === module) { + const root = path.resolve(process.argv[2] || path.join(__dirname, '../prebuilds')); + for (const target of fs.readdirSync(root).sort()) { + compressPrebuild(path.join(root, target), target); + } +} +module.exports = { compressPrebuild }; diff --git a/scripts/native-target.js b/scripts/native-target.js new file mode 100644 index 000000000..850b8d689 --- /dev/null +++ b/scripts/native-target.js @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +// Check the compiler artifact before labeling a package with a platform/CPU. +// The native loader still validates the complete image and its signature. +function verifyNativeTarget(bytes, target) { + const [platform, arch] = target.split('-'); + let valid = false; + if (bytes.length >= 64 && (arch === 'arm64' || arch === 'x64')) { + if (platform === 'linux') { + valid = bytes.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])) && + bytes[4] === 2 && bytes[5] === 1 && bytes.readUInt16LE(18) === (arch === 'arm64' ? 183 : 62); + } else if (platform === 'darwin') { + valid = bytes.readUInt32LE(0) === 0xfeedfacf && bytes.readUInt32LE(4) === (arch === 'arm64' ? 0x100000c : 0x1000007); + } else if (platform === 'win32') { + const offset = bytes.readUInt32LE(0x3c); + valid = bytes[0] === 0x4d && bytes[1] === 0x5a && offset <= bytes.length - 6 && + bytes.readUInt32LE(offset) === 0x4550 && bytes.readUInt16LE(offset + 4) === (arch === 'arm64' ? 0xaa64 : 0x8664); + } + } + if (!valid) { throw new Error(`Native image does not match ${target}`); } +} +module.exports = { verifyNativeTarget }; diff --git a/src/compressedNative.ts b/src/compressedNative.ts new file mode 100644 index 000000000..ba2aafd0f --- /dev/null +++ b/src/compressedNative.ts @@ -0,0 +1,217 @@ +/** Copyright (c) Microsoft Corporation. Licensed under the MIT License. */ +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +export const MANIFEST = 'native-manifest.json'; +export const MAX_FILE_SIZE = 64 * 1024 * 1024; +const MAX_MANIFEST_SIZE = 16384; + +export interface INativeFile { + path: string; + size: number; + sha512: string; + compressed: boolean; + encodedSize?: number; + encodedSha512?: string; +} +export type NativeCodec = 'brotli' | 'zstd'; + +// Release packing and runtime validation share the platform policy. macOS keeps +// the compact built-in codec; a native LZFSE bridge would add another loader. +export function nativeCodecForTarget(target: string): NativeCodec { + allowedFiles(target); + return target.startsWith('darwin-') ? 'brotli' : 'zstd'; +} + +export interface INativeManifest { + version: number; + codec: NativeCodec; + target: string; + files: INativeFile[]; +} + +// Node 20.12+ avoids allocating a streaming hash for these bounded buffers. +const hash = (crypto as typeof crypto & { + hash?: (algorithm: string, data: Buffer, encoding: 'hex') => string +}).hash; + +export function digest(bytes: Buffer): string { + if (hash) { return hash('sha512', bytes, 'hex'); } + return crypto.createHash('sha512').update(bytes).digest('hex'); +} + +// These are runtime assets, not arbitrary archive paths. Keep Windows DLLs next +// to the addon: conpty resolves its DLL relative to its own loaded image. +function allowedFiles(target: string): string[] { + if (/^win32-(x64|arm64)$/.test(target)) { + return ['conpty.node', 'conpty_console_list.node', 'conpty/conpty.dll', 'conpty/OpenConsole.exe']; + } + if (/^darwin-(x64|arm64)$/.test(target)) { + return ['pty.node', 'spawn-helper']; + } + if (/^linux-(x64|arm64)$/.test(target)) { + return ['pty.node']; + } + throw new Error(`Unsupported native prebuild target: ${target}`); +} + +export function validateManifest(value: INativeManifest, target: string): void { + const expected = allowedFiles(target); + if (!value || value.version !== 2 || value.target !== target || value.codec !== nativeCodecForTarget(target) || !Array.isArray(value.files) || value.files.length !== expected.length) { + throw new Error('Invalid native prebuild manifest or target'); + } + const seen = new Set(); + for (const file of value.files) { + if (!file || !expected.includes(file.path) || seen.has(file.path) || + !Number.isSafeInteger(file.size) || file.size <= 0 || file.size > MAX_FILE_SIZE || + typeof file.sha512 !== 'string' || !/^[a-f0-9]{128}$/.test(file.sha512) || + file.compressed !== file.path.endsWith('.node') || + (file.compressed && (!Number.isSafeInteger(file.encodedSize) || file.encodedSize! <= 0 || file.encodedSize! > MAX_FILE_SIZE || + typeof file.encodedSha512 !== 'string' || !/^[a-f0-9]{128}$/.test(file.encodedSha512)))) { + throw new Error('Invalid native prebuild file'); + } + seen.add(file.path); + } +} + +export function readManifest(dir: string, target: string): { manifest: INativeManifest; key: string } { + const bytes = readRegularFile(path.join(dir, MANIFEST), MAX_MANIFEST_SIZE); + const manifest: INativeManifest = JSON.parse(bytes.toString('utf8')); + validateManifest(manifest, target); + return { manifest, key: digest(bytes) }; +} + +function readRegularFile(filename: string, maxSize: number, privateFile: boolean = false): Buffer { + const stat = fs.lstatSync(filename); + if (!stat.isFile() || stat.size > maxSize || stat.size === 0) { + throw new Error(`Invalid native prebuild file: ${filename}`); + } + const fd = fs.openSync(filename, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0)); + try { + const opened = fs.fstatSync(fd); + if (!opened.isFile() || opened.dev !== stat.dev || opened.ino !== stat.ino || opened.size > maxSize) { + throw new Error(`Native prebuild changed while opening: ${filename}`); + } + // Check permissions on the opened file rather than statting the path again. + if (privateFile && process.platform !== 'win32' && + (opened.uid !== process.getuid!() || (opened.mode & 0o077) !== 0 || + (path.basename(filename) === 'spawn-helper' && (opened.mode & 0o100) === 0))) { + throw new Error(`Unsafe native prebuild cache file: ${filename}`); + } + // Read at most the promised bound even if another process grows the file. + const bytes = Buffer.alloc(opened.size); + let offset = 0; + while (offset < bytes.length) { + const count = fs.readSync(fd, bytes, offset, bytes.length - offset, offset); + if (!count) { throw new Error(`Truncated native prebuild: ${filename}`); } + offset += count; + } + return bytes; + } finally { + fs.closeSync(fd); + } +} + +export function verifyBytes(bytes: Buffer, file: INativeFile): void { + if (bytes.length !== file.size || digest(bytes) !== file.sha512) { + throw new Error(`Native prebuild integrity check failed: ${file.path}`); + } +} + +function checkDirectory(dir: string, privateDirectory: boolean): void { + const stat = fs.lstatSync(dir); + if (!stat.isDirectory() || (process.platform !== 'win32' && + ((privateDirectory ? stat.uid !== process.getuid!() : stat.uid !== 0 && stat.uid !== process.getuid!()) || + (stat.mode & (privateDirectory ? 0o077 : 0o022)) !== 0))) { + throw new Error(`Unsafe native prebuild cache directory: ${dir}`); + } +} + +function cacheRoot(): string { + const configured = process.env.NODE_PTY_CACHE; + if (configured && !path.isAbsolute(configured)) { + throw new Error('NODE_PTY_CACHE must be an absolute path'); + } + // Resolve system aliases such as macOS /var before checking ancestors. Do not + // resolve the private directory: a symlink there must be rejected. + const parent = fs.realpathSync(configured ? path.dirname(configured) : os.homedir()); + let ancestor = parent; + for (;;) { + checkDirectory(ancestor, false); + const next = path.dirname(ancestor); + if (next === ancestor) { break; } + ancestor = next; + } + const root = path.join(parent, configured ? path.basename(configured) : '.node-pty'); + try { fs.mkdirSync(root, { mode: 0o700 }); } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { throw error; } + } + checkDirectory(root, true); + return root; +} + +function verifyDirectory(dir: string, manifest: INativeManifest): void { + checkDirectory(dir, true); + if (manifest.target.startsWith('win32-')) { checkDirectory(path.join(dir, 'conpty'), true); } + for (const file of manifest.files) { + const filename = path.join(dir, file.path); + verifyBytes(readRegularFile(filename, file.size, true), file); + } +} + +/** Decode into a private staging directory, verify, then publish it atomically. + * The installed package is never modified; read-only installs and disabled + * install scripts work. A cache hit is verified before any executable is loaded. + */ +export function materializeNative(dir: string, target: string): string { + const { manifest, key } = readManifest(dir, target); + const root = cacheRoot(); + const destination = path.join(root, `v1-${key}`); + try { + verifyDirectory(destination, manifest); + return destination; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw new Error(`Cannot use native cache ${destination}: ${error}. Remove this cache entry and retry.`); + } + } + // Select a backend only when verified compressed bytes actually need decoding. + let decode: ((encoded: Buffer, size: number) => Buffer) | undefined; + const stage = fs.mkdtempSync(path.join(root, '.stage-')); + try { + for (const file of manifest.files) { + const source = path.join(dir, file.path + (file.compressed ? (manifest.codec === 'brotli' ? '.br' : '.zst') : '')); + const encoded = readRegularFile(source, MAX_FILE_SIZE); + if (file.compressed && (encoded.length !== file.encodedSize || digest(encoded) !== file.encodedSha512)) { + throw new Error(`Native prebuild compressed integrity check failed: ${file.path}`); + } + let bytes = encoded; + if (file.compressed) { + if (!decode) { + // The codec was validated against the platform before reaching here. + // Never eagerly import both backends or import either on a cache hit. + decode = manifest.codec === 'brotli' + ? (require('./compression/brotli') as typeof import('./compression/brotli')).decode + : (require('./compression/zstd') as typeof import('./compression/zstd')).decode; + } + bytes = decode(encoded, file.size); + } + verifyBytes(bytes, file); + const filename = path.join(stage, file.path); + fs.mkdirSync(path.dirname(filename), { recursive: true, mode: 0o700 }); + fs.writeFileSync(filename, bytes, { flag: 'wx', mode: file.path === 'spawn-helper' ? 0o700 : 0o600 }); + } + verifyDirectory(stage, manifest); + try { fs.renameSync(stage, destination); } catch (error) { + // Another process may have won publication. Never overwrite its files, + // particularly DLLs that may already be mapped on Windows. + if (!['EEXIST', 'ENOTEMPTY', 'EPERM'].includes((error as NodeJS.ErrnoException).code || '')) { throw error; } + verifyDirectory(destination, manifest); + } + return destination; + } finally { + (fs as typeof fs & { rmSync(p: string, options: { recursive: boolean; force: boolean }): void }).rmSync(stage, { recursive: true, force: true }); + } +} diff --git a/src/compression/brotli.ts b/src/compression/brotli.ts new file mode 100644 index 000000000..b76c307d2 --- /dev/null +++ b/src/compression/brotli.ts @@ -0,0 +1,7 @@ +/** Copyright (c) Microsoft Corporation. Licensed under the MIT License. */ +import type * as zlibTypes from 'zlib'; +const zlib: typeof zlibTypes = require('node:zlib'); + +export function decode(encoded: Buffer, size: number): Buffer { + return zlib.brotliDecompressSync(encoded, { maxOutputLength: size } as zlibTypes.BrotliOptions); +} diff --git a/src/compression/zstd.ts b/src/compression/zstd.ts new file mode 100644 index 000000000..60b6e688f --- /dev/null +++ b/src/compression/zstd.ts @@ -0,0 +1,17 @@ +/** Copyright (c) Microsoft Corporation. Licensed under the MIT License. */ +// Kept in its own module so other platforms and verified cache hits never load it. +// The project's development typings predate Node's built-in Zstd API. +const zlib = require('node:zlib') as { + constants: { [key: string]: number }; + zstdDecompressSync?: (input: Buffer, options: { maxOutputLength: number, params: { [key: number]: number } }) => Buffer; +}; + +export function decode(encoded: Buffer, size: number): Buffer { + if (!zlib.zstdDecompressSync) { + throw new Error('This native prebuild requires built-in Zstd support. Use Node 22.15+ or a supported newer runtime.'); + } + return zlib.zstdDecompressSync(encoded, { + maxOutputLength: size, + params: { [zlib.constants.ZSTD_d_windowLogMax]: 26 } + }); +} diff --git a/src/utils.ts b/src/utils.ts index 1d2d7c933..1a9c4b6bd 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -3,6 +3,12 @@ * Copyright (c) 2018, Microsoft Corporation (MIT License). */ +import * as fs from 'fs'; +import * as path from 'path'; + +// Match require's process-local caching after a compressed addon loads successfully. +const loadedNativeModules = new Map(); + export function assign(target: any, ...sources: any[]): any { sources.forEach(source => Object.keys(source).forEach(key => target[key] = source[key])); return target; @@ -10,6 +16,8 @@ export function assign(target: any, ...sources: any[]): any { export function loadNativeModule(name: string): {dir: string, module: any} { + const loaded = loadedNativeModules.get(name); + if (loaded) { return loaded; } // Check build, debug, and then prebuilds. const dirs = ['build/Release', 'build/Debug', `prebuilds/${process.platform}-${process.arch}`]; // Check relative to the parent dir for unbundled and then the current dir for bundled @@ -18,6 +26,14 @@ export function loadNativeModule(name: string): {dir: string, module: any} { for (const d of dirs) { for (const r of relative) { const dir = `${r}/${d}`; + const absoluteDir = path.resolve(__dirname, dir); + if (fs.existsSync(path.join(absoluteDir, 'native-manifest.json'))) { + const { materializeNative }: typeof import('./compressedNative') = require('./compressedNative'); + const decodedDir = materializeNative(absoluteDir, `${process.platform}-${process.arch}`); + const result = { dir: decodedDir, module: require(path.join(decodedDir, `${name}.node`)) }; + loadedNativeModules.set(name, result); + return result; + } try { return { dir, module: require(`${dir}/${name}.node`) }; } catch (e) { diff --git a/test/compressed-prebuilds.js b/test/compressed-prebuilds.js new file mode 100644 index 000000000..c71e1a901 --- /dev/null +++ b/test/compressed-prebuilds.js @@ -0,0 +1,186 @@ +// Copyright (c) Microsoft Corporation. Licensed under the MIT License. +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawn } = require('child_process'); +const zlib = require('zlib'); +const { compressPrebuild } = require('../scripts/compress-prebuilds'); +const { MANIFEST, materializeNative, validateManifest } = require('../lib/compressedNative'); + +const target = `${process.platform}-${process.arch}`; +const source = process.env.NODE_PTY_TEST_SOURCE || path.resolve(__dirname, '../build/Release'); +const names = process.platform === 'win32' + ? ['conpty.node', 'conpty_console_list.node', 'conpty/conpty.dll', 'conpty/OpenConsole.exe'] + : process.platform === 'darwin' ? ['pty.node', 'spawn-helper'] : ['pty.node']; +// Use the home directory so POSIX ancestor checks also run under CI runners +// whose temporary directory may be shared or group-writable. +let work; +let input; +let manifest; +let suffix; +let previousCache; +function copyAssets(destination) { + for (const name of names) { + fs.mkdirSync(path.dirname(path.join(destination, name)), { recursive: true }); + fs.copyFileSync(path.join(source, name), path.join(destination, name)); + } +} +function child(script) { + return new Promise((resolve, reject) => { + const p = spawn(process.execPath, ['-e', script], { env: process.env }); + let output = ''; + p.stdout.on('data', bytes => output += bytes); + p.stderr.on('data', bytes => output += bytes); + p.on('error', reject); + p.on('exit', code => code === 0 ? resolve(output) : reject(new Error(output))); + }); +} + +describe('compressed prebuilds', function () { + this.timeout(60000); + beforeEach(() => { + work = fs.mkdtempSync(path.join(os.homedir(), '.node-pty-test-')); + input = path.join(work, 'input'); + copyAssets(input); + manifest = compressPrebuild(input, target); + suffix = manifest.codec === 'brotli' ? '.br' : '.zst'; + previousCache = process.env.NODE_PTY_CACHE; + process.env.NODE_PTY_CACHE = path.join(work, 'cache'); + }); + afterEach(() => { + if (previousCache === undefined) { delete process.env.NODE_PTY_CACHE; } + else { process.env.NODE_PTY_CACHE = previousCache; } + fs.rmSync(work, { recursive: true, force: true }); + }); + it('round-trips every byte and preserves companion paths and helper permissions', () => { + const dir = materializeNative(input, target); + for (const name of names) { + assert.deepStrictEqual(fs.readFileSync(path.join(dir, name)), fs.readFileSync(path.join(source, name))); + } + if (process.platform === 'darwin') { assert.ok(fs.statSync(path.join(dir, 'spawn-helper')).mode & 0o100); } + assert.strictEqual(materializeNative(input, target), dir); + assert.ok(!fs.existsSync(path.join(input, names[0]))); + assert.ok(!fs.readdirSync(process.env.NODE_PTY_CACHE).some(name => name.startsWith('.stage-'))); + }); + it('works with a read-only package directory', function () { + if (process.platform === 'win32') { this.skip(); } + fs.chmodSync(input, 0o555); + try { assert.ok(materializeNative(input, target)); } finally { fs.chmodSync(input, 0o755); } + }); + it('rejects wrong targets, paths, duplicates and oversized output declarations', () => { + assert.throws(() => validateManifest(manifest, 'bad-target')); + for (const change of [m => m.files[0].path = '../escape.node', m => m.files[0].size = 2 ** 32, + m => m.files.push(m.files[0]), m => m.files[0].compressed = false, m => m.version++, m => m.codec = 'unsupported', m => m.codec = m.codec === 'brotli' ? 'zstd' : 'brotli']) { + const copy = JSON.parse(JSON.stringify(manifest)); + change(copy); + assert.throws(() => validateManifest(copy, target)); + } + }); + it('rejects payload corruption before publishing any executable', () => { + fs.writeFileSync(path.join(input, names[0] + suffix), Buffer.from('corrupt')); + assert.throws(() => materializeNative(input, target)); + assert.deepStrictEqual(fs.readdirSync(process.env.NODE_PTY_CACHE), []); + }); + it('rejects corrupt payloads before loading any decoder backend', async () => { + fs.writeFileSync(path.join(input, names[0] + suffix), Buffer.from('corrupt')); + await child(` + const assert = require('assert'); + const Module = require('module'); + const load = Module._load; + Module._load = function(name, ...args) { + assert.ok(name !== 'zlib' && name !== 'node:zlib' && !name.includes('/compression/'), 'unverified payload must not load a decoder'); + return load.call(this, name, ...args); + }; + const { materializeNative } = require(${JSON.stringify(path.resolve(__dirname, '../lib/compressedNative'))}); + assert.throws(() => materializeNative(${JSON.stringify(input)}, ${JSON.stringify(target)}), /compressed integrity check failed/); + `); + }); + it('enforces the output bound before hashing', () => { + const file = manifest.files[0]; + file.size = 1; + fs.writeFileSync(path.join(input, MANIFEST), JSON.stringify(manifest)); + assert.throws(() => materializeNative(input, target), /larger than|size|length/i); + }); + it('rejects valid compression with the wrong bytes', () => { + const file = manifest.files[0]; + fs.writeFileSync(path.join(input, file.path + suffix), (manifest.codec === 'brotli' ? zlib.brotliCompressSync : zlib.zstdCompressSync)(Buffer.alloc(file.size))); + assert.throws(() => materializeNative(input, target), /integrity/); + }); + it('rejects cache corruption instead of loading it', () => { + const dir = materializeNative(input, target); + fs.writeFileSync(path.join(dir, names[0]), Buffer.from('corrupt')); + assert.throws(() => materializeNative(input, target), /Remove this cache entry/); + }); + it('rejects cached files with unsafe permissions', function () { + if (process.platform === 'win32') { this.skip(); } + const dir = materializeNative(input, target); + fs.chmodSync(path.join(dir, names[0]), 0o644); + assert.throws(() => materializeNative(input, target), /Unsafe native prebuild cache file/); + }); + it('rejects relative cache overrides', () => { + process.env.NODE_PTY_CACHE = 'relative'; + assert.throws(() => materializeNative(input, target), /absolute/); + }); + it('rejects symlinked or shared cache directories', function () { + if (process.platform === 'win32') { this.skip(); } + const cache = process.env.NODE_PTY_CACHE; + fs.mkdirSync(cache, { mode: 0o700 }); + fs.chmodSync(cache, 0o777); + assert.throws(() => materializeNative(input, target), /Unsafe/); + fs.rmdirSync(cache); + fs.symlinkSync(input, cache, 'dir'); + assert.throws(() => materializeNative(input, target), /Unsafe/); + }); + it('coordinates simultaneous first loads without partial files', async () => { + const script = `console.log(require(${JSON.stringify(path.resolve(__dirname, '../lib/compressedNative'))}).materializeNative(${JSON.stringify(input)}, ${JSON.stringify(target)}))`; + const results = await Promise.all(Array.from({ length: 6 }, () => child(script))); + assert.strictEqual(new Set(results).size, 1); + assert.strictEqual(fs.readdirSync(process.env.NODE_PTY_CACHE).length, 1); + }); + it('loads and spawns a real PTY from an isolated compressed-only package', async () => { + const pkg = path.join(work, 'package'); + fs.cpSync(path.resolve(__dirname, '../lib'), path.join(pkg, 'lib'), { recursive: true }); + fs.cpSync(input, path.join(pkg, 'prebuilds', target), { recursive: true }); + const script = ` + const pty = require(${JSON.stringify(path.join(pkg, 'lib'))}); + const term = pty.spawn(process.platform === 'win32' ? 'cmd.exe' : '/bin/sh', + process.platform === 'win32' ? ['/c', 'echo compressed-ok'] : ['-c', 'printf compressed-ok'], + { env: process.env, cwd: process.cwd(), useConptyDll: true }); + let output = ''; + term.onData(data => output += data); + term.onExit(({ exitCode }) => { if (exitCode !== 0 || !output.includes('compressed-ok')) process.exit(1); }); + setTimeout(() => process.exit(2), 10000).unref(); + `; + // Cold loads may load only the selected platform backend. + await child(` + const assert = require('assert'); + const Module = require('module'); + const load = Module._load; + const wrongBackend = ${JSON.stringify(manifest.codec === 'brotli' ? 'zstd' : 'brotli')}; + Module._load = function(name, ...args) { + assert.ok(!name.endsWith('/compression/' + wrongBackend), 'must not load the other backend'); + return load.call(this, name, ...args); + }; + ${script} + `); + // A fresh process with a populated cache must not initialize any compression backend. + await child(` + const assert = require('assert'); + const Module = require('module'); + const load = Module._load; + Module._load = function(name, ...args) { + assert.ok(name !== 'zlib' && name !== 'node:zlib' && !name.includes('/compression/'), 'cached loads must not load any decoder backend'); + return load.call(this, name, ...args); + }; + ${script} + const { loadNativeModule } = require(${JSON.stringify(path.join(pkg, 'lib/utils'))}); + const name = process.platform === 'win32' ? 'conpty' : 'pty'; + assert.strictEqual(loadNativeModule(name), loadNativeModule(name)); + `); + // Process-local reuse must not allow a later process to trust changed files. + const cached = materializeNative(input, target); + fs.writeFileSync(path.join(cached, names[0]), Buffer.from('corrupt')); + await assert.rejects(child(script), /integrity check failed/); + }); +}); diff --git a/test/pty-smoke.js b/test/pty-smoke.js new file mode 100644 index 000000000..6ee654f5d --- /dev/null +++ b/test/pty-smoke.js @@ -0,0 +1,23 @@ +// Exercise the public API of a packed/installed package, including companion files. +const assert = require('assert'); +const path = require('path'); +const root = path.resolve(process.argv[2]); +const start = process.hrtime.bigint(); +const pty = require(root); +const loaded = process.hrtime.bigint(); +const repeatStart = process.hrtime.bigint(); +const repeated = require(root); +const repeatLoaded = process.hrtime.bigint(); +assert.strictEqual(repeated, pty); +const term = pty.spawn(process.platform === 'win32' ? 'cmd.exe' : '/bin/sh', + process.platform === 'win32' ? ['/c', 'echo packed-ok'] : ['-c', 'printf packed-ok'], + { cwd: process.cwd(), env: process.env, useConptyDll: true }); +let output = ''; +term.onData(data => output += data); +term.onExit(({ exitCode }) => { + assert.strictEqual(exitCode, 0); + assert.ok(output.includes('packed-ok'), output); + clearTimeout(timeout); + console.log(JSON.stringify({ requireMs: Number(loaded - start) / 1e6, sameProcessRequireMs: Number(repeatLoaded - repeatStart) / 1e6, smoke: 'passed' })); +}); +const timeout = setTimeout(() => { console.error('PTY smoke timed out'); process.exit(1); }, 10000);