Skip to content

Custom Adapter

Build a custom adapter when you’re uploading to your own backend or a third party service. You implement one interface (UploadAdapter) to handle the upload, and optionally a second (StatusChecker) if your backend needs time to process after upload completes. Everything else is handled for you: queueing, concurrency, retries, and UI state.

An adapter is a class that implements UploadAdapter<TOptions>. The generic type parameter lets you define whatever options your backend needs.

interface UploadAdapter<TOptions = Record<string, unknown>> {
upload(
file: FileRef,
options: TOptions,
callbacks: { onProgress: (pct: number) => void },
signal: AbortSignal,
): Promise<UploadResult>;
}
type UploadResult = {
videoId: string;
playbackUrl?: string;
metadata?: Record<string, unknown>;
};

Here’s a complete example that requests a pre-signed URL from your backend, then uploads directly to storage:

import type {
FileRef,
UploadAdapter,
UploadResult,
WebFileRef,
} from "@hyperserve/upload";
type MyUploadOptions = {
folderId?: string;
};
class MyAdapter implements UploadAdapter<MyUploadOptions> {
constructor(private uploadEndpoint: string) {}
async upload(
file: FileRef,
options: MyUploadOptions,
callbacks: { onProgress: (pct: number) => void },
signal: AbortSignal,
): Promise<UploadResult> {
const { uploadUrl, videoId } = await fetch(this.uploadEndpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: file.name,
size: file.size,
folderId: options.folderId,
}),
signal,
}).then((r) => r.json());
await uploadWithProgress(
uploadUrl,
(file as WebFileRef).raw,
callbacks.onProgress,
signal,
);
return { videoId };
}
}
function uploadWithProgress(
url: string,
file: File,
onProgress: (pct: number) => void,
signal: AbortSignal,
): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
onProgress(Math.round((e.loaded / e.total) * 100));
}
};
xhr.onload = () =>
xhr.status < 400
? resolve()
: reject(new Error(`Upload failed: ${xhr.status}`));
xhr.onerror = () => reject(new Error("Upload failed"));
signal.addEventListener("abort", () => xhr.abort());
xhr.open("PUT", url);
xhr.send(file);
});
}

If your backend returns a playbackUrl in UploadResult, the file transitions directly to "ready" with no status checker needed.

return { videoId, playbackUrl };

If your backend needs time to process after upload (transcoding, thumbnail generation, etc.), implement StatusChecker. The library calls it after each successful upload.

interface StatusChecker {
checkStatus(options: {
uploadResult: UploadResult;
onStatusChange: (
status: "processing" | "ready" | "failed",
playbackUrl?: string,
statusDetail?: string,
) => void;
signal: AbortSignal;
}): void;
}
import type { StatusChecker } from "@hyperserve/upload";
class MyStatusChecker implements StatusChecker {
constructor(private statusEndpoint: string) {}
checkStatus({ uploadResult, onStatusChange, signal }) {
const poll = async () => {
while (!signal.aborted) {
const res = await fetch(
`${this.statusEndpoint}/${uploadResult.videoId}`,
{ signal },
).then((r) => r.json());
if (res.status === "ready") {
onStatusChange("ready", res.playbackUrl);
return;
}
if (res.status === "failed") {
onStatusChange("failed");
return;
}
onStatusChange("processing", undefined, res.statusDetail);
await new Promise((r) => setTimeout(r, 3000));
}
};
poll();
}
}

statusDetail is optional. Surface it when your backend provides it (for example, "Transcoding 1080p"). The library exposes it as file.statusDetail during processing.

import type { UploadConfig } from "@hyperserve/upload";
const config: UploadConfig<MyUploadOptions> = {
adapter: new MyAdapter("/api/upload-url"),
uploadOptions: { folderId: "my-folder" },
statusChecker: new MyStatusChecker("/api/video-status"),
maxConcurrentUploads: 2,
onFileReady: (file) => {
console.log("Ready:", file.videoId, file.playbackUrl);
},
};

The generic on UploadConfig<MyUploadOptions> ensures uploadOptions is type-checked against your adapter.

If you prefer to push status updates rather than poll, skip the StatusChecker entirely and call updateFileStatus from useUpload() when your server sends an event.

const { updateFileStatus } = useUpload();
socket.onmessage = (event) => {
const { videoId, status, playbackUrl } = JSON.parse(event.data);
updateFileStatus(videoId, status, playbackUrl);
};

Without a StatusChecker, files stay in "processing" after upload until updateFileStatus is called.