[Short] Bun & Hono: Send and get files using AWS S3 client

Quentin Klein
La Mobilery
Published in
5 min readMay 27, 2024

I’m working on a project where I need an API that deals with files.
Since Bun is out, every time I need to bootstrap something I use it.

Now dealing with files is something else, a CRUD with files can be different from one API to another.

Anyway, here was my requirements :

  • Bun and Hono
  • CRUD on files (or at least CRD)
  • Using a S3 object storage (or compatible S3 object storage)
  • Validating inputs
  • Using the old form friend in HTTP

For testing purpose, I use MinIO in a docker env.

(Here, I won’t talk about user authentication, it would make the code more complex to read.)

Let’s do this !

Our journey to get things done in 5 steps:

  1. Dependencies
  2. Folder structure
  3. Schema validation
  4. File Service
  5. Routing

One more thing, it is not as easy as it seems 😅, but not as hard as it could have been.

Dependencies

Using Hono, I will use Zod for input validation and the official amazon S3 client.

  • bun add zod for input validation
  • bun add @hono/zod-validator for easy to read input validation
  • bun add @aws-sdk/client-s3 for communication with S3 like storage
  • bun add uuid (optional but for setting id to files in S3)
  • (also bun add -D @types/uuid )

Folder structure

I like simple things, in my src folder, I have an index.ts and a routes folder.

In the routes folder I create a documents folder to handle the files route.

And in the documents folder I have:

  • a documents.route.ts for hono routing
  • a documents.schema.ts for zod validation
  • a documents.service.ts to deal with S3 without adding compexity to the routing file

So it looks like this:

- index.ts
- routes
-- documents
--- documents.route.ts
--- documents.schema.ts
--- documents.service.ts

Let’s begin with input validation

documents.schema.ts is the easy part, I want to be able to give a file and a custom name in my body, so the validation will look like this.

import { z } from 'zod';

const createDocumentSchema = z.object({
name: z.string(),
file: z.instanceof(File),
});

export { createDocumentSchema };

And now let’s speaks with S3

documents.service.ts is the the hard part, to do so, we have 5 environment variables in our .env file (auto loaded by Bun).

We will consider that all files are in the same bucket, but you can do whatever you want.

S3_REGION=""
S3_URL=""
S3_ACCESS_KEY_ID=""
S3_ACCESS_KEY_SECRET=""
S3_BUCKET=""

And now the service with our 3 exposed functions createDocument , getDocument and deleteDocument.

If you want to keep a track on what documents are on the S3, use a database.

import {
DeleteObjectCommand,
GetObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import { v4 } from 'uuid';

// This is the client we will use to deal with our S3 like service
const s3 = new S3Client({
region: process.env.S3_REGION as string,
forcePathStyle: true,
endpoint: process.env.S3_URL as string,
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID as string,
secretAccessKey: process.env.S3_ACCESS_KEY_SECRET as string,
},
});

// This function will upload our document to the storage on a specific bucket
// and returns the id of the file on the S3
// (if you want to keep a track on them)
const createDocument = async (
file: File,
): Promise<string> => {
// Create a specific uuid v4 to assign on the document on the s3 :)
const docId = v4();
const params = {
Body: Buffer.from(await file.arrayBuffer()),
Bucket: process.env.S3_BUCKET as string,
Key: docId,
// Important thing here, add the type or every file will have
// 'application/octet-stream'
ContentType: file.type,
};

// Upload file to S3
const uploadCommand = new PutObjectCommand(params);
const response = await s3.send(uploadCommand);
return docId;
};

// This function will fetch the document data on the S3 given an ID and
// return a byte array (file content) with a content type
// might throw an exception if the file does not exist.
const readDocument = async (
docId: string
): Promise<{
data: Uint8Array;
contentType: string;
}> => {
const readCommand = new GetObjectCommand({
Bucket: process.env.S3_BUCKET as string,
Key: docId,
});
const object = await s3.send(readCommand);
const byteArray = await object.Body?.transformToByteArray();
if (byteArray === undefined) {
throw new Error('File does not exist');
}

return {
data: byteArray,
contentType: object.ContentType ?? 'application/octet-stream',
};
};

// This function will delete the file on S3 given an ID
const deleteDocument = async (docId: string): Promise<void> => {
const deleteCommand = new DeleteObjectCommand({
Bucket: process.env.S3_BUCKET as string,
Key: docId,
});

await s3.send(deleteCommand);
};

export { createDocument, deleteDocument, readDocument };

And to finish, expose our routes

With the routing file documents.route.ts , is quite simple, also, we add our routes to our file.

import { zValidator } from '@hono/zod-validator';

import { Hono } from 'hono';

const documentsRoute = new Hono();

// Create a document
documentsRoute.post(
'/',
zValidator('form', createDocumentSchema),
async => {
const fileData = c.req.valid('form');
try {
const docId = await createDocument(fileData.file);
return c.json({ id: docId }, 201);
} catch (error) {
console.error(error);
return c.json({ error: 'Failed to upload file', details: error }, 500);
}
}
);

// Get a document
documentsRoute.get(
'/:docId',
async => {
const docId = c.req.param('docId');
try {
const object = await readDocument(docId);
// our object contains the data and the content type
const byteArray = await object.data;
if (byteArray === undefined) {
return c.json({ error: 'Failed to read file' }, 500);
}

// Set the content type of the file in header to be understandable
// by clients
c.header('Content-Type', object.contentType);

// Just stream the byte array (with content type it will be interpreted
// in the good way)
return stream(c, async (stream) => {
// Write a process to be executed when aborted.
stream.onAbort(() => {
console.log('Aborted!');
});
await stream.write(byteArray);
});
} catch (error) {
return c.json({ error: 'Failed to read file' }, 500);
}
}
);

documentsRoute.delete(
'/:docId',
async (c) => {
const docId = c.req.param('docId');
try {
await deleteDocument(documentId);
return c.status(204);
} catch (error) {
return c.json({ error: 'Failed to delete file' }, 500);
}
});

export default documentsRoute;

Izipizi lemon squeezy ! You just have to add your documentsRoute in your main router index.ts.

import { Hono } from 'hono';

import documentsRoute from './routes/documents/documents.route';

const app = new Hono();

app.route('/documents', documentsRoute);

export default app;

Thank you for reading this post up to this.

If you liked it, please clap or share it, it’s always nice 😘

--

--