Google Meetに(ちょっとだけ)楽にアクセスできるRaycastの拡張機能をつくった

はじめに

随分前にRaycastというランチャーがReactで拡張機能を作れると知って、ランチャーをRaycastに乗り換えたが、結局拡張機能を作ることはせずに時間が過ぎてしまっていた。 とりあえずなにか作ってみようと思い、普段の業務で該当時間になったらミーティングのたびにgoogleカレンダーをブラウザで開いてそこからアクセスするのが面倒だったので、キーボード操作のみでアクセスできるようにした。 (目的からすると時間になったらURLをpush通知してクリックすればよいだけなのでわざわざraycastでというのはある)

Raycastとは

Raycastは、ランチャーアプリケーションで、ショートカットキーを使って素早くアプリやファイルにアクセスすることができます。また、Raycastでは、Reactを使用して独自の拡張機能を作成することができます!

www.raycast.com

拡張機能の作り方

  1. Raycastを開き、「create」と入力します。
  2. 「Create Extension」を選択します。
  3. 拡張機能作成画面で必要な項目を選択します。ここでは、Templateを選択することができます。Templateに応じて、Locationで設定したディレクトリに必要なコードが自動生成されます。
  4. 生成されたコードはReactで書かれていて、npm run devで開発サーバーを起動できます。

実際に作った拡張機能

github.com

概要

現在時刻から前後15分の間にGoogleカレンダーに設定されているGoogle Meetにアクセスするようにした。

認証

Googleアカウントの認証には以下の3つの方法があります。

  • APIキー

  • OAuth2.0

  • サービスアカウント

今回の目的としてOAuth2.0での認証が一番使い勝手が良さそうだったのでそちらで実装していたのですが、一通り動いた数日後に

Token has been expired or revoked.

というエラーが発生し、調べたところ、該当のアプリケーションの公開ステータスが「テスト」のものに関しては7日で更新期限が切れるとのことで、サービスアカウントでの認証方式を利用しました。

サービスアカウントとは

サービスアカウントは、Google Cloud Platformでアプリケーションの認証やアクセス制御を提供するアカウントです。サービスアカウントを作成すると、特定のサービスアカウントに関連付けられるキーペアが生成されます。このキーペアは、Google Cloud APIの利用時の認証に使用されるプライベートキーと公開キーです。

サービスアカウントの作成方法や利用方法は以下を参照してください。

JSON形式で取得できるので、そのJSONを手元に保管してください。

ただし、サービスアカウントでの認証を利用する際に、認証したサービスアカウントもカレンダーに追加する必要がある点に注意してください。毎回追加するのが面倒であれば、公開可能なアプリの場合はOAuth2.0で認証するか、ライトな使い方であればAPIキーベースでの認証にした方が良いと思います。

googleapisの利用について

Raycastは拡張機能のヒープ領域が制限されているらしく、googleapisをインポートするとheap out of memoryのエラーが発生します。この問題を解決するために、分割インポートを使用しました。   このエラーで多少ハマって調べていたらこちらの記事を見つけることができ、上記解決方法に至りました。

利用方法

Raycastを開いて「Import Extension」を起動し、gitから一式格納したディレクトリを指定してください。

その後、以下のkeyFileにサービスアカウントのjsonを格納したpathを設定してください。

const auth = new GoogleAuth({
    scopes: 'https://www.googleapis.com/auth/calendar.readonly',
    keyFile: '/path/to/serviceaccount.json',
});

上記設定後npm run devで起動します。

googleカレンダーの参加者にサービスアカウントのメールアドレスを追加してください。サービスアカウントのメールアドレスの確認は左のナビゲーションメニューから「IAMと管理」→「サービスアカウント」で確認できます。

実際にRaycastから起動すると以下のように現在時刻を基準に前後15分のカレンダー情報を取得できていると思います。

参考

Raycast で作業スピードを爆速化 ! Google カレンダー連携 拡張機能の作り方 - Goodpatch Tech Blog

go generateでモックを生成して呼び出すまで

今更感あるがgo generateを使ってモックを生成して実際にテストコードなどで呼び出すところまで触ってみたので記録として残しておく。

構成

