🌫️

GAS + Typescript のいい感じのビルド環境を整える

2023/06/29に公開

株式会社TERASSで主にフロントエンドを中心にエンジニアをしている myrear (読みは「みりあ」)です。
社内で GAS を使った小さいアプリケーションを作る機会があり、その時にビルド周りで行った工夫をシェアしていきます。

はじめに

Google Apps Script (以下 GAS) を使ったアプリケーションの開発は、 GAS 上のエディタで直接 .gs ファイルを編集するか、ローカルで書いた .js (または .ts ) ファイルを clasp を使って変換・プッシュ(アップロード)するのが主流かと思います。
ここでは後者の clasp を使う方法の中でも .js .ts から .gs へ変換する部分に焦点を当て、この方法に含まれる問題点を取り上げそれらを解決していくことでいい感じのビルド環境を整えることが目標です。

ゴール

この記事の最終的なゴールは以下です。

  1. typescript コードを適切にバンドルし clasp を用いて GAS にプッシュする
  2. すべてをグローバルにせず GAS 上に公開する関数を絞ることができる
  3. npm ライブラリが利用できる

準備

作業ディレクトリを初期化したあとに最低限のライブラリをインストールします。

$ yarn init -y # 初期化
$ yarn add -D @google/clasp typescript # ライブラリインストール
$ yarn clasp login # clasp でログイン
$ yarn clasp create # GAS プロジェクトの新規作成
$ yarn tsc --init # tsconfig.json の作成

src ディレクトリ内にスクリプトのソースを書いていきたいので src ディレクトリを作ります。

$ mkdir src
$ touch src/index.ts
$ mv appsscript.json src/$1

src ディレクトリの中身をプッシュしたいので .clasp.jsonrootDirsrc に変更します。

.clasp.json
{
  "scriptId": "(スクリプトID)",
- "rootDir": "(カレントディレクトリ)"
+ "rootDir": "src"
}

ここまでやると以下のようなファイル群が出来上がるはずです。

.
├── .clasp.json
├── package.json
├── src
│   ├── appsscript.json
│   └── index.ts
├── tsconfig.json
└── yarn.lock

clasp をそのまま使う

まずは clasp をそのまま使ってプッシュするところから始めます。
src/index.ts に適当な関数を用意して、

src/index.ts
function main() {
  console.log('Hello World')
}

プッシュすると、

$ yarn clasp push

無事 GAS にプッシュされていることが確認できるはずです。

関数の実行も可能です。

ここまでは特に問題はなさそうです。

ファイルを分割する

では console.log('Hello World') の部分を printHelloWorld() という関数名にして別ファイルに切り出してプッシュしてみます。

src/printHelloWorld.ts
export const printHelloWorld = () => console.log('Hello World')
src/index.ts
import { printHelloWorld } from './printHelloWorld'

function main() {
  printHelloWorld()
}
$ yarn clasp push

プッシュ後に実行してみるとエラーになるはずです。

それもそのはず、 GAS は ESModule に非対応のため、一部の構文をサポートしていません。
今回の例でいうと import が非対応であるため、プッシュされる際には clasp によって単にコメントアウトされてしまいます。
以下は先程 GAS にプッシュされた index.gs (元 src/index.ts )の中身ですが、 import している行がコメントアウトされているのがわかります。

index.gs
// Compiled using gas-sandbox 1.0.0 (TypeScript 4.9.5)
var exports = exports || {};
var module = module || { exports: exports };
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
//import { printHelloWorld } from './printHelloWorld'
function main() {
    (0, printHelloWorld_1.printHelloWorld)();
}

import が使えないということは npm ライブラリも使うことができません。
これに対する解決策は2種類あります。

  1. npm ライブラリを使わずに1ファイルで完結させる
  2. モジュールバンドラ(以下バンドラ)を用いて1ファイルにバンドルする

1は根本的な解決策ではないですが、 npm ライブラリも不要かつ1ファイルで完結できる規模であれば採用可能な選択肢です。
この記事では2の方法を探っていきます。

