How to go about uploading to external API?

Hello there,

I just found about tus and I would really love to get some advice from you guys.
We have a SvelteKit app that is deployed via node-adapter.

The application is sort of a youtube clone where users can upload videos.
For the video hosting we are using Bunny Stream.

I quickly realised that we can’t just upload the video all at once.
Today in Bunny docs I found that we could use TUS resumable upload (see: TUS Resumable Uploads).

But here’s my issue. I just can’t wrap my head around how this should work.
We have an front-end where can’t just place all the needed code, since we can’t expose the AccessKey for Bunny API.
So I was thinking we will call a SvelteKit endpoint (API) that will then call Bunny API with the TUS upload request.

But I don’t really know where to start a what tools should I use.
How I think it should work: Our webapp frontend file upload → API endpoint request → TUS API request to Bunny Stream

Could you please help me out and direct me with the right steps?
Thank you so much.

From quickly skimming their documentation, the upload itself (i.e. file transfer) should happen directly from the client to Bunny without going through your servers. To authentication your application and avoid tampering, you have to supply a signature that is generated on your server. So the flow would be:

  1. Use wants to start an upload.
  2. Client sends an API to your server which generates a signature for Bunny (TUS Resumable Uploads) and responds with signature, expiration time, library id and video id)
  3. Client begins upload directly to Bunny using the received values.

The example in TUS Resumable Uploads showcases welll how to do the actual transfer once the signature is available. The docs don’t mention it, but the example uses GitHub - tus/tus-js-client: A pure JavaScript client for the tus resumable upload protocol.

I hope this helps.

Thank you for the answer. After a good night sleep :smile: I came up with the same solution.
And also realised that the client should just get the signature from the API and start uploading with the provided values from the API.

I also ended up using Uppy for easier and more complete integration.

For anyone interested or having the same scenario here’s the code.
Please note I did not test the code yet.

npm install @uppy/core @uppy/locales @uppy/svelte @uppy/tus
// tus-file-upload.svelte
<script lang="ts">
	import Uppy from '@uppy/core';
	import Tus from '@uppy/tus';
	import Czech from '@uppy/locales/lib/cs_CZ';
	import { Dashboard } from '@uppy/svelte';
 
	// Uppy CSS
	import '@uppy/core/dist/style.min.css';
	import '@uppy/dashboard/dist/style.min.css';
 
	import { PUBLIC_BUNNY_LIBRARY_ID } from '$env/static/public';
	import { fieldProxy } from 'sveltekit-superforms';
 
	interface TusUploadSignature {
		presignedSignature: string;
		expiresAt: any; // Number
		videoId: string;
		collectionId: string;
	}
 
	const uppy = new Uppy({
		locale: Czech,
		restrictions: {
			allowedFileTypes: [
				'.4mv',
				'.amv',
				'.avi',
				'.flv',
				'.m4p',
				'.m4v',
				'.mkv',
				'.mov',
				'.mp3',
				'.mp4',
				'.mpeg',
				'.mpg',
				'.mxf',
				'.ogg',
				'.ts',
				'.vod',
				'.wav',
				'.webm',
				'.wmv'
			]
		}
	}).use(Tus, {
		endpoint: 'https://video.bunnycdn.com/tusupload',
		async onBeforeRequest(req, file) {
			// Get the upload signature data from the API
			const response = await fetch('/api/video/tus-upload-signature');
			const data: TusUploadSignature = await response.json();
 
			// Set the required request headers
			req.setHeader('AuthorizationSignature', data.presignedSignature);
			req.setHeader('AuthorizationExpire', data.expiresAt);
			req.setHeader('VideoId', data.videoId);
			req.setHeader('LibraryId', PUBLIC_BUNNY_LIBRARY_ID);
 
			// Set the required metadata
			this.metadata = {
				filetype: file.type,
				title: 'Test',
				collection: data.collectionId
			};
		}
	});
</script>
 
<Dashboard {uppy} />
// tus-file-upload/server.ts
import { json, error } from "@sveltejs/kit";
 
import { sha256 } from "oslo/crypto";
import { encodeHex } from "oslo/encoding";
 
import { eq } from 'drizzle-orm';
import { db } from "$lib/server/db";
import { videoTable } from "$lib/server/db/schema";
 
import type { DatabaseUserAttributes } from "$lib/server/auth";
 
import { BUNNY_LIBRARY_API_KEY } from "$env/static/private";
import { PUBLIC_BUNNY_LIBRARY_ID } from "$env/static/public";
 
const getOrCreateCollection = async (creator: DatabaseUserAttributes) => {
    // If the creator already has collectionId set return it
    if (creator.collectionId) return creator.collectionId;
 
    // The creator doesn't have a collection yet
    // create one and return the id of it
    let collectionId: string;
 
    await fetch(`https://video.bunnycdn.com/library/${PUBLIC_BUNNY_LIBRARY_ID}/collections`, {
        method: "POST",
        headers: {
            accept: 'application/json',
            'content-type': 'application/json',
            AccessKey: BUNNY_LIBRARY_API_KEY
        },
        body: JSON.stringify({ name: creator.email })
    })
        .then(response => response.json())
        .then(data => { collectionId = data.guid })
        .catch(err => error(500, {
            message: err
        }));
 
    // Save the newly created collectionId to the database
    await db
        .update(userTable)
        .set({
            collectionId: collectionId
        })
        .where(eq(userTable.id, creator.id));
 
    return collectionId;
}
 