modelとrepositoryを作る。
このrepositoryで定義したInterfaceを利用可能なモックをmockディレクトリ配下に生成してmain_test.goでそのmockを利用したテストコードを実装する。

├── domain
│   ├── model
│   │   └── item_model.go
│   └── repository
│       └── item_repository.go
├── go.mod
├── go.sum
├── main_test.go
└── mock

一応ソース置いておきます。

github.com

model

とりあえず簡単なモデルを作ります。 domain/model/item/item_model.go

package model

type ItemModel struct {
    Id   int
    Name string
}

repository

Itemモデルのリポジトリを簡易的に作る。
domain/repository/item/item_repository.go

//go:generate mockgen -source=$GOFILE -package=mock_$GOPACKAGE -destination=../../../mock/$GOPACKAGE/$GOFILE
package repository

import "github.com/ybalexdp/go-generate-sample/domain/item/model"

type ItemGetter interface {
    Get(id int) (item model.ItemModel, err error)
}

この際に以下の一文を冒頭に追記する。(オプションに指定する内容は適宜読み替えてください) //go:generate mockgen -source=$GOFILE -package=mock_$GOPACKAGE -destination=../../mock/$GOPACKAGE/$GOFILE

go generate

オプションなど

今回指定したオプションについては以下の通り。

オプション 内容
-source 対象のファイル名
-package 生成されたmockファイルに設定するpackage名
-destination mockファイルの生成先

また設定できる変数に関しては以下のようです。詳細はこちら

Go generate sets several variables when it runs the generator:

    $GOARCH
        The execution architecture (arm, amd64, etc.)
    $GOOS
        The execution operating system (linux, windows, etc.)
    $GOFILE
        The base name of the file.
    $GOLINE
        The line number of the directive in the source file.
    $GOPACKAGE
        The name of the package of the file containing the directive.
    $GOROOT
        The GOROOT directory for the 'go' command that invoked the
        generator, containing the Go toolchain and standard library.
    $DOLLAR
        A dollar sign.

実行

$ go generate ./domain/repository/item_repository.go

これで mock/repository/item_repository.go が生成されている。
内容は以下の通り。

// Code generated by MockGen. DO NOT EDIT.
// Source: item_repository.go

// Package mock_repository is a generated GoMock package.
package mock_repository

import (
    reflect "reflect"

    gomock "github.com/golang/mock/gomock"
    model "github.com/ybalexdp/go-generate-sample/domain/model"
)

// MockItemGetter is a mock of ItemGetter interface.
type MockItemGetter struct {
    ctrl     *gomock.Controller
    recorder *MockItemGetterMockRecorder
}

// MockItemGetterMockRecorder is the mock recorder for MockItemGetter.
type MockItemGetterMockRecorder struct {
    mock *MockItemGetter
}

// NewMockItemGetter creates a new mock instance.
func NewMockItemGetter(ctrl *gomock.Controller) *MockItemGetter {
    mock := &MockItemGetter{ctrl: ctrl}
    mock.recorder = &MockItemGetterMockRecorder{mock}
    return mock
}

// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockItemGetter) EXPECT() *MockItemGetterMockRecorder {
    return m.recorder
}

// Get mocks base method.
func (m *MockItemGetter) Get(id int) (model.ItemModel, error) {
    m.ctrl.T.Helper()
    ret := m.ctrl.Call(m, "Get", id)
    ret0, _ := ret[0].(model.ItemModel)
    ret1, _ := ret[1].(error)
    return ret0, ret1
}

// Get indicates an expected call of Get.
func (mr *MockItemGetterMockRecorder) Get(id interface{}) *gomock.Call {
    mr.mock.ctrl.T.Helper()
    return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockItemGetter)(nil).Get), id)
}

テストコード

実際にテストコードを書いてmockを呼び出してみる。

package main

import (
    "testing"

    "github.com/golang/mock/gomock"
    "github.com/ybalexdp/go-generate-sample/domain/model"
    mock_repository "github.com/ybalexdp/go-generate-sample/mock/repository"
)

func TestSample(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    mockApiClient := mock_repository.NewMockItemGetter(ctrl)

    expected := model.ItemModel{Id: 1, Name: "hoge"}
    mockApiClient.EXPECT().Get(1).Return(model.ItemModel{Id: 1, Name: "hoge"}, nil)

    res, err := mockApiClient.Get(1)
    if err != nil {
        t.Errorf("error! %v", err)
    }
    if res != expected {
        t.Errorf("Get() = %v want %v", res, expected)
    }
}

