对于 Next.js 15 Custom Server 的最佳实践

Web 补丁网站next.jsreactsocket.iocustom serverchatroombuild
浏览数 - 212发布于 - 2025-06-19 - 08:24
鲲

5932

背景

要给 https://www.moyu.moe 实现一个聊天室 / Chatroom 的功能,所以需要在 Next.js server 启动的时候进行 Socket.IO 的连接工作

这个时候就要用到 Next.js 的 custom server 功能了,因为类似于 Next.js 这样的全栈框架是没有 index.ts 这样的服务器入口文件的,无法在项目启动时做一些全局的服务连接准备工作,譬如创建一个 Socket.IO 实例进行连接准备

本文用到的参考文献有下面的四篇,希望阅读完这些文章后再阅读本文

Custom server with TypeScript + Nodemon example - 一个 Next.js custom server 的最小实例

How to set up a custom server in Next.js - Next.js 官方文档

Configuration > next.config.js > output - Next.js 官方文档

How to use with Next.js - Socket.IO 官方文档

Infra

在开始我们的代码编写之前,有下面的一些工作需要做

安装依赖

首先需要安装 Socket.IO 相关的依赖,以及构建项目所需要的依赖,本次我们没有采用 Next.js 官网编写的 httpServer 的手法进行 Custom Server,而是采用了 ultimate-express 作为了 Custom Server

ultimate-express 几乎是目前最快的 Node.js Server,就单位并发数来讲它比 Nuxt 所用的 Nitro 快三倍, 甚至比大部分 GO 框架并发数都高 , 注意,它不是 express

The Ultimate Express. Fastest http server with full Express compatibility, based on µWebSockets.

This library is a very fast re-implementation of Express.js 4. It is designed to be a drop-in replacement for Express.js, with the same API and functionality, while being much faster. It is not a fork of Express.js.
To make sure µExpress matches behavior of Express in all cases, we run all tests with Express first, and then with µExpress and compare results to make sure they match.

我们来安装依赖

pnpm i socket.io socket.io-client # Socket.IO 相关依赖
pnpm i ultimate-express # ultimate-express
pnpm i -D @types/express # ultimate-express 类型定义,由于它几乎是完全和 express 兼容的,使用 express 的类型即可
pnpm i -D nodemon # 监听变化,快速重载 js 文件或文件夹,HMR
pnpm i -D tsx # 这里当作编译 TypeScript 的工具

上面为了解释每个 package 的作用,实际上运行这个命令就可以了

pnpm i socket.io socket.io-client ultimate-express && pnpm i -D @types/express nodemon tsx

环境变量

我们需要新增下面的环境变量

  NEXT_PUBLIC_KUN_PATCH_APP_ADDRESS_DEV_HOST: z.string(),
  NEXT_PUBLIC_KUN_PATCH_APP_ADDRESS_DEV_PORT: z.string(),
  NEXT_PUBLIC_KUN_PATCH_APP_ADDRESS_PROD_HOST: z.string(),
  NEXT_PUBLIC_KUN_PATCH_APP_ADDRESS_PROD_PORT: z.string(),

然后我们新建一个文件利用这些请求地址,以便于生产环境和开发时直接使用

/config/app.ts

export const KUN_VISUAL_NOVEL_PATCH_APP_ADDRESS =
  process.env.NODE_ENV === 'production'
    ? `https://${process.env.NEXT_PUBLIC_KUN_PATCH_APP_ADDRESS_PROD_HOST}${process.env.NEXT_PUBLIC_KUN_PATCH_APP_ADDRESS_PROD_PORT ? `:${process.env.NEXT_PUBLIC_KUN_PATCH_APP_ADDRESS_PROD_PORT}` : ''}`
    : `http://${process.env.NEXT_PUBLIC_KUN_PATCH_APP_ADDRESS_DEV_HOST}${process.env.NEXT_PUBLIC_KUN_PATCH_APP_ADDRESS_DEV_PORT ? `:${process.env.NEXT_PUBLIC_KUN_PATCH_APP_ADDRESS_DEV_PORT}` : ''}`

export const KUN_SOCKET_IO_ROUTE = '/ws'

