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

@zakkster/lite-og

v1.1.0

Published

Server-side Open Graph image generation via Canvas2D. Declarative scene-graph -> PNG/JPEG/WebP. Zero runtime deps (peer @napi-rs/canvas). Rich text, maxLines, char-wrap, LRU cache.

Downloads

42

Readme

@zakkster/lite-og

npm version sponsor npm bundle size npm downloads npm total downloads Tree-Shakeable types runtime deps

Server-side Open Graph image generation from a declarative scene graph. A plain JavaScript object describes the card; renderOG rasterizes it to a PNG (or JPEG/WebP) Buffer you can write to an HTTP response or to disk.

It is a framework-agnostic alternative to @vercel/og. Where Vercel pairs React with Satori's WASM text-layout engine (heavy, React-only), lite-og uses Canvas2D via @napi-rs/canvas (native, prebuilt, no system Cairo) and a small scene-graph API with explicit coordinates and greedy text wrapping. No JSX, no WASM, no layout engine, zero runtime dependencies of its own.

v1.1 adds robust text handling while keeping the zero-dependency, no-layout-engine philosophy:

  • Character-level fallback wrapping for words wider than maxWidth
  • maxLines + automatic ... ellipsis truncation
  • Per-span rich text (spans) with independent color/font that still wraps as a single flow
import { renderOG, loadFont } from "@zakkster/lite-og";

await loadFont("Inter Bold", "./fonts/Inter-Bold.ttf");

const png = await renderOG({
    width: 1200,
    height: 630,
    background: {
        type: "linear-gradient",
        stops: [{ offset: 0, color: "#5b34f8" }, { offset: 1, color: "#1abc9c" }],
    },
    children: [
        { type: "image", src: "./logo.png", x: 60, y: 60, width: 96, height: 96 },
        {
            type: "text",
            x: 60,
            y: 220,
            font: '80px "Inter Bold"',
            color: "#ffffff",
            maxWidth: 1080,
            lineHeight: 96,
            // v1.1 rich text example (or use plain `text:`)
            spans: [
                { text: "Ship social cards ", color: "#ffffff" },
                { text: "without the WASM tax", color: "#ffd700", font: '80px "Inter Bold"' }
            ]
        },
        { type: "rect", x: 60, y: 540, width: 600, height: 6, fill: "#ffffff", opacity: 0.5, radius: 3 },
    ],
});
// png is a Buffer -> res.end(png) or fs.writeFile("og.png", png)

Install

@napi-rs/canvas is a peer dependency that lite-og imports lazily. This keeps lite-og's own dependency tree empty (so importing the types costs nothing), and a missing backend produces a clear OGError('missing_canvas') instead of an import crash. Install both:

npm install @zakkster/lite-og @napi-rs/canvas

Node 18+. v1.1 targets the Node runtime (see Scope for edge runtimes).

How it renders

flowchart LR
  S[Scene object] --> V{validate}
  V -- invalid --> E[reject OGError]
  V -- ok --> C["createCanvas(w*scale, h*scale)"]
  C --> B[paint background]
  B --> N[draw children in order]
  N --> EN[encode png / jpeg / webp]
  EN --> O[Buffer]

A single pass: validate the scene, create a canvas (scaled for hi-DPI), paint the background, draw each child in array order onto the 2D context, then encode. There is no layout step. Every node carries its own absolute coordinates.

Scene

| Field | Type | Notes | | --- | --- | --- | | width, height | number | Logical pixels (required, positive). | | background | string \| Background | Solid color, gradient, or fitted image. Omit for transparent. | | children | Node[] | Drawn over the background, in order. | | format | 'png' \| 'jpeg' \| 'webp' | Default 'png'. | | quality | number | 0..1 for jpeg/webp. | | scale | number | Hi-DPI multiplier. Output is width*scale by height*scale; coordinates stay logical. Default 1. |

Backgrounds

background: "#0b0e14"                                   // solid color
background: { type: "linear-gradient", angle: 135, stops: [...] }
background: { type: "radial-gradient", stops: [...] }   // brightest at center
background: { type: "image", src, fit: "cover" }        // fit: cover | contain | fill

Gradient angle is in degrees, clockwise, where 0 = left-to-right and 90 = top-to-bottom (default 0). Stops are { offset: 0..1, color }.

Nodes

flowchart TD
  Scene --> Background
  Scene --> Children
  Background --> bc[color string]
  Background --> lg[linear-gradient]
  Background --> rg[radial-gradient]
  Background --> bi[image]
  Children --> t[text]
  Children --> r[rect]
  Children --> i[image]
  Children --> l[line]
  Children --> g[group]
  g -. translate + opacity .-> Children

text -- { type, x, y } plus optional text (plain string) or spans (rich). Common: font (CSS shorthand) or fontSize/fontFamily/fontWeight; color (default); maxWidth (wrap + char fallback); maxLines (truncation + ...); lineHeight; align; letterSpacing; opacity; shadow.

rect -- { type, x, y, width, height } plus: fill, radius (rounded corners), stroke, strokeWidth, opacity.

image -- { type, src, x, y } plus: width/height (default natural size); fit (cover/contain/fill, default stretches); radius (rounded clip); opacity; cache (default true). src may be a file path, an http(s)/file:/data: URL string, a URL, or raw bytes (Buffer/Uint8Array/ArrayBuffer).

line -- { type, x1, y1, x2, y2 } plus: stroke, strokeWidth, cap (butt/round/square), opacity.

group -- { type, children } plus optional x/y (translate) and opacity. A group only translates its children and multiplies their opacity -- it is for moving a cluster of nodes together, not auto-layout.

Text (v1.1 -- robust)

Plain text or rich spans are wrapped with the same greedy algorithm:

  • Words are packed until the next would overflow maxWidth.
  • Character fallback: any word (or span run) wider than maxWidth is broken at character boundaries so it never overflows.
  • Explicit \n always creates a hard line break.
  • maxLines + ellipsis: if the wrapped result exceeds maxLines, the final visible line is trimmed and terminated with ....

Rich text example (spans wrap together as one flow, each can override color/font):

{
  type: "text",
  x: 60, y: 180,
  maxWidth: 1080,
  maxLines: 2,
  font: '48px Inter',
  color: "#fff",
  spans: [
    { text: "Build beautiful Open Graph images " },
    { text: "fast", color: "#ffd700", font: "bold 48px Inter" },
    { text: " -- zero WASM, zero React." }
  ]
}

The font / color on the node act as defaults for any span that omits them.

Fonts

await loadFont("Inter Bold", "./fonts/Inter-Bold.ttf");   // file path
await loadFont("Inter Bold", new URL("./Inter-Bold.ttf", import.meta.url)); // file: URL
await loadFont("Inter Bold", fontBuffer);                  // Buffer / Uint8Array / ArrayBuffer
const families = await listFonts();                        // registered + system families

Font registration is process-global (the backend registers fonts globally). Register once at startup. A missing file or unreadable font rejects with OGError('font_load_failed'). Reference the family in a text node via the font shorthand or fontFamily.

Images and the cache

Decoded images are cached so a long-lived server doesn't re-decode (and, for remote sources, re-fetch) the same brand logo or template on every request. The cache is a bounded LRU:

  • Default capacity 100; at capacity it evicts the least-recently-used entry (a cache hit marks an entry most-recent). This is the key protection against OOM: a dynamic endpoint that renders per-request avatars can never grow the cache without bound.
  • Cacheable sources are keyed by src (path / URL / data URI). Raw byte sources are never cached -- the caller already owns those bytes.
  • Per-node cache: false opts a single image out.
setImageCacheLimit(256);   // tune capacity (0 disables caching entirely)
imageCacheSize();          // current entry count, for monitoring
clearImageCache();         // empty it

Output and hi-DPI

format selects png (default), jpeg, or webp; quality (0..1) applies to jpeg/webp. For retina, set scale: 2 and keep your coordinates at the logical size:

const buf = await renderOG({ width: 1200, height: 630, scale: 2, /* ... */ });
// encodes a 2400x1260 image; you still position elements in 1200x630 space

Errors

renderOG and loadFont reject with a typed OGError (it has a .code). Image and font failures fail loud by design: a corrupted OG image is worse than an error, because caching layers (Slack, X, etc.) may pin the broken result. Catch the rejection on your server and serve a static fallback or a 404.

