使用 Web API 构建现代化服务器应用程序

旧风格

曾几何时,我们是像这样编写服务器应用程序的:

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

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

server.listen(8000);
TypeScript

或者使用传统的框架,如 Express

// main.ts
import express from "express"

const app = express()

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

app.listen(8000)
TypeScript

如果我们想要解析请求体,我们需要使用一个插件来实现:

// 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

而如果我们想要处理文件上传,则需要另一个插件:

// 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

我的天呐!编写一个简单的服务器应用程序这么麻烦的吗?

新风格

随着 Fetch API 被现代化运行时所支持,我们可以重写上面的例子为一个简单又容易的版本。

// 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

并且我们可以在多个运行时中运行上面的代码,并不只是 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

使用 Hono 框架

上面的示例可能看起来有些原始,我们需要使用 if 语句来处理每一个路由,我们需要一种方式来使我们的代码具有更高的可读性,类似前面 Express 的例子。

幸运的是,我们有一个现代化的框架,Hono,来帮助我们更优雅地编写应用程序。

// 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 也是一个可以工作于多种运行时的框架,因此我们也可以使用相同的命令来启动应用程序。

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

返回 HTML

在以前,我们会使用一个模版引擎,例如 EJS 来渲染 HTML 页面。然而模版引擎问题众多,在模版文件中我们没有任何变量的智能提示和自动完成,很容易就写出有 bug 的程序来。

但是现在我们可以在服务器应用程序中直接编写 JSX(或者说 React 组件),就像这样:

// 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",
            },
        })
    }
}
TypeScript

一如即往,我们可以使用相同的命令来启用服务器,只需要将入口文件名由 main.ts 修改为 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

返回 FormData

我不知道在传统的服务器应用程序是否有方法返回一个 FormData 给客户端,但是使用现代化的 Web API,这是非常容易的。

// 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

标准的 Web API 中并没有提供用于服务端响应服务器发送事件的原生接口,然而,我已经在 JsExt 库中编写了一个可用于 SSE 的模块,并且它遵从 Web 标准。

// 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

上面的应用程序也可以运行在所有运行时中。

WebSocket

虽然在客户端有一个 WebSocket API,但在服务端却没有一个通用的方式来搭建 WebSocket 服务。Node.js 需要使用第三方库如 ws,Deno 有 Deno.upgradeWebSocket,Bun 需要设置一组特别的 websocket 监听器,而 Cloudflare Workers 则使用 new WebSocketPair()

为简洁和保持一致,我已经在 JsExt 库中编写了一个用于 WebSocket 的模块,可以运行在所有这些运行时中,它也遵守 Web 标准的设计。

// 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

托管静态文件

我已在 JsExt 库的 http 模块中创建了一个函数,在我们的代码中使用它可以让我们的应用程序在所有这四个运行时中托管静态文件,而不需要依赖任何受限于运行时的工具。

// 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

上面的代码无需任何特殊配置即可运行在 Node.js,Deno 和 Bun 中(虽然我们需要在 Deno 中使用 --allow-read 命令行参数)。

要实现在 Cloudflare Workers 中运行,我们需要创建一个 wrangler.toml 文件,里面包含如下的配置:

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

[site]
bucket = "./assets"
TOML

现在我们的代码也可以正常地运行在 Cloudflare Workers 中了。

结论

这篇文章演示了在 JavaScript 中编写服务器应用程序的现代化方式,通过使用现代化的 Web API 并且遵循 Web 标准,我们终于可以一统前后端技术栈。

Leave a comment