Open37

clasp を試す

hankei6kmhankei6km

インストールは README の Install に従う。

  • npm でパッケージをグローバルにインストールするとローカルでコマンドが使えるようになる。
  • Apps Script の設定で API の実行を許可しておく。

とくに迷うところもないかな。

hankei6kmhankei6km

push するところまではできたが、function の import ができない。

ソース
main.ts
import { t1, C1 } from './test.js'

function test() {
  const c = new C1()
  c.print()
  t1()
}
test.ts
export const t1 = () => console.log('from t1')

export class C1 {
  constructor() {}
  print() {
    console.log('from t2')
  }
}
プッシュしたもの
main.gs
// Compiled using first-clasp 1.0.0 (TypeScript 4.5.4)
var exports = exports || {};
var module = module || { exports: exports };
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
//import { t1, C1 } from './test.js'
function test() {
    const c = new C1();
    c.print();
    (0, test_js_1.t1)();
}
test.gs
// Compiled using first-clasp 1.0.0 (TypeScript 4.5.4)
var exports = exports || {};
var module = module || { exports: exports };
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.C1 = exports.t1 = void 0;
const t1 = () => console.log('from t1');
exports.t1 = t1;
class C1 {
    constructor() { }
    print() {
        console.log('from t2');
    }
}
exports.C1 = C1;

関数を import しようとして Rferrence Error となっているスクリーンショット

そういうものらしいが、class ができるのはなぜ?

この辺は rollup.js を使うことになりそうなのであまり深くは追及しない。
https://github.com/google/clasp/blob/master/docs/esmodules.md

hankei6kmhankei6km

あとは targetes2019 にしておいた方がよさそうなので変更した。

V8 support take advantage of the performance boost of Chrome JavaScript engine:

  • Every ES2019 features (except ES modules)
  • Edit your appsscript.json manifest to choose between the Rhino and V8 engines
  • Typescript users should update their tsconfig.json with the "target": "ES2019" compiler option
hankei6kmhankei6km

ローカル側のプロジェクト内の構成など。

  • ローカル側のプロジェクトの構成はどうするのがよいのか?
    rollup.js などで変換したら srcdist みたくなりそう。その場合 rootDirdist を指すようになる?
  • Git のリポジトリにする場合は?
    .clasp.json は含めない方がよさそうな感じがしている。 含めるもよう。
  • テストは?

この後は、この辺も気にしながら進める。

追記、実際に利用されているものを参考にした方がよい。

https://github.com/googleworkspace/apps-script-oauth2

hankei6kmhankei6km

.mjs(.mts) は使えないもよう。

$ ls *ts
main.ts  test.mts

$ clasp push
└─ /home/node/clasp/first-clasp/appsscript.json
└─ /home/node/clasp/first-clasp/main.ts
Pushed 2 files.
hankei6kmhankei6km

パッケージを EMS にしてみたが clasp push の挙動に変化はなさそう?

package.json
{
  "type": "module",
}
tsconfig.json
{
    "module": "es2020", 
}
hankei6kmhankei6km

外部パッケージも使いたいので rollup.js を使うことにした。

基本は esmodules.md のとおり。
babel は使わずに以前に dual package 化で使った設定を混ぜる。

  • tree-shaking をさせない
  • format は esm(cjs でも動いたが require が混ざるとエラーになる)

にしておけばよさそう。

$ npm install --save-dev rollup @rollup/plugin-commonjs @rollup/plugin-node-resolve @rollup/plugin-typescript tslib
rollup.config.js
import typescript from '@rollup/plugin-typescript'
import commonjs from '@rollup/plugin-commonjs'
import { nodeResolve } from '@rollup/plugin-node-resolve'

const extensions = ['.ts', '.js']

const preventThreeShakingPlugin = () => {
  return {
    name: 'no-threeshaking',
    resolveId(id, importer) {
      if (!importer) {
        // let's not theeshake entry points, as we're not exporting anything in Apps Script files
        return { id, moduleSideEffects: 'no-treeshake' }
      }
      return null
    }
  }
}

