Usage guide

Install, register codecs, and execute an image pipeline.

This guide covers Node.js and browser entry points, accepted inputs, transform ordering, output methods, limits, and Lambda-specific storage behavior.

Node.js 22+Modern browsersStrict TypeScript

Install

The Node entry requires Node.js 22 or newer. Modern browser applications use purejsimage/browser. The published package has no runtime dependencies, native addons, external binaries, or required WebAssembly modules.

Terminal
npm install purejsimage

The package is ESM and includes its TypeScript declarations. Import the core API and each codec from its own entry point so your application loads only what it accepts.

Run in a browser

The browser entry uses the same codecs, limits, immutable pipeline, and pixel kernels as Node. Its platform adapter accepts DOM-native files and returns web-native output without Buffer polyfills or Node built-ins.

browser-images.ts
import { createImageLibrary } from 'purejsimage/browser'
import { jpegCodec } from 'purejsimage/codecs/jpeg'
import { pngCodec } from 'purejsimage/codecs/png'

const images = createImageLibrary([jpegCodec, pngCodec])
const image = await images.open(file)

const output = await image
  .autoOrient()
  .resize({ width: 1200, withoutEnlargement: true })
  .jpeg({ quality: 80 })
  .toBlob()

preview.src = URL.createObjectURL(output)

Use File/Blob, ArrayBuffer, Uint8Array, or a custom ImageSource for input. Use toUint8Array(), toBlob(), or toSink() for output. Browser security does not permit arbitrary local path strings, so toFile(path) is Node-only.

Bounded browser transforms. Rotation and row-reordering orientation use origin-private file storage when available. If that is unavailable, a lazy 64 MiB chunked-memory store handles ordinary photos and IndexedDB handles larger transforms without a source-sized heap allocation. Requests fail explicitly when no suitable persistent store is available.
PNG compression. Browser PNG output uses CompressionStream and currently accepts the default compressionLevel: 6 only. Other levels fail with UNSUPPORTED_OPERATION.

Open scientific rasters in a browser

Alpha application platform. PureJsImage 0.10.0 introduces the scientific application entrypoints used below. Provider and extension APIs remain experimental; the ordinary codec installation above remains the established npm workflow.

GSF and paired ENVI inputs use the portable scientific dataset API rather than the ordinary photographic codec registry. Pass a GSF File/Blob directly, or pass the ENVI header and binary as two independent browser inputs. Native samples, physical units, no-data markers, channel names, and wavelengths remain quantitative until an explicit renderer produces display pixels.

scientific-worker.ts
import { createScientificLibrary, renderScientificPlane } from 'purejsimage/scientific'
import { createScientificFileContext } from 'purejsimage/scientific/browser'
import { enviReader } from 'purejsimage/scientific/readers/envi'
import { gsfReader } from 'purejsimage/scientific/readers/gsf'

const science = createScientificLibrary({ readers: [gsfReader, enviReader] })
const document = await science.open(createScientificFileContext(gsfFile))
const surface = await document.openDataset(document.datasets[0].id)
const display = await renderScientificPlane(surface, {
  plane: { displayAxes: ['x', 'y'], fixedIndices: [] },
  range: { mode: 'percentile', low: 1, high: 99 },
  palette: 'viridis',
  relief: { azimuth: 315, elevation: 45, strength: 0.5 },
})

const cubeDocument = await science.open(createScientificFileContext(headerFile, { companions: [binaryFile] }))
const cube = await cubeDocument.openDataset(cubeDocument.datasets[0].id)

Keep expensive range scans and rendering in a Web Worker, transfer final display bytes to the main thread, and use Canvas only as that final display surface. The core reader and renderer do not depend on Canvas. The Scientific Raster Explorer demonstrates local file selection, paired-file ENVI opening, wavelength controls, false-color composition, relief, and honest binary-read timing entirely client-side. The OME-Zarr WSI viewer demonstrates measured remote Range access to visible logical chunks in sharded multiscale stores.

Create a library

A library is an immutable registry of codecs. Create it once at module scope and reuse it across requests and warm Lambda invocations.

images.ts
import { createImageLibrary } from 'purejsimage'
import { jpegCodec } from 'purejsimage/codecs/jpeg'
import { pngCodec } from 'purejsimage/codecs/png'
import { webpCodec } from 'purejsimage/codecs/webp'

