みこしば屋

GoogleAppsScriptによるWebアプリ構築ボイラープレートを作成しました


概要

GoogleAppsScriptでWebアプリを作成するための環境を構築しました。
もう一度同じものを作るためには手順が多かったため、ボイラープレートとして再利用できるようにしました。
DevContainer、React、TypeScriptあたりを使用し、VSCodeでの開発を想定しています。

構築手順

構築手順は以下の通りなので、好みに応じてアレンジしてみるのも良いかと思います。

1. DevContainer の定義

プロジェクトのフォルダを作成し、直下に .devcontainer フォルダを作成。 その中に devcontainer.json を作成して内容を以下のようにします。

devcontainer.json
{
  "name": "container name",
  "image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm",
  "customizations": {
    "vscode": {
      "extensions": ["esbenp.prettier-vscode"]
    }
  },
  "postCreateCommand": "yarn && yarn global add @google/clasp"
}

VSCodeの場合の拡張機能はとりあえず Prettier だけ入れました。 postCreateCommand ではパッケージのインストールを行いますが、yarn global add @google/clasp で clasp のインストールも定義しています。

以降の作業は DevContainer を起動して行っていきます。 この辺で git init もしておきます。

2. Vite による React の導入

yarn create vite で React + TypeScript の導入を行います。

$ yarn create vite

? Project name: your project name # プロジェクトのフォルダ名と合わせておくと良いです
? Select a framework: React
? Select a variant: TypeScript + SWC

$ cd your project name
$ yarn
$ yarn dev

とりあえずの起動確認ができました。 01.png

フォルダ構成の変更

今作成したプロジェクトフォルダの中身をすべてプロジェクトのルートに移動し、空になったフォルダは削除します。

02.png

次に現在の src フォルダの中身を frontend フォルダにまとめておきます。 ついでに backend フォルダも作成しておきます。

root/public 内の vite.svgsrc/frontend/assets に移動し、フォルダは削除してしまいます。

03.png

構成を変更したので一部のファイルを修正します。

index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
-    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + React + TS</title>
  </head>
  <body>
    <div id="root"></div>
-    <script type="module" src="/src/main.tsx"></script>
+    <script type="module" src="/src/frontend/main.tsx"></script>
  </body>
</html>
App.tsx
import { useState } from 'react'
import reactLogo from './assets/react.svg'
- import viteLogo from '/vite.svg'
+ import viteLogo from './assets/vite.svg'
import './App.css'

ここまでで yarn dev を実行すると先ほどと同じ画面が表示されます。

3. 各種パッケージのインストール

GoogleAppsScriptの開発に関係する以下のパッケージをインストールします。

$ yarn global add @google/clasp
$ yarn add --dev @types/google-apps-script
$ yarn add --dev vite-plugin-singlefile
$ yarn add --dev rollup-plugin-google-apps-script
$ yarn add gas-client

4. デプロイするファイルの出力ディレクトリを作成

yarn build で最終的なファイルを出力するディレクトリを作成します。 プロジェクトルートに gas フォルダを作成します。 最終的には gas/dist にファイルが出力されます。

デプロイ時に必要となる appsscript.jsongas の直下に作成しておきます。

appsscript.json
{
  "timeZone": "Asia/Tokyo",
  "dependencies": {},
  "webapp": {
    "executeAs": "USER_DEPLOYING",
    "access": "MYSELF"
  },
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8"
}

5. バックエンドのサンプル関数を作成

src/backendserverFunctions フォルダを作成します。 中に index.ts を作成し、内容を以下のようにします。 受け取った数値をプラス1して返すだけのサンプル関数です。

index.ts
export const sampleFunction = (num: number): number => num + 1;

src/backend 直下に main.ts を作成し、内容を以下のようにします。

main.ts
import { sampleFunction } from "./serverFunctions";

declare const global: {
  [x: string]: unknown;
};

// This function is required to run as a webApp
global.doGet = (): GoogleAppsScript.HTML.HtmlOutput => {
  return HtmlService.createHtmlOutputFromFile("dist/index.html");
};

// Create the necessary functions below.
global.sampleFunction = sampleFunction;

global.doGet は GoogleAppsScript でWebアプリを立ち上げるために必要な関数です。 global.sampleFunction のように関数をグローバルに定義し、フロントエンド側から呼び出すことができるようになりました。

Vite+Reactのスタートページではサンプルとしてクリックするとカウントアップするボタンが設置されています。 Webアプリを立ち上げた際には、先程作成した sampleFunction を使用してカウントアップするようにしてみます。

src/frontendApp.tsx を以下のようにします。

App.tsx
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import viteLogo from "./assets/vite.svg";
import "./App.css";
+ import { GASClient } from "gas-client";
+ const { serverFunctions } = new GASClient();

