npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@printwithsynergy/lens-server

v0.1.0

Published

Canonical headless renderer for the lens-pdf viewer family. Serves report rendering (HTML / PDF / annotated PDF / markup PDF), page rendering, and color sampling for backends that can't run the browser-only lens-pdf React library directly. Powered by Ghos

Readme

lens-pdf-server

A small Express + Ghostscript service that supplies the LensPDF viewer with everything the in-browser pdf.js fallback can't: real ink separations (CMYK + spot inks), per-pixel TAC heatmaps, point densitometer readings, and color samples derived from the actual rendered raster.

This is a reference implementation. Use it directly if it fits, or read the source as a contract guide and write your own.

Run

cd server
npm install
npm run build
LENS_JOBS_DIR=/tmp/lens-jobs LENS_CACHE_DIR=/tmp/lens-cache npm start

Or via Docker:

docker build -t lens-pdf-server ./server
docker run -p 3000:3000 \
  -v lens-jobs:/var/lib/lens-pdf/jobs \
  lens-pdf-server

The image already includes Ghostscript. The only host requirement is storage for uploaded PDFs (a Docker volume, an EFS mount, etc.).

Configure

Environment variables, all optional except where noted:

| Var | Default | Purpose | | --- | --- | --- | | PORT | 3000 | HTTP port. | | LENS_JOBS_DIR | /var/lib/lens-pdf/jobs | Where uploaded PDFs land on disk. | | LENS_CACHE_DIR | /var/cache/lens-pdf | Render cache (currently in-memory; reserved for future on-disk caching). | | LENS_MAX_UPLOAD_MIB | 100 | Refuse uploads larger than this. | | LENS_BEARER_TOKEN | unset | When set, every request must carry Authorization: Bearer <token>. Coarse single-secret auth meant for private-network deploys; put a real gateway in front for anything else. | | GS_BIN | gs | Path / name of the Ghostscript binary. |

Wire into the viewer

import type { ViewerServices } from "@printwithsynergy/lens-pdf/plugin";

const apiBase = "https://separations.example.com";
const jobId = "job-abc";

// Register the PDF before you render anything. This can be done at
// upload time on your own backend rather than inside the viewer.
await fetch(`${apiBase}/jobs/${jobId}/source`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ url: signedPdfUrl }),
});

// Then point the viewer's services at the same base URL:
const services: ViewerServices = {
  pageImages: {
    getPageImageUrl: ({ pageNum, dpi }) =>
      `${apiBase}/jobs/${jobId}/page/${pageNum}.png?dpi=${dpi}`,
  },
  separations: {
    getChannelImageUrl: ({ pageNum, channelName, dpi }) =>
      `${apiBase}/jobs/${jobId}/channel/${encodeURIComponent(channelName)}.png?page=${pageNum}&dpi=${dpi}`,
  },
  tacHeatmap: {
    getHeatmapImageUrl: ({ pageNum, dpi, tacLimit }) =>
      `${apiBase}/jobs/${jobId}/tac.png?page=${pageNum}&dpi=${dpi}&limit=${tacLimit}`,
    listRuns: async () => [], // run-level metadata isn't part of this server yet
  },
  colorSample: {
    sampleAt: async ({ pageNum, pdfX, pdfY, dpi = 150 }) => {
      const r = await fetch(
        `${apiBase}/jobs/${jobId}/color?page=${pageNum}&x=${pdfX}&y=${pdfY}&dpi=${dpi}&pageWidthPts=${pageWidthPts}&pageHeightPts=${pageHeightPts}`,
      );
      return r.ok ? await r.json() : null;
    },
  },
  densitometer: {
    sampleAt: async ({ pageNum, pdfX, pdfY, dpi = 150, tacLimit }) => {
      const r = await fetch(`${apiBase}/jobs/${jobId}/density`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          page: pageNum,
          x: pdfX,
          y: pdfY,
          pageWidthPts,
          pageHeightPts,
          dpi,
          tacLimit,
        }),
      });
      if (!r.ok) {
        if (r.status === 422) throw new Error("No separations available for this page.");
        throw new Error(`Sampling failed (${r.status})`);
      }
      return await r.json();
    },
  },
  // …leave layers / annotations / reports unwired or wire them to your own services.
} as ViewerServices;

Endpoint reference

All endpoints are scoped to a jobId (1–128 chars of [a-zA-Z0-9_-]).

| Method | Path | Notes | | --- | --- | --- | | GET | /healthz | Liveness. | | POST | /jobs/{jobId}/source | Register a PDF. Body: application/pdf raw bytes or application/json { "url": "https://…" } to fetch on the server's behalf. | | DELETE | /jobs/{jobId} | Drop cached state for the job. | | GET | /jobs/{jobId}/page/{pageNum}.png?dpi=N | Composite RGB PNG of one page. | | GET | /jobs/{jobId}/channels?page=N&dpi=N | List of ink-channel names present on the page. | | GET | /jobs/{jobId}/channel/{name}.png?page=N&dpi=N | One per-ink grayscale PNG. | | GET | /jobs/{jobId}/tac.png?page=N&dpi=N&limit=N | TAC heatmap PNG (transparent under the limit). | | GET | /jobs/{jobId}/color?page=N&x=…&y=…&pageWidthPts=…&pageHeightPts=…&dpi=N | Single-pixel ColorSample JSON. | | POST | /jobs/{jobId}/density | DensitometerSample JSON; body fields: page, x, y, pageWidthPts, pageHeightPts, dpi, tacLimit. |