在我们的项目中,我们在前端用到了 fetch,并不是纯 server actions,所以生产环境是 https 的请求路径

类型定义

上面我们提到了 ultimate-express 的类型需要 express 的类型,因此我们补充一个 d.ts 文件来获取更良好更强大的类型提示

/types/ultimate-express.d.ts

declare module 'ultimate-express' {
  import express from '@types/express'
  export = express
}

这个类型会被自动应用

代码实现

我们的 Custom Server 有下面几个要点

server.ts

Next.js 15 规定 custom server 的 server 入口文件在 /server.ts ,因此我们新建一个文件,编写下面的内容

server.ts

import { createServer } from 'http'
import { Server } from 'socket.io'
import { parse } from 'url'
import next from 'next'
import express from 'ultimate-express'
import { onSocketConnection } from './socket/handler' // 我们关于 Socket.IO 的核心逻辑,以前我们写过一篇 Nuxt Socket.IO 的实践
import { KUN_SOCKET_IO_ROUTE } from '~/config/app' // 定义一个 Socket.IO 的专用路由,这里我们按照常规定义为 /ws,也可以定义为 /api/socket-io 之类的东东

const dev = process.env.NODE_ENV !== 'production'
const hostname = process.env.NEXT_PUBLIC_KUN_PATCH_APP_ADDRESS_PROD_HOST
const port =
  Number(process.env.NEXT_PUBLIC_KUN_PATCH_APP_ADDRESS_PROD_PORT) || 2333

const app = next({ dev, hostname, port, turbopack: true }) // 这里很关键,没有 Turbopack 的 Next.js 慢到你怀疑人生,这里直接开启 Turbopack
const handle = app.getRequestHandler()

app.prepare().then(() => {
  const expressApp = express()

  const httpServer = createServer((req, res) => {
    const parsedUrl = parse(req.url!, true)

    if (parsedUrl.pathname?.startsWith(KUN_SOCKET_IO_ROUTE)) {
      expressApp(req, res) // 让 ultimate-express 处理 /ws 开头的路由
// 这里实测这个 if 判断没什么用,直接使用 handle(req, res, parsedUrl) 处理所有文件即可,这里用的目的就是前期折腾了太多关于 ultimate-express 的问题,不用感觉有点亏,嗯,就是这样!
// 因为我了解到很多开发人员习惯直接在 Next.js 接一个 hono.js 使用,这里我也想体验一下是什么样
    } else {
      handle(req, res, parsedUrl)
    }
  }).once('error', (err) => {
    console.error(err)
    process.exit(1)
  })

  const io = new Server(httpServer, {
    path: KUN_SOCKET_IO_ROUTE,
    cors: {
      origin: process.env.NEXT_PUBLIC_KUN_PATCH_ADDRESS_DEV // 按理说这里可以加一个 NODE_ENV 判断
    }
  })

  io.on('connection', (socket) => {
    onSocketConnection(io, socket)
  })

  httpServer.listen(port, () => {
    console.log(`> Ready on http://${hostname}:${port}`)
    console.log(`> Socket.IO server listening on http://${hostname}:${port}/ws`)
  })
})

package.json

我们的启动脚本也要更改,所以需要自定义一个脚本来启动项目

  "scripts": {
    "dev": "nodemon --exec esno server.ts",
    ...
  },

这里的 dev 命令被我们改为了 "dev": "nodemon --exec esno server.ts", ,这里的 esno 是由 antfu 编写的一个执行 ts 文件的 package,目前只是 tsx 的一个 alias,直接用 tsx 也可以

构建

构建 Next.js Custom Server 的一个相当踩坑的过程,我们总结出了下面的一套构建过程,这是很宝贵的经验

构建脚本