export default {
  input: './src/main.ts',
  output: {
    dir: 'build',
    format: 'esm'
  },
  plugins: [
    preventThreeShakingPlugin(),
    typescript({}),
    nodeResolve({
      extensions
    }),
    commonjs({})
  ]
}
package.json
{
  "scripts": {
    "build": "rollup --config",
  },
}
.claspignore
# ignore all files…
**/**

# except the extensions…
!appsscript.json
# and our transpiled code
!build/*.js

# ignore even valid files if in…
.git/**
node_modules/**
hankei6kmhankei6km

ためしに lodash.tosafeinteger を使ってみる。

main.ts
import toSafeInteger from 'lodash.tosafeinteger'
import { t1, C1 } from './test.js'

function test() {
  const c = new C1()
  c.print()
  const s = '10.05'
  console.log(`${toSafeInteger(s)}`)
  t1()
}
$ npm run build

> first-clasp@1.0.0 build /home/node/clasp/first-clasp
> rollup --config


./src/main.ts → build...
created build in 4.1s

$ clasp push
└─ /home/node/clasp/first-clasp/appsscript.json
└─ /home/node/clasp/first-clasp/build/main.js
Pushed 2 files.

function の import も動くようになる。

ビルドした結果を push した状態の Apps Script エディターで実行結果を表示しているスクリーンショット

hankei6kmhankei6km

babel を使う方法も試してみた。

以下のようなエラーが出る。

[!] (plugin babel) Error: You must use the `@babel/plugin-transform-runtime` plugin when `babelHelpers` is "runtime".

以下で対応。
https://github.com/rollup/plugins/issues/381#issuecomment-627215009

以下インストールなど。

$ npm install --save-dev @rollup/plugin-babel @babel/core @babel/plugin-transform-runtime @babel/preset-env @babel/preset-typescript
rollup.config.js
import typescript from '@rollup/plugin-typescript'
import commonjs from '@rollup/plugin-commonjs'
import { babel } from '@rollup/plugin-babel'
import { nodeResolve } from '@rollup/plugin-node-resolve'

const extensions = ['.ts', '.js']

// https://github.com/google/clasp/blob/master/docs/esmodules.md
const preventThreeShakingPlugin = () => {
  return {
    name: 'no-threeshaking',
    resolveId(id, importer) {
      if (!importer) {
        // let's not theeshake entry points, as we're not exporting anything in Apps Script files
        return { id, moduleSideEffects: 'no-treeshake' }
      }
      return null
    }
  }
}

export default {
  input: './src/main.ts',
  output: {
    dir: 'build',
    format: 'esm'
  },
  plugins: [
    preventThreeShakingPlugin(),
    //typescript({}),
    nodeResolve({
      extensions
    }),
    commonjs({}),
    babel({
      extensions,
      babelHelpers: 'runtime',
      skipPreflightCheck: true,
      exclude: '**/node_modules/**'
    })
  ]
}

babel.config.js はそのまま使う。esm パッケージにしているなら .cjs にする。

実行手順や結果にとくに違いはなさそう。公式の手順に従っておくのが無難かな。

hankei6kmhankei6km

現状ためしているものはビルドしたファイルの中は関数がグローバルで記述された状態なので、ちょっと気になっていた。上記スターターだと webpack の設定がされているのでそこは魅力。

が、native EMS パッケージにしたいので(Jest を使うなら ESM で動かしたいので)、やはりボチボチやっていくことに。

hankei6kmhankei6km

Markdown を HTML にしてみた。
unified だとNode.js の Built-in モジュール(path url process)を使っていてその辺が解決できなかったので、mdast と hast の util を使っている。

main.ts
import { fromMarkdown } from 'mdast-util-from-markdown'
import { toHast } from 'mdast-util-to-hast'
import { toHtml } from 'hast-util-to-html'

function test() {
  const mdast = fromMarkdown(`# test

test test test

## test2

test test test
`)
  const hast = toHast(mdast)
  if (hast) {
    const html = toHtml(hast)
    console.log(html)
  }
}

Apps Script エディターで実行した関数により変換された HTML がログに表示されているスクリーンショット
HTML になっている

hankei6kmhankei6km

native ESM ありでの Jest のテスト。

$ npm install --save-dev "@types/jest" "jest" "ts-jest" "ts-node"
jest.config.js
export default {
  roots: ['<rootDir>'],
  testMatch: [
    '**/__tests__/**/*.+(ts|tsx|js)',
    '**/?(*.)+(spec|test).+(ts|tsx|js)'
  ],
  collectCoverage: true,
  collectCoverageFrom: ['<rootDir>/src/**/*.ts'],
  coveragePathIgnorePatterns: [
    '<rootDir>/node_modules/',
    '<rootDir>/src/types/'
  ],
  transform: {},
  testEnvironment: 'jest-environment-node',
  // https://kulshekhar.github.io/ts-jest/docs/next/guides/esm-support/
  preset: 'ts-jest/presets/default-esm',
  extensionsToTreatAsEsm: ['.ts'],
  globals: {
    'ts-jest': {
      useESM: true
    }
  },
  moduleNameMapper: {
    '^(\\.{1,2}/.*)\\.js$': '$1'
  }
}
package.json
 {
 "scripts": {
    "build": "rollup --config",
    "test": "node --experimental-vm-modules node_modules/.bin/jest"
  },
}
md2html.ts
import { fromMarkdown } from 'mdast-util-from-markdown'
import { toHast } from 'mdast-util-to-hast'
import { toHtml } from 'hast-util-to-html'

