aspidaのRestClientで多重送信を抑制する

OpenAPIでAPIが定義されているプロジェクトにおいて @aspida/fetch を使ってRestClientを生成・利用しています。

とあるGETリクエストを複数のコンポーネントからほぼ同時に呼び出す必要があり、リクエストの回数を1回にまとめたい状況がありました。
これをaspidaでやろうと思ったときにちょっと思ったようにできなかったのでメモ。

最初にやろうとした実装は、aspidaに渡すfetchをラップする方法。

const promises = {};
function customFetch(url, option) {
if (option.method === 'GET') {
const key = `GET__${url}`;
if (!promises[key]) {
promises[key] = fetch(url, option);
}
return promises[key];
} else {
return fetch(url, option);
}
}

promiseを再利用したいわけですが、こうすると、ResponseのStreamが使用済みなのでエラーになります。そりゃそうだ。

Response.clone() で複製してから使えばよいわけですが、aspidaの生成するtsコードで Response.json() を実行してしまうので、外からcloneを挟むようなことは無理そう…

色々考えた結果、こうなりました

const promises = {};
function dedupe(api, option) {
const key = `GET__${api.$path(option)}`;
if (!promises[key]) {
promises[key] = api.$get(option);
}
return promises[key];
}

使う側では

const result = await client.path.to.hoge.$get({ query: { fuga: 'piyo' } });

これを、

const result = await dedupe(client.path.to.hoge, { query: { fuga: 'piyo' } });

こうする。

関数で囲わなければいけなくなったのがちょっとスマートじゃないですが、まあ重複排除したいところだけ使えてポータブルな関数になったので良いということにしよう。

CSSのfont-synthesisとtext-renderingが原因でAndroidで太字にならない

Viteで新規にプロジェクトを作るとapp.cssに font-synthesistext-rendering が指定されています。

:root {
  ...
  font-synthesis: none;
  text-rendering: optimizeLegibility;
  ...
}

これらのプロパティがあると、 Android Chrome で見たときにfont-weightで700以上を指定しても太字にならない場合があります。

font-synthesis は、フォントに太字やイタリックの字体が含まれていない場合にブラウザが合成してよいかどうかを制御するもの。

text-rendering は、合字やカーニングの処理を行うか否かを制御し、読みやすさと速度のどちらを優先するか決めることができます。

これら2つの両方もしくはどちらかと、おそらく font-family によるフォント指定の組み合わせによって太字書体が使える環境にも関わらず太字にならないことがあるようです。

基本的に欧文フォントのためのプロパティなので、日本語環境では、少なくとも現段階では使わないほうが無難なようです。

JSで(-8)**(2/3)を計算するとNaNになる

タイトルの通りなのですが、なぜかとある条件のべき乗を計算するとNaNが返ってくるので不思議に思って調べてみました(調べてくれたのは神谷)。

その条件は、基数が負なおかつ指数が分数 であること。

たとえば (-8)**(2/3) という計算は、答えは 4 なのですが、JSで計算をするとNaNが返ります。

(-8)**(2/3) // NaN

一体全体何が起きているのか?
答えは MdNのMath.powのページ に書いてありました。

// due to "even" and "odd" roots laying close to each other,
// and limits in the floating number precision,
// negative bases with fractional exponents always return NaN

最初の一行の意味はわからなかったのですが、要するに浮動小数点の精度の問題で 基数が負なおかつ指数が分数のときは静的にNaNを返すのがJSの仕様 ということみたいです

実際にMath.powで同じことをしても結果はNaNでした。

Math.pow(-8, 2/3) // NaN

試しに、指数の分数を分解して (3√(-8))^2 で計算してみると

Math.pow(Math.cbrt(-8), 2) // 4

正しく4が返ります。


そういえば、正の数の平方根の計算結果って±になりますよね。

√4 の答えは ±2 と学校で習った記憶がある。

Math.sqrt(4) // 2

この辺の計算って結構曖昧なのかもしれません

