Susano
Susano library by JOYCO Studio.
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
| Feature | Description |
|---|---|
| Batched loading | Queue any number of assets with .add() and kick off the whole batch with .start() — progress is tracked across the set. |
| Standalone loading | Use .load() to fire a single asset immediately, outside the batch queue. |
| Built-in loaders | First-class support for image, video, audio, and generic out of the box. |
| Pluggable loader types | registerLoader(type, Loader) extends the registry with your own typed loaders — full TS inference for add() / load() options. |
| Postprocess pipeline | Optional async postprocess transforms the loaded content (e.g. Image → ImageBitmap) and blocks the loader's resolution until it completes. |
| Stable promises | Every loader exposes a stable .promise that resolves with the final content — await one asset or the whole batch. |
| Dedupe by URL | Repeated .add(url, ...) calls return the same loader instance; extra configs are composed via _appendConfig. |
| Event-driven | Susano and every SusanoLoader extend TinyEmitter — subscribe to start, progress, completed, loaded, and error. |
| Typed manifest | TypeScript knows which loaderArgs shape is valid for each registered type. |
Install
pnpm add @joycostudio/susanoQuick 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
| Layer | Responsibility |
|---|---|
| Susano | Registry of loader classes keyed by type. Owns the batch queue, the items map (deduped by URL), and emits batch events. |
| SusanoLoader | Base class. Holds a stable promise, tracks progress + loaded, runs postprocess, and emits loaded / progress / error. |
| Loader types | ImageLoader, VideoLoader, AudioLoader, GenericLoader — each wraps a native element or a custom loadFn. |
| TinyEmitter | Base 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
| Method | Description |
|---|---|
registerLoader(type, Loader) | Register a loader class under a type key. |
queue | Array of loaders waiting for the next .start(). |
active | Array of loaders currently loading in the running batch. |
items | Map<url, loader> — every loader ever added, keyed by URL. |
loadCount | Number of loaders finished in the running batch. |
loadLength | Size 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 // HTMLImageElementStarting 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)
})| Event | Payload | Fires when |
|---|---|---|
start | susano | .start() is called. |
progress | { value, item, susano } | A loader in the running batch finishes. |
completed | susano | Every 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
| Property | Type | Description |
|---|---|---|
url | string | The source URL. |
loaded | boolean | true once load() (and postprocess) has resolved. |
content | T | The raw loaded content (e.g. HTMLImageElement, HTMLVideoElement). |
progress | number | 0 → 1. |
weight | number | Weight in the batch total (currently 1 for all built-ins). |
promise | Promise<R> | Stable promise that resolves with the final (post-processed) content. |
config | SusanoLoaderConfig<T, R> | Composed config from all add() / load() calls for this URL. |
Methods
| Method | Description |
|---|---|
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.promiseIf 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'| Option | Type | Description |
|---|---|---|
srcSet | string | Maps to img.srcset |
sizes | string | Maps 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'| Option | Type | Default | Description |
|---|---|---|---|
video | HTMLVideoElement | new element | Existing 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'| Option | Type | Default | Description |
|---|---|---|---|
audio | HTMLAudioElement | new element | Existing 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'| Option | Type | Description |
|---|---|---|
loadFn | GenericLoadFn | Required. Custom load function. |
type GenericLoadFn = (ctx: {
url: string
done: (content: any) => void
error: (error: Error) => void
progress: (value: number) => void // 0 → 1
}) => voidsusano.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.promiseProgress 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')!.promiseBatched vs standalone
| Mode | API | Triggers load | Counted in progress |
|---|---|---|---|
| Batched | susano.add() | on start() | yes |
| Standalone | susano.load() | immediately | no |
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 path | Contents |
|---|---|
@joycostudio/susano | susano (default instance), Susano, SusanoLoader, ImageLoader, VideoLoader, AudioLoader, GenericLoader, VERSION, and all types. |