export const images = createImageLibrary([
  jpegCodec,
  pngCodec,
  webpCodec,
])

console.log(images.formats())
// ['jpeg', 'png', 'webp']
Registration controls capability. Converting PNG to WebP requires both codecs: a decoder for the input and an encoder for the output. A missing codec fails explicitly; nothing is loaded dynamically.

For common web images, import allWebCodecs from purejsimage/codecs/web to register JPEG, PNG, WebP, and AVIF together. TIFF stays explicit to keep that aggregate focused. For tools that need every stable implementation, import allCodecs from purejsimage/codecs/all. Experimental HEIF/HEIC is deliberately excluded from both aggregates and requires the direct purejsimage/codecs/experimental/heic import described in the API reference.

Open an image

open() detects the registered format from content, not the filename. Node accepts a path, Buffer, Uint8Array, ArrayBuffer, Blob, or custom ImageSource. Browsers accept File/Blob, Uint8Array, ArrayBuffer, or a custom source.

inspect.ts
const image = await images.open('photo.jpg')
const metadata = await image.metadata()

console.log({
  width: metadata.width,
  height: metadata.height,
  format: metadata.format,
  orientation: metadata.orientation,
})

Metadata inspection avoids decoding all pixels. When called on a transformed pipeline, it reports the planned output dimensions and format.

Borrowed memory. Buffer, Uint8Array, and ArrayBuffer inputs are not copied. Do not mutate or detach them until every pipeline derived from the image has finished.

Remote or custom sources

A custom source exposes its byte length and a range-read function. Each in-range read must return exactly min(length, size - offset) bytes as a Uint8Array, or reject if the backing read fails. Returned bytes only need to remain valid until the next read begins.

range-source.ts
import type { ImageSource } from 'purejsimage'

const source: ImageSource = {
  size: contentLength,
  async read(offset, length) {
    return fetchRange(offset, length)
  },
}

const image = await images.open(source)

File, Blob, and custom sources use up to four aligned 256 KiB cache regions. This bounds the shared cache at 1 MiB while coalescing small reads. Contract violations and rejected custom reads surface as ImageError results.

Build a pipeline

Image pipelines are immutable and lazy. Each method returns a new Image; decoding begins only at toBuffer() or toFile(). You can safely branch from a common source.

variants.ts
const source = (await images.open(upload)).autoOrient()

const hero = source
  .resize({ width: 1600, height: 900, fit: 'cover' })
  .jpeg({ quality: 82 })

const thumb = source
  .resize({ width: 320, height: 320, fit: 'cover' })
  .webp({ quality: 76 })

const heroBytes = await hero.toBuffer()
const thumbBytes = await thumb.toBuffer()

Resize behavior

FitResultTypical use
coverFills exact dimensions and center-crops overflow. Default when both dimensions are present.Cards and hero images
containFits inside exact dimensions and pads the remaining canvas.Logos and product images
fillUses the exact dimensions without preserving aspect ratio.Known geometric assets
insidePreserves aspect ratio inside the maximum dimensions.General upload limits
outsidePreserves aspect ratio while meeting both minimum dimensions.Minimum-size masters

Resize uses lanczos3 by default to suppress downscale aliasing. Choose bilinear for a faster lower-quality path or nearest to preserve hard pixel edges. Set withoutEnlargement: true when small uploads should remain at their original size.

Contain options. position and background are available only with fit: 'contain', and fit options require both width and height.

Transform order

Spatial operations execute in call order. A crop after a resize uses resized coordinates, and a second resize works from the first resize result. rotate() uses clockwise degrees, flip() mirrors top-to-bottom, and flop() mirrors left-to-right.

Recipes

Build a whole-slide viewer from remote SVS tiles

An Aperio SVS file is a tiled, multi-resolution TIFF. A viewer does not need to download or decode the complete slide. Open the remote file through HttpRangeSource, inspect its pyramid with openAperioSvs(), and decode only the tiles that intersect the canvas viewport.

Keep file access and tile decoding in a Web Worker. The main thread chooses a pyramid level, requests visible tile coordinates, and draws transferred ImageBitmap objects. The following worker-side core opens a slide and converts one native RGB tile into a canvas-ready bitmap.

