Toolbox

Susano

See on GitHub

Susano library by JOYCO Studio.

npm gzip

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

Features

FeatureDescription
Batched loadingQueue any number of assets with .add() and kick off the whole batch with .start() — progress is tracked across the set.
Standalone loadingUse .load() to fire a single asset immediately, outside the batch queue.
Built-in loadersFirst-class support for image, video, audio, and generic out of the box.
Pluggable loader typesregisterLoader(type, Loader) extends the registry with your own typed loaders — full TS inference for add() / load() options.
Postprocess pipelineOptional async postprocess transforms the loaded content (e.g. ImageImageBitmap) and blocks the loader's resolution until it completes.
Stable promisesEvery loader exposes a stable .promise that resolves with the final content — await one asset or the whole batch.
Dedupe by URLRepeated .add(url, ...) calls return the same loader instance; extra configs are composed via _appendConfig.
Event-drivenSusano and every SusanoLoader extend TinyEmitter — subscribe to start, progress, completed, loaded, and error.
Typed manifestTypeScript knows which loaderArgs shape is valid for each registered type.

Install

pnpm add @joycostudio/susano

Quick start

import { susano } from '@joycostudio/susano'
 
// Queue assets
susano.add('/hero.png', { type: 'image' })
susano.add('/intro.mp4', { type: 'video' })
susano.add('/music.mp3', { type: 'audio' })
 
// Start loading
susano.start({
  onProgress: ({ value }) => console.log(`${Math.round(value * 100)}%`),
  onCompleted: () => console.log('All assets loaded!'),
})

Quick start (React)

import { useEffect, useState } from 'react'
import { susano, type ProgressEventArgs } from '@joycostudio/susano'
 
function App() {
  const [progress, setProgress] = useState(0)
 
  useEffect(() => {
    susano.add('/hero.png', { type: 'image' })
    susano.add('/intro.mp4', { type: 'video' })
 
    const onProgress = ({ value }: ProgressEventArgs) => setProgress(value)
    susano.on('progress', onProgress)
    susano.start({ onCompleted: () => console.log('done') })
 
    return () => {
      susano.off('progress', onProgress)
    }
  }, [])
 
  return <div>Loading: {Math.round(progress * 100)}%</div>
}

Architecture

LayerResponsibility
SusanoRegistry of loader classes keyed by type. Owns the batch queue, the items map (deduped by URL), and emits batch events.
SusanoLoaderBase class. Holds a stable promise, tracks progress + loaded, runs postprocess, and emits loaded / progress / error.
Loader typesImageLoader, VideoLoader, AudioLoader, GenericLoader — each wraps a native element or a custom loadFn.
TinyEmitterBase event emitter. Both Susano and every loader inherit on / once / off / emit.

Default instance

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

import { susano } from '@joycostudio/susano'

Need a second, isolated queue? Construct your own:

import { Susano, ImageLoader, VideoLoader, AudioLoader, GenericLoader } from '@joycostudio/susano'
 
const s = new Susano<{
  image: { type: 'image'; loader: typeof ImageLoader }
  video: { type: 'video'; loader: typeof VideoLoader }
  audio: { type: 'audio'; loader: typeof AudioLoader }
  generic: { type: 'generic'; loader: typeof GenericLoader }
}>()
 
s.registerLoader(ImageLoader.type, ImageLoader)
s.registerLoader(VideoLoader.type, VideoLoader)
s.registerLoader(AudioLoader.type, AudioLoader)
s.registerLoader(GenericLoader.type, GenericLoader)

Core API

Susano

import { Susano } from '@joycostudio/susano'
 
const s = new Susano<T extends LoaderTypes>()

The generic T maps type strings to their loader classes. TypeScript uses it to infer the loaderArgs shape accepted by .add() and .load() for each type.

Registry

MethodDescription
registerLoader(type, Loader)Register a loader class under a type key.
queueArray of loaders waiting for the next .start().
activeArray of loaders currently loading in the running batch.
itemsMap<url, loader> — every loader ever added, keyed by URL.
loadCountNumber of loaders finished in the running batch.
loadLengthSize of the running batch.