Security

Read this before exposing the server to anything you don't fully control.

  • No auth by default. The optional LENS_BEARER_TOKEN gives a single shared secret check; that's it. Multi-tenant isolation, per-user authz, audit logging — all out of scope. Put a real gateway in front of this service.
  • PDF URL fetching is unguarded. When a host POSTs { url: "https://…" }, the server fetches it as-is. SSRF mitigation (block 127.0.0.1, 169.254.0.0/16, internal hostnames, etc.) is not built in — do it at your egress layer, or avoid the URL flow and upload PDFs directly.
  • Ghostscript with -dSAFER is on by default but Ghostscript has had sandbox bypasses historically. Run the container with --read-only, drop capabilities, and treat any uploaded PDF as hostile.
  • Resource exhaustion: a malicious PDF can keep Ghostscript busy. The 60-second per-render timeout protects against the most obvious cases; pair with a request rate limit and a per-tenant concurrent- render cap.
  • PDF storage is filesystem-based and unencrypted at rest. Use encrypted storage if any of the PDFs you process need protection at rest.
  • Logs include URLs and sizes. Don't ship them to a service that shouldn't see those.

Performance notes

  • Ghostscript's tiffsep device is the bottleneck — 1–4 seconds per page at 150 DPI on a 4-core machine, much more for image-heavy pages or high DPIs. Prefer 96–150 DPI for viewer tiles, only render 300+ when the user explicitly zooms in.
  • The in-memory cache holds 256 entries / 30 minutes. For multi-pod deployments, swap cache.ts for a Redis-backed implementation — every cacheable surface routes through getOrRender helpers in index.ts, so the change is contained.
  • sharp decodes channel PNGs once per pixel sample. For high-frequency densitometer use, keep one rendered job hot and consider returning the channel rasters as raw planar buffers cached alongside the PNG.

Cloudflare / CDN edge caching

Every per-job response is marked immutable with a 1-year TTL and tagged with Cache-Tag: job-{jobId}. A given (jobId, page, dpi, channel) tuple never changes — the only way the content changes is replacing the source PDF, which means a new jobId (or a DELETE /jobs/{jobId} followed by re-upload).

Cache headers emitted on cacheable responses:

Cache-Control: public, max-age=31536000, immutable, s-maxage=31536000
Cache-Tag: job-{jobId}

Cacheable endpoints (GETs only):

  • /jobs/{jobId}/page/{n}.png — composite RGB
  • /jobs/{jobId}/channel/{name}.png — per-ink raster
  • /jobs/{jobId}/tac.png — TAC heatmap
  • /jobs/{jobId}/channels — channel list JSON
  • /jobs/{jobId}/color — point sample JSON (deterministic per coord)

POST endpoints (/jobs/{jobId}/source, /jobs/{jobId}/density) are non-cacheable per HTTP spec.

Wiring at Cloudflare

  1. Put the server behind Cloudflare with proxy mode on (orange cloud). The default Cache Rules will respect the Cache-Control header above and edge-cache for 1 year.
  2. Don't set LENS_BEARER_TOKEN if you want CDN caching. An Authorization header makes Cloudflare bypass the edge cache by default. Move auth to the gateway tier (Cloudflare Access, signed URLs, mTLS at the origin) so the cacheable URL space is unauth'd.
  3. Pair DELETE /jobs/{jobId} with a Cloudflare purge-by-tag call from your control plane. The tag to purge is job-{jobId}. Tag purges require Cloudflare Enterprise; on lower plans, purge by URL (you'll need to enumerate the tiles your viewer fetched) or rely on the immutable URL pattern (new jobId = new URLs = no cache hit).
  4. Optional: enable Cloudflare Polish to recompress PNGs at the edge — helpful for the per-channel rasters which are mostly grayscale and compress well.

The server emits no Set-Cookie headers, so the default Cloudflare heuristic ("don't cache responses with cookies") doesn't bite.

Limitations

  • ICC output intent embedded in the PDF is honoured by Ghostscript; if you need to override it (e.g., always render to GRACoL2006), pass -sDefaultRGBProfile=... / -sDefaultCMYKProfile=... to Ghostscript in ghostscript.ts. Not exposed via env vars yet.
  • The tacHeatmap.listRuns per-text-run TAC list isn't implemented — the heatmap renders fine, but the hover-tooltip layer in TACHeatmapOverlay will be empty. Adding it requires walking the PDF's text content stream and intersecting each run's bbox with the rasterised TAC image.
  • Annotations, layers (OCGs), and report exports are not part of this server — wire those to your own services.

License

AGPL-3.0-or-later, same as LensPDF itself. See ../LICENSE.