Use Web APIs to Build Modern Server Applications

The old fashion

There was a time when we wrote our server applications like this:

// main.ts
import http from "node:http"

const server = http.createServer((req, res) => {
    res.end("Hello, World!")
})

server.listen(8000);
TypeScript

Or used traditional frameworks such as Express:

// main.ts
import express from "express"

const app = express()

app.get("/", (req, res) => {
    res.send("Hello, World!")
})

app.listen(8000)
TypeScript

If we wanted to parse the request body, we would need a plugin to do so:

// main.ts
import express from "express"
import bodyParser from "body-parser"

const app = express()

app.use(bodyParser.text())
app.use(bodyParser.json())

app.get("/", (req, res) => {
    res.send("Hello, World!")
})

app.post("/text", (req, res) => {
    const text = req.body
    res.send("The client sent: " + text)
})

app.post("/json", (req, res) => {
    const data = req.body
    res.send("The client sent: " + JSON.stringify(data))
})

app.listen(8000)
TypeScript

And If we wanted to handle file uploads, we need another plugin:

// main.ts
import express from "express"
import bodyParser from "body-parser"
import multer from "multer"

const app = express()
const upload = multer()

app.use(bodyParser.text())
app.use(bodyParser.json())

app.get("/", (req, res) => {
    res.send("Hello, World!")
})

app.post("/text", (req, res) => {
    const text = req.body
    res.send("The client sent: " + text)
})

app.post("/json", (req, res) => {
    const data = req.body
    res.send("The client sent: " + JSON.stringify(data))
})

app.put("/upload", upload.single("file"), (req, res) => {
    const file = req.file
    res.send("The client uploaded: " + file. originalname)
})

app.listen(8000)
TypeScript

Oh my god! Why is writing a simple web server application so hard?

The new fashion

As Fetch API is now adopted by modern server runtimes, we can rewrite the above application to a much more simpler and easier version.

// main.ts
export default {
    async fetch(req: Request): Promise<Response> {
        const { pathname } = new URL(req.url)
        
        if (req.method === "GET" || pathname === "/") {
            return new Response("Hello, World!")
        }
        
        if (req.method === "POST" || pathname === "/text") {
            const text = await req.text()
            return new Response("The client sent: " + text)
        }
        
        if (req.method === "POST" || pathname === "/json") {
            const data = await req.json()
            return new Response("The client sent: " + JSON.stringify(data))
        }
        
        if (req.method === "PUT" || pathname === "/upload") {
            const formData = await req.formData()
            const file = formData.get("file") as File
            return new Response("The client uploaded: " + file.name)
        }
        
        return new Response("Not Found", { status: 404 })
    }
}
TypeScript

And we can run the above code in many runtimes, not just Node.js:

tsx --import=@ayonli/jsext/http main.ts # Node.js (with `tsx` and `@ayonli/jsext`)

deno serve main.ts # Deno

bun run main.ts # Bun

wrangler dev main.ts # Cloudflare Workers (with `wrangler`)
Bash

Use Hono framework

The above example may seem a little verbose, we need to use if statements for every route. We need a way to make our code more readable, like the Express example.

Luckily, we have a modern framework, Hono, to help us write applications more elegantly.

// main.ts
import { Hono } from "hono"

const app = new Hono()

app.get("/", () => {
    return new Response("Hello, World!")
})

app.post("/text", async ({ req }) => {
    const text = await req.text()
    return new Response("The client sent: " + text)
})

app.post("/json", async ({ req }) => {
    const data = await req.json()
    return new Response("The client sent: " + JSON.stringify(data))
})

app.put("/upload", async ({ req }) => {
    const formData = await req.formData()
    const file = formData.get("file") as File
    return new Response("The client uploaded: " + file.name)
})

export default app
TypeScript

Hono is a framework that works in many runtimes too, so we can also use the same commands to start the application.

tsx --import=@ayonli/jsext/http main.ts # Node.js (with `tsx` and `@ayonli/jsext`)

deno serve main.ts # Deno

bun run main.ts # Bun

wrangler dev main.ts # Cloudflare Workers (with `wrangler`)
Bash

Respond HTML

In the old days, we would like to use a template engine, such as EJS, for rendering HTML pages, but template engines are troublesome, we don’t have any code intellisense and auto-completion for variables in the template files, which is very easy to write buggy code.

But now we can write JSX (or React components) directly in our server applications, just like this:

// main.tsx
import React from "react"
import ReactDOM from "react-dom/server"

async function render(node: React.ReactNode) {
    if (ReactDOM.renderToReadableStream) {
        return await ReactDOM.renderToReadableStream(node)
    } else {
        return ReactDOM.renderToString(node)
    }
}

function Greetings(props: { name: string }) {
    return <p>Hello, {props.name}!</p>
}

export default {
    async fetch(req: Request): Promise<Response> {
        const dom = await render(<Greetings name="World" />)
        return new Response(dom, {
            headers: {
                "Content-Type": "text/html",
            },
        })
    }
}
TSX

As always, we can use the same commands to start the server, just change the entry filename from main.ts to main.tsx:

tsx --import=@ayonli/jsext/http main.tsx # Node.js (with `tsx` and `@ayonli/jsext`)

deno serve main.tsx # Deno

bun run main.tsx # Bun

wrangler dev main.tsx # Cloudflare Workers (with `wrangler`)
Bash

Respond FormData

I don’t know if there is a way in traditional server applications to respond a FormData to the client, but with the modern Web APIs, this can be very easy.

// main.ts
// A simple file storage server
import { Hono } from "hono"

const app = new Hono()
const store = new Map<string, {
    file: File;
    meta: { createdAt: Date }
}>()

