教程Node.js

部署 Node.js 项目

Zeabur 支持多种类型的 Node.js 项目:

Monorepo 支持

Zeabur 会自动识别 pnpm workspaceYarn Workspace 以及绝大多数基于这两个 Monorepo 方法的工具链,如 Turborepo 和 Lerna 等。

以 Turborepo 的 basic 模板为例,它的 pnpm-workspace.yaml 内容为:

packages:
  - "apps/*"
  - "packages/*"

apps 下有 docsweb 两个目录。Zeabur 默认会挑选列在 workspace 包清单中的第一个 Node.js 应用程序来部署,就此例而言,Zeabur 会部署 apps/docs 这个应用程序。如果想要部署 apps/web,可以在项目的根目录新增 zbpack.json 文件,并加入以下内容:

{
    "app_dir": "apps/web"
}

或者是使用 环境变量 进行设置:

ZBPACK_APP_DIR=apps/web

这样就会部署 apps/web 这个应用程序。

如果你的应用程序确实在根目录,但使用 pnpm-workspace.yaml 放置 React 组件、设计系统等元素,你可以使用环境变量 ZBPACK_APP_DIR=/ 或在 zbpack.json 加入

{
    "app_dir": "/"
}

让 Zeabur 部署你放在根目录的应用程序。

NxRush 等不基于上述两种方法的 Monorepo 方案尚未支持一键部署,可以先参考“停用缓存功能”和“更改编译和启动命令”对 Monorepo 进行设置。

停用缓存功能

默认 Zeabur 会调换安装流程,通过记录安装依赖的步骤,加速你往后 CI/CD 的速度。一般的 Node.js 项目应该不受影响,但假如你的项目在安装依赖时需要用到项目中的其他文件而导致项目编译失败,你可能得停用缓存功能才能正常使用。

在项目的根目录新增 zbpack.json 文件,并加入以下内容,即可停用这个功能。

{
    "cache_dependencies": false
}

或者是设置 环境变量

ZBPACK_CACHE_DEPENDENCIES=false

更改编译和启动命令

假如你的项目类型较为特殊(如使用自定义的 Monorepo 工具链),你可能会需要指定服务的编译 (build) 和启动 (start) 命令,比如将 frontend 服务的启动命令改成 pnpm run start:frontend;把 api 服务的编译命令改成 pnpm run start:api

这里介绍两种更改这个命令的方式。

使用文件修改

zbpack.json 加入以下两个设置项:

{
    "build_command": "<自定义编译命令>",
    "start_command": "<自定义启动命令>"
}

默认 zbpack.json 的设置会应用到所有部署的服务。如果你想限定「服务名称为 api 的使用某个命令;服务名称为 frontend 的使用另一个命令」,则需要创建 zbpack.[服务名称].json 的文件:

// zbpack.api.json
{
    "build_command": "pnpm run build:api",
    "start_command": "pnpm run start:api"
}
// zbpack.frontend.json
{
    "build_command": "pnpm run build:frontend",
    "start_command": "pnpm run start:frontend"
}

zbpack.json 的应用优先级是 zbpack.[服务名称].json 大于 zbpack.json

使用环境变量修改

你也可以使用 环境变量 来设置编译和启动命令:

ZBPACK_BUILD_COMMAND=pnpm run build:api
ZBPACK_START_COMMAND=pnpm run start:api

指定 Node.js 和包管理器版本

Node.js 版本

默认情况下,Zeabur 会使用最新的 LTS Node.js 版本来构建你的项目。

如果你想使用不同的版本,你可以在 package.json 中指定:

{
    "engines": {
        "node": "18.1.0"
    }
}

包管理器版本

默认情况下,Zeabur 会使用 yarn 来安装你的项目依赖。

如果你想使用其他的包管理器以及特定的版本,你可以在 package.json 中指定:

{
    "packageManager": "pnpm@8.0.0"
}

网页爬取

Playwright 支持

如果你的 package.json 有声明 playwright-chromium,Zeabur 会自动帮您准备好运行 Playwright 必要的环境。

注意 Playwright 应以 Headless 模式运行,通常默认就是如此。

Puppeteer 支持

如果你的 package.json 有声明 puppeteer,Zeabur 会自动帮您准备好运行 Puppeteer 必要的环境。

注意 Puppeteer 应以 Headless 模式运行,通常默认就是如此。

以 Serverless 方式部署

如果你的 Node.js 符合以下条件,那么你可以在 Zeabur 使用 Serverless 方式部署:

  1. 项目本身使用了如 Next.jsNuxt.jsRemix 等专为 Serverless 部署所设计的框架。
  2. 项目本身不使用上述框架,但服务本身符合 Serverless 的理念:每个请求的状态彼此独立,且可以在没有请求的情况下自动休眠。

启用 Serverless

如果您的项目是使用 Next.js、Nuxt.js、Waku、Angular 和 Remix 编写的(完整名单可以参考 zbpack 代码库的 getServerless 函数), Zeabur 会自动将项目部署成 serverless 形式。如果需要停用,可参考〈启用 Serverless〉一章。

如果是其他框架编写而成的项目(或者是如下文,自行制作的 Serverless 格式),目前需要 opt-in。请参考 启用 Serverless 页面启用 serverless 支持。如果测试上没有问题,也欢迎送 Pull Request 到 zbpack 代码库

把项目构建成 Serverless 格式