iOS Safari ではXHRで巨大なHTMLを読み込めない事がある

古き良き、サーバーサイドレンダリングなマルチページで一部分だけ動的に書き換える際に、HTML全体を非同期で取得して必要なDOMだけ差し替えるような実装をすることがある。

たとえば WordPress サイトなんかで、サーバーサイドは変更せずに簡易的な非同期遷移を実装しようと思ったらこういう実装になる。

const xhr = new XMLHttpRequest();
xhr.open('GET', 'http://192.168.x.x/hoge/fuga', true);
xhr.responseType = 'document';
xhr.addEventListener('readystatechange', () => {
    if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
        const html = xhr.response.querySelector('.hogehoge-selector').innerHTML;
        container.innerHTML = html;
    }
});
xhr.send();

注目スべきはここ

xhr.responseType = 'document';

document を指定することで xhr.response がDocumentで返ってくるので、いきなり querySelector なんかを使うことができるのだ。

こんな感じで実装されていたわりと古いコードがあったのだが、iPhone実機でエラーに遭遇した。

TypeError: null is not an object (evaluating 'xhr.response.querySelector')

readystatechange リスナー内の xhr.responsenull だというのだ。
xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200 という条件式の中にあるにも関わらず。

Webインスペクタのネットワークパネルでレスポンス内容を見ると正しくHTMLがレスポンスされているのも確認できる。

しかもこのエラー、毎回出るのではなく、5回に1回程度の割合で出るのでたちが悪い。

試しにDocumentではなく文字列で取得してみる

    if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
        console.log(xhr.responseText);
    }

InvalidStateError: The object is in an invalid state.

xhr.responseText にアクセスすらできない状態で、エラー内容もよくわからない。

エラー文でググってみると、どうやらガーベジコレクションが行われたオブジェクトに対してアクセスしたときに出るエラーのようだ。まだ使ってないのにGCが発火してしまったらしい。

今回この事象に遭遇したのは2000行超あるかなり巨大なHTMLだったので、試しに500行くらいまで減らしてみると、案の定エラーが出ることはなくなって、正常動作が確認できた。

少しずつ行数を増やしていくと、徐々にエラーになる確率が増えていくような感じがあるので、やはりメモリ管理にバグがあるらしい。

手元の環境では、下記で発生することが確認できた

  • iPhone X : iOS 15.0, 15.0.1
  • iPad Pro 11インチ (第1世代) : iPadOS 15.0

Mac Safari や iOS シミュレータでは再現しなかったが、母艦の搭載メモリや起動しているアプリなど、条件によっては発生するんじゃないかとおもう。

行った対処法としては、

xhr.responseType = 'document';

これを使うのをやめて、textで取得し、 DOMParser なりなんなりで自分でDocumentに変換してやる。

xhr.responseType = 'text';
xhr.addEventListener('readystatechange', () => {
    if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
        const parser = new DOMParser();
        const doc = parser.parseFromString(xhr.response, 'text/html');
        console.log(doc);
    }
});

もしくはレガシーコードは窓から投げ捨てて、素直に fetch で書き直してればこんなバグには遭遇しないのだ。

fetch('http://192.168.x.x/hoge/fuga')
    .then(response => response.text())
    .then(text => {
        const parser = new DOMParser();
        const doc = parser.parseFromString(text, 'text/html');
        console.log(doc);
    });

いまどきXHRなんて使ってんのが悪い。

TypeScript+Rollupでビルドが終わらない

RollupでTypeScriptのビルド設定を書いていたら、なぜかrollupのビルドコマンドがいつまで経っても終わらず、ctrl+cせざるをえない状態になり、原因調査に時間がかかったので備忘メモ

症状としてはこんな感じ

ビルド自体は終わっている(標準出力)のに、プロンプトが返ってこない

エラーも何も出ないのでなんの情報もなく、途方に暮れた挙げ句は神に祈りながらMacの再起動までしてみたけどダメでした 🤷‍♂️