app.put("/*", async ({ req }) => {
    const { pathname } = new URL(req.url)
    const formData = await req.formData()
    const file = formData.get("file") as File
    const createdAt = new Date()
    
    store.set(pathname, { file, meta: { createdAt } })
    
    return new Response("OK")
})

app.get("/*", async ({ req }) => {
    const { pathname } = new URL(req.url)
    const entry = store.get(pathname)
    
    if (entry) {
        const formData = new FormData()
        
        formData.set("file", entry.file)
        formData.set("createdAt", entry.meta.createdAt.toISOString())
        
        return new Response(formData)
    } else {
        return new Response("File Not Found", { status: 404 })
    }
})

export default app
TypeScript

Server-Sent Events

The standard Web APIs don’t provide a native API for serving server-sent events, however, I’ve created a module in the JsExt library that conforms to the web standard which can be used to serve SSE.

// main.ts
// A simple pub-sub application
import { Hono } from "hono"
import { EventEndpoint } from "@ayonli/jsext/sse"

const app = new Hono()
const clients = new Set<EventEndpoint>()

app.get("/subscribe", async ({ req }) => {
    const client = new EventEndpoint(req.raw) // need to use `req.raw` here

    const timer = setInterval(() => {
        client.dispatchEvent(new MessageEvent("ping", {
            data: "ping",
        }))
    }, 5_000)

    clients.add(client)
    client.addEventListener("close", () => {
        clearInterval(timer)
        clients.delete(client)
    })

    return client.response!
})

app.post("/publish", async ({ req }) => {
    const data = await req.json()

    for (const client of clients) {
        client.dispatchEvent(new MessageEvent("publish", {
            data: JSON.stringify(data)
        }))
    }

    return new Response("OK")
})

export default app
TypeScript

The above application can be run in all runtimes as well.

WebSocket

Although there is a WebSocket API in the client, there isn’t a universal way to serve WebSockets on the server side. Node.js needs third-party libraries such as ws, Deno has Deno.upgradeWebSocket, Bun needs a group of special websocket listeners, and Cloudflare Workers use new WebSocketPair().

For simplicity and consistency, I’ve created a module in the JsExt library that can be used in all these runtimes to serve WebSockets, it conforms to the web standard as well.

// main.ts
// A simple chat application
import { Hono } from "hono"
import { WebSocketServer, WebSocketConnection } from "@ayonli/jsext/ws"
import runtime from "@ayonli/jsext/runtime"

const app = new Hono()
const server = new WebSocketServer()
const clients = new Map<string, WebSocketConnection>()

app.get("/join", async ({ req }) => {
    const name = req.query("name")!

    if (clients.has(name)) {
        return new Response(`Name '${name}' is taken`, { status: 409 })
    }

    const { socket, response } = server.upgrade(req.raw) // need to use `req.raw` here
    let timer: NodeJS.Timeout | number

    socket.addEventListener("open", () => {
        clients.set(name, socket)
        timer = setInterval(() => {
            socket.send(JSON.stringify({ event: "ping", data: "ping" }))
        }, 5_000)
    })

    socket.addEventListener("close", (ev) => {
        clearInterval(timer)
        clients.delete(name)

        if (!ev.wasClean) {
            console.error(`Client '${name}' closed unexpectedly, reason: ${ev.reason}`)
        }
    })

    socket.addEventListener("message", ev => {
        if (typeof ev.data !== "string")
            return

        const { event, data } = JSON.parse(ev.data) as {
            event: string
            data: unknown
        }

        if (event === "chat") {
            const { receiver, message } = data as {
                receiver: string
                message: string
            }
            const client = clients.get(receiver)

            if (client) {
                client.send(JSON.stringify({
                    event,
                    data: { sender: name, message },
                }))
            } else {
                socket.send(JSON.stringify({
                    event: "error",
                    message: `Receiver '${receiver}' is offline`,
                }))
            }
        } else if (event === "broadcast") {
            const { message } = data as { message: string }
            const _data = { sender: name, message }

            for (const client of clients.values()) {
                client.send(JSON.stringify(_data))
            }
        }
    })

    return response
})

app.get("/clients", () => {
    return Response.json([...clients.keys()])
})

export default {
    fetch: runtime().identity === "bun" ? undefined : app.fetch,
}

// Bun needs special setup for WebSocket server
if (runtime().identity === "bun") {
    // @ts-ignore
    const bunServer = Bun.serve({
        fetch: app.fetch,
        websocket: server.bunListener,
    })
    server.bunBind(bunServer)
}
TypeScript

Serve Static Files

I’ve created a function in the http module of JsExt library, using it in our code will allow our code to serve static files in all four runtimes without any runtime-bound dependencies.

// main.ts
import { Hono } from "hono"
import { serveStatic } from "@ayonli/jsext/http"

const app = new Hono()

app.get("/assets/*", async ({ req, env }, next) => {
    const res = await serveStatic(req.raw, {
        fsDir: "./assets",
        kv: (env as any)?.__STATIC_CONTENT, // for Cloudflare Workers
        urlPrefix: "/assets",
    })

    if (res.status === 404) {
        return await next() // fallback to Hono route handlers
    } else {
        return res
    }
})

export default app
TypeScript

The above code will run in Node.js, Deno, and Bun, without any special configuration(, although we need to use --allow-read flag in Deno).

In order to work in Cloudflare Workers, we need to create a wrangler.toml file with the following configuration:

# wrangler.toml
main = "main.ts"
compatibility_date = "2024-07-25"

[site]
bucket = "./assets"
TOML

And now our code will run in Cloudflare Workers as expected.

Conclusion

This article demonstrates the modern approach to writing server applications in JavaScript, by using modern Web APIs and conforming to web standards, we can finally unify the tech stacks between the backend and the frontend.

Leave a comment