以下で実際に呼び出すメソッドと与える引数、そしてReturnで返却される値を定義できる。

mockApiClient.EXPECT().Get(1).Return(model.ItemModel{Id: 1, Name: "hoge"}, nil)

テスト実行してみる

$ go test -v
=== RUN   TestSample
--- PASS: TestSample (0.00s)
PASS
ok      github.com/ybalexdp/go-generate-sample  0.336s

参考

Bubble Teaでマインスイーパー作った

昨年ごろ、ふとインタラクティブCLIを作りたいと思い、色々調べてみたらBubble Teaというものを見つけたのだが、作りたかったCLIは諸事情で不要になったので、その内触りたいなぁなんて思っていたら完全に忘れてしまっていた。
この前Bubble Teaに関する記事を見かけたので、なんかターミナルアプリ作ってみるか、という気分になったので手始めにマインスイーパーを作ってみた。

github.com

Bubble Teaとは

タピオカミルクティーターミナルアプリを構築するための、Elm Architectureに基づいたGo製のフレームワークらしい。

github.com

実装

tea.Modelを実装する構造体を作る

https://github.com/charmbracelet/bubbletea/blob/master/tea.go

type Model interface {
    Init() Cmd
    Update(Msg) (Model, Cmd)
    View() string
}

このinterfaceを実装する構造体を軸に、init()で初期化し、View()でstring型で描画し、操作するたびUpdate(Msg)が呼び出され再度View()が呼ばれるという感じ。

サンプル的に簡単なアプリ作って説明しようと思ったけど、既に素晴らしい記事があったのでそちらを貼り付けさせていただきます。

motemen.hatenablog.com

今回実装したmodelは以下の通り

type model struct {
    column   int       // 列数
    row      int       // 行数
    bombnum  int       // 地雷数
    gameover bool      // ゲームオーバー判定用
    remain   int       // 地雷以外の残りマス数
    num      [9]string // 描画用
    points   [][]point // マス内のデータ
}

各マスごとに保持するデータは以下

type point struct {
    data    int  // 周りの爆弾数(地雷の場合は-1)
    opened  bool // 既にOPENしたか判定
    flagged bool // フラグが立っているか判定
}

初期化

func (m model) Init() tea.Cmd {
    return nil
}

アプリ起動時に実行したいコマンドを定義できるが、今回は利用せず上記のようにした。

別途起動時に行数と列数と爆弾数を入力して、入力値に従ったマスとマス内のデータの初期設定を行う関数を実装して、modelの初期設定を行った。(詳細は割愛)

func InitialModel() (model, error) {
    m := model{}
    // m.columnを初期化する処理
    // m.rowを初期化する処理
    // m.bombnumを初期化する処理
    // m.bombnumの数だけm.points[x][y]にランダムで-1を格納する処理と爆弾が入らないマスに周りの爆弾数を格納する処理
}

View()

string型で描画するので設定した行数×列数のマスと、爆弾数を表示する文字列を構築する実装をした。

func (m model) View() string {
    var s strings.Builder
    s.WriteString(m.viewHeader())
    s.WriteString(top(m.column - 1))

    for i := 0; i < m.row; i++ {
        s.WriteString(cellLeft())
        for j := 0; j < m.column; j++ {
            if m.gameover {
                m.points[i][j].opened = true
            }
            s.WriteString(m.cellMiddle(m.points[i][j].data, m.points[i][j].opened, m.points[i][j].flagged))

        }

        s.WriteString(cellRight())

        if i < m.row-1 {
            s.WriteString(middle(m.column - 1))
        } else {
            s.WriteString(bottom(m.column - 1))
        }
    }

    if m.gameover {
        s.WriteString(gameover())  // 地雷を開いた場合はゲームオーバー表示
    } else if m.remain == 0 {
        s.WriteString(gameclear()) // 未開封マスが地雷のみの場合はゲームクリア表示
    }

    return s.String()
}

行数、列数が10で、地雷数が15の場合の初期表示

Update(Msg)