複数ファイルを1ファイルにバンドルする

ここでバンドラが必要になるわけですが、バンドラにもいくつか種類があり、その中には gas 用プラグインが用意されているものもあります。→ gas-webpack-plugin , esbuild-gas-plugin
他にもあるかもしれませんが上記のプラグインは両方ともスター数が多いとは言えず、今後も継続してメンテナンスされていくかどうかに若干の不安を感じます。
これらプラグインを利用する決断が可能であればそれらの設定を完了させれば良いのでこれ以上読み進めていただく必要はありません。
個人的にはスター数の少なさに加え、公開する関数を global オブジェクトに突っ込む書き方が好みではなかった(エントリポイントから export しているものだけが公開される形が良い)ため、プラグインを利用せずにやってみたいと思います。

バンドラの設定

バンドラには esbuild を利用します。なんとなく早そうなので。

$ yarn add -D esbuild

.ts ファイルから .js にバンドルするため、バンドルしたファイルを入れる dist ディレクトリを作成し、そのファイルを GAS にプッシュするため appsscript.json もそこに移動します。

$ mkdir dist
$ mv src/appsscript.json dist/$1

.clasp.jsonrootDirdist に変更します。

.clasp.json
{
  "scriptId": "(スクリプトID)",
- "rootDir": "src"
+ "rootDir": "dist"
}

エントリポイントとなる src/index.ts から関数をエクスポートしておきます。

src/index.ts
import { printHelloWorld } from './printHelloWorld'