slide-worker.ts
import type { PixelBlock } from 'purejsimage'
import { openAperioSvs } from 'purejsimage/pathology'
import type { WholeSlideLevel } from 'purejsimage/pathology'
import { HttpRangeSource } from 'purejsimage/sources/http-range'
import { openTiffDocument } from 'purejsimage/tiff'

const source = await HttpRangeSource.open(slideUrl, {
  blockBytes: 65_536,
  maxCacheBytes: 1_048_576,
})

const document = await openTiffDocument(source, {
  maxInputBytes: 6_000_000_000,
  maxWidth: 250_000,
  maxHeight: 250_000,
  maxPixels: 20_000_000_000,
  maxDecodedBytes: 268_435_456,
  maxSegmentCount: 1_000_000,
  maxSegmentTableBytes: 33_554_432,
  maxEncodedSegmentBytes: 134_217_728,
})

const slide = await openAperioSvs(document)

const copyRgbBlock = (
  target: Uint8ClampedArray,
  targetWidth: number,
  targetHeight: number,
  block: PixelBlock,
): void => {
  if (block.format !== 'rgb8') throw new Error(`Expected RGB8, received ${block.format}`)
  if (block.x < 0 || block.y < 0 || block.x + block.width > targetWidth || block.y + block.height > targetHeight) {
    throw new Error('Decoded pixels lie outside the requested tile')
  }
  for (let row = 0; row < block.height; row += 1) {
    let input = row * block.stride
    let output = ((block.y + row) * targetWidth + block.x) * 4
    for (let column = 0; column < block.width; column += 1) {
      target[output] = block.data[input] ?? 0
      target[output + 1] = block.data[input + 1] ?? 0
      target[output + 2] = block.data[input + 2] ?? 0
      target[output + 3] = 255
      input += 3
      output += 4
    }
  }
}

const tileToBitmap = async (
  level: WholeSlideLevel,
  column: number,
  row: number,
  signal: AbortSignal,
): Promise<ImageBitmap> => {
  if (level.tileWidth === undefined || level.tileHeight === undefined) {
    throw new Error('The selected pyramid level is not tiled')
  }
  const width = Math.min(level.tileWidth, level.width - column * level.tileWidth)
  const height = Math.min(level.tileHeight, level.height - row * level.tileHeight)
  const rgba = new Uint8ClampedArray(width * height * 4)

  for await (const block of level.tile(column, row, { signal })) {
    try {
      copyRgbBlock(rgba, width, height, block)
    } finally {
      block.release?.()
    }
  }
  return createImageBitmap(new ImageData(rgba, width, height))
}

The canvas side needs four policies to remain responsive:

  1. Choose the finest level whose downsample is no greater than 1 / zoom.
  2. Convert the viewport bounds to level coordinates, divide by tileWidth and tileHeight, and request only the intersecting rows and columns.
  3. Create one AbortController per tile. Abort work as soon as its key leaves the visible set.
  4. Keep a bounded LRU of transferred bitmaps. Draw cached coarse-level tiles first so the previous view remains visible while sharper tiles arrive, and call bitmap.close() on eviction.
Range and CORS requirements. The slide server must answer byte requests with 206 Partial Content and an accurate Content-Range. Cross-origin browser use must allow your origin and expose Content-Range. If the storage service cannot provide those headers, place a range-preserving endpoint on your own origin.

The live whole-slide demo adds a four-request decode queue, stale-request cancellation, a 192-tile LRU, lower-resolution placeholders, pan and zoom math, and live transfer statistics. Read the complete worker implementation and canvas implementation for production-ready bounds checks and message handling.

Normalize an upload to JPEG

normalize.ts
const output = await (await images.open(upload))
  .autoOrient()
  .resize({ width: 2048, fit: 'inside', withoutEnlargement: true })
  .jpeg({ quality: 80, background: '#ffffff' })
  .toBuffer()

Retain EXIF and ICC metadata

Metadata is stripped by default. Opt in to EXIF and ICC independently. Reorienting pixels also normalizes the retained EXIF orientation so downstream viewers do not rotate them a second time.

preserve-metadata.ts
await (await images.open('camera.jpg'))
  .keepExif()
  .keepIcc()
  .autoOrient()
  .resize({ width: 1600, withoutEnlargement: true })
  .jpeg({ quality: 82 })
  .toFile('camera-web.jpg')