ここでキーボードかマウス操作があった際の処理を行う。
描画に必要なデータをmodelのメンバに定義して、Update()で操作内容に応じてメンバを更新してやるのが基本的な使い方だと認識している。
ここではマウス操作(クリック or command+クリック)時にどのマス上での操作かを判定して操作内容に応じた処理(マスを開く or フラグを立てる)を実装している。
またマウス操作ではなくキーボードでctrl+cかqを押下された際にアプリケーションを終了する処理を実装している。

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {

    // マウス操作かキーボード操作かで処理を切り分ける
    switch msg := msg.(type) {

    // マウス操作の場合
    case tea.MouseMsg:

        // ゲームオーバー or ゲームクリア判定が出ている場合は操作不可
        if m.gameover || m.remain == 0 {
            return m, nil
        }

        // マウス操作されたマスを判定
        col, row := m.cell(msg.X, msg.Y)
        if col == -1 || row == -1 {
            return m, nil
        }

        //  Alt(Macはcommand) + クリックの場合でまだ開いていないマスであればフラグを立てる(立ってたら降ろす)
        if msg.Alt && msg.Type == tea.MouseLeft && !m.points[row][col].opened {
            if m.points[row][col].flagged {
                m.points[row][col].flagged = !m.points[row][col].flagged
                m.bombnum++
            // 既に立てたフラグ数が爆弾数以上になってなければフラグを立てる
            } else if m.bombnum > 0 {
                m.bombnum--
                m.points[row][col].flagged = !m.points[row][col].flagged
            }
            return m, nil
        }

        if msg.Type != tea.MouseLeft {
            return m, nil
        }
        return m.choose(col, row), nil

    // キーボード操作の場合
    case tea.KeyMsg:

        switch msg.String() {
        // ctrl+cかqの場合はアプリ終了
        case "ctrl+c", "q":
            return m, tea.Quit
        }
    }
    return m, nil
}

アプリケーション起動

tea.NewProgramにmodelとその他オプションを引数に入れて、Start()でアプリを起動できます。
今回はWithAltScreen()というフルウィンドウモードで実行されるオプションとWithMouseCellMotion()というマウス処理を行うオプションを指定します。

func main() {

    m, err := ms.InitialModel()
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }

    p := tea.NewProgram(
        m,
        tea.WithAltScreen(),
        tea.WithMouseCellMotion(),
    )

    err = p.Start()
    if err != nil {
        fmt.Println(err)
    }

}

操作したマスの判定

