Skip to content

feat: AbortSignal support, ENCRYPTED + ABORTED codes, bench, ci#1

Merged
ob-aion merged 19 commits into
mainfrom
feat/optim
May 21, 2026
Merged

feat: AbortSignal support, ENCRYPTED + ABORTED codes, bench, ci#1
ob-aion merged 19 commits into
mainfrom
feat/optim

Conversation

@ob-aion

@ob-aion ob-aion commented May 21, 2026

Copy link
Copy Markdown
Contributor

Summary

  • clean() accepts signal?: AbortSignal in CleanOptions. Checked before pdf-lib load, after load, and after the strip phase; aborts throw CleanError with code: 'ABORTED' and cause = signal.reason. Non-cooperative inside pdf-lib itself.
  • Encrypted PDFs throw a distinct ENCRYPTED code instead of being lumped under PARSE_FAILED. Implementation loads with ignoreEncryption: true and explicitly inspects doc.isEncrypted.
  • CleanErrorCode is now the four-code union 'INVALID_INPUT' | 'PARSE_FAILED' | 'ENCRYPTED' | 'ABORTED'.
  • fast-check property tests — 5 invariants, 30 runs each, over generated PDFs.
  • mitata bench over 5 fixture buckets (small-1page, metadata-only, links-50, mixed-medium, large-100pages) with three decompositional cases per bucket (raw pdf-lib floor / clean no-strip / clean default). Numbers committed in bench/baseline.md together with bundle sizes and a 10 % per-bucket regression budget.
  • README documents the new option, the four error codes, the bench/baseline.md link, and an AbortSignal.timeout() example.
  • CLAUDE.md adds an additive-only stability rule, the regression-budget rule, and the publish-auth rule.
  • .github/workflows/ci.yml ships in the bootstrap shape: calls coroboros/ci/.github/workflows/javascript-npm-packages.yml@v0, forwards five NPM_* secrets, auto-detects the token for the first publish.

Test plan

  • pnpm lint clean
  • pnpm typecheck clean
  • pnpm test — 55 tests pass (48 unit + 5 property + 2 new error cases)
  • pnpm build produces dist/index.{mjs,cjs} + dist/cli.{mjs,cjs} + a shared chunk
  • pnpm bench runs without error
  • CI preflight job runs green on first push

Breaking changes

  • CleanError for encrypted PDFs now reports code: 'ENCRYPTED' (previously 'PARSE_FAILED'). Documented in the README Errors table and the Limitations section.

Upgrade notes

  • Replace err.code === 'PARSE_FAILED' branches that handle encryption with err.code === 'ENCRYPTED'. Generic parse failures keep 'PARSE_FAILED'.

ob-aion added 19 commits May 21, 2026 12:24
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.
@ob-aion ob-aion merged commit 24ed6cf into main May 21, 2026
5 checks passed
@ob-aion ob-aion deleted the feat/optim branch May 21, 2026 07:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant