@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
Maintainers
Readme
@zakkster/lite-og
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 independentcolor/fontthat 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/canvasNode 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 | fillGradient 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 .-> Childrentext -- { 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
maxWidthis broken at character boundaries so it never overflows. - Explicit
\nalways creates a hard line break. maxLines+ ellipsis: if the wrapped result exceedsmaxLines, 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 familiesFont 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: falseopts a single image out.
setImageCacheLimit(256); // tune capacity (0 disables caching entirely)
imageCacheSize(); // current entry count, for monitoring
clearImageCache(); // empty itOutput 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 spaceErrors
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-plainthe 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;
grouponly 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; anOffscreenCanvasadapter 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
