Toolbox
Open in GitHub

Susano

Susano library by JOYCO Studio.

npm gzip

Asset load orchestration made easy. A typed, promise-friendly loader for images, video, audio, and arbitrary data — with built-in progress tracking, per-call postprocessing, and pluggable loader types.

Mental model

Susano separates two things that are usually conflated:

  • Content — the expensive fetch (HTMLImageElement, decoded buffer, parsed JSON). Cached and deduped per URL. Loaded once, no matter how many call sites ask for it.
  • Result — what your call wants out of that content, via an optional postprocess. Per call, never cached. Two call sites can derive different results from the same shared content.

So load() returns a Promise<R> of your call's result, and batch() returns a Batch handle that tracks a fixed set. Reach the shared content loader (raw content, events) via susano.get(url). Want to cache a transformed result too? Encode the transform in a custom loader, so its output becomes the content.

Features

FeatureDescription
Scoped batchesbatch([...]) loads a fixed set and tracks only that set's progress/completion. Auto-starts; returns a handle with .promise / .progress / .completed.
Content dedupOne content fetch per URL across every load() / batch() — in-flight or already-loaded assets are joined, never re-fetched.
Per-call postprocesspostprocess is your call's projection of the shared content. It always runs for your call; results aren't cached.
Built-in loadersFirst-class image, video, audio, and generic out of the box.
Pluggable typesPass a { [type]: LoaderClass } map to new Susano(...) — the keys become the valid types, fully inferred for load() / batch().
Stable promisesload() resolves with your result; every Batch exposes a .promise. Await one result or a whole set.
Typed manifestTypeScript knows which loaderArgs and postprocess content type are valid for each registered type.

Install

pnpm add @joycostudio/susano

Quick start

import { susano } from '@joycostudio/susano'
 
const batch = susano.batch(
  [
    { url: '/hero.png', type: 'image' },
    { url: '/intro.mp4', type: 'video' },
    { url: '/music.mp3', type: 'audio' },
  ],
  {
    onProgress: ({ value }) => console.log(`${Math.round(value * 100)}%`),
    onCompleted: () => console.log('All assets loaded!'),
  }
)
 
await batch.promise

Quick start (React)

import { useEffect, useState } from 'react'
import { susano, type BatchProgressEventArgs } from '@joycostudio/susano'
 
function App() {
  const [progress, setProgress] = useState(0)
 
  useEffect(() => {
    susano.batch(
      [
        { url: '/hero.png', type: 'image' },
        { url: '/intro.mp4', type: 'video' },
      ],
      { onProgress: ({ value }: BatchProgressEventArgs) => setProgress(value) }
    )
  }, [])
 
  return <div>Loading: {Math.round(progress * 100)}%</div>
}

Architecture

LayerResponsibility
SusanoRegistry of loader classes keyed by type, plus the items cache (one content loader per URL). Exposes load(), batch(), get(). Its private per-call projection ensures content, then applies that call's postprocess.
SusanoLoaderCaches one piece of raw content per URL. Idempotent load(), tracks status / progress, emits loaded / progress / error. Does not postprocess.
BatchTracks progress + completion for a fixed set of loads. Owns its own counter and completion promise — concurrent batches never interfere.
Loader typesImageLoader, VideoLoader, AudioLoader, GenericLoader — each wraps a native element or a custom loadFn.

Completion is the resolution of a Promise.all over the batch — one-shot by construction. A failed load is fail-soft: it surfaces via onError and still advances the batch so a load screen can finish.


Default instance

The package ships a pre-configured susano singleton with image, video, audio, and generic loaders registered. For most apps this is the only instance you need.

import { susano } from '@joycostudio/susano'

Need a second, isolated registry? Construct your own — the loader map is the single source of truth, and load() / batch() are fully inferred from it:

import { Susano, ImageLoader, VideoLoader, AudioLoader, GenericLoader } from '@joycostudio/susano'
 
const s = new Susano({
  image: ImageLoader,
  video: VideoLoader,
  audio: AudioLoader,
  generic: GenericLoader,
})
 
s.load('/x.png', { type: 'image' }) // `type` is 'image' | 'video' | 'audio' | 'generic'

Add your own loader by adding a key — no separate type map, no registerLoader, nothing to keep in sync:

const s = new Susano({ image: ImageLoader, json: JSONLoader })
//                                          ^ adds 'json' as a valid type, fully typed

Core API

Susano

new Susano(loaders)loaders is a { [type]: LoaderClass } map. Its keys become the valid types; each loader's content/loaderArgs types are inferred per key.