function App() {
  const [count, setCount] = useState(0);

+ const handleButton = async () => {
+   if (import.meta.env.PROD) {
+     try {
+       const response: number = await serverFunctions.sampleFunction(count);
+       setCount(response);
+     } catch (err) {
+       console.log(err);
+     }
+   } else {
+     setCount(count + 1);
+   }
+ };
  
  return (
    <>
      <div>
        <a href="https://vitejs.dev" target="_blank">
          <img src={viteLogo} className="logo" alt="Vite logo" />
        </a>
        <a href="https://react.dev" target="_blank">
          <img src={reactLogo} className="logo react" alt="React logo" />
        </a>
      </div>
      <h1>Vite + React</h1>
      <div className="card">
-       <button onClick={() => setCount((count) => count + 1)}>
-         count is {count}
-       </button>
+       <button onClick={handleButton}>count is {count}</button>
        <p>
          Edit <code>src/App.tsx</code> and save to test HMR
        </p>
      </div>
      <p className="read-the-docs">
        Click on the Vite and React logos to learn more
      </p>
    </>
  );
}

export default App;

6. ビルドの設定

アプリとしての構成は出来たので、ビルドの設定を行っていきます。 最終的にフロントエンドは単一の html ファイル、バックエンドは単一の js ファイルとなるようにします。

それぞれ個別にビルドする必要があるため、vite.config.ts ファイルをコピーし、 vite.config.frontend.tsvite.config.backend.ts を作成します。

vite.config.frontend.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import { viteSingleFile } from "vite-plugin-singlefile";

export default defineConfig({
  plugins: [react(), viteSingleFile()],
  build: {
    outDir: "gas/dist",
    emptyOutDir: false,
  },
});

フロントエンドは vite-plugin-singlefile を使用して単一ファイルを生成します。

vite.config.backend.ts
import { defineConfig } from "vite";
import rollupPluginGas from "rollup-plugin-google-apps-script";

export default defineConfig({
  plugins: [rollupPluginGas()],
  build: {
    rollupOptions: {
      input: "src/backend/main.ts",
      output: {
        dir: "gas/dist",
        entryFileNames: "main.js",
      },
    },
    emptyOutDir: false,
    minify: false,
  },
});

バックエンドは rollup-plugin-google-apps-script を使用して単一ファイルを生成します。

どちらも emptyOutDir: false として、フォルダの初期化はスクリプト側で行います。 package.json"scripts" を定義します。

package.json
  "scripts": {
    "dev": "vite",
-   "build": "tsc -b && vite build",
+   "clean": "rimraf gas/dist",
+   "build:frontend": "vite build --config vite.config.frontend.ts",
+   "build:backend": "vite build --config vite.config.backend.ts",
+   "build": "yarn clean && yarn build:frontend && yarn build:backend",
    "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview"
  }

yarn build を実行すると gas/dist フォルダ内にファイルが生成されます。 04.png

ここで作成される gas/dist.gitignore に追加して除外しておくのが良いです。

.gitignore
+ # clasp
+ gas/dist

7. デプロイする

まず clasp にログインします。 以下のコマンドにより開くブラウザ上で認証を行います。

$ clasp login

次にデプロイ先のプロジェクトファイルを作成します。 この方法で作成したプロジェクトファイルは Google Drive のルートに配置されます。

$ clasp create

? Create which script?
  standalone
  docs
  sheets
  slides
  forms
> webapp
  api

プロジェクトを作成すると appscript.json.clasp.json がプロジェクトルートに作成されます。 このボイラープレートでは appscript.jsongas フォルダにあらかじめ配置しているため、 新たに作成されたものは削除してしまっても良いです。

.clasp.json"rootDir" はアップロードする対象のフォルダを指すので変更しておきます。

.clasp.json
{
  "scriptId": "xxxxxxxx",
-  "rootDir": "/workspaces/your project name"
+  "rootDir": "/workspaces/your project name/gas"
}

また、.clasp.json"scriptId" の情報を含んでいるため、こちらも .gitignore に追加しておきます。

.gitignore
# clasp
gas/dist
.clasp.json

最後にデプロイを行います。

$ yarn build # 何か変更があれば
$ clasp push
$ clasp deploy

以降は開発サイクルとして変更するたびに上記のコマンドを実行していく流れとなります。

テスト関連

1. Vitest を導入する

ユニットテストを整備するために Vitest を導入します。

$ yarn add -D vitest

ユニットテストを実行するため、package.json"scripts" を定義します。

package.json
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "clean": "rimraf gas/dist",
    "build:frontend": "vite build --config vite.config.frontend.ts",
    "build:backend": "vite build --config vite.config.backend.ts",
    "build": "yarn clean && yarn build:frontend && yarn build:backend",
+   "test:unit": "vitest",
    "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview"
  }

サンプルとして作った関数に対して疎通確認してみます。 src/backend/serverFunctionsindex.spec.ts を作成し、雑ですが内容を以下のようにします。

index.spec.ts
import { expect, test } from "vitest";
import { sampleFunction } from "./index";

test("If 1 is given, 2 is returned.", () => {
  expect(sampleFunction(1)).toBe(2);
});

vite.config.ts の内容を以下のようにします。 後ほど Playwright を導入し、そのテストコードは e2e フォルダに作成していきます。 Vitest の実行時に Playwright のテストが実行されないように excludee2e フォルダを指定しています。