export const POST = async ({ locals }) => {
    const user = locals.user;
 
    if (!user) {
        error(401, {
            message: "K provedení této akce musíte být přihlášeni."
        });
    }
 
    // Get or create a Bunny Stream collection for the user
    const collectionId = await getOrCreateCollection(user);
 
    // Create a video object that we will use for uploading the video to
    const response = await fetch(`https://video.bunnycdn.com/library/${PUBLIC_BUNNY_LIBRARY_ID}/videos`, {
        method: "POST",
        headers: {
            accept: 'application/json',
            'content-type': 'application/json',
            AccessKey: BUNNY_LIBRARY_API_KEY
        },
        body: JSON.stringify({ title: "Název souboru", collectionId: collectionId, thumbnailTime: 0 })
    });
 
    const data = await response.json();
    const videoId: string = data.guid; // the guid of the created video object
 
    // Create a UNIX timestamp that marks the URL expiration (5 minutes from now)
    const expiresAt = Math.floor((Date.now() + 5 * 60 * 1000) / 1000);
    // Create a SHA256 hash to create the URL token
    const signatureHash = await sha256(new TextEncoder().encode(PUBLIC_BUNNY_LIBRARY_ID + BUNNY_LIBRARY_API_KEY + expiresAt + videoId));
    const presignedSignature = encodeHex(signatureHash);
 
    return json({
        presignedSignature, // SHA256 signature (libraryId + apiKey + expiresAt + videoId)
        expiresAt, // UNIX timestamp of expiry date
        videoId, // ID of the created video object
        collectionId // ID of the creator's video collection
    })
}

Using onBeforeRequest to fetch the signature is not ideal. This callback is invoked before tus-js-client sends a request, but a single upload may consist of multiple request, hence multiple signature will be fetched. You should instead fetch the signature once before the upload is started and then inject it as a header into Uppy/tus-js-client.

Oh yeah, I came accross this issue. Multiple fetch calls are getting called with this code.

What would be a good place to call the API to get the signature?
I found a function onBeforeUpload.

Could you please help with a quick example code? Thank you.

Okay, I looked into the docs and edited the code to this:

interface VideoObject {
		videoId: string;
		collectionId: string;
	}

	interface TusUploadSignature {
		presignedSignature: string;
		expiresAt: any; // Number
	}

	//
	let headers: {
		AuthorizationSignature: string;
		AuthorizationExpire: string;
		VideoId: string;
		LibraryId: string;
	};

	let metadata: {
		filetype: string;
		title: string;
		collection: string;
	};

	const uppy = new Uppy({
		locale: Czech,
		meta: {
			filename: 'Název videa'
		},
		restrictions: {
			maxNumberOfFiles: 1,
			allowedFileTypes: [
				'.4mv',
				'.amv',
				'.avi',
				'.flv',
				'.m4p',
				'.m4v',
				'.mkv',
				'.mov',
				'.mp3',
				'.mp4',
				'.mpeg',
				'.mpg',
				'.mxf',
				'.ogg',
				'.ts',
				'.vod',
				'.wav',
				'.webm',
				'.wmv'
			]
		}
	}).use(Tus, {
		endpoint: 'https://video.bunnycdn.com/tusupload',
		onBeforeRequest(req, file) {
			// Set the required headers
			req.setHeader('AuthorizationSignature', headers.AuthorizationSignature);
			req.setHeader('AuthorizationExpire', headers.AuthorizationExpire);
			req.setHeader('VideoId', headers.VideoId);
			req.setHeader('LibraryId', headers.LibraryId);
		},
		onError(error) {
			console.error(error);
		}
	});

	// PrePrecessor for creating the video object
	// and getting the getting the upload signature
	uppy.addPreProcessor((fileIDs) => {
		return new Promise(async (resolve, reject) => {
			// Get the uploaded file
			const file = uppy.getFile(fileIDs[0]);

			const videoResponse = await fetch('/api/video/upload/video-object', {
				method: 'POST',
				body: JSON.stringify({
					filename: file.name
				})
			});

			// If the video object couldn't be created don't continue
			if (!videoResponse.ok) {
				uppy.info(
					{
						message: 'Ale ne, něco se pokazilo!',
						details: 'Video se nepodařilo nahrát. Zkuste to prosím později.'
					},
					'error',
					5000
				); // Show an error message
				uppy.cancelAll(); // Cancel all uploads

				return reject(false);
			}

			const videoData: VideoObject = await videoResponse.json();

			// Get the upload signature data from the API
			const signatureResponse = await fetch('/api/video/upload/tus-signature', {
				method: 'POST',
				body: JSON.stringify({
					videoId: videoData.videoId
				})
			});
			const signatureData: TusUploadSignature = await signatureResponse.json();

			// Set the required request headers and metadata
			headers = {
				AuthorizationSignature: signatureData.presignedSignature,
				AuthorizationExpire: signatureData.expiresAt,
				VideoId: videoData.videoId,
				LibraryId: PUBLIC_BUNNY_LIBRARY_ID
			};

			metadata = {
				filetype: file.type,
				title: file.name ?? 'Nové video',
				collection: videoData.collectionId
			};

			return resolve(true);
		});
	});

Seems to be working pretty well, only one video object is getting created and the video is getting uploaded accordingly.

One issue I’m having: how do I set the programmatically metadata before uploading the video?
The metadata are based on the file and on data response from the API call and I’m not really sure how to set them AFTER the API call.

Any ideas?
Thank you.

Hi, you can use uppy.setFileMeta() per file or uppy.setMeta() to set it on all files.

I’m not sure I understand though why you only get a signature from the first file you are uploading (fileIDs[0]), regardless of how many files you are uploading?

Thank you, missed this in the docs.

Oh, since I’m only allowing the user to upload only file at a time with maxNumberOfFiles: 1 I’m just accessing this single file.

Do you see any issues with this approach? Should I instead use a loop?
Thanks.