- function main() {
+ export function main() {
  printHelloWorld()
}

バンドルしてみます。

$ yarn esbuild src/index.ts --bundle --outdir=dist --format=iife --global-name=_entry

できあがった dist/index.ts を覗いてみます。

dist/index.js
"use strict";
var _entry = (() => {
  // ...
  // 長いので省略

  // src/printHelloWorld.ts
  var printHelloWorld = () => console.log("Hello World");

  // src/index.ts
  function main() {
    printHelloWorld();
  }
  return __toCommonJS(src_exports);
})();

esbuild では IIFE 形式にバンドルする際に global-name オプションを指定可能で、指定した名前のグローバル変数に IIFE の戻り値を格納してくれます。
こうすることで _entry{ main: ... } のようなエクスポートされた関数または変数名をキーに持つオブジェクトになり、 _entry.main() のような形でアクセスすることできます。
これをプッシュしてみます。

$ yarn clasp push

GAS を見てみると「関数なし」となり実行できる関数がなくなってしまっています。

このままでは実行できないので、今はとりあえず GAS 上の index.gs を直接編集して先頭に関数宣言を追加して実行してみます。

すると期待通りの結果が得られるはずです。

試しに _entry.printHelloWorld() を呼んでみてもエラーになるので、エクスポートした関数だけを公開することができています。
公開する関数が一つだけならバンドルしてプッシュしたあとに手動で関数宣言を追加することも不可能ではないですが、関数が増えていくと手に負えなくなるのでこれも自動化してみようと思います。

エクスポートされた関数名の取得

esbuild には banner というオプションがあり、バンドルされたファイルの先頭に指定した文字列を追加できます。
あとはエントリポイントからエクスポートされた関数名の一覧さえ取得してしませば function 関数名 () { return _entry.関数名(...arguments) } を関数の数だけ生成して banner に渡せば欲しい物が手に入りそうです。
ではどうやって関数名を取得するかというと、 typescript の AST (抽象構文木) を解析して取得します。
以下はそのコードです。

import ts from 'typescript'

const ENTRY_POINT = 'src/index.ts'

const program = ts.createProgram([ENTRY_POINT], {})
const sourceFile = program.getSourceFile(ENTRY_POINT)

const exportedFunctionNames: string[] = []
sourceFile?.forEachChild((node) => {
  if (ts.isExportDeclaration(node)) {
    node.forEachChild((node) => {
      if (ts.isNamedExports(node)) {
        node.forEachChild((node) => {
          if (ts.isExportSpecifier(node)) {
            exportedFunctionNames.push(ts.idText(node.name))
          }
        })
      }
    })
  } else if (
    ts.isVariableStatement(node) &&
    includesExportKeywordModifier(node)
  ) {
    node.forEachChild((node) => {
      if (ts.isVariableDeclarationList(node)) {
        node.forEachChild((node) => {
          if (ts.isVariableDeclaration(node)) {
            node.forEachChild((node) => {
              if (ts.isIdentifier(node)) {
                exportedFunctionNames.push(ts.idText(node))
              }
            })
          }
        })
      }
    })
  } else if (
    ts.isFunctionDeclaration(node) &&
    includesExportKeywordModifier(node)
  ) {
    node.name && exportedFunctionNames.push(ts.idText(node.name))
  }
})

function includesExportKeywordModifier(node: ts.Node) {
  const modifiers = ts.canHaveModifiers(node) ? ts.getModifiers(node) : null
  if (!modifiers) return false

  return modifiers.some(
    (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword
  )
}

一つ一つ解説するととても長くなるので詳しくは省略しますが、ビジターパターンによって AST を解析しています。
最終的に変数 exportedFunctionNames に関数名の一覧が入ってきます。

最後のバンドル

関数名の一覧を取得できたので、あとはこれを加工して banner に渡すだけですが、 CLI からだとコマンドが長くなるので esbuild の Javascript API を利用し、 build.ts を実行することでバンドルできるようにします。

build.ts
import { build } from 'esbuild'
import ts from 'typescript'

// ...

const exportedFunctionNames: string[] = []
// ...

const globalName = '_entry'

build({
  entryPoints: [ENTRY_POINT],
  format: 'iife',
  bundle: true,
  outdir: 'dist',
  target: 'es6',
  globalName,
  banner: {
    js: `
${exportedFunctionNames
  .map((functionName) =>
    `
function ${functionName} () {
  return ${globalName}.${functionName}(...arguments);
};
`.trim()
  )
  .join('\n')}
`.trim(),
  },
})

最後に build.ts を実行するための ts-node をインストールし、プッシュまでやってしまいます。

$ yarn add -D ts-node
$ yarn ts-node -T build.ts
$ yarn clasp push

GAS を開いてみると main だけが公開され、実行できるようになっていると思います。

課題

一見すべてがうまくいっているように見えますが、3点課題が残っています。

export * に対応していない

export * from 'someModule' のようなエクスポートに対応できていません。

someModule.ts
export const someFunction = () => void 0

このようなファイルをアスタリスクでエクスポートしても function someFunction ... は生成されません。
これに対応するためにはもう少しちゃんと AST を解析する必要があります。

引数を解析していない

この方法でビルドした GAS アプリケーションをライブラリとして公開すると、利用側からするとエディタ上での情報量がイマイチです。
GAS で利用可能なサービスの関数には丁寧にドキュメントコメントが記述されていることが多く、それらをエディタ上から確認することができます。

が、この方法だと .ts ファイルにいくらコメントを記述してもそれが利用側に伝わることはありません。

引数に関しては AST の解析をサボり ...arguments で渡しているため、しっかり解析すればいい感じにできるはずです。
コメントも今は解析していないので同様に解析してあげればよさそうです。

_entry オブジェクトにアクセスできてしまう

これもこのライブラリを利用側から見た時の話ですが、ちゃっかり _entry オブジェクトにアクセスできてしまいます。

実害はないはずですが、あまり気持ちよくはありませんね。
これに関してはいい解決策が思いつきませんでした…

おわりに

ことの発端は社内のハッカソンでスプレッドシートと連携する GAS アプリケーションを作ろうとなったのが始まりでした。
せっかくなら DX を高めようと思いいろいろ工夫していると typescript の AST の解析までやる羽目になりました。
ただ、 AST なんて普段触らないところなので手探りで進めてる感は結構楽しかったです。

最後までお読みいただきありがとうございました。
この記事がどなたかのお役に立てば幸いです。

Terass Tech Blog

Discussion