Queueing

const item = susano.add(url, { type, loaderArgs? })

Appends a loader to the batch queue. If a loader already exists for url, the same instance is returned and loaderArgs is composed onto it (callbacks chain, postprocess wraps the previous one).

const img = susano.add('/photo.png', {
  type: 'image',
  loaderArgs: {
    srcSet: '/photo-2x.png 2x',
    sizes: '100vw',
    onLoaded: (loader) => console.log('photo ready', loader.content),
  },
})

Standalone loading

const item = susano.load(url, { type, loaderArgs? })

Registers (or reuses) a loader for url and calls .load() on it immediately. Does not add it to the batch queue, so .start() progress is unaffected.

const lazy = susano.load('/lazy.png', { type: 'image' })
await lazy.promise // HTMLImageElement

Starting a batch

susano.start({
  onProgress?: (progress: ProgressEventArgs) => void,
  onCompleted?: (susano: Susano<T>) => void,
})

Drains the queue into active, resets counters, and kicks off every loader in parallel. onProgress fires once per finished loader; onCompleted fires once when every loader in the batch has resolved.

susano.start({
  onProgress: ({ value, item }) => {
    // value: 0 → 1
    // item: the loader that just finished
  },
  onCompleted: (s) => console.log('batch done'),
})

Events

type SusanoEventMap = {
  start: Susano<T>
  progress: { value: number; item: SusanoLoader; susano: Susano<T> }
  completed: Susano<T>
}
 
susano.on('progress', ({ value, item }) => {
  console.log(`${Math.round(value * 100)}%`, item.url)
})
EventPayloadFires when
startsusano.start() is called.
progress{ value, item, susano }A loader in the running batch finishes.
completedsusanoEvery loader in the batch is done.

SusanoLoader

Base class for every loader. You only instantiate subclasses — but these properties and events are available on every loader returned by .add() / .load().

import { SusanoLoader, type SusanoLoaderConfig } from '@joycostudio/susano'

Config

type SusanoLoaderConfig<T, R = T> = {
  onLoaded?: (loader: SusanoLoader<T, R>) => void
  onProgress?: (loader: SusanoLoader<T, R>) => void
  postprocess?: (content: T, loader: SusanoLoader<T, R>) => R | Promise<R>
}

All built-in loader configs extend this, so every loader supports onLoaded, onProgress, and postprocess.

Properties

PropertyTypeDescription
urlstringThe source URL.
loadedbooleantrue once load() (and postprocess) has resolved.
contentTThe raw loaded content (e.g. HTMLImageElement, HTMLVideoElement).
progressnumber01.
weightnumberWeight in the batch total (currently 1 for all built-ins).
promisePromise<R>Stable promise that resolves with the final (post-processed) content.
configSusanoLoaderConfig<T, R>Composed config from all add() / load() calls for this URL.

Methods

MethodDescription
load()Start the load. Safe to call multiple times; returns the same promise.

Events

type SusanoLoaderEventMap = {
  loaded: SusanoLoader
  progress: SusanoLoader
  error: unknown
}
 
const img = susano.add('/photo.png', { type: 'image' })
img.on('loaded', (loader) => console.log(loader.content))
img.on('error', (err) => console.error(err))

Postprocess

postprocess lets you transform the raw content before the loader resolves. The loaded event and the promise both wait for it. Useful for decoding into bitmaps, generating thumbnails, or parsing payloads on a worker.

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

If you add() the same URL again with another postprocess, the new function wraps the previous one — the output of the first feeds the input of the second.


Built-in loaders

image

Loads images via HTMLImageElement. Resolves with the element (or whatever postprocess returns).

import { ImageLoader, type SusanoImageLoaderConfig } from '@joycostudio/susano'
OptionTypeDescription
srcSetstringMaps to img.srcset
sizesstringMaps to img.sizes
susano.add('/photo.png', {
  type: 'image',
  loaderArgs: {
    srcSet: '/photo.png 1x, /photo-2x.png 2x',
    sizes: '(max-width: 600px) 480px, 800px',
  },
})