先程のUpdate()の中で操作しているマスを判定している処理を再掲する。

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {

    switch msg := msg.(type) {
    case tea.MouseMsg:
        if m.gameover || m.remain == 0 {
            return m, nil
        }
        col, row := m.cell(msg.X, msg.Y)

Updateの引数に入ってくるtea.Msgはempty interface。

type Msg interface{}

マウス操作の場合はtea.MouseMsgがキーボード操作の場合はtea.KeyMsgが入ってくるので、まずはその切り分けを行い処理する。
tea,MouseMsgは以下の通り、MouseEvent構造体型になっていて、マウス操作時の情報をメンバにもっている。

type MouseMsg MouseEvent

type MouseEvent struct {
    X    int
    Y    int
    Type MouseEventType
    Alt  bool
    Ctrl bool
}

今回は操作した際の座標情報がX,Yに格納されてくるので、その値を利用してどのマスの操作かを判定する。

func (m model) cell(x, y int) (int, int) {
    col := (x - marginLeft) / cellWidth
    row := (y - marginTop) / cellHeight

    return toDisplayNum(col, m.column), toDisplayNum(row, m.row)
}

XとYはターミナル上の座標のデータが入ってくるので、描画時に整形したマージン分ずらしてやって、マスの長さで割ると操作したマスの特定ができる。

操作がクリックであれば、開封処理を、command+クリックであればフラグを立てるなどの処理行い、地雷がセットされているマスを開封するか、地雷がセットされていないマスを全て開封するまでゲームを続ける。

今後の改善予定箇所

  • タイムアタック
    非同期処理で右上とかに時間を表示させる処理を入れたい

  • キーボードのみで操作
    もともとはキーボードのみでマスを移動して操作するような形で考えていたが、マウス操作がどこまで上手く処理できるのか知りたくてキーボード操作は実装しなかったが、どっちでもできるようにしてみたい

参考

同じようにBubble Teaで作ったチェスアプリを見つけたので(かなり)実装の参考にした。 github.com

Neovim上でのターミナルを水平分割で画面下方に開きたい

vimを使っていてターミナルでコマンド打ちたい時はよくあると思う。
:term(:terminal)を実行することでvim上でターミナルを起動できるのは既知だと思うが、nvimだと画面を分割して開いてくれない。
ターミナルを画面を水平分割して、かつ新たに開いたウィンドウを下に表示させる設定を記載する。

:term実行時に水平分割する

init.vimにcommandでTermというコマンドを再定義しました。
commandの仕様上、既存コマンドと混在させないよう先頭文字は大文字である必要があるため、残念ながら:termを上書くことはできませんでした。

init.vim

command -nargs=* Term split | terminal <args>

commandでコマンドの定義ができます。command!とすることですでに同じ名前で定義されていた場合に上書きしてくれます。

-nargs=はコマンドに渡す引数の数を指定します。

以上の設定により、:Termを実行した場合splitにより画面が水平分割され新しく表示された画面でtermが実行された状態で開かれます。

水平分割した際に新しい画面を画面下方で開く

これまでの設定では画面上方にターミナルが開かれるのですが、自分は下方に開きたいので、その設定を追加します。

init.vim

 set splitbelow

これで:splitを実行した際に新しい画面が下に開いてくれます。

以上。2022年に書くようなネタではなかったですね!

参考

Neovimの`:terminal`でも,分割して開くように - ばかもりだし
Hack #158: ユーザコマンドを定義する
Hack #198: ウィンドウを開く方向を指定する

grpc-gatewayでレスポンスのHTTPステータスコードを指定する

grpc-gatewayを経由して返却されるレスポンスのHTTPステータスコード指定する方法を備忘がてら書く。

ソースはこちら

status.Status

HTTPレスポンスのステータスコードに相当するgrpcのステータスコードこちらで定義されている。 grpc-gatewayではgrpcサーバが返却したステータスコードをHTTPレスポンスのステータスコードに変換してクライアントに返してくれるので、grpcサーバ側に上記のステータスを意識した実装をすれば良い。
具体的には実装したサービスが返却するerrorインターフェースにgrpc/status.Status型が実装しているErr()をセットする。その際、status.Statusに上述したエラーコードを指定してやる。
NotFoundを返却したい場合は以下を返却してやる。

status.New(codes.NotFound, "error message").Err()

このcodes.NotFoundステータスコードに該当するので、grpcのステータスコード定義を参考に返却したいコードを指定してやれば良い。

ちなみにstatus.Newでステータスコードを生成せずに、他のerrorインタフェースを返却すると500が返却される。

以下は今回実装したサンプルの説明と実際に動かす手順を記す。
サンプル実装ではREST APIで指定されたIDが見つからなかった場合に404 NotFoundを返却するというものを想定して実装した。
protocのインストールが事前に必要だがその辺の手順は省く。

protoファイル

google/api/annotations.protoをprotoでimportしてやる必要がある。実態はprotocコマンドのオプションでパスを指定することで認識してくれる(実際のファイルを手元に落としてきてprotoファイル側で格納先パスをimportしても動く)

またRESTのエンドポイントをprotoで記載する必要がある。

今回サンプル的に以下のprotoファイルを作成した。

schema/sample.proto

syntax = "proto3";

package pb;

import "google/api/annotations.proto";

service Sample {
    rpc GetSample (GetSampleRequest) returns (GetSampleResponse) {
        option (google.api.http) = {
            get: "/v1/sample/{id}"
        };
    };
}

message GetSampleRequest {
  int64 id = 1;
}

message GetSampleResponse {
    int64 id = 1;
    string name = 2;
}

grpc/grpc-gatewayのコード生成

protocによる自動生成を実行する。
grpc-gateway用にも生成が必要なので注意。

$ cd schema

// grpcのコード生成
$ protoc -I. -I$GOPATH/src/github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis --go_out=plugins=grpc:../service/pb sample.proto

// grpc-gatewayのコード生成
$ protoc -I. -I$GOPATH/src/github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis --grpc-gateway_out=logtostderr=true:../service/pb sample.proto

サーバ起動

go buildしてgrpcサーバとgrpc gatewayサーバを起動する。

grpcサーバ

$ go build
$ ./sample-grpc-gateway-http-response

grpc gatewayサーバ

$ cd gateway
$ go build
$ ./gateway

それぞれのサーバが起動している状態で/v1/sample/{id}に対してGETしてみる。

 $curl -D - -X GET 'http://localhost:2008/v1/sample/1'
HTTP/1.1 404 Not Found
Content-Type: application/json
Date: Sun, 04 Apr 2021 05:19:42 GMT
Content-Length: 66

{"error":"sample not found","code":5,"message":"sample not found"}

404が返ってきた。

エラー実装

サービス側の実装例として、データを取得できなかった際に404を返却する例を示す。

func (s *SampleService) GetSample(ctx context.Context, params *pb.GetSampleRequest) (*pb.GetSampleResponse, error) {
    data, err := getSampleData(ctx, params.Id)
    if err != nil {
        return nil, err
    }
    if data == nil {
        return nil, status.New(codes.NotFound, "sample not found").Err()
    }
    return &pb.GetSampleResponse{
        Id:   data.Id,
        Name: data.Name,
    }, nil
}

上記のstatus.New(codes.NotFound, "sample not found").Err()status.New()の第一引数でステータスコードを指定するだけであとはgrpc gatewayが対応したHTTPステータスコードを返却してくれる。

参考

gRPC status codeの一覧 - Qiita

2020年振り返り

2020年の抱負に対して

昨年の振り返り記事より

アンパンマンミュージアムとディズニーランドには絶対に娘を連れて行く。できれば国内旅行もしたい。
仕事面では新規事業やサービスの言語リプレイス・インフラリプレイスを控えているので中々濃厚な感じになりそう。
あとそろそろ登壇する機会を増やしたい。

ということで家族サービス的なことはコロナ渦にも関わらずできた(いいのか・・・?)

読書

2020年はは36冊読んだ(昨年比-4冊)。
リモートワーク中心になり通勤時間での読書時間がマルっと無くなったことを考えればまずまずなのかしら。
以下読んだリスト。

技術書

自己啓発・ビジネス・一般書

小説

ノンフィクション・エッセイ

趣味・娯楽

競馬

回収率 的中率 本命馬成績 本命馬
勝率
本命馬
連対率
本命馬
複勝
2017年 63.0% 8.6% [19.18.16.75] 14.8% 28.9% 41.4%
2018年 123.5% 11.5% [17.22.14.78] 13.0% 29.8% 40.5%
2019年 73.4% 3.1% [15.10.9.95] 11.6% 19.4% 26.4%
2020年 78.4% 7.8% [20.15.15.79] 15.5% 27.1% 38.8%

10月末まで三連単本命1着固定で勝負していたが的中率が悪すぎて精神的健康状態が悪化したので、レースごとにちゃんと買い方も含めて検討するように変更。
すると11月〜12月の回収率が112.3%と盛り返したので、来年からレースごとに馬券の種類も検討することにします。。

ウイスキー

計13本飲んだ。以下飲んだ順。

サッカー

ユーベはサッリのサッカーが浸透せず、CLはベスト16敗退。カンピオナートはギリギリ優勝で9連覇達成。
その後サッリ解任でピルロ新監督就任。
内容は徐々に改善傾向に見られるものの今季はCLはおろか、リーグ制覇も逃しそう。

以下19-20個人的ベストイレブン

18-19から総入れ替えになった。
特に右サイドバックはこれといった選手が見当たらず、自分が見てた試合で高パフォーマンスだったダンブロージオにした。
来季はユーベの選手が数名入ってるくらい活躍してもらいたい。

2021年の抱負

ちょっと技術的なアウトプット量をさすがに増やしたいと思っている。
業務は組織づくりや採用などの比重が徐々に大きくなってきているが、時間を見つけてなんとかやれれば。。
プライベートでもネタ見つけながら何かしらやっていきたい。

コロナ渦が2021年どうなっていくのか不安もあるが、家族が健康で一年過ごせますように。
ではよいお年を。

github pagesにreactアプリをデプロイして少しハマった話

自己紹介サイトをgithub pagesで作ろうと思って、ついでにreactアプリでやってみようと思ってやってみた。 いくつかハマったので備忘録として残しておく。
ちなみにサイトはこちら。。

ybalexdp.me

デプロイ

gh-pages

インストール

アプリ実装後、デプロイのためにgh-pagesというgithub pagesにデプロイできるnpmパッケージがあり、こいつをインストールする。

$ npm install gh-pages --save-dev

package.json

その後だいたいの紹介ページでpackage.jsonへデプロイ用に以下のように設定する記述があり、その通りやる。

"scripts": {
  (略)
  "predeploy": "npm run build",
  "deploy": "gh-pages -d build"
},
"homepage": "https://{user name}.github.io/{repository name}",

ちなみに自分はwebpackで組んでいるため、npm run buildで実行されるコマンドを下記のように設定した。

 "scripts": {
   "build": "webpack --mode production",
  (略)
   "predeploy": "npm run build",
   "deploy": "gh-pages -d build"
}

デプロイコマンド実行

(yarn使ってる人)

$ yarn deploy

(npmの人)

$ npm run deploy

すると自分は以下のようなエラーに。。

$ yarn deploy
()
ENOENT: no such file or directory, stat '/Users/ybalexdp/src/github.com/ybalexdp/ybalexdp.github.io/build'
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

なるほど、そもそもgh-pagesコマンドで指定しているbuildってなんだと思ってたけど、ディレクトリがないというエラーですか。
ではbuildディレクトリを作って再度実行と・・

$ yarn deploy
()
The pattern in the "src" property didn't match any files.
error Command failed with exit code 1.
info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.

コケた。。 色々ググってみたが同じとこで困っている感じの記事もあったが、解決しておらず。
git - The pattern in the "src" property didn't match any files" React.js - Stack Overflow

srcがどのファイルにもマッチしないというのがよくわからず、試行錯誤した結果、指定しているディレクトリがindex.htmlが格納されているディレクトリじゃなかろうかと思い立ち、下記に修正。

"scripts": {
  (略)
  "predeploy": "npm run build",
  "deploy": "gh-pages -d ."
},
"homepage": "https://{user name}.github.io/{repository name}",

すると、、通った!

react-router-domによる直接URL指定の404

今回公開したサイトはトップページと/aboutを設けています。この際にreact-router-domを利用してルーティングしているのですが、直接/aboutにアクセスすると404になります。
ローカルはwebpack-dev-serverで組んでいるのでhistoryApiFallbackを設定することで解決できます。
webpack.config.js

devServer: {
  historyApiFallback: true
},

しかし、github pagesにデプロイする際にyarn deploy時にwebpackコマンドでのbuildを指定しており、かつwebpack-dev-serverだとデプロイ後にアクセスできなくなっていたので、こいつは使えませんでした。
色々調べると、こんなものがありました。 github.com

READMEのタイトルがSingle Page Apps for GitHub Pagesですからもうバッチリです。
詳細な使い方はREADMEを読んでいただければと思いますが、自分がやったのは以下二つの手順です。

まず公開されている404.htmlを自身のリポジトリのルートディレクトリに配置。この際自身のトップページのURLの階層に応じてファイル内のsegmentCountの値をいじる必要があります。
自分はルートの階層で公開(ybalexdp.me)しているため0となります。例えばybalexdp.me/topなどであれば1となります。

もう一つは公開されているindex.htmlの以下を自身のindex.htmlのheadタグ直前に配置します。

<!-- Start Single Page Apps for GitHub Pages -->
(略)
<!-- End Single Page Apps for GitHub Pages -->

以上で直接トップページ以外のURLへアクセスした際に想定しているページへリダイレクトしてくれます。

証明証が有効にならない

github pagesは無償でHTTPSサポートしてくれます。手順としては公式を見ていただければと思います。
ただ自分が実施した際に、アクセスすると証明書が有効になるまでラグがあるのかセキュリティリスクの警告ページが出ました。
これも色々調べたら一晩くらい放置すると有効になるという記事が散見されたので放置して見たら普通にアクセスできました ^ ^;

参考

GitHub PagesにReactアプリをデプロイする方法 - Qiita

ReactアプリをGitHub Pages(Project Pages)で公開する - Qiita