# TypeScript 运行库

**URL:** https://heroiclabs.com/docs/zh/nakama/server-framework/typescript-runtime/
**Summary:** 学习如何使用 JavaScript 编写游戏服务器自定义逻辑，配置和开发项目。

---


# TypeScript 运行库

游戏服务器嵌入了一个 JavaScript 虚拟机（VM），可用于加载和运行游戏项目特有的自定义逻辑。这是除 [Go](../go-runtime/) 和 [Lua](../lua-runtime/) 之外，又一种编写服务器代码的受支持编程语言。

这适用于实现您不想在客户端上运行的游戏代码，或者信任客户端提供未经检查的输入。您可以将此 Nakama 功能想象为类似于其他系统中的 Lambda 或云函数。如果您想授予用户[玩游戏日奖](../../guides/concepts/daily-rewards/)，这就是一个很好的用例。

TypeScript 是 JavaScript 语言的超集。您可以用来使用类型编写代码，这有助于减少错误和意外的运行时行为。Nakama 对 JavaScript 的支持旨在直接考虑在代码中使用 TypeScript，推荐用它来开发 JavaScript 代码。

在[官方文档](https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes.html)中，您可以学习如何编写使用了 TypeScript 的 JavaScript 代码。

{{< youtube "FXguREV6Zf8" >}}

## 前提条件

您需要安装这些工具才能在项目中使用 TypeScript：

- Node v14 (active LTS) 或以上版本。
- 有关 UNIX 工具或关于同等 Windows 工具的基础知识。

TypeScript 编译器和其他依赖项将用 NPM 获取。

## 初始化项目

这些步骤将设置一个工作区，以编写将由游戏服务器运行的所有项目代码。

定义项目工作区的文件夹名称。在本例中我们将使用“ts-project”。

```bash
mkdir -p ts-project/{src,build}
cd ts-project
```

使用 NPM 在项目中设置节点依赖项。安装 TypeScript 编译器。

```bash
npm init -y
npm install --save-dev typescript
```

使用安装到项目中的 TypeScript 编译器设置编译器选项。

```bash
npx tsc --init
```

现在您将拥有一个“tsconfig.json”文件，该文件描述了在 TypeScript 编译器上运行的可用选项。删掉注释后的条目并更新它时，一个最小的文件是这样的：

```json
{
  "compilerOptions": {
    "target": "es5",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}
```

将此配置添加到 `"compilerOptions"` 块：

```json
"outFile": "./build/index.js",
```

