かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

JavaScript OS の判定をしたい

ショートカットの案内を表示するのに Mac, iPhone, iPad なら それ以外は Ctrl を表示したい。という要望でクラアンと側で OS の判定をしたメモ

navigator.userAgent / navigator.platform を使う

MDN のドキュメントには navigator.userAgent, navigator.platform 共に信頼性が低いと書かれてるけど、クライアント側で判定しようとしたら現実的にこれらで判別するしかなさそう。

NavigatorID.userAgent
ユーザーエージェント文字列の検出に基づくブラウザーの識別は信頼性が低く、ユーザーエージェント文字列はユーザーが設定可能なので推奨されません。
cf. NavigatorID.userAgent - Web API | MDN

Mac OS 10.15.7 googleChrome での場合はこんな感じ

const getUserAgent = () => {
  return (navigator && navigator.userAgent) || false;
};

console.log(getUserAgent());
// Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)
// AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36

NavigatorID.platform
ブラウザーのプラットフォームを表す文字列を返します。仕様書ではブラウザーが常に空文字列を返すことを許可していますので、信頼できる答えを得るためにこのプロパティを頼らないようにしてください。
cf. NavigatorID.platform - Web API | MDN

navigator.platform は OS の情報だけ

const getPlatform = () => {
  return (navigator && navigator.platform) || false;
};

console.log(getPlatform());
// MacIntel

OS を判定する

navigator.userAgentnavigator.platform も文字列なので、そこに特定の文字が含まれるかで判定すればいいので、indexOfString.includes を使えばOK
String.includesIE が未対応で polyfill が必要になりそうなので対応ブラウザに IE が含まれるなら indexOf を使うのが良いかも)

navigator.userAgentnavigator.platform とで微妙に含まれる文字が異なるっぽいのでどちらを使うかで別々の判定リストを作成するのが良さそう

navigator.userAgent を使って判定

const OS_LIST = {
  'Windows': 'windows nt',
  'MacOS': 'mac os',
  'iPhone': 'iphone',
  'ipad': 'iPad',
  'Android': 'android',
  'Linux': 'linux',
};

const getUserAgent = () => {
  return (navigator && navigator.userAgent) || false;
};

indexOf を使って判定

const checkIncludeText = (target) => (obj) => {
  let result = false;
  Object.entries(obj).some(([key, value]) => {
    if ( target.indexOf(value) !== -1 ) {
      result = key;
      return true;
    }
    return false;
  });

  return result;
};

const checkOS = () => {
  const ua = getUserAgent();
  let os = 'unknown';
  if (!ua) return os;
  
  return checkIncludeText(OS_LIST)(ua.toLowerCase());
};

checkOS();
// => MacOS

includes を使って判定

オブジェクトを回して判定するメソッドの中身を変えればOK

const checkIncludeText = (target) => (obj) => {
  let result = false;
  Object.entries(obj).some(([key, value]) => {
    if ( target.includes(value) ) {
      result = key;
      return true;
    }
    return false;
  });

  return result;
};

const checkOS = () => {
  const ua = getUserAgent();
  let os = 'unknown';
  if (!ua) return os;
  
  return checkIncludeText(OS_LIST)(ua.toLowerCase());
};

checkOS();
// => MacOS

navigator.platform を使って判定

navigator.platformuserAgent に含まれる文字と微妙に異なるっぽいので、こちらを使って判定する場合は別のリストを作成しておけばOK

const OS_LIST = {
  'MacOS': 'mac',
  'iPhone': 'iphone',
  'ipad': 'iPad',
  'Windows': 'windows',
  'Android': 'android',
  'Linux': 'linux',
};

const getPlatform = () => {
  return (navigator && navigator.platform) || false;
};

indexOf を使って判定

const checkIncludeText = (target) => (obj) => {
  let result = false;
  Object.entries(obj).some(([key, value]) => {
    if ( target.indexOf(value) !== -1 ) {
      result = key;
      return true;
    }
    return false;
  });

  return result;
};

const checkOS = () => {
  const platform = getPlatform();
  let os = 'unknown';
  if (!platform) return os;
  
  return checkIncludeText(OS_LIST)(platform.toLowerCase());
};

checkOS();
// => MacOS

includes を使って判定

const checkIncludeText = (target) => (obj) => {
  let result = false;
  Object.entries(obj).some(([key, value]) => {
    if ( target.includes(value) ) {
      result = key;
      return true;
    }
    return false;
  });

  return result;
};

const checkOS = () => {
  const platform = getPlatform();
  let os = 'unknown';
  if (!platform) return os;
  
  return checkIncludeText(OS_LIST)(platform.toLowerCase());
};

