qpdf-compress 0.6.4 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,42 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [Unreleased]
8
+
9
+ ## [0.7.0] - 2026-06-27
10
+
11
+ ### Added
12
+
13
+ - Typed error hierarchy: `QpdfError` with `QpdfFileError`, `QpdfFormatError`, and `QpdfPasswordError` subclasses for actionable compression failures
14
+ - `.nvmrc`, `.editorconfig`, and a `packageManager` field for reproducible toolchains
15
+ - `typecheck` CI step and a weekly `windows-latest` canary job that re-tests the `windows-2022` pin
16
+ - `./package.json` to the package `exports` map
17
+
18
+ ### Changed
19
+
20
+ - Raised the Node.js `engines` floor to `>=22.0.0`, dropping end-of-life Node 20
21
+ - Modernized `tsconfig`: `nodenext` module resolution, `es2023` target, and stricter type-checking (`verbatimModuleSyntax`, `exactOptionalPropertyTypes`, `noUnusedLocals`/`noUnusedParameters`, `noImplicitOverride`, `noFallthroughCasesInSwitch`)
22
+ - Reworked ESLint to type-aware linting for `lib/`, adopted the `globals` package, and dropped `eslint-plugin-prettier`; switched coverage to Vitest's v8 provider
23
+ - Bumped the native build to C++20 and Node-API 9 (C++ standard defined once via a gyp variable)
24
+ - Hardened and sped up CI/CD: pinned all GitHub Actions to commit SHAs, added npm and native-dependency caching, a concurrency group, job timeouts, and top-level least-privilege permissions
25
+ - Migrated npm publish to OIDC trusted publishing (dropped `NPM_TOKEN`)
26
+ - Switched QPDF's source download to the official byte-stable release tarball; HarfBuzz uses the SHA-256-pinned GitHub git-archive `.tar.gz` (its only release asset is `.tar.xz`, which hangs Windows `tar` during extraction)
27
+
28
+ ### Security
29
+
30
+ - Pin and verify the SHA-256 of every downloaded native dependency (`scripts/native-deps.json`) via a hardened downloader (timeout, retry, atomic writes, origin pinning), with an `npm run verify:checksums` tripwire
31
+ - Added OpenSSF Scorecard scanning, Dependabot updates, and a `SECURITY.md` policy (CodeQL code scanning runs via GitHub's default setup)
32
+
33
+ ### Fixed
34
+
35
+ - **Windows build**: upgraded node-gyp to 13 and dropped Node 20 from the CI matrix; pinned Windows CI to `windows-2022` to avoid the VS 2026 MSVC internal compiler error (C1001); fixed Windows zlib detection and addon linking
36
+
37
+ ## [0.6.4] - 2026-06-26
38
+
39
+ ### Fixed
40
+
41
+ - **Double-free on teardown**: fixed a double free of the addon instance data during Node.js environment teardown
42
+
7
43
  ## [0.6.3] - 2026-04-26
8
44
 
9
45
  ### Changed
@@ -156,3 +192,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
156
192
  - Release pipeline with prebuild generation and npm provenance
157
193
  - QEMU-based cross-compilation for Linux arm64/arm and musl variants
158
194
  - vcpkg integration for Windows static linking
195
+
196
+ [Unreleased]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.7.0...HEAD
197
+ [0.7.0]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.6.4...v0.7.0
198
+ [0.6.4]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.6.3...v0.6.4
199
+ [0.6.3]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.6.2...v0.6.3
200
+ [0.6.2]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.6.1...v0.6.2
201
+ [0.6.1]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.6.0...v0.6.1
202
+ [0.6.0]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.5.0...v0.6.0
203
+ [0.5.0]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.4.0...v0.5.0
204
+ [0.4.0]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.3.0...v0.4.0
205
+ [0.3.0]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.2.0...v0.3.0
206
+ [0.2.0]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.1.3...v0.2.0
207
+ [0.1.3]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.1.2...v0.1.3
208
+ [0.1.2]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.1.1...v0.1.2
209
+ [0.1.1]: https://github.com/xonaman/nodejs-qpdf-compress/compare/v0.1.0...v0.1.1
210
+ [0.1.0]: https://github.com/xonaman/nodejs-qpdf-compress/releases/tag/v0.1.0
package/README.md CHANGED
@@ -1,9 +1,10 @@
1
1
  # qpdf-compress
2
2
 
3
+ [![CI](https://github.com/xonaman/nodejs-qpdf-compress/actions/workflows/ci.yml/badge.svg)](https://github.com/xonaman/nodejs-qpdf-compress/actions/workflows/ci.yml)
3
4
  [![npm version](https://img.shields.io/npm/v/qpdf-compress)](https://www.npmjs.com/package/qpdf-compress)
4
5
  [![Node.js](https://img.shields.io/node/v/qpdf-compress)](https://nodejs.org)
5
6
  [![License](https://img.shields.io/npm/l/qpdf-compress)](https://github.com/xonaman/nodejs-qpdf-compress/blob/main/LICENSE)
6
- [![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-blue)]()
7
+ [![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-blue)](#-supported-platforms)
7
8
 
8
9
  Native PDF compression for Node.js — powered by [QPDF](https://qpdf.sourceforge.io/), the industry-standard PDF transformation library. Built as a C++ addon with N-API for ABI stability across Node.js versions.
9
10
 
@@ -40,7 +41,7 @@ const smaller = await compress(pdfBuffer, { lossy: true });
40
41
  **🔒 Reliability**
41
42
 
42
43
  - Built on QPDF — the most widely used PDF structural transformation library
43
- - ABI-stable via N-API — works across Node.js 18–24 without recompilation
44
+ - ABI-stable via N-API — works across Node.js 22+ without recompilation
44
45
  - Automatic repair — damaged PDFs are reconstructed during compression
45
46
  - Apache-2.0 licensed — no AGPL/copyleft concerns
46
47
 
@@ -0,0 +1,44 @@
1
+ # Third-Party Notices
2
+
3
+ `qpdf-compress` is distributed under the [Apache-2.0](./LICENSE) license. The
4
+ published package and its prebuilt native binaries statically link the
5
+ third-party libraries listed below. Each remains under its own license, and the
6
+ full license text of each is available from the upstream project linked.
7
+
8
+ | Library | Version | License | Source |
9
+ | -------- | ------------------ | --------------------------------------------------------- | -------------------------------------- |
10
+ | QPDF | 12.3.2 | Apache-2.0 | <https://github.com/qpdf/qpdf> |
11
+ | mozjpeg | 4.1.1 | BSD-3-Clause and IJG (with zlib-licensed SIMD components) | <https://github.com/mozilla/mozjpeg> |
12
+ | HarfBuzz | 14.1.0 | MIT ("Old MIT") | <https://github.com/harfbuzz/harfbuzz> |
13
+ | zlib | bundled on Windows | zlib License | <https://github.com/madler/zlib> |
14
+
15
+ ## Details
16
+
17
+ ### QPDF
18
+
19
+ Copyright Jay Berkenbilt and QPDF contributors. Licensed under the Apache
20
+ License, Version 2.0. QPDF performs the structural PDF transformation and
21
+ compression. See <https://github.com/qpdf/qpdf/blob/main/LICENSE.txt>.
22
+
23
+ ### mozjpeg
24
+
25
+ Copyright the Mozilla Foundation and contributors; derived from libjpeg-turbo
26
+ and the Independent JPEG Group (IJG) library. mozjpeg is covered by three
27
+ licenses: the IJG License, a BSD-3-Clause-style license for libjpeg-turbo
28
+ modifications, and the zlib License for the SIMD extensions. mozjpeg is used for
29
+ lossless JPEG Huffman optimization and lossy JPEG re-encoding. See
30
+ <https://github.com/mozilla/mozjpeg/blob/master/LICENSE.md>.
31
+
32
+ ### HarfBuzz
33
+
34
+ Copyright the HarfBuzz contributors. Licensed under the "Old MIT" license. Used
35
+ for TrueType/OpenType font handling and subsetting (`harfbuzz` and
36
+ `harfbuzz-subset`). See
37
+ <https://github.com/harfbuzz/harfbuzz/blob/main/COPYING>.
38
+
39
+ ### zlib
40
+
41
+ Copyright Jean-loup Gailly and Mark Adler. Licensed under the zlib License.
42
+ zlib provides DEFLATE compression and is statically linked into the Windows
43
+ build (via vcpkg); on macOS and Linux the system zlib is used at build time.
44
+ See <https://github.com/madler/zlib/blob/master/LICENSE>.
package/binding.gyp CHANGED
@@ -2,6 +2,9 @@
2
2
  "targets": [
3
3
  {
4
4
  "target_name": "qpdf_compress",
5
+ "variables": {
6
+ "cpp_std%": "c++20"
7
+ },
5
8
  "sources": [
6
9
  "src/qpdf_addon.cc",
7
10
  "src/jpeg.cc",
@@ -20,7 +23,7 @@
20
23
  "src"
21
24
  ],
22
25
  "defines": [
23
- "NAPI_VERSION=8"
26
+ "NAPI_VERSION=9"
24
27
  ],
25
28
  "cflags!": [
26
29
  "-fno-exceptions"
@@ -35,7 +38,7 @@
35
38
  "-fno-exceptions"
36
39
  ],
37
40
  "cflags_cc": [
38
- "-std=c++17",
41
+ "-std=<(cpp_std)",
39
42
  "-fvisibility=hidden"
40
43
  ],
41
44
  "conditions": [
@@ -49,7 +52,7 @@
49
52
  ],
50
53
  "xcode_settings": {
51
54
  "GCC_ENABLE_CPP_EXCEPTIONS": "YES",
52
- "CLANG_CXX_LANGUAGE_STANDARD": "c++17",
55
+ "CLANG_CXX_LANGUAGE_STANDARD": "<(cpp_std)",
53
56
  "GCC_SYMBOLS_PRIVATE_EXTERN": "YES",
54
57
  "DEAD_CODE_STRIPPING": "YES",
55
58
  "LLVM_LTO": "YES",
@@ -106,7 +109,7 @@
106
109
  "WholeProgramOptimization": "true",
107
110
  "RuntimeLibrary": 0,
108
111
  "AdditionalOptions": [
109
- "/std:c++17"
112
+ "/std:<(cpp_std)"
110
113
  ]
111
114
  },
112
115
  "VCLinkerTool": {
@@ -1,5 +1,5 @@
1
1
  import { availableParallelism } from 'node:os';
2
- import pLimit from 'p-limit';
2
+ import pLimit, {} from 'p-limit';
3
3
  let limit = pLimit(availableParallelism());
4
4
  /**
5
5
  * Gets or sets the maximum number of concurrent operations.
@@ -1 +1 @@
1
- {"version":3,"file":"concurrency.js","sourceRoot":"","sources":["../lib/concurrency.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAC;AAC/C,OAAO,MAA8B,MAAM,SAAS,CAAC;AAErD,IAAI,KAAK,GAAkB,MAAM,CAAC,oBAAoB,EAAE,CAAC,CAAC;AAE1D;;;;;GAKG;AACH,MAAM,UAAU,WAAW,CAAC,KAAc;IACxC,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;QACxB,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,KAAK,GAAG,CAAC,EAAE,CAAC;YAC1C,MAAM,IAAI,SAAS,CAAC,4CAA4C,CAAC,CAAC;QACpE,CAAC;QACD,KAAK,GAAG,MAAM,CAAC,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,oBAAoB,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;IAC/D,CAAC;IACD,OAAO,KAAK,CAAC,WAAW,CAAC;AAC3B,CAAC;AAED,MAAM,UAAU,eAAe,CAAI,EAAoB;IACrD,OAAO,KAAK,CAAC,EAAE,CAAC,CAAC;AACnB,CAAC"}
1
+ {"version":3,"file":"concurrency.js","sourceRoot":"","sources":["../lib/concurrency.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAC;AAC/C,OAAO,MAAM,EAAE,EAAsB,MAAM,SAAS,CAAC;AAErD,IAAI,KAAK,GAAkB,MAAM,CAAC,oBAAoB,EAAE,CAAC,CAAC;AAE1D;;;;;GAKG;AACH,MAAM,UAAU,WAAW,CAAC,KAAc;IACxC,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;QACxB,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,KAAK,GAAG,CAAC,EAAE,CAAC;YAC1C,MAAM,IAAI,SAAS,CAAC,4CAA4C,CAAC,CAAC;QACpE,CAAC;QACD,KAAK,GAAG,MAAM,CAAC,KAAK,KAAK,CAAC,CAAC,CAAC,CAAC,oBAAoB,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;IAC/D,CAAC;IACD,OAAO,KAAK,CAAC,WAAW,CAAC;AAC3B,CAAC;AAED,MAAM,UAAU,eAAe,CAAI,EAAoB;IACrD,OAAO,KAAK,CAAC,EAAE,CAAC,CAAC;AACnB,CAAC"}
@@ -0,0 +1,20 @@
1
+ /** Base error for all qpdf-compress operations. */
2
+ export declare class QpdfError extends Error {
3
+ readonly code: string;
4
+ constructor(code: string, message: string);
5
+ }
6
+ /** Thrown when an input or output file cannot be read or written. */
7
+ export declare class QpdfFileError extends QpdfError {
8
+ constructor(message: string);
9
+ }
10
+ /** Thrown when the input is not a valid PDF or is too damaged to recover. */
11
+ export declare class QpdfFormatError extends QpdfError {
12
+ constructor(message: string);
13
+ }
14
+ /** Thrown when the PDF is encrypted and cannot be opened without a password. */
15
+ export declare class QpdfPasswordError extends QpdfError {
16
+ constructor(message: string);
17
+ }
18
+ /** Decode the native "CODE:message" convention into a typed error. */
19
+ export declare function parseNativeError(err: unknown): QpdfError;
20
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../lib/errors.ts"],"names":[],"mappings":"AAAA,mDAAmD;AACnD,qBAAa,SAAU,SAAQ,KAAK;IAClC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;gBACV,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM;CAK1C;AAED,qEAAqE;AACrE,qBAAa,aAAc,SAAQ,SAAS;gBAC9B,OAAO,EAAE,MAAM;CAI5B;AAED,6EAA6E;AAC7E,qBAAa,eAAgB,SAAQ,SAAS;gBAChC,OAAO,EAAE,MAAM;CAI5B;AAED,gFAAgF;AAChF,qBAAa,iBAAkB,SAAQ,SAAS;gBAClC,OAAO,EAAE,MAAM;CAI5B;AAED,sEAAsE;AACtE,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,OAAO,GAAG,SAAS,CAkBxD"}
package/dist/errors.js ADDED
@@ -0,0 +1,50 @@
1
+ /** Base error for all qpdf-compress operations. */
2
+ export class QpdfError extends Error {
3
+ code;
4
+ constructor(code, message) {
5
+ super(message);
6
+ this.name = 'QpdfError';
7
+ this.code = code;
8
+ }
9
+ }
10
+ /** Thrown when an input or output file cannot be read or written. */
11
+ export class QpdfFileError extends QpdfError {
12
+ constructor(message) {
13
+ super('FILE', message);
14
+ this.name = 'QpdfFileError';
15
+ }
16
+ }
17
+ /** Thrown when the input is not a valid PDF or is too damaged to recover. */
18
+ export class QpdfFormatError extends QpdfError {
19
+ constructor(message) {
20
+ super('FORMAT', message);
21
+ this.name = 'QpdfFormatError';
22
+ }
23
+ }
24
+ /** Thrown when the PDF is encrypted and cannot be opened without a password. */
25
+ export class QpdfPasswordError extends QpdfError {
26
+ constructor(message) {
27
+ super('PASSWORD', message);
28
+ this.name = 'QpdfPasswordError';
29
+ }
30
+ }
31
+ /** Decode the native "CODE:message" convention into a typed error. */
32
+ export function parseNativeError(err) {
33
+ const msg = err instanceof Error ? err.message : String(err);
34
+ const colonIdx = msg.indexOf(':');
35
+ if (colonIdx === -1)
36
+ return new QpdfError('UNKNOWN', msg);
37
+ const code = msg.slice(0, colonIdx);
38
+ const text = msg.slice(colonIdx + 1);
39
+ switch (code) {
40
+ case 'FILE':
41
+ return new QpdfFileError(text);
42
+ case 'FORMAT':
43
+ return new QpdfFormatError(text);
44
+ case 'PASSWORD':
45
+ return new QpdfPasswordError(text);
46
+ default:
47
+ return new QpdfError(code, text);
48
+ }
49
+ }
50
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.js","sourceRoot":"","sources":["../lib/errors.ts"],"names":[],"mappings":"AAAA,mDAAmD;AACnD,MAAM,OAAO,SAAU,SAAQ,KAAK;IACzB,IAAI,CAAS;IACtB,YAAY,IAAY,EAAE,OAAe;QACvC,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,WAAW,CAAC;QACxB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACnB,CAAC;CACF;AAED,qEAAqE;AACrE,MAAM,OAAO,aAAc,SAAQ,SAAS;IAC1C,YAAY,OAAe;QACzB,KAAK,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;QACvB,IAAI,CAAC,IAAI,GAAG,eAAe,CAAC;IAC9B,CAAC;CACF;AAED,6EAA6E;AAC7E,MAAM,OAAO,eAAgB,SAAQ,SAAS;IAC5C,YAAY,OAAe;QACzB,KAAK,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC;QACzB,IAAI,CAAC,IAAI,GAAG,iBAAiB,CAAC;IAChC,CAAC;CACF;AAED,gFAAgF;AAChF,MAAM,OAAO,iBAAkB,SAAQ,SAAS;IAC9C,YAAY,OAAe;QACzB,KAAK,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;QAC3B,IAAI,CAAC,IAAI,GAAG,mBAAmB,CAAC;IAClC,CAAC;CACF;AAED,sEAAsE;AACtE,MAAM,UAAU,gBAAgB,CAAC,GAAY;IAC3C,MAAM,GAAG,GAAG,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IAC7D,MAAM,QAAQ,GAAG,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IAClC,IAAI,QAAQ,KAAK,CAAC,CAAC;QAAE,OAAO,IAAI,SAAS,CAAC,SAAS,EAAE,GAAG,CAAC,CAAC;IAE1D,MAAM,IAAI,GAAG,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAC;IACpC,MAAM,IAAI,GAAG,GAAG,CAAC,KAAK,CAAC,QAAQ,GAAG,CAAC,CAAC,CAAC;IAErC,QAAQ,IAAI,EAAE,CAAC;QACb,KAAK,MAAM;YACT,OAAO,IAAI,aAAa,CAAC,IAAI,CAAC,CAAC;QACjC,KAAK,QAAQ;YACX,OAAO,IAAI,eAAe,CAAC,IAAI,CAAC,CAAC;QACnC,KAAK,UAAU;YACb,OAAO,IAAI,iBAAiB,CAAC,IAAI,CAAC,CAAC;QACrC;YACE,OAAO,IAAI,SAAS,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;IACrC,CAAC;AACH,CAAC"}
package/dist/index.d.ts CHANGED
@@ -17,5 +17,6 @@ export declare function compress(input: PdfInput, options: CompressOptions & {
17
17
  }): Promise<void>;
18
18
  export declare function compress(input: PdfInput, options?: CompressOptions): Promise<Buffer>;
19
19
  export { concurrency } from './concurrency.js';
20
+ export { QpdfError, QpdfFileError, QpdfFormatError, QpdfPasswordError } from './errors.js';
20
21
  export type { CompressOptions } from './types.js';
21
22
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../lib/index.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,eAAe,EAAe,MAAM,YAAY,CAAC;AAY/D,KAAK,QAAQ,GAAG,MAAM,GAAG,MAAM,CAAC;AAEhC;;;;;;;;;;;GAWG;AACH,wBAAgB,QAAQ,CACtB,KAAK,EAAE,QAAQ,EACf,OAAO,EAAE,eAAe,GAAG;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GAC5C,OAAO,CAAC,IAAI,CAAC,CAAC;AACjB,wBAAgB,QAAQ,CAAC,KAAK,EAAE,QAAQ,EAAE,OAAO,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;AAuBtF,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAC/C,YAAY,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../lib/index.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,eAAe,EAAe,MAAM,YAAY,CAAC;AAc/D,KAAK,QAAQ,GAAG,MAAM,GAAG,MAAM,CAAC;AAEhC;;;;;;;;;;;GAWG;AACH,wBAAgB,QAAQ,CACtB,KAAK,EAAE,QAAQ,EACf,OAAO,EAAE,eAAe,GAAG;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GAC5C,OAAO,CAAC,IAAI,CAAC,CAAC;AACjB,wBAAgB,QAAQ,CAAC,KAAK,EAAE,QAAQ,EAAE,OAAO,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;AA2BtF,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAC/C,OAAO,EAAE,SAAS,EAAE,aAAa,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAC3F,YAAY,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC"}
package/dist/index.js CHANGED
@@ -2,12 +2,15 @@ import { createRequire } from 'node:module';
2
2
  import { dirname, resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { withConcurrency } from './concurrency.js';
5
+ import { parseNativeError } from './errors.js';
5
6
  const require = createRequire(import.meta.url);
6
7
  const __dirname = dirname(fileURLToPath(import.meta.url));
7
8
  const addonDir = resolve(__dirname, '..', 'build', 'Release');
8
9
  if (process.platform === 'win32') {
9
10
  process.env.PATH = `${addonDir};${process.env.PATH ?? ''}`;
10
11
  }
12
+ // The native addon is loaded through createRequire, so its export is `any` at
13
+ // the module boundary; assert it to the typed surface once, here.
11
14
  const addon = require('../build/Release/qpdf_compress.node');
12
15
  export async function compress(input, options) {
13
16
  if (Buffer.isBuffer(input)) {
@@ -24,11 +27,17 @@ export async function compress(input, options) {
24
27
  throw new TypeError('Input must be a Buffer or file path string');
25
28
  }
26
29
  const stripMetadata = options?.stripMetadata ?? true;
27
- return withConcurrency(() => addon.compress(input, {
28
- ...(options?.lossy ? { lossy: true } : {}),
29
- ...(stripMetadata ? { stripMetadata: true } : {}),
30
- ...(options?.output ? { output: options.output } : {}),
31
- }));
30
+ try {
31
+ return await withConcurrency(() => addon.compress(input, {
32
+ ...(options?.lossy ? { lossy: true } : {}),
33
+ ...(stripMetadata ? { stripMetadata: true } : {}),
34
+ ...(options?.output ? { output: options.output } : {}),
35
+ }));
36
+ }
37
+ catch (err) {
38
+ throw parseNativeError(err);
39
+ }
32
40
  }
33
41
  export { concurrency } from './concurrency.js';
42
+ export { QpdfError, QpdfFileError, QpdfFormatError, QpdfPasswordError } from './errors.js';
34
43
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../lib/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAGnD,MAAM,OAAO,GAAG,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAC/C,MAAM,SAAS,GAAG,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;AAE1D,MAAM,QAAQ,GAAG,OAAO,CAAC,SAAS,EAAE,IAAI,EAAE,OAAO,EAAE,SAAS,CAAC,CAAC;AAC9D,IAAI,OAAO,CAAC,QAAQ,KAAK,OAAO,EAAE,CAAC;IACjC,OAAO,CAAC,GAAG,CAAC,IAAI,GAAG,GAAG,QAAQ,IAAI,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,EAAE,EAAE,CAAC;AAC7D,CAAC;AAED,MAAM,KAAK,GAAgB,OAAO,CAAC,qCAAqC,CAAC,CAAC;AAqB1E,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,KAAe,EAAE,OAAyB;IACvE,IAAI,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;QAC3B,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACvB,MAAM,IAAI,SAAS,CAAC,8BAA8B,CAAC,CAAC;QACtD,CAAC;IACH,CAAC;SAAM,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QACrC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACvB,MAAM,IAAI,SAAS,CAAC,4BAA4B,CAAC,CAAC;QACpD,CAAC;IACH,CAAC;SAAM,CAAC;QACN,MAAM,IAAI,SAAS,CAAC,4CAA4C,CAAC,CAAC;IACpE,CAAC;IACD,MAAM,aAAa,GAAG,OAAO,EAAE,aAAa,IAAI,IAAI,CAAC;IACrD,OAAO,eAAe,CAAC,GAAG,EAAE,CAC1B,KAAK,CAAC,QAAQ,CAAC,KAAK,EAAE;QACpB,GAAG,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC1C,GAAG,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACjD,GAAG,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KACvD,CAAC,CACuB,CAAC;AAC9B,CAAC;AAED,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../lib/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACzC,OAAO,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAG/C,MAAM,OAAO,GAAG,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAC/C,MAAM,SAAS,GAAG,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;AAE1D,MAAM,QAAQ,GAAG,OAAO,CAAC,SAAS,EAAE,IAAI,EAAE,OAAO,EAAE,SAAS,CAAC,CAAC;AAC9D,IAAI,OAAO,CAAC,QAAQ,KAAK,OAAO,EAAE,CAAC;IACjC,OAAO,CAAC,GAAG,CAAC,IAAI,GAAG,GAAG,QAAQ,IAAI,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,EAAE,EAAE,CAAC;AAC7D,CAAC;AAED,8EAA8E;AAC9E,kEAAkE;AAClE,MAAM,KAAK,GAAG,OAAO,CAAC,qCAAqC,CAAgB,CAAC;AAqB5E,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,KAAe,EAAE,OAAyB;IACvE,IAAI,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;QAC3B,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACvB,MAAM,IAAI,SAAS,CAAC,8BAA8B,CAAC,CAAC;QACtD,CAAC;IACH,CAAC;SAAM,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QACrC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACvB,MAAM,IAAI,SAAS,CAAC,4BAA4B,CAAC,CAAC;QACpD,CAAC;IACH,CAAC;SAAM,CAAC;QACN,MAAM,IAAI,SAAS,CAAC,4CAA4C,CAAC,CAAC;IACpE,CAAC;IACD,MAAM,aAAa,GAAG,OAAO,EAAE,aAAa,IAAI,IAAI,CAAC;IACrD,IAAI,CAAC;QACH,OAAO,MAAM,eAAe,CAAC,GAAG,EAAE,CAChC,KAAK,CAAC,QAAQ,CAAC,KAAK,EAAE;YACpB,GAAG,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YAC1C,GAAG,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACjD,GAAG,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SACvD,CAAC,CACH,CAAC;IACJ,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,MAAM,gBAAgB,CAAC,GAAG,CAAC,CAAC;IAC9B,CAAC;AACH,CAAC;AAED,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAC/C,OAAO,EAAE,SAAS,EAAE,aAAa,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC"}
package/lib/errors.ts ADDED
@@ -0,0 +1,54 @@
1
+ /** Base error for all qpdf-compress operations. */
2
+ export class QpdfError extends Error {
3
+ readonly code: string;
4
+ constructor(code: string, message: string) {
5
+ super(message);
6
+ this.name = 'QpdfError';
7
+ this.code = code;
8
+ }
9
+ }
10
+
11
+ /** Thrown when an input or output file cannot be read or written. */
12
+ export class QpdfFileError extends QpdfError {
13
+ constructor(message: string) {
14
+ super('FILE', message);
15
+ this.name = 'QpdfFileError';
16
+ }
17
+ }
18
+
19
+ /** Thrown when the input is not a valid PDF or is too damaged to recover. */
20
+ export class QpdfFormatError extends QpdfError {
21
+ constructor(message: string) {
22
+ super('FORMAT', message);
23
+ this.name = 'QpdfFormatError';
24
+ }
25
+ }
26
+
27
+ /** Thrown when the PDF is encrypted and cannot be opened without a password. */
28
+ export class QpdfPasswordError extends QpdfError {
29
+ constructor(message: string) {
30
+ super('PASSWORD', message);
31
+ this.name = 'QpdfPasswordError';
32
+ }
33
+ }
34
+
35
+ /** Decode the native "CODE:message" convention into a typed error. */
36
+ export function parseNativeError(err: unknown): QpdfError {
37
+ const msg = err instanceof Error ? err.message : String(err);
38
+ const colonIdx = msg.indexOf(':');
39
+ if (colonIdx === -1) return new QpdfError('UNKNOWN', msg);
40
+
41
+ const code = msg.slice(0, colonIdx);
42
+ const text = msg.slice(colonIdx + 1);
43
+
44
+ switch (code) {
45
+ case 'FILE':
46
+ return new QpdfFileError(text);
47
+ case 'FORMAT':
48
+ return new QpdfFormatError(text);
49
+ case 'PASSWORD':
50
+ return new QpdfPasswordError(text);
51
+ default:
52
+ return new QpdfError(code, text);
53
+ }
54
+ }
package/lib/index.ts CHANGED
@@ -2,6 +2,7 @@ import { createRequire } from 'node:module';
2
2
  import { dirname, resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { withConcurrency } from './concurrency.js';
5
+ import { parseNativeError } from './errors.js';
5
6
  import type { CompressOptions, NativeAddon } from './types.js';
6
7
 
7
8
  const require = createRequire(import.meta.url);
@@ -12,7 +13,9 @@ if (process.platform === 'win32') {
12
13
  process.env.PATH = `${addonDir};${process.env.PATH ?? ''}`;
13
14
  }
14
15
 
15
- const addon: NativeAddon = require('../build/Release/qpdf_compress.node');
16
+ // The native addon is loaded through createRequire, so its export is `any` at
17
+ // the module boundary; assert it to the typed surface once, here.
18
+ const addon = require('../build/Release/qpdf_compress.node') as NativeAddon;
16
19
 
17
20
  type PdfInput = Buffer | string;
18
21
 
@@ -46,14 +49,19 @@ export async function compress(input: PdfInput, options?: CompressOptions): Prom
46
49
  throw new TypeError('Input must be a Buffer or file path string');
47
50
  }
48
51
  const stripMetadata = options?.stripMetadata ?? true;
49
- return withConcurrency(() =>
50
- addon.compress(input, {
51
- ...(options?.lossy ? { lossy: true } : {}),
52
- ...(stripMetadata ? { stripMetadata: true } : {}),
53
- ...(options?.output ? { output: options.output } : {}),
54
- }),
55
- ) as Promise<Buffer | void>;
52
+ try {
53
+ return await withConcurrency(() =>
54
+ addon.compress(input, {
55
+ ...(options?.lossy ? { lossy: true } : {}),
56
+ ...(stripMetadata ? { stripMetadata: true } : {}),
57
+ ...(options?.output ? { output: options.output } : {}),
58
+ }),
59
+ );
60
+ } catch (err) {
61
+ throw parseNativeError(err);
62
+ }
56
63
  }
57
64
 
58
65
  export { concurrency } from './concurrency.js';
66
+ export { QpdfError, QpdfFileError, QpdfFormatError, QpdfPasswordError } from './errors.js';
59
67
  export type { CompressOptions } from './types.js';
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "qpdf-compress",
3
- "version": "0.6.4",
3
+ "version": "0.7.0",
4
4
  "description": "Native PDF compression for Node.js, powered by QPDF",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
7
+ "packageManager": "npm@11.17.0",
7
8
  "engines": {
8
- "node": ">=20.11.0"
9
+ "node": ">=22.0.0"
9
10
  },
10
11
  "keywords": [
11
12
  "pdf",
@@ -31,7 +32,8 @@
31
32
  ".": {
32
33
  "types": "./dist/index.d.ts",
33
34
  "default": "./dist/index.js"
34
- }
35
+ },
36
+ "./package.json": "./package.json"
35
37
  },
36
38
  "files": [
37
39
  "dist",
@@ -41,6 +43,7 @@
41
43
  "binding.gyp",
42
44
  "README.md",
43
45
  "CHANGELOG.md",
46
+ "THIRD-PARTY-NOTICES.md",
44
47
  "LICENSE"
45
48
  ],
46
49
  "repository": {
@@ -73,7 +76,10 @@
73
76
  "download:mozjpeg": "node scripts/download-mozjpeg.mjs",
74
77
  "download:qpdf": "node scripts/download-qpdf.mjs",
75
78
  "download:harfbuzz": "node scripts/download-harfbuzz.mjs",
79
+ "verify:checksums": "node scripts/verify-checksums.mjs",
76
80
  "test": "vitest run",
81
+ "test:coverage": "vitest run --coverage",
82
+ "typecheck": "tsc --noEmit",
77
83
  "lint": "eslint --fix .",
78
84
  "lint:check": "eslint .",
79
85
  "format": "prettier --write .",
@@ -81,8 +87,8 @@
81
87
  "prepare": "husky && tsc"
82
88
  },
83
89
  "lint-staged": {
84
- "*.{ts,js,json,md,yaml,yml}": "prettier --write",
85
- "*.{ts,js}": "eslint --max-warnings 0"
90
+ "*.{ts,js,mjs,cjs,json,md,yaml,yml}": "prettier --write",
91
+ "*.{ts,js,mjs,cjs}": "eslint --max-warnings 0"
86
92
  },
87
93
  "dependencies": {
88
94
  "node-addon-api": "^8.0.0",
@@ -91,15 +97,16 @@
91
97
  "devDependencies": {
92
98
  "@eslint/js": "^10.0.1",
93
99
  "@types/node": "^25.5.0",
100
+ "@vitest/coverage-v8": "^4.1.4",
94
101
  "eslint": "^10.1.0",
95
102
  "eslint-config-prettier": "^10.1.8",
96
- "eslint-plugin-prettier": "^5.5.5",
103
+ "globals": "^17.0.0",
97
104
  "husky": "^9.1.7",
98
105
  "lint-staged": "^16.4.0",
99
106
  "node-gyp": "^13.0.0",
100
107
  "prettier": "^3.8.1",
101
108
  "typescript": "^5.9.3",
102
109
  "typescript-eslint": "^8.57.2",
103
- "vitest": "^4.1.2"
110
+ "vitest": "^4.1.4"
104
111
  }
105
112
  }
@@ -1,11 +1,10 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { createWriteStream, mkdirSync, existsSync, rmSync, cpSync, readdirSync } from 'node:fs';
3
- import { pipeline } from 'node:stream/promises';
4
- import { Readable } from 'node:stream';
2
+ import { mkdirSync, existsSync, rmSync, cpSync, readdirSync } from 'node:fs';
5
3
  import { join } from 'node:path';
4
+ import { downloadFile, extractTarball, loadManifest } from './lib/integrity.mjs';
6
5
 
7
- const HARFBUZZ_VERSION = '14.1.0';
8
- const BASE_URL = 'https://github.com/harfbuzz/harfbuzz/archive/refs/tags';
6
+ const { harfbuzz: dep } = loadManifest();
7
+ const HARFBUZZ_VERSION = dep.version;
9
8
 
10
9
  const root = join(import.meta.dirname, '..');
11
10
  const depsDir = join(root, 'deps', 'harfbuzz');
@@ -21,27 +20,19 @@ if (!/^\d+\.\d+\.\d+$/.test(HARFBUZZ_VERSION)) {
21
20
  process.exit(1);
22
21
  }
23
22
 
24
- const url = `${BASE_URL}/${HARFBUZZ_VERSION}.tar.gz`;
25
23
  const tarball = join(root, `harfbuzz-${HARFBUZZ_VERSION}.tar.gz`);
26
24
  const srcDir = join(root, `harfbuzz-${HARFBUZZ_VERSION}`);
27
25
  const buildDir = join(root, 'build-harfbuzz');
28
26
 
29
- // step 1: download
27
+ // step 1: download (origin- and SHA-256-pinned via native-deps.json)
30
28
  console.log(`Downloading HarfBuzz ${HARFBUZZ_VERSION}...`);
31
- console.log(`URL: ${url}`);
32
-
33
- const response = await fetch(url, { redirect: 'follow' });
34
- if (!response.ok) {
35
- console.error(`Download failed: ${response.status} ${response.statusText}`);
36
- process.exit(1);
37
- }
29
+ console.log(`URL: ${dep.url}`);
38
30
 
39
31
  mkdirSync(join(root, 'deps'), { recursive: true });
40
- await pipeline(Readable.fromWeb(response.body), createWriteStream(tarball));
41
-
42
- console.log('Extracting...');
43
- execFileSync('tar', ['-xzf', tarball, '-C', root], { stdio: 'inherit' });
44
- rmSync(tarball);
32
+ await downloadFile(dep.url, tarball, { expectedSha256: dep.sha256, allowedOrigin: dep.origin });
33
+ console.log('Checksum verified. Extracting...');
34
+ extractTarball(tarball, root);
35
+ rmSync(tarball, { force: true });
45
36
 
46
37
  // GitHub archive tarballs extract to `harfbuzz-{tag}` — find it
47
38
  if (!existsSync(srcDir)) {
@@ -1,11 +1,10 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { createWriteStream, mkdirSync, existsSync, rmSync, readdirSync } from 'node:fs';
3
- import { pipeline } from 'node:stream/promises';
4
- import { Readable } from 'node:stream';
2
+ import { mkdirSync, existsSync, rmSync, readdirSync } from 'node:fs';
5
3
  import { join } from 'node:path';
4
+ import { downloadFile, extractTarball, loadManifest } from './lib/integrity.mjs';
6
5
 
7
- const MOZJPEG_VERSION = '4.1.1';
8
- const BASE_URL = 'https://github.com/mozilla/mozjpeg/archive/refs/tags';
6
+ const { mozjpeg: dep } = loadManifest();
7
+ const MOZJPEG_VERSION = dep.version;
9
8
 
10
9
  const root = join(import.meta.dirname, '..');
11
10
  const depsDir = join(root, 'deps', 'mozjpeg');
@@ -21,27 +20,19 @@ if (!/^\d+\.\d+\.\d+$/.test(MOZJPEG_VERSION)) {
21
20
  process.exit(1);
22
21
  }
23
22
 
24
- const url = `${BASE_URL}/v${MOZJPEG_VERSION}.tar.gz`;
25
23
  const tarball = join(root, `mozjpeg-${MOZJPEG_VERSION}.tar.gz`);
26
24
  const srcDir = join(root, `mozjpeg-${MOZJPEG_VERSION}`);
27
25
  const buildDir = join(root, 'build-mozjpeg');
28
26
 
29
- // step 1: download
27
+ // step 1: download (origin- and SHA-256-pinned via native-deps.json)
30
28
  console.log(`Downloading mozjpeg ${MOZJPEG_VERSION}...`);
31
- console.log(`URL: ${url}`);
32
-
33
- const response = await fetch(url, { redirect: 'follow' });
34
- if (!response.ok) {
35
- console.error(`Download failed: ${response.status} ${response.statusText}`);
36
- process.exit(1);
37
- }
29
+ console.log(`URL: ${dep.url}`);
38
30
 
39
31
  mkdirSync(join(root, 'deps'), { recursive: true });
40
- await pipeline(Readable.fromWeb(response.body), createWriteStream(tarball));
41
-
42
- console.log('Extracting...');
43
- execFileSync('tar', ['-xzf', tarball, '-C', root], { stdio: 'inherit' });
44
- rmSync(tarball);
32
+ await downloadFile(dep.url, tarball, { expectedSha256: dep.sha256, allowedOrigin: dep.origin });
33
+ console.log('Checksum verified. Extracting...');
34
+ extractTarball(tarball, root);
35
+ rmSync(tarball, { force: true });
45
36
 
46
37
  // GitHub archive tarballs extract to `mozjpeg-{tag}` — find the directory
47
38
  if (!existsSync(srcDir)) {
@@ -1,11 +1,10 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { createWriteStream, mkdirSync, existsSync, rmSync, cpSync, readdirSync } from 'node:fs';
3
- import { pipeline } from 'node:stream/promises';
4
- import { Readable } from 'node:stream';
2
+ import { mkdirSync, existsSync, rmSync, cpSync, readdirSync } from 'node:fs';
5
3
  import { join } from 'node:path';
4
+ import { downloadFile, extractTarball, loadManifest } from './lib/integrity.mjs';
6
5
 
7
- const QPDF_VERSION = '12.3.2';
8
- const BASE_URL = 'https://github.com/qpdf/qpdf/archive/refs/tags';
6
+ const { qpdf: dep } = loadManifest();
7
+ const QPDF_VERSION = dep.version;
9
8
 
10
9
  const root = join(import.meta.dirname, '..');
11
10
  const depsDir = join(root, 'deps', 'qpdf');
@@ -21,27 +20,19 @@ if (!/^\d+\.\d+\.\d+$/.test(QPDF_VERSION)) {
21
20
  process.exit(1);
22
21
  }
23
22
 
24
- const url = `${BASE_URL}/v${QPDF_VERSION}.tar.gz`;
25
23
  const tarball = join(root, `qpdf-${QPDF_VERSION}.tar.gz`);
26
24
  const srcDir = join(root, `qpdf-${QPDF_VERSION}`);
27
25
  const buildDir = join(root, 'build-qpdf');
28
26
 
29
- // step 1: download
27
+ // step 1: download (origin- and SHA-256-pinned via native-deps.json)
30
28
  console.log(`Downloading QPDF ${QPDF_VERSION}...`);
31
- console.log(`URL: ${url}`);
32
-
33
- const response = await fetch(url, { redirect: 'follow' });
34
- if (!response.ok) {
35
- console.error(`Download failed: ${response.status} ${response.statusText}`);
36
- process.exit(1);
37
- }
29
+ console.log(`URL: ${dep.url}`);
38
30
 
39
31
  mkdirSync(join(root, 'deps'), { recursive: true });
40
- await pipeline(Readable.fromWeb(response.body), createWriteStream(tarball));
41
-
42
- console.log('Extracting...');
43
- execFileSync('tar', ['-xzf', tarball, '-C', root], { stdio: 'inherit' });
44
- rmSync(tarball);
32
+ await downloadFile(dep.url, tarball, { expectedSha256: dep.sha256, allowedOrigin: dep.origin });
33
+ console.log('Checksum verified. Extracting...');
34
+ extractTarball(tarball, root);
35
+ rmSync(tarball, { force: true });
45
36
 
46
37
  // GitHub archive tarballs extract to `qpdf-{tag}` (without 'v' prefix)
47
38
  // but may also be `qpdf-qpdf-{ver}` depending on repo naming — find it
@@ -6,11 +6,12 @@
6
6
  * Musl (Alpine) naming: qpdf-compress-v{version}-linux-musl-{arch}.tar.gz
7
7
  * Contents: build/Release/qpdf_compress.node
8
8
  */
9
- import { execFileSync, execSync } from 'node:child_process';
9
+ import { execSync } from 'node:child_process';
10
10
  import { createWriteStream, existsSync, mkdirSync, readFileSync, unlinkSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import { Readable } from 'node:stream';
13
13
  import { pipeline } from 'node:stream/promises';
14
+ import { extractTarball } from './lib/integrity.mjs';
14
15
 
15
16
  const root = join(import.meta.dirname, '..');
16
17
  const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
@@ -57,7 +58,7 @@ async function tryDownload() {
57
58
  if (releaseUrl.origin !== releaseOrigin) {
58
59
  throw new Error(`Unexpected URL origin: ${releaseUrl.origin}`);
59
60
  }
60
- const res = await fetch(releaseUrl, { redirect: 'follow' });
61
+ const res = await fetch(releaseUrl, { redirect: 'follow', signal: AbortSignal.timeout(60000) });
61
62
  if (!res.ok) {
62
63
  console.log(`No prebuilt binary found (HTTP ${res.status}), will compile from source.`);
63
64
  return false;
@@ -73,8 +74,8 @@ async function tryDownload() {
73
74
 
74
75
  await pipeline(Readable.fromWeb(body), fileStream);
75
76
 
76
- // extract tar.gz into project root
77
- execFileSync('tar', ['xzf', tmpTar, '-C', root], { stdio: 'inherit' });
77
+ // extract tar.gz into project root (hardened: no -P, no archived ownership)
78
+ extractTarball(tmpTar, root);
78
79
  unlinkSync(tmpTar);
79
80
 
80
81
  // verify the .node file exists
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Shared download/verify helpers for the install and dependency-build scripts.
3
+ *
4
+ * Goals:
5
+ * - never trust the network blindly: verify a pinned SHA-256 before use,
6
+ * - survive flaky connections: timeout + exponential-backoff retry,
7
+ * - never leave a half-written file behind: download to `<dest>.part` and
8
+ * rename atomically only after the checksum passes,
9
+ * - extract archives without restoring archived ownership or honouring
10
+ * absolute/`..` paths (tar's default, kept by NOT passing -P).
11
+ */
12
+ import { execFileSync } from 'node:child_process';
13
+ import { createHash } from 'node:crypto';
14
+ import {
15
+ createReadStream,
16
+ createWriteStream,
17
+ mkdirSync,
18
+ readFileSync,
19
+ rmSync,
20
+ renameSync,
21
+ } from 'node:fs';
22
+ import { dirname, join } from 'node:path';
23
+ import { Readable } from 'node:stream';
24
+ import { pipeline } from 'node:stream/promises';
25
+
26
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
27
+
28
+ /** Compute the SHA-256 of a file as a lowercase hex string. */
29
+ export async function sha256File(path) {
30
+ const hash = createHash('sha256');
31
+ await pipeline(createReadStream(path), hash);
32
+ return hash.digest('hex');
33
+ }
34
+
35
+ /** Read the native-dependency manifest (scripts/native-deps.json). */
36
+ export function loadManifest() {
37
+ return JSON.parse(readFileSync(join(import.meta.dirname, '..', 'native-deps.json'), 'utf8'));
38
+ }
39
+
40
+ /**
41
+ * Download `url` to `dest` with a per-attempt timeout and exponential-backoff
42
+ * retry. Writes to `<dest>.part` (same filesystem, so the rename is atomic) and
43
+ * only promotes it once the optional `expectedSha256` matches. A checksum
44
+ * mismatch or unexpected origin fails immediately (not retried).
45
+ *
46
+ * @param {string} url
47
+ * @param {string} dest
48
+ * @param {{ expectedSha256?: string, allowedOrigin?: string, timeoutMs?: number, retries?: number }} [opts]
49
+ */
50
+ export async function downloadFile(url, dest, opts = {}) {
51
+ const { expectedSha256, allowedOrigin, timeoutMs = 60000, retries = 4 } = opts;
52
+
53
+ const u = new URL(url);
54
+ if (allowedOrigin && u.origin !== allowedOrigin) {
55
+ throw new Error(`Refusing to download from unexpected origin: ${u.origin}`);
56
+ }
57
+
58
+ mkdirSync(dirname(dest), { recursive: true });
59
+ const tmp = `${dest}.part`;
60
+
61
+ let lastErr;
62
+ for (let attempt = 1; attempt <= retries; attempt++) {
63
+ try {
64
+ const res = await fetch(u, { redirect: 'follow', signal: AbortSignal.timeout(timeoutMs) });
65
+ if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
66
+ if (!res.body) throw new Error('empty response body');
67
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(tmp));
68
+
69
+ if (expectedSha256) {
70
+ const actual = await sha256File(tmp);
71
+ if (actual !== expectedSha256.toLowerCase()) {
72
+ throw new Error(
73
+ `checksum mismatch for ${u.href}\n expected ${expectedSha256}\n actual ${actual}`,
74
+ );
75
+ }
76
+ }
77
+
78
+ renameSync(tmp, dest);
79
+ return dest;
80
+ } catch (err) {
81
+ lastErr = err;
82
+ rmSync(tmp, { force: true });
83
+ const msg = String(err?.message ?? err);
84
+ // integrity failures are not transient — surface them immediately
85
+ if (msg.includes('checksum mismatch') || msg.includes('unexpected origin')) throw err;
86
+ if (attempt < retries) {
87
+ const backoff = Math.min(1000 * 2 ** (attempt - 1), 8000);
88
+ console.warn(` attempt ${attempt}/${retries} failed (${msg}); retrying in ${backoff}ms`);
89
+ await sleep(backoff);
90
+ }
91
+ }
92
+ }
93
+ throw new Error(`Failed to download ${u.href} after ${retries} attempts: ${lastErr?.message}`);
94
+ }
95
+
96
+ /**
97
+ * Extract a .tar.gz / .tar.xz into `destDir`. Compression is auto-detected.
98
+ * Not passing `-P` keeps tar's default refusal of absolute and `..` paths;
99
+ * `--no-same-owner` avoids restoring archived uid/gid.
100
+ *
101
+ * stdin is redirected from /dev/null: tar never reads it for `-xf`, and on
102
+ * Windows an inherited console stdin can deadlock a spawned decompressor.
103
+ */
104
+ export function extractTarball(tarball, destDir, { strip } = {}) {
105
+ mkdirSync(destDir, { recursive: true });
106
+ const args = ['-xf', tarball, '-C', destDir, '--no-same-owner'];
107
+ if (strip) args.push('--strip-components', String(strip));
108
+ execFileSync('tar', args, { stdio: ['ignore', 'inherit', 'inherit'] });
109
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "qpdf": {
3
+ "version": "12.3.2",
4
+ "origin": "https://github.com",
5
+ "url": "https://github.com/qpdf/qpdf/releases/download/v12.3.2/qpdf-12.3.2.tar.gz",
6
+ "sha256": "6cba2f9f2cd887d905faeb99e0e51a307b217920d1bbf3e9cfbb2e8178a2deda",
7
+ "checksumSource": "official PGP-signed release manifest (qpdf-12.3.2.sha256)"
8
+ },
9
+ "harfbuzz": {
10
+ "version": "14.1.0",
11
+ "origin": "https://github.com",
12
+ "url": "https://github.com/harfbuzz/harfbuzz/archive/refs/tags/14.1.0.tar.gz",
13
+ "sha256": "9deffe9dded3c367c7d08265fd0a64193fad505a362a4889d898568c1cd46812",
14
+ "checksumSource": "self-computed from the GitHub git-archive tarball; HarfBuzz's only release asset is .tar.xz, whose extraction hangs Windows bsdtar — the gzip git-archive builds identically"
15
+ },
16
+ "mozjpeg": {
17
+ "version": "4.1.1",
18
+ "origin": "https://github.com",
19
+ "url": "https://github.com/mozilla/mozjpeg/archive/refs/tags/v4.1.1.tar.gz",
20
+ "sha256": "66b1b8d6b55d263f35f27f55acaaa3234df2a401232de99b6d099e2bb0a9d196",
21
+ "checksumSource": "self-computed from the GitHub archive tarball (mozilla/mozjpeg publishes no release asset or checksum)"
22
+ }
23
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Verify that the SHA-256 hashes pinned in native-deps.json still match what
3
+ * the upstream URLs serve. Use after a version bump, or in CI as a supply-chain
4
+ * tripwire. By default only this platform's artifacts are checked; pass --all
5
+ * to verify every pinned target (large download for prebuilt-binary deps).
6
+ */
7
+ import { rmSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { downloadFile, loadManifest } from './lib/integrity.mjs';
11
+
12
+ const all = process.argv.includes('--all');
13
+ const manifest = loadManifest();
14
+ const platform = process.platform;
15
+ const arch = process.arch;
16
+
17
+ const checks = [];
18
+ for (const [name, dep] of Object.entries(manifest)) {
19
+ if (dep.targets) {
20
+ for (const [key, t] of Object.entries(dep.targets)) {
21
+ if (!all && key !== `${platform}-${arch}` && key !== `${platform}-musl-${arch}`) continue;
22
+ checks.push({
23
+ label: `${name} ${key}`,
24
+ url: `${dep.baseUrl}/${encodeURIComponent(dep.version)}/${t.asset}`,
25
+ sha256: t.sha256,
26
+ origin: dep.origin,
27
+ });
28
+ }
29
+ } else {
30
+ checks.push({
31
+ label: `${name} ${dep.version}`,
32
+ url: dep.url,
33
+ sha256: dep.sha256,
34
+ origin: dep.origin,
35
+ });
36
+ }
37
+ }
38
+
39
+ let failed = 0;
40
+ for (const c of checks) {
41
+ const tmp = join(tmpdir(), `verify-${c.label.replace(/\W+/g, '-')}`);
42
+ try {
43
+ process.stdout.write(`Verifying ${c.label} ... `);
44
+ await downloadFile(c.url, tmp, { expectedSha256: c.sha256, allowedOrigin: c.origin });
45
+ console.log('OK');
46
+ } catch (err) {
47
+ console.log(`FAIL\n ${err.message}`);
48
+ failed++;
49
+ } finally {
50
+ rmSync(tmp, { force: true });
51
+ }
52
+ }
53
+
54
+ if (failed) {
55
+ console.error(`\n${failed} checksum(s) did not match.`);
56
+ process.exit(1);
57
+ }
58
+ console.log(`\nAll ${checks.length} checksum(s) verified.`);
package/src/qpdf_addon.cc CHANGED
@@ -1,6 +1,8 @@
1
1
  #include <napi.h>
2
2
 
3
+ #include <algorithm>
3
4
  #include <atomic>
5
+ #include <cctype>
4
6
  #include <cerrno>
5
7
  #include <cstdio>
6
8
  #include <cstdlib>
@@ -52,6 +54,28 @@ static std::string writeToFile(const std::string &path, const uint8_t *data,
52
54
  return {};
53
55
  }
54
56
 
57
+ // ---------------------------------------------------------------------------
58
+ // Error classification — prefix a native error message with a stable CODE so
59
+ // the JS layer (parseNativeError) can map it to a typed QpdfError subclass.
60
+ // Mirrors the "CODE:message" convention used by pdfium-native.
61
+ // ---------------------------------------------------------------------------
62
+
63
+ static std::string classifyError(const std::string &what) {
64
+ std::string lower = what;
65
+ std::transform(lower.begin(), lower.end(), lower.begin(),
66
+ [](unsigned char c) { return std::tolower(c); });
67
+ auto has = [&](const char *needle) {
68
+ return lower.find(needle) != std::string::npos;
69
+ };
70
+ if (has("password") || has("encrypted"))
71
+ return "PASSWORD:" + what;
72
+ if (has("damaged") || has("not a pdf") || has("not well-formed") ||
73
+ has("invalid pdf") || has("corrupt") || has("xref") ||
74
+ has("unable to find") || has("/root"))
75
+ return "FORMAT:" + what;
76
+ return "COMPRESS:" + what;
77
+ }
78
+
55
79
  // ---------------------------------------------------------------------------
56
80
  // Per-environment alive flag for worker thread safety.
57
81
  // When a Node.js worker thread is terminated, the V8 isolate tears down but
@@ -125,7 +149,7 @@ protected:
125
149
 
126
150
  if (useFile_) {
127
151
  if (access(filePath_.c_str(), F_OK) != 0) {
128
- SetError("Input file not found: " + filePath_);
152
+ SetError("FILE:Input file not found: " + filePath_);
129
153
  return;
130
154
  }
131
155
  qpdf->processFile(filePath_.c_str());
@@ -192,7 +216,7 @@ protected:
192
216
  auto err = writeToFile(outputPath_, writerBuf_->getBuffer(),
193
217
  writerBuf_->getSize());
194
218
  if (!err.empty()) {
195
- SetError(err);
219
+ SetError("FILE:" + err);
196
220
  return;
197
221
  }
198
222
  writerBuf_.reset();
@@ -204,7 +228,7 @@ protected:
204
228
  qpdf.reset();
205
229
  } catch (...) {
206
230
  }
207
- SetError(e.what());
231
+ SetError(classifyError(e.what()));
208
232
  } catch (...) {
209
233
  // on macOS/libc++, catch(std::exception&) may fail to match QPDFExc
210
234
  // due to typeinfo visibility mismatch between the static library and
@@ -213,7 +237,7 @@ protected:
213
237
  qpdf.reset();
214
238
  } catch (...) {
215
239
  }
216
- SetError("PDF compression failed");
240
+ SetError("UNKNOWN:PDF compression failed");
217
241
  }
218
242
  }
219
243