@agent-scope/render
v1.20.0
Published
Render paths for React components: Satori (simple/flexbox) and Playwright BrowserPool (complex)
Readme
@agent-scope/render
Dual-path render engine for React components. Routes simple (flexbox-only) components through a pure Node.js Satori pipeline, and complex components through a warm Playwright BrowserPool. A RenderMatrix generates the full Cartesian product of prop axes; SpriteSheetGenerator composites the results into a single annotated PNG.
Installation
npm install @agent-scope/render
# or
bun add @agent-scope/renderPeer dependencies: react, react-dom.
What it does / when to use
| Use case | Choose |
|---|---|
| Flexbox-only component, screenshot needed fast (~8 ms) | SatoriRenderer |
| CSS grid, absolute positioning, animations, real browser CSS | BrowserPool |
| Render all prop combinations in a grid | RenderMatrix |
| Composite results into a single labelled PNG | SpriteSheetGenerator |
| Layout context stress-testing | contextAxis + RenderMatrix |
| Edge-case prop stress-testing | stressAxis + RenderMatrix |
Architecture
complexityClass?
│
┌────────────────┴────────────────┐
"simple" "complex"
│ │
SatoriRenderer BrowserPool
React → Satori SVG Playwright pages
→ resvg-js PNG (inject-don't-navigate)
│ │
└─────────────┬────────────────────┘
│
RenderResult
│
┌─────────────┴───────────────┐
RenderMatrix SpriteSheetGenerator
(Cartesian product) (PNG composite)Satori path
SatoriRenderer runs entirely in Node.js with no browser overhead:
- Load font (Inter
.woffby default; custom TTF/OTF/WOFF supported — not woff2, opentype.js limitation) - Wrap element with mock providers (
ThemeContext,LocaleContext, generic fallbacks) - Call
satori(element, { width, height, fonts })→ SVG string - Call
new Resvg(svg).render().asPng()→ PNGBuffer
Target: ~8 ms per render at 375×812.
BrowserPool path
BrowserPool maintains N browser instances × M pages per browser. The "inject-don't-navigate" pattern keeps pages warm:
init()launches Chromium instances and pre-loads each page once with a skeleton HTML containingwindow.__renderComponent,window.__componentRegistry, andwindow.__registerComponentrender(name, props)callspage.evaluate(() => window.__renderComponent(name, props))— nopage.goto()between renders- Waits for
window.__renderReady === true - Screenshots the component bounding box (not the full viewport)
- Optionally extracts DOM tree, computed styles, console output, and accessibility info
API Reference
SatoriRenderer
import { SatoriRenderer } from '@agent-scope/render';
const renderer = new SatoriRenderer(config?: RendererConfig);RendererConfig
interface RendererConfig {
/** Path to a TTF/OTF/WOFF font file. NOT woff2 — opentype.js limitation.
* Defaults to bundled Inter woff (@fontsource/inter). */
fontPath?: string;
/** Font family name. Defaults to "Inter". */
fontFamily?: string;
/** Default viewport. Defaults to { width: 375, height: 812 }. */
defaultViewport?: ViewportOptions;
}Methods
// Pre-load and cache the font (call before the first render for consistent timing).
renderer.preloadFont(): Promise<void>
// Render a React element to PNG.
renderer.render(
element: React.ReactElement,
options?: SatoriRenderOptions,
descriptor?: Pick<ComponentDescriptor, 'requiredContexts'>,
): Promise<RenderResult>
// Shorthand: render at explicit viewport size.
renderer.renderAt(
element: React.ReactElement,
width: number,
height: number,
descriptor?: Pick<ComponentDescriptor, 'requiredContexts'>,
): Promise<RenderResult>SatoriRenderOptions
interface SatoriRenderOptions {
viewport?: { width: number; height: number };
container?: Partial<ContainerOptions>;
capture?: Partial<CaptureOptions>;
environment?: EnvironmentOptions;
}
interface ContainerOptions {
type: 'centered' | 'flex-row' | 'flex-col' | 'none';
padding: number; // pixels, applied to all sides
background?: string; // CSS colour string
}
interface CaptureOptions {
screenshot: boolean; // default: true
styles: boolean; // default: true
timing: boolean; // default: true
}
interface EnvironmentOptions {
viewport?: ViewportOptions;
theme?: string; // passed to mock ThemeContext
locale?: string; // e.g. "en-US"
}Example
import React from 'react';
import { SatoriRenderer } from '@agent-scope/render';
const renderer = new SatoriRenderer();
await renderer.preloadFont();
const result = await renderer.render(
<button style={{ padding: 16, background: '#0070f3', color: '#fff' }}>
Click me
</button>,
{ viewport: { width: 375, height: 200 } },
);
console.log(result.width, result.height, result.renderTimeMs);
// → 375 200 7.4BrowserPool
import { BrowserPool } from '@agent-scope/render';
const pool = new BrowserPool(config?: BrowserPoolConfig);
await pool.init();BrowserPoolConfig
interface BrowserPoolConfig {
/**
* Named size preset. Mutually exclusive with `size`.
* Defaults to "local".
*
* | Preset | browsers | pagesPerBrowser | max concurrency |
* |--------------|----------|-----------------|-----------------|
* | local | 1 | 5 | 5 |
* | ci-standard | 3 | 15 | 45 |
* | ci-large | 6 | 20 | 120 |
*/
preset?: 'local' | 'ci-standard' | 'ci-large';
/** Custom size config — overrides preset when provided. */
size?: { browsers: number; pagesPerBrowser: number };
viewportWidth?: number; // default: 1440
viewportHeight?: number; // default: 900
/** Max ms to wait for a free slot before acquire() rejects. Default: 30_000. */
acquireTimeoutMs?: number;
}Methods
// Launch browsers and pre-load skeleton HTML. Must be called before render().
pool.init(): Promise<void>
// Render a registered component by name.
pool.render(
componentName: string,
props?: Record<string, unknown>,
options?: RenderOptions,
): Promise<RenderResult>
// Acquire a free page slot manually (advanced — prefer render()).
pool.acquire(): Promise<PageSlot>
// Return a slot to the pool (wakes the next queued waiter).
pool.release(slot: PageSlot): void
// Gracefully drain in-flight renders then close all browsers.
pool.close(): Promise<void>
// Introspection
pool.totalSlots: number
pool.freeSlots: number
pool.activeSlots: number
pool.queueDepth: number
pool.isInitialized: boolean
pool.isClosed: booleanRenderOptions
interface RenderOptions {
captureDom?: boolean; // capture DOM tree. Default: false
captureStyles?: boolean; // capture computed styles. Default: true
captureConsole?: boolean; // capture console output. Default: false
captureA11y?: boolean; // capture accessibility snapshot. Default: false
viewportWidth?: number; // override pool-level viewport
viewportHeight?: number;
timeoutMs?: number; // wait for window.__renderReady. Default: 10_000
screenshotPadding?: number; // px around bounding box crop. Default: 24
minScreenshotWidth?: number; // Default: 320
minScreenshotHeight?: number;// Default: 200
}Example
import { BrowserPool } from '@agent-scope/render';
const pool = new BrowserPool({ preset: 'ci-standard' });
await pool.init();
const result = await pool.render('Button', { label: 'Click me' }, {
captureDom: true,
captureConsole: true,
});
// result.dom.elementCount, result.console.errors, etc.
await pool.close();Pool exhaustion & queuing (from browser-pool.test.ts)
When all slots are busy, acquire() queues callers FIFO and resumes them on release(). With acquireTimeoutMs: 50 and one slot:
const slot = await pool.acquire();
// Second acquire times out after 50 ms when no slot is released:
await pool.acquire(); // throws: "BrowserPool.acquire() timed out after 50ms"
pool.release(slot);RenderMatrix
Generates the Cartesian product of all axis value combinations and renders each cell.
import { RenderMatrix } from '@agent-scope/render';
const matrix = new RenderMatrix(
renderer, // anything implementing MatrixRenderer
axes, // MatrixAxis[]
options?, // { complexityClass?, concurrency? }
);
const result: MatrixResult = await matrix.render();MatrixAxis
interface MatrixAxis<T = unknown> {
name: string; // axis name, used as prop key
values: T[]; // values along this axis
}MatrixRenderer interface
interface MatrixRenderer {
renderCell(
props: Record<string, unknown>,
complexityClass: ComplexityClass,
): Promise<RenderResult>;
}Both SatoriRenderer and BrowserPool can be adapted to this interface.
MatrixResult
interface MatrixResult {
cells: MatrixCell[]; // flat list, row-major order
axes: MatrixAxis[];
axisLabels: string[][]; // [axisIndex][valueIndex] → display label
stats: MatrixStats;
rows: number; // last axis cardinality
cols: number; // product of remaining axes
}
interface MatrixCell {
props: Record<string, unknown>; // axis name → value for this cell
result: RenderResult;
index: number; // flat row-major index
axisIndices: number[]; // per-axis indices
}
interface MatrixStats {
totalCells: number;
totalRenderTimeMs: number;
avgRenderTimeMs: number;
minRenderTimeMs: number;
maxRenderTimeMs: number;
wallClockTimeMs: number;
}Cell ordering (from matrix.test.ts)
First axis iterates slowest (row-major). For axes [['A','B'], [1,2]]:
index 0 → { x: 'A', y: 1 }
index 1 → { x: 'A', y: 2 }
index 2 → { x: 'B', y: 1 }
index 3 → { x: 'B', y: 2 }Example: 3×3×2 = 18 cells
const matrix = new RenderMatrix(renderer, [
{ name: 'variant', values: ['primary', 'secondary', 'ghost'] },
{ name: 'size', values: ['sm', 'md', 'lg'] },
{ name: 'disabled', values: [false, true] },
]);
const result = await matrix.render();
// result.cells.length === 18
// result.stats.avgRenderTimeMs — average per-cell render timeGrid dimensions
rows = last axis cardinality
cols = totalCells / rows
// Single-axis: [{ name: 'v', values: ['a','b','c'] }]
// → rows: 3, cols: 1
// Two axes: [2 values, 3 values]
// → rows: 3, cols: 2, cells: 6cartesianProduct utility
import { cartesianProduct } from '@agent-scope/render';
cartesianProduct([[1,2], ['a','b']]);
// → [[1,'a'], [1,'b'], [2,'a'], [2,'b']]SpriteSheetGenerator
Composites a MatrixResult into a single labelled PNG.
import { SpriteSheetGenerator } from '@agent-scope/render';
const gen = new SpriteSheetGenerator(options?: SpriteSheetOptions);
const { png, coordinates, width, height } = await gen.generate(matrixResult);SpriteSheetOptions
interface SpriteSheetOptions {
cellPadding?: number; // px around each cell. Default: 8
borderWidth?: number; // border thickness. Default: 1
background?: string; // CSS hex. Default: "#f5f5f5"
borderColor?: string; // Default: "#cccccc"
labelHeight?: number; // top label row height. Default: 32
labelWidth?: number; // left label column width. Default: 80
labelDpi?: number; // label text DPI. Default: 72
labelBackground?: string; // Default: "#e8e8e8"
}SpriteSheetResult
interface SpriteSheetResult {
png: Buffer; // full composite PNG
coordinates: CellCoordinateMap; // CellBounds[] indexed by flat cell index
width: number; // total sprite sheet width in pixels
height: number; // total sprite sheet height in pixels
}
interface CellBounds {
x: number; // left edge in sprite sheet
y: number; // top edge in sprite sheet
width: number; // content width (excludes padding)
height: number; // content height (excludes padding)
}Layout rules (from sprite-sheet.test.ts)
- Cells in the same column share the same
xcoordinate - Cells in the same row share the same
ycoordinate - Row
yvalues increase monotonically:coords[0].y < coords[1].y < coords[2].y - Larger
cellPadding→ larger total sprite sheet dimensions
const gen = new SpriteSheetGenerator({ cellPadding: 12, background: '#fff' });
const { png, coordinates } = await gen.generate(matrixResult);
// Access cell 3's position:
const { x, y, width, height } = coordinates[3];Composition Contexts
Ten built-in layout environments for use as RenderMatrix axes.
import { contextAxis, COMPOSITION_CONTEXTS, getContext } from '@agent-scope/render';Available context IDs
| ID | Description |
|---|---|
| centered | Centered in viewport (flex, align/justify center) |
| flex-row | Flex row with sibling placeholders |
| flex-col | Flex column with sibling placeholders |
| grid | CSS grid cell (3-column auto-fill) |
| sidebar | Narrow 240 px sidebar |
| scroll | Scrollable container, fixed 300 px height |
| full-width | Stretches to full viewport width |
| constrained | Centered, max-width 1024 px |
| rtl | dir="rtl", lang="ar" — bidi layout testing |
| nested-flex | Three levels of nested flex containers |
Usage
import { RenderMatrix, contextAxis } from '@agent-scope/render';
// All 10 contexts × 2 variants = 20 cells
const matrix = new RenderMatrix(renderer, [
contextAxis(['centered', 'flex-row', 'sidebar', 'rtl']),
{ name: 'variant', values: ['primary', 'secondary'] },
]);
// Or use all 10:
const allContextMatrix = new RenderMatrix(renderer, [
contextAxis(),
{ name: 'size', values: ['sm', 'md', 'lg'] },
]);Helper functions
getContext('rtl') // → CompositionContext
getContexts(['rtl', 'centered']) // → CompositionContext[]
contextAxis(['centered', 'rtl', 'grid']) // → MatrixAxis<CompositionContext>Content Stress Presets
Seven categories of edge-case prop values for RenderMatrix axes.
import { stressAxis, getStressPreset, ALL_STRESS_PRESETS } from '@agent-scope/render';Available categories
| ID | Values | What it exercises |
|---|---|---|
| text.short | "", " ", "A", "OK", "Hello", "Submit", "Cancel" | Empty, whitespace, single-char |
| text.long | 80–200 char strings, long words, 100-char runs | Overflow, text wrapping |
| text.unicode | Japanese, Chinese, Korean, diacritics, emoji, zero-width space, RTL override | CJK, emoji, bidi |
| text.rtl | Arabic and Hebrew strings, mixed Arabic+LTR | Right-to-left layout |
| number.edge | 0, -0, 1, -1, NaN, Infinity, -Infinity, 999999, MAX_SAFE_INTEGER | Numeric extremes |
| list.count | [], 1-item, 3-item, 10-item, 100-item, 1000-item arrays | Empty/huge lists |
| image.states | null, "", broken URL, 1×1 data URI, 200×200, 4000×4000 | Image error states |
Usage
import { RenderMatrix, stressAxis } from '@agent-scope/render';
// 7 short-text values × 2 disabled states = 14 cells
const matrix = new RenderMatrix(renderer, [
stressAxis('text.short'),
{ name: 'disabled', values: [false, true] },
]);
// Direct preset access:
const preset = getStressPreset('number.edge');
// preset.values → [0, -0, 1, -1, NaN, Infinity, ...]
// preset.valueLabels → ['zero', 'neg-zero', 'one', ...]RenderResult
Shared return type for both render paths.
interface RenderResult {
screenshot: Buffer; // PNG as a Buffer
width: number; // pixel width of the rendered component
height: number; // pixel height
renderTimeMs: number; // wall-clock render time in ms
computedStyles: Record<string, Record<string, string>>;
// BrowserPool only (when captureXxx options enabled):
dom?: { tree: DOMNode; elementCount: number; boundingBox: BoundingBox };
console?: { errors: string[]; warnings: string[]; logs: string[] };
accessibility?: { role: string; name: string; violations: string[] };
}The computedStyles key for the BrowserPool path is "[data-reactscope-root] > *" and captures: display, flexDirection, flexWrap, alignItems, justifyContent, gridTemplateColumns, position, width, height, color, backgroundColor, fontSize, fontFamily, fontWeight, borderRadius, boxShadow, opacity, transform, and more.
Structured error handling
import { safeRender, RenderError, detectHeuristicFlags } from '@agent-scope/render';
// or from the sub-path:
import { safeRender } from '@agent-scope/render/errors';
const outcome = await safeRender(
() => pool.render('Button', props),
{ props, sourceLocation: { file: 'Button.tsx', line: 12, column: 0 } },
);
if (outcome.crashed) {
console.error(outcome.error.heuristicFlags);
// e.g. ['MISSING_PROVIDER', 'TYPE_MISMATCH']
} else {
const png = outcome.result.screenshot;
}HeuristicFlag values: MISSING_PROVIDER, UNDEFINED_PROP, TYPE_MISMATCH, NETWORK_REQUIRED, ASYNC_NOT_SUSPENDED, HOOK_CALL_VIOLATION, ELEMENT_TYPE_INVALID, HYDRATION_MISMATCH, CIRCULAR_DEPENDENCY, UNKNOWN_ERROR.
Real test payloads
Matrix: 2×2 produces 4 cells with correct prop sets (from matrix.test.ts)
const matrix = new RenderMatrix(renderer, [
{ name: 'variant', values: ['primary', 'secondary'] },
{ name: 'size', values: ['sm', 'md'] },
]);
const result = await matrix.render();
// result.cells.map(c => c.props):
// [{ variant: 'primary', size: 'sm' }, { variant: 'primary', size: 'md' },
// { variant: 'secondary', size: 'sm' }, { variant: 'secondary', size: 'md' }]
// result.stats:
// { totalCells: 4, avgRenderTimeMs: 12.5, minRenderTimeMs: 5, maxRenderTimeMs: 20 }BrowserPool: clipped screenshot dimensions match bounding box (from browser-pool.test.ts)
// Component bounding box: 40×40, viewport: 1440×900
// result.width === 40, result.height === 40 (not 1440×900)SpriteSheet: coordinate alignment (from sprite-sheet.test.ts)
const matrix = makeMockMatrixResult(3, 2); // 3 rows, 2 cols
const { coordinates } = await gen.generate(matrix);
// Col 0: cells 0,1,2 share x:
coordinates[0].x === coordinates[1].x === coordinates[2].x
// Row 0: cells 0 and 3 share y:
coordinates[0].y === coordinates[3].y
// y increases down rows:
coordinates[0].y < coordinates[1].y < coordinates[2].yUsed by
@agent-scope/cli— invokesBrowserPoolandSatoriRendererto render component screenshots duringscope renderruns@agent-scope/manifest— providesComplexityClassto route renders between paths
