Susano
Susano library by JOYCO Studio.
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
| Feature | Description |
|---|---|
| Scoped batches | batch([...]) loads a fixed set and tracks only that set's progress/completion. Auto-starts; returns a handle with .promise / .progress / .completed. |
| Content dedup | One content fetch per URL across every load() / batch() — in-flight or already-loaded assets are joined, never re-fetched. |
| Per-call postprocess | postprocess is your call's projection of the shared content. It always runs for your call; results aren't cached. |
| Built-in loaders | First-class image, video, audio, and generic out of the box. |
| Pluggable types | Pass a { [type]: LoaderClass } map to new Susano(...) — the keys become the valid types, fully inferred for load() / batch(). |
| Stable promises | load() resolves with your result; every Batch exposes a .promise. Await one result or a whole set. |
| Typed manifest | TypeScript knows which loaderArgs and postprocess content type are valid for each registered type. |
Install
pnpm add @joycostudio/susanoQuick 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.promiseQuick 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
| Layer | Responsibility |
|---|---|
| Susano | Registry 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. |
| SusanoLoader | Caches one piece of raw content per URL. Idempotent load(), tracks status / progress, emits loaded / progress / error. Does not postprocess. |
| Batch | Tracks progress + completion for a fixed set of loads. Owns its own counter and completion promise — concurrent batches never interfere. |
| Loader types | ImageLoader, VideoLoader, AudioLoader, GenericLoader — each wraps a native element or a custom loadFn. |
Completion is the resolution of a
Promise.allover the batch — one-shot by construction. A failed load is fail-soft: it surfaces viaonErrorand 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 typedCore 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.
| Member | Description |
|---|---|
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). |
items | Map<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.
| Member | Type | Description |
|---|---|---|
promise | Promise<Batch> | Resolves once the whole set has settled. |
progress | number | 0 → 1, readable at any time. |
completed | boolean | true once finished. |
loadCount | number | Loads settled so far. |
loadLength | number | Total loads in the batch. |
items | BatchItem[] | { 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
| Property | Type | Description |
|---|---|---|
url | string | The source URL. |
status | 'idle' | 'loading' | 'loaded' | 'error' | Content lifecycle state. |
loading | boolean | true while the content fetch is in flight. |
loaded | boolean | true once content has loaded. |
content | T | The raw loaded content. |
progress | number | 0 → 1. |
promise | Promise<T> | Stable promise resolving with the raw content. |
Methods
| Method | Description |
|---|---|
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'loaderArgs | Type | Description |
|---|---|---|
srcSet | string | Maps to img.srcset |
sizes | string | Maps 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'loaderArgs | Type | Default | Description |
|---|---|---|---|
video/audio | element | new element | Existing 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
}) => voidsusano.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' },
]).promisePer-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 fetchFetch 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' }]).promiseLazy 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 usedPackage exports
| Import path | Contents |
|---|---|
@joycostudio/susano | susano (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 bundledocs/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.