{{< note type="important" title="Important" >}}
有关不依赖 TypeScript 编译器的示例，请参阅 [TypeScript 通过 Rollup 捆绑](../typescript-runtime/#bundling-with-rollup)，使您能够将其他节点模块与 Nakama 的 TypeScript 代码捆绑在一起。
{{< / note >}}

将 Nakama 运行时类型作为依赖项添加到项目中，并配置编译器以查找这些类型。

``` shell
npm i 'https://github.com/heroiclabs/nakama-common'
```

将此配置选项添加到“tsconfig.json”文件的 `"compilerOptions"` 块中：

``` json
"typeRoots": [
  "./node_modules"
],
```

这样就完成了设置，您的项目应该与此布局类似：

``` shell
.
├── build
├── node_modules
│   ├── nakama-runtime
│   └── typescript
├── package-lock.json
├── package.json
├── src
└── tsconfig.json
```

## 开发代码

我们将编写一些简单的代码并将其编译为 JavaScript，以便游戏服务器可以运行它。

所有代码必须从游戏服务器在启动时在全局范围内查找的函数开始执行。此函数必须调用 `"InitModule"`，您以此注册 RPC、before/after 挂钩和服务器管理的其他事件函数。

以下代码为一个简单的 Hello World 示例，使用了 `"Logger"` 来编写消息。将源文件命名为“src”文件夹内的“main.ts”。您可以用最喜欢的编辑器或 IDE 编写它。

``` typescript
let InitModule: nkruntime.InitModule =
        function(ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, initializer: nkruntime.Initializer) {
    logger.info("Hello World!");
}
```

现在，我们可以将此文件添加到编译器选项并运行 TypeScript 编译器。

``` json
{
  "files": [
    "./src/main.ts"
  ],
  "compilerOptions": {
    // ... etc
  }
}
```

编译代码库：

``` shell
npx tsc
```

## 限制

### 兼容性

JavaScript 基于当前支持 JavaScript ES5 规范的 [goja VM](https://github.com/dop251/goja) 。JavaScript 运行库可以访问 ES5 规范中包含的标准库函数。 

不支持需要 Node、web/浏览器 API 或本机支持（例如通过 Node）的库。

不能从 Go 运行库调用 TypeScript 函数，也不能从 TypeScript 运行库调用 Go 函数。

### 全局状态

JavaScript 运行时代码在实例化上下文（VM 池）中执行。不能用全局变量在内存中存储状态或与其他 JS 进程或函数调用通信。

### 单一线程

在 JavaScript 运行库中不支持使用多线程处理。

### 沙盒

JavaScript 运行时代码是完全沙盒化的，无法访问文件系统、输入/输出设备或生成 OS 线程或进程。 

这样服务器可保证 JS 模块不会导致致命错误 — 运行时代码不会触发意外的客户端断开连接或影响主服务器进程。

## 运行项目

### 通过 Docker

通过 Docker 在本地运行服务器最方便。

为此创建一个名为 `Dockerfile` 的文件。

```dockerfile
FROM node:alpine AS node-builder

WORKDIR /backend

COPY package*.json .
RUN npm install

COPY tsconfig.json .
COPY main.ts .
RUN npx tsc

FROM registry.heroiclabs.com/heroiclabs/nakama:3.13.1

COPY --from=node-builder /backend/build/*.js /nakama/data/modules/build/
COPY local.yml .
```

然后创建 `docker-compose.yml` 文件。更多信息，请参阅[通过 Docker Compose 安装 Nakama](../../getting-started/install/docker/)文档。

```yaml
version: '3'
services:
  postgres:
    command: postgres -c shared_preload_libraries=pg_stat_statements -c pg_stat_statements.track=all
    environment:
      - POSTGRES_DB=nakama
      - POSTGRES_PASSWORD=localdb
    expose:
      - "8080"
      - "5432"
    image: postgres:12.2-alpine
    ports:
      - "5432:5432"
      - "8080:8080"
    volumes:
      - data:/var/lib/postgresql/data

  nakama:
    build: .
    depends_on:
      - postgres
    entrypoint:
      - "/bin/sh"
      - "-ecx"
      - >
        /nakama/nakama migrate up --database.address postgres:localdb@postgres:5432/nakama &&
        exec /nakama/nakama --config /nakama/data/local.yml --database.address postgres:localdb@postgres:5432/nakama
    expose:
      - "7349"
      - "7350"
      - "7351"
    healthcheck:
      test: ["CMD", "/nakama/nakama", "healthcheck"]
      interval: 10s
      timeout: 5s
      retries: 5
    links:
      - "postgres:db"
    ports:
      - "7349:7349"
      - "7350:7350"
      - "7351:7351"
    restart: unless-stopped

volumes:
  data:
```

现在通过此命令运行服务器：

```bash
docker compose up
```

### 不使用 Docker

为 [Linux](../../getting-started/install/linux/)、[Windows](../../getting-started/install/windows/) 或 [macOS](../../getting-started/install/macos/) 安装二进制文件堆栈。完成此步骤后，您可以运行游戏服务器，使其加载代码：

``` shell
nakama --logger.level DEBUG --runtime.js_entrypoint "build/index.js"
```

记住，您需要从 Terminal（“终端”）运行 `npx tsc`，从而构建 `build/index.js` 文件，然后才能执行上述命令。

### 确认服务器正在运行

服务器日志将显示此输出或类似输出，显示在启动时加载并执行了我们在上面编写的代码。

``` json
{"level":"info","ts":"...","msg":"Hello World!","caller":"server/runtime_javascript_logger.go:54"}
```

## 通过 Rollup 捆绑

上述设置仅依赖 TypeScript 编译器。这样就只需简单的工具链和工作流，但限制了将 TypeScript 代码与其他节点模块绑定的能力。

[Rollup](https://rollupjs.org/guide/en/) 是可用于绑定不依赖于 Node.js 运行库而在 Nakama 中运行的节点模块的选项之一。

### 配置 Rollup

当配置 TypeScript 项目以使用 Rollup 时，如果您遵循了上述步骤，则需要对项目进行一些额外的步骤和更改。

您首先需要安装一些附加依赖项，使您能够运行 Rollup，以构建服务器运行时代码。这些包括 [Babel](https://babeljs.io/)、[Rollup](https://rollupjs.org/)、各自的多个插件/预设和 `tslib`。

为此，在 Terminal 中运行以下命令，这将安装依赖项，并将其作为开发依赖项添加到 `package.json` 文件：

```bash
npm i -D @babel/core @babel/plugin-external-helpers @babel/preset-env @rollup/plugin-babel @rollup/plugin-commonjs @rollup/plugin-json @rollup/plugin-node-resolve @rollup/plugin-typescript rollup tslib
```

将 Rollup 作为项目开发依赖项安装后，需要修改 `package.json` 中的 `build` 脚本，以运行 `rollup -c` 命令而非 `tsc` 命令。您还应该添加一个 `type-check` 脚本，以便您验证 TypeScript 的编译，而无需实际发出构建文件。

**package.json**

```json
{
  ...
  "scripts": {
    "build": "rollup -c",
    "type-check": "tsc --noEmit"
  },
  ...
}
```

然后必须将以下 `rollup.config.js` 文件添加到项目。

**rollup.config.js**

```javascript
import resolve from '@rollup/plugin-node-resolve';
import commonJS from '@rollup/plugin-commonjs';
import json from '@rollup/plugin-json';
import babel from '@rollup/plugin-babel';
import typescript from '@rollup/plugin-typescript';
import pkg from './package.json';

const extensions = ['.mjs', '.js', '.ts', '.json'];

export default {
  input: './src/main.ts',
  external: ['nakama-runtime'],
  plugins: [
    // Allows node_modules resolution
    resolve({ extensions }),

    // Compile TypeScript
    typescript(),

    json(),

    // Resolve CommonJS modules
    commonJS({ extensions }),

    // Transpile to ES5
    babel({
      extensions,
      babelHelpers: 'bundled',
    }),
  ],
  output: {
    file: 'build/index.js',
  },
};
```

然后将 `babel.config.json` 文件添加到项目。

**babel.config.json**

```json
{
  "presets": [
    "@babel/env"
  ],
  "plugins": []
}
```

也必须对 `tsconfig.json` 文件做更改。使用 Rollup 可简化构建过程，即您每次将新 `*.ts` 文件添加到项目，不再必须手动更新 `tsconfig.json` 文件。用以下示例更新现有 `tsconfig.json` 文件的内容。

**tsconfig.json**

```json
{
  "compilerOptions": {
    "noImplicitReturns": true,
    "moduleResolution": "node",
    "esModuleInterop": true,
    "noUnusedLocals": true,
    "removeComments": true,
    "target": "es5",
    "module": "ESNext",
    "strict": false,
  },
  "files": [
    "./node_modules/nakama-runtime/index.d.ts",
  ],
  "include": [
    "src/**/*",
  ],
  "exclude": [
    "node_modules",
    "build"
  ]
}
```

然后需要在 `main.ts` 文件的末尾加入一行，该行引用 `InitModule` 函数。这可以确保 Rollup 不在构建中忽略它。

**main.ts**

```typescript
function InitModule(ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, initializer: nkruntime.Initializer) {
  logger.info('TypeScript module loaded.');
}

// Reference InitModule to avoid it getting removed on build
!InitModule && InitModule.bind(null);
```

您要需要为 nakama 创建名为 `local.yml` 的配置。`runtime.js_entrypoint` 设置指示 nakama 读取构建 javascript 代码。

```yaml
console:
  max_message_size_bytes: 409600
logger:
  level: "DEBUG"
runtime:
  js_entrypoint: "build/index.js"
session:
  token_expiry_sec: 7200 # 2 hours
socket:
  max_message_size_bytes: 4096 # reserved buffer
  max_request_size_bytes: 131072
```

最后，您需要对 `Dockerfile` 略做改动，确保跨 `rollup.config.js` 和 `babel.config.json` 文件复制。您还必须修改 `RUN` 命令，以运行更新过的构建命令，而不是直接使用 TypeScript 编译器。用以下示例更新现有 `Dockerfile` 文件的内容。

**Dockerfile**

```dockerfile
FROM node:alpine AS node-builder

WORKDIR /backend

COPY package*.json .
RUN npm install

COPY . .
RUN npm run build

FROM registry.heroiclabs.com/heroiclabs/nakama:3.13.1

COPY --from=node-builder /backend/build/*.js /nakama/data/modules/build/
COPY local.yml /nakama/data/
```

### 本地构建模块

确保所有依赖项已安装：

```bash
npm i
```

执行类型检查，确保 TypeScript 将成功编译：

```bash
npm run type-check
```

构建项目：

```bash
npm run build
```

### 通过 Docker 运行模块

要通过自定义服务器运行时代码运行 Nakama，运行：

```bash
docker compose up
```

如更改过模块，想要重新运行它，可以运行：

```bash
docker compose up --build nakama
```

这可以确保映像是用最新更改构建的。

## 错误处理

JavaScript 用异常处理错误。发生错误时，引发异常。为处理自定义函数引发或运行库提供的异常，必须将代码包在 `try catch` 块中。

```typescript
function throws(): void {
    throw Error("I'm an exception");
}

try {
    throws();
} catch(error) {
    // Handle error.
    logger.error('Caught exception: %s', error.message);
}
```

JavaScript 中未处理的异常由运行库捕获并记录，除非它们在初始化期间（运行库在启动时调用 InitModule 函数时）未被处理 — 这些异常将停止服务器。我们建议您使用此模式，并包装所有运行时 API 调用以进行错误处理和检查。

```typescript
try {
    // Will throw an exception because this function expects a valid user ID.
    nk.accountsGetId([ 'invalid_id' ]);
} catch(error) {
    logger.error('An error has occurred: %s', error.message);
}
```

## 向客户端返回错误

在编写自己的自定义运行时代码时，应确保在处理请求时发生的任何错误都将会以适当方式传回客户端。这意味着返回给客户端的错误应该包含一条明确的告知错误消息和一个适当的 HTTP 状态代码。

在内部，Nakama 运行库使用 gRPC 错误代码，并在将错误返回给客户端时将其转换为适当的 HTTP 状态代码。

Nakama TypeScript 运行库在 `nkruntime.Codes` 枚举中定义错误代码。您可以用这些来定义自定义 `nkruntime.Error` 对象。以下是您可以在自己的模块中定义的一些错误示例。

``` typescript
const errBadInput: nkruntime.Error = {
  message: 'input contained invalid data',
  code: nkruntime.Codes.INVALID_ARGUMENT
};

const errGuildAlreadyExists: nkruntime.Error = {
  message: 'guild name is in use',
  code: nkruntime.Codes.ALREADY_EXISTS
};
```

以下示例显示如何在 [RPC](../introduction/#rpc-functions) 调用和 [Before 挂钩](/docs/nakama/server-framework/introduction/hooks/#before-hooks)中返回合适的错误。

``` typescript
const createGuildRpc: nkruntime.RpcFunction = (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, payload: string): string | void  => {
  // ... check if a guild already exists and set value of `alreadyExists` accordingly
  const alreadyExists = true;

  if (alreadyExists) {
    throw errGuildAlreadyExists;
  }

  return JSON.stringify({ success: true });
};

const beforeAuthenticateCustom: nkruntime.BeforeHookFunction<nkruntime.AuthenticateCustomRequest> = (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime.Nakama, data: nkruntime.AuthenticateCustomRequest): void | nkruntime.AuthenticateCustomRequest => {
  const pattern = new RegExp('^cid-([0-9]{6})$');

  if (!pattern.test(data.account.id)) {
    throw errBadInput;
  }
  
  return data;
}
```

## 升级

### 确定您的当前版本

在尝试升级 Nakama 服务器时，您应该首先确定您正在使用的当前版本。为此既可以看 `Dockerfile` 和映像名称末尾标记的版本（例如 `heroiclabs/nakama:3.22.0`），或看 `package.json`（或 `package-lock.json` — 如安装时使用的是最新的，这将给出精确提交哈希)，用于 `nakama-runtime` 的 `version`（又称 Nakama Common）。使用后者时，确定当前 `nakama-runtime` 版本后，您可以查询[兼容性矩阵](../../getting-started/release-notes/#compatibility-matrix)，确定您使用的 Nakama 二进制文件的版本。


### 确定更改

建立当前 Nakama 版本后，您应当查看[服务器运行时版本说明](../../getting-started/release-notes/#nakama)，查看自您当前的版本后，已有了哪些更改。这将帮助您识别可能影响您编写的自定义服务器运行时代码的任何中断更改或更改。

### 安装最新版本

确定要升级到哪个 Nakama 版本后，应当更新项目中的 `nakama-runtime` 版本。再次查询[兼容性矩阵](../../getting-started/release-notes/#compatibility-matrix)，您可以确定应当安装的 `nakama-runtime` 包的版本。

然后可以按如下方式安装（其中 `<version>` 是 github 标记，例如 `v1.23.0`）：

```bash
npm i https://github.com/heroiclabs/nakama-common#<version>
```

### 升级二进制 Nakama 文件

升级 `nakama-runtime` 包的版本后，必须升级服务器使用的 Nakama 二进制文件的版本。

如果您直接使用二进制文件，您可以直接从 [Nakama GitHub 版本](https://github.com/heroiclabs/nakama/releases)页面下载合适的版本。

如使用的是 Docker，则必须在最终 `FROM` 语句中指明正确的版本，升级 `Dockerfile` 。

```dockerfile
FROM registry.heroiclabs.com/heroiclabs/nakama:3.13.1
```

### 常见问题

**TypeError：对象没有成员**

如果您收到上述错误消息，很可能服务器运行的 Nakama 版本中没有您正在使用的 Nakama 函数。出现这种情况是因为您在 TypeScript 项目中安装的 `nakama-runtime` 包的版本较新，与您使用的 Nakama 二进制文件的版本不兼容。查询[兼容性矩阵](../../getting-started/release-notes/#compatibility-matrix)，确保您使用的 Nakama 和 Nakama Common 版本兼容 (`nakama-runtime`)。

## 沙盒和限制

TypeScript 服务器运行库通过 [Goja](https://github.com/dop251/goja) Go 包以沙盒化 JavaScript VM 的形式提供。在服务器上执行的所有 TypeScript/JavaScript 服务器运行时代码只能访问通过 Nakama 向其公开的特定功能。

使用 TypeScript 开发服务器运行时代码时，需要注意几个关键限制：

- 必须将所有代码编译为符合 ES5 的 JavaScript
- 您的代码不能以任何方式与操作系统交互，包括文件系统
- 不能使用依赖 NodeJS 功能的任何模块（例如 `crypto`、`fs`），因为您的代码不是在 Node 环境中运行

关于 `Goja` 内的特定兼容性问题，请参阅[已知 Goja 非兼容性与解释](https://github.com/dop251/goja#known-incompatibilities-and-caveats)。

### 全局状态

TypeScript 运行库不能使用全局变量在内存中存储状态。

### 记录器

JavaScript 记录器是包裹服务器记录器的包装器。在这些示例中，您已经在输出字符串中看到格式化的“动词”（例如“%s”），然后是将替换它们的参数。 

为了更好地记录和检查 JavaScript VM 使用的底层 Go 结构，可以使用“%#v”等动词。[此处](https://golang.org/pkg/fmt/)为完整参考。

## 后续步骤

请查看 [Nakama 项目模板](https://github.com/heroiclabs/nakama-project-template)，其涉及以下 Nakama 功能：

- [权威多人游戏比赛处理程序](../../concepts/multiplayer/authoritative/)
- [应用程序内通知](../../concepts/notifications/)
- [存储](../../concepts/storage/collections/)
- [RPC](../introduction/#functionality)
- [用户钱包](../../concepts/user-accounts/#virtual-wallet)
