@zakkster/lite-og
v1.0.0
Published
Server-side Open Graph image generation via Canvas2D (@napi-rs/canvas). A declarative scene-graph renders to a PNG/JPEG/WebP Buffer: text with word-wrap, gradients, fitted images, hi-DPI scaling. Framework-agnostic replacement for @vercel/og. Zero runtime
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.
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", text: "Ship social cards without the WASM tax", x: 60, y: 220, font: '80px "Inter Bold"', color: "#ffffff", maxWidth: 1080, lineHeight: 96 },
{ 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 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, text, x, y } plus: font (CSS shorthand, e.g. '80px "Inter Bold"') or fontSize/fontFamily/fontWeight; color; maxWidth (enables wrapping); lineHeight (px, default round(fontSize * 1.25)); align (left/center/right); letterSpacing; opacity; shadow ({ color, blur, x, y }). y is the top of the first line (baseline is top).
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 wrapping
With maxWidth, text is wrapped greedily using ctx.measureText: words are packed onto a line until the next word would overflow. Explicit \n always breaks. This is fast and dependency-free for the handful of words in a typical OG title. Limitation: a single word longer than maxWidth is not character-broken in v1; it stays on one line and overflows. Pre-break very long unbroken strings yourself if needed.
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.mjs measures end-to-end renderOG throughput for a 1200x630 card across formats and scale. Numbers are hardware-dependent (native rasterization dominates), so run it on your own machine:
npm run benchServer 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 is deliberately small and explicit:
- 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, no per-span rich text within a single text node, and emoji depend on a system emoji font being available.
- Overlong unbroken words are not character-wrapped (see Text wrapping).
License
MIT (c) Zahary Shinikchiev