export function md2html(md: string) {
  const mdast = fromMarkdown(md)
  const hast = toHast(mdast)
  if (hast) {
    const html = toHtml(hast)
    return html
  }
  return ''
}
md2html.ts
import { md2html } from '../src/md2html'

describe('md2html()', () => {
  it('should return html', () => {
    expect(
      md2html(`# test1
test test
`)
    ).toEqual(`<h1>test1</h1>
<p>test test</p>`)
  })
})
hankei6kmhankei6km

GitHub Actions でデプロイする場合。

ここでいうデプロイは clasp deploy でバージョンを付けること。

スクリプトをライブラリとして使う場合の HEAD の更新(clasp push)は手動でやると思う。

Actions でやりたいのは「リポジトリ側でリリースを公開したらclasp push clasp deploy で HEAD にバージョンを振るような感じにしたい。
(自作のパッケージで npm publish しているような感じ)

それを実現するにはどうすればよいのか。

clasp のクレデンシャル?は .clasprc に入っているのだが、わりと短い間隔(たぶん数時間)でリフレッシュトークンを使って更新していもよう。

以前に GitHub Actions でリフレッシュトークン使う方法を調べてみたが良い方法はわからなかった。

今回も無理そうかとおもったら以下がヒットした。

https://dev.classmethod.jp/articles/github-actions-gas-deploy/

おそらく「ストレージ上に期限切れのアクセストークンで .clasprc を作成し、clasp xxx コマンドは実行されたら自動で .clasprc を更新する機能を利用」しているのだと思う。

この方法で大丈夫?

リフレッシュトークンの有効期間が長いといっても、更新を繰り返して発行されたリフレッシュトークンが増えてくると古いのが消えるとかあったような。

ローカルの .clasprc は更新されても token.refresh_token は変わらないようなので大丈夫なのかな。

hankei6kmhankei6km

やはり、import した関数がグローバルに大量にあるのはよろしくない。
例. ライブラリとして利用する場合、コード補完で目的ではない関数が表示される。

webpack (の gas 用プラグイン)を使うのも大変なので少し考える。

以下がヒントになった。

というもので、実は Script Editor 上で異なるタブで開いているコードで定義されている内容は import / export なしにアクセスできるのだ。

https://aligach.net/diary/2020/1112/

  1. 利用したい関数を export する
  2. rollup で output.formatumd にして output.name_entrty_point などを指定
  3. これで、グローバルの _entry_piontexport されている関数がセットされるので、それを実行する関数を記述したファイルを作成
  4. clasp push の対象に上記ファイルを含める