どうやらtypescriptの4.4.xにバグがあるみたいで、4.3.xや4.5.0のデイリービルドなら起きないようだ。

すでに PRが作られて おり、4.4.3でリリースされるみたいなのでもう少し待ってみるか

sass-loaderとdart-sassにまつわるfibersの話

tl;dr

用語について

現在npmで sass として公開されているパッケージはdart製のsassとなっています。旧来使われていたnode製のsassは node-sass として公開されています。

ここでは区別を分かりやすくするため、dart製のsassを dart-sass、 node製のsassを node-sass と呼び分けています。

本文

そもそもsass-loaderで何を設定しているのでしょうか。sass-loaderでdart-sassを利用する際はだいたい下記のように設定しているかと思います。

// webpack.config.js
module.exports = {
  // ...
  module: {
    rules: [
      {
        // ...
        use: [
          `// ...
          {
            loader: `sass-loader`,
            options: {
              implementation: require('sass'), // ここで `dart-sass` を読み込んでいる
              sassOptions: {
                fiber: require(`fibers`), // 大体の人がなんとなく一緒に読み込んでいる
              },
            },
          },
        ],
      },
    ],
  },
  // ...
};

まず implementation プロパティについてはsass-loaderのドキュメント に書いてあるとおりですが簡単にまとめると

  • implementation を設定しない場合、 node-sassとdart-sassの両方インストールされている場合、自動的に後者が読み込まれる
  • implementation を設定することで明示することができる

の2点かとおもいます。この「両方がインストールされている場合」というのは、プロジェクト内の package.jsondevDependencies などに追加されている場合以外にも他のパッケージの依存に入っている場合も含まれるので、基本的には明示したほうが良いでしょう。

では、なぜ dart-sass を利用する場合に fiber プロパティにfibersパッケージを指定しているのでしょうか。

node-sassにもdart-sassにも render()renderSync() というAPIがあります。これはどちらもscssからcssへコンパイルするための関数ですが、前者が非同期に実行され、後者は同期的に実行されます。sass-loaderでは render() が利用され非同期的にscssファイルがコンパイルされることになります。

しかし、 dart-sassでは非同期にに実行する render() が非同期実行のオーバーヘッドのため renderSync() より2倍近く遅くなってしまいます。これを避けるために、利用されるのがfibresです。

fibersはもともとnode.jsに非同期に関数を実行する仕組み(Promiseやasnyc/await)がなかった頃に開発されたものです。同期関数を非同期で実行することができる Promise に相当する機能があります。(他にもGeneratorに相当する機能もありますがここでは割愛します。)

速度の早い renderSync() 関数を非同期でするために、このfibersを利用する設定が前述した webpack.config.js です。ただしfibersは作者により使用を避けるべきとされており、node 16への対応もされていません。

まとめ

つまり記事の冒頭でまとめたように、 node 16 を利用するのであればfibersを利用することはできず、コンパイル速度の低下を受け入れざるを得ません。sass-loaderではdart-sassの場合に renderSync() を利用するIssueが立っているのでもしかするとsass-loader側で対応がなされるかもしれません。

nvmとかnodenvとかanyenvをやめてasdfにした

これまでnode環境はnvmで、他はpyenvやgoenvを利用していたのですが、nvmが .node-version ファイルに対応していませんでした。(.nvmrc で指定することはできませす。)
そこでいい加減nodenvに移行したのですが、せっかくなのでどうせなら新しい物をを使おうと思いasdfに移行しました。

anyenvの問題点ははただの「**envをインストールソフト」であり、nodenvやpyenvなどそれぞれの利用法を知っている必要があります。
asdfは実行環境ごとのプラグインを介して直接バージョンを管理するため
どの実行環境でも同様に扱うことができます。
ある日突然「PHPの5.3のバイナリが欲しい…」となっても使い方を迷う必要がありません。

例としてnodejsであれば

asdf install nodejs 16.1.0

Pythonであれば

asdf install python 3.9.5

でインストールが可能です。

また、nodejsであれば lts-fermium のようにタグで指定することも可能です。
自分はグローバルにインストールするものは「一番新しいLTSの最新バージョン」を利用することにしているので、非常に便利です。(nodenvではこれができませんでした。)
また、.asdfrclegacy_version_file = yes を追加することで .node-version なども自動的に読み込み、ディレクトリによって自動的にバージョンを切り替えることもできます。

注意点としては、ビルドする関係上、使用するプラグイン(実行環境)によって依存するライブラリがあるため、そのインストールは別途必要になります。

anyenvより圧倒的に使いやすいので、anyenvやその他のバージョン管理ツールを利用している場合は、asdfへの移行を強くおすすめします。

ランチに行く人を勝手に選出するGoogle App Scriptを作った

弊社では、コロナ禍以前からその日に最もパフォーマンスの出る場所で働くことをルールとして環境を整えているので、あまり事務所に人が集まることはありません。

自宅でも事務所でも、同じように仕事ができることは、これができない方々からすれば良いことのように思うかもしれませんが、各自宅に環境が整いすぎると出社する理由がなくなってしまい、万年リモートのようになってしまう人が一定数現れます。

リモートでは業務上の連絡はもちろん、わからないこと、知りたいこと、聞きたいことはチャットツールでもオンライン会議ツールでも、最適なものを使えば不自由なくコミュニケーションできますが、隣の席の人がどんな仕事をしているのかはわかりませんし、斜め後ろの仲良しの2人の雑談が耳に入ることもありません。

もし自分の仕事と無関係な情報だったとしても、自分と違う環境で生きている人間の話は、新たな興味を生むキッカケになり、仕事の幅や人生の歩みをわずかに変える可能性があります。

ある程度社会人経験を積んだオッサンだけの会社であれば事務所の重要性なんぞほとんどないと思うのですが、新卒を含む若年者にとっては、せっかく組織で働くのであればこういうメリットは大いに教授すべきだと思うわけです。

そんな万年リモート社員を多く抱える弊社でなんとなくアンケート調査を行ったところ、出社がイヤで万年リモートなのではなく、出社する理由がないだけであることがわかりました。

そこで、出社するキッカケづくりをするため、一緒にランチに行く人をGoogleカレンダーにランダムでアサインするGoogle App Scriptを作ってみることに。

要件は

  • このスクリプト専用のGoogleカレンダーを作成し、毎週月曜日にランチの予定を作る
  • 予定を作成するのは可能であればサービスアカウントを使いたい
  • ランダムで選出した社員3名を前述の予定に招待する
  • 毎月10日に来月の予定を自動作成するようトリガーをセットする

以下gs全文です。

こちらの記事を参考にさせて頂きました。

サービスアカウントの作り方なんかも説明するの面倒なので上記記事から辿ってください。

途中サービスアカウントを扱うためのGSAppというライブラリを使っていますが、メンテナンスされておらずライブラリとして読み込むことができなかったので元のソースを別のgsファイルとしてコピペしています。

const MEMBER_NUMBER = 3;
const USERS = [ // 社員のメールアドレス(Googleアカウント)を列挙しとく
	'staff001@nasbi.jp',
	'staff002@nasbi.jp',
	'staff003@nasbi.jp',
	'staff004@nasbi.jp',
	'staff005@nasbi.jp',
];
const SCOPE = [
  'https://www.googleapis.com/auth/calendar',
  'https://www.googleapis.com/auth/calendar.events', // たぶんいらない
  'https://www.googleapis.com/auth/admin.directory.resource.calendar', // ユーザー招待するのに必要?いらないかも
  'https://www.googleapis.com/auth/gmail.send' // 招待メールを飛ばすので必要かと思ったけど動いてない
];
const CALENDAR_ID = 'hogefugapiyo@group.calendar.google.com';

function main() {
  // カレンダーのスコープを指定
  const serverToken = new GSApp(jsonKey.private_key, SCOPE, jsonKey.client_email);

  // トークンの取得
  const tokens = serverToken.addUser(USERS[0]).requestToken().getTokens();

  const events = generateNextMonthEvents();
  events.forEach((event) => {
    insertCalendar(event, CALENDAR_ID, tokens[USERS[0]].token)
  });

  console.log('done');
}

function insertCalendar(eventData, CALENDAR_ID, token) {

  const date = Utilities.formatDate(eventData.date,'JST', 'yyyy-MM-dd');

  const attendees = eventData.members.map((email) => ({ email }))

  const payload = {
    start: { date },
    end: { date },
    summary: 'ランチ行くぞ ٩(ˊᗜˋ*)و',
    attendees,
  };

  // リクエストの設定 
  const fetchOptions = {
    method: 'post',
    payload: JSON.stringify(payload),
    contentType: 'application/json',
    headers: {
      Authorization: `Bearer ${token}`,
    },
    muteHttpExceptions: true,
  };

  //リクエストURLを作成  
  const url = `https://www.googleapis.com/calendar/v3/calendars/${CALENDAR_ID}/events`;

  try {
    const res = UrlFetchApp.fetch(url, fetchOptions);
    Logger.log(res)
  } catch(e) {
    // 例外エラー処理
    Logger.log('Error:')
    Logger.log(e)
  }
}

function generateNextMonthEvents() {
  const events = [];

  const today = new Date();
  const firstDay = new Date();
  firstDay.setMonth(today.getMonth() + 1);
  firstDay.setDate(1);

  const thisMonth = firstDay.getMonth();
  const tmpDate = dateCopy(firstDay);
  while (tmpDate.getMonth() === thisMonth) {
    if (tmpDate.getDay() === 1) { // 月曜日は1
      events.push({
        date: dateCopy(tmpDate),
      });
    }
    tmpDate.setDate(tmpDate.getDate() + 1);
  }

  let tmpUsers = [];
  events.forEach((event) => {
    event.members = [];
    for (let i = 0; i < MEMBER_NUMBER; i++) {
      if (tmpUsers.length === 0) {
        tmpUsers = arrayShuffle([...USERS]);
      }
      while (event.members.includes(tmpUsers[0])) {
        tmpUsers = arrayShuffle(tmpUsers)
      }
      event.members.push(tmpUsers.shift());
    }
  })

  console.log(events);
  return events;
}

/**
 * 引数のDateを複製する
 * @param {Date} date
 * @return {Date}
 */
function dateCopy(date) {
	return new Date(date.getTime());
}

/**
 * 配列をシャッフルする
 * @param {Array} origin
 * @return {Array}
 */
function arrayShuffle(origin) {
	const array = [...origin];
	for (let i = array.length - 1; i > 0; i--) {
		const r = Math.floor(Math.random() * (i + 1));
		const tmp = array[i];
		array[i] = array[r];
		array[r] = tmp;
	}
	return array;
}

同じ人が毎週招待されたり、招待されない人がいないよう、単純なランダム選出ではなく満遍なく招待が届くようにしてみた。

GASの発火トリガーはこんな感じ


作ってみて困ったのは、2つ。

どうせならアカウントに依存しないようサービスアカウントを予定の作成者としたかったのだけど、他のアカウントを予定に招待する場合は実物のGoogleアカウントを偽装して予定の作成をする必要があったこと。

どうやらスパム対策でこのような仕様になったみたい。

偽装する必要があるなら最初からサービスアカウントなんて使う必要なかったなあと思ったり。

もうひとつは招待メールが飛ばないこと。

これは原因がわからなかったのだけど、もしかしたらこれもスパム対策なのかもしれない。

ともあれ最低限の要件は満たせたと思うので、来月から運用を開始します。

もちろんこれはあくまでキッカケづくりであり義務ではないので、気分じゃなかったら参加しなくてもいいし、ふたり以上集まればランチ代は領収書を切ってくれて構わないです

義務や個人の負担はチリツモでモチベーション低下に繋がりますからね。

@nuxtjs/apolloでレスポンスが空だったら404を出す

Vue ApolloがNuxtで利用可能になる @nuxtjs/apollo を使うときレスポンスが空だったら404を出したい場合があります。

下記のようにSmart Queryを使うと簡単にサーバーサイドとクライアントサイドでfetchする機能を書くことができます。

// pages/blog/_id.vue
import GetPosts from '~/apollo/GetPost.gql';

export default {
  name: 'BlogIndex',
  apollo: {
    posts: {
      query: GetPost,
      variables () {
        return {
          pageId: 123,
        };
      },
    },
  },
}

しかしSmartQueryはレスポンスのHTTPステータスコードを設定できないという問題があります。
その場合は素直に asyncData() を利用しましょう

// pages/blog/_id.vue
import GetPost from '~/apollo/GetPost.gql';

export default {
  name: 'BlogPost',
  async asyncData ({ error, app, params }) {
    const response = await app.apolloProvider.defaultClient.query({
      query: GetPost,
      variables: { pageId: params.id },
    });
    if (!response.data.post) {
      error({ statusCode: 404, message: 'Not Found' });
    }
    return { post: response.data.post };
  },
  data () {
    return {
      post: {},
    };
  },
};

ただそうなると @nuxtjs/apollo を使う意義も薄くなってしまいますね。
vue-apolloの開発もあまり活発でないので何か別の手段を使ったほうがいいんでしょうか…。

CSSでフォントサイズの変数名にSI接頭辞を使ってみたらどうだろう

これは今に始まったことじゃないですが、JSにしろCSSにしろ、変数名ってめちゃくちゃ悩むんですよね。

今日はCSSのフォントサイズを変数化する際にSI接頭辞を使ってみたらどうだろうか、という提案をさせていただきたい。

フォントサイズの値の特徴として、下記のようなものがあると思います

  • 数値型
  • 基準となる値があり、それより小さいものと大きいものに分けられる
  • 値そのものより、他の値より大きいのか小さいのかが重要(イメージできるので)
  • せいぜい5〜8種類程度になるはず

要するに 数値型で相対的な大きさがなんとなく想像できるような変数名 だと良い変数だと思うわけです。

そこでこちらをご覧ください

$font-size-micro: 12px;
$font-size-milli: 14px;
$font-size-base: 16px;
$font-size-kilo: 18px;
$font-size-mega: 24px;
$font-size-giga: 32px;

基準である16pxをベースに、それより小さいものをミリやマイクロ、大きいものをキロ・メガ・ギガとすることで、相対的な大きさを想像することができる変数名とすることができたと思います

試しにこの変数名を、あえて分かりにくいセレクタで使ってみるとこんな感じになります。

.headline {
  font-size: $font-size-kilo;
}
.date {
  font-size: $font-size-milli;
}
.value {
  font-size: $font-size-giga;
}

フォントサイズの大小が明確になるだけで、随分と要素の重要度がCSSを見ただけで伝わるのではないかと思うわけです。

しかも、この程度のSI接頭辞ならエンジニアではなくても、なんなら小学生でも直感的にわかるという嬉しさ。

中間サイズの追加に弱いという欠点はあるものの、デザインシステムがカチッとしている状況であれば、十分使えるのではないでしょうか。


ちなみに、これを考えているときに思いついた別案ですが、むしろ日本語で曖昧な変数名作っちゃえばもっとわかりやすくて汎用的になるのではと思ったりしました

$font-size-結構小さい: 12px;
$font-size-ちょっと小さい: 14px;
$font-size-基準: 16px;
$font-size-ちょっとデカい: 18px;
$font-size-まあまあデカい: 24px;
$font-size-相当なデカさ: 32px;
$font-size-アホほどデカい: 64px;

これなら中間サイズが追加されても表現次第でどうにでもなるので最強です。優れたエディタなら補完もしてくれるので表記ゆれなんて気にしなくて良い。日本語すごい!