Skip to content

Media Library Step 3: Coding the Backend (Part 2)

Let's build a route that lets the user upload an image. This route's function will split the image up into small chunks if the image file is large. It will also generate a thumbnail image:

async fn upload_handler(
    State(state): State<Arc<AppState>>,
    mut multipart: Multipart,
) -> impl IntoResponse {
    let mut filename: Option<String> = None;
    let mut tags: Option<String> = None;
    let mut custom_annotations = vec![];
    let mut image_bytes: Option<Vec<u8>> = None;
    let mut mime_type: Option<String> = None;

    // --- 1. VALIDATE AND PARSE THE INPUT ---
    println!("Parsing multipart form data...");
    while let Some(field) = multipart.next_field().await.unwrap() {
        let name = field.name().unwrap().to_string();

        match name.as_str() {
            "filename" => {
                filename = Some(field.text().await.unwrap());
            }
            "tags" => {
                tags = Some(field.text().await.unwrap());
            }
            "imageFile" => {
                println!("Reading image file...");
                // We'll get the content type first, which borrows the field,
                // and then get the bytes, which moves the field.
                mime_type = Some(field.content_type().unwrap().to_string());
                image_bytes = Some(field.bytes().await.unwrap().to_vec());
                println!("Image size: {} bytes", image_bytes.as_ref().unwrap().len());
            }
            custom_key if custom_key.starts_with("custom_key") => {
                let value_name = custom_key.replace("key", "value");
                let value = multipart.next_field().await.unwrap().unwrap().text().await.unwrap();

                if !value.is_empty() {
                    custom_annotations.push(Annotation::new(value_name.replace("custom_value", ""), value));
                }
            }
            _ => {
                // Ignore other fields
            }
        }
    }

    let original_image_bytes = match image_bytes {
        Some(bytes) => bytes,
        None => return (StatusCode::BAD_REQUEST, "No image file was uploaded.").into_response(),
    };
    let tags_str = tags.unwrap_or_else(|| "".to_string());
    let original_filename = filename.unwrap_or_else(|| "image.png".to_string());
    let mime_type_str = mime_type.unwrap_or_else(|| "image/png".to_string());

    println!("Received upload with tags: \"{}\"", tags_str);

    let mut string_annotations = vec![
        Annotation::new("type", "image"),
        Annotation::new("app", "golem-images-0.1"),
        Annotation::new("filename", original_filename.clone()),
        Annotation::new("mime_type", mime_type_str.clone()),
        Annotation::new("tag", tags_str.clone())
    ];

    // let tag_list: Vec<&str> = tags_str.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
    // for tag in tag_list {
    //     string_annotations.push(Annotation::new("tag", tag));
    // }
    //string_annotations.push(Annotation::new("tag", &tags_str));

    // Combine custom annotations
    string_annotations.extend(custom_annotations);

    // --- 2. RESIZE THE IMAGE FOR A THUMBNAIL ---
    let image_data = image::load_from_memory(&original_image_bytes).unwrap();
    let resized_image_data = image_data.resize_to_fill(100, 100, FilterType::Lanczos3);

    // Use a cursor to write to the Vec<u8> in memory
    let mut thumbnail_bytes_cursor = Cursor::new(Vec::new());
    resized_image_data.write_to(&mut thumbnail_bytes_cursor, ImageFormat::Jpeg).unwrap();
    let thumbnail_bytes = thumbnail_bytes_cursor.into_inner();
    let thumbnail_len = thumbnail_bytes.len();

    println!("Resized image size: {} bytes", thumbnail_len);

    // --- 3. CHUNK THE ORIGINAL IMAGE IF NEEDED ---
    const CHUNK_SIZE: usize = 100000;
    let chunks: Vec<&[u8]> = original_image_bytes.chunks(CHUNK_SIZE).collect();
    println!("Number of chunks: {}", chunks.len());

    let mut create_entities = vec![];

    // First, create the main image entity (first chunk)
    let main_entity_create = Create {
        data: chunks[0].to_vec().into(),
        btl: 25,
        string_annotations: string_annotations.clone(),
        numeric_annotations: vec![
            Annotation::new("part", 1u64),
            Annotation::new("part_of", chunks.len() as u64),
        ],
    };
    let receipts = state.client.create_entities(vec![main_entity_create]).await;
    let receipts = match receipts {
        Ok(r) => r,
        Err(e) => {
            eprintln!("Error creating main entity: {:?}", e);
            return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to create main entity").into_response();
        }
    };
    let main_entity_key = Some(receipts[0].entity_key);
    println!("Created main entity: {:?}", main_entity_key);

    // Create the thumbnail entity
    let thumb_create = Create {
        data: thumbnail_bytes.into(),
        btl: 25,
        string_annotations: vec![
            Annotation::new("parent", main_entity_key.unwrap().to_string()),
            Annotation::new("type", "thumbnail"),
            Annotation::new("app", "golem-images-0.1"),
            Annotation::new("resize", "100x100"),
            Annotation::new("filename", format!("thumb_{}", original_filename)),
            Annotation::new("mime_type", "image/jpeg"),
        ],
        numeric_annotations: vec![],
    };
    let thumb_receipts = state.client.create_entities(vec![thumb_create]).await;
    match thumb_receipts {
        Ok(r) => println!("Created thumbnail entity: {:?}", r),
        Err(e) => eprintln!("Error creating thumbnail: {:?}", e),
    };

    // If there are more chunks, create an entity for each
    if chunks.len() > 1 {
        for (i, chunk) in chunks.iter().skip(1).enumerate() {
            let chunk_create = Create {
                data: chunk.to_vec().into(),
                btl: 25,
                string_annotations: vec![
                    Annotation::new("parent", main_entity_key.unwrap().to_string()),
                    Annotation::new("type", "image_chunk"),
                    Annotation::new("app", "golem-images-0.1"),
                    Annotation::new("filename", original_filename.clone()),
                    Annotation::new("mime_type", mime_type_str.clone()),
                ],
                numeric_annotations: vec![
                    Annotation::new("part", (i + 2) as u64), // parts are 1-based
                    Annotation::new("part_of", chunks.len() as u64),
                ],
            };
            create_entities.push(chunk_create);
        }

        // Send all remaining chunks in a single API call
        let chunk_receipts = state.client.create_entities(create_entities).await;
        println!("{:?}", chunk_receipts);
        match chunk_receipts {
            Ok(r) => println!("Created {} chunk entities.", r.len()),
            Err(e) => eprintln!("Error creating chunks: {:?}", e),
        };
    }

    // --- 4. SEND A SUCCESS RESPONSE ---
    (StatusCode::OK, Json(json!({
        "message": "File processed successfully!",
        "originalSize": original_image_bytes.len(),
        "resizedSize": thumbnail_len,
        "tags": tags_str,
        "entity_key": main_entity_key.unwrap().to_string()
    }))).into_response()
}