ビルドしたプロジェクトをライブラリとして利用しているときに、コード補完で関数が表示されているスクリーンショット
候補の関数は 1 つになる

良い点は index.js はバンドルとは別にコピーするので no-treeshkaeなど考慮しなくてもよくなる。
良くない点は関数を別の関数でラップするので少し無駄がある。

あとは this などが面倒なことになりそうな気もするのでその辺は注意。

code
main.ts
import { md2html } from './md2html.js'

export function mdToHtml() {
  const html = md2html(`# test

test test test

## test2

test test test
`)
  console.log(html)
}
index.js
/**
 * Mardkdown を HTML へ変換.
 *
 * @param {string} md
 * @returns {string}
 */
function mdToHtml(md) {
  // これは export しない.
  return _entry_point.mdToHtml(md)
}
rollup.config.js
export default {
  input: './src/main.ts',
  output: {
    dir: 'build',
    format: 'iife',
    name: '_entry_point'
  },
  plugins: [
    // preventThreeShakingPlugin(),
    // typescript({ tsconfig: './tsconfig_rollup.json' }),
    nodeResolve({
      extensions
    }),
    babel({
      extensions,
      babelHelpers: 'runtime',
      skipPreflightCheck: true
      //exclude: '**/node_modules/**'
    }),
    commonjs({})
  ]
}
package.json
{
  "scripts": {
    "clean": "rimraf build/*",
    "build": "npm run clean && rollup --config && cp src/index.js build/index.js",
    "test": "node --experimental-vm-modules node_modules/.bin/jest"
  },
}
hankei6kmhankei6km

clasp 上でライブラリーを利用する場合、コード補完を使おうと思ったら .d.ts が必要になると思われる。

この辺の対応はとりあえず保留。

hankei6kmhankei6km

いちおう書き換えができなくなるが、泥臭い対応。

index.jsconst _mdToHtml などに _entry_point の関数を退避し、delete _entry_point で削除してしまう。

この方法は以下の特性に依存する。

  • const はライブラリーの外から参照できない(ように見えるが、明確な記述は見つからなかった)
  • filePushOrderindex.js が読み込まれる前に _entry_point を定義させることができる

いったん定義してしまえばいいだけだが、ちょっと面倒。

index.js
const _mdToHtml = _entry_point.mdToHtml

/**
 * Mardkdown を HTML へ変換.
 *
 * @param {string} md
 * @returns {string}
 */
function mdToHtml(md) {
  return _mdToHtml(md)
}

delete _entry_point
{
  "filePushOrder": ["build/main.js", "build/index.js"]
}
hankei6kmhankei6km

以下にしっかり書いてあった。

https://developers.google.com/apps-script/guides/libraries#best_practices

Only enumerable global properties are visible to library users. This includes function declarations, variables created outside a function with var, and properties explicitly set on the global object. For example, Object.defineProperty() with enumerable set to false creates a symbol you can use in your library, but this symbol isn't accessible by your users.

const だからというよりも列挙できるかという話らしい。

If you want one or more methods of your script to not be visible (nor usable) to your library users, you can end the name of the method with an underscore. For example, myPrivateMethod_().

また、名前の末尾に _ があれば利用できないとのこと。

hankei6kmhankei6km

ということで _entry_point_ に変更して代入や delete を外す、スッキリした。

hankei6kmhankei6km

GitHub Actions から clasp push できるようにした。

が、使い続けるかはちょっと悩む。

  • リフレッシュトークンが更新されたときにエラーになる(はず)
    secret を設定しなおせばよいのだが。

  • 更新されたアクセストークンはマスクされない(ログ上に露出したときに "***" に置き換わらない)
    そういうときのためにリフレッシュトークンで更新するのだけど。よろしくはないよなと。

あとはファイルとしてストレージに書き込むのも少しもやっとする。
コンテナが削除されれば消えるけど、うっかりキャッシュに入れると見えてしまう。

hankei6kmhankei6km