如果你的项目使用了 Next.js、Nuxt.js、Remix 等框架,那么你可以直接跳过这个步骤,因为 zbpack 会自动将他转换成 Serverless 产物格式。

如果你的项目不使用上述框架,那么你需要自行把项目构建成 Serverless 格式,这里以一个最基本的 Express.js 应用程序为例:

// app.js
 
const express = require('express')
const app = express()
 
app.get('/', (req, res) => {
    res.send('Hello World!')
})
 
module.exports = app

从上述示范中,可以看到 app.js 这个模块已经输出了 app 这个对象,这是一个符合 Zeabur 的 Serverless 处理函数格式的对象,因此我们唯一要做的就是将他在构建阶段输出到 .zeabur/output/functions 中的 index.func 目录下。

为了实现这件事,我们可以在代码中加入以下脚本:

// scripts/build.js
 
const esbuild = require('esbuild');
const fs = require('fs');
 
// dynamic-required files
const dynamicRequiredDirs = ['views']
 
// static files
const staticFileDirs = ['public']
 
// Remove old output
if (fs.existsSync('.zeabur/output')) {
    console.info('Removing old .zeabur/output')
    fs.rmSync('.zeabur/output', {recursive: true})
}
 
function getModuleEntries() {
    function getModuleEntriesRecursive(dir) {
        let entries = []
        fs.readdirSync(dir).forEach(file => {
            const path = `${dir}/${file}`
            if (fs.statSync(path).isDirectory()) {
                if(file === 'node_modules') return
                entries = entries.concat(getModuleEntriesRecursive(path))
            } else if (file.endsWith('.js')) {
                entries.push(path)
            }
        })
        return entries
    }
    return getModuleEntriesRecursive('.')
}
 
// build with esbuild
try {
    esbuild.build({
        entryPoints: getModuleEntries(),
        bundle: false,
        minify: false,
        outdir: '.zeabur/output/functions/index.func',
        platform: 'node',
        target: 'node20',
        plugins: [{
            name: 'make-all-packages-external',
            setup(build) {
                let filter = /^[^.\/]|^\.[^.\/]|^\.\.[^\/]/ // Must not start with "/" or "./" or "../"
                build.onResolve({filter}, args => ({path: args.path, external: true}))
            },
        }],
    }).then(res => {
        if (res.errors.length > 0) {
            console.error(res.errors)
            process.exit(1)
        }
        console.info('Successfully built app.js into .zeabur/output/functions/index.func')
        fs.copyFileSync('.zeabur/output/functions/index.func/app.js', '.zeabur/output/functions/index.func/index.js')
        fs.rmSync('.zeabur/output/functions/index.func/app.js')
    })
} catch (error) {
    console.error(error)
}
 
// copy node_modules into function output directory
console.info('Copying node_modules into .zeabur/output/functions/index.func/node_modules')
fs.cpSync('node_modules', '.zeabur/output/functions/index.func/node_modules', {recursive: true, verbatimSymlinks: true})
 
// copy package.json into function output directory
console.info('Copying package.json into .zeabur/output/functions/index.func')
fs.cpSync('package.json', '.zeabur/output/functions/index.func/package.json')
 
// copy dynamic-required files into function output directory, so they can be required during runtime
dynamicRequiredDirs.forEach(dir => {
    copyIfDirExists(dir, `.zeabur/output/functions/index.func/${dir}`)
})
 
// copy static files into function output directory, so they can be served by the web server directly
staticFileDirs.forEach(dir => {
    copyIfDirExists(dir, `.zeabur/output/static`)
})
 
function copyIfDirExists(src, dest) {
    if (fs.statSync(src).isDirectory()) {
        console.info(`Copying ${src} to ${dest}`)
        fs.cp(src, dest, {recursive: true}, (err) => {
            if (err) throw err;
        });
        return
    }
    console.warn(`${src} is not a directory`)
}

这个脚本做的事情非常简单,他会使用 esbuild 构建你的项目,然后把所有 .js 文件按照原有的相对路径放入 .zeabur/output/functions/index.func 目录下,并且把 node_modulespackage.json 复制到 .zeabur/output/functions/index.func 目录下,这样就完成了项目的构建。

特别注意到,我们在这个脚本中保留了两个特殊的可设定字段:

// dynamic-required files
const dynamicRequiredDirs = ['views']
 
// static files
const staticFileDirs = ['public']

这两个字段让我们把除了 .js 以外的东西也一起放入产物内,其中 dynamicRequiredDirs 是一些在运行时期才会被 require 的文件,比如 views 目录下的模板文件; 则是一些静态文件,比如 public 目录下的静态资源,这些资源被放入 .zeabur/output/static 目录下,让他们可以直接被 Zeabur Edge Network 用更快的速度分发给全世界的使用者。

加入这个脚本以后别忘了在项目中安装 esbuild,然后在 package.jsonscripts 中加入 build 指令:

{
    "scripts": {
        "build": "node scripts/build.js"
    }
}

你可以在 zeabur/expressjs-template 找到完整的范例代码。也可以根据自己的需求修改 scripts/build.js 脚本。

额外注意事项

  1. 监听的 port 使用 process.env.PORT

    例如:

    const port = process.env.PORT || 3000
    // 而非 const port = 3000
  2. 避免使用 nodemon 作为 runtime,开发完后,换成一般的 node 指令

    例如 package.json 內:

    {
        "scripts": {
            "start": "node server.js"
            // 而非 "start": "nodemon server.js"
        }
    }