vite.config.ts
+ /// <reference types="vitest" />
  import { defineConfig } from "vite";
  import react from "@vitejs/plugin-react-swc";

  export default defineConfig({
    plugins: [react()],
+   test: {
+     exclude: [
+       "**/node_modules/**",
+       "**/dist/**",
+       "**/cypress/**",
+       "**/.{idea,git,cache,output,temp}/**",
+       "**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build}.config.*",
+       "**/e2e/**",
      ],
    },
  });

Vitestの拡張機能を導入するため devcontainer.json を更新しておきます。

devcontainer.json
{
  "name": "container name",
  "image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm",
  "customizations": {
    "vscode": {
-     "extensions": ["esbenp.prettier-vscode"]
+     "extensions": [
+       "esbenp.prettier-vscode",
+       "vitest.explorer"
+     ]
    }
  },
  "postCreateCommand": "yarn && yarn global add @google/clasp"
}

yarn test:unit を実行するとユニットテストが開始されパスしました。

 ✓ src/backend/serverFunctions/index.spec.ts (1)
   ✓ If 1 is given, 2 is returned.

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Start at  13:19:49
   Duration  2.37s (transform 729ms, setup 0ms, collect 820ms, tests 1ms, environment 0ms, prepare 1.15s)

2. Playwright を導入する

E2Eテストを整備するために Playwright を導入します。

$ yarn create playwright

> Where to put your end-to-end tests? : e2e
> Add a GitHub Actions workflow? (y/N) : true
> Install Playwright browsers (can be done manually via 'yarn playwright install')? (Y/n) : true

yarn create playwright

コンテナ作成時に yarn playwright install --with-deps を実行するように devcontainer.json を更新します。 ついでに Playwright の拡張機能も導入しておきます。

devcontainer.json
{
  "name": "container name",
  "image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm",
  "customizations": {
    "vscode": {
      "extensions": [
        "esbenp.prettier-vscode",
        "vitest.explorer",
+       "ms-playwright.playwright"
      ]
    }
  },
- "postCreateCommand": "yarn && yarn global add @google/clasp"
+ "postCreateCommand": "yarn && yarn global add @google/clasp && yarn playwright install --with-deps"
}

E2Eテストを実行するため、package.json"scripts" を定義します。

package.json
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "clean": "rimraf gas/dist",
    "build:frontend": "vite build --config vite.config.frontend.ts",
    "build:backend": "vite build --config vite.config.backend.ts",
    "build": "yarn clean && yarn build:frontend && yarn build:backend",
    "test:unit": "vitest",
+   "test:e2e": "playwright test",
    "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview"
  }

Github Actions から任意のタイミングで実行できるようにしておきます。 .github/workflowsplaywright.yml を編集します。

playwright.yml
name: Playwright Tests
on:
- push:
-   branches: [ main, master ]
- pull_request:
-   branches: [ main, master ]
+ workflow_dispatch:
+   inputs:
+     baseURL:
+       description: 'Base URL for the tests'
+       required: true
jobs:
  test:
    timeout-minutes: 60
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: lts/*
      - name: Install dependencies
        run: npm install -g yarn && yarn
      - name: Install Playwright Browsers
        run: yarn playwright install --with-deps
      - name: Run Playwright tests
        run: yarn playwright test
+       env:
+         PLAYWRIGHT_BASE_URL: ${{ github.event.inputs.baseURL }}
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30

playwright.config.tsbaseURL を調整します。 Github Actions から実行する際は PLAYWRIGHT_BASE_URL を外から指定する形にします。

playwright.config.ts
use: {
  baseURL: process.env.PLAYWRIGHT_BASE_URL || 'your apps url',
}

適当なテストを作成して疎通確認してみます。 e2e 直下の example.spec.ts の内容を以下のようにします。 Vite のスタートページにあるカウントアップボタンをクリックすると結果が1になる、というテストです。

example.spec.ts
import { test, expect, Locator, FrameLocator } from "@playwright/test";

test("Clicking on it makes the count go to 1.", async ({ page }) => {
  await page.goto("");

  const appFrame: FrameLocator = page
    .frameLocator("#sandboxFrame")
    .frameLocator("#userHtmlFrame");

  if (!appFrame) {
    throw new Error("appFrame is not found");
  }

  const countUpButton: Locator = appFrame!.getByRole("button");
  await countUpButton.click();

  await expect(countUpButton).toContainText("1");
});

デプロイしたアプリの公開範囲を全体に設定し、baseURL を公開URLに変更した状態で、 yarn test:e2e を実行するとE2Eテストが開始されパスしました。

Running 3 tests using 3 workers
  3 passed (10.9s)

To open last HTML report run:

  yarn playwright show-report

Done in 18.62s.

まとめ

このように、Google Apps Script で Webアプリの構築を行い、意外と色々できそうな気配はありました。
ただ、環境構築という点では難なく動いていますが、特殊な環境であるため作り込んでいくと壁にぶつかる瞬間がやってくる気もします。
ここまで書いておいてなんですが、結果としては、Google Apps Script でこういうことはやるべきではない、やる意味がない、という感想でした。

リンク

こちらの記事はQiitaにも掲載しています。