.clasprc.json をファイルに書き出すのではなく Bash の Process Substitution を使ったら「キャッシュされてしまうのは心配」的なことは避けられるのでは?

が、おそらく更新されたトークンを書き込みにくるのでその辺が微妙なところかな。

hankei6kmhankei6km

上記のことを調べていたら以下がヒットした。envsubst の GitHub Actions 版みたいな感じ
(だと思う、実際に試していないので違うかも)。

.json などの中を SECRET で書き換えたい場合は、jq で頑張るよりも楽ができそう。

    - uses: microsoft/variable-substitution@v1 
      with:
        files: 'Application/*.json, Application/*.yaml, ./Application/SampleWebApplication/We*.config'
      env:
        Var1: "value1"
        Var2.key1: "value2"
        SECRET: ${{ secrets.SOME_SECRET }}

https://docs.microsoft.com/en-us/azure/developer/github/github-variable-substitution

https://github.com/marketplace/actions/variable-substitution

hankei6kmhankei6km

Variable Substitution Action が作られたということは需要がある(アンチパターンではない)ということなので、気にしすぎだったかな。

hankei6kmhankei6km

GitHub で Release を publish したら clasp deploy を実行するようにした。

GitHub 上でリリースを表示しているスクリーンショット
リリースすると

スクリプトエディターのデプロイの管理を表示しているスクリーンショット
デプロイされる

課題

  • description の指定はどうする。Relase をトリガーにしているので本文の 1 行目などか
  • バージョン番号
    • npm version とどう擦り合わせる?
    • 開発と公開でプロジェクトを分けた場合ずれる可能性がある
hankei6kmhankei6km

リリース本文の 1 行目を description となるようにした。

GitHub 上でリリースの本文を表示しているスクリーンショット
本文の 1 行目を description 用に記述

スクリプトエディターでデプロイの詳細を表示しているスクリーンショット
description として転記されている

hankei6kmhankei6km

package.json とリリース(tag)とスクリプト(プロジェクト)での version と description の関係。

OAuth2 for Apps Script では以下のとおり。

  • package.json のマイナーバージョン(xx.yy.zz の yy)がスクリプトのバージョンと対応してように見える
  • リリースのタグがスクリプトバージョンと対応しているように見える(タグは semver 形式ではなく yy のみ)
  • リリースのタイトルが description になっているように思える(description は利用者側からは見えない?)
  • リリース本文の 1 行目に package.json のバージョンが記載されている

https://github.com/googleworkspace/apps-script-oauth2

これを参考に自作のライブラリーでは以下のようにする予定

  • マイナーバージョンの部分をスクリプトのバージョンと対応させる
  • タグとリリースのタイトルは npm パッケージのときと同じ
  • リリース本文の 1 行目を description にする

開発と公開のプロジェクトをわける場合は以下のとおり

  • 動作チェック用にでローカルから開発用プロジェクトにclasp push をすることがある
  • GitHub へ push すると開発用プロジェクトに push する
  • リリースを作成すると開発と公開用プロジェクトに push し新しい version で deploy する
hankei6kmhankei6km

OAuth2 for Apps Scrip ではスクリプトの ID でライブラリーとして追加する以外に、ビルドしたソースファイルをコピペしてもいいよ的なことになっている。

Alternatively, you can copy and paste the files in the /dist directory directly into your script project.

よって、上記の場合 dist もリポジトリに含まれている。

これと同じようにするかちょっと悩んでいた(ビルドしたものをリポジトリに含めるのは面倒そう)。

今回はデプロイのトリガーをリリースにしているので、プロジェクトへプッシュしたファイルとライセンスのファイルをアーカイブして Assets へアップロードするようにした。

GitHub 上でのリリースの画面で Assets の一覧を表示しているスクリーンショット
Asseets へ md2html.zip をアップロード

アップロードには以下の Action を利用。

https://github.com/actions/upload-release-asset

リリースがトリガーなので upload_url は context から取り出せる。