MemberDescription
load(url, cnfg)Load one asset; returns a Promise<R> for this call's result.
batch(entries, handlers?)Load a fixed set and track its progress/completion; returns a Batch.
get(url)The shared content loader cached for url, or undefined. Shorthand for items.get(url).
itemsMap<url, loader> — every content loader, keyed by URL.

A load config splits construction from projection:

susano.load(url, {
  type,                 // which loader
  loaderArgs?,          // construction: how to produce the content (srcSet, loadEvent, loadFn…)
  cache?,               // false → force a fresh content fetch (default true)
  postprocess?,         // per call: (content, loader) => R | Promise<R>
  onLoaded?,            // per call: (result, loader) => void
  onProgress?,          // observes the shared content fetch: (value, loader) => void
  onError?,             // per call: (error, loader) => void
})

loaderArgs defines the content and is honored once (when the loader is first created for a URL). postprocess / onLoaded / onError are per call — each load()/batch entry gets its own.

load()

const bitmap = await susano.load('/hero.png', {
  type: 'image',
  postprocess: async (img) => createImageBitmap(img),
}) // ImageBitmap (this call's result)
 
susano.get('/hero.png')?.content // the shared HTMLImageElement (cached)

Calling load() again for the same URL reuses the cached content (no second fetch) and runs this call's postprocess. Pass cache: false to force a fresh content fetch that overwrites the cached content.

batch()

const batch = susano.batch(
  [
    { url: '/studio.glb', type: 'gltf', postprocess: parseScene },
    { url: '/env.exr', type: 'exr' },
    { url: '/noise.png', type: 'texture', cache: false },
  ],
  { onProgress, onCompleted, onError }
)

Each entry runs a per-call load. Content fetches dedupe across entries (and across any concurrent load()), so an in-flight or already-loaded asset is joined, not re-fetched. Auto-starts; returns a Batch. An empty batch completes immediately with value: 1.

Batch

The handle returned by susano.batch(). Auto-starts on the next microtask, so handlers passed to batch() (or .on() listeners attached immediately after) never miss a tick.

MemberTypeDescription
promisePromise<Batch>Resolves once the whole set has settled.
progressnumber0 → 1, readable at any time.
completedbooleantrue once finished.
loadCountnumberLoads settled so far.
loadLengthnumberTotal loads in the batch.
itemsBatchItem[]{ loader, promise } per entry (entry order).
type BatchHandlers = {
  onProgress?: (e: BatchProgressEventArgs) => void  // { value, loader, batch }
  onCompleted?: (batch: Batch) => void
  onError?: (e: BatchErrorEventArgs) => void         // { loader, error, batch }
}

Batch extends TinyEmitter — subscribe directly with .on('progress' | 'error' | 'completed', …). The 'completed' event fires exactly once; 'progress''s loader is null for the empty-batch tick.


SusanoLoader

Base class for every loader. Caches one piece of raw content per URL. You instantiate subclasses; reach any loader via susano.get(url).

Properties

PropertyTypeDescription
urlstringThe source URL.
status'idle' | 'loading' | 'loaded' | 'error'Content lifecycle state.
loadingbooleantrue while the content fetch is in flight.
loadedbooleantrue once content has loaded.
contentTThe raw loaded content.
progressnumber0 → 1.
promisePromise<T>Stable promise resolving with the raw content.

Methods

MethodDescription
load(cache = true)Start (or join) the content fetch. Idempotent: repeat calls return the same promise without re-fetching. cache: false forces a fresh fetch that overwrites the content.

Events

susano.load('/photo.png', { type: 'image' })
const loader = susano.get('/photo.png')!
loader.on('loaded', (l) => console.log(l.content))
loader.on('error', (err) => console.error(err))

Postprocess

postprocess is a per-call projection from the shared content to your result. It runs every call (never cached), and an async postprocess blocks that call's loaded / promise until it resolves.

const bitmap: ImageBitmap = await susano.load('/hero.png', {
  type: 'image',
  postprocess: async (img) => createImageBitmap(img),
})

Two calls for the same URL share one content fetch but each run their own postprocess — so there's no composition or "which transform wins" ambiguity. If a transform is expensive and you want its output cached/shared, promote it into a custom loader so the transformed value becomes the content.


Built-in loaders

image

Loads images via HTMLImageElement.

import { ImageLoader, type SusanoImageLoaderConfig } from '@joycostudio/susano'
loaderArgsTypeDescription
srcSetstringMaps to img.srcset
sizesstringMaps to img.sizes
susano.load('/photo.png', {
  type: 'image',
  loaderArgs: { srcSet: '/photo.png 1x, /photo-2x.png 2x', sizes: '100vw' },
})