Next, here's the handler that retrieves a thumbnail:

// Handler for the `GET /thumbnails` route.
async fn get_thumbnails(State(state): State<Arc<AppState>>) -> impl IntoResponse {
    let query = "type=\"thumbnail\" && app=\"golem-images-0.1\"";

    println!("GET /thumbnails called. Executing query: {}", query);

    match state.client.query_entity_keys(query).await {
        Ok(keys) => Json(keys.into_iter().map(|key| key.to_string()).collect::<Vec<_>>()).into_response(),
        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Error querying thumbnails: {}", e)).into_response(),
    }
}

Each thumbnail has an associate parent containing the actual image that was uploaded. Here's a function that takes a thumbnail ID and returns the parent ID:

async fn get_parent(
    State(state): State<Arc<AppState>>,
    Path(thumbid): Path<String>,
) -> impl IntoResponse {

    let entity_key = parse_b256(&thumbid);

    let metadata = state.client.get_entity_metadata(entity_key).await;

    match metadata {
        Ok(md) => {
            for annot in md.string_annotations {
                if annot.key == "parent" {
                    return (StatusCode::OK, annot.value).into_response();
                }
            }
            (StatusCode::NOT_FOUND, "Parent key not found.").into_response()
        }
        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Error fetching metadata: {}", e)).into_response(),
    }
}

Here's a route that takes an image ID (which can be either a thumbnail ID or a parent ID) and returns the actual image data. It calls the function we created earlier:

