PulumiでデプロイされるTypeScript製Cloud FunctionsをES2019にする小技

前回は、[PulumiでGoogle Cloud FunctionsにTypeScriptの関数をデプロイする方法](https://qiita.com/suin/items/57e2043779c921d342c2)という投稿をしました。そこでは、PulumiではインフラもGoogle Cloud Functionsの実装もTypeScriptで書け、かつ、インフラと関数を一体化して1つのファイルで管理できることを説明しました。

PulumiはかなりTypeScriptフレンドリーなDevOpsが実現できるわけですが、ひとつ残念な点があります。それは、Pulumiの構成ファイルに直書きした関数はES2015(ES6)としてコンパイルされデプロイされるという点です。

この投稿では、PulumiでTypeScript製のCloud Functionsをデプロイする際、その関数のtargetをES2019にする方法を紹介します。

## Pulumiがデプロイする関数はES2015にコンパイルされるのが玉にキズ

Pulumiの売りはCloud Functionsの実装をインフラ構成に混ぜて、シームレスに書けるところでした。それはそれで便利なのですが、PulumiがトランスパイルするTypeScriptコードは、古めのJavaScriptであるES2015になってしまいます。

そのため、次のようなES2015より後に導入された構文、例えば`await`(ES2017)などを使った関数は、

```index.ts
import * as gcp from "@pulumi/gcp";

// デプロイしたい関数
const helloWorld: gcp.cloudfunctions.HttpCallback = async (req, res) => {
  const message = await Promise.resolve("Hello World!"); // await(ES2017の構文を使用)
  res.send(message);
};

// 上の関数のデプロイに関するインフラの設定
const helloWorldFunction = new gcp.cloudfunctions.HttpCallbackFunction(
  "helloWorldFunction",
  { runtime: "nodejs12", callback: helloWorld }
);
```

そのシンタックスを使わないコードにトランスパイルされていまいます:

```js:CloudFunctionsにデプロイされるJS
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
    function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
    return new (P || (P = Promise))(function (resolve, reject) {
        function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
        function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
        function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
        step((generator = generator.apply(thisArg, _arguments || [])).next());
    });
};
const helloWorld = (req, res) => __awaiter(void 0, void 0, void 0, function* () {
    const message = yield Promise.resolve("Hello World!"); // await(ES2017の構文を使用)
    res.send(message);
});
```

もちろん、ES2015でもCloud Functionsでは問題なく実行されるのですが、今日現在のCloud FunctionsはNode.js 12に対応していてES2019までの構文が使えるはずなので、デプロイされるコードもES2019としてコンパイルしてほしいところです。そのほうが、`await`などがNode.jsのネイティブなパフォーマンスで実行されたり、可読性が上がることでデバッグがしやすかったりとメリットがあるはずです。

## tsconfig.jsonのtargetを変えても意味がない

Pulumiを初期化すると、プロジェクトディレクトリにtsconfig.jsonを作ってくれます。その設定のデフォルトは`target`が`es2016`になっています。一見すると、これを`es2019`にすれば、デプロイされるCloud Functionsもそれに従いそうです。

実際は、tsconfig.jsonのtarget設定を変更しても、それはCloud Functionsのトランスパイルには影響しません。Pulumiはes2015をtargetにしてCloud Functionsをトランスパイルしてしまいます。これは仕様です。現にPulumiのトランスパイル処理では、target=es2015がハードコーディングされていて、設定からこれを変える手立てはありません。このハードコーディング問題はIssueにもなっていますが、解決はされていません:

> [Pulumi overrides tsconfig.json target · Issue #2673 · pulumi/pulumi](https://github.com/pulumi/pulumi/issues/2673)

以上が、PulumiでTypeScript製の関数をデプロイするにあたって受ける制約の話になります。

## PulumiでデプロイされるTypeScript製Cloud FunctionsをES2019にする小技

では、PulumiでTS製の関数をデプロイする際、そのコンパイルターゲットをES2019にするにはどうしたらいいか見て行きましょう。

Pulumiが提供するAPIの[gcp.cloudfunctions.HttpCallbackFunction](https://www.pulumi.com/docs/reference/pkg/nodejs/pulumi/gcp/cloudfunctions/#HttpCallbackFunction)は、関数の実装をそのまま渡せて便利ですが、まずこの使用は諦める必要があります。

その代わりに、関数をZIPに固めてアップロードする方法を採用します。「えっ？ZIPで固めてからデプロイ？コマンド一発でインフラも関数もデプロイできるのがPulumiの良さだったのに、ひと手間増えるの？めんどくさそう！」そう思った皆さん、ちょっと待ってください。ここで紹介する方法は、思ったほど面倒ではありません。ZIPで固めると言いましたが、自分でZIPを作る必要もありませんし、デプロイも今まで通り`pulumi up`だけでできます。なので心配しないで下さい。ではどのようにやるのか順を追って見ていきましょう。

### 手順1: 関数を別パッケージにする

まず、PulumiとCloud Functionsの実装がくっついていると、どうしてもES2019にはできないので、ファイルを分けます。関数の実装は、ディレクトリを掘ってそこのindex.tsに移動します:

```
.
├── Pulumi.dev.yaml
├── Pulumi.yaml
├── helloWorld
│   └── index.ts ← helloWorld関数をここに移動
├── index.ts
├── package.json
└── tsconfig.json
```

移動した関数の中身は、Pulumiを使わずにCloud Functionsの関数を実装したときのようになります:

```helloWorld/index.ts
import * as express from "express";

export const helloWorld = async (
  req: express.Request,
  res: express.Response
) => {
  const message = await Promise.resolve("Hello World!"); // await(ES2017)
  res.send(message);
};
```

次に、helloWorldディレクトリを単なるサブディレクトリではなく、NPMパッケージにしていきます。具体的には、helloWorldディレクトリに移動して、`yarn init`してpackage.jsonを作ります。

```bash
cd helloWorld
yarn init
```

### 手順2: TypeScriptを入れてtargetをES2019にする

パッケージの準備ができたら、helloWorldパッケージにTypeScriptとexpressの型定義をインストールします:

```bash
yarn add -D typescript @types/express
```

TSをインストールしたら、TypeScript Compilerを設定を作り、targetをES2019にします:

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

```helloWorld/tsconfig.json
{
  "compilerOptions": {
    "target": "es2019",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}
```

ここまでの手順で、helloWorldディレクトリには以下の4つのファイルと`node_modules`ディレクトリができているはずです:

```
helloWorld
├── node_modules
├── index.ts
├── package.json
├── tsconfig.json
└── yarn.lock
```

### 手順3: GCPデプロイ時にトランスパイルされるようにする

続いて、GCPにデプロイ時に、TypeScriptからJavaScriptへコンパイルされるように、package.jsonの設定を加えて行きます。[GCPはpackage.jsonの`scripts`フィールドに`gcp-build`があると、関数をデプロイしたときにそのコマンドを実行してくれます](https://cloud.google.com/appengine/docs/standard/nodejs/running-custom-build-step)。これを利用して、デプロイ時トランスパイルを実現します:

```jsonnet:helloWorld/package.json
{
  /* ...中略... */
  "scripts": {
    "gcp-build": "tsc"
  }
}
```

もちろん、ローカルで`tsc`したり`WebPack`でバンドルしてからデプロイする方法も考えられますが、`pulumi up`コマンド一発でデプロイする体験を崩さないようにするには、Google Cloudにビルドをおまかせするのが手っ取り早いです。

### 手順4: デプロイされるZIPファイルの内容を決める

あとは、このhelloWorldパッケージをZIP化してデプロイするようにすればいいのですが、どのファイルをZIPに含めるかは、`npm pack`のアルゴリズムに任せるのが最も手軽です。`npm pack`のアルゴリズムでは、package.jsonは含めるが、node_modulesは除外するなどといった、パッケージをリリースするのに特化した条件が組み込まれています。これは普通NPMのレジストリにパッケージを登録するためのものですが、Google Cloud Functionsに関数をデプロイするシーンでも流用できる条件になります。自前でZIPファイルの内容を決めるロジックを実装するよりは、`npm pack`のアルゴリズムに任せたほうが断然楽です。

ということで、`npm pack`が活用できるよう、package.jsonの設定を加えていきます。ここでは、デプロイに必要なのに`npm pack`が取りこぼしてしまうファイルを追加していく手順になります。package.jsonは次のようになります:

```jsonnet:helloWorld/package.json
{
  /* ...中略... */
  "files": [
    "**/*.ts",
    "tsconfig.json",
    "yarn.lock"
  ]
}
```

`npm pack`はデフォルトでは、yarn.lockなどを除外してしまうので、`files`フィールドでZIPに含まれるように調整しました。`files`フィールドを定義すると、npm packはpackage.jsonなどを除き、そこに書かれているファイルだけをZIPに含めるようになるので、加えてtsファイルやtsconfig.jsonも連ねておく必要があります。ちなみに、含まれるファイル一覧は`npm pack --dry-run`で確認できます。

以上で、helloWorldパッケージで必要な設定は終わりです。

一応念の為、index.tsがちゃんとコンパイルできるか確認しておきます:

```bash
npx tsc --noEmit
```

### 手順5: Pulumi側のインフラ構成を直す

続いて、Pulumi側のインフラ構成ファイルを直して行きます。helloWorldディレクトリから抜けて、カレントディレクトリはPulumi.yamlがあるディレクトリに移動しておいてください。

まず、`npm pack`のアルゴリズムをPulumi側で使いたいので、そのパッケージである[npm-packlist]をインストールします:

```bash
npm install npm-packlist @types/npm-packlist
```

次に、index.tsを直しますが、その先に完成形を示しておこうと思います。各部分の説明はその後にします。

```index.ts
import * as pulumi from "@pulumi/pulumi";
import * as gcp from "@pulumi/gcp";
import packlist from "npm-packlist";

// Cloud Functions APIを有効化する
const cloudFunctionsService = new gcp.projects.Service(
  "cloudFunctionsService",
  {
    disableDependentServices: true,
    service: "cloudfunctions.googleapis.com",
  }
);

// helloWorldパッケージのZIPの内容を定義する
const assetArchive = new pulumi.asset.AssetArchive(
  packlist({ path: "./helloWorld" }).then((files) => {
    const map: pulumi.asset.AssetMap = {};
    for (const file of files) {
      map["./" + file] = new pulumi.asset.FileAsset("./helloWorld/" + file);
    }
    return map;
  })
);

// Cloud Functionsの関数のZIP置き場をStorageに作る
const cloudfunctionsBucket = new gcp.storage.Bucket("cloudfunctions");

// helloWorldパッケージのZIPをStorageにアップロードする
const helloWorldZip = new gcp.storage.BucketObject("helloWorld", {
  bucket: cloudfunctionsBucket.name,
  name: "helloWorld.zip",
  source: assetArchive,
});

// デプロイする関数のインフラ構成定義
const helloWorldFunction = new gcp.cloudfunctions.Function(
  "helloWorldFunction",
  {
    name: "helloWorld",
    runtime: "nodejs12",
    sourceArchiveBucket: cloudfunctionsBucket.name,
    sourceArchiveObject: helloWorldZip.name,
    triggerHttp: true,
    entryPoint: "helloWorld",
  },
  { dependsOn: cloudFunctionsService }
);

// helloWorld関数を誰でも呼び出せるようにする設定
const helloWorldInvoker = new gcp.cloudfunctions.FunctionIamMember(
  "helloWorldInvoker",
  {
    cloudFunction: helloWorldFunction.name,
    role: "roles/cloudfunctions.invoker",
    member: "allUsers",
  }
);

// デプロイ後に表示する情報
export const functionUrl = helloWorldFunction.httpsTriggerUrl;
```

ご覧のとおり、`gcp.cloudfunctions.HttpCallbackFunction`を使っていたときと比べると、たった3行で書けていたものが、格段に記述量が増えました。これは、それだけ`HttpCallbackFunction`クラスが高レベルなものであり、隠蔽していた定義がこれだけあったということです。量は多いですが、ひとつひとつは難しくないので、各設定の内容を見ていきましょう。

まず、helloWorldパッケージのZIPの内容を決める部分です:

```typescript
// helloWorldパッケージのZIPの内容を定義する
const assetArchive = new pulumi.asset.AssetArchive(
  packlist({ path: "./helloWorld" }).then((files) => {
    const map: pulumi.asset.AssetMap = {};
    for (const file of files) {
      map["./" + file] = new pulumi.asset.FileAsset("./helloWorld/" + file);
    }
    return map;
  })
);
```

ZIPの内容は、helloWorldパッケージのpackage.jsonで設定したものが効いてきますので、ここで改めて何らかのリストを作る必要はありません。`npm pack`のアルゴリズムを使えさえすれば良いので、ここではその`packlist`関数を使い、helloWorldディレクトリのファイルリストを取って来ます。そして、ファイルリストをPulumiが扱えるように、AssetMap型に変形するのがここの主たる処理です。

こうして作ったZIPは、Google Storageにアップロードされる必要があります。それを行うのが次の部分です:

```typescript
// Cloud Functionsの関数のZIP置き場をStorageに作る
const cloudfunctionsBucket = new gcp.storage.Bucket("cloudfunctions");

// helloWorldパッケージのZIPをStorageにアップロードする
const helloWorldZip = new gcp.storage.BucketObject("helloWorld", {
  bucket: cloudfunctionsBucket.name,
  name: "helloWorld.zip",
  source: assetArchive,
});
```

この部分は、`HttpCallbackFunction`クラスを使っていたときは隠蔽されていたので気にする必要がありませんでしたが、今回はそれが使えないので、明示的に定義する必要が出てきた部分です。

続いて、Cloud Functionsの設定です:

```typescript
// デプロイする関数のインフラ構成定義
const helloWorldFunction = new gcp.cloudfunctions.Function(
  "helloWorldFunction",
  {
    name: "helloWorld", // これはURLになる部分なので自由に決めていい
    runtime: "nodejs12",
    sourceArchiveBucket: cloudfunctionsBucket.name,
    sourceArchiveObject: helloWorldZip.name,
    triggerHttp: true,
    entryPoint: "helloWorld", // helloWorld/index.tsでexport constした変数
  },
  { dependsOn: cloudFunctionsService }
);
```

ここでは、先程決めたアップロード先の情報を設定したり、呼び出す関数の変数名を指定したりします。

最後に、Cloud Functionsは誰が呼び出せるかを決めるところです:

```typescript
// helloWorld関数を誰でも呼び出せるようにする設定
const helloWorldInvoker = new gcp.cloudfunctions.FunctionIamMember(
  "helloWorldInvoker",
  {
    cloudFunction: helloWorldFunction.name,
    role: "roles/cloudfunctions.invoker",
    member: "allUsers", // 誰でも呼び出しOK
  }
);
```

以上がPulumi側のインフラ構成の定義になります。

この構成ファイルを使って、`pulumi up`でデプロイすると、デプロイされた関数はES2019をtargetにコンパイルされたものになります。

次: [Pulumi: 複数のスタックにまたがる設定をまとめて再利用可能にする方法 - Qiita](https://qiita.com/suin/items/4cf7e97ae902b2d16417)