我们在 package.json 中编写下面的构建脚本

  "scripts": {
    "dev": "nodemon --exec esno server.ts",
    "build:next": "next build",
    "build:server": "tsc --project tsconfig.server.json && tsc-alias -p tsconfig.server.json",
    "build:sitemap": "esno scripts/generateKunSitemap.ts",
    "build": "pnpm build:next && pnpm build:server",
    "preview": "node .next/server/server.js",
    "postbuild": "pnpm build:sitemap",
    "format": "prettier . --write",
    "lint": "next lint",
    "lint:fix": "next lint --fix",
    "typecheck": "tsc --noEmit",
    "prisma:push": "pnpx prisma db push",
    "prisma:generate": "pnpx prisma generate",
    "up": "pnpm update --latest && pnpm prisma:generate",
    "start": "pm2 start ecosystem.config.cjs",
    "stop": "pm2 delete kun-visual-novel-patch",
    "deploy:install": "esno scripts/deployInstall.ts",
    "deploy:build": "esno scripts/deployBuild.ts"
  },

这里我们的 pnpm build 会运行 pnpm build:next && pnpm build:server 

生产环境中我们会运行 pnpm deploy:build,它会使用 esno 运行 deployBuild.ts 文件,它是这样的

deployBuild.ts

import { execSync } from 'child_process'
import { config } from 'dotenv'
import { envSchema } from '../validations/dotenv-check'
import { fileURLToPath } from 'url'
import { dirname } from 'path'
import * as fs from 'fs'
import * as path from 'path'

const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)

const envPath = path.resolve(__dirname, '..', '.env')
if (!fs.existsSync(envPath)) {
  console.error('.env file not found in the project root.')
  process.exit(1)
}

config({ path: envPath })

try {
  envSchema.safeParse(process.env)

  console.log('Environment variables are valid.')
  console.log('Executing the commands...')

  execSync(
    'git pull && pnpm prisma:push && pnpm build && pnpm stop && pnpm start',
    { stdio: 'inherit' }
  )
} catch (error) {
  console.error('Invalid environment variables', error)
  process.exit(1)
}

构建 ultimate-express Server

上面的构建脚本中,build:server 较为关键,它会运行 tsc --project tsconfig.server.json && tsc-alias -p tsconfig.server.json 这个脚本的主要作用有两点

  • 使用 tsc 按照 tsconfig.server.json 的配置来构建 ultimate-express server
  • 使用 tsc-alias 来根据 tsconfig.server.json 的配置,将路径别名自动变更到构建后的 dist 文件中

其中第二点很重要,使用 tsc 构建出的文件默认是不会自动将路径别名替换到 dist 文件中的

我们项目目前的 tsconfig.json 为

tsconfig.json

{
  "compilerOptions": {
    "target": "ESNext",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "baseUrl": ".",
    "paths": {
      "~/*": ["./*"]
    },
    "plugins": [
      {
        "name": "next"
      }
    ]
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules", ".next", "backup"]
}

我们项目目前的 tsconfig.server.json 为 

tsconfig.server.json

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "module": "commonjs",
    "outDir": "./.next/kun",
    "target": "esnext",
    "isolatedModules": false,
    "noEmit": false
  },
  "include": ["server.ts", "socket/**/*.ts", "types/**/*.ts"]
}

可以看到,这里我们用 ~ 代替了根目录 /, 举一个例子,我们的代码是这样的

import { prisma } from '~/prisma'
import { messageReactionSchema } from '~/validations/chat'
import { KUN_CHAT_EVENT } from '~/constants/chat'
import type { ChatMessageReaction } from '~/types/api/chat'

这个代码构建后,如果不经过 tsc-alias 的处理,会变为

const prisma_1 = require("~/prisma");
const chat_1 = require("~/validations/chat");
const chat_2 = require("~/constants/chat");

需要注意,ts 是配置了路径别名,所以 dev 的时候知道 ~ 代表了根目录,然而构建后的 js 文件可不认识 ~ 到底是什么,于是就会报错了

然而,加上了 tsc-alias 的配置,构建后的文件就会变成这样

const prisma_1 = require("../../prisma");
const chat_1 = require("../../validations/chat");
const chat_2 = require("../../constants/chat");

它会自动的按照 tsconfig.server.json 的配置转换路径,这样就可以成功构建出 server.js 文件了,server 构建成功

next build

除了构建 server 之外还需要构建 Next.js 的实例,这里有一个值得注意的地方

next.config.ts 是 Next.js 的配置文件,其中有一个配置项是 output,我们在文章开头的参考文献中提到了这一点

