feat: AbortSignal support, ENCRYPTED + ABORTED codes, bench, ci#1
Merged
Conversation
Extend the discriminant union so callers can branch on encrypted PDFs and aborted operations distinctly from generic PARSE_FAILED. Surface expands additively; existing INVALID_INPUT and PARSE_FAILED keep the same semantics. BREAKING CHANGE: encrypted PDFs now throw code: 'ENCRYPTED' instead of 'PARSE_FAILED'. Surfaced in a subsequent commit when clean() starts detecting them.
Load with ignoreEncryption: true so pdf-lib does not throw on the
/Encrypt trailer entry, then inspect doc.isEncrypted and raise
CleanError('ENCRYPTED', ...) explicitly. Callers get an actionable
code without having to string-match on the parser error message.
The fixtures helper grows an encrypted: true option that injects a
stub /Encrypt dict into the trailer so the test suite can exercise
the new path without committing a binary encrypted PDF.
Add a signal: AbortSignal field to CleanOptions and check it at three
checkpoints: before pdf-lib load, after load, after the strip phase.
Aborts throw CleanError('ABORTED', ...) with cause = signal.reason.
The cancellation is cooperative between phases only. pdf-lib's
PDFDocument.load and PDFDocument.save run synchronously to completion
once entered; multi-MB PDFs that cancel mid-phase will only observe
the abort at the next checkpoint. Documented as a limitation in the
follow-up README pass.
Five invariants asserted by fast-check over random PDF inputs (pages 1-3, links 0-10 per page, title + author metadata): - output parses back as a valid PDF - default clean leaves zero /Link annotations on every page - default clean wipes Info dict metadata - keepLinks + keepMetadata preserves the inspectable surface - cleaning is idempotent (clean(clean(x)) matches clean(x)) 30 runs per property, 150 generated PDFs total, runs in under 500 ms.
bench/pdf-cleaner.bench.mjs builds each fixture once at startup and runs three cases per bucket: raw pdf-lib load+save (the floor), clean with keepLinks + keepMetadata (the wrapper overhead), and default clean (full strip). The decomposition makes it obvious where the time goes on each input shape. bench/baseline.md captures the 1.0.0 numbers on Apple Silicon arm64 Node 22.22.2, the bundle sizes, the explanation for default beating the floor on link-heavy buckets (smaller output to serialize), and the going-forward regression budget of 10 percent per bucket at fixed feature set. Add mitata to devDeps and a bench script that builds first so the bench imports the latest dist.
…e.md CleanOptions table grows a signal row noting where the abort is checked and the limitation that cancellation is non-cooperative inside pdf-lib. CleanErrorCode shows the four-code union. The Throws block on clean(input, options?) covers all four codes with their cause semantics, and a new Notes line links bench/baseline.md. Examples gain a third snippet using AbortSignal.timeout(). Errors table splits ENCRYPTED out of PARSE_FAILED and adds ABORTED. Limitations section points encrypted PDFs at the new ENCRYPTED code instead of PARSE_FAILED.
Tech Stack adds fast-check and mitata; Commands grows pnpm bench; Important Files lists cli-runner.ts, the property test, and the bench files. Public API contract updates to reflect signal, the four-code CleanErrorCode union, and the Buffer-via-structural-compatibility note. A new additive-only rule freezes removals from CleanOptions, CleanErrorCode, and the CLI flag set. The regression budget rule fires on src/clean.ts changes. The Publish auth rule documents the token-bootstrap to OIDC handoff so the post-1.0.0 chore(ci) PR has the rule it removes the token under. Git bullet kept verbatim — release machinery lives in coroboros/ci.
Verbatim copy of packages/sparkline/.github/workflows/ci.yml. The reusable workflow gates internally: preflight (branch/PR) runs lint + build + test; publish (tag) verifies the tag SHA matches main HEAD, pins package.json to the tag, generates the CHANGELOG section, publishes, creates the GitHub release, and rolls the major tag. security runs on every call. The bootstrap shape forwards five secrets: NPM_CONFIG_FILE, NPM_EXTRA_CONFIG, NPM_PACKAGE_REGISTRY, NPM_PACKAGE_PROXY_REGISTRY, NPM_PACKAGE_REGISTRY_TOKEN. The reusable workflow auto-detects the token and publishes the first 1.0.0 via the org token. A follow-up chore(ci) PR drops NPM_PACKAGE_REGISTRY_TOKEN + NPM_EXTRA_CONFIG once the npm-side Trusted Publisher is configured, at which point the workflow takes the OIDC + provenance path for 1.0.1+.
Auto-fixes from biome check --write: - bench/pdf-cleaner.bench.mjs — alphabetize imports (mitata before pdf-lib) - src/clean.ts — collapse the ENCRYPTED throw to a single line - tests/clean.property.test.ts — inline the flatMap chain No behavior changes.
Reshape to mirror packages/location-timezone (and packages/clone) end to end so it reads as part of the same set: - Centered header div + tagline + lead paragraph (no logo asset yet). - Tagline switched to the em-dash form so it matches package.json description and the GitHub repo About verbatim: "Strip metadata and links from PDFs locally — no upload, no tracking." - Install reduced to the four package-manager add commands only. - New Usage section split between CLI and Programmatic subheaders; CLI examples and exit codes live here, not under Install. - Why this exists replaces the old Privacy block, sits between Usage and API, and links bench/baseline.md. - API section gains the missing Cleaning subheader so Types and the clean() function are in separate H3 groups (was wrongly nested inside Types). Errors stays as ### Errors after Cleaning. - Contributing keeps a pnpm bench note now that the bench exists.
The shared workspace logo `assets/logo-gold.png` is the canonical mark across `@coroboros/*` packages. Drop it into the package's own `assets/` and surface it above the H1 in the centered div, mirroring the shape already used by `@coroboros/location-timezone`.
`private: false` is npm's default. `preferGlobal` is deprecated. `analyze` is not an npm field at all. None of the three are honored by npm or pnpm, and none appear in the sibling `@coroboros/*` packages. Carry-overs from the migration template that the optim pass should have caught.
The existing signal tests only exercised the pre-load checkpoint (already-aborted case) and `signal.reason` preservation. Add two tests that schedule `controller.abort(reason)` via nested `queueMicrotask` calls so the abort lands between the after-load checkpoint (line 85) and the after-strip checkpoint (line 98). This verifies checkpoint placement, not pdf-lib mid-operation interruption — pdf-lib's `load` and `save` are non-cooperative once entered. Five back-to-back local runs returned 57/57 each time, so the microtask scheduling is deterministic enough to ship.
The encrypted fixture injects a stub `/Encrypt` dict and trusted `doc.isEncrypted` to read it back. If pdf-lib ever refactors the trailer-reading path, the fixture would still emit valid PDF bytes and the ENCRYPTED-code test would pass spuriously — the `/Encrypt` would just be ignored. Re-load the saved bytes once with `ignoreEncryption: true` and throw if `isEncrypted === false`. Cheap, runs only when the encrypted branch is taken, and makes the fixture loudly fail instead of quietly lying.
The existing invariants verify parseability, link removal, metadata removal, both-options preservation, and idempotence on the inspectable surface. None catch the failure mode where `clean()` keeps the input bytes around verbatim (e.g. a future change that forgets to call `doc.save()` and accidentally returns the source). Add a 6th invariant: when the fixture has both metadata and at least one link, the cleaned output is strictly smaller than the source. `fc.pre()` filters out the no-link cases — bench fixtures without strippable content can produce same-size or slightly larger outputs after pdf-lib re-serializes.
`subtype === LINK` relied on `pdf-lib@^1.17.1`'s internal global
pool returning the same `PDFName` instance for `PDFName.of('Link')`
across the parser, the strip loop, and the surface inspector. The
pool is real (`PDFName.js` line 100), so identity worked — but
pdf-lib's type surface does not document or guarantee it, and a
pdf-lib 2.x refactor could split the pool per-document or per-tree.
Switch to value comparison via `subtype?.toString() === '/Link'`.
One extra string format per annotation iteration. Bench across the
five fixture buckets shows the worst delta on any `clean` path is
+3.4 % on `large-100pages` — within the 10 % regression budget,
and inside the environmental noise floor (the raw `pdf-lib
(load+save)` floor on that bucket drifted +5.9 % in the same run).
Test helpers in `clean.test.ts` and `clean.property.test.ts` get
the same change so the surface inspectors stay aligned with the
production code.
Both `appendAnnotation` helpers replace the entire `/Annots` array when the existing ref does not resolve to a `PDFArray`. Safe in fresh in-memory fixtures (every ref is built on the spot and resolves), lossy if the helper ever gets pointed at a real-world PDF whose `/Annots` is an indirect reference to something other than an array. One-line comment in both call sites surfaces the constraint without widening the diff.
`toUint8Array` returns the input Uint8Array directly when it is already a Uint8Array — no copy. Two concurrent `clean()` calls therefore share the buffer. pdf-lib treats input as read-only, so this is safe in practice, but nothing in the suite proved it. `Promise.all([clean(source), clean(source)])` against a baseline single-call clean asserts byte-for-byte equality. `clean()` is deterministic on its own output, so any aliasing bug surfaces as a byte diff.
`fc.string({ minLength: 1, maxLength: 30 })` defaults to a UTF-16
code-unit alphabet, which can produce lone surrogates. pdf-lib's
`setTitle`/`setAuthor` happen to tolerate them (180 generated PDFs
across the property suite have never crashed), but the input is
out of spec for valid PDF text strings.
`unit: 'grapheme'` (supported by `fast-check@4.8.0` —
`StringConstraints.unit`) restricts generation to printable
graphemes. Same coverage breadth, no lone surrogates, no implicit
reliance on pdf-lib's silent normalization.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
clean()acceptssignal?: AbortSignalinCleanOptions. Checked before pdf-libload, afterload, and after the strip phase; aborts throwCleanErrorwithcode: 'ABORTED'andcause = signal.reason. Non-cooperative inside pdf-lib itself.ENCRYPTEDcode instead of being lumped underPARSE_FAILED. Implementation loads withignoreEncryption: trueand explicitly inspectsdoc.isEncrypted.CleanErrorCodeis now the four-code union'INVALID_INPUT' | 'PARSE_FAILED' | 'ENCRYPTED' | 'ABORTED'.fast-checkproperty tests — 5 invariants, 30 runs each, over generated PDFs.mitatabench over 5 fixture buckets (small-1page,metadata-only,links-50,mixed-medium,large-100pages) with three decompositional cases per bucket (raw pdf-lib floor /cleanno-strip /cleandefault). Numbers committed inbench/baseline.mdtogether with bundle sizes and a 10 % per-bucket regression budget.bench/baseline.mdlink, and anAbortSignal.timeout()example..github/workflows/ci.ymlships in the bootstrap shape: callscoroboros/ci/.github/workflows/javascript-npm-packages.yml@v0, forwards fiveNPM_*secrets, auto-detects the token for the first publish.Test plan
pnpm lintcleanpnpm typecheckcleanpnpm test— 55 tests pass (48 unit + 5 property + 2 new error cases)pnpm buildproducesdist/index.{mjs,cjs}+dist/cli.{mjs,cjs}+ a shared chunkpnpm benchruns without errorpreflightjob runs green on first pushBreaking changes
CleanErrorfor encrypted PDFs now reportscode: 'ENCRYPTED'(previously'PARSE_FAILED'). Documented in the README Errors table and the Limitations section.Upgrade notes
err.code === 'PARSE_FAILED'branches that handle encryption witherr.code === 'ENCRYPTED'. Generic parse failures keep'PARSE_FAILED'.