checkOS();
// => MacOS

複数での判定は test を使うのが良さそう

indexOf, includes は単一のテキストが含まれているかでしか判定できないので、Mac || iPhone || iPad のような複数にマッチさせたい場合は正規表現を使える test() を使うのが良さそうです。

e.g. Mac, iPhone, iPad かどうか判定したい

const IS_MAC_OR_IOS_REGEXP = /mac|iphone|ipad/;

const getUserAgent = () => {
  return (navigator && navigator.userAgent) || false;
};

const getPlatform = () => {
  return (navigator && navigator.platform) || false;
};

// userAgent で判定
IS_MAC_OR_IOS_REGEXP.test(getUserAgent());
// => true or false

// platform で判定
IS_MAC_OR_IOS_REGEXP.test(getPlatform());
// => true or false

const isMac = () => {
  return IS_MAC_OR_IOS_REGEXP.test(getPlatform());
}

const save = `${isMac() ? '⌘' : 'Ctrl'} + s`;

example

See the Pen Check OS by userAgent & platform by KIKIKI (@kikiki_kiki) on CodePen.

感想

navigator.userAgentnavigator.platform も変更が可能なので完璧な判定ができるわけではないですが、変更してる人は自分の意志なことが多いだろうしフロントで判別するならこんな感じでいいんじゃないかな〜ってイメージです。ブラウザは無視して OS だけ判定するなら navigator.platform の方がシンプルな文字列が返ってくるので扱いやすそうな印象を持ちました。
単一の判定なら indexOf()includes() を使って複数での判定なら test() を使うのが良さそうです。

まぁ自前で実装せずに判定できる信頼性のあるライブラリ入れちゃうのが簡単で良いと思いますが。(なんでこの記事書いたんでしょうねw)

おわり。


[参考]

webpack v5 npx webpack-dev-server で dev server が動かないにハマる

久しぶりに webpack 環境を作っていて web-pack-dev-server を動かそうとしてハマったのでメモ

package.json

"devDependencies": {
  "webpack": "^5.6.0",
  "webpack-cli": "^4.2.0",
  "webpack-dev-server": "^3.11.0"
},

Error: Cannot find module 'webpack-cli/bin/config-yargs'

npm のドキュメントにある通り webpack-dev-server を起動しようとしたらエラーになった

$ npx webpack-dev-server
Cannot find module 'webpack-cli/bin/config-yargs'

どうやら webpack-cli/bin/config-yargscli の v3 系で削除されているらしい

webpack v5, webpack-cli v4 系では webpack serve コマンドを使う

webpack のドキュメントに次のように書かれていました。

"scripts": {
    "start": "webpack serve --open",
   },
