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);
TypeScriptOr 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)
TypeScriptIf 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)
TypeScriptAnd 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)
TypeScriptOh 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 })
}
}
TypeScriptAnd 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`)
BashUse 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
TypeScriptHono 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`)
BashRespond 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",
},
})
}
}
TSXAs 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`)
BashRespond 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
TypeScriptServer-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
TypeScriptThe 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)
}
TypeScriptServe 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
TypeScriptThe 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"
TOMLAnd 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.