| code | When | | --- | --- | | missing_canvas | The @napi-rs/canvas peer dependency is not installed. | | invalid_scene | Bad dimensions, options, background, or a node missing required fields. | | font_load_failed | loadFont could not read or parse the font. | | image_load_failed | An image source could not be loaded or decoded. | | unsupported_node | A child has an unknown type. |

Performance

bench/bench.mjs measures end-to-end renderOG throughput for a 1200x630 card across a spread of scenarios: plain text, rich spans, long-word character-breaking, maxLines ellipsis, a complex multi-node card, stress cases (many small elements, emoji-heavy, many spans), plus PNG/JPEG/WebP output and 2x scale. It reports throughput and mean/median/p95 latency per scenario.

npm run bench                 # default 2s per scenario
DURATION=5000 npm run bench   # longer sampling window
node bench/bench.mjs --json   # machine-readable (one JSON object per scenario)

Sample run (Node 23.5.0, 2s per scenario). Absolute numbers are hardware-dependent (native rasterization dominates) -- run it on your own machine:

| Scenario | Cards/sec | Median | p95 | Size | | --- | ---: | ---: | ---: | ---: | | baseline-plain (PNG) | 15.7 | 62.9 ms | 66.6 ms | 131.2 KB | | rich-spans-maxlines | 25.7 | 38.7 ms | 40.7 ms | 61.7 KB | | long-word-charbreak | 21.8 | 42.2 ms | 60.8 ms | 34.0 KB | | maxlines-ellipsis | 24.0 | 41.7 ms | 43.4 ms | 48.9 KB | | complex-card | 24.9 | 39.9 ms | 42.6 ms | 58.7 KB | | stress-many-small-elements | 24.3 | 41.3 ms | 44.1 ms | 22.5 KB | | stress-emoji-heavy | 23.8 | 42.3 ms | 43.8 ms | 34.1 KB | | stress-rich-text-many-spans | 25.0 | 38.3 ms | 50.3 ms | 25.0 KB | | format-jpeg | 54.3 | 17.7 ms | 22.7 ms | 44.9 KB | | format-webp | 6.7 | 95.8 ms | 523.7 ms | 23.7 KB | | scale-2x | 5.0 | 195.1 ms | 227.9 ms | 340.7 KB |

Takeaways:

  • Output format is the biggest lever. The same card renders ~3.5x faster as JPEG (54 cards/sec, 18 ms) than as PNG (16 cards/sec, 63 ms). PNG encode cost tracks output size, so the full-bleed gradient makes baseline-plain the heaviest PNG here.
  • WebP is CPU-heavy with a long tail (median 96 ms, p95 524 ms). Good for pre-generated or cached cards; think twice before encoding WebP per request on a hot path.
  • The v1.1 text features are effectively free. Plain, rich-span, long-word char-break, ellipsis, 12-span, and emoji scenes all land within ~38-46 ms -- text layout is dwarfed by rasterization and encoding.
  • scale: 2 (4x the pixels) costs roughly 3x the latency; fixed per-render overhead amortizes, so it stays sub-linear.

Server example

import { createServer } from "node:http";
import { renderOG, OGError } from "@zakkster/lite-og";

createServer(async (req, res) => {
    try {
        const png = await renderOG({ width: 1200, height: 630, background: "#0b0e14", children: [/* ... */] });
        res.writeHead(200, { "content-type": "image/png", "cache-control": "public, max-age=86400" });
        res.end(png);
    } catch (e) {
        const code = e instanceof OGError ? e.code : "error";
        res.writeHead(500, { "content-type": "text/plain" });
        res.end(code);
    }
}).listen(3000);

Scope and limitations

v1.1 keeps the deliberate minimalism:

  • No flexbox / auto-layout. Coordinates are absolute; group only translates. This is the intentional simplification versus Satori -- OG cards are rigid, fixed-size designs.
  • Node runtime only. Cloudflare Workers / Vercel Edge do not provide @napi-rs/canvas; an OffscreenCanvas adapter is a candidate for a later version.
  • No arbitrary SVG paths. Emoji rendering depends on a system emoji font being available.
  • Rich text spans and long-word character breaking are now supported (the previous v1 holes are closed).

License

MIT (c) Zahary Shinikchiev