cf. [https://webpack.js.org/guides/development/#using-webpack-dev-server:title]

コマンドが変わっていたみたいです。

$ npx webpack serve
ℹ : Project is running at http://localhost:8080/

起動しました 👌

--open オプションでエラー

$ npx webpack serve --open
error: option '--open <value>' argument missing

webpack のドキュメントにある通り自動でブラウザを開く --open オプションを付けると option '--open <value>' argument missing 引数がないというエラーになってしまいました。

どのブラウザで開くのかを --open の引数に渡してあげる必要がある

$ npx webpack serve --open 'google chrome'
ℹ : Project is running at http://localhost:8080/

chrome でタブが開いて dev server が起動しました!
引数があれば何でも良いみたいで npx webpack serve --open foo みたいにしてもブラウザが開かないだけで問題なくどうさしました。(良いのか…

webpack.config.js にオプション指定すれば --open の引数は不要

webpack.config.jsdevServer.open オプションを使えば何で開くかの引数は不要でデフォルトブラウザで起動できるようでした。

webpack.config.js

module.exports = {
  // ...
  devServer: {
    contentBase: outputPath,
    open: true,
  },
}

package.json

"scripts": {
  "start:dev": "webpack serve",
  // …
},

👇 実行

$ npm run start:dev
ℹ : Project is running at http://localhost:8080/

デフォルトブラウザでタブが開いて dev server が起動されました!
₍ ᐢ. ̫ .ᐢ ₎ 👌

設定ファイルを作って npm script から実行させるのが良さそうです。
おわり


[参考]

絵柄がめっちゃ好みだった〜

Express な API でエラーハンドリングするよ

TypeScript な Express でAPI作ってエラーを返す処理を都度書いているとめんどいので、エラーハンドリングして返すようにしたメモ

エラーハンドリングのミドルウェアはルーティングより後に書く

上から順番にルーティングにマッチしていくか見ていって、該当するルーティングが無ければエラーハンドリングするミドルウェアに処理が流れてくるイメージ

import express, { HttpException, Request, Response, NextFunction } from 'express';
import apiRouter from './api/controller'
const app = express();
const router = express.Router();

// Routing
app.get('/api/user', async (req: Request, res: Response, next: NextFunction): Promise<data> => {
  try {
    const data = await something(….);
    return data;
  } catch (err) {
    return next(err);
  }
});

// error handling
export default function errorHandler(
  err: HttpException,
  req: Request,
  res: Response,
): void {
  res.status(500).send('error message');
}

ルーティング内で next(new Error()) されるとエラーハンドリングに処理が流れる

エラーのステータスを変えたりエラー時にデータを返したい

Nest.js みたいにエラーのステータスごとの関数を使ってエラーが返せるようになると楽そう。
一緒にエラー時にもデータを返したい!

HttpException クラスを拡張してエラーを返す関数を作成すれば OK

// errorException.ts 
import { Request, Response } from 'express';

class HttpException extends Error {
  statusCode?: number;
  message: string;
  constructor(statusCode: number, message: string) {
    super(message);
    this.statusCode = statusCode || 500;
    this.message = message;
  }
}

export const badRequestException =
  (message = '400 Bad Request'): HttpException => {
    return new HttpException(400, message);
};

export const forbiddenException =
  (message = '403 Forbidden'): HttpException => {
    return new HttpException(403, message);
};

// Error Handler Middleware
export default function errorHandler(
  err: HttpException,
  req: Request,
  res: Response,
): void {
  res.status(err.statusCode || 500).send(err.message);
}
// app.ts
import express, { HttpException, Request, Response, NextFunction } from 'express';
import apiRouter from './api/controller'
import errorHandler, { badRequestException } fromn './errorException';
const app = express();
const router = express.Router();

// Routing
app.get('/api/user', async (req: Request, res: Response, next: NextFunction): Promise<data> => {
  try {
    const data = await something(….);
    return data;
  } catch (err) {
    return next( badRequestException('ジョニー別府!') );
  }
});

app.use(errorHandler);

ブラウザで直接アクセスするとエラーメッセージが表示される。
axios でこの API を叩いている場合、エラーメッセージは error.response.data に格納されるので API で使用する場合は次のようにエラーメッセージを取得する

try {
  // ...
} catch (err) {
  const errorMessage = err?.response?.data || err.message;
  // => "ジョニー別府!"
}

これでデフォルトのエラーオブジェクトの代わりにステータスコードを変更してカスタムメッセージを付けたエラーハンドリングができるようになしました!

エラーに独自のオブジェクトを追加して返す

API として使う場合、メッセージ以外にも情報を返したいケースがあります。
その場合はフロントで使いたい情報のオブジェクトを返すようにすればOK

// errorException.ts 
import { Request, Response } from 'express';

type errorData = {
  [key: string]: any;
};

class HttpException extends Error {
  statusCode?: number;
  message: string;
  data: errorData;
  constructor(statusCode: number, message: string, data?: errorData) {
    super(message);
    this.statusCode = statusCode || 500;
    this.message = message;
    this.data = data ? { ...data } : {};
  }
}

export const badRequestException =
  (message = '400 Bad Request', data?: errorData,): HttpException => {
    return new HttpException(400, message, data);
};

export const forbiddenException =
  (message = '403 Forbidden', data?: errorData,): HttpException => {
    return new HttpException(403, message, data);
};

// Error Handler Middleware

export default function errorHandler(
  err: HttpException,
  req: Request,
  res: Response,
): void {
  // 直接 メッセージだけを入れられないので error {} 内にメッセージ・データを格納する
  res.status(err.statusCode || 500).send({
    message: err.message,
    error: {
      ...err.data,
    },
  });
}
// Routing
app.get('/api/user', async (req: Request, res: Response, next: NextFunction): Promise<data> => {
  try {
    const data = await something(….);
    return data;
  } catch (err) {
    return next( badRequestException('ジョニー別府!', {
      type: "Not idol, Great teacher!"
    }) );
  }
});

フロント

try {
  // ...
} catch (err) {
  const { message, error } = err?.response?.data || err;
  // => "ジョニー別府!", { type:  "Not idol, Great teacher!" }
}

err.response.data が無い時は通常のエラーオブジェクトが使われ、 errorundefined になる。

これで Express を API にしていい感じにエラーハンドリングができるようになりました!
₍ ᐢ. ̫ .ᐢ ₎ ヤッタネ
Nest.js 使えるようになれよ…って話でもあるケド


[参考]

自転車えくすぷれす

自転車えくすぷれす

  • 発売日: 2011/10/12
  • メディア: MP3 ダウンロード
さよならポニーテールを聞こう!