deploy.yaml
      - name: Upload archive file to release Asset
        id: upload-release-asset
        if: ${{ matrix.environment == 'rel' }}
        uses: actions/upload-release-asset@v1
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          upload_url: ${{ github.event.release.upload_url }}
          asset_path: ./md2html.zip
          asset_name: md2html.zip
          asset_content_type: application/zip
hankei6kmhankei6km

上記でライセンスファイルの作成を行ったのでライセンス関連について。

スクリプトのプロジェクトにライセンスを明記するためのフィールドや慣例があるのかは不明。
少し検索した限りでは触れている情報は見当たらなかった。

プッシュするソースファイルに JSDoc 形式でライセンスを明記してもとくに変化なさそう。

プッシュされたファイルをスクリプトエディターで表示しているスクリーンショット
JSDoc 形式でライセンスを明記

ライブラリーの参照先でバージョンの情報を表示しているスクリーンショット
デプロイの decription と関数の情報のみ表示される

hankei6kmhankei6km

TypeScript(@types/google-apps-script) とコードエディター上で型の定義が違っている?

  • TypeScipt - GoogleAppsScript.Spreadsheet.Sheet
  • コードトエディター - SpreadsheetApp.Sheet

JSDoc で GoogleAppsScript.Spreadsheet.Sheet を指定するとコードエディターでは any と表示される。
コードエディターで SpreadsheetApp.getActiveSheet() の戻り値を確認すると SpreadsheetApp.Sheet になる。

clasp はこの辺のことは感知しないという方針のもよう。

https://github.com/google/clasp/issues/821

hankei6kmhankei6km

上記のように型定義が食い違うとライブラリーとして公開する部分と TypeScript のコードで整合性がとれなくなる(と思う、しばらく悩んでみたが解決策は思いつかなかった)。

強引に index.js から型定義を作って JSDoc の記述を共有していたが、あきらめて TypeScript 側でも記述することにした。

ライブラリーとして公開される関数。

index.js
/**
 * スプレッドシートの空行を削除.
 * @param { SpreadsheetApp.Sheet } sheet - シート.
 * @returns {Array<number>} - 削除した行番号(降順).
 */
function deleteBlankRows(sheet) {
  return _entry_point_.BlankRows.deleteBlankRows(sheet)
}

TypeScript 内での namespace 付きの記述。

src/blank-rows.ts
export namespace BlankRows {
  /**
   * スプレッドシートの空行を削除.
   * @param sheet - シート.
   * @returns - 削除した行番号(降順).
   */
  export function deleteBlankRows(
    sheet: GoogleAppsScript.Spreadsheet.Sheet
  ): number[] {
    const rows = blankRowsInRangeValue(sheet.getDataRange().getValues())
    rows.forEach((r) => {
      sheet.deleteRows(r, 1)
    })
    return rows
  }
}

上記の .ts から .d.ts を作成すると以下のように export が付いてしまうので型定義のパッケージには使えない(モジュールとして認識されてしまう)。

/// <reference types="@types/google-apps-script" />
export declare namespace BlankRows {
    /**
     * スプレッドシートの空行を削除.
     * @param sheet - シート.
     * @returns - 削除した行番号(降順).
     */
    function deleteBlankRows(sheet: GoogleAppsScript.Spreadsheet.Sheet): number[];
}

export を取り除く方法を調べたが最終的には sed で取り除くことになってしまった。

hankei6kmhankei6km

すったもんだあったが、以下のような個人用スターターができた。

  • TypeScript で記述、Jest でテスト
  • 公開用と開発用のプロジェクトを設定可能
  • 外部の NPM パッケージを利用可能(バンドルされた関数群はライブラリーとして外部に公開されない)
  • TypeScript(clasp) でライブラリーを利用するための型定義(index.d.t)を生成
  • GitHub 上へブランチをプッシュすると開発用プロジェクトへプッシュされる
  • GitHub 上でリリースを公開すると開発用と公開用プロジェクトへデプロイ(バージョン付け)される
    • リリースの内容に types ラベルが付いたプルリクエストがあると型定義のパッケージが npm レジストリで公開される

https://github.com/hankei6km/my-starter-gas-lib-ts