Serverless v3 向けプラグインの作り方

## はじめに

今年の 1 月末に Serverless Framework v3 がリリースされました。

https://www.serverless.com//blog/serverless-framework-v3-is-live

この記事では Serverless v3 を対象としたプラグインの作り方を詳しく解説しつつ、v2 との違いやベストプラクティスについて紹介したいと思います。

なお、記事中の情報は執筆時のバージョン ([v3.7.1](https://github.com/serverless/serverless/releases/tag/v3.7.1)) に基づくものです。
また、記事中のサンプルコードでは簡略化のために [Optional chaining](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Optional_chaining) や [Nullish coalescing](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Nullish_coalescing_operator) を使用していますが、これらは Node.js v14 以降でしか使えないため注意してください。

## プラグイン開発の基本

Serverless プラグインは JavaScript で開発します。

基本的には次のように、プラグインの Class を定義して [module.exports](https://nodejs.org/api/modules.html#moduleexports) で CommonJS Module として export する形になります。

```javascript
"use strict";

class MyPlugin {
  constructor() {
    // 初期化
  }
}

module.exports = MyPlugin;
```

作成したプラグインは `serverless.yml` から相対パスで読み込むことができます。

```yaml:serverless.yaml
service: app

plugins:
  - ./my-plugin.js
```

この相対パスの書き方ですが、`serverless.yml` があるディレクトリを基準として [require()](https://nodejs.org/api/modules.html#requireid) でパス解決できる形であればなんでも OK です。

ちなみに v2 では `.serverless_plugins` というディレクトリ以下にあるファイルがローカルプラグインとしてみなされ、`serverless.yml` からプラグイン名 (ディレクトリ名やファイル名) のみで読み込むことができました。
これは v3 でも引き続き動作しますが、現在ではレガシー扱い[^1]で、今後は非推奨として扱われるようなのでローカルプラグインの読み込みには必ず相対パスを使用するようにしましょう。
後述の [ESM プラグイン](#esm-プラグイン) は `.serverless_plugins` からの読み込みに対応していません。

[^1]: 最新のドキュメントからは削除されています。またコード内でも `legacyLocalPluginsPath` という変数名になっています。

### npm パッケージとして公開

作成したプラグインを npm パッケージとして公開したい場合は `package.json` の [main](https://docs.npmjs.com/cli/v8/configuring-npm/package-json#main) を書くようにします。

```json:package.json
{
  "main": "my-plugin.js"
}
```

また、[peerDependencies](https://docs.npmjs.com/cli/v8/configuring-npm/package-json#peerdependencies) でサポートしている Serverless のバージョンを指定しておくことが推奨されています。
v3 のみをサポートする場合は次のようになります。

```jsonc:package.json
{
  // ...
  "peerDependencies": {
    "serverless": "3"
  }
}
```

`npm install` したプラグインは `serverless.yml` から単にプラグイン名 (npm パッケージ名) で読み込むことができます。

```yaml:serverless.yaml
service: app

plugins:
  - my-plugin
```

### ESM プラグイン

[v3.2.0](https://github.com/serverless/serverless/releases/tag/v3.2.0) からは、Serverless 実行環境の Node.js が v12.22 以降である場合に限り [Native ESM](https://nodejs.org/api/esm.html) によるプラグインもサポートされています。
ESM としてプラグインを作る場合は default export します。
ただし、TypeScript や Babel に代表される疑似 ESM での default export はサポートされていないことに注意してください。

```javascript
export default class MyPlugin {
  constructor() {
    // 初期化
  }
}
```

ESM プラグインを npm パッケージとして公開する場合は `package.json` に [peerDependencies](https://docs.npmjs.com/cli/v8/configuring-npm/package-json#peerdependencies) や [engines](https://docs.npmjs.com/cli/v8/configuring-npm/package-json#engines) をしっかり書いてあげると良いでしょう。

```jsonc:package.json
{
  // ...
  "peerDependencies": {
    "serverless": "^3.2.0"
  },
  "engines": {
    "node": ">=12.22"
  }
}
```

## ライフサイクルイベントにフックする

Serverless プラグインは基本的に Serverless の [ライフサイクルイベント](https://www.serverless.com/framework/docs/guides/plugins/creating-plugins#lifecycle-events) にフックする形でカスタムロジックを実行します。

ライフサイクルイベントとは、`serverless` コマンドの実行時に順次発生するイベントのことを意味します。
例えば、デプロイ時には `deploy:deploy` `deploy:finalize` のようなイベントが発生します。
また、各ライフサイクルイベントには `before` イベントと `after` イベントもそれぞれ作成されます。これは `before:deploy:deploy` `after:deploy:deploy` のような形で表されます。

ライフサイクルイベントに対するフック処理は、コンストラクタ内で `hooks` というインスタンスプロパティに定義します。
次の例の場合、デプロイ前に `before deploy` デプロイ後に `after deploy` とコンソール出力されます。

```javascript
class MyPlugin {
  constructor() {
    this.hooks = {
      "before:deploy:deploy": () => this.beforeDeploy(),
      "after:deploy:deploy": () => this.afterDeploy(),
    };
  }

  beforeDeploy() {
    console.log("before deploy");
  }

  afterDeploy() {
    console.log("after deploy");
  }
}
```

`initialize` というライフサイクルイベントは特殊で、すべての `serverless` コマンドで CLI 起動時に発生します。
設定値の上書きなど、すべてのコマンドで事前に実行しておきたい処理はこのイベントにフックすると良いでしょう。

```javascript
class MyPlugin {
  constructor() {
    this.hooks = {
      initialize: () => this.init(),
    };
  }

  init() {
    console.log("initialize");
  }
}
```

他のプラグインなどによって既にロジックが定義されているライフサイクルイベントに対してフック処理を追加した場合、プラグインの読み込み順 (`serverless.yml` に記載した順) に応じて順番に処理されることを覚えておきましょう。

各コマンドでどのようなライフサイクルイベントがどのような順番で発生するかについては公式ドキュメントには記載されていませんが、Serverless の開発者が Gist でチートシートを公開しています。
随時更新されているようなのでこちらを参考にすると良いでしょう。

https://gist.github.com/HyperBrain/50d38027a8f57778d5b0f135d80ea406

## Serverless インスタンス

プラグインクラスのコンストラクタには引数として Serverless インスタンスが与えられます。
Serverless インスタンスを参照することで、フック処理の実行時にサービス構成にアクセスすることができます。

```javascript
class MyPlugin {
  constructor(serverless) {
    this.serverless = serverless;
    this.hooks = {
      initialize: () => this.init(),
    };
  }

  init() {
    console.log("Serverless instance: ", this.serverless);

    const service = this.serverless.service;
    console.log("Provider name: ", service.provider.name);
    console.log("Functions: ", service.functions);
  }
}
```

Serverless インスタンスの詳細な仕様はドキュメント化されていませんが、`service` プロパティに (解決された) `serverless.yml` の設定値を持っていることさえ覚えておけば大体なんとかなります。

プラグインのコンストラクタが実行される時点では [Serverless 変数](https://www.serverless.com/framework/docs/providers/aws/guide/variables) などが解決されていないので、必ずライフサイクルイベントのフック処理からアクセスする必要があることに注意してください。

## CLI オプション

プラグインクラスのコンストラクタには第二引数として CLI オプション (`serverless` コマンドの実行時オプション) が与えられます。

```javascript
class MyPlugin {
  constructor(serverless, options) {
    console.log("CLI options: ", options);
  }
}
```

大抵の場合、後述する [カスタムコマンド](#カスタムコマンドを定義する) で定義した独自オプションの値を取得するために使うことになります。

## プロバイダー固有プラグイン

プラグインを特定のプロバイダーでのみ動作するように作ることも可能です。

コンストラクタ内で `provider` というインスタンスプロパティに、`serverless.getProvider()` で得られた provider インスタンスを設定します。
`serverless.getProvider()` には動作させたいプロバイダーの名前 (`serverless.yml` の `provider.name` に指定する名前) を与えます。

AWS のみで動作するプラグインの場合は次のようになります。

```javascript
class MyPlugin {
  constructor(serverless, options) {
    this.provider = serverless.getProvider("aws");

    // ...
  }
}
```

ここで `this.provider` にセットした provider インスタンスはプロバイダー固有の処理を書くときに色々と活用することができます (後述)。

## カスタムコマンドを定義する

Serverless に独自のカスタムコマンドを追加することもできます。

次の例では `my-command` というカスタムコマンドを定義しています。

```javascript
class MyPlugin {
  constructor() {
    this.commands = {
      "my-command": {
        lifecycleEvents: ["init", "run"],
      },
    };
  }
}
```

カスタムコマンドを定義するときは、必ずライフサイクルイベントも定義します。
上記の例では `serverless my-command` を設定すると、`my-command:init` `my-command:run` というライフサイクルイベントが順番に発生します (それぞれのイベントの `before` と `after` も)。

これだけだとコマンドに対するロジックがないので実行しても何も起こりません。
定義したコマンドにカスタムロジックを追加するには、[ライフサイクルイベントにフック](#ライフサイクルイベントにフックする) します。

```javascript
class MyPlugin {
  constructor() {
    this.commands = {
      "my-command": {
        lifecycleEvents: ["init", "run"],
      },
    };

    this.hooks = {
      "my-command:init": () => this.init(),
      "my-command:run": () => this.run(),
    };
  }

  init() {
    console.log("init");
  }

  run() {
    console.log("run");
  }
}
```

`my-command` を実行すると次のようになります。

```console
$ npx serverless my-command
init
run
```

コマンドの定義に `usage` プロパティを追加すると、`-h` でヘルプを参照したときにコマンドの説明を表示させることができます。

```javascript
class MyPlugin {
  constructor() {
    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    console.log("run");
  }
}
```

```console
$ npx serverless -h
Serverless Framework v3.7.1

Usage
serverless <command> <options>
sls <command> <options>

MyPlugin
my-command                      My custom command

$ npx serverless my-command -h
my-command                      My custom command
--region / -r                   Region of the service
--aws-profile                   AWS profile to use with the command
...
```

### コマンド名について

コマンド名は Serverless の組み込みコマンドや他のプラグインと衝突しないようにユニークである必要があります。
なるべくマルチワードで命名し、プラグイン名や組織名を含めるなどの工夫をしましょう。

### サブコマンドについて

Serverless v2 では次のようにカスタムコマンドの定義をネストさせ、サブコマンドを定義することが可能でした。

```javascript
class MyPlugin {
  constructor() {
    this.commands = {
      "my-command": {
        commands: {
          "sub-command": {
            lifecycleEvents: ["run"],
          },
        },
      },
    };

    this.hooks = {
      "my-command:sub-command:run": () => this.run(),
    };
  }

  run() {
    console.log("run");
  }
}
```

```console
$ npx serverless my-command sub-command
run
```

これは互換性のため v3 でも引き続き動作しますが、v2 時点ですでに非推奨になっている機能で現在は公式ドキュメントからも削除されているので使わないようにしましょう。

## カスタムコマンドにオプションを定義する

カスタムコマンドに独自のオプションを定義することも可能です。

次の例では `--function` オプション (ショートカット `-f`) を定義しています。

```javascript
class MyPlugin {
  constructor(serverless, options) {
    this.options = options;

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
        options: {
          function: {
            usage: "Specify the function you want to handle",
            shortcut: "f",
            default: "myFunction",
            type: "string",
          },
        },
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    console.log(this.options.function);
  }
}
```

### 必須オプション

オプションを必須にしたい場合は、`default` でデフォルト値を設定する代わりに `required` プロパティを指定します。

```javascript
class MyPlugin {
  constructor(serverless, options) {
    this.options = options;

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
        options: {
          function: {
            usage: "Specify the function you want to handle",
            shortcut: "f",
            required: true,
            type: "string",
          },
        },
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    console.log(this.options.function);
  }
}
```

ちなみに、`default` も `required` も指定せず、実行時に与えなかったオプションの値は `undefined` になります。

### オプションの型指定

`type` プロパティではオプションの型を指定します。
`"string"` `"boolean"` `"multiple"` のいずれかを指定することができます。
`type` の指定は Serverless v4 から必須になる予定[^2]なので、必ず指定するようにしましょう。

[^2]: https://www.serverless.com/framework/docs/deprecations#cli-options-extensions-type-requirement

`"boolean"` を指定した場合は値の型が `boolean` になります。

```javascript
class MyPlugin {
  constructor(serverless, options) {
    this.options = options;

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
        options: {
          "dry-run": {
            usage: "Use dry-run mode",
            default: false,
            type: "boolean",
          },
        },
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    console.log("Dry-run:", this.options["dry-run"]);
    console.log("Option type:", typeof this.options["dry-run"]);
  }
}
```

```console
$ npx serverless my-command --dry-run
Dry-run: true
Option type: boolean
```

明示的に `false` を指定したい場合は、オプション名の前に `no-` を付加します。

```console
npx serverless my-command --no-dry-run
Dry-run: false
Option type: boolean
```

`"multiple"` を指定した場合は文字列の配列として扱われます。

```javascript
class MyPlugin {
  constructor(serverless, options) {
    this.options = options;

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
        options: {
          functions: {
            usage: "Specify the functions you want to handle",
            shortcut: "f",
            required: true,
            type: "multiple",
          },
        },
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    console.log(this.options.functions);
  }
}
```

```console
$ npx serverless my-command --functions func1 --functions func2
[ 'func1', 'func2' ]
```

### 既存のコマンドにカスタムオプションを追加する

v2 では未定義のオプションであっても値を取得することができたため、適当なオプションをでっちあげることが可能でした。

```javascript
class MyPlugin {
  constructor(serverless, options) {
    this.options = options;

    this.hooks = {
      "before:deploy:deploy": () => this.beforeDeploy(),
    };
  }

  beforeDeploy() {
    console.log(this.options["my-option"]); // 定義していないオプションを勝手に参照
  }
}
```

v3 では未定義のオプションを指定して `serverless` コマンドを実行した場合にエラーが発生するようになった[^3]ため、この方法は使用できなくなりました。

[^3]: https://www.serverless.com/framework/docs/deprecations#handling-of-unrecognized-cli-options

v3 で同等のことを実現するには 2 通りの方法があります。

#### 代わりにカスタム設定を使う

公式に言及されているのは、オプションを追加する代わりに後述の [カスタム設定](#カスタム設定を利用する) を利用する方法です。

[環境変数](https://www.serverless.com/framework/docs/providers/aws/guide/variables#referencing-environment-variables) や [dotenv ファイル](https://www.serverless.com/framework/docs/environment-variables)、[Stage パラメータ](https://www.serverless.com/framework/docs/guides/parameters#stage-parameters) 等との組み合わせにより、CLI オプションに近い役割を担うことが可能です。
[v3.3.0](https://github.com/serverless/serverless/releases/tag/v3.3.0) からは [CLI パラメータ](https://www.serverless.com/framework/docs/guides/parameters#cli-parameters) を利用することもできます。

#### 既存のコマンドを再定義する

公式ドキュメントには記載がありませんが、既存のコマンドを再定義するような形で独自オプションの追加が可能なようです。

```javascript
class MyPlugin {
  constructor(serverless, options) {
    this.options = options;

    this.commands = {
      // 組み込みのコマンドである deploy に独自オプションを追加
      deploy: {
        options: {
          "my-option": {
            usage: "My custom option",
            default: "",
            type: "string",
          },
        },
      },
    };

    this.hooks = {
      "before:deploy:deploy": () => this.beforeDeploy(),
    };
  }

  beforeDeploy() {
    console.log(this.options["my-option"]);
  }
}
```

この方法は Serverless 内部プラグイン[^4] や、Serverless の公式プラグインである [serverless-azure-functions](https://github.com/serverless/serverless-azure-functions) で使用されています。

[^4]: Serverless の組み込みコマンドや AWS プロバイダーは「内部プラグイン」という形で実装されています。

[serverless-azure-functions](https://github.com/serverless/serverless-azure-functions) のような [独自プロバイダー](#独自プロバイダーを定義する) プラグインを作る場合は、組み込みコマンドである `deploy` などを再定義していくことになるのでこの方法で良さそうです。

ただ既存プロバイダーの既存コマンド (例えば AWS プロバイダーにおける `serverless deploy`) に独自オプションを追加するのは、次の理由から避けておいた方が無難かと思います。

- 公式ドキュメントに記載がない
- コマンドが本来持っているオプションと競合する可能性がある
  - 現時点では同名のオプションがなくても、将来的に追加される可能性もあります

## カスタム設定を利用する

`servreless.yml` に記述されたプラグイン独自の設定を利用することもできます。

`serverless.yml` は [Service configuration validation](https://www.serverless.com/framework/docs/configuration-validation) によりスキーマ検証が行われるため、独自の設定を勝手に書くと警告 (v4 からはエラー[^5]) が出てしまいますが、`custom` 以下だけは自由に使うことが許されています。

[^5]: https://www.serverless.com/framework/docs/deprecations#configvalidationmode-error-will-be-new-default

他のプラグインと競合しないように、プラグイン名などをキーにして設定を分けるようにすると良いでしょう。

```yaml:serverless.yml
service: plugin-example

frameworkVersion: "3"

provider:
  name: aws
  runtime: nodejs14.x

plugins:
  - ./my-plugin

custom:
  myPlugin:
    foo: bar
```

`custom` 以下の値は `serverless.service.custom` で取得することができます。

```javascript
"use strict";

class MyPlugin {
  constructor(serverless) {
    this.serverless = serverless;

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    console.log(this.serverless.service.custom?.myPlugin?.foo);
  }
}

module.exports = MyPlugin;
```

## スキーマ定義を拡張する

プラグイン側で `serverless.yml` のスキーマ定義を拡張することもできます。
これにより、[Service configuration validation](https://www.serverless.com/framework/docs/configuration-validation) をプラグイン独自の設定に対して適用させることが可能となります。
また、スキーマの拡張によりトップレベルのカスタム設定や関数単位のカスタム設定も利用することができるようになります。

Serverless は [Ajv](https://ajv.js.org/) という [JSON Schema](https://json-schema.org/) の検証ライブラリによって `serverless.yml` の検証を行います。

https://ajv.js.org/

そのため、スキーマは JSON Schema によって定義することになります。
([v3.7.1](https://github.com/serverless/serverless/releases/tag/v3.7.1)) 時点では Ajv v8 のデフォルトが使用されているようなので、JSON Schema のバージョンは [draft-07](https://json-schema.org/specification-links.html#draft-7) です。

### `custom` 以下のスキーマを定義する

[前述](#カスタム設定を利用する) の通り、ビルトインのスキーマでは `custom` 以下は自由に使用できるようになっています。
そのためスキーマの定義は必須ではありませんが、設定ミスを防ぐためにも定義しておいた方が良いでしょう。

例として次のような設定のスキーマを定義してみます。
`custom.myPlugin.type` には `foo` `bar` `baz` いずれかの文字列が設定されます。

```yaml:serverless.yaml
custom:
  myPlugin:
    type: foo
```

Serverless インスタンスの `configSchemaHandler` プロパティに、スキーマ拡張用のメソッドがいくつか定義されています。

`custom` 以下のスキーマを定義するには `defineCustomProperties()` を使用します。

```javascript
class MyPlugin {
  constructor(serverless) {
    this.serverless = serverless;

    this.serverless.configSchemaHandler.defineCustomProperties({
      type: "object",
      properties: {
        myPlugin: {
          type: "object",
          properties: {
            type: {
              type: "string",
              enum: ["foo", "bar", "baz"],
            },
          },
          additionalProperties: false,
        },
      },
    });
  }
}
```

`custom.myPlugin.type` にスキーマ違反の `qux` を設定してを設定すると、コマンド実行時に次のように警告を出してくれます。

```console
$ npx serverless print

Warning: Invalid configuration encountered
  at 'custom.myPlugin.type': must be equal to one of the allowed values [foo, bar, baz]

Learn more about configuration validation here: http://slss.io/configuration-validation
...
```

### トップレベルのスキーマを拡張する

**後述しますがトップレベルのカスタム設定は使用しない方が良さそうです。**

ビルトインのスキーマでは、トップレベルに未知のプロパティがあることは許容されていません。
そのためトップレベルのプロパティをカスタム設定として利用したい場合はスキーマの拡張が必須となります。

例として次のような設定のスキーマを定義してみます。
`myPlugin.type` には `foo` `bar` `baz` いずれかの文字列が設定されます。

```yaml:serverless.yml
myPlugin:
  type: foo
```

トップレベルのスキーマを拡張するには `defineTopLevelProperty()` を使用します。
`defineCustomProperties()` とは異なり、第一引数にプロパティ名、第二引数に JSON Schema を与える形になります。

```javascript
class MyPlugin {
  constructor(serverless) {
    this.serverless = serverless;

    this.serverless.configSchemaHandler.defineTopLevelProperty("myPlugin", {
      type: "object",
      properties: {
        type: {
          type: "string",
          enum: ["foo", "bar", "baz"],
        },
      },
      additionalProperties: false,
    });
  }
}
```

ただし、トップレベルに定義したプロパティは参照方法がいまいちはっきりしていません。
`serverless.service.myPlugin` のような形では参照できず、`serverless.configurationInput.myPlugin` または `serverless.service.initialServerlessConfig.myPlugin` で取得することができますが、これらのプロパティはドキュメント化されていないようです。

```javascript
class MyPlugin {
  constructor(serverless) {
    this.serverless = serverless;

    this.serverless.configSchemaHandler.defineTopLevelProperty("myPlugin", {
      type: "object",
      properties: {
        type: {
          type: "string",
          enum: ["foo", "bar", "baz"],
        },
      },
      additionalProperties: false,
    });

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    console.log(this.serverless.service.myPlugin?.type); // 取得できない
    console.log(this.serverless.configurationInput.myPlugin?.type); // 取得できる
    console.log(this.serverless.service.initialServerlessConfig.myPlugin?.type); // 取得できる
  }
}
```

あえてトップレベルのプロパティを使用するメリットも特にないので、`custom` 以下を利用するようにした方が無難でしょう。

### 関数設定のスキーマを拡張する

関数単位で挙動をカスタマイズしたい場合は、関数設定にカスタムプロパティを追加できると便利です。
ビルトインのスキーマでは関数設定に未知のプロパティがあることは許容されていないませんが、関数設定のスキーマも拡張することが可能です。

例として関数設定に `customProperty` プロパティを追加してみます。

```yaml:serverless.yaml
functions:
  myFunction:
    handler: handler.main
    customProperty: foo
```

関数設定のスキーマを拡張するには `defineFunctionProperties()` を使用します。
`functions` 以下の設定はプロバイダーに強く依存しているため、プロバイダー名を第一引数として与える必要があります。

定義したプロパティは `serverless.service.functions` 以下の各関数のオブジェクトから参照することができます。

```javascript
class MyPlugin {
  constructor(serverless) {
    this.serverless = serverless;

    this.serverless.configSchemaHandler.defineFunctionProperties("aws", {
      properties: {
        customProperty: { type: "string" },
      },
    });

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    const functions = this.serverless.service.functions;
    for (const [funcName, funcObj] of Object.entries(functions)) {
      console.log(`${funcName}: ${funcObj.customProperty}`);
    }
  }
}
```

### 独自の関数イベントを定義する

独自の関数イベントも定義することができます。

例として次のような `myEvent` のスキーマを定義してみます。

```yaml:serverless.yml
functions:
  myFunction:
    handler: handler.main
    events:
      - myEvent:
          foo: value
          bar: 1
```

関数イベントのスキーマを定義するには `defineFunctionEvent()` を使用します。
第一引数にプロバイダー名、第二引数にイベント名を与えます。

定義した関数イベントは各関数のオブジェクトの `events` から探して参照することになります。

```javascript
class MyPlugin {
  constructor(serverless) {
    this.serverless = serverless;

    this.serverless.configSchemaHandler.defineFunctionEvent("aws", "myEvent", {
      type: "object",
      properties: {
        foo: { type: "string" },
        bar: { type: "number" },
      },
      required: ["foo"],
      additionalProperties: false,
    });

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    const functions = this.serverless.service.functions;
    for (const [funcName, funcObj] of Object.entries(functions)) {
      const myEvent = funcObj.events.find((v) => {
        return v.myEvent != null;
      })?.myEvent;

      if (!myEvent) {
        continue;
      }

      console.log(`${funcName}:`, myEvent);
    }
  }
}
```

### 関数イベントのスキーマを拡張する

既存の関数イベントのスキーマを拡張することも可能です。
これにより、イベントにプラグインのための設定を追加することができます。

例として `http` イベントを拡張し、`documentation` プロパティを追加してみます。

```yaml:serverless.yml
functions:
  auth:
    handler: handler.main
    events:
      - http:
          path: /auth
          method: post
          documentation: Authenticate client app
```

既存の関数イベントのスキーマを拡張するには `defineFunctionEventProperties()` を使用します。
第一引数にプロバイダー名、第二引数にイベント名を与えます。

```javascript
class MyPlugin {
  constructor(serverless) {
    this.serverless = serverless;

    this.serverless.configSchemaHandler.defineFunctionEventProperties(
      "aws",
      "http",
      {
        properties: {
          documentation: { type: "string" },
        },
        required: ["documentation"],
      }
    );

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    const functions = this.serverless.service.functions;
    for (const funcObj of Object.values(functions)) {
      const httpEvent = funcObj.events.find((v) => {
        return v.http != null;
      })?.http;

      if (!httpEvent) {
        continue;
      }

      console.log(this.genDocumentation(httpEvent));
    }
  }

  genDocumentation(httpEvent) {
    if (typeof httpEvent === "string") {
      return httpEvent;
    }

    const { path, method, documentation } = httpEvent;
    return `${method.toUpperCase()} ${path} : ${documentation}`;
  }
}
```

### 独自プロバイダーを定義する

独自プロバイダーを定義することも可能です。

例として次のような設定を書ける独自プロバイダーのスキーマを定義してみます。

```yaml:serverless.yml
service: provider-example

frameworkVersion: "3"

provider:
  name: my-provider
  stage: dev

plugins:
  - my-provider

functions:
  myFunction:
    handler: handler.main
    runtime: nodejs
    events:
      - http:
          method: GET

resources:
  storage:
    name: storage-name

layers:
  name: layer-name
```

独自プロバイダーのスキーマを定義するには `defineProvider()` を使用します。
第一引数にプロバイダー名を与えます。

```javascript
class MyProvider {
  constructor(serverless) {
    serverless.configSchemaHandler.defineProvider("my-provider", {
      // 再利用するためのスキーマ定義
      definitions: {
        runtime: {
          enum: ["nodejs", "ruby", "go"],
        },
      },

      // provider のスキーマ定義
      provider: {
        properties: {
          stage: { type: "string" },
        },
        additionalProperties: false,
      },

      // 関数 (functions の要素) のスキーマ定義
      function: {
        properties: {
          handler: { type: "string" },
          // definitions で定義したスキーマを利用できます。
          runtime: { $ref: "#/definitions/runtime" },
        },
        additionalProperties: false,
      },

      // 関数イベントのスキーマ定義
      // 公式ドキュメントの例は誤っているので注意が必要です。
      functionEvents: {
        http: {
          type: "object",
          properties: {
            method: {
              enum: ["GET", "POST", "PUT", "DELETE", "HEAD"],
            },
          },
          additionalProperties: false,
        },
      },

      // resouces のスキーマ定義
      resources: {
        type: "object",
        properties: {
          storage: {
            type: "object",
            properties: {
              name: { type: "string" },
            },
            additionalProperties: false,
          },
        },
        additionalProperties: false,
      },

      // layers のスキーマ定義
      layers: {
        type: "object",
        properties: {
          name: { type: "string" },
        },
        additionalProperties: false,
      },
    });
  }
}
```

これだけだとスキーマを定義しただけなので、実際には独自プロバイダー用のコマンド等を実装していく必要があります。

また、なぜかドキュメントには記載がありませんが、独自プロバイダーを `serverless.getProvider()` ([プロバイダー固有プラグイン](#プロバイダー固有プラグイン) を参照) に対応させるには `serverless.setProvider()` を使用します。
第一引数にプロバイダー名、第二引数に `serverless.getProvider()` で取得させたいオブジェクトを与えます。
このオブジェクトは静的メソッドとしてプロバイダー名を返す `getProviderName()` を持つクラスのインスタンスである必要があります。
実際には、独自プロバイダークラスのインスタンスそのものを与えるケースが多いようです。

```javascript
const providerName = "my-provider";

class MyProvider {
  static getProviderName() {
    return providerName;
  }

  constructor(serverless) {
    this.provider = this;
    serverless.setProvider(providerName, this);

    serverless.configSchemaHandler.defineProvider(providerName, {
      // ...
    });
  }
}
```

## カスタム変数ソースを定義する

`serverless.yml` で使用できる独自の [Serverless 変数](https://www.serverless.com/framework/docs/providers/aws/guide/variables) ソースを定義することができます。

カスタム変数ソースは、コンストラクタ内で `configurationVariablesSources` というインスタンスプロパティに定義します。

次の例では、`,` 区切りの文字列が格納された環境変数を分割して配列として解決するカスタム変数ソースを定義しています。

```javascript
class MyPlugin {
  constructor(serverless) {
    this.serverless = serverless;

    this.configurationVariablesSources = {
      // オブジェクトのキーがソース名となります。
      splitEnv: {
        // 値のオブジェクトには `resolve()` メソッドを定義します。
        // async function であること (Promise を返すこと) が許容されているため、
        // 非同期な変数解決も可能です。
        async resolve({ address }) {
          // address には解決する変数の名前が格納されています。
          // `${splitEnv:ALLOWED_HOSTS}` の場合は `ALLOWED_HOSTS` です。
          const value = process.env[address];

          const resovled = value ? value.split(",") : [];

          // 解決された値を `value` に持つオブジェクトを返します。
          return { value: resovled };
        },
      },
    };

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    console.log(this.serverless.service.custom?.foo);
  }
}
```

このプラグインにより、次のように `splitEnv` 変数が使用できるようになります。

```yaml:serverless.yml
custom:
  foo: ${splitEnv:ALLOWED_HOSTS}
```

```console
$ export ALLOWED_HOSTS="localhost,127.0.0.1"
$ npx serverless my-command
[ 'localhost', '127.0.0.1' ]
```

### 変数パラメータを参照する

カスタム変数ソースは次のようにパラメータを受け取ることもできます。

```yaml:serverless.yml
custom:
  value: ${foo(one, two):bar}
```

パラメータは `resolve()` の引数の `params` プロパティから配列の形で参照することができます。
各パラメータは文字列として扱われます。

次の例では文字列とセパレータをパラメータとして受け取り、文字列を分割して配列として解決するカスタム変数ソースを定義しています。

```javascript
class MyPlugin {
  constructor(serverless) {
    this.serverless = serverless;

    this.configurationVariablesSources = {
      split: {
        async resolve({ params }) {
          const paramsLength = (params ?? []).length;
          const value = paramsLength > 0 ? params[0] : "";
          const separator = paramsLength > 1 ? params[1] : ",";
          const resovled = value ? value.split(separator) : [];

          return { value: resovled };
        },
      },
    };

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    console.log(this.serverless.service.custom?.foo);
  }
}
```

先ほどの `splitEnv` よりも汎用的な変数ソースとして使うことができます。

```yaml:serverless.yml
custom:
  # 記号やスペースなどを渡したい場合はクォートなどで括ります。
  foo: ${split(${env:ALLOWED_HOSTS, ''}, ' ')}
```

```console
$ export ALLOWED_HOSTS="localhost 127.0.0.1"
$ npx serverless my-command
[ 'localhost', '127.0.0.1' ]
```

### 他の変数を参照する

他の [Serverless 変数](https://www.serverless.com/framework/docs/providers/aws/guide/variables) の解決された値を参照することもできます。

`resolve()` の引数の `resolveVariable()` メソッドで他の変数の値を解決することができます。

```javascript
class MyPlugin {
  constructor(serverless) {
    this.serverless = serverless;

    this.configurationVariablesSources = {
      stageEnv: {
        async resolve({ address, resolveVariable }) {
          const stage = await resolveVariable("sls:stage");
          const key = `${address}_${stage.toUpperCase()}`;
          const value = process.env[key];
          return { value };
        },
      },
    };

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    console.log(this.serverless.service.custom?.foo);
  }
}
```

### CLI オプションを参照する

`resolve()` の引数の `options` プロパティから CLI オプションを参照することもできます。
ただしカスタム変数はどのコマンドからも参照される可能性があるので、特定コマンドに固有のオプションには依存しない方が良さそうです。

```javascript
class MyPlugin {
  constructor(serverless) {
    this.serverless = serverless;

    this.configurationVariablesSources = {
      myVariable: {
        async resolve({ options }) {
          return { value: options["my-option"] };
        },
      },
    };

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
        options: {
          "my-option": {
            type: "string",
            required: true,
          },
        },
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    console.log(this.serverless.service.custom?.foo);
  }
}
```

## CLI 出力のベストプラクティス

v3 からはプラグインからのコンソール出力に関するベストプラクティスが定められています。

v2 では次のように Serverless インスタンスのログ出力機能がよく使われていましたが、これは v3 では非推奨となります。

```javascript
serverless.cli.log("message");
```

v3 ではプラグインのコンストラクタの第三引数にプラグイン用のユーティリティが渡されるようになっています。
このプラグインユーティリティに含まれる機能を使ってコンソール出力を行うことが推奨されています。

```javascript
class MyPlugin {
  constructor(serverless, options, utils) {
    this.utils = utils;
  }
}
```

### ログ出力

ログ出力には [log ユーティリティ](https://github.com/serverless/utils/blob/main/docs/log.md) を使用します。
すべてのログは標準エラー出力に書き込まれます。

```javascript
class MyPlugin {
  constructor(serverless, options, { log }) {
    this.log = log;

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    this.log.error("error");
    this.log.warning("warning");
    this.log.notice("notice");
    this.log.info("info");
    this.log.debug("debug");
  }
}
```

#### `log.error()`

エラーログを出力するためのメソッドです。

```javascript
log.error("message");
```

次のようなログが出力されます (実際には赤色で出力されます)。

```console
✖ message
```

#### 各メソッドの仕様

##### `log.warning()`

警告ログを出力するためのメソッドです。

```javascript
log.warning("warning");
```

次のようなログが出力されます (実際にはオレンジ色で出力されます)。

```console
$ npx serverless my-command
Warning: message
```

##### `log.notice()`

エラーや警告ではないものの必ず表示したいログを出力するためのメソッドです。

```javascript
log.notice("message");
```

`log()` は `log.notice()` のエイリアスとして使用できます。

```javascript
log("message");
```

次のようなログが出力されます。
色やプレフィックスはつきません。

```console
$ npx serverless my-command
message
```

##### `log.info()`

フォーマットは `log.notice()` と同等ですが、コマンドに `--verbose` オプションがつけられている場合にのみログが表示されます。

```javascript
log.info("message");
```

`log.verbose()` は `log.info()` のエイリアスとして使用できます。

```javascript
log.verbose("message");
```

```console
$ npx serverless my-command
$ npx serverless my-command --verbose
message
```

##### `log.debug()`

デバッグ用のログを出力するためのメソッドです。
コマンドに `--debug` オプションがつけられている場合にのみログが表示されます。

```javascript
log.debug("message");
```

Serverless のデバッグログは [debug](https://github.com/debug-js/debug) という npm と同じスタイルのネームスペース機能を持っています。

https://github.com/debug-js/debug

`--debug` オプションには出力したいネームスペースを値として渡す必要があります。

```console
$ npx serverless my-command
$ npx serverless my-command --debug=plugin:my-plugin
plugin:my-plugin: message
```

ネームスペース名はデフォルトで `plugin:${PLUGIN_NAME}` になっています。
`PLUGIN_NAME` には `serverless.yml` の `plugins` 以下に書いてある名前が使用されますが、英数字と `-` 以外の文字はすべて `-` に変換されることに注意が必要です。

```yaml:serverless.yml
plugins:
  - my-plugin   # ネームスペースは `plugin:my-plugin`
  - ./my-plugin # ネームスペースは `plugin:--my-plugin`
```

また、`log.get()` を使用することでさらに細かくネームスペースを区切ることも可能です。

```javascript
log.get("namespace").debug("message");
```

```console
$ npx serverless my-command --debug=plugin:my-plugin:namespace
plugin:my-plugin:namespace: message
```

##### `log.success()`

成功ログを出力するためのメソッドです。

```javascript
log.success("message");
```

次のようなログが出力されます (実際には `✔` のみ赤色で出力されます)。

```console
$ npx serverless my-command
✔ message
```

#### printf フォーマット

各ログメソッドは printf フォーマットによるログ出力をサポートしています。

```javascript
log.notice("address: %s:%d", "localhost", 8080);
```

次のようなログが出力されます (実際には使用されたフォーマット指定子に応じて色がつきます)。

```console
$ npx serverless my-command
address: localhost:8080
```

#### ログ出力のベストプラクティス

次のようなベストプラクティスが公式に提示されています。

- デフォルトの出力を最小限に抑える
  - なるべく `log.info()` を使用するようにしましょう
- `log.warning()` は基本的に使わない
  - 代わりにエラーを throw するか、`log.info()` を使用しましょう
  - 非推奨となった機能に対して警告を出したい場合は後述の [logDeprecation() ヘルパー](#非推奨機能に対する警告) を使用しましょう
- `log.error()` は基本的に使わない
  - 代わりにエラーを throw しましょう
- デバッグログはなるべくネームスペースを分ける

#### なぜ標準エラー出力なのか

Serverless では標準出力が他のプログラムにパイプされたり、解析されたりすることを想定しています。
そのため、プラグインが自由にログ出力しても Serverless 本来の出力を壊さないように標準エラー出力に書き込む仕様となっています。

このことからも分かるように、`console.log()` など標準出力を使用する言語機能やライブラリは使用しない方が良いでしょう。
安全のため、Serverless プラグイン開発では ESLint の [no-console](https://eslint.org/docs/rules/no-console) ルールを有効にしておくのも手です。

https://eslint.org/docs/rules/no-console

### 標準出力の使用

標準出力への書き込みには `writeText()` を使用します。

```javascript
class MyPlugin {
  constructor(serverless, options, { writeText }) {
    this.writeText = writeText;

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    this.writeText("command output");
  }
}
```

[前述](#なぜ標準エラー出力なのか) の通り、Serverless の標準出力は他のプログラムにパイプされたり、解析されたりすることを目的としています。
そのため、標準出力への書き込みには注意が必要です。

次のようなベストプラクティスが公式に提示されています。

- 標準出力への書き込みはプラグイン自身が定義したカスタムコマンドでのみ行う
  - 組み込みのコマンドや他のプラグインのコマンドの出力を壊さないようにするためです
- 標準出力にはコマンドのメイン出力 (コマンドの結果) のみを書き込む

基本的に人間にとって読みやすいメッセージは [log ユーティリティ](#ログ出力) を使用して標準エラー出力に書き込むようにしましょう。

### 色とフォーマットに関するベストプラクティス

出力に色をつけたい場合は、Serverless も内部で使用している [chalk](https://github.com/chalk/chalk) を使用することが推奨されています。

https://github.com/chalk/chalk

```javascript
log.notice(chalk.gray("message"));
```

次のようなベストプラクティスが公式に提示されています。

- プライマリ情報には `white`、セカンダリ情報には `gray` を使用する
  - プライマリ情報とは「コマンドの結果」を意味します
  - セカンダリ情報はプライマリ情報以外のすべてです
- 基本的に `white` と `gray` 以外の色は使用しない
- プラグイン側で出力に独自のフォーマットを使用しない
  - 原則として出力には Serverless が公式に提供しているユーティリティのみを使用するようにしましょう

また、ユーザーの注意を引くために Serverless red と名づけられた色 (`#fd5750`) を使用することができます。

```javascript
const serverlessRed = chalk.hex("#fd5750");
log.notice(serverlessRed("message"));
```

Serverless red を使用する際は次の点に注意する必要があります。

- 使用は最小限に抑える
  - 1 つのコマンドにつき最大でも 1 回までとされています
- コマンドが出力する最も重要な情報にユーザーの注意を引くためだけに使用する

### progress

プラグインが時間のかかる処理を実行する際、インタラクティブな progress 表示を行うことができます。

```javascript
class MyPlugin {
  constructor(serverless, options, { progress }) {
    this.progress = progress;

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  async run() {
    const filename = "data.json";

    // progress 作成
    const progress = this.progress.create({
      message: `Downloading ${filename} in my-plugin`,
    });
    const data = await this.download(filename);

    // progress のメッセージを更新
    progress.update(`Checking ${filename} in my-plugin`);
    const result = await this.check(data);

    if (!result) {
      throw new Error(`${filename} is invalid`);
    }

    // progress 削除
    progress.remove();
  }

  async download(filename) {
    // 時間がかかる処理
  }

  async check(data) {
    // 時間がかかる処理
  }
}
```

```console
$ npx serverless my-command

⠋ Downloading data.json in my-plugin
```

次のように progress インスタンスにユニークな名前をつけることも可能です。
この場合は作成したインスタンスそのものを引き回さずに progress にアクセスすることができます。

```javascript
progress.create({
  name: "my-plugin-progress",
  message: `Downloading in my-plugin`,
});

progress.get("my-plugin-progress").remove();
```

progress の使用について、次のようなベストプラクティスが公式に提示されています。

- 2 秒以上かかると想定されるタスクに対して使用する
  - 2 秒かからないタスクの場合は progress を表示せずに `log.info()` でログ出力するようにしましょう
- どのプラグインによる progress なのかが分かるようなメッセージを設定する
  - 悪い例: "Compiling"
  - 悪い例: "[Webpack] Compiling"
    - プレフィックスのような独自のフォーマットを使用してはいけません
  - 良い例: "Compiling with webpack"
- なるべく複数の progress 同時に表示しない
  - 表示するとしても 3 - 4 個程度に留めましょう

### `@serverless/utils` の利用

`log`, `writeText`, `progress` といったプラグインユーティリティは [@serverless/utils](https://www.npmjs.com/package/@serverless/utils) という npm パッケージから取得することもできます。

```javascript
const { log, writeText, progress } = require("@serverless/utils/log");
```

この場合、`log.debug()` のデフォルトのネームスペースが設定されないことに注意してください。

### サービス情報の拡張

`serverless info` コマンドなどで表示されるサービス情報にプラグイン独自のセクションを追加できます。
何らかのリソースを作成するようなプラグインの場合、そのリソースに関する情報を追加してあげると良さそうです。

Serverless インスタンスの `addServiceOutputSection()` メソッドを使用しますが、コンストラクタ内で実行するとエラーになるので `initialize` のタイミングで実行しましょう。

次の例では一行のセクションを追加しています。

```javascript
class MyPlugin {
  constructor(serverless) {
    this.serverless = serverless;
    this.hooks = {
      initialize: () => this.init(),
    };
  }

  init() {
    this.serverless.addServiceOutputSection("my section", "content");
  }
}
```

```console
$ npx serverless info
...
my section: content
```

複数行のセクションを追加することもできます。

```javascript
class MyPlugin {
  constructor(serverless) {
    this.serverless = serverless;
    this.hooks = {
      initialize: () => this.init(),
    };
  }

  init() {
    this.serverless.addServiceOutputSection("my section", ["line 1", "line 2"]);
  }
}
```

```console
$ npx serverless info
...
my section:
  line 1
  line 2
```

### 非推奨機能に対する警告

プラグインの非推奨機能が利用された場合に警告を出したい場合は、Serverless インスタンスの `logDeprecation()` メソッドを使用します。
第一引数にその非推奨機能を表すコードを、第二引数に表示するメッセージを与えます。

```javascript
class MyPlugin {
  constructor(serverless) {
    this.serverless = serverless;

    this.commands = {
      "old-command": {
        usage: "My custom command (deprecated)",
        lifecycleEvents: ["run"],
      },
      "new-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
    };

    this.hooks = {
      "old-command:run": () => this.runOldCommand(),
      "new-command:run": () => this.runNewCommand(),
    };
  }

  async runOldCommand() {
    this.serverless.logDeprecation(
      "MY_PLUGIN_OLD_COMMAND",
      "old-command of my-plugin is deprecated. Please use new-command."
    );
  }

  async runNewCommand() {
    // ...
  }
}
```

非推奨機能を実行すると次のように警告が表示されるようになります。

```console
$ npx serverless old-command

1 deprecation found: run 'serverless doctor' for more details
```

`serverless doctor` コマンドで詳細を確認できます。
このとき、設定した非推奨メッセージを確認することができます。

```console
$ npx serverless doctor
1 deprecation triggered in the last command:

old-command of my-plugin is deprecated. Please use new-command.
```

公式ドキュメントには記載されていませんが、設定した非推奨コードは [警告を無効化する](https://www.serverless.com/framework/docs/deprecations#how-to-disable-a-specific-deprecation) 際に使用することができます。

プラグインの非推奨コードを参照する場合はプレフィックス `EXT_` が必要なことに注意してください。

```yaml:serverless.yaml
disabledDeprecations:
  - EXT_MY_PLUGIN_OLD_COMMAND
```

次のようなベストプラクティスが公式に提示されています。

- 非推奨コードにはプレフィックスとしてプラグイン名を付加する
- メッセージはユーザーにとって実用的なものにする
  - 代わりに何を使用すればよいかを提示するようにしましょう
  - 必要に応じてリンクを追加しましょう

## エラーハンドリング

v3 ではエラーは次の 2 種類に区別されます。

- ユーザーエラー
- プログラマエラー (バグ)

ユーザーエラーはユーザーによる誤った入力や設定に起因するエラーを指します。
このようなエラーは、次のように Serverless 側で定義された Error クラスを使用してエラーを throw することが推奨されています。

```javascript
throw new serverless.classes.Error("Invalid configuration");
```

次の例ではオプションで指定された関数が `serverless.yml` に存在しなかった場合にユーザーエラーを throw しています。

```javascript
class MyPlugin {
  constructor(serverless, options) {
    this.serverless = serverless;
    this.options = options;

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
        options: {
          function: {
            usage: "Specify the function you want to handle",
            shortcut: "f",
            required: true,
            type: "string",
          },
        },
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    const funcName = this.options.function;
    const func = this.serverless.service.functions[funcName];
    if (!func) {
      throw new this.serverless.classes.Error(`${funcName} does not exist`);
    }

    // ...
  }
}
```

Serverless は throw されたユーザーエラーを適切にフォーマットして出力します。
上記の例で実際に存在しない関数を指定して実行すると次のようになります。

```console
$ npx serverless my-command --function myFunction
Environment: linux, node 14.19.0, framework 3.7.1 (local), plugin 6.1.5, SDK 4.3.2
Docs:        docs.serverless.com
Support:     forum.serverless.com
Bugs:        github.com/serverless/serverless/issues

Error:
myFunction does not exist
```

プログラマエラーはユーザーエラー以外のすべてのエラーです。
プログラマエラーの場合は発生したエラーを単純に throw します。

```javascript
class MyPlugin {
  constructor(serverless, options) {
    this.serverless = serverless;
    this.options = options;

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    // ...

    throw new Error("unexpected error");
  }
}
```

プログラマエラーの場合、Serverless はスタックトレースと共にエラー情報を出力します。
上記の例の場合は次のようになります。

```console
$ npx serverless my-command
Environment: linux, node 14.19.0, framework 3.7.1 (local), plugin 6.1.5, SDK 4.3.2
Docs:        docs.serverless.com
Support:     forum.serverless.com
Bugs:        github.com/serverless/serverless/issues

Error:
Error: unexpected error
    at MyPlugin.run (/home/frozenbonito/plugin-example/my-plugin/index.js:23:11)
    at my-command:run (/home/frozenbonito/plugin-example/my-plugin/index.js:16:36)
    at PluginManager.runHooks (/home/frozenbonito/plugin-example/node_modules/serverless/lib/classes/plugin-manager.js:530:15)
    at PluginManager.invoke (/home/frozenbonito/plugin-example/node_modules/serverless/lib/classes/plugin-manager.js:564:20)
    at async PluginManager.run (/home/frozenbonito/plugin-example/node_modules/serverless/lib/classes/plugin-manager.js:604:7)
    at async Serverless.run (/home/frozenbonito/plugin-example/node_modules/serverless/lib/serverless.js:174:5)
    at async /home/frozenbonito/plugin-example/node_modules/serverless/scripts/serverless.js:687:9
```

エラーハンドリングについて、次のようなベストプラクティスが公式に提示されています。

- エラーによってコマンドが停止する場合は、エラーを throw する
- エラーによってコマンドが停止しない場合は `log.error()` を使用してエラーをログ出力する
  - 特に理由がない場合は基本的に throw することが推奨されます
  - [serverless-offline](https://github.com/dherault/serverless-offline) のようなプラグインの場合はエラーが発生してもローカルサーバーは停止すべきではないため、例外的に `log.error()` を使用できます

## Tips

プラグイン開発の Tips を紹介します。

### `serverless.yml` があるディレクトリを取得する

`serverless.yml` からの相対パスを解決したい場合など、 `serverless.yml` があるディレクトリの絶対パスが欲しくなることがあります。

これを取得するには 2 通りの方法があります。

#### cwd を取得する

v3 では `serverless.yml` がコマンドの実行ディレクトリにない場合にエラーが出るようになっています。

https://www.serverless.com/framework/docs/deprecations#service-configurations-should-not-be-nested-in-service-sub-directories

逆に言うとコマンドの実行ディレクトリに `serverless.yml` があることが保障されているので、プロセスの cwd を取得すれば OK です。

```javascript
class MyPlugin {
  constructor() {
    this.servicePath = process.cwd();

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    console.log(this.servicePath);
  }
}
```

#### Serverless インスタンスから取得する

Serverless インスタンスの `serviceDir` プロパティから取得することができます。

```javascript
class MyPlugin {
  constructor(serverless) {
    this.serverless = serverless;

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    console.log(this.serverless.serviceDir);
  }
}
```

ただしこの方法は公式ドキュメントには記載されていないため、使わない方が良いかもしれません。

### 任意の Serverless コマンドを呼び出す

`serverless.pluginManager.spawn()` というメソッドにより、任意の Serverless コマンドを呼び出すことが可能です。

これを使うことで、プラグインから Serverless 組み込みコマンドの機能 (デプロイアーティファクトの作成など) や他のプラグインの機能を利用することができます。

```javascript
class MyPlugin {
  constructor(serverless) {
    this.serverless = serverless;

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
      spawned: {
        usage: "This command is spawend by my-command",
        lifecycleEvents: ["init", "run"],
      },
    };

    this.hooks = {
      "my-command:run": () => this.spawn(),
      "spawned:init": () => this.spawnedInit(),
      "spawned:run": () => this.spawnedRun(),
    };
  }

  spawn() {
    this.serverless.pluginManager.spawn("spawned");
  }

  spawnedInit() {
    console.log("init");
  }

  spawnedRun() {
    console.log("spawned");
  }
}
```

このテクニックは公式ドキュメントには記載されていませんが、前述の開発者によるチートシートで紹介されています。

https://gist.github.com/HyperBrain/50d38027a8f57778d5b0f135d80ea406

### サブライフサイクルを定義する

`serverless.pluginManager.spawn()` の応用で、既存のライフサイクルイベントにサブライフサイクルを定義することができます。

これは特に [独自プロバイダー](#独自プロバイダーを定義する) プラグインにおいて有用なテクニックで、例えば次のように組み込みの `deploy` コマンドで独自のライフサイクルイベントを発生させることができます。
これにより、他のプラグインがフックできるタイミングを増やすことが可能です。

```javascript
class MyPlugin {
  constructor(serverless) {
    this.serverless = serverless;

    this.provider = serverless.getProvider("my-provider");

    this.commands = {
      "my-deploy": {
        usage: "My deploy command",
        lifecycleEvents: ["createStack", "uploadArtifacts", "updateStack"],
      },
    };

    this.hooks = {
      "deploy:deploy": () => this.deploy(),
      "my-deploy:createStack": () => this.createStack(),
      "my-deploy:uploadArtifacts": () => this.uploadArtifacts(),
      "my-deploy:updateStack": () => this.updateStack(),
    };
  }

  deploy() {
    this.serverless.pluginManager.spawn("my-deploy");
  }

  createStack() {
    console.log("create stack");
  }

  uploadArtifacts() {
    console.log("upload artifacts");
  }

  updateStack() {
    console.log("update stack");
  }
}
```

ちなみにこの方法だと `my-deploy` コマンドがユーザーから直接利用できてしまいますが、次のように `type` を `"entrypoint"` にしてあげることでコマンドを隠蔽できます。
ただし公式ドキュメントには記載されていない仕様で、内部プラグイン以外での利用例が見つからなかったため利用しない方が良いかもしれません。

```javascript
this.commands = {
  "my-deploy": {
    type: "entrypoint",
    lifecycleEvents: ["createStack", "uploadArtifacts", "updateStack"],
  },
};
```

### プラグインを機能単位で分ける

[独自プロバイダー](#独自プロバイダーを定義する) のような大規模なプラグインを開発する場合、分かりやすさのために機能単位でプラグインを分けることがあります。

このような場合にはインデックスとなるプラグインを用意し、そこから各プラグインを読み込むようにしてあげるとユーザー側からは単一のプラグインのように扱うことができます。

```javascript
const MyProvider = require("../my-provider");
const MyProviderDeploy = require("../deploy");

class MyProviderIndex {
  constructor(serverless) {
    this.serverless = serverless;

    // pluginManager の addPlugin() でプラグインを追加できます。
    serverless.pluginManager.addPlugin(MyProvider);
    serverless.pluginManager.addPlugin(MyProviderDeploy);
  }
}
```

これは [serverless-azure-functions](https://github.com/serverless/serverless-azure-functions) や [serverless-google-cloudfunctions](https://github.com/serverless/serverless-google-cloudfunctions) などの公式プラグインで使われているテクニックです。

## AWS 用プラグイン開発の Tips

AWS プロバイダー専用プラグイン開発の Tips を紹介します。

### 解決されたリージョンを取得する

provider インスタンスの `getRegion()` で、Serverless 側で解決されたリージョンを取得することができます。

```javascript
class MyPlugin {
  constructor(serverless) {
    this.provider = serverless.getProvider("aws");

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    console.log(this.provider.getRegion());
  }
}
```

CLI オプションや `serverless.yml` から自前で解決するよりは Serverless が解決したものを直接参照した方が手っ取り早くて確実です。

ちなみに provider クラスの仕様はドキュメント化されていないのでこういったメソッドを使っていいかどうかは実際のところ不明ですが、メジャーなプラグインでも使われているので基本的には問題ないはずです。

### 解決された認証情報を取得する

Serverless では様々な方法で [AWS 認証情報](https://www.serverless.com/framework/docs/providers/aws/guide/credentials) を設定できるので、これを自前で解決するのは至難の業です。
こちらも provider インスタンスの `getCredentials()` から解決されたものを取得できます。

```javascript
class MyPlugin {
  constructor(serverless) {
    this.provider = serverless.getProvider("aws");

    this.commands = {
      "my-command": {
        usage: "My custom command",
        lifecycleEvents: ["run"],
      },
    };

    this.hooks = {
      "my-command:run": () => this.run(),
    };
  }

  run() {
    const { credentials } = this.provider.getCredentials();
    console.log(credentials);
  }
}
```

Serverless 内部では [AWS SDK for JavaScript v2](https://github.com/aws/aws-sdk-js) が使われているので、取得できる認証情報も [v2 のもの](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/Credentials.html) になります。

### AWS SDK を使う

provider インスタンスの `sdk` プロパティから Serverless が内部で利用している AWS SDK (v2) を取得することができます。

```javascript
const s3 = new provider.sdk.S3();
```

しかしこのプロパティは [v2.30.0](https://github.com/serverless/serverless/releases/tag/v2.30.0) で一度削除されています。
その後、プラグインへの影響を鑑みて [v2.30.2](https://github.com/serverless/serverless/releases/tag/v2.30.2) で差し戻されていますが、Serverless 側としてはこのプロパティを非推奨にしようという動きがあるようです。

このことから、今後はプラグイン側で直接 AWS SDK を依存関係として持つようにした方が良さそうです。
この方法であれば Serverless 内部で使用されている AWS SDK のバージョンに縛られないというメリットもあります。

#### AWS SDK for JavaScript v2 を使う

前述の [getRegion()](#解決されたリージョンを取得する) と [getCredentials()](#解決された認証情報を取得する) を活用すれば簡単にサービスクライアントのセットアップが可能です。

```javascript
const SSM = require("aws-sdk/clients/ssm");

class MyPlugin {
  constructor(serverless) {
    this.provider = serverless.getProvider("aws");

    this.hooks = {
      initialize: () => this.init(),
    };
  }

  init() {
    const region = this.provider.getRegion();
    const { credentials } = this.provider.getCredentials();
    const config = {
      region,
      credentials,
    };
    this.ssm = new SSM(config);
  }
}
```

#### AWS SDK for JavaScript v3 を使う

provider インスタンスから取得できるのは v2 の認証情報なので、[AWS SDK for JavaScript v3](https://github.com/aws/aws-sdk-js-v3) を使うには少し工夫が必要です。

```javascript
const { SSMClient } = require("@aws-sdk/client-ssm");

class MyPlugin {
  constructor(serverless) {
    this.provider = serverless.getProvider("aws");

    this.hooks = {
      initialize: () => this.init(),
    };
  }

  init() {
    const region = this.provider.getRegion();
    const { credentials: v2Credentials } = this.provider.getCredentials();
    const credentials = {
      accessKeyId: v2Credentials.accessKeyId,
      secretAccessKey: v2Credentials.secretAccessKey,
      sessionToken: v2Credentials.sessionToken ?? undefined,
      expiration: v2Credentials.expireTime ?? undefined,
    };
    const config = {
      region,
      credentials,
    };
    this.ssm = new SSMClient(config);
  }
}
```

## TypeScript による開発

TypeScript による開発も可能です。

[DefinitelyTyped](https://github.com/DefinitelyTyped/DefinitelyTyped) に [型定義](https://www.npmjs.com/package/@types/serverless) もありますが、あまり正確ではないので自分で必要な型を定義した方が早いかもしれません。

公式プラグインである [serverless-azure-functions](https://github.com/serverless/serverless-azure-functions) が TypeScript で開発されているのでこちらのコードが参考になるかと思います。

https://github.com/serverless/serverless-azure-functions