video

Loads video via HTMLVideoElement.

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

Pass an existing <video> element via video if you need to preload into a DOM node you already control.

audio

Loads audio via HTMLAudioElement.

import { AudioLoader, type SusanoAudioLoaderConfig } from '@joycostudio/susano'
OptionTypeDefaultDescription
audioHTMLAudioElementnew elementExisting audio element to load into.
loadEvent'canplay' | 'canplaythrough''canplay'Event that signals load completion.
susano.add('/music.mp3', {
  type: 'audio',
  loaderArgs: { loadEvent: 'canplaythrough' },
})

generic

Fully custom loader. You provide the loadFn; Susano handles progress, promises, postprocess, and batch bookkeeping.

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

Call progress(v) inside your loadFn to report incremental progress (e.g. for fetch-with-reader streams).


Recipes

Awaiting a single asset

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

Awaiting the whole batch

const items = [
  susano.add('/a.png', { type: 'image' }),
  susano.add('/b.mp4', { type: 'video' }),
  susano.add('/c.mp3', { type: 'audio' }),
]
 
susano.start()
const [a, b, c] = await Promise.all(items.map((i) => i.promise))

Decoding to ImageBitmap via postprocess

const hero = susano.add('/hero.png', {
  type: 'image',
  loaderArgs: {
    postprocess: async (el) => createImageBitmap(el),
  },
})
 
susano.start()
const bitmap: ImageBitmap = await hero.promise

Progress bar

susano.start({
  onProgress: ({ value, item }) => {
    progressBar.style.width = `${value * 100}%`
    label.textContent = item.url
  },
  onCompleted: () => {
    progressBar.classList.add('done')
  },
})

Loading JSON with a generic loader

susano.add('/config.json', {
  type: 'generic',
  loaderArgs: {
    loadFn: ({ url, done, error }) => {
      fetch(url).then((r) => r.json()).then(done).catch(error)
    },
  },
})

Fetch with streamed progress

susano.add('/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)
      }
    },
  },
})

Reusing an existing <video> element

const el = document.querySelector<HTMLVideoElement>('#hero-video')!
 
susano.add('/intro.mp4', {
  type: 'video',
  loaderArgs: { video: el, loadEvent: 'canplaythrough' },
})

Custom loader type

import { Susano, SusanoLoader, type SusanoLoaderConfig } from '@joycostudio/susano'
 
class JSONLoader extends SusanoLoader<unknown> {
  static type = 'json' as const
 
  constructor(url: string, cnfg: SusanoLoaderConfig<unknown> = {}) {
    super(url, cnfg)
  }
 
  async load() {
    if (this.loaded) { this._onLoaded(); return this.promise }
    try {
      const res = await fetch(this.url)
      this.content = await res.json()
      this._onLoaded()
    } catch (e) {
      this._onError(e)
    }
    return this.promise
  }
}
 
const s = new Susano<{ json: { type: 'json'; loader: typeof JSONLoader } }>()
s.registerLoader(JSONLoader.type, JSONLoader)
 
s.add('/config.json', { type: 'json' })
s.start()

Standalone + batched, same URL

Calling .add() and .load() for the same URL reuses the same loader instance — so a standalone preload and a later batch entry share progress and promise.

const lazy = susano.load('/hero.png', { type: 'image' })   // fires immediately
susano.add('/hero.png', { type: 'image' })                  // joins the batch
 
// Both are the same loader under the hood:
lazy.promise === susano.items.get('/hero.png')!.promise

Batched vs standalone

ModeAPITriggers loadCounted in progress
Batchedsusano.add()on start()yes
Standalonesusano.load()immediatelyno

Use batched loading for everything you want to gate behind a single progress bar (hero screens, level loads). Use standalone for opportunistic or late-discovered assets that shouldn't hold up the main loading screen.


Package exports

Import pathContents
@joycostudio/susanosusano (default instance), Susano, SusanoLoader, ImageLoader, VideoLoader, AudioLoader, GenericLoader, VERSION, and all types.

Related Toolboxs