async fn get_full_image(
    State(state): State<Arc<AppState>>,
    Path(id): Path<String>,
) -> impl IntoResponse {
    let entity_key = parse_b256(&id);

    // Fetch and combine the image data
    let image_result = get_full_image_data(&state.client, entity_key).await;

    match image_result {
        Ok(result) => {
            // Success: Return the image data with the correct MIME type

            // Axum's IntoResponse allows us to build a custom response with headers.
            (
                [
                    ("Content-Type", result.mimetype),
                    ("Content-Disposition", format!("inline; filename=\"{}\"", result.filename)),
                ],
                // Convert Vec<u8> to axum::body::Bytes for the response body
                Bytes::from(result.image_data), 
            ).into_response()
        }
        Err(e) => {
            eprintln!("Error fetching image data: {}", e);
            (StatusCode::INTERNAL_SERVER_ERROR, "Failed to retrieve and combine image data.").into_response()
        }
    }
}

And finally, let's provide a query function that allows the user to search for images that contain a particular tag. Notice howe we're piecing together the query such that it can search for a tag in the format of "tag1,tag2,tag3,...":

async fn query_entities(
    State(state): State<Arc<AppState>>,
    Path(search): Path<String>,
) -> impl IntoResponse {
    // The query string to search for thumbnails that match the tag.
    let query = format!(
        "type=\"thumbnail\" && app=\"golem-images-0.1\" && (tag~\"{}\" || (tag~\"{},*\" || (tag~\"*,{}\" || tag~\"*,{},\"))) ", 
        search, search, search, search
    );

    println!("GET /query/{} called. Executing query: {}", search, query);

    // The Rust function already returns Vec<Hash>, so we map them to strings and return.
    match state.client.query_entity_keys(&query).await {
        Ok(keys) => Json(keys.into_iter().map(|key| key.to_string()).collect::<Vec<_>>()).into_response(),
        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Query failed: {}", e)).into_response(),
    }
}

Now let's add in the rest of the routes; they're rather short. Then we'll present you with the entire code.

Here's a route that returns the IDs of only the thumbnails; the idea is that these could (and will) be used to build a page that generates a bunch of IMG html tags with the source set to the GET /image/:id route:

app.get('/thumbnails', async (req, res) => {
    // todo: Consider building an index, as pulling back all the thumbnail data via query is a lot of unnecessary overhead
    const thumbs = await client.queryEntities(
        'type="thumbnail" && app="golem-images-0.1"'
    )
    res.send(
        thumbs.map((item) => {
            return item.entityKey
        })
    )
})

Here we use the queryEntities call, specifying the type as thumbnail and the app name. That way we'll get every instance of thumbnail available to us for this particular app.

Note: The queryEntities presently also returns the data, but we're not using that here; thus we loop through each one via the map function, returning only the entity's key.

Next we need a special route that given the thumbnail ID, return the "real" or "parent" image ID. The idea is that the user will be able to click on a thumbnail pic, and then open the real pic. (I'll be frank here; we debated whether to return the parent image itself, or only it's ID. We settled on ID, but the app could still function well, perhaps even more optimally, if we simply returned the parent image. You might experiment with returning the image and see how that works out.)

Here's the code:

app.get('/parent/:thumbid', async (req, res) => {
    let id: Hex = prepend0x(req.params.thumbid)

    // Get the metadata

    const metadata = await client.getEntityMetaData(id as Hex)
    if (metadata) {
        for (let annot of metadata.stringAnnotations) {
            if (annot.key == 'parent') {
                // Not sure yet, let's just return the parent key for now and see how that works
                res.send(annot.value)
                return
            }
        }
        // No parent key found
        res.status(404)
        res.send('not found')
        return
    } else {
        res.status(404)
        res.send('not found')
        return
    }
})

And finally, we have the required express server code:

app.listen(port, () => {
    console.log(`Server is running at http://localhost:${port}`)
})

That's it for the backend! We'll provide you with the entire code at the end of this page.

Ready to run it? Make sure you have the script tags in your package.json, and type:

npm run build

to build it, and make sure there aren't any errors. Then to run it:

npm run start

Even without a front end yet, you can test out its built in static HTML page for a form that lets you upload images. And you can easily test out all the routes in Postman.

The full code