video / audio

Load via HTMLVideoElement / HTMLAudioElement.

import { VideoLoader, AudioLoader } from '@joycostudio/susano'
loaderArgsTypeDefaultDescription
video/audioelementnew elementExisting element to load into.
loadEvent'canplay' | 'canplaythrough''canplay'Event that signals load completion.
susano.load('/intro.mp4', { type: 'video', loaderArgs: { loadEvent: 'canplaythrough' } })

generic

Fully custom content production. You provide loadFn; Susano handles dedup, progress, and promises.

import { GenericLoader, type GenericLoadFn } from '@joycostudio/susano'
 
type GenericLoadFn = (ctx: {
  url: string
  done: (content: any) => void
  error: (error: Error) => void
  progress: (value: number) => void // 0 → 1
}) => void
susano.load('/data.json', {
  type: 'generic',
  loaderArgs: {
    loadFn: ({ url, done, error }) => {
      fetch(url).then((r) => r.json()).then(done).catch(error)
    },
  },
})

Recipes

Awaiting a single result

const img = susano.load('/hero.png', { type: 'image' })
document.body.appendChild(await img.promise)

Awaiting a whole batch

const [a, b] = await Promise.all([
  susano.load('/a.png', { type: 'image' }),
  susano.load('/b.mp4', { type: 'video' }),
])
// …or drive a progress bar with a batch over the same URLs (dedupes the fetches):
await susano.batch([
  { url: '/a.png', type: 'image' },
  { url: '/b.mp4', type: 'video' },
]).promise

Per-call postprocess, shared fetch

// Both share one /noise.png fetch; each gets its own result.
const bitmap = await susano.load('/noise.png', { type: 'image', postprocess: (i) => createImageBitmap(i) })
const raw = await susano.load('/noise.png', { type: 'image' }) // raw HTMLImageElement, shared fetch

Fetch with streamed progress

susano.load('/big.bin', {
  type: 'generic',
  loaderArgs: {
    loadFn: async ({ url, done, error, progress }) => {
      try {
        const res = await fetch(url)
        const total = Number(res.headers.get('content-length')) || 0
        const reader = res.body!.getReader()
        const chunks: Uint8Array[] = []
        let received = 0
        while (true) {
          const { done: d, value } = await reader.read()
          if (d) break
          chunks.push(value)
          received += value.length
          if (total) progress(received / total)
        }
        done(new Blob(chunks))
      } catch (e) {
        error(e as Error)
      }
    },
  },
})

Custom loader type

A custom loader implements protected _load() — the raw content fetch. The base owns the promise, dedup, and status; call _onLoaded() / _onError() / _onProgress() when the work settles. (If your loader's job is to produce a transformed value, that value becomes the cached content here.)

import { Susano, SusanoLoader } from '@joycostudio/susano'
 
class JSONLoader extends SusanoLoader<unknown> {
  protected _load() {
    fetch(this.url)
      .then((res) => res.json())
      .then((content) => {
        this.content = content
        this._onLoaded()
      })
      .catch((e) => this._onError(e))
  }
}
 
const s = new Susano({ json: JSONLoader })
 
await s.batch([{ url: '/config.json', type: 'json' }]).promise

Lazy load + batch, same URL

load() and batch() share one content loader per URL — so an eager preload and a later manifest batch share the fetch.

susano.load('/hero.png', { type: 'image' }) // fires immediately
const batch = susano.batch([{ url: '/hero.png', type: 'image' }]) // joins it — no second fetch
 
await batch.promise
susano.get('/hero.png') // the one shared content loader both used

Package exports

Import pathContents
@joycostudio/susanosusano (default instance), Susano, Batch, SusanoLoader, ImageLoader, VideoLoader, AudioLoader, GenericLoader, VERSION, and all types (LoadOptions, BatchEntry, BatchItem, BatchHandlers, BatchProgressEventArgs, BatchErrorEventArgs, ContentOf, …).

Development

pnpm install
pnpm test          # Vitest suite (run); pnpm test:watch for watch mode
pnpm typecheck     # tsc --noEmit
pnpm build         # tsup bundle
  • docs/architecture.md — how Susano is built and why (content-vs-result, the dedup state machine, batch completion), with diagrams.
  • docs/testing.md — the test setup (Vitest + jsdom), conventions, and patterns, written to be replicated across JOYCO libraries.

Related Toolboxs