JPEG, PNG, and WebP support retained EXIF and compatible ICC profiles. TIFF supports compatible ICC profiles but not retained EXIF. Unsupported combinations fail explicitly.

Convert a Windows icon to PNG

The ICO decoder selects the largest entry, then prefers useful bit depth and alpha. It decodes only that selected PNG- or DIB-backed image.

favicon.ts
import { createImageLibrary } from 'purejsimage'
import { icoCodec } from 'purejsimage/codecs/ico'
import { pngCodec } from 'purejsimage/codecs/png'

const icons = createImageLibrary([icoCodec, pngCodec])

await (await icons.open('favicon.ico'))
  .resize({ width: 64, height: 64, fit: 'contain' })
  .png({ compressionLevel: 7 })
  .toFile('favicon.png')

Create a transparent contained PNG

contain.ts
await (await images.open('logo.webp'))
  .resize({
    width: 800,
    height: 800,
    fit: 'contain',
    background: 'transparent',
  })
  .png({ compressionLevel: 7 })
  .toFile('logo.png')

Compose ordered transforms

Use coordinates in the image state at that point in the pipeline. Here the crop uses resized coordinates, then the cropped image is resized again and rotated clockwise onto an opaque canvas.

crop.ts
await (await images.open('camera.jpg'))
  .autoOrient()
  .resize({ width: 1600 })
  .crop({ x: 200, y: 150, width: 1200, height: 900 })
  .resize({ width: 600, kernel: 'lanczos3' })
  .rotate(12, { background: '#ffffffff' })
  .flop()
  .jpeg({ quality: 80 })
  .toFile('transformed.jpg')

Deploy on Lambda

Keep the library at module scope. The immutable registry and small coefficient caches can be reused on warm invocations.

  1. Register only the codecs your endpoint accepts and produces.
  2. Set input limits to the smallest values the application accepts.
  3. Prefer downscaling and decoder-aware crops before expensive format conversion.
  4. Measure absolute peak RSS under realistic concurrent workloads, not only allocation deltas.
Size for CPU as well as memory. A 256 MiB Lambda completed all four measured 4000 × 3000 resize/conversion workflows with 121–156 MiB maximum use. But JPEG → WebP warm operation time fell from 10,601 ms at 256 MiB to 5,261 ms at 512 MiB and 2,533 ms at 1024 MiB while maximum use stayed at 120–122 MiB. For latency-sensitive endpoints, start at 1024 MiB even if the process consumes only about 150 MiB; choose 256 MiB only when its lower CPU allocation and roughly 10-second latency are acceptable. Re-measure with your images and concurrency. See the Lambda results.
handler.ts
const image = await images.open(body, {
  limits: {
    maxInputBytes: 15 * 1024 * 1024,
    maxWidth: 12_000,
    maxHeight: 12_000,
    maxPixels: 60_000_000,
    maxDecodedBytes: 240_000_000,
  },
})
Orientation, rotation, and temporary storage. EXIF orientations 3–8 and arbitrary-angle rotations use a 32 × 32 tile spool. Node stores the spool in lazy 1 MiB memory chunks by default and does not open temporary files. The spool is limited by the image dimensions and configured decoded-byte limits.

Pass { temporaryFiles: true } as the second argument to createImageLibrary() to opt into file storage under os.tmpdir(). This uses about one padded decoded frame of storage. It can substantially reduce process RSS, but the memory path was 20–28% faster across the measured orientation and arbitrary-rotation cases. The measured 4000 × 3000 RGBA orientation used 147.69 MiB peak RSS and 654.72 ms with memory, compared with 90.90 MiB and 820.48 ms with a file.

A tmpfs still consumes host memory outside process RSS. PureJsImage tests file creation, writing, reading, and truncation before consuming input rows. Failed setup or later file writes, including ENOSPC, move the spool to memory and preserve output. The temporary directory is removed on success or failure. An error that prevents recovery of bytes already written to the file becomes a structured ImageError.

Handle failures

PureJsImage uses five stable error categories. Check the code to decide whether to reject an upload, tighten limits, or report a capability boundary.

errors.ts
import { ImageError } from 'purejsimage'

try {
  return await pipeline.toBuffer()
} catch (error) {
  if (error instanceof ImageError) {
    console.warn(error.code, error.message)
  }
  throw error
}

See the error reference for the meaning of each category.