(You can also find the full code at https://github.com/frecklefacelabs/golembase-images).


import express from 'express'
import cors from 'cors'
import multer from 'multer'
import sharp from 'sharp'
import { inspect } from 'util'
import {
    AccountData,
    Annotation,
    createClient,
    GolemBaseCreate,
    Hex,
    Tagged,
} from 'golem-base-sdk'
import { readFileSync } from 'fs'

const app = express()
const port = 3000

const corsOptions = {
    origin: 'http://localhost:4200',
}
app.use(cors(corsOptions))

app.use(express.json())

// Configure multer to handle file uploads in memory
// This means the file will be available as a Buffer on `req.file`
const storage = multer.memoryStorage()
const upload = multer({ storage: storage })

const keyBytes = readFileSync('./private.key')
const key: AccountData = new Tagged('privatekey', keyBytes)
export const client = await createClient(
    1337,
    key,
    'http://localhost:8545',
    'ws://localhost:8545'
)

app.get('/', (req, res) => {
    res.send(`<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Image Uploader</title>
    <style>
        body { font-family: sans-serif; max-width: 600px; margin: 2em auto; }
        form { display: flex; flex-direction: column; gap: 1em; }
        input, button { padding: 0.5em; }
    </style>
</head>
<body>
    <h1>Upload an Image</h1>
    <form action="http://localhost:3000/upload" method="POST" enctype="multipart/form-data">
        <div>
            <label for="imageFile">Choose image:</label>
            <input type="file" id="imageFile" name="imageFile" accept="image/*" required />
        </div>
        <div>
            <label for="filename">Filename (if you want it different from original):</label>
            <input type="text" id="filename" name="filename" />
        </div>
        <div>
            <label for="tags">Tags (comma-separated):</label>
            <input type="text" id="tags" name="tags" value="landscape, nature, sunset" required />
        </div>
        <div for="custom_key1">Optional Custom Tags (Key, Value)</div>
        <div>
            <input type="text" id="custom_key1" name="custom_key1" value="" />
            <input type="text" id="custom_value1" name="custom_value1" value="" />
        </div>
        <div>
            <input type="text" id="custom_key2" name="custom_key2" value="" />
            <input type="text" id="custom_value2" name="custom_value2" value="" />
        </div>
        <div>
            <input type="text" id="custom_key3" name="custom_key3" value="" />
            <input type="text" id="custom_value3" name="custom_value3" value="" />
        </div>
        <button type="submit">Upload</button>
    </form>
</body>
</html>
    `)
})

const prepend0x = (id: string): Hex => {
    // Prepend '0x' if it's missing
    if (!id.startsWith('0x')) {
        id = '0x' + id
    }

    return id as Hex
}

interface ImageResult {
    id: string | null
    image_data: Buffer
    filename: string
    mimetype: string
}

const getFullImage = async (id: Hex) => {
    // For those not familiar with Partial, it's a great way to build up the object as we go
    // without having to put a bunch of | null's at the end of each type in the Interface
    // (because we don't want them to be null when we return the object.)
    // Here's the ref: https://www.typescriptlang.org/docs/handbook/utility-types.html#partialtype
    let result: Partial<ImageResult> = {
        id: id,
        mimetype: '',
        filename: '',
    }

    // Grab the metadata
    const metadata = await client.getEntityMetaData(id)
    console.log(metadata)

    // Grab the filename and mime type

    let filename = 'image'
    let partof = 1
    for (let annot of metadata.stringAnnotations) {
        if (annot.key == 'filename') {
            filename = annot.value
        } else if (annot.key == 'mime-type') {
            result.mimetype = annot.value
        }
    }
    for (let annot of metadata.numericAnnotations) {
        if (annot.key == 'part-of') {
            partof = annot.value
        }
    }

    result.filename = filename
    console.log(filename)
    console.log(result.mimetype)
    console.log(partof)

    console.log('Fetching raw data...')

    result.image_data = Buffer.from(await client.getStorageValue(id as Hex))

    // See if there are more parts.

    if (partof > 1) {
        const chunks = [result.image_data]

        // The query only gives us the payload and not the metadata, so we'll query them each individually
        // (Note that we saved the values 1-based not 0-based, so the second has index 2 now)

        for (let i = 2; i <= partof; i++) {
            const chunk_info = await client.queryEntities(
                `parent="${id}" && type="image_chunk" && app="golem-images-0.1" && part=${i}`
            )
            console.log(`CHUNKS ${i}:`)
            console.log(chunk_info)
            chunks.push(chunk_info[0].storageValue as Buffer)
        }

        console.log(`SENDING ${chunks.length} chunks`)

        result.image_data = Buffer.concat(chunks)
    }

    return result as ImageResult
}

app.get('/image/:id', async (req, res) => {
    let id: Hex = prepend0x(req.params.id)

    let result: ImageResult = await getFullImage(id)
    res.set('Content-Disposition', `inline; filename="${result.filename}"`)
    res.type(result.mimetype)
    res.send(result.image_data)
})

app.post('/upload', upload.single('imageFile'), async (req, res) => {
    try {
        let entity_key = ''

        // --- 1. VALIDATE THE INPUT ---
        // Check if a file was uploaded
        if (!req.file) {
            return res.status(400).send('No image file was uploaded.')
        }

        console.log('Filename:')
        console.log(req.body.filename || req.file.originalname)

        // Check for the tags field
        console.log(req.body)
        const { tags } = req.body
        if (!tags || typeof tags !== 'string') {
            return res.status(400).send('Tags string is required.')
        }

        console.log(`Received upload with tags: "${tags}"`)

        let stringAnnotations = []
        let numericAnnotations = []

        // Add each tag as an annotation.
        const tag_list = tags
            .split(',') // split by commas
            .map((tag) => tag.trim()) // remove leading/trailing space
            .filter((tag) => tag.length > 0) // remove empty strings resulting from multiple commas
        for (let tag of tag_list) {
            stringAnnotations.push(new Annotation('tag', tag))
        }

        for (let i = 1; i <= 3; i++) {
            const key = req.body[`custom_key${i}`]
            const value = req.body[`custom_value${i}`]
            if (key && value) {
                console.log(`Found custom key/value ${i}:`)
                console.log(key, value)
                if (typeof value === 'number' && !isNaN(value)) {
                    numericAnnotations.push(new Annotation(key, value))
                } else {
                    stringAnnotations.push(new Annotation(key, String(value)))
                }
            }
        }

        // --- 2. GET THE ORIGINAL IMAGE DATA ---
        // The original image is already in memory as a Buffer
        const originalImageBuffer = req.file.buffer
        console.log(`Original image size: ${originalImageBuffer.length} bytes`)

        // --- 3. RESIZE THE IMAGE USING SHARP ---
        // sharp takes the buffer, resizes it, and outputs a new buffer
        console.log('Resizing image to 60px width...')
        const resizedImageBuffer = await sharp(originalImageBuffer)
            .resize({
                width: 100,
                height: 100,
                fit: 'inside', // This ensures the image is resized to fit within a 100x100 box
            })
            .jpeg({ quality: 70 })
            .toBuffer()
        console.log(`Resized image size: ${resizedImageBuffer.length} bytes`)

        // Break into chunks if it's too big

        const chunks: Buffer[] = []

        const chunkSize = 100000

        for (let i = 0; i < originalImageBuffer.length; i += chunkSize) {
            const chunk = Buffer.from(
                originalImageBuffer.subarray(i, i + chunkSize)
            )
            chunks.push(chunk)
        }

        console.log(`Number of chunks: ${chunks.length}`)

        for (let chunk of chunks) {
            console.log(chunk.length)
        }

        // --- 4. PREPARE DATA ---

        try {
            // We have to do these creates sequentially, as we need the returned hash to be used in the thumbnail (and additional parts if needed).
            let creates_main: GolemBaseCreate[] = [
                {
                    data: chunks[0],
                    btl: 25,
                    stringAnnotations: [
                        new Annotation('type', 'image'),
                        new Annotation('app', 'golem-images-0.1'),
                        new Annotation(
                            'filename',
                            req.body.filename || req.file.originalname
                        ),
                        new Annotation('mime-type', req.file.mimetype),
                        ...stringAnnotations,
                    ],
                    numericAnnotations: [
                        new Annotation('part', 1),
                        new Annotation('part-of', chunks.length),
                        ...numericAnnotations,
                    ],
                },
            ]

            console.log('Sending main:')
            console.log(inspect(creates_main, { depth: 10 }))
            const receipts_main = await client.createEntities(creates_main)
            let hash = receipts_main[0].entityKey
            console.log('Receipts for main:')
            console.log(receipts_main)
            entity_key = receipts_main[0].entityKey

            // Now if there are more chunks for the larger files, build creates for them.

            // Start at index [1] here, since we already saved index [0]
            for (let i = 1; i < chunks.length; i++) {
                const next_create: GolemBaseCreate[] = [
                    {
                        data: chunks[i],
                        btl: 25,
                        stringAnnotations: [
                            new Annotation(
                                'parent',
                                receipts_main[0].entityKey
                            ),
                            new Annotation('type', 'image_chunk'),
                            new Annotation('app', 'golem-images-0.1'),
                            new Annotation(
                                'filename',
                                req.body.filename || req.file.originalname
                            ),
                            new Annotation('mime-type', req.file.mimetype),
                            ...stringAnnotations,
                        ],
                        numericAnnotations: [
                            new Annotation('part', i + 1),
                            new Annotation('part-of', chunks.length),
                            ...numericAnnotations,
                        ],
                    },
                ]
                const next_receipt = await client.createEntities(next_create)
                console.log(`Next receipt: (part ${i + 1})`)
                console.log(next_receipt)
            }

            console.log('Sending thumbs and chunks:')
            let create_thumb: GolemBaseCreate[] = [
                {
                    data: resizedImageBuffer,
                    btl: 25,
                    stringAnnotations: [
                        new Annotation('parent', receipts_main[0].entityKey),
                        new Annotation('type', 'thumbnail'),
                        new Annotation('app', 'golem-images-0.1'),
                        new Annotation('resize', '100x100'),
                        new Annotation(
                            'filename',
                            `thumb_${req.body.filename || req.file.originalname}`
                        ),
                        new Annotation('mime-type', 'image/jpeg'), // Our thumbnail is jpg
                        ...stringAnnotations,
                    ],
                    numericAnnotations: [],
                },
            ]

            const receipts_thumb = await client.createEntities(create_thumb)
            console.log('Receipts for thumb:')
            console.log(receipts_thumb)
        } catch (e) {
            console.log('ERROR')
            if (e instanceof Error) {
                if ((e as any)?.cause?.details) {
                    throw (e as any).cause.details
                }
            } else {
                throw e
            }
        }

        // --- 5. SEND A SUCCESS RESPONSE ---
        res.status(200).json({
            message: 'File processed successfully!',
            originalSize: originalImageBuffer.length,
            resizedSize: resizedImageBuffer.length,
            tags: tags,
            entity_key: entity_key,
        })
    } catch (error) {
        console.error('Error processing image:', error)
        res.status(500).send(
            `An error occurred while processing the image: ${error}`
        )
    }
})

app.get('/thumbnails', async (req, res) => {
    // todo: Consider building an index, as pulling back all the thumbnail data via query is a lot of unnecessary overhead
    const thumbs = await client.queryEntities(
        'type="thumbnail" && app="golem-images-0.1"'
    )
    res.send(
        thumbs.map((item) => {
            return item.entityKey
        })
    )
})

app.get('/parent/:thumbid', async (req, res) => {
    let id: Hex = prepend0x(req.params.thumbid)

    // Get the metadata

    const metadata = await client.getEntityMetaData(id as Hex)
    if (metadata) {
        for (let annot of metadata.stringAnnotations) {
            if (annot.key == 'parent') {
                // Not sure yet, let's just return the parent key for now and see how that works
                res.send(annot.value)
                return
            }
        }
        // No parent key found
        res.status(404)
        res.send('not found')
        return
    } else {
        res.status(404)
        res.send('not found')
        return
    }
})

app.get('/query/:search', async (req, res) => {
    const results = await client.queryEntities(
        `type="thumbnail" && app="golem-images-0.1" && tag="${req.params.search}"`
    )
    res.send(
        results.map((item) => {
            return item.entityKey
        })
    )
})

// Start server
app.listen(port, () => {
    console.log(`Server is running at http://localhost:${port}`)
})

Ready for the front end? Let's go!

Head to Step 4.