(property) NextConfig.output?: "standalone" | "export" | undefined

The type of build output.

  • undefined: The default build output, .next directory, that works with production mode next start or a hosting provider like Vercel
  • 'standalone': A standalone build output, .next/standalone directory, that only includes necessary files/dependencies. Useful for self-hosting in a Docker container.
  • 'export': An exported build output, out directory, that only includes static HTML/CSS/JS. Useful for self-hosting without a Node.js server.

@see — Output File Tracing

@see — Static HTML Export

我们原来的 Next.js 项目是 standalone 的方式部署的,然而,现在在我们的 Custom Server 场景,使用 standalone 构建似乎是不行的,这里我们保持 undefined,也就是不写,这样构建出来的 Next.js 产物就是成功的,可以通过 ultimate-express 构建出来的 server.js 文件启动

构建环境变量

限于 Next.js 的特性,构建时所用的 .dev 文件中,必须有下面的环境变量

NODE_ENV = "production"

如果为 development 则会因为读不出环境变量而报错退出

而,开发时所用的 .dev 文件,必须有下面的环境变量

NODE_ENV = "development"

如果为 production 则会发生警告

我们没有测试过 .env 文件中没有 NODE_ENV 变量会发生什么

构建产物联调

在上面的构建过程中,ultimate-express server 构建出的产物会被放在 ./.next/kun 目录下,这个目录下有一个 server.js 文件,这是整个项目的入口文件

next app 的构建产物在 ./.next 目录下

这里需要注意的一点是,server.js 文件的启动成功与否似乎与 kun 这个文件夹所在的位置无关,也就是和 ultimate-express server 构建产物所在的位置无关

这意味着你可以将 custom server 的入口文件放在任何地方

限于我们上面没有使用 standalone 模式进行构建,此时我们没有得到一个可以自由分发的构建产物文件夹,可能有些不利于集群部署,不过对于我们目前使用 PM2 部署还是绰绰有余的

如果此时使用 node server.js ,就会发现构建后的项目被成功启动

部署

我们只需要使用 PM2 来启动构建出的 server.js 文件即可

我们在项目根目录编写一个 ecosystem.config.cjs 文件,它是这样的

ecosystem.config.cjs

const path = require('path')

module.exports = {
  apps: [
    {
      name: 'kun-visual-novel-patch',
      port: 2333,
      cwd: path.join(__dirname),
      instances: 1,
      autorestart: true,
      watch: false,
      max_memory_restart: '1G',
      script: './.next/kun/server.js'
    }
  ]
}

我们的 package.json 文件中有

  "scripts": {
    ...
    "start": "pm2 start ecosystem.config.cjs",
    "stop": "pm2 delete kun-visual-novel-patch",
    ...
  },

因此运行 pnpm start 就可以部署完成项目了

我们在实际生产中使用了 Cloudflare Tunnel,因此 up 之后就可以立即在线上访问了,之后会增加一些更智能的 CI / CD 控制流

成品展示

emmm,这个聊天室目前之后补丁站有,想体验的可以去体验一下

https://www.moyu.moe/message/chat

记得先加 kun 这个群才能体验,具体有下面的功能

  • 创建私聊
  • 创建群聊
  • 发送实时消息
  • 实时删除消息
  • 实时给消息点 Reactions,例如 🥰, 😍
  • 实时回复消息,点击回复的消息会跳转到被回复的那条消息
  • 实时看到群在线人数
  • 实时看到 xxx 正在输入, xxx 等 xxx 人正在输入
  • 消息支持 Markdown
  • 支持表情包和 emoji
  • 兼容手机和电脑端

以后如果我不咕咕咕的话可能给论坛的聊天系统也升级一下,当然要先重构 server

关键问题是 Nuxt 现在的 Nitro server dev 的时候卡的我想把它砸了,实在是开发不了,这一定不是我懒,一定不是!

image.png如果对 Web 开发或者计算机感兴趣的朋友欢迎加入网站的 Telegram 开发群 https://t.me/KUNForum

重新编辑于 - 2025-06-19 - 08:38

#1

kunkun好棒!>_<

